Newer
Older
gnexus-creds / frontend / src / App.vue
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import {
  GnActionList,
  GnBadge,
  GnButton,
  GnCheckbox,
  GnDescriptionList,
  GnDropdown,
  GnEmptyState,
  GnFileUpload,
  GnIconButton,
  GnIdentity,
  GnInput,
  GnMetricCard,
  GnNavigationShell,
  GnPagination,
  GnSearchField,
  GnSkeleton,
  GnTable,
  GnTextarea,
  GnTimeline,
  GnToastProvider,
  GnToolbar
} from "gnexus-ui-kit/vue";

import { api } from "./api";
import GnModal from "./components/GnModal.vue";
import SecretDetailPanel from "./components/SecretDetailPanel.vue";

const baseTabs = [
  { id: "secrets", label: "Secrets" },
  { id: "history", label: "History" },
  { id: "audit", label: "Audit" },
  { id: "tokens", label: "Tokens" },
  { id: "settings", label: "Settings" }
];
const tabRoutes = {
  secrets: "/secrets",
  history: "/history",
  audit: "/audit",
  tokens: "/tokens",
  settings: "/settings",
  admin: "/admin"
};
const routeTabs = Object.fromEntries(Object.entries(tabRoutes).map(([tab, path]) => [path, tab]));

const activeTab = ref("secrets");
let suppressNextRouteSync = false;
const me = ref(null);
const loading = ref(false);
const error = ref("");
const toastProvider = ref(null);
const copiedFieldKey = ref("");
const secrets = ref([]);
const total = ref(0);
const query = ref("");
const secretsPage = ref(1);
const secretsLimit = 20;
const secretsSort = ref({ by: "updated_at", dir: "desc" });
const selected = ref(null);
const secretsView = ref("list");
const revealed = ref(null);
const revealedVisibleFields = ref(new Set());
const versions = ref([]);
const revealedVersion = ref(null);
const auditEvents = ref([]);
const auditTotal = ref(0);
const auditLoading = ref(false);
const auditMode = ref("all");
const tokens = ref([]);
const tokensLoading = ref(false);
const newToken = ref(null);
const showCreateModal = ref(false);
const showCreateTokenModal = ref(false);
const showProfileModal = ref(false);
const showDeleteConfirm = ref(false);
const editing = ref(false);
const editFields = ref(false);
const pendingDeleteSecret = ref(null);
const importFiles = ref([]);
const importFile = ref(null);
const importPreview = ref(null);
const importError = ref("");
const adminUsers = ref([]);
const adminUsersTotal = ref(0);
const adminUsersPage = ref(1);
const adminUsersLimit = 20;
const auditPage = ref(1);
const auditLimit = 20;
const stats = reactive({
  total_secrets: 0,
  active_secrets: 0,
  mcp_enabled_secrets: 0
});
const diagnostics = reactive({
  health: "unknown",
  ready: "unknown"
});
const backups = ref([]);
const backupsLoading = ref(false);
const showRestoreConfirm = ref(false);
const pendingRestoreFile = ref(null);

const form = reactive({
  title: "",
  purpose: "",
  category: "",
  source: "",
  notes: "",
  tags: "",
  allow_ui: true,
  allow_rest_api: true,
  allow_mcp: false
});

const formFields = ref(defaultCreateFields());

const editForm = reactive({
  title: "",
  purpose: "",
  category: "",
  source: "",
  notes: "",
  tags: "",
  status: "actual",
  archived: false,
  allow_ui: true,
  allow_rest_api: true,
  allow_mcp: false,
  fieldsText: ""
});

const tokenForm = reactive({
  name: "MCP client",
  read: true,
  reveal: true,
  write: true,
  admin: false,
  mcp: true
});

const profileForm = reactive({
  display_name: "",
  locale: "en"
});

const navItems = computed(() => {
  const items = [
    { id: "secrets", label: "Secrets", icon: "ph-vault" },
    { id: "history", label: "History", icon: "ph-clock-counter-clockwise" },
    { id: "audit", label: "Audit", icon: "ph-list-checks" },
    { id: "tokens", label: "Tokens", icon: "ph-key" },
    { id: "settings", label: "Settings", icon: "ph-gear" }
  ];
  if (me.value?.role === "admin") {
    items.push({ id: "admin", label: "Admin", icon: "ph-user-gear" });
  }
  return items.map((item) => ({ ...item, active: activeTab.value === item.id }));
});

const currentNavLabel = computed(() => {
  const item = navItems.value.find((i) => i.id === activeTab.value);
  return item?.label || "Secrets";
});

const pageTitle = computed(() => {
  const label = currentNavLabel.value;
  const secret = selected.value?.title;
  if (activeTab.value === "secrets" && secret) {
    return `${secret} · ${label} · gnexus-creds`;
  }
  if ((activeTab.value === "history" || activeTab.value === "audit") && secret) {
    return `${label} · ${secret} · gnexus-creds`;
  }
  return `${label} · gnexus-creds`;
});

const userInitials = computed(() => {
  const name = me.value?.display_name || me.value?.email || "";
  return name
    .split(/[\s._-]+/)
    .filter(Boolean)
    .slice(0, 2)
    .map((word) => word[0].toUpperCase())
    .join("");
});

function onNavSelect(item) {
  if (item.id === "audit") {
    auditMode.value = "all";
  }
  activeTab.value = item.id;
}

function onPanelNavigate(target) {
  if (target === "history") {
    activeTab.value = "history";
  } else if (target === "audit") {
    auditMode.value = "secret";
    activeTab.value = "audit";
    if (selected.value) {
      suppressNextRouteSync = true;
      setRouteForAudit(selected.value.id);
    }
  } else if (target === "edit-fields") {
    toggleEditFields();
  }
}

async function logout() {
  try {
    await api.logout();
  } catch {
    // ignore
  }
  window.location.href = "/auth/login";
}
const canCreateSecret = computed(() => Boolean(form.title.trim() && parseCreateFields().length));
const sortItems = computed(() => [
  { label: "Updated (newest)", value: { by: "updated_at", dir: "desc" } },
  { label: "Updated (oldest)", value: { by: "updated_at", dir: "asc" } },
  { label: "Title (A–Z)", value: { by: "title", dir: "asc" } },
  { label: "Title (Z–A)", value: { by: "title", dir: "desc" } },
  { label: "Status", value: { by: "status", dir: "asc" } }
]);
const secretRows = computed(() =>
  secrets.value.map((secret) => ({
    id: secret.id,
    title: secret.title,
    purpose: secret.purpose || "Not set",
    category: secret.category || "uncategorized",
    status: secret.status,
    raw: secret
  }))
);
const secretColumns = [
  { key: "title", label: "Title" },
  { key: "purpose", label: "Purpose" },
  { key: "category", label: "Category" },
  { key: "status", label: "Status" },
  { key: "actions", label: "" }
];
const versionItems = computed(() =>
  versions.value.map((version) => ({
    key: String(version.id),
    title: `Version ${version.version_number}`,
    time: version.created_at,
    text: `${version.fields.length} fields`,
    icon: "ph-clock-counter-clockwise",
    raw: version
  }))
);
const auditRows = computed(() =>
  auditEvents.value.map((event) => ({
    id: event.id,
    time: event.created_at,
    action: event.action,
    channel: event.channel,
    ip: event.ip_address || "—",
    metadataItems: formatAuditMetadata(event.metadata),
    raw: event
  }))
);
const tokenItems = computed(() =>
  tokens.value
    .filter((token) => !token.revoked_at)
    .map((token) => ({
      title: token.name,
      subtitle: token.scopes.join(", "),
      raw: token
    }))
);
const diagnosticsItems = computed(() => [
  { label: "/health", key: "health", value: diagnostics.health },
  { label: "/ready", key: "ready", value: diagnostics.ready }
]);
const adminColumns = [
  { key: "user", label: "User" },
  { key: "status", label: "Status" },
  { key: "role", label: "Role" },
  { key: "last_seen_at", label: "Last seen" }
];
const auditColumns = [
  { key: "time", label: "Time" },
  { key: "action", label: "Action" },
  { key: "channel", label: "Channel" },
  { key: "ip", label: "IP" },
  { key: "metadata", label: "Metadata" }
];

function formatAuditMetadata(meta) {
  if (!meta || typeof meta !== "object") return [];

  function formatValue(value) {
    if (value === null || value === undefined) return "";
    if (typeof value === "object" && "old" in value && "new" in value) {
      const oldVal = JSON.stringify(value.old);
      const newVal = JSON.stringify(value.new);
      return `${oldVal} → ${newVal}`;
    }
    if (typeof value === "object") return JSON.stringify(value);
    return String(value);
  }

  const result = [];
  for (const [label, value] of Object.entries(meta)) {
    if (label === "diff" && value && typeof value === "object") {
      for (const [field, change] of Object.entries(value)) {
        result.push({ label: field, value: formatValue(change) });
      }
    } else {
      result.push({ label, value: formatValue(value) });
    }
  }
  return result;
}

function parseFieldsText(value) {
  return value
    .split("\n")
    .map((line, index) => {
      const [rawName, ...rest] = line.split("=");
      const name = rawName.trim();
      if (!name) return null;
      const rawValue = rest.join("=");
      const encrypted = rawValue.startsWith("*");
      return {
        name,
        value: encrypted ? rawValue.slice(1) : rawValue,
        encrypted,
        masked: encrypted,
        position: index
      };
    })
    .filter(Boolean);
}

function defaultCreateFields() {
  return [
    { id: crypto.randomUUID(), name: "username", value: "", encrypted: false },
    { id: crypto.randomUUID(), name: "password", value: "", encrypted: true }
  ];
}

function openCreateModal() {
  showCreateModal.value = true;
}

function addCreateField() {
  formFields.value.push({
    id: crypto.randomUUID(),
    name: "",
    value: "",
    encrypted: true
  });
}

function removeCreateField(index) {
  if (formFields.value.length === 1) return;
  formFields.value.splice(index, 1);
}

function parseCreateFields() {
  return formFields.value
    .map((field, index) => {
      const name = field.name.trim();
      if (!name) return null;
      return {
        name,
        value: field.value,
        encrypted: Boolean(field.encrypted),
        masked: Boolean(field.encrypted),
        position: index
      };
    })
    .filter(Boolean);
}

function resetCreateForm() {
  form.title = "";
  form.purpose = "";
  form.category = "";
  form.source = "";
  form.notes = "";
  form.tags = "";
  form.allow_ui = true;
  form.allow_rest_api = true;
  form.allow_mcp = false;
  formFields.value = defaultCreateFields();
}

function canUseTab(tab) {
  if (tab === "admin") {
    return me.value?.role === "admin";
  }
  return baseTabs.some((item) => item.id === tab);
}

function routeToTab(pathname = window.location.pathname) {
  if (pathname.startsWith("/secrets/")) {
    return "secrets";
  }
  if (pathname.startsWith("/audit/")) {
    return "audit";
  }
  return routeTabs[pathname] || (pathname === "/" ? "secrets" : null);
}

function routeSecretId(pathname = window.location.pathname) {
  const match = pathname.match(/^\/secrets\/([^/]+)$/);
  return match ? decodeURIComponent(match[1]) : null;
}

function routeAuditSecretId(pathname = window.location.pathname) {
  const match = pathname.match(/^\/audit\/([^/]+)$/);
  return match ? decodeURIComponent(match[1]) : null;
}

function setRoute(path, replace = false, state = {}) {
  if (window.location.pathname === path) return;
  const method = replace ? "replaceState" : "pushState";
  window.history[method](state, "", path);
}

function setRouteForTab(tab, replace = false) {
  const path = tabRoutes[tab] || tabRoutes.secrets;
  setRoute(path, replace, { tab });
}

function setRouteForSecret(secretId, replace = false) {
  setRoute(`/secrets/${encodeURIComponent(secretId)}`, replace, { tab: "secrets", secretId });
}

function setRouteForAudit(secretId, replace = false) {
  const path = secretId ? `/audit/${encodeURIComponent(secretId)}` : "/audit";
  setRoute(path, replace, { tab: "audit", secretId });
}

async function syncTabFromRoute(replace = false) {
  const routedTab = routeToTab();
  const nextTab = routedTab && canUseTab(routedTab) ? routedTab : "secrets";
  if (activeTab.value !== nextTab) {
    suppressNextRouteSync = true;
    activeTab.value = nextTab;
  }
  if (!routedTab || routedTab !== nextTab || window.location.pathname === "/") {
    setRouteForTab(nextTab, replace);
  }
  if (nextTab === "secrets") {
    const secretId = routeSecretId();
    if (secretId) {
      try {
        await openSecretById(secretId, replace);
      } catch {
        selected.value = null;
        secretsView.value = "list";
        setRouteForTab("secrets", true);
      }
    } else {
      secretsView.value = "list";
    }
  }
  if (nextTab === "audit") {
    const auditSecretId = routeAuditSecretId();
    if (auditSecretId) {
      try {
        const cached = secrets.value.find((s) => s.id === auditSecretId);
        selected.value = cached || (await api.getSecret(auditSecretId));
        auditMode.value = "secret";
      } catch {
        auditMode.value = "all";
        setRouteForAudit(null, true);
      }
    } else {
      auditMode.value = "all";
    }
  }
}

function onPopState() {
  void syncTabFromRoute(true);
}

function serializeFields(fields) {
  return fields
    .map((field) => `${field.name}=${field.encrypted ? "*" : ""}${field.value || ""}`)
    .join("\n");
}

function fillEditForm(secret) {
  if (!secret) return;
  editForm.title = secret.title || "";
  editForm.purpose = secret.purpose || "";
  editForm.category = secret.category || "";
  editForm.source = secret.source || "";
  editForm.notes = secret.notes || "";
  editForm.tags = secret.tags?.join(", ") || "";
  editForm.status = secret.status || "actual";
  editForm.archived = Boolean(secret.archived);
  editForm.allow_ui = Boolean(secret.allow_ui);
  editForm.allow_rest_api = Boolean(secret.allow_rest_api);
  editForm.allow_mcp = Boolean(secret.allow_mcp);
  editForm.fieldsText = serializeFields(secret.fields || []);
}

function selectSecret(secret) {
  selected.value = secret;
  secretsView.value = "detail";
  revealed.value = null;
  revealedVersion.value = null;
  editing.value = false;
  editFields.value = false;
  fillEditForm(secret);
  setRouteForSecret(secret.id);
}

async function openSecretById(secretId, replace = false) {
  const cached = secrets.value.find((secret) => secret.id === secretId);
  const secret = cached || (await api.getSecret(secretId));
  selected.value = secret;
  secretsView.value = "detail";
  revealed.value = null;
  revealedVersion.value = null;
  editing.value = false;
  editFields.value = false;
  fillEditForm(secret);
  setRouteForSecret(secret.id, replace);
}

function showSecretsList() {
  secretsView.value = "list";
  setRouteForTab("secrets");
}

function openEditMetadata() {
  if (!selected.value) return;
  fillEditForm(selected.value);
  editing.value = true;
}

function cancelEditMetadata() {
  if (selected.value) {
    fillEditForm(selected.value);
  }
  editing.value = false;
}

function toggleEditFields() {
  editFields.value = !editFields.value;
}

async function loadSecrets() {
  loading.value = true;
  error.value = "";
  try {
    const payload = await api.listSecrets({
      q: query.value,
      offset: (secretsPage.value - 1) * secretsLimit,
      limit: secretsLimit,
      sort_by: secretsSort.value.by,
      sort_dir: secretsSort.value.dir
    });
    secrets.value = payload.items;
    total.value = payload.total;
    if (selected.value) {
      selected.value = secrets.value.find((secret) => secret.id === selected.value.id) || null;
    } else {
      selected.value = secrets.value[0] || null;
    }
    fillEditForm(selected.value);
    revealed.value = null;
  } catch (err) {
    error.value = err.message;
    toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 });
  } finally {
    loading.value = false;
  }
}

async function createSecret() {
  const fields = parseCreateFields();
  await api.createSecret({
    title: form.title,
    purpose: form.purpose || null,
    category: form.category || null,
    source: form.source || null,
    notes: form.notes || null,
    tags: form.tags.split(",").map((tag) => tag.trim()).filter(Boolean),
    allow_ui: form.allow_ui,
    allow_rest_api: form.allow_rest_api,
    allow_mcp: form.allow_mcp,
    fields
  });
  resetCreateForm();
  showCreateModal.value = false;
  await loadSecrets();
}

async function saveSecretMetadata() {
  if (!selected.value) return;
  const updated = await api.updateSecret(selected.value.id, {
    title: editForm.title,
    purpose: editForm.purpose || null,
    category: editForm.category || null,
    source: editForm.source || null,
    notes: editForm.notes || null,
    tags: editForm.tags.split(",").map((tag) => tag.trim()).filter(Boolean),
    status: editForm.status,
    archived: editForm.archived,
    allow_ui: editForm.allow_ui,
    allow_rest_api: editForm.allow_rest_api,
    allow_mcp: editForm.allow_mcp
  });
  selected.value = updated;
  editing.value = false;
  await loadSecrets();
}

async function saveSecretFields() {
  if (!selected.value) return;
  const updated = await api.updateSecret(selected.value.id, {
    fields: parseFieldsText(editForm.fieldsText)
  });
  selected.value = updated;
  revealed.value = null;
  editFields.value = false;
  await loadSecrets();
}

async function setStatus(status) {
  if (!selected.value) return;
  selected.value = await api.updateSecret(selected.value.id, { status });
  await loadSecrets();
}

async function archiveSelected() {
  if (!selected.value) return;
  selected.value = await api.updateSecret(selected.value.id, { archived: true });
  await loadSecrets();
}

function confirmDeleteSecret(secret) {
  pendingDeleteSecret.value = secret;
}

async function deleteSelectedSecret() {
  if (!pendingDeleteSecret.value) return;
  await api.deleteSecret(pendingDeleteSecret.value.id);
  pendingDeleteSecret.value = null;
  selected.value = null;
  secretsView.value = "list";
  revealed.value = null;
  await loadSecrets();
}

async function reveal(secret) {
  revealed.value = await api.revealSecret(secret.id);
  revealedVisibleFields.value = new Set();
}

async function copyValue(value, key) {
  if (!value) return;
  await navigator.clipboard.writeText(value);
  toastProvider.value?.success({ title: "Copied", text: "Value copied to clipboard.", lifetime: 1800 });
  if (key) {
    copiedFieldKey.value = key;
    setTimeout(() => {
      if (copiedFieldKey.value === key) copiedFieldKey.value = "";
    }, 2000);
  }
}

function maskValue(value) {
  return "*".repeat(Math.max(8, String(value || "").length));
}

function isRevealedFieldVisible(key) {
  return revealedVisibleFields.value.has(key);
}

function toggleRevealedField(key) {
  const next = new Set(revealedVisibleFields.value);
  if (next.has(key)) {
    next.delete(key);
  } else {
    next.add(key);
  }
  revealedVisibleFields.value = next;
}

async function loadVersions(secretId) {
  revealedVersion.value = null;
  versions.value = await api.versions(secretId);
}

async function revealVersion(version) {
  if (!selected.value) return;
  revealedVersion.value = await api.revealVersion(selected.value.id, version.id);
}

async function loadAudit() {
  auditLoading.value = true;
  try {
    const params = {
      offset: (auditPage.value - 1) * auditLimit,
      limit: auditLimit
    };
    let payload;
    if (auditMode.value === "secret" && selected.value) {
      payload = await api.secretAudit(selected.value.id, params);
    } else {
      payload = await api.audit(params);
    }
    auditEvents.value = payload.items;
    auditTotal.value = payload.total;
  } catch (err) {
    toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 });
  } finally {
    auditLoading.value = false;
  }
}

async function loadTokens() {
  tokensLoading.value = true;
  try {
    tokens.value = await api.tokens();
  } catch (err) {
    toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 });
  } finally {
    tokensLoading.value = false;
  }
}

async function createApiToken() {
  const scopes = ["read", "reveal", "write", "admin", "mcp"].filter((scope) => tokenForm[scope]);
  newToken.value = await api.createToken({
    name: tokenForm.name || "API token",
    scopes
  });
  await loadTokens();
}

function closeCreateTokenModal() {
  newToken.value = null;
  showCreateTokenModal.value = false;
}

function openProfileModal() {
  profileForm.display_name = me.value?.display_name || "";
  profileForm.locale = me.value?.locale || "en";
  showProfileModal.value = true;
}

async function saveProfile() {
  try {
    const updated = await api.updateMe({
      display_name: profileForm.display_name || null,
      locale: profileForm.locale || null
    });
    me.value = updated;
    showProfileModal.value = false;
    toastProvider.value?.success({ title: "Saved", text: "Profile updated.", lifetime: 2000 });
  } catch (err) {
    toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 });
  }
}

async function revokeToken(token) {
  await api.revokeToken(token.id);
  await loadTokens();
}

async function copyText(value) {
  await copyValue(value);
}

async function exportData() {
  const payload = await api.exportData();
  const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
  const url = URL.createObjectURL(blob);
  const link = document.createElement("a");
  link.href = url;
  link.download = "gnexus-creds-export.json";
  link.click();
  URL.revokeObjectURL(url);
}

async function handleImportFiles(files) {
  importError.value = "";
  importPreview.value = null;
  const file = files?.[0];
  importFile.value = file || null;
  if (!file) return;
  try {
    const text = await file.text();
    const payload = JSON.parse(text);
    if (payload.format !== "gnexus-creds-export" || payload.version !== 1) {
      throw new Error("Unsupported import file");
    }
    importPreview.value = payload;
  } catch (err) {
    importError.value = err.message;
  }
}

async function importData() {
  if (!importPreview.value) return;
  await api.importData(importPreview.value);
  importFiles.value = [];
  importFile.value = null;
  importPreview.value = null;
  await loadSecrets();
}

async function deleteAllData() {
  try {
    await api.deleteAccountData();
    showDeleteConfirm.value = false;
    await loadSecrets();
    toastProvider.value?.success({ title: "Deleted", text: "All data has been removed.", lifetime: 2000 });
  } catch (err) {
    toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 });
  }
}

async function loadAdminUsers() {
  try {
    activeTab.value = "admin";
    const payload = await api.adminUsers({
      offset: (adminUsersPage.value - 1) * adminUsersLimit,
      limit: adminUsersLimit
    });
    adminUsers.value = payload.items;
    adminUsersTotal.value = payload.total;
  } catch (err) {
    toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 });
  }
}

async function loadBackups() {
  backupsLoading.value = true;
  try {
    backups.value = await api.listBackups();
  } catch (err) {
    toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 });
  } finally {
    backupsLoading.value = false;
  }
}

async function createBackup() {
  try {
    await api.createBackup();
    toastProvider.value?.success({ title: "Backup", text: "Database backup created.", lifetime: 2000 });
    await loadBackups();
  } catch (err) {
    toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 });
  }
}

function downloadBackup(filename) {
  window.location.href = api.downloadBackup(filename);
}

function confirmRestore(filename) {
  pendingRestoreFile.value = filename;
  showRestoreConfirm.value = true;
}

async function restoreBackup() {
  if (!pendingRestoreFile.value) return;
  try {
    await api.restoreBackup(pendingRestoreFile.value);
    showRestoreConfirm.value = false;
    pendingRestoreFile.value = null;
    toastProvider.value?.success({ title: "Restored", text: "Database restored. Please reload the page.", lifetime: 5000 });
  } catch (err) {
    toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 });
  }
}

async function loadDiagnostics() {
  const [health, ready] = await Promise.allSettled([api.health(), api.ready()]);
  diagnostics.health =
    health.status === "fulfilled" ? health.value.status || "ok" : "error";
  diagnostics.ready = ready.status === "fulfilled" ? ready.value.status || "ok" : "error";
}

async function loadStats() {
  const payload = await api.stats();
  stats.total_secrets = payload.total_secrets;
  stats.active_secrets = payload.active_secrets;
  stats.mcp_enabled_secrets = payload.mcp_enabled_secrets;
}

onMounted(async () => {
  try {
    me.value = await api.me();
    await loadSecrets();
    await syncTabFromRoute(true);
    window.addEventListener("popstate", onPopState);
  } catch {
    window.location.href = "/auth/login";
  }
});

onBeforeUnmount(() => {
  window.removeEventListener("popstate", onPopState);
});

watch(activeTab, (tab) => {
  if (!canUseTab(tab)) {
    activeTab.value = "secrets";
    return;
  }
  if (suppressNextRouteSync) {
    suppressNextRouteSync = false;
  } else {
    if (tab === "secrets") {
      secretsView.value = "list";
    }
    if (tab === "audit" && auditMode.value === "secret" && selected.value) {
      setRouteForAudit(selected.value.id);
    } else {
      setRouteForTab(tab);
    }
  }
  if (tab === "history" && selected.value) {
    loadVersions(selected.value.id);
  }
  if (tab === "audit") {
    if (auditMode.value === "secret" && !selected.value) {
      auditMode.value = "all";
    }
    loadAudit();
  }
  if (tab === "tokens") {
    loadTokens();
  }
  if (tab === "settings") {
    loadStats();
  }
  if (tab === "admin" && me.value?.role === "admin" && !adminUsers.value.length) {
    loadAdminUsers();
  }
  if (tab === "admin" && me.value?.role === "admin") {
    loadDiagnostics();
    loadBackups();
  }
});

watch(pageTitle, (title) => {
  document.title = title;
}, { immediate: true });

let searchDebounce = null;
watch(query, () => {
  clearTimeout(searchDebounce);
  searchDebounce = setTimeout(() => {
    if (activeTab.value === "secrets") {
      loadSecrets();
    }
  }, 250);
});
</script>

<template>
  <GnToastProvider ref="toastProvider">
    <GnNavigationShell
      brand="GNEXUS CREDS"
      logo-src="https://gnexus.space/resources/logo.png"
      title="GNEXUS CREDS"
      :current="currentNavLabel"
      :items="navItems"
      @select="onNavSelect"
    >
      <template #footer>
        <a
          :href="me?.auth_profile_url || undefined"
          target="_blank"
          rel="noopener noreferrer"
          class="profile-identity"
        >
          <GnIdentity
            :title="me?.display_name || me?.email || 'User'"
            :meta="me?.email || ''"
            :avatar="{
              src: me?.avatar_url || '',
              initials: userInitials,
              size: 'sm',
              variant: 'primary'
            }"
          />
        </a>
        <GnIconButton
          icon="ph ph-pencil-simple"
          label="Edit profile"
          @click="openProfileModal"
        />
        <GnIconButton
          icon="ph ph-sign-out"
          label="Logout"
          @click="logout"
        />
      </template>
      <template #content>
        <main class="creds-app">

        <section v-if="activeTab === 'secrets'" class="secrets-page">
          <section v-if="secretsView === 'list'" class="workspace-surface list-panel">
            <p v-if="error" class="error">{{ error }}</p>
            <GnToolbar title="Secrets" :meta="loading ? 'Loading' : `${total} records`">
              <template #actions>
                <GnSearchField
                  v-model="query"
                  placeholder="service, login, tag, source"
                />
                <GnDropdown
                  :items="sortItems"
                  label="Sort"
                  @select="(item) => { secretsSort.value = item.value; loadSecrets(); }"
                />
              </template>
            </GnToolbar>
            <div class="create-actions">
              <GnButton size="sm" variant="success" icon="ph ph-plus-circle" @click="openCreateModal">
                Create
              </GnButton>
            </div>
            <GnSkeleton v-if="loading" />
            <GnTable
              v-else-if="secrets.length"
              :columns="secretColumns"
              :rows="secretRows"
              class="secrets-table"
              empty-text="No secrets yet"
            >
              <template #cell-title="{ row }">
                <strong
                  :class="{ 'selected-secret-title': selected?.id === row.id }"
                  class="secret-title-link"
                  @click="selectSecret(row.raw)"
                >
                  {{ row.title }}
                </strong>
              </template>
              <template #cell-status="{ row }">
                <GnBadge :variant="row.status === 'actual' ? 'success' : 'warning'">
                  {{ row.status }}
                </GnBadge>
              </template>
              <template #cell-actions="{ row }">
                <GnIconButton icon="ph ph-caret-right" label="Open secret" @click="selectSecret(row.raw)" />
              </template>
            </GnTable>
            <GnPagination
              v-if="total > secretsLimit"
              :page="secretsPage"
              :total-pages="Math.ceil(total / secretsLimit)"
              @update:page="secretsPage = $event; loadSecrets()"
            />
            <GnEmptyState
              v-if="!secrets.length"
              title="No secrets yet"
              text="Create the first record using the Create button."
              icon="ph ph-vault"
            />
          </section>

          <section v-else class="workspace-surface detail-panel">
            <template v-if="selected">
              <SecretDetailPanel
                show-back
                :secret="selected"
                :fields="revealed?.fields"
                :copied-field-key="copiedFieldKey"
                :visible-field-keys="revealedVisibleFields"
                @back="showSecretsList"
                @reveal="reveal(selected)"
                @hide="revealed.value = null"
                @edit-metadata="openEditMetadata"
                @toggle-field-visibility="toggleRevealedField"
                @copy-field="({ value, key }) => copyValue(value, key)"
                @status-change="setStatus"
                @archive="archiveSelected"
                @delete="confirmDeleteSecret(selected)"
                @navigate="onPanelNavigate"
              />
              <section v-if="editFields" class="workspace-surface inset-surface">
                <GnToolbar title="Edit fields" />
                <GnTextarea
                  v-model="editForm.fieldsText"
                  label="Fields"
                  icon="ph ph-brackets-curly"
                  rows="8"
                />
                <div class="actions">
                  <GnButton variant="primary" icon="ph ph-floppy-disk" @click="saveSecretFields">
                    Save new version
                  </GnButton>
                  <GnButton variant="secondary" @click="editFields = false">Cancel</GnButton>
                </div>
              </section>
            </template>
            <GnEmptyState
              v-else
              class="large"
              title="Select a secret"
              text="Metadata is shown by default. Values are available through reveal."
              icon="ph ph-lock-key"
            />
          </section>
        </section>

        <section v-if="activeTab === 'history'" class="panel-stack">
          <section class="workspace-surface">
            <GnToolbar title="Version history" :meta="selected?.title || 'Select a secret first from the Secrets tab'" />
            <GnTimeline v-if="versionItems.length" :items="versionItems">
              <template #meta="{ item }">
                <div class="version-actions">
                  <GnButton
                    v-if="revealedVersion?.version_id === item.raw.id"
                    size="sm"
                    icon="ph ph-eye-slash"
                    @click="revealedVersion = null"
                  >
                    Hide
                  </GnButton>
                  <GnButton
                    v-else
                    size="sm"
                    icon="ph ph-eye"
                    @click="revealVersion(item.raw)"
                  >
                    Reveal
                  </GnButton>
                </div>
              </template>
              <template
                v-for="item in versionItems"
                :key="item.key"
                #[item.key]="{ item }"
              >
                <SecretDetailPanel
                  v-if="revealedVersion?.version_id === item.raw.id"
                  :show-header="false"
                  :show-actions="false"
                  :secret="revealedVersion"
                  :fields="revealedVersion.fields"
                  :copied-field-key="copiedFieldKey"
                  :visible-field-keys="revealedVisibleFields"
                  @toggle-field-visibility="toggleRevealedField"
                  @copy-field="({ value, key }) => copyValue(value, key)"
                />
              </template>
            </GnTimeline>
            <GnEmptyState
              v-else
              title="No versions"
              text="Select a secret and open its history to see versions."
              icon="ph ph-clock-counter-clockwise"
            />
          </section>
        </section>

        <section v-if="activeTab === 'audit'" class="panel-stack">
          <section class="workspace-surface">
            <GnToolbar
              title="Audit"
              :meta="auditMode === 'secret' && selected ? selected.title : 'All secrets'"
            />
            <GnSkeleton v-if="auditLoading" />
            <GnTable
              v-else-if="auditRows.length"
              :columns="auditColumns"
              :rows="auditRows"
              class="audit-table"
              empty-text="No audit events"
            >
              <template #cell-channel="{ value }">
                <GnBadge :variant="value === 'ui' ? 'info' : value === 'rest' ? 'warning' : 'success'">
                  {{ value }}
                </GnBadge>
              </template>
              <template #cell-metadata="{ row }">
                <GnDescriptionList v-if="row.metadataItems.length" :items="row.metadataItems" compact />
                <span v-else>—</span>
              </template>
            </GnTable>
            <GnPagination
              v-if="auditTotal > auditLimit"
              :page="auditPage"
              :total-pages="Math.ceil(auditTotal / auditLimit)"
              @update:page="auditPage = $event; loadAudit()"
            />
            <GnEmptyState
              v-else
              title="No audit events"
              text="Activity will appear here once actions are performed."
              icon="ph ph-list-checks"
            />
          </section>
        </section>

        <section v-if="activeTab === 'tokens'" class="panel-stack">
          <section class="workspace-surface token-panel">
            <GnToolbar title="API tokens">
              <template #actions>
                <GnButton variant="primary" icon="ph ph-plus-circle" @click="showCreateTokenModal = true">
                  Create token
                </GnButton>
              </template>
            </GnToolbar>
            <GnSkeleton v-if="tokensLoading" />
            <GnActionList v-else-if="tokenItems.length" :items="tokenItems">
              <template #controls="{ item }">
                <GnButton size="sm" variant="danger" icon="ph ph-trash" @click="revokeToken(item.raw)">
                  Revoke
                </GnButton>
              </template>
            </GnActionList>
            <GnEmptyState
              v-else
              title="No tokens"
              text="Create an API token to access secrets programmatically."
              icon="ph ph-key"
            />
          </section>
        </section>

        <section v-if="activeTab === 'settings'" class="panel-stack">
          <section class="settings-metrics" aria-label="Storage overview">
            <GnMetricCard label="Total secrets" :value="stats.total_secrets" icon="ph ph-vault" />
            <GnMetricCard label="Active secrets" :value="stats.active_secrets" icon="ph ph-check-circle" />
            <GnMetricCard label="MCP enabled" :value="stats.mcp_enabled_secrets" icon="ph ph-plugs-connected" />
          </section>

          <section class="workspace-surface">
            <GnToolbar title="Import data" />
            <GnFileUpload
              v-model="importFiles"
              title="Import JSON"
              description="Only gnexus-creds export JSON files are supported."
              primary="Choose export file"
              secondary="The file is parsed locally before import."
              badge="JSON"
              :multiple="false"
              accept="application/json,.json"
              @change="handleImportFiles"
            />
            <p v-if="importError" class="error">{{ importError }}</p>
            <div v-if="importPreview" class="import-preview">
              <GnBadge variant="success">{{ importPreview.secrets?.length || 0 }} secrets ready</GnBadge>
              <span class="muted">{{ importFile?.name }}</span>
            </div>
            <div class="create-actions">
              <GnButton
                variant="primary"
                icon="ph ph-upload-simple"
                :disabled="!importPreview"
                @click="importData"
              >
                Import selected file
              </GnButton>
            </div>
          </section>

          <section class="workspace-surface">
            <GnToolbar title="Export data" />
            <p class="input-info">
              Export all secrets as decrypted JSON. Store the file securely — it contains plaintext values.
            </p>
            <div class="create-actions">
              <GnButton icon="ph ph-export" @click="exportData">Export decrypted JSON</GnButton>
            </div>
          </section>

          <section class="workspace-surface danger-zone">
            <GnToolbar title="Danger zone" />
            <p class="input-info">
              Deleting all data removes every secret and version. Audit records and API tokens are preserved.
            </p>
            <div class="create-actions">
              <GnButton variant="danger" icon="ph ph-trash" @click="showDeleteConfirm = true">
                Delete all data
              </GnButton>
            </div>
          </section>
        </section>

        <section v-if="activeTab === 'admin' && me?.role === 'admin'" class="panel-stack admin-stack">
          <section class="workspace-surface">
            <GnToolbar title="Users" :meta="`${adminUsersTotal} records`">
              <template #actions>
                <GnButton icon="ph ph-arrows-clockwise" @click="loadAdminUsers">Refresh</GnButton>
              </template>
            </GnToolbar>
            <GnDescriptionList :items="diagnosticsItems" compact>
              <template #health>
                <GnBadge :variant="diagnostics.health === 'ok' ? 'success' : 'danger'">
                  {{ diagnostics.health }}
                </GnBadge>
              </template>
              <template #ready>
                <GnBadge :variant="diagnostics.ready === 'ok' ? 'success' : 'danger'">
                  {{ diagnostics.ready }}
                </GnBadge>
              </template>
            </GnDescriptionList>
            <GnButton size="sm" icon="ph ph-heartbeat" @click="loadDiagnostics">Check diagnostics</GnButton>
            <GnTable :columns="adminColumns" :rows="adminUsers" empty-text="No users loaded">
              <template #cell-user="{ row }">
                <span class="identity-cell">
                  <strong>{{ row.email }}</strong>
                  <small>{{ row.display_name || row.id }}</small>
                </span>
              </template>
              <template #cell-status="{ value }">
                <GnBadge :variant="value === 'enabled' ? 'success' : 'danger'">{{ value }}</GnBadge>
              </template>
              <template #cell-role="{ value }">
                <GnBadge :variant="value === 'admin' ? 'warning' : 'info'">{{ value }}</GnBadge>
              </template>
            </GnTable>
            <GnPagination
              v-if="adminUsersTotal > adminUsersLimit"
              :page="adminUsersPage"
              :total-pages="Math.ceil(adminUsersTotal / adminUsersLimit)"
              @update:page="adminUsersPage = $event; loadAdminUsers()"
            />
          </section>

          <section class="workspace-surface">
            <GnToolbar title="Backups">
              <template #actions>
                <GnButton icon="ph ph-floppy-disk" @click="createBackup">Create backup</GnButton>
              </template>
            </GnToolbar>
            <GnSkeleton v-if="backupsLoading" />
            <GnTable
              v-else-if="backups.length"
              :columns="[
                { key: 'filename', label: 'File' },
                { key: 'size', label: 'Size' },
                { key: 'created_at', label: 'Created' },
                { key: 'actions', label: '' }
              ]"
              :rows="backups.map(b => ({ ...b, size: `${(b.size / 1024).toFixed(1)} KB` }))"
              empty-text="No backups yet"
            >
              <template #cell-actions="{ row }">
                <GnButton size="sm" icon="ph ph-download-simple" @click="downloadBackup(row.filename)">Download</GnButton>
                <GnButton size="sm" variant="danger" icon="ph ph-arrow-u-up-left" @click="confirmRestore(row.filename)">Restore</GnButton>
              </template>
            </GnTable>
            <GnEmptyState
              v-else
              title="No backups"
              text="Create a backup before making major changes."
              icon="ph ph-floppy-disk"
            />
          </section>
        </section>

        <GnModal v-model:open="showCreateModal" title="Create secret">
          <div class="create-modal-body">
            <section class="modal-section">
              <div class="form-grid two">
                <GnInput
                  v-model="form.title"
                  label="Title"
                  icon="ph ph-text-t"
                  help="Human-readable name shown in lists and search results."
                />
                <GnInput
                  v-model="form.purpose"
                  label="Purpose"
                  icon="ph ph-target"
                  help="Target service, server, account, or other secret destination."
                />
                <GnInput
                  v-model="form.category"
                  label="Category"
                  icon="ph ph-folder"
                  help="Single grouping value for browsing, for example work or banking."
                />
                <GnInput
                  v-model="form.tags"
                  label="Tags"
                  icon="ph ph-tag"
                  placeholder="comma,separated"
                  help="Keywords used by search. Separate multiple tags with commas."
                />
                <GnInput
                  v-model="form.source"
                  label="Source"
                  icon="ph ph-signpost"
                  help="Where this secret came from: person, import, ticket, or note."
                />
                <GnTextarea
                  v-model="form.notes"
                  label="Notes"
                  icon="ph ph-note-pencil"
                  rows="3"
                  maxlength="140"
                  help="Short context for the secret. Limited to 140 characters."
                />
              </div>
            </section>

            <section class="modal-section">
              <GnToolbar title="Fields" meta="One row per secret value">
                <template #actions>
                  <GnButton size="sm" variant="secondary" icon="ph ph-plus" @click="addCreateField">
                    Add field
                  </GnButton>
                </template>
              </GnToolbar>
              <p class="input-info modal-help">
                Add one row per secret value. Enable encryption for passwords, tokens, PINs, and keys.
              </p>
              <div class="create-fields">
                <div v-for="(field, index) in formFields" :key="field.id" class="create-field-row">
                  <GnInput
                    v-model="field.name"
                    label="Field name"
                    icon="ph ph-brackets-curly"
                    help="Name copied into API and MCP responses, for example username."
                  />
                  <GnInput
                    v-model="field.value"
                    label="Value"
                    icon="ph ph-key"
                    help="Actual value stored for this field."
                  />
                  <div class="field-row-actions">
                    <GnCheckbox v-model="field.encrypted" label="Encrypt" />
                    <GnIconButton
                      icon="ph ph-trash"
                      label="Remove field"
                      :disabled="formFields.length === 1"
                      @click="removeCreateField(index)"
                    />
                  </div>
                </div>
              </div>
            </section>

            <section class="modal-section">
              <GnToolbar title="Access" meta="Global channels for this secret" />
              <div class="create-access-grid">
                <div class="access-option">
                  <GnCheckbox v-model="form.allow_ui" label="UI" />
                  <div class="input-info">Available from the web interface after login.</div>
                </div>
                <div class="access-option">
                  <GnCheckbox v-model="form.allow_rest_api" label="REST API" />
                  <div class="input-info">Available to external clients using API tokens.</div>
                </div>
                <div class="access-option">
                  <GnCheckbox v-model="form.allow_mcp" label="MCP" />
                  <div class="input-info">Available to MCP clients when token scopes allow it.</div>
                </div>
              </div>
            </section>
          </div>
          <template #actions>
            <GnButton variant="secondary" @click="showCreateModal = false">Cancel</GnButton>
            <GnButton variant="success" icon="ph ph-check-circle" :disabled="!canCreateSecret" @click="createSecret">
              Create
            </GnButton>
          </template>
        </GnModal>

        <GnModal v-model:open="editing" title="Edit metadata" @close="cancelEditMetadata">
          <div v-if="selected" class="create-modal-body">
            <section class="modal-section">
              <div class="form-grid two">
                <GnInput v-model="editForm.title" label="Title" icon="ph ph-text-t" />
                <GnInput v-model="editForm.purpose" label="Purpose" icon="ph ph-target" />
                <GnInput v-model="editForm.category" label="Category" icon="ph ph-folder" />
                <GnInput v-model="editForm.tags" label="Tags" icon="ph ph-tag" />
                <GnInput v-model="editForm.source" label="Source" icon="ph ph-signpost" />
                <GnInput v-model="editForm.notes" label="Notes" icon="ph ph-note-pencil" maxlength="140" />
              </div>
            </section>
            <section class="modal-section">
              <GnToolbar title="Status" meta="Metadata shown in lists and filters" />
              <div class="controls-line">
                <GnButton
                  :variant="editForm.status === 'actual' ? 'primary' : 'secondary'"
                  @click="editForm.status = 'actual'"
                >
                  Actual
                </GnButton>
                <GnButton
                  :variant="editForm.status === 'outdated' ? 'primary' : 'secondary'"
                  @click="editForm.status = 'outdated'"
                >
                  Outdated
                </GnButton>
                <GnCheckbox v-model="editForm.archived" label="Archived" />
              </div>
            </section>
            <section class="modal-section">
              <GnToolbar title="Access" meta="Global availability channels for this secret" />
              <div class="access-grid">
                <div class="access-option">
                  <GnCheckbox v-model="editForm.allow_ui" label="UI" />
                  <div class="input-info">Visible in the user interface.</div>
                </div>
                <div class="access-option">
                  <GnCheckbox v-model="editForm.allow_rest_api" label="REST API" />
                  <div class="input-info">Available to external clients using API tokens.</div>
                </div>
                <div class="access-option">
                  <GnCheckbox v-model="editForm.allow_mcp" label="MCP" />
                  <div class="input-info">Available to MCP clients when token scopes allow it.</div>
                </div>
              </div>
            </section>
          </div>
          <template #actions>
            <GnButton variant="secondary" @click="cancelEditMetadata">Cancel</GnButton>
            <GnButton variant="primary" icon="ph ph-floppy-disk" @click="saveSecretMetadata">
              Save metadata
            </GnButton>
          </template>
        </GnModal>

        <GnModal v-model:open="showDeleteConfirm" title="Delete all data">
          <p>This permanently removes all secrets and versions. Audit records remain.</p>
          <template #actions>
            <GnButton variant="secondary" @click="showDeleteConfirm = false">Cancel</GnButton>
            <GnButton variant="danger" @click="deleteAllData">Delete</GnButton>
          </template>
        </GnModal>

        <GnModal :open="Boolean(pendingDeleteSecret)" title="Delete secret" @close="pendingDeleteSecret = null">
          <p>
            This permanently deletes
            <strong>{{ pendingDeleteSecret?.title }}</strong>
            and all its versions. Audit records remain.
          </p>
          <template #actions>
            <GnButton variant="secondary" @click="pendingDeleteSecret = null">Cancel</GnButton>
            <GnButton variant="danger" @click="deleteSelectedSecret">Delete</GnButton>
          </template>
        </GnModal>

        <GnModal v-model:open="showCreateTokenModal" title="Create token" @close="closeCreateTokenModal">
          <div v-if="!newToken" class="create-modal-body">
            <p class="input-info">
              API tokens let external clients and MCP tools authenticate with gnexus-creds without a browser session. Pick a descriptive name and select the scopes this token should have.
            </p>
            <GnInput v-model="tokenForm.name" label="Token name" icon="ph ph-key" />
            <div class="controls-line">
              <GnCheckbox v-model="tokenForm.read" label="Read" />
              <GnCheckbox v-model="tokenForm.reveal" label="Reveal" />
              <GnCheckbox v-model="tokenForm.write" label="Write" />
              <GnCheckbox v-model="tokenForm.admin" label="Admin" />
              <GnCheckbox v-model="tokenForm.mcp" label="MCP" />
            </div>
          </div>
          <div v-else class="create-modal-body">
            <GnTextarea :model-value="newToken.token" label="Token" icon="ph ph-key" readonly />
            <p class="input-info">
              Copy this token now. It will not be shown again.
            </p>
          </div>
          <template #actions>
            <GnButton
              v-if="!newToken"
              variant="primary"
              icon="ph ph-plus-circle"
              @click="createApiToken"
            >
              Create token
            </GnButton>
            <template v-else>
              <GnButton icon="ph ph-copy" @click="copyText(newToken.token)">Copy token</GnButton>
              <GnButton variant="secondary" @click="closeCreateTokenModal">Close</GnButton>
            </template>
          </template>
        </GnModal>

        <GnModal v-model:open="showProfileModal" title="Edit profile">
          <div class="create-modal-body">
            <GnInput v-model="profileForm.display_name" label="Display name" icon="ph ph-user" />
            <GnInput v-model="profileForm.locale" label="Locale" icon="ph ph-globe" />
          </div>
          <template #actions>
            <GnButton variant="primary" icon="ph ph-floppy-disk" @click="saveProfile">Save profile</GnButton>
            <GnButton variant="secondary" @click="showProfileModal = false">Cancel</GnButton>
          </template>
        </GnModal>

        <GnModal v-model:open="showRestoreConfirm" title="Restore database">
          <p>
            This will overwrite the current database with
            <strong>{{ pendingRestoreFile }}</strong>.
            All existing data will be replaced.
          </p>
          <template #actions>
            <GnButton variant="secondary" @click="showRestoreConfirm = false">Cancel</GnButton>
            <GnButton variant="danger" icon="ph ph-arrow-u-up-left" @click="restoreBackup">Restore</GnButton>
          </template>
        </GnModal>
        </main>
      </template>
    </GnNavigationShell>
  </GnToastProvider>
</template>