Newer
Older
gnexus-creds / frontend / src / App.vue
<script setup>
import { computed, h, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import {
  GnAlert,
  GnBadge,
  GnButton,
  GnChip,
  GnChipGroup,
  GnConfirmDialog,
  GnCopyButton,
  GnDescriptionList,
  GnDropdown,
  GnEmptyState,
  GnFileUpload,
  GnIconButton,
  GnIdentity,
  GnInput,
  GnLoader,
  GnMetricCard,
  GnModal,
  GnNavigationShell,
  GnSearchField,
  GnSelect,
  GnSkeleton,
  GnStatusCard,
  GnSwitch,
  GnTable,
  GnTagInput,
  GnTextarea,
  GnTimeline,
  GnToastProvider,
  GnToolbar,
  GnTooltip,
  GnUserCard
} from "gnexus-ui-kit/vue";

import { api } from "./api";
import SecretDetailPanel from "./components/SecretDetailPanel.vue";
import { setLocale, t } from "./i18n";
import {
  assertGate,
  clearStore,
  enrollGate,
  gateState,
  readStore as bioGateStore,
  refreshGateState
} from "./biometric";

// Ellipsised pagination: with 20+ pages GnPagination printed every page
// number in an unwrappable row. Same props/emits/looks as the kit's
// GnPagination, but long runs collapse to "1 2 3 … 22".
const AppPagination = {
  props: {
    page: { type: Number, required: true },
    totalPages: { type: Number, required: true },
    ariaLabel: { type: String, default: "Pagination" }
  },
  emits: ["update:page"],
  setup(props, { emit }) {
    const setPage = (page) => {
      if (page >= 1 && page <= props.totalPages && page !== props.page) {
        emit("update:page", page);
      }
    };
    return () => {
      const pages = paginationRange(props.page, props.totalPages);
      return h("nav", { class: "pagination", "aria-label": props.ariaLabel }, [
        h("button", {
          class: "pagination-item",
          type: "button",
          disabled: props.page <= 1,
          onClick: () => setPage(props.page - 1)
        }, [h("i", { class: "ph ph-arrow-left" })]),
        pages.map((page) =>
          page === "…"
            ? h("span", { class: "pagination-ellipsis" }, "…")
            : h("button", {
                class: ["pagination-item", { "pagination-item-active": page === props.page }],
                type: "button",
                "aria-current": page === props.page ? "page" : undefined,
                onClick: () => setPage(page)
              }, String(page))
        ),
        h("button", {
          class: "pagination-item",
          type: "button",
          disabled: props.page >= props.totalPages,
          onClick: () => setPage(props.page + 1)
        }, [h("i", { class: "ph ph-arrow-right" })])
      ]);
    };
  }
};

function paginationRange(page, totalPages) {
  if (totalPages <= 7) {
    return Array.from({ length: totalPages }, (_, index) => index + 1);
  }
  const shown = new Set([1, totalPages, page - 1, page, page + 1]);
  const sorted = [...shown].filter((p) => p >= 1 && p <= totalPages).sort((a, b) => a - b);
  const range = [];
  let previous = 0;
  for (const p of sorted) {
    if (p - previous > 1) range.push("…");
    range.push(p);
    previous = p;
  }
  return range;
}

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

// Auto ("") = follow the auth account; the service stores its own otherwise
const localeOptions = computed(() => [
  { value: "", label: t("profile.languageAuto") },
  { value: "en", label: t("profile.langEn") },
  { value: "uk", label: t("profile.langUk") },
  { value: "ru", label: t("profile.langRu") }
]);

const navItems = computed(() => {
  const items = [
    { id: "secrets", label: t("nav.secrets"), icon: "ph-vault" },
    { id: "history", label: t("nav.history"), icon: "ph-clock-counter-clockwise" },
    { id: "audit", label: t("nav.audit"), icon: "ph-list-checks" },
    { id: "tokens", label: t("nav.tokens"), icon: "ph-key" },
    { id: "extension", label: t("nav.extension"), icon: "ph-puzzle-piece" },
    { id: "settings", label: t("nav.settings"), icon: "ph-gear" }
  ];
  if (me.value?.role === "admin") {
    items.push({ id: "admin", label: t("nav.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 || t("nav.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
  }
  // navi-style: reload so /me answers 401 and the SPA shows its internal
  // login screen; going to /auth/login here would silently auto-login
  // through the still-alive SSO session
  window.location.reload();
}

// 'checking' keeps a splash on screen until /me answers, so the empty shell
// never flashes before the sign-in gate appears; shell renders on success
const authState = ref("checking");

function loginNow() {
  // explicit user click starts OAuth — no silent SSO bounce on load
  window.location.href = "/auth/login";
}
const canCreateSecret = computed(() => Boolean(form.title.trim() && parseCreateFields().length));
const sortItems = computed(() => [
  { label: t("secrets.sortUpdatedNewest"), value: { by: "updated_at", dir: "desc" } },
  { label: t("secrets.sortUpdatedOldest"), value: { by: "updated_at", dir: "asc" } },
  { label: t("secrets.sortTitleAz"), value: { by: "title", dir: "asc" } },
  { label: t("secrets.sortTitleZa"), value: { by: "title", dir: "desc" } },
  { label: t("secrets.sortStatus"), value: { by: "status", dir: "asc" } }
]);
const secretRows = computed(() =>
  secrets.value.map((secret) => ({
    id: secret.id,
    title: secret.title,
    purpose: secret.purpose || t("common.notSet"),
    category: secret.category || t("common.uncategorized"),
    status: secret.status,
    raw: secret
  }))
);
const secretColumns = computed(() => [
  { key: "title", label: t("col.title") },
  { key: "purpose", label: t("col.purpose") },
  { key: "category", label: t("col.category") },
  { key: "status", label: t("col.status") },
  { key: "actions", label: "" }
]);
// created_at values arrive as UTC ISO strings; render the server timestamp
// as local "YYYY-MM-DD HH:mm" — the kit timeline draws it in a narrow
// .timeline-time, where a compact fixed format reads better than Locale
const formatTimestamp = (iso) => {
  const date = new Date(iso);
  if (Number.isNaN(date.getTime())) return iso;
  const pad = (n) => String(n).padStart(2, "0");
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
const versionItems = computed(() =>
  versions.value.map((version) => ({
    key: String(version.id),
    title: t("history.version", { n: version.version_number }),
    time: formatTimestamp(version.created_at),
    text: t("history.fields", { n: version.fields.length }),
    icon: "ph-clock-counter-clockwise",
    raw: version
  }))
);
const auditRows = computed(() =>
  auditEvents.value.map((event) => ({
    id: event.id,
    time: formatTimestamp(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) => ({
      key: token.public_id,
      name: token.name,
      scopes: token.scopes,
      raw: token
    }))
);
const tokenColumns = computed(() => [
  { key: "name", label: t("col.name") },
  { key: "scopes", label: t("col.scopes") },
  { key: "actions", label: "" }
]);
const diagnosticsItems = computed(() => [
  { label: "/health", key: "health", value: diagnostics.health },
  { label: "/ready", key: "ready", value: diagnostics.ready }
]);
const adminColumns = computed(() => [
  { key: "user", label: t("col.user") },
  { key: "status", label: t("col.status") },
  { key: "role", label: t("col.role") },
  { key: "last_seen_at", label: t("col.lastSeen") }
]);
const auditColumns = computed(() => [
  { key: "time", label: t("col.time") },
  { key: "action", label: t("col.action") },
  { key: "channel", label: t("col.channel") },
  { key: "ip", label: t("col.ip") },
  { key: "metadata", label: t("col.metadata") }
]);
const backupColumns = computed(() => [
  { key: "filename", label: t("col.file") },
  { key: "size", label: t("col.size") },
  { key: "created_at", label: t("col.created") },
  { key: "actions", label: "" }
]);

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: t("common.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) {
  if (!(await biometryAllowed())) return;
  revealed.value = await api.revealSecret(secret.id);
  revealedVisibleFields.value = new Set();
}

function hideRevealed() {
  revealed.value = null;
}

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;
  if (!(await biometryAllowed())) return;
  revealedVersion.value = await api.revealVersion(selected.value.id, version.id);
}

function bioGateUserLabel() {
  return me.value?.email || me.value?.display_name || "GNEXUS CREDS user";
}

// The one choke point in front of both reveal entry points; a failure of any
// kind (cancelled prompt, tampered claim, deleted passkey) blocks the API call.
async function biometryAllowed() {
  const result = await assertGate(bioGateUserLabel());
  if (result.ok) return true;
  if (result.code === "cancelled") {
    toastProvider.value?.info({
      title: t("bio.canceled"),
      text: t("bio.canceledText"),
      lifetime: 2500
    });
  } else if (result.code === "no-credential") {
    toastProvider.value?.error({
      title: t("bio.gateTitle"),
      text: t("bio.noCredential"),
      lifetime: 4000
    });
  } else if (result.code !== "no-gate") {
    toastProvider.value?.error({
      title: t("bio.gateTitle"),
      text: t("bio.failedText"),
      lifetime: 3500
    });
  }
  return false;
}

async function toggleBioGate() {
  const store = bioGateStore();
  if (store?.enabled) {
    // Turning the gate off requires the same biometric proof as a reveal:
    // nobody should be able to switch it off from an unattended session.
    const proof = await assertGate(bioGateUserLabel());
    if (!proof.ok) {
      toastProvider.value?.error({
        title: t("bio.gateTitle"),
        text: proof.code === "cancelled" ? t("bio.stayOnCanceled") : t("bio.stayOnFailed"),
        lifetime: 3000
      });
      return;
    }
    clearStore();
    await refreshGateState();
    toastProvider.value?.success({
      title: t("bio.saved"),
      text: t("bio.disabled"),
      lifetime: 2500
    });
    return;
  }
  const result = await enrollGate(bioGateUserLabel());
  if (!result.ok) {
    toastProvider.value?.info({
      title: t("bio.gateTitle"),
      text:
        result.code === "cancelled"
          ? t("bio.enrollCanceled")
          : result.message || t("bio.enrollFailed"),
      lifetime: 3500
    });
    await refreshGateState();
    return;
  }
  await refreshGateState();
  toastProvider.value?.success({
    title: t("bio.saved"),
    text: t("bio.enabled"),
    lifetime: 3000
  });
}

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: t("common.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: t("common.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;
}

async function changeLanguage(value) {
  try {
    // empty/"auto" clears the service-level override; the next /me's
    // locale_effective decides the language again (profile -> auth account)
    // override and the effective locale falls back to the auth account
    const updated = await api.updateMe({ locale: value || "auto" });
    me.value = updated;
    setLocale(updated.locale_effective);
    toastProvider.value?.success({ title: t("bio.saved"), text: t("profile.saved"), lifetime: 2000 });
  } catch (err) {
    toastProvider.value?.error({ title: t("common.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(t("import.unsupportedFile"));
    }
    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: t("toast.deleted"), text: t("toast.allDataRemoved"), lifetime: 2000 });
  } catch (err) {
    toastProvider.value?.error({ title: t("common.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: t("common.error"), text: err.message, lifetime: 3000 });
  }
}

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

async function createBackup() {
  try {
    await api.createBackup();
    toastProvider.value?.success({ title: t("toast.backup"), text: t("toast.backupCreated"), lifetime: 2000 });
    await loadBackups();
  } catch (err) {
    toastProvider.value?.error({ title: t("common.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: t("toast.restored"), text: t("toast.databaseRestored"), lifetime: 5000 });
  } catch (err) {
    toastProvider.value?.error({ title: t("common.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 || t("extension.loadFailed");
  } 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();
    // the backend decides the language: settings override -> auth account.
    // The profile modal's switcher can override it for this service only.
    setLocale(me.value.locale_effective);
    await refreshGateState();
    await loadSecrets();
    await syncTabFromRoute(true);
    window.addEventListener("popstate", onPopState);
    authState.value = "ready";
  } catch (err) {
    if (err?.status === 401) {
      authState.value = "denied";
    } else {
      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">
    <div v-if="authState === 'checking'" class="login-screen" aria-hidden="true">
      <article class="card login-screen-card">
        <img src="/logo.svg" alt="" class="login-screen-logo" />
        <GnLoader circle class="login-screen-loader" />
      </article>
    </div>
    <div v-else-if="authState === 'denied'" class="login-screen">
      <article class="card login-screen-card">
        <img src="/logo.svg" alt="" class="login-screen-logo" />
        <h1 class="login-screen-title">{{ t("login.title") }}</h1>
        <p class="login-screen-subtitle">{{ t("login.subtitle") }}</p>
        <GnButton variant="primary" icon="ph ph-sign-in" @click="loginNow">
          {{ t("login.action") }}
        </GnButton>
      </article>
    </div>
    <GnNavigationShell
      v-else-if="authState === 'ready'"
      brand="GNEXUS CREDS"
      logo-src="/logo.svg"
      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 || t('common.user')"
            :meta="me?.email || ''"
            :avatar="{
              src: me?.avatar_url || '',
              initials: userInitials,
              size: 'sm',
              variant: 'primary'
            }"
          />
        </a>
        <GnIconButton
          icon="ph ph-sign-out"
          :label="t('profile.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="t('nav.secrets')" :meta="loading ? t('common.loading') : t('common.records', { n: total })">
              <template #actions>
                <GnSearchField
                  v-model="query"
                  :placeholder="t('secrets.searchPlaceholder')"
                />
              </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="t('secrets.sort')" @click="toggle" />
                </template>
              </GnDropdown>
              <GnButton size="sm" variant="success" icon="ph ph-plus-circle" @click="openCreateModal">
                {{ t("secrets.create") }}
              </GnButton>
            </div>
            <GnSkeleton v-if="loading" />
            <GnTable
              v-else-if="secrets.length"
              :columns="secretColumns"
              :rows="secretRows"
              class="secrets-table"
              :empty-text="t('secrets.empty')"
            >
              <template #cell-title="{ row }">
                <strong
                  :class="{ 'selected-secret-title': selected?.id === row.id }"
                  class="secret-title-link"
                  @click="selectSecret(row.raw)"
                >
                  {{ row.title }}
                </strong>
                <span class="secret-title-sub">{{ row.category }}</span>
              </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="t('secrets.openSecret')" @click="selectSecret(row.raw)" />
              </template>
            </GnTable>
            <AppPagination
              v-if="total > secretsLimit"
              :page="secretsPage"
              :total-pages="Math.ceil(total / secretsLimit)"
              @update:page="secretsPage = $event; loadSecrets()"
            />
            <GnEmptyState
              v-if="!secrets.length"
              :title="t('secrets.empty')"
              :text="t('secrets.emptyText')"
              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="hideRevealed"
                @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="t('detail.editFields')" />
                <GnTextarea
                  v-model="editForm.fieldsText"
                  :label="t('create.fieldsLabel')"
                  icon="ph ph-brackets-curly"
                  rows="8"
                />
                <div class="actions">
                  <GnButton variant="primary" icon="ph ph-floppy-disk" @click="saveSecretFields">
                    {{ t("create.saveNewVersion") }}
                  </GnButton>
                  <GnButton variant="secondary" @click="editFields = false">{{ t("common.cancel") }}</GnButton>
                </div>
              </section>
            </template>
            <GnEmptyState
              v-else
              class="large"
              :title="t('detail.selectSecret')"
              :text="t('detail.selectSecretText')"
              icon="ph ph-lock-key"
            />
          </section>
        </section>

        <section v-if="activeTab === 'history'" class="panel-stack">
          <section class="workspace-surface">
            <GnToolbar :title="t('history.toolbar')" :meta="selected?.title || t('history.selectSecret')" />
            <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"
                  >
                    {{ t("common.hide") }}
                  </GnButton>
                  <GnButton
                    v-else
                    size="sm"
                    icon="ph ph-eye"
                    @click="revealVersion(item.raw)"
                  >
                    {{ t("common.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"
                />
                <!-- A truthy dynamic slot always beats GnTimeline's
                     item.text, so spell the fallback out here -->
                <span v-else>{{ item.text }}</span>
              </template>
            </GnTimeline>
            <GnEmptyState
              v-else
              :title="t('history.noVersions')"
              :text="t('history.noVersionsText')"
              icon="ph ph-clock-counter-clockwise"
            />
          </section>
        </section>

        <section v-if="activeTab === 'audit'" class="panel-stack">
          <section class="workspace-surface">
            <GnToolbar
              :title="t('audit.toolbar')"
              :meta="auditMode === 'secret' && selected ? selected.title : t('audit.allSecrets')"
            />
            <GnSkeleton v-if="auditLoading" />
            <GnTable
              v-else-if="auditRows.length"
              :columns="auditColumns"
              :rows="auditRows"
              class="audit-table"
              :empty-text="t('audit.empty')"
            >
              <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>
            <!-- The table's own pagination must stay OUTSIDE the loading /
                 rows / empty v-if chain — a v-else here would bind to the
                 pagination's v-if and phantom-render alongside real rows -->
            <GnEmptyState
              v-else
              :title="t('audit.empty')"
              :text="t('audit.emptyText')"
              icon="ph ph-list-checks"
            />
            <AppPagination
              v-if="!auditLoading && auditTotal > auditLimit"
              :page="auditPage"
              :total-pages="Math.ceil(auditTotal / auditLimit)"
              @update:page="auditPage = $event; loadAudit()"
            />
          </section>
        </section>

        <section v-if="activeTab === 'tokens'" class="panel-stack">
          <section class="workspace-surface token-panel">
            <GnToolbar :title="t('tokens.toolbar')">
              <template #actions>
                <GnButton variant="primary" icon="ph ph-plus-circle" @click="showCreateTokenModal = true">
                  {{ t("tokens.create") }}
                </GnButton>
              </template>
            </GnToolbar>
            <GnSkeleton v-if="tokensLoading" />
            <GnTable
              v-else-if="tokenItems.length"
              :columns="tokenColumns"
              :rows="tokenItems"
              class="tokens-table"
            >
              <template #cell-name="{ row }">
                <strong>{{ row.name }}</strong>
              </template>
              <template #cell-scopes="{ row }">
                {{ row.scopes.join(", ") }}
              </template>
              <template #cell-actions="{ row }">
                <GnButton size="sm" variant="danger" icon="ph ph-trash" @click="confirmRevokeToken(row.raw)">
                  {{ t("common.revoke") }}
                </GnButton>
              </template>
            </GnTable>
            <GnEmptyState
              v-else
              :title="t('tokens.empty')"
              :text="t('tokens.emptyText')"
              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="t('extension.toolbar')"
              :meta="extensionMeta ? `v${extensionMeta.version}` : (extensionLoading ? t('common.loading') : '')"
            />
            <p class="input-info">{{ t("extension.description") }}</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)"
                  >
                    {{ t("extension.download", { browser: build.browser }) }}
                  </GnButton>
                </div>
              </div>
            </div>

            <div class="create-actions">
              <GnButton icon="ph ph-key" @click="goToTokens">{{ t("extension.createToken") }}</GnButton>
            </div>
          </section>
        </section>

        <section v-if="activeTab === 'settings'" class="panel-stack">
          <section class="settings-metrics" :aria-label="t('settings.overview')">
            <GnMetricCard :label="t('settings.totalSecrets')" :value="stats.total_secrets" icon="ph ph-vault" />
            <GnMetricCard :label="t('settings.activeSecrets')" :value="stats.active_secrets" icon="ph ph-check-circle" />
            <GnMetricCard :label="t('settings.mcpEnabled')" :value="stats.mcp_enabled_secrets" icon="ph ph-plugs-connected" />
          </section>

          <section class="workspace-surface">
            <GnToolbar :title="t('settings.languageToolbar')" />
            <p class="input-info">{{ t("settings.languageDescription") }}</p>
            <div class="create-actions">
              <GnSelect
                class="language-select"
                :model-value="me?.locale || ''"
                :options="localeOptions"
                @update:model-value="changeLanguage"
              />
            </div>
          </section>

          <section class="workspace-surface">
            <GnToolbar :title="t('settings.importToolbar')" />
            <GnFileUpload
              v-model="importFiles"
              :title="t('settings.importTitle')"
              :description="t('settings.importDescription')"
              :primary="t('settings.importPrimary')"
              :secondary="t('settings.importSecondary')"
              badge="JSON"
              :multiple="false"
              accept="application/json,.json"
              @change="handleImportFiles"
            >
              <template #actions>
                <GnButton
                  variant="primary"
                  icon="ph ph-upload-simple"
                  :disabled="!importPreview"
                  @click="importData"
                >
                  {{ t("settings.importAction") }}
                </GnButton>
              </template>
            </GnFileUpload>
            <GnAlert v-if="importError" variant="danger" role="alert">{{ importError }}</GnAlert>
            <div v-if="importPreview" class="import-preview">
              <GnBadge variant="success">{{ t("settings.importReady", { n: importPreview.secrets?.length || 0 }) }}</GnBadge>
              <span class="muted">{{ importFile?.name }}</span>
            </div>
          </section>

          <section class="workspace-surface">
            <GnToolbar :title="t('settings.exportToolbar')" />
            <p class="input-info">{{ t("settings.exportDescription") }}</p>
            <div class="create-actions">
              <GnButton icon="ph ph-export" @click="exportData">{{ t("settings.exportAction") }}</GnButton>
            </div>
          </section>

          <section class="workspace-surface">
            <GnToolbar :title="t('settings.gateToolbar')" />
            <p class="input-info">{{ t("settings.gateDescription") }}</p>
            <GnAlert v-if="gateState.status === 'unavailable'" variant="info" role="status">
              {{ gateState.detail || t("settings.gateUnavailable") }}
            </GnAlert>
            <GnAlert v-else-if="gateState.status === 'broken'" variant="danger" role="alert">
              {{ t("settings.gateBroken") }}
            </GnAlert>
            <div class="create-actions">
              <GnButton
                icon="ph ph-fingerprint"
                variant="primary"
                :disabled="gateState.status === 'unavailable' || gateState.status === 'unknown'"
                @click="toggleBioGate"
              >
                {{ gateState.status === 'enrolled' ? t("settings.gateDisable") : t("settings.gateEnable") }}
              </GnButton>
            </div>
          </section>

          <section class="workspace-surface danger-zone">
            <GnToolbar :title="t('settings.dangerToolbar')" />
            <p class="input-info">{{ t("settings.dangerDescription") }}</p>
            <div class="create-actions">
              <GnButton variant="danger" icon="ph ph-trash" @click="showDeleteConfirm = true">
                {{ t("settings.deleteAll") }}
              </GnButton>
            </div>
          </section>
        </section>

        <section v-if="activeTab === 'admin' && me?.role === 'admin'" class="panel-stack admin-stack">
          <section class="workspace-surface">
            <GnToolbar :title="t('admin.usersToolbar')" :meta="t('common.records', { n: adminUsersTotal })">
              <template #actions>
                <GnButton icon="ph ph-arrows-clockwise" @click="loadAdminUsers">{{ t("admin.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">{{ t("admin.checkDiagnostics") }}</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="t('admin.noUsers')"
              :text="t('admin.noUsersText')"
              icon="ph ph-users"
            />
            <AppPagination
              v-if="adminUsersTotal > adminUsersLimit"
              :page="adminUsersPage"
              :total-pages="Math.ceil(adminUsersTotal / adminUsersLimit)"
              @update:page="adminUsersPage = $event; loadAdminUsers()"
            />
          </section>

          <section class="workspace-surface">
            <GnToolbar :title="t('admin.backups')">
              <template #actions>
                <GnButton icon="ph ph-floppy-disk" @click="createBackup">{{ t("admin.createBackup") }}</GnButton>
              </template>
            </GnToolbar>
            <GnSkeleton v-if="backupsLoading" />
            <GnTable
              v-else-if="backups.length"
              :columns="backupColumns"
              :rows="backups.map(b => ({ ...b, size: `${(b.size / 1024).toFixed(1)} KB` }))"
              :empty-text="t('admin.noBackupsYet')"
            >
              <template #cell-actions="{ row }">
                <GnButton size="sm" icon="ph ph-download-simple" @click="downloadBackup(row.filename)">{{ t("common.download") }}</GnButton>
                <GnButton size="sm" variant="danger" icon="ph ph-arrow-u-up-left" @click="confirmRestore(row.filename)">{{ t("common.restore") }}</GnButton>
              </template>
            </GnTable>
            <GnEmptyState
              v-else
              :title="t('admin.noBackups')"
              :text="t('admin.noBackupsText')"
              icon="ph ph-floppy-disk"
            />
          </section>
        </section>

        <GnModal v-model:open="showCreateModal" :title="t('create.title')">
          <div class="create-modal-body">
            <section class="modal-section">
              <div class="form-grid two">
                <GnInput
                  v-model="form.title"
                  :label="t('col.title')"
                  icon="ph ph-text-t"
                  :help="t('create.titleHelp')"
                />
                <GnInput
                  v-model="form.purpose"
                  :label="t('col.purpose')"
                  icon="ph ph-target"
                  :help="t('create.purposeHelp')"
                />
                <div class="form-row-two">
                  <GnInput
                    v-model="form.category"
                    :label="t('col.category')"
                    icon="ph ph-folder"
                    :help="t('create.categoryHelp')"
                  />
                  <GnInput
                    v-model="form.source"
                    :label="t('create.sourceLabel')"
                    icon="ph ph-signpost"
                    :help="t('create.sourceHelp')"
                  />
                </div>
                <GnTagInput
                  v-model="form.tags"
                  :label="t('detail.tags')"
                  icon="ph ph-tag"
                  separator=","
                  unique
                  :help="t('create.tagsHelp')"
                  class="form-col-full"
                />
                <GnTextarea
                  v-model="form.notes"
                  :label="t('create.notesLabel')"
                  icon="ph ph-note-pencil"
                  rows="3"
                  maxlength="140"
                  :help="t('create.notesHelp')"
                  class="form-col-full"
                />
              </div>
            </section>

            <section class="modal-section">
              <GnToolbar :title="t('create.fieldsTitle')" :meta="t('create.fieldsMeta')">
                <template #actions>
                  <GnButton size="sm" variant="secondary" icon="ph ph-plus" @click="addCreateField">
                    {{ t("create.addField") }}
                  </GnButton>
                </template>
              </GnToolbar>
              <p class="input-info modal-help">{{ t("create.fieldsHelp") }}</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="t('create.fieldName')"
                    icon="ph ph-brackets-curly"
                    :help="t('create.fieldNameHelp')"
                  />
                  <GnInput
                    v-model="field.value"
                    :label="t('create.fieldValue')"
                    icon="ph ph-key"
                    :help="t('create.fieldValueHelp')"
                  />
                  <div class="field-row-actions">
                    <GnCheckbox v-model="field.encrypted" :label="t('create.encrypt')" />
                    <GnIconButton
                      icon="ph ph-trash"
                      :label="t('create.removeField')"
                      :disabled="formFields.length === 1"
                      @click="removeCreateField(index)"
                    />
                  </div>
                </div>
              </div>
            </section>

            <section class="modal-section">
              <GnToolbar :title="t('create.accessTitle')" :meta="t('create.accessMeta')" />
              <div class="create-access-grid">
                <div class="access-option">
                  <GnSwitch v-model="form.allow_ui" :label="t('create.channelUi')" />
                  <div class="input-info">{{ t("create.uiInfo") }}</div>
                </div>
                <div class="access-option">
                  <GnSwitch v-model="form.allow_rest_api" :label="t('create.channelRest')" />
                  <div class="input-info">{{ t("create.restInfo") }}</div>
                </div>
                <div class="access-option">
                  <GnSwitch v-model="form.allow_mcp" :label="t('create.channelMcp')" />
                  <div class="input-info">{{ t("create.mcpInfo") }}</div>
                </div>
              </div>
            </section>
          </div>
          <template #actions>
            <GnButton variant="secondary" @click="showCreateModal = false">{{ t("common.cancel") }}</GnButton>
            <GnButton variant="success" icon="ph ph-check-circle" :disabled="!canCreateSecret" @click="createSecret">
              {{ t("secrets.create") }}
            </GnButton>
          </template>
        </GnModal>

        <GnModal v-model:open="editing" :title="t('detail.editMetadata')" @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="t('col.title')" icon="ph ph-text-t" />
                <GnInput v-model="editForm.purpose" :label="t('col.purpose')" icon="ph ph-target" />
                <div class="form-row-two">
                  <GnInput v-model="editForm.category" :label="t('col.category')" icon="ph ph-folder" />
                  <GnInput v-model="editForm.source" :label="t('create.sourceLabel')" icon="ph ph-signpost" />
                </div>
                <GnTagInput
                  v-model="editForm.tags"
                  :label="t('detail.tags')"
                  icon="ph ph-tag"
                  separator=","
                  unique
                  class="form-col-full"
                />
                <GnTextarea
                  v-model="editForm.notes"
                  :label="t('create.notesLabel')"
                  icon="ph ph-note-pencil"
                  rows="3"
                  maxlength="140"
                  class="form-col-full"
                />
              </div>
            </section>
            <section class="modal-section">
              <GnToolbar :title="t('create.statusTitle')" :meta="t('create.statusMeta')" />
              <div class="controls-line">
                <GnButton
                  :variant="editForm.status === 'actual' ? 'primary' : 'secondary'"
                  @click="editForm.status = 'actual'"
                >
                  {{ t("create.statusActual") }}
                </GnButton>
                <GnButton
                  :variant="editForm.status === 'outdated' ? 'primary' : 'secondary'"
                  @click="editForm.status = 'outdated'"
                >
                  {{ t("create.statusOutdated") }}
                </GnButton>
                <GnSwitch v-model="editForm.archived" :label="t('create.archived')" />
              </div>
            </section>
            <section class="modal-section">
              <GnToolbar :title="t('create.accessTitle')" :meta="t('create.accessMetaEdit')" />
              <div class="access-grid">
                <div class="access-option">
                  <GnSwitch v-model="editForm.allow_ui" :label="t('create.channelUi')" />
                  <div class="input-info">{{ t("create.uiInfo") }}</div>
                </div>
                <div class="access-option">
                  <GnSwitch v-model="editForm.allow_rest_api" :label="t('create.channelRest')" />
                  <div class="input-info">{{ t("create.restInfo") }}</div>
                </div>
                <div class="access-option">
                  <GnSwitch v-model="editForm.allow_mcp" :label="t('create.channelMcp')" />
                  <div class="input-info">{{ t("create.mcpInfo") }}</div>
                </div>
              </div>
            </section>
          </div>
          <template #actions>
            <GnButton variant="secondary" @click="cancelEditMetadata">{{ t("common.cancel") }}</GnButton>
            <GnButton variant="primary" icon="ph ph-floppy-disk" @click="saveSecretMetadata">
              {{ t("create.saveMetadata") }}
            </GnButton>
          </template>
        </GnModal>

        <GnConfirmDialog
          v-model:open="showDeleteConfirm"
          :title="t('confirm.deleteAllTitle')"
          :message="t('confirm.deleteAllText')"
          :confirm-text="t('common.delete')"
          confirm-variant="danger"
          @confirm="deleteAllData"
        />

        <GnConfirmDialog
          :open="Boolean(pendingDeleteSecret)"
          :title="t('confirm.deleteSecretTitle')"
          :message="t('confirm.deleteSecretText', { title: pendingDeleteSecret?.title || t('detail.selectSecret') })"
          :confirm-text="t('common.delete')"
          confirm-variant="danger"
          @confirm="deleteSelectedSecret"
          @update:open="pendingDeleteSecret = null"
        />

        <GnModal v-model:open="showCreateTokenModal" :title="t('tokens.create')" @close="closeCreateTokenModal">
          <div v-if="!newToken" class="create-modal-body">
            <p class="input-info">
              {{ t("tokens.intro") }}
            </p>
            <GnInput v-model="tokenForm.name" :label="t('tokens.tokenName')" icon="ph ph-key" />
            <div class="controls-line">
              <GnCheckbox v-model="tokenForm.read" :label="t('tokens.scopeRead')" />
              <GnCheckbox v-model="tokenForm.reveal" :label="t('tokens.scopeReveal')" />
              <GnCheckbox v-model="tokenForm.write" :label="t('tokens.scopeWrite')" />
              <GnCheckbox v-model="tokenForm.admin" :label="t('tokens.scopeAdmin')" />
              <GnCheckbox v-model="tokenForm.mcp" :label="t('create.channelMcp')" />
            </div>
          </div>
          <div v-else class="create-modal-body">
            <GnTextarea :model-value="newToken.token" :label="t('tokens.token')" icon="ph ph-key" readonly />
            <p class="input-info">
              {{ t("tokens.copyNotice") }}
            </p>
          </div>
          <template #actions>
            <GnButton
              v-if="!newToken"
              variant="primary"
              icon="ph ph-plus-circle"
              @click="createApiToken"
            >
              {{ t("tokens.create") }}
            </GnButton>
            <template v-else>
              <GnCopyButton :text="newToken.token" size="sm" />
              <GnButton variant="secondary" @click="closeCreateTokenModal">{{ t("common.close") }}</GnButton>
            </template>
          </template>
        </GnModal>

        <GnConfirmDialog
          v-model:open="showRestoreConfirm"
          :title="t('confirm.restoreTitle')"
          :message="t('confirm.restoreText', { file: pendingRestoreFile })"
          :confirm-text="t('common.restore')"
          confirm-variant="danger"
          @confirm="restoreBackup"
        />

        <GnConfirmDialog
          v-model:open="showRevokeConfirm"
          :title="t('tokens.revokeTitle')"
          :message="t('tokens.revokeText', { name: pendingRevokeToken?.name || '' })"
          :confirm-text="t('common.revoke')"
          confirm-variant="danger"
          @confirm="doRevokeToken"
        />
        </main>
      </template>
    </GnNavigationShell>
  </GnToastProvider>
</template>