Newer
Older
gnexus-creds / frontend / src / App.vue
@Eugene Sukhodolskiy Eugene Sukhodolskiy 3 days ago 30 KB Improve create secret modal UX
<script setup>
import { computed, onMounted, reactive, ref, watch } from "vue";
import {
  GnBadge,
  GnButton,
  GnCheckbox,
  GnInput,
  GnModal,
  GnTabs,
  GnTextarea
} 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 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));

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 handleImportFile(event) {
  importError.value = "";
  importPreview.value = null;
  const file = event.target.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);
  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">
      <div>
        <p class="eyebrow">Personal secret storage</p>
        <h1>gnexus-creds</h1>
      </div>
      <div class="session-box">
        <span>{{ me?.email || "Session loading" }}</span>
        <GnBadge :tone="me?.role === 'admin' ? 'warning' : 'info'">{{ me?.role || "user" }}</GnBadge>
      </div>
    </header>

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

    <section class="metrics-row" aria-label="Overview">
      <div class="metric">
        <span>Total</span>
        <strong>{{ total }}</strong>
      </div>
      <div class="metric">
        <span>Active</span>
        <strong>{{ activeSecrets }}</strong>
      </div>
      <div class="metric">
        <span>MCP</span>
        <strong>{{ mcpSecrets }}</strong>
      </div>
      <div class="metric wide">
        <span>Search</span>
        <div class="search-line">
          <GnInput v-model="query" label="" placeholder="service, login, tag, source" />
          <GnButton variant="primary" @click="loadSecrets">Search</GnButton>
        </div>
      </div>
    </section>

    <section v-if="activeTab === 'secrets'" class="workspace-grid">
      <aside class="panel list-panel">
        <p v-if="error" class="error">{{ error }}</p>
        <div class="panel-title">
          <h2>Secrets</h2>
          <div class="panel-actions">
            <span>{{ loading ? "Loading" : `${total} records` }}</span>
            <GnButton size="sm" variant="success" @click="openCreateModal">
              <span class="button-content">
                <i class="ph ph-plus-circle" aria-hidden="true"></i>
                Create
              </span>
            </GnButton>
          </div>
        </div>
        <button
          v-for="secret in secrets"
          :key="secret.id"
          class="secret-row"
          :class="{ selected: selected?.id === secret.id }"
          type="button"
          @click="selectSecret(secret)"
        >
          <span>
            <strong>{{ secret.title }}</strong>
            <small>{{ secret.purpose || secret.category || "No purpose" }}</small>
          </span>
          <span class="row-meta">
            <GnBadge :tone="secret.status === 'actual' ? 'success' : 'warning'">
              {{ secret.status }}
            </GnBadge>
            <small>{{ secret.category || "uncategorized" }}</small>
          </span>
        </button>
        <div v-if="!secrets.length" class="empty-state">
          <strong>No secrets yet</strong>
          <span>Create the first record using the Create button.</span>
        </div>
      </aside>

      <section class="panel detail-panel">
        <div class="panel-title">
          <h2>{{ selected?.title || "No secret selected" }}</h2>
          <div v-if="selected" class="panel-actions">
            <GnBadge :tone="selected.status === 'actual' ? 'success' : 'warning'">
              {{ selected.status }}
            </GnBadge>
            <GnButton size="sm" @click="editing = !editing">Edit</GnButton>
          </div>
        </div>
        <template v-if="selected">
          <div v-if="editing" class="edit-box">
            <div class="form-grid two">
              <GnInput v-model="editForm.title" label="Title" />
              <GnInput v-model="editForm.purpose" label="Purpose" />
              <GnInput v-model="editForm.category" label="Category" />
              <GnInput v-model="editForm.tags" label="Tags" />
              <GnInput v-model="editForm.source" label="Source" />
              <GnInput v-model="editForm.notes" label="Notes" maxlength="140" />
            </div>
            <div class="status-row">
              <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="checks">
              <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>
            <div class="toolbar">
              <GnButton variant="primary" @click="saveSecretMetadata">Save metadata</GnButton>
              <GnButton @click="editing = false">Cancel</GnButton>
            </div>
          </div>

          <div class="description-grid">
            <span>Purpose</span>
            <strong>{{ selected.purpose || "Not set" }}</strong>
            <span>Category</span>
            <strong>{{ selected.category || "Not set" }}</strong>
            <span>Tags</span>
            <strong>{{ selected.tags?.join(", ") || "No tags" }}</strong>
            <span>Access</span>
            <strong>
              UI {{ selected.allow_ui ? "on" : "off" }} · REST
              {{ selected.allow_rest_api ? "on" : "off" }} · MCP
              {{ selected.allow_mcp ? "on" : "off" }}
            </strong>
          </div>
          <div class="field-list">
            <div v-for="field in visibleFields" :key="field.name" class="field-row">
              <span>
                <strong>{{ field.name }}</strong>
                <small>{{ field.encrypted ? "encrypted" : "plain" }}</small>
              </span>
              <code>{{ field.value ?? "••••••" }}</code>
              <GnButton size="sm" @click="copyField(selected, field)">Copy</GnButton>
            </div>
          </div>
          <div class="toolbar">
            <GnButton variant="primary" @click="reveal(selected)">Reveal</GnButton>
            <GnButton @click="loadVersions(selected)">Versions</GnButton>
            <GnButton @click="editFields = !editFields">Edit fields</GnButton>
            <GnButton
              @click="setStatus(selected.status === 'actual' ? 'outdated' : 'actual')"
            >
              Mark {{ selected.status === "actual" ? "outdated" : "actual" }}
            </GnButton>
            <GnButton @click="archiveSelected">Archive</GnButton>
            <GnButton variant="danger" @click="confirmDeleteSecret(selected)">Delete</GnButton>
          </div>
          <div v-if="editFields" class="edit-box">
            <GnTextarea v-model="editForm.fieldsText" label="Fields" rows="8" />
            <div class="toolbar">
              <GnButton variant="primary" @click="saveSecretFields">Save new version</GnButton>
              <GnButton @click="editFields = false">Cancel</GnButton>
            </div>
          </div>
          <div v-if="revealed" class="field-list revealed">
            <div v-for="field in revealed.fields" :key="field.name" class="field-row">
              <span>{{ field.name }}</span>
              <code>{{ field.value }}</code>
            </div>
          </div>
        </template>
        <div v-else class="empty-state large">
          <strong>Select a secret</strong>
          <span>Metadata is shown by default. Values are available through reveal.</span>
        </div>
      </section>
    </section>

    <section v-if="activeTab === 'history'" class="panel-stack">
      <div class="panel">
        <div class="panel-title">
          <h2>Version history</h2>
          <GnButton v-if="selected" @click="loadVersions(selected)">Refresh</GnButton>
        </div>
        <p class="muted">{{ selected?.title || "Select a secret first from the Secrets tab" }}</p>
        <div v-for="version in versions" :key="version.id" class="secret-row">
          <span>
            <strong>Version {{ version.version_number }}</strong>
            <small>{{ version.created_at }}</small>
          </span>
          <span class="row-meta">
            <small>{{ version.fields.length }} fields</small>
            <GnButton size="sm" @click="revealVersion(version)">Reveal</GnButton>
          </span>
        </div>
        <div v-if="revealedVersion" class="field-list revealed">
          <div class="panel-title inline-title">
            <h2>Version {{ revealedVersion.version_number }}</h2>
          </div>
          <div v-for="field in revealedVersion.fields" :key="field.name" class="field-row">
            <span>
              <strong>{{ field.name }}</strong>
              <small>{{ field.encrypted ? "encrypted" : "plain" }}</small>
            </span>
            <code>{{ field.value }}</code>
            <GnButton size="sm" @click="copyText(field.value)">Copy</GnButton>
          </div>
        </div>
      </div>
    </section>

    <section v-if="activeTab === 'audit'" class="panel-stack">
      <div class="panel">
        <div class="toolbar">
          <h2>Audit</h2>
          <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 @click="loadAudit">Refresh</GnButton>
        </div>
        <div v-for="event in auditEvents" :key="event.id" class="audit-row">
          <span>
            <strong>{{ event.action }}</strong>
            <small>{{ event.channel }} · {{ event.created_at }}</small>
          </span>
          <code>{{ JSON.stringify(event.metadata) }}</code>
        </div>
      </div>
    </section>

    <section v-if="activeTab === 'tokens'" class="panel-stack">
      <div class="panel token-panel">
        <div class="toolbar">
          <h2>API tokens</h2>
          <GnButton @click="loadTokens">Refresh</GnButton>
        </div>
        <div class="edit-box">
          <GnInput v-model="tokenForm.name" label="Token name" />
          <div class="checks">
            <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>
          <GnButton variant="primary" @click="createApiToken">Create token</GnButton>
        </div>
        <div v-if="newToken" class="new-token-box">
          <GnTextarea :model-value="newToken.token" label="New token" readonly />
          <GnButton @click="copyText(newToken.token)">Copy token</GnButton>
        </div>
        <div v-for="token in tokens" :key="token.id" class="secret-row">
          <span>{{ token.name }}</span>
          <span class="row-meta">
            <small>{{ token.scopes.join(", ") }}</small>
            <GnButton size="sm" variant="danger" @click="revokeToken(token)">Revoke</GnButton>
          </span>
        </div>
      </div>
    </section>

    <section v-if="activeTab === 'settings'" class="panel-stack">
      <div class="panel">
        <h2>Settings</h2>
        <div class="edit-box">
          <h3>Import JSON</h3>
          <input type="file" accept="application/json,.json" @change="handleImportFile" />
          <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>
          <GnButton
            variant="primary"
            :disabled="!importPreview"
            @click="importData"
          >
            Import selected file
          </GnButton>
        </div>
        <div class="toolbar">
          <GnButton @click="exportData">Export decrypted JSON</GnButton>
          <GnButton variant="danger" @click="showDeleteConfirm = true">Delete all data</GnButton>
        </div>
      </div>
    </section>

    <section v-if="activeTab === 'admin' && me?.role === 'admin'" class="panel-stack admin-stack">
      <div class="panel">
        <div class="toolbar">
          <h2>Users</h2>
          <span class="muted">{{ adminUsersTotal }} records</span>
          <GnButton @click="loadAdminUsers">Refresh</GnButton>
        </div>
        <div class="diagnostics-row">
          <span>
            <small>/health</small>
            <GnBadge :tone="diagnostics.health === 'ok' ? 'success' : 'danger'">
              {{ diagnostics.health }}
            </GnBadge>
          </span>
          <span>
            <small>/ready</small>
            <GnBadge :tone="diagnostics.ready === 'ok' ? 'success' : 'danger'">
              {{ diagnostics.ready }}
            </GnBadge>
          </span>
          <GnButton size="sm" @click="loadDiagnostics">Check</GnButton>
        </div>
        <div class="admin-grid admin-grid-head">
          <span>User</span>
          <span>Status</span>
          <span>Role</span>
          <span>Last seen</span>
        </div>
        <div v-for="user in adminUsers" :key="user.id" class="admin-grid">
          <span>
            <strong>{{ user.email }}</strong>
            <small>{{ user.display_name || user.id }}</small>
          </span>
          <GnBadge :tone="user.status === 'enabled' ? 'success' : 'danger'">
            {{ user.status }}
          </GnBadge>
          <GnBadge :tone="user.role === 'admin' ? 'warning' : 'info'">
            {{ user.role }}
          </GnBadge>
          <small>{{ user.last_seen_at || "never" }}</small>
        </div>
        <div v-if="!adminUsers.length" class="empty-state">
          <strong>No users loaded</strong>
          <span>Use refresh to load gnexus-creds users.</span>
        </div>
      </div>
    </section>

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

        <section class="modal-section">
          <div class="panel-title inline-title">
            <h2>Fields</h2>
            <GnButton size="sm" @click="addCreateField">
              <span class="button-content">
                <i class="ph ph-plus" aria-hidden="true"></i>
                Add field
              </span>
            </GnButton>
          </div>
          <p class="section-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">
              <div class="form-field">
                <GnInput v-model="field.name" label="Field name" />
                <small class="field-help">Name copied into API and MCP responses, for example username.</small>
              </div>
              <div class="form-field">
                <GnInput v-model="field.value" label="Value" />
                <small class="field-help">Actual value stored for this field.</small>
              </div>
              <div class="field-row-actions">
                <GnCheckbox v-model="field.encrypted" label="Encrypt" />
                <small class="field-help">Encrypted fields cannot be searched by value.</small>
                <GnButton
                  size="sm"
                  variant="danger"
                  :disabled="formFields.length === 1"
                  @click="removeCreateField(index)"
                >
                  <span class="button-content">
                    <i class="ph ph-trash" aria-hidden="true"></i>
                    Remove
                  </span>
                </GnButton>
              </div>
            </div>
          </div>
        </section>

        <section class="modal-section">
          <div class="create-access-grid">
            <div class="access-option">
              <GnCheckbox v-model="form.allow_ui" label="UI" />
              <small class="field-help">Available from the web interface after login.</small>
            </div>
            <div class="access-option">
              <GnCheckbox v-model="form.allow_rest_api" label="REST API" />
              <small class="field-help">Available to external clients using API tokens.</small>
            </div>
            <div class="access-option">
              <GnCheckbox v-model="form.allow_mcp" label="MCP" />
              <small class="field-help">Available to MCP clients when token scopes allow it.</small>
            </div>
          </div>
        </section>
      </div>
      <template #footer>
        <GnButton @click="showCreateModal = false">Cancel</GnButton>
        <GnButton variant="success" :disabled="!canCreateSecret" @click="createSecret">
          <span class="button-content">
            <i class="ph ph-check-circle" aria-hidden="true"></i>
            Create
          </span>
        </GnButton>
      </template>
    </GnModal>

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

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