<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import {
GnActionList,
GnActivityLog,
GnBadge,
GnButton,
GnCheckbox,
GnDescriptionList,
GnDropdown,
GnEmptyState,
GnFileUpload,
GnIconButton,
GnInput,
GnMetricCard,
GnPageHeader,
GnSearchField,
GnTable,
GnTabs,
GnTextarea,
GnToastProvider,
GnToolbar
} from "gnexus-ui-kit/vue";
import { api } from "./api";
import GnModal from "./components/GnModal.vue";
const baseTabs = [
{ id: "secrets", label: "Secrets" },
{ id: "history", label: "History" },
{ id: "audit", label: "Audit" },
{ id: "tokens", label: "Tokens" },
{ id: "settings", label: "Settings" }
];
const tabRoutes = {
secrets: "/secrets",
history: "/history",
audit: "/audit",
tokens: "/tokens",
settings: "/settings",
admin: "/admin"
};
const routeTabs = Object.fromEntries(Object.entries(tabRoutes).map(([tab, path]) => [path, tab]));
const activeTab = ref("secrets");
let suppressNextRouteSync = false;
const me = ref(null);
const loading = ref(false);
const error = ref("");
const toastProvider = ref(null);
const secrets = ref([]);
const total = ref(0);
const query = ref("");
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 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 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 secretRows = computed(() =>
secrets.value.map((secret) => ({
id: secret.id,
title: secret.title,
purpose: secret.purpose || "Not set",
category: secret.category || "uncategorized",
status: secret.status,
raw: secret
}))
);
const secretColumns = [
{ key: "title", label: "Title" },
{ key: "purpose", label: "Purpose" },
{ key: "category", label: "Category" },
{ key: "status", label: "Status" },
{ key: "actions", label: "" }
];
const 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 revealedFieldItems = computed(() =>
(revealed.value?.fields || []).map((field, index) => ({
key: `${field.name}:${index}`,
title: field.name,
subtitle: field.encrypted ? "encrypted" : "plain",
raw: field
}))
);
const secretActionItems = computed(() => {
if (!selected.value) return [];
return [
{ label: "Reveal", icon: "ph ph-eye", onSelect: () => reveal(selected.value) },
{ label: "Versions", icon: "ph ph-clock-counter-clockwise", onSelect: () => loadVersions(selected.value) },
{ label: "Edit fields", icon: "ph ph-list-plus", onSelect: () => toggleEditFields() },
{
label: `Mark ${selected.value.status === "actual" ? "outdated" : "actual"}`,
icon: "ph ph-arrows-clockwise",
onSelect: () => setStatus(selected.value.status === "actual" ? "outdated" : "actual")
},
{ label: "Archive", icon: "ph ph-archive", onSelect: () => archiveSelected() },
{ label: "Delete", icon: "ph ph-trash", danger: true, onSelect: () => confirmDeleteSecret(selected.value) }
];
});
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 canUseTab(tab) {
if (tab === "admin") {
return me.value?.role === "admin";
}
return baseTabs.some((item) => item.id === tab);
}
function routeToTab(pathname = window.location.pathname) {
return routeTabs[pathname] || (pathname === "/" ? "secrets" : null);
}
function setRouteForTab(tab, replace = false) {
const path = tabRoutes[tab] || tabRoutes.secrets;
if (window.location.pathname === path) return;
const method = replace ? "replaceState" : "pushState";
window.history[method]({ tab }, "", path);
}
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);
}
}
function onPopState() {
syncTabFromRoute(true);
}
function serializeFields(fields) {
return fields
.map((field) => `${field.name}=${field.encrypted ? "*" : ""}${field.value || ""}`)
.join("\n");
}
function fillEditForm(secret) {
if (!secret) return;
editForm.title = secret.title || "";
editForm.purpose = secret.purpose || "";
editForm.category = secret.category || "";
editForm.source = secret.source || "";
editForm.notes = secret.notes || "";
editForm.tags = secret.tags?.join(", ") || "";
editForm.status = secret.status || "actual";
editForm.archived = Boolean(secret.archived);
editForm.allow_ui = Boolean(secret.allow_ui);
editForm.allow_rest_api = Boolean(secret.allow_rest_api);
editForm.allow_mcp = Boolean(secret.allow_mcp);
editForm.fieldsText = serializeFields(secret.fields || []);
}
function selectSecret(secret) {
selected.value = secret;
secretsView.value = "detail";
revealed.value = null;
revealedVersion.value = null;
editing.value = false;
editFields.value = false;
fillEditForm(secret);
}
function showSecretsList() {
secretsView.value = "list";
}
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, 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;
secretsView.value = "list";
revealed.value = null;
await loadSecrets();
}
async function reveal(secret) {
revealed.value = await api.revealSecret(secret.id);
revealedVisibleFields.value = new Set();
}
async function copyValue(value) {
if (value) {
await navigator.clipboard.writeText(value);
showCopyToast();
}
}
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(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);
showCopyToast();
}
}
function showCopyToast() {
toastProvider.value?.success({
title: "Copied",
text: "Value copied to clipboard.",
lifetime: 1800
});
}
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();
syncTabFromRoute(true);
window.addEventListener("popstate", onPopState);
await loadSecrets();
} catch {
window.location.href = "/auth/login";
}
});
onBeforeUnmount(() => {
window.removeEventListener("popstate", onPopState);
});
watch(activeTab, (tab) => {
if (!canUseTab(tab)) {
activeTab.value = "secrets";
return;
}
if (suppressNextRouteSync) {
suppressNextRouteSync = false;
} else {
setRouteForTab(tab);
}
if (tab === "admin" && me.value?.role === "admin" && !adminUsers.value.length) {
loadAdminUsers();
}
if (tab === "admin" && me.value?.role === "admin") {
loadDiagnostics();
}
});
</script>
<template>
<GnToastProvider ref="toastProvider">
<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" />
<section class="workspace-surface search-surface" aria-label="Search">
<GnToolbar title="Search">
<template #actions>
<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>
</template>
</GnToolbar>
</section>
</section>
<section v-if="activeTab === 'secrets'" class="secrets-page">
<section v-if="secretsView === 'list'" class="workspace-surface list-panel">
<p v-if="error" class="error">{{ error }}</p>
<GnToolbar title="Secrets" :meta="loading ? 'Loading' : `${total} records`">
<template #actions>
<GnButton size="sm" variant="success" icon="ph ph-plus-circle" @click="openCreateModal">
Create
</GnButton>
</template>
</GnToolbar>
<GnTable
v-if="secrets.length"
:columns="secretColumns"
:rows="secretRows"
class="secrets-table"
empty-text="No secrets yet"
>
<template #cell-title="{ row }">
<strong :class="{ 'selected-secret-title': selected?.id === row.id }">{{ row.title }}</strong>
</template>
<template #cell-status="{ row }">
<GnBadge :variant="row.status === 'actual' ? 'success' : 'warning'">
{{ row.status }}
</GnBadge>
</template>
<template #cell-actions="{ row }">
<GnIconButton icon="ph ph-arrow-square-out" label="Open secret" @click="selectSecret(row.raw)" />
</template>
</GnTable>
<GnEmptyState
v-if="!secrets.length"
title="No secrets yet"
text="Create the first record using the Create button."
icon="ph ph-vault"
/>
</section>
<section v-else class="workspace-surface detail-panel">
<GnToolbar :title="selected?.title || 'No secret selected'">
<template #actions>
<div v-if="selected" class="card-title-actions">
<GnButton size="sm" icon="ph ph-arrow-left" @click="showSecretsList">Back</GnButton>
<GnBadge :variant="selected.status === 'actual' ? 'success' : 'warning'">
{{ selected.status }}
</GnBadge>
<GnIconButton icon="ph ph-pencil-simple" label="Edit metadata" @click="openEditMetadata" />
<GnDropdown :items="secretActionItems">
<template #trigger="{ toggle }">
<GnIconButton icon="ph ph-dots-three-outline" label="Secret actions" @click="toggle" />
</template>
</GnDropdown>
</div>
</template>
</GnToolbar>
<template v-if="selected">
<GnDescriptionList :items="selectedDetails" />
<section v-if="editFields" class="workspace-surface inset-surface">
<GnToolbar title="Edit fields" />
<GnTextarea
v-model="editForm.fieldsText"
label="Fields"
icon="ph ph-brackets-curly"
rows="8"
/>
<div class="actions">
<GnButton variant="primary" icon="ph ph-floppy-disk" @click="saveSecretFields">
Save new version
</GnButton>
<GnButton variant="secondary" @click="editFields = false">Cancel</GnButton>
</div>
</section>
<section v-if="revealed" class="workspace-surface inset-surface">
<GnToolbar title="Revealed fields" />
<GnActionList :items="revealedFieldItems" class="revealed-fields-list">
<template #controls="{ item }">
<code class="secret-value">
{{ isRevealedFieldVisible(item.key) ? item.raw.value : maskValue(item.raw.value) }}
</code>
<GnIconButton
:icon="isRevealedFieldVisible(item.key) ? 'ph ph-eye-slash' : 'ph ph-eye'"
:label="isRevealedFieldVisible(item.key) ? 'Hide field value' : 'Show field value'"
@click="toggleRevealedField(item.key)"
/>
<GnIconButton
icon="ph ph-copy"
label="Copy field value"
@click="copyValue(item.raw.value)"
/>
</template>
</GnActionList>
</section>
</template>
<GnEmptyState
v-else
class="large"
title="Select a secret"
text="Metadata is shown by default. Values are available through reveal."
icon="ph ph-lock-key"
/>
</section>
</section>
<section v-if="activeTab === 'history'" class="panel-stack">
<section class="workspace-surface">
<GnToolbar title="Version history" :meta="selected?.title || 'Select a secret first from the Secrets tab'">
<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>
<section v-if="revealedVersion" class="workspace-surface inset-surface">
<GnToolbar :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>
</section>
</section>
</section>
<section v-if="activeTab === 'audit'" class="panel-stack">
<section class="workspace-surface">
<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>
</section>
</section>
<section v-if="activeTab === 'tokens'" class="panel-stack">
<section class="workspace-surface token-panel">
<GnToolbar title="API tokens">
<template #actions>
<GnButton icon="ph ph-arrows-clockwise" @click="loadTokens">Refresh</GnButton>
</template>
</GnToolbar>
<section class="workspace-surface inset-surface">
<GnToolbar 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>
<GnButton variant="primary" icon="ph ph-plus-circle" @click="createApiToken">
Create token
</GnButton>
</section>
<section v-if="newToken" class="workspace-surface inset-surface">
<GnToolbar title="New token" />
<GnTextarea :model-value="newToken.token" label="Token" icon="ph ph-key" readonly />
<GnButton icon="ph ph-copy" @click="copyText(newToken.token)">Copy token</GnButton>
</section>
<GnActionList :items="tokenItems">
<template #controls="{ item }">
<GnButton size="sm" variant="danger" icon="ph ph-trash" @click="revokeToken(item.raw)">
Revoke
</GnButton>
</template>
</GnActionList>
</section>
</section>
<section v-if="activeTab === 'settings'" class="panel-stack">
<section class="workspace-surface">
<GnToolbar 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>
</section>
</section>
<section v-if="activeTab === 'admin' && me?.role === 'admin'" class="panel-stack admin-stack">
<section class="workspace-surface">
<GnToolbar title="Users" :meta="`${adminUsersTotal} records`">
<template #actions>
<GnButton icon="ph ph-arrows-clockwise" @click="loadAdminUsers">Refresh</GnButton>
</template>
</GnToolbar>
<GnDescriptionList :items="diagnosticsItems" compact>
<template #health>
<GnBadge :variant="diagnostics.health === 'ok' ? 'success' : 'danger'">
{{ diagnostics.health }}
</GnBadge>
</template>
<template #ready>
<GnBadge :variant="diagnostics.ready === 'ok' ? 'success' : 'danger'">
{{ diagnostics.ready }}
</GnBadge>
</template>
</GnDescriptionList>
<GnButton size="sm" icon="ph ph-heartbeat" @click="loadDiagnostics">Check diagnostics</GnButton>
<GnTable :columns="adminColumns" :rows="adminUsers" empty-text="No users loaded">
<template #cell-user="{ row }">
<span class="identity-cell">
<strong>{{ row.email }}</strong>
<small>{{ row.display_name || row.id }}</small>
</span>
</template>
<template #cell-status="{ value }">
<GnBadge :variant="value === 'enabled' ? 'success' : 'danger'">{{ value }}</GnBadge>
</template>
<template #cell-role="{ value }">
<GnBadge :variant="value === 'admin' ? 'warning' : 'info'">{{ value }}</GnBadge>
</template>
</GnTable>
</section>
</section>
<GnModal v-model:open="showCreateModal" title="Create secret">
<div class="create-modal-body">
<section class="modal-section">
<div class="form-grid two">
<GnInput
v-model="form.title"
label="Title"
icon="ph ph-text-t"
help="Human-readable name shown in lists and search results."
/>
<GnInput
v-model="form.purpose"
label="Purpose"
icon="ph ph-target"
help="Target service, server, account, or other secret destination."
/>
<GnInput
v-model="form.category"
label="Category"
icon="ph ph-folder"
help="Single grouping value for browsing, for example work or banking."
/>
<GnInput
v-model="form.tags"
label="Tags"
icon="ph ph-tag"
placeholder="comma,separated"
help="Keywords used by search. Separate multiple tags with commas."
/>
<GnInput
v-model="form.source"
label="Source"
icon="ph ph-signpost"
help="Where this secret came from: person, import, ticket, or note."
/>
<GnTextarea
v-model="form.notes"
label="Notes"
icon="ph ph-note-pencil"
rows="3"
maxlength="140"
help="Short context for the secret. Limited to 140 characters."
/>
</div>
</section>
<section class="modal-section">
<GnToolbar title="Fields" meta="One row per secret value">
<template #actions>
<GnButton size="sm" variant="secondary" icon="ph ph-plus" @click="addCreateField">
Add field
</GnButton>
</template>
</GnToolbar>
<p class="input-info modal-help">
Add one row per secret value. Enable encryption for passwords, tokens, PINs, and keys.
</p>
<div class="create-fields">
<div v-for="(field, index) in formFields" :key="field.id" class="create-field-row">
<GnInput
v-model="field.name"
label="Field name"
icon="ph ph-brackets-curly"
help="Name copied into API and MCP responses, for example username."
/>
<GnInput
v-model="field.value"
label="Value"
icon="ph ph-key"
help="Actual value stored for this field."
/>
<div class="field-row-actions">
<GnCheckbox v-model="field.encrypted" label="Encrypt" />
<GnIconButton
icon="ph ph-trash"
label="Remove field"
:disabled="formFields.length === 1"
@click="removeCreateField(index)"
/>
</div>
</div>
</div>
</section>
<section class="modal-section">
<GnToolbar title="Access" meta="Global channels for this secret" />
<div class="create-access-grid">
<div class="access-option">
<GnCheckbox v-model="form.allow_ui" label="UI" />
<div class="input-info">Available from the web interface after login.</div>
</div>
<div class="access-option">
<GnCheckbox v-model="form.allow_rest_api" label="REST API" />
<div class="input-info">Available to external clients using API tokens.</div>
</div>
<div class="access-option">
<GnCheckbox v-model="form.allow_mcp" label="MCP" />
<div class="input-info">Available to MCP clients when token scopes allow it.</div>
</div>
</div>
</section>
</div>
<template #actions>
<GnButton variant="secondary" @click="showCreateModal = false">Cancel</GnButton>
<GnButton variant="success" icon="ph ph-check-circle" :disabled="!canCreateSecret" @click="createSecret">
Create
</GnButton>
</template>
</GnModal>
<GnModal v-model:open="editing" title="Edit metadata" @close="cancelEditMetadata">
<div v-if="selected" class="create-modal-body">
<section class="modal-section">
<div class="form-grid two">
<GnInput v-model="editForm.title" label="Title" icon="ph ph-text-t" />
<GnInput v-model="editForm.purpose" label="Purpose" icon="ph ph-target" />
<GnInput v-model="editForm.category" label="Category" icon="ph ph-folder" />
<GnInput v-model="editForm.tags" label="Tags" icon="ph ph-tag" />
<GnInput v-model="editForm.source" label="Source" icon="ph ph-signpost" />
<GnInput v-model="editForm.notes" label="Notes" icon="ph ph-note-pencil" maxlength="140" />
</div>
</section>
<section class="modal-section">
<GnToolbar title="Status" meta="Metadata shown in lists and filters" />
<div class="controls-line">
<GnButton
:variant="editForm.status === 'actual' ? 'primary' : 'secondary'"
@click="editForm.status = 'actual'"
>
Actual
</GnButton>
<GnButton
:variant="editForm.status === 'outdated' ? 'primary' : 'secondary'"
@click="editForm.status = 'outdated'"
>
Outdated
</GnButton>
<GnCheckbox v-model="editForm.archived" label="Archived" />
</div>
</section>
<section class="modal-section">
<GnToolbar title="Access" meta="Global availability channels for this secret" />
<div class="access-grid">
<div class="access-option">
<GnCheckbox v-model="editForm.allow_ui" label="UI" />
<div class="input-info">Visible in the user interface.</div>
</div>
<div class="access-option">
<GnCheckbox v-model="editForm.allow_rest_api" label="REST API" />
<div class="input-info">Available to external clients using API tokens.</div>
</div>
<div class="access-option">
<GnCheckbox v-model="editForm.allow_mcp" label="MCP" />
<div class="input-info">Available to MCP clients when token scopes allow it.</div>
</div>
</div>
</section>
</div>
<template #actions>
<GnButton variant="secondary" @click="cancelEditMetadata">Cancel</GnButton>
<GnButton variant="primary" icon="ph ph-floppy-disk" @click="saveSecretMetadata">
Save metadata
</GnButton>
</template>
</GnModal>
<GnModal v-model:open="showDeleteConfirm" title="Delete all data">
<p>This permanently removes all secrets and versions. Audit records remain.</p>
<template #actions>
<GnButton variant="secondary" @click="showDeleteConfirm = false">Cancel</GnButton>
<GnButton variant="danger" @click="deleteAllData">Delete</GnButton>
</template>
</GnModal>
<GnModal :open="Boolean(pendingDeleteSecret)" title="Delete secret" @close="pendingDeleteSecret = null">
<p>
This permanently deletes
<strong>{{ pendingDeleteSecret?.title }}</strong>
and all its versions. Audit records remain.
</p>
<template #actions>
<GnButton variant="secondary" @click="pendingDeleteSecret = null">Cancel</GnButton>
<GnButton variant="danger" @click="deleteSelectedSecret">Delete</GnButton>
</template>
</GnModal>
</main>
</GnToastProvider>
</template>