<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import {
GnBadge,
GnButton,
GnCheckbox,
GnInput,
GnModal,
GnTabs,
GnTextarea
} from "gnexus-ui-kit/vue";
import { api } from "./api";
const tabs = [
{ 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 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 form = reactive({
title: "",
purpose: "",
category: "",
source: "",
notes: "",
tags: "",
allow_ui: true,
allow_rest_api: true,
allow_mcp: false,
fieldsText: "username=\npassword=*"
});
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 activeSecrets = computed(() => secrets.value.filter((secret) => !secret.archived).length);
const mcpSecrets = computed(() => secrets.value.filter((secret) => secret.allow_mcp).length);
function parseFields() {
return parseFieldsText(form.fieldsText);
}
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 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() {
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: parseFields()
});
form.title = "";
form.purpose = "";
form.category = "";
form.source = "";
form.notes = "";
form.tags = "";
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();
}
onMounted(async () => {
try {
me.value = await api.me();
await loadSecrets();
} catch {
window.location.href = "/auth/login";
}
});
</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>
<span>{{ loading ? "Loading" : `${total} records` }}</span>
</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 form on the right.</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>
<aside class="panel form-panel">
<div class="panel-title">
<h2>Create secret</h2>
</div>
<div class="form-grid">
<GnInput v-model="form.title" label="Title" />
<GnInput v-model="form.purpose" label="Purpose" />
<GnInput v-model="form.category" label="Category" />
<GnInput v-model="form.tags" label="Tags" placeholder="comma,separated" />
<GnTextarea v-model="form.fieldsText" label="Fields" rows="6" />
<GnInput v-model="form.source" label="Source" />
<GnInput v-model="form.notes" label="Notes" maxlength="140" />
<div class="checks">
<GnCheckbox v-model="form.allow_ui" label="UI" />
<GnCheckbox v-model="form.allow_rest_api" label="REST API" />
<GnCheckbox v-model="form.allow_mcp" label="MCP" />
</div>
</div>
<GnButton variant="primary" @click="createSecret">Create</GnButton>
</aside>
</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>
<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>