Newer
Older
gnexus-creds / frontend / src / App.vue
@Eugene Sukhodolskiy Eugene Sukhodolskiy 10 hours ago 57 KB Add in-app extension download page
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import {
  GnActionList,
  GnAlert,
  GnBadge,
  GnButton,
  GnChip,
  GnChipGroup,
  GnConfirmDialog,
  GnCopyButton,
  GnDescriptionList,
  GnDropdown,
  GnEmptyState,
  GnFileUpload,
  GnIconButton,
  GnIdentity,
  GnInput,
  GnMetricCard,
  GnNavigationShell,
  GnPagination,
  GnSearchField,
  GnSkeleton,
  GnStatusCard,
  GnSwitch,
  GnTable,
  GnTagInput,
  GnTextarea,
  GnTimeline,
  GnToastProvider,
  GnToolbar,
  GnTooltip,
  GnUserCard
} 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: "extension", label: "Extension" },
  { id: "settings", label: "Settings" }
];
const tabRoutes = {
  secrets: "/secrets",
  history: "/history",
  audit: "/audit",
  tokens: "/tokens",
  extension: "/extension",
  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 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 pendingRevokeToken = ref(null);
const showRevokeConfirm = ref(false);
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 extensionMeta = ref(null);
const extensionLoading = ref(false);
const extensionError = ref("");

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: "extension", label: "Extension", icon: "ph-puzzle-piece" },
    { 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 || [];
  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,
    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,
    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();
}

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 });
  }
}

function confirmRevokeToken(token) {
  pendingRevokeToken.value = token;
  showRevokeConfirm.value = true;
}

async function doRevokeToken() {
  if (!pendingRevokeToken.value) return;
  await api.revokeToken(pendingRevokeToken.value.id);
  pendingRevokeToken.value = null;
  showRevokeConfirm.value = false;
  await loadTokens();
}

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;
}

async function loadExtension() {
  if (extensionMeta.value || extensionLoading.value) return;
  extensionLoading.value = true;
  extensionError.value = "";
  try {
    extensionMeta.value = await api.extension();
  } catch (err) {
    extensionError.value = err.message || "Failed to load extension info.";
  } finally {
    extensionLoading.value = false;
  }
}

function formatBytes(bytes) {
  if (!bytes && bytes !== 0) return "";
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

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

function goToTokens() {
  activeTab.value = "tokens";
}

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 === "extension") {
    loadExtension();
  }
  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">
            <GnAlert v-if="error" variant="danger" role="alert">{{ error }}</GnAlert>
            <GnToolbar title="Secrets" :meta="loading ? 'Loading' : `${total} records`">
              <template #actions>
                <GnSearchField
                  v-model="query"
                  placeholder="service, login, tag, source"
                />
              </template>
            </GnToolbar>
            <div class="toolbar-actions-row">
              <GnDropdown
                class="sort-dropdown"
                :items="sortItems"
                @select="(item) => { secretsSort = item.value; loadSecrets(); }"
              >
                <template #trigger="{ toggle }">
                  <GnIconButton icon="ph ph-sort-ascending" label="Sort" @click="toggle" />
                </template>
              </GnDropdown>
              <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"
                :visible-field-keys="revealedVisibleFields"
                @back="showSecretsList"
                @reveal="reveal(selected)"
                @hide="revealed.value = null"
                @edit-metadata="openEditMetadata"
                @toggle-field-visibility="toggleRevealedField"
                @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"
                  :visible-field-keys="revealedVisibleFields"
                  @toggle-field-visibility="toggleRevealedField"
                />
              </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="confirmRevokeToken(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 === 'extension'" class="panel-stack">
          <GnAlert v-if="extensionError" variant="danger" role="alert">{{ extensionError }}</GnAlert>

          <section class="workspace-surface">
            <GnToolbar
              title="Browser extension"
              :meta="extensionMeta ? `v${extensionMeta.version}` : (extensionLoading ? 'Loading' : '')"
            />
            <p class="input-info">
              Autofill saved credentials, capture new logins, and browse secrets from the toolbar.
              Download the build for your browser, load it unpacked, and point it at this server with
              an API token.
            </p>
            <p v-if="extensionMeta?.notes" class="input-info muted">{{ extensionMeta.notes }}</p>

            <div v-if="extensionLoading && !extensionMeta" class="extension-loading">
              <GnSkeleton />
            </div>

            <div v-if="extensionMeta" class="extension-builds">
              <div v-for="build in extensionMeta.builds" :key="build.filename" class="extension-card">
                <div class="extension-card-head">
                  <GnBadge variant="info">{{ build.browser }}</GnBadge>
                  <span class="muted">{{ formatBytes(build.size) }}</span>
                </div>
                <div class="extension-card-sha">
                  <span class="muted">SHA-256</span>
                  <GnCopyButton :value="build.sha256" />
                </div>
                <div class="create-actions">
                  <GnButton
                    variant="primary"
                    icon="ph ph-download-simple"
                    @click="downloadExtension(build.filename)"
                  >
                    Download {{ build.browser }} build
                  </GnButton>
                </div>
              </div>
            </div>

            <div class="create-actions">
              <GnButton icon="ph ph-key" @click="goToTokens">Create an API token</GnButton>
            </div>
          </section>

          <section class="workspace-surface">
            <GnToolbar title="Install" />
            <GnDescriptionList>
              <div>
                <dt>Chrome / Edge</dt>
                <dd>
                  Open <code>chrome://extensions/</code>, enable Developer mode, click
                  "Load unpacked" and select the unzipped build folder. Then open the popup, set the
                  server URL and an API token with <code>read</code>, <code>reveal</code>, and
                  <code>write</code> scopes.
                </dd>
              </div>
              <div>
                <dt>Firefox</dt>
                <dd>
                  Open <code>about:debugging</code>, choose "This Firefox", click "Load Temporary
                  Add-on" and select the <code>manifest.json</code> from the unzipped build. Configure
                  the server URL and API token in the popup.
                </dd>
              </div>
            </GnDescriptionList>
          </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"
            >
              <template #actions>
                <GnButton
                  variant="primary"
                  icon="ph ph-upload-simple"
                  :disabled="!importPreview"
                  @click="importData"
                >
                  Import selected file
                </GnButton>
              </template>
            </GnFileUpload>
            <GnAlert v-if="importError" variant="danger" role="alert">{{ importError }}</GnAlert>
            <div v-if="importPreview" class="import-preview">
              <GnBadge variant="success">{{ importPreview.secrets?.length || 0 }} secrets ready</GnBadge>
              <span class="muted">{{ importFile?.name }}</span>
            </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>
            <div v-if="adminUsers.length" class="admin-user-cards">
              <GnUserCard
                v-for="user in adminUsers"
                :key="user.id"
                :name="user.display_name || user.email"
                :email="user.email"
                :role="user.role"
                :avatar="{ initials: (user.display_name || user.email).split(/[\s._-]+/).filter(Boolean).slice(0, 2).map(w => w[0].toUpperCase()).join(''), size: 'sm' }"
                :compact="true"
              >
                <template #actions>
                  <GnBadge :variant="user.status === 'enabled' ? 'success' : 'danger'">{{ user.status }}</GnBadge>
                  <GnBadge :variant="user.role === 'admin' ? 'warning' : 'info'">{{ user.role }}</GnBadge>
                </template>
              </GnUserCard>
            </div>
            <GnEmptyState
              v-else
              title="No users"
              text="Users will appear here once they sign in."
              icon="ph ph-users"
            />
            <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."
                />
                <div class="form-row-two">
                  <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.source"
                    label="Source"
                    icon="ph ph-signpost"
                    help="Where this secret came from: person, import, ticket, or note."
                  />
                </div>
                <GnTagInput
                  v-model="form.tags"
                  label="Tags"
                  icon="ph ph-tag"
                  separator=","
                  unique
                  help="Keywords used by search."
                  class="form-col-full"
                />
                <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."
                  class="form-col-full"
                />
              </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">
                  <GnSwitch v-model="form.allow_ui" label="UI" />
                  <div class="input-info">Available from the web interface after login.</div>
                </div>
                <div class="access-option">
                  <GnSwitch 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">
                  <GnSwitch 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" />
                <div class="form-row-two">
                  <GnInput v-model="editForm.category" label="Category" icon="ph ph-folder" />
                  <GnInput v-model="editForm.source" label="Source" icon="ph ph-signpost" />
                </div>
                <GnTagInput
                  v-model="editForm.tags"
                  label="Tags"
                  icon="ph ph-tag"
                  separator=","
                  unique
                  class="form-col-full"
                />
                <GnTextarea
                  v-model="editForm.notes"
                  label="Notes"
                  icon="ph ph-note-pencil"
                  rows="3"
                  maxlength="140"
                  class="form-col-full"
                />
              </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>
                <GnSwitch 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">
                  <GnSwitch v-model="editForm.allow_ui" label="UI" />
                  <div class="input-info">Visible in the user interface.</div>
                </div>
                <div class="access-option">
                  <GnSwitch 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">
                  <GnSwitch 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>

        <GnConfirmDialog
          v-model:open="showDeleteConfirm"
          title="Delete all data"
          message="This permanently removes all secrets and versions. Audit records remain."
          confirm-text="Delete"
          confirm-variant="danger"
          @confirm="deleteAllData"
        />

        <GnConfirmDialog
          :open="Boolean(pendingDeleteSecret)"
          title="Delete secret"
          :message="`This permanently deletes ${pendingDeleteSecret?.title || 'this secret'} and all its versions. Audit records remain.`"
          confirm-text="Delete"
          confirm-variant="danger"
          @confirm="deleteSelectedSecret"
          @update:open="pendingDeleteSecret = null"
        />

        <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>
              <GnCopyButton :text="newToken.token" size="sm" />
              <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>

        <GnConfirmDialog
          v-model:open="showRestoreConfirm"
          title="Restore database"
          :message="`This will overwrite the current database with ${pendingRestoreFile}. All existing data will be replaced.`"
          confirm-text="Restore"
          confirm-variant="danger"
          @confirm="restoreBackup"
        />

        <GnConfirmDialog
          v-model:open="showRevokeConfirm"
          title="Revoke token"
          :message="`This will permanently revoke token '${pendingRevokeToken?.name || 'this token'}'. It can no longer be used for API or MCP access.`"
          confirm-text="Revoke"
          confirm-variant="danger"
          @confirm="doRevokeToken"
        />
        </main>
      </template>
    </GnNavigationShell>
  </GnToastProvider>
</template>