Newer
Older
gnexus-creds / frontend / src / App.vue
@Eugene Sukhodolskiy Eugene Sukhodolskiy 3 days ago 32 KB Use gnexus ui kit across frontend
<script setup>
import { computed, onMounted, reactive, ref, watch } from "vue";
import {
  GnActionList,
  GnActivityLog,
  GnBadge,
  GnButton,
  GnCard,
  GnCheckbox,
  GnDescriptionList,
  GnEmptyState,
  GnFileUpload,
  GnIconButton,
  GnInput,
  GnMetricCard,
  GnModal,
  GnPageHeader,
  GnSearchField,
  GnTable,
  GnTabs,
  GnTextarea,
  GnToolbar
} from "gnexus-ui-kit/vue";

import { api } from "./api";

const baseTabs = [
  { id: "secrets", label: "Secrets" },
  { id: "history", label: "History" },
  { id: "audit", label: "Audit" },
  { id: "tokens", label: "Tokens" },
  { id: "settings", label: "Settings" }
];

const activeTab = ref("secrets");
const me = ref(null);
const loading = ref(false);
const error = ref("");
const secrets = ref([]);
const total = ref(0);
const query = ref("");
const selected = ref(null);
const revealed = ref(null);
const versions = ref([]);
const revealedVersion = ref(null);
const auditEvents = ref([]);
const auditMode = ref("all");
const tokens = ref([]);
const newToken = ref(null);
const showCreateModal = ref(false);
const showDeleteConfirm = ref(false);
const editing = ref(false);
const editFields = ref(false);
const pendingDeleteSecret = ref(null);
const importFiles = ref([]);
const importFile = ref(null);
const importPreview = ref(null);
const importError = ref("");
const adminUsers = ref([]);
const adminUsersTotal = ref(0);
const diagnostics = reactive({
  health: "unknown",
  ready: "unknown"
});

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 visibleFields = computed(() => selected.value?.fields || []);
const tabs = computed(() =>
  me.value?.role === "admin" ? [...baseTabs, { id: "admin", label: "Admin" }] : baseTabs
);
const activeSecrets = computed(() => secrets.value.filter((secret) => !secret.archived).length);
const mcpSecrets = computed(() => secrets.value.filter((secret) => secret.allow_mcp).length);
const canCreateSecret = computed(() => Boolean(form.title.trim() && parseCreateFields().length));
const secretItems = computed(() =>
  secrets.value.map((secret) => ({
    id: secret.id,
    title: secret.title,
    subtitle: secret.purpose || secret.category || "No purpose",
    raw: secret
  }))
);
const selectedDetails = computed(() => {
  if (!selected.value) return [];
  return [
    { label: "Purpose", value: selected.value.purpose || "Not set" },
    { label: "Category", value: selected.value.category || "Not set" },
    { label: "Tags", value: selected.value.tags?.join(", ") || "No tags" },
    {
      label: "Access",
      value:
        `UI ${selected.value.allow_ui ? "on" : "off"} · REST ` +
        `${selected.value.allow_rest_api ? "on" : "off"} · MCP ` +
        `${selected.value.allow_mcp ? "on" : "off"}`
    }
  ];
});
const visibleFieldItems = computed(() =>
  visibleFields.value.map((field) => ({
    title: field.name,
    subtitle: field.encrypted ? "encrypted" : "plain",
    raw: field
  }))
);
const revealedFieldItems = computed(() =>
  (revealed.value?.fields || []).map((field) => ({
    title: field.name,
    subtitle: field.value,
    raw: field
  }))
);
const versionItems = computed(() =>
  versions.value.map((version) => ({
    title: `Version ${version.version_number}`,
    subtitle: `${version.created_at} · ${version.fields.length} fields`,
    raw: version
  }))
);
const revealedVersionItems = computed(() =>
  (revealedVersion.value?.fields || []).map((field) => ({
    title: field.name,
    subtitle: field.encrypted ? "encrypted" : "plain",
    raw: field
  }))
);
const auditLogItems = computed(() =>
  auditEvents.value.map((event) => ({
    key: String(event.id),
    time: event.created_at,
    title: `${event.action} · ${event.channel}`,
    raw: event
  }))
);
const tokenItems = computed(() =>
  tokens.value.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" }
];

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 serializeFields(fields) {
  return fields
    .map((field) => `${field.name}=${field.encrypted ? "*" : ""}${field.value || ""}`)
    .join("\n");
}

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

function selectSecret(secret) {
  selected.value = secret;
  revealed.value = null;
  revealedVersion.value = null;
  editing.value = false;
  editFields.value = false;
  fillEditForm(secret);
}

async function loadSecrets() {
  loading.value = true;
  error.value = "";
  try {
    const payload = await api.listSecrets({ q: query.value, limit: 50 });
    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;
  } finally {
    loading.value = false;
  }
}

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

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

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

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

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

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

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

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

async function copyField(secret, field) {
  const payload = revealed.value?.id === secret.id ? revealed.value : await api.revealSecret(secret.id);
  const full = payload.fields.find((item) => item.name === field.name);
  if (full?.value) {
    await navigator.clipboard.writeText(full.value);
  }
}

async function loadVersions(secret) {
  selected.value = secret;
  activeTab.value = "history";
  revealedVersion.value = null;
  versions.value = await api.versions(secret.id);
}

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

async function loadAudit() {
  activeTab.value = "audit";
  if (auditMode.value === "secret" && selected.value) {
    auditEvents.value = (await api.secretAudit(selected.value.id)).items;
  } else {
    auditEvents.value = (await api.audit()).items;
  }
}

async function loadTokens() {
  activeTab.value = "tokens";
  tokens.value = await api.tokens();
}

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

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

async function copyText(value) {
  if (value) {
    await navigator.clipboard.writeText(value);
  }
}

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

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

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

async function deleteAllData() {
  await api.deleteAccountData();
  showDeleteConfirm.value = false;
  await loadSecrets();
}

async function loadAdminUsers() {
  activeTab.value = "admin";
  const payload = await api.adminUsers({ limit: 50 });
  adminUsers.value = payload.items;
  adminUsersTotal.value = payload.total;
}

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

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

watch(activeTab, (tab) => {
  if (tab === "admin" && me.value?.role === "admin" && !adminUsers.value.length) {
    loadAdminUsers();
  }
  if (tab === "admin" && me.value?.role === "admin") {
    loadDiagnostics();
  }
});
</script>

<template>
  <main class="creds-app">
    <header class="app-header">
      <GnPageHeader
        title="gnexus-creds"
        kicker="Personal secret storage"
        subtitle="Secrets, REST API tokens, and MCP access"
        compact
      >
        <template #actions>
          <div class="session-box">
            <span>{{ me?.email || "Session loading" }}</span>
            <GnBadge :variant="me?.role === 'admin' ? 'warning' : 'info'">
              {{ me?.role || "user" }}
            </GnBadge>
          </div>
        </template>
      </GnPageHeader>
    </header>

    <nav class="app-tabs" aria-label="Main sections">
      <GnTabs v-model="activeTab" :items="tabs" />
    </nav>

    <section class="metrics-row" aria-label="Overview">
      <GnMetricCard label="Total" :value="total" icon="ph ph-vault" />
      <GnMetricCard label="Active" :value="activeSecrets" icon="ph ph-check-circle" />
      <GnMetricCard label="MCP" :value="mcpSecrets" icon="ph ph-plugs-connected" />
      <GnCard title="Search" class="search-card">
        <div class="search-line">
          <GnSearchField v-model="query" placeholder="service, login, tag, source" />
          <GnButton variant="primary" icon="ph ph-magnifying-glass" @click="loadSecrets">Search</GnButton>
        </div>
      </GnCard>
    </section>

    <section v-if="activeTab === 'secrets'" class="workspace-grid">
      <GnCard class="list-panel">
        <p v-if="error" class="error">{{ error }}</p>
        <GnToolbar title="Secrets" :meta="loading ? 'Loading' : `${total} records`">
          <template #actions>
            <GnButton size="sm" variant="success" icon="ph ph-plus-circle" @click="openCreateModal">
              Create
            </GnButton>
          </template>
        </GnToolbar>
        <GnActionList v-if="secrets.length" :items="secretItems" class="secret-list">
          <template #title="{ item }">
            <GnButton
              size="sm"
              :variant="selected?.id === item.raw.id ? 'primary' : 'secondary'"
              @click="selectSecret(item.raw)"
            >
              {{ item.title }}
            </GnButton>
          </template>
          <template #controls="{ item }">
            <GnBadge :variant="item.raw.status === 'actual' ? 'success' : 'warning'">
              {{ item.raw.status }}
            </GnBadge>
            <small>{{ item.raw.category || "uncategorized" }}</small>
          </template>
        </GnActionList>
        <GnEmptyState
          v-if="!secrets.length"
          title="No secrets yet"
          text="Create the first record using the Create button."
          icon="ph ph-vault"
        />
      </GnCard>

      <GnCard class="detail-panel">
        <template #title>
          <div class="card-title-row">
            <span>{{ selected?.title || "No secret selected" }}</span>
            <div v-if="selected" class="card-title-actions">
              <GnBadge :variant="selected.status === 'actual' ? 'success' : 'warning'">
                {{ selected.status }}
              </GnBadge>
              <GnButton size="sm" icon="ph ph-pencil-simple" @click="editing = !editing">Edit</GnButton>
            </div>
          </div>
        </template>
        <template v-if="selected">
          <GnCard v-if="editing" class="form-card" title="Edit metadata">
            <div class="form-grid two">
              <GnInput v-model="editForm.title" label="Title" icon="ph ph-text-t" />
              <GnInput v-model="editForm.purpose" label="Purpose" icon="ph ph-target" />
              <GnInput v-model="editForm.category" label="Category" icon="ph ph-folder" />
              <GnInput v-model="editForm.tags" label="Tags" icon="ph ph-tag" />
              <GnInput v-model="editForm.source" label="Source" icon="ph ph-signpost" />
              <GnInput v-model="editForm.notes" label="Notes" icon="ph ph-note-pencil" maxlength="140" />
            </div>
            <div class="controls-line">
              <GnButton
                :variant="editForm.status === 'actual' ? 'primary' : 'secondary'"
                @click="editForm.status = 'actual'"
              >
                Actual
              </GnButton>
              <GnButton
                :variant="editForm.status === 'outdated' ? 'primary' : 'secondary'"
                @click="editForm.status = 'outdated'"
              >
                Outdated
              </GnButton>
              <GnCheckbox v-model="editForm.archived" label="Archived" />
            </div>
            <div class="controls-line">
              <GnCheckbox v-model="editForm.allow_ui" label="UI" />
              <GnCheckbox v-model="editForm.allow_rest_api" label="REST API" />
              <GnCheckbox v-model="editForm.allow_mcp" label="MCP" />
            </div>
            <template #footer>
              <div class="actions">
                <GnButton variant="primary" icon="ph ph-floppy-disk" @click="saveSecretMetadata">
                  Save metadata
                </GnButton>
                <GnButton variant="secondary" @click="editing = false">Cancel</GnButton>
              </div>
            </template>
          </GnCard>

          <GnDescriptionList :items="selectedDetails" />

          <GnActionList :items="visibleFieldItems">
            <template #controls="{ item }">
              <code>{{ item.raw.value ?? "••••••" }}</code>
              <GnButton size="sm" icon="ph ph-copy" @click="copyField(selected, item.raw)">Copy</GnButton>
            </template>
          </GnActionList>

          <GnToolbar>
            <template #actions>
              <GnButton variant="primary" icon="ph ph-eye" @click="reveal(selected)">Reveal</GnButton>
              <GnButton icon="ph ph-clock-counter-clockwise" @click="loadVersions(selected)">Versions</GnButton>
              <GnButton icon="ph ph-list-plus" @click="editFields = !editFields">Edit fields</GnButton>
              <GnButton
                icon="ph ph-arrows-clockwise"
                @click="setStatus(selected.status === 'actual' ? 'outdated' : 'actual')"
              >
                Mark {{ selected.status === "actual" ? "outdated" : "actual" }}
              </GnButton>
              <GnButton icon="ph ph-archive" @click="archiveSelected">Archive</GnButton>
              <GnButton variant="danger" icon="ph ph-trash" @click="confirmDeleteSecret(selected)">
                Delete
              </GnButton>
            </template>
          </GnToolbar>

          <GnCard v-if="editFields" class="form-card" title="Edit fields">
            <GnTextarea
              v-model="editForm.fieldsText"
              label="Fields"
              icon="ph ph-brackets-curly"
              rows="8"
            />
            <template #footer>
              <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>
            </template>
          </GnCard>

          <GnCard v-if="revealed" class="revealed-card" title="Revealed fields">
            <GnActionList :items="revealedFieldItems">
              <template #controls="{ item }">
                <code>{{ item.raw.value }}</code>
              </template>
            </GnActionList>
          </GnCard>
        </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"
        />
      </GnCard>
    </section>

    <section v-if="activeTab === 'history'" class="panel-stack">
      <GnCard>
        <GnToolbar title="Version history" :meta="selected?.title || 'Select a secret first from the Secrets tab'">
          <template #actions>
            <GnButton
              v-if="selected"
              icon="ph ph-arrows-clockwise"
              @click="loadVersions(selected)"
            >
              Refresh
            </GnButton>
          </template>
        </GnToolbar>
        <GnActionList :items="versionItems">
          <template #controls="{ item }">
            <GnButton size="sm" icon="ph ph-eye" @click="revealVersion(item.raw)">Reveal</GnButton>
          </template>
        </GnActionList>
        <GnCard v-if="revealedVersion" class="revealed-card" :title="`Version ${revealedVersion.version_number}`">
          <GnActionList :items="revealedVersionItems">
            <template #controls="{ item }">
              <code>{{ item.raw.value }}</code>
              <GnButton size="sm" icon="ph ph-copy" @click="copyText(item.raw.value)">Copy</GnButton>
            </template>
          </GnActionList>
        </GnCard>
      </GnCard>
    </section>

    <section v-if="activeTab === 'audit'" class="panel-stack">
      <GnCard>
        <GnToolbar title="Audit">
          <template #actions>
            <GnButton
              :variant="auditMode === 'all' ? 'primary' : 'secondary'"
              @click="auditMode = 'all'; loadAudit()"
            >
              All
            </GnButton>
            <GnButton
              :variant="auditMode === 'secret' ? 'primary' : 'secondary'"
              :disabled="!selected"
              @click="auditMode = 'secret'; loadAudit()"
            >
              Selected secret
            </GnButton>
            <GnButton icon="ph ph-arrows-clockwise" @click="loadAudit">Refresh</GnButton>
          </template>
        </GnToolbar>
        <GnActivityLog :items="auditLogItems">
          <template #actions="{ item }">
            <code>{{ JSON.stringify(item.raw.metadata) }}</code>
          </template>
        </GnActivityLog>
      </GnCard>
    </section>

    <section v-if="activeTab === 'tokens'" class="panel-stack">
      <GnCard class="token-panel">
        <GnToolbar title="API tokens">
          <template #actions>
            <GnButton icon="ph ph-arrows-clockwise" @click="loadTokens">Refresh</GnButton>
          </template>
        </GnToolbar>
        <GnCard class="form-card" title="Create token">
          <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>
          <template #footer>
            <GnButton variant="primary" icon="ph ph-plus-circle" @click="createApiToken">
              Create token
            </GnButton>
          </template>
        </GnCard>
        <GnCard v-if="newToken" class="revealed-card" title="New token">
          <GnTextarea :model-value="newToken.token" label="Token" icon="ph ph-key" readonly />
          <template #footer>
            <GnButton icon="ph ph-copy" @click="copyText(newToken.token)">Copy token</GnButton>
          </template>
        </GnCard>
        <GnActionList :items="tokenItems">
          <template #controls="{ item }">
            <GnButton size="sm" variant="danger" icon="ph ph-trash" @click="revokeToken(item.raw)">
              Revoke
            </GnButton>
          </template>
        </GnActionList>
      </GnCard>
    </section>

    <section v-if="activeTab === 'settings'" class="panel-stack">
      <GnCard title="Settings">
        <GnFileUpload
          v-model="importFiles"
          title="Import JSON"
          description="Only gnexus-creds export JSON files are supported."
          primary="Choose export file"
          secondary="The file is parsed locally before import."
          badge="JSON"
          :multiple="false"
          accept="application/json,.json"
          @change="handleImportFiles"
        />
        <p v-if="importError" class="error">{{ importError }}</p>
        <p v-if="importPreview" class="muted">
          {{ importPreview.secrets?.length || 0 }} secrets ready to import from
          {{ importFile?.name }}
        </p>
        <GnToolbar>
          <template #actions>
            <GnButton
              variant="primary"
              icon="ph ph-upload-simple"
              :disabled="!importPreview"
              @click="importData"
            >
              Import selected file
            </GnButton>
            <GnButton icon="ph ph-export" @click="exportData">Export decrypted JSON</GnButton>
            <GnButton variant="danger" icon="ph ph-trash" @click="showDeleteConfirm = true">
              Delete all data
            </GnButton>
          </template>
        </GnToolbar>
      </GnCard>
    </section>

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

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

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

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

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

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