<script setup>
import { computed, h, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import {
GnAlert,
GnBadge,
GnButton,
GnChip,
GnChipGroup,
GnConfirmDialog,
GnCopyButton,
GnDescriptionList,
GnDropdown,
GnEmptyState,
GnFileUpload,
GnIconButton,
GnIdentity,
GnInput,
GnLoader,
GnMetricCard,
GnModal,
GnNavigationShell,
GnSearchField,
GnSelect,
GnSkeleton,
GnStatusCard,
GnSwitch,
GnTable,
GnTagInput,
GnTextarea,
GnTimeline,
GnToastProvider,
GnToolbar,
GnTooltip,
GnUserCard
} from "gnexus-ui-kit/vue";
import { api } from "./api";
import SecretDetailPanel from "./components/SecretDetailPanel.vue";
import { setLocale, t } from "./i18n";
import {
assertGate,
clearStore,
enrollGate,
gateState,
readStore as bioGateStore,
refreshGateState
} from "./biometric";
// Ellipsised pagination: with 20+ pages GnPagination printed every page
// number in an unwrappable row. Same props/emits/looks as the kit's
// GnPagination, but long runs collapse to "1 2 3 … 22".
const AppPagination = {
props: {
page: { type: Number, required: true },
totalPages: { type: Number, required: true },
ariaLabel: { type: String, default: "Pagination" }
},
emits: ["update:page"],
setup(props, { emit }) {
const setPage = (page) => {
if (page >= 1 && page <= props.totalPages && page !== props.page) {
emit("update:page", page);
}
};
return () => {
const pages = paginationRange(props.page, props.totalPages);
return h("nav", { class: "pagination", "aria-label": props.ariaLabel }, [
h("button", {
class: "pagination-item",
type: "button",
disabled: props.page <= 1,
onClick: () => setPage(props.page - 1)
}, [h("i", { class: "ph ph-arrow-left" })]),
pages.map((page) =>
page === "…"
? h("span", { class: "pagination-ellipsis" }, "…")
: h("button", {
class: ["pagination-item", { "pagination-item-active": page === props.page }],
type: "button",
"aria-current": page === props.page ? "page" : undefined,
onClick: () => setPage(page)
}, String(page))
),
h("button", {
class: "pagination-item",
type: "button",
disabled: props.page >= props.totalPages,
onClick: () => setPage(props.page + 1)
}, [h("i", { class: "ph ph-arrow-right" })])
]);
};
}
};
function paginationRange(page, totalPages) {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, index) => index + 1);
}
const shown = new Set([1, totalPages, page - 1, page, page + 1]);
const sorted = [...shown].filter((p) => p >= 1 && p <= totalPages).sort((a, b) => a - b);
const range = [];
let previous = 0;
for (const p of sorted) {
if (p - previous > 1) range.push("…");
range.push(p);
previous = p;
}
return range;
}
const baseTabs = [
{ id: "secrets", label: "Secrets" },
{ id: "history", label: "History" },
{ id: "audit", label: "Audit" },
{ id: "tokens", label: "Tokens" },
{ id: "extension", label: "Extension" },
{ id: "settings", label: "Settings" }
];
const tabRoutes = {
secrets: "/secrets",
history: "/history",
audit: "/audit",
tokens: "/tokens",
extension: "/extension",
settings: "/settings",
admin: "/admin"
};
const routeTabs = Object.fromEntries(Object.entries(tabRoutes).map(([tab, path]) => [path, tab]));
const activeTab = ref("secrets");
let suppressNextRouteSync = false;
const me = ref(null);
const loading = ref(false);
const error = ref("");
const toastProvider = ref(null);
const secrets = ref([]);
const total = ref(0);
const query = ref("");
const secretsPage = ref(1);
const secretsLimit = 20;
const secretsSort = ref({ by: "updated_at", dir: "desc" });
const selected = ref(null);
const secretsView = ref("list");
const revealed = ref(null);
const revealedVisibleFields = ref(new Set());
const versions = ref([]);
const revealedVersion = ref(null);
const auditEvents = ref([]);
const auditTotal = ref(0);
const auditLoading = ref(false);
const auditMode = ref("all");
const tokens = ref([]);
const tokensLoading = ref(false);
const newToken = ref(null);
const showCreateModal = ref(false);
const showCreateTokenModal = ref(false);
const showDeleteConfirm = ref(false);
const editing = ref(false);
const editFields = ref(false);
const pendingDeleteSecret = ref(null);
const pendingRevokeToken = ref(null);
const showRevokeConfirm = ref(false);
const importFiles = ref([]);
const importFile = ref(null);
const importPreview = ref(null);
const importError = ref("");
const adminUsers = ref([]);
const adminUsersTotal = ref(0);
const adminUsersPage = ref(1);
const adminUsersLimit = 20;
const auditPage = ref(1);
const auditLimit = 20;
const stats = reactive({
total_secrets: 0,
active_secrets: 0,
mcp_enabled_secrets: 0
});
const diagnostics = reactive({
health: "unknown",
ready: "unknown"
});
const backups = ref([]);
const backupsLoading = ref(false);
const showRestoreConfirm = ref(false);
const pendingRestoreFile = ref(null);
const extensionMeta = ref(null);
const extensionLoading = ref(false);
const extensionError = ref("");
const form = reactive({
title: "",
purpose: "",
category: "",
source: "",
notes: "",
tags: [],
allow_ui: true,
allow_rest_api: true,
allow_mcp: false
});
const formFields = ref(defaultCreateFields());
const editForm = reactive({
title: "",
purpose: "",
category: "",
source: "",
notes: "",
tags: [],
status: "actual",
archived: false,
allow_ui: true,
allow_rest_api: true,
allow_mcp: false,
fieldsText: ""
});
const tokenForm = reactive({
name: "MCP client",
read: true,
reveal: true,
write: true,
admin: false,
mcp: true
});
// Auto ("") = follow the auth account; the service stores its own otherwise
const localeOptions = computed(() => [
{ value: "", label: t("profile.languageAuto") },
{ value: "en", label: t("profile.langEn") },
{ value: "uk", label: t("profile.langUk") },
{ value: "ru", label: t("profile.langRu") }
]);
const navItems = computed(() => {
const items = [
{ id: "secrets", label: t("nav.secrets"), icon: "ph-vault" },
{ id: "history", label: t("nav.history"), icon: "ph-clock-counter-clockwise" },
{ id: "audit", label: t("nav.audit"), icon: "ph-list-checks" },
{ id: "tokens", label: t("nav.tokens"), icon: "ph-key" },
{ id: "extension", label: t("nav.extension"), icon: "ph-puzzle-piece" },
{ id: "settings", label: t("nav.settings"), icon: "ph-gear" }
];
if (me.value?.role === "admin") {
items.push({ id: "admin", label: t("nav.admin"), icon: "ph-user-gear" });
}
return items.map((item) => ({ ...item, active: activeTab.value === item.id }));
});
const currentNavLabel = computed(() => {
const item = navItems.value.find((i) => i.id === activeTab.value);
return item?.label || t("nav.secrets");
});
const pageTitle = computed(() => {
const label = currentNavLabel.value;
const secret = selected.value?.title;
if (activeTab.value === "secrets" && secret) {
return `${secret} · ${label} · gnexus-creds`;
}
if ((activeTab.value === "history" || activeTab.value === "audit") && secret) {
return `${label} · ${secret} · gnexus-creds`;
}
return `${label} · gnexus-creds`;
});
const userInitials = computed(() => {
const name = me.value?.display_name || me.value?.email || "";
return name
.split(/[\s._-]+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => word[0].toUpperCase())
.join("");
});
function onNavSelect(item) {
if (item.id === "audit") {
auditMode.value = "all";
}
activeTab.value = item.id;
}
function onPanelNavigate(target) {
if (target === "history") {
activeTab.value = "history";
} else if (target === "audit") {
auditMode.value = "secret";
activeTab.value = "audit";
if (selected.value) {
suppressNextRouteSync = true;
setRouteForAudit(selected.value.id);
}
} else if (target === "edit-fields") {
toggleEditFields();
}
}
async function logout() {
try {
await api.logout();
} catch {
// ignore
}
// navi-style: reload so /me answers 401 and the SPA shows its internal
// login screen; going to /auth/login here would silently auto-login
// through the still-alive SSO session
window.location.reload();
}
// 'checking' keeps a splash on screen until /me answers, so the empty shell
// never flashes before the sign-in gate appears; shell renders on success
const authState = ref("checking");
function loginNow() {
// explicit user click starts OAuth — no silent SSO bounce on load
window.location.href = "/auth/login";
}
const canCreateSecret = computed(() => Boolean(form.title.trim() && parseCreateFields().length));
const sortItems = computed(() => [
{ label: t("secrets.sortUpdatedNewest"), value: { by: "updated_at", dir: "desc" } },
{ label: t("secrets.sortUpdatedOldest"), value: { by: "updated_at", dir: "asc" } },
{ label: t("secrets.sortTitleAz"), value: { by: "title", dir: "asc" } },
{ label: t("secrets.sortTitleZa"), value: { by: "title", dir: "desc" } },
{ label: t("secrets.sortStatus"), value: { by: "status", dir: "asc" } }
]);
const secretRows = computed(() =>
secrets.value.map((secret) => ({
id: secret.id,
title: secret.title,
purpose: secret.purpose || t("common.notSet"),
category: secret.category || t("common.uncategorized"),
status: secret.status,
raw: secret
}))
);
const secretColumns = computed(() => [
{ key: "title", label: t("col.title") },
{ key: "purpose", label: t("col.purpose") },
{ key: "category", label: t("col.category") },
{ key: "status", label: t("col.status") },
{ key: "actions", label: "" }
]);
// created_at values arrive as UTC ISO strings; render the server timestamp
// as local "YYYY-MM-DD HH:mm" — the kit timeline draws it in a narrow
// .timeline-time, where a compact fixed format reads better than Locale
const formatTimestamp = (iso) => {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
const pad = (n) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
const versionItems = computed(() =>
versions.value.map((version) => ({
key: String(version.id),
title: t("history.version", { n: version.version_number }),
time: formatTimestamp(version.created_at),
text: t("history.fields", { n: version.fields.length }),
icon: "ph-clock-counter-clockwise",
raw: version
}))
);
const auditRows = computed(() =>
auditEvents.value.map((event) => ({
id: event.id,
time: formatTimestamp(event.created_at),
action: event.action,
channel: event.channel,
ip: event.ip_address || "—",
metadataItems: formatAuditMetadata(event.metadata),
raw: event
}))
);
const tokenItems = computed(() =>
tokens.value
.filter((token) => !token.revoked_at)
.map((token) => ({
key: token.public_id,
name: token.name,
public_id: token.public_id,
scopes: token.scopes,
created_at: formatTimestamp(token.created_at),
last_used_at: token.last_used_at ? formatTimestamp(token.last_used_at) : "—",
raw: token
}))
);
const tokenColumns = computed(() => [
{ key: "name", label: t("col.name") },
{ key: "public_id", label: t("col.publicId") },
{ key: "scopes", label: t("col.scopes") },
{ key: "created_at", label: t("col.created") },
{ key: "last_used_at", label: t("col.lastUsed") },
{ key: "actions", label: "" }
]);
const diagnosticsItems = computed(() => [
{ label: "/health", key: "health", value: diagnostics.health },
{ label: "/ready", key: "ready", value: diagnostics.ready }
]);
const adminColumns = computed(() => [
{ key: "user", label: t("col.user") },
{ key: "status", label: t("col.status") },
{ key: "role", label: t("col.role") },
{ key: "last_seen_at", label: t("col.lastSeen") }
]);
const auditColumns = computed(() => [
{ key: "time", label: t("col.time") },
{ key: "action", label: t("col.action") },
{ key: "channel", label: t("col.channel") },
{ key: "ip", label: t("col.ip") },
{ key: "metadata", label: t("col.metadata") }
]);
const backupColumns = computed(() => [
{ key: "filename", label: t("col.file") },
{ key: "size", label: t("col.size") },
{ key: "created_at", label: t("col.created") },
{ key: "actions", label: "" }
]);
function formatAuditMetadata(meta) {
if (!meta || typeof meta !== "object") return [];
function formatValue(value) {
if (value === null || value === undefined) return "";
if (typeof value === "object" && "old" in value && "new" in value) {
const oldVal = JSON.stringify(value.old);
const newVal = JSON.stringify(value.new);
return `${oldVal} → ${newVal}`;
}
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
const result = [];
for (const [label, value] of Object.entries(meta)) {
if (label === "diff" && value && typeof value === "object") {
for (const [field, change] of Object.entries(value)) {
result.push({ label: field, value: formatValue(change) });
}
} else {
result.push({ label, value: formatValue(value) });
}
}
return result;
}
function parseFieldsText(value) {
return value
.split("\n")
.map((line, index) => {
const [rawName, ...rest] = line.split("=");
const name = rawName.trim();
if (!name) return null;
const rawValue = rest.join("=");
const encrypted = rawValue.startsWith("*");
return {
name,
value: encrypted ? rawValue.slice(1) : rawValue,
encrypted,
masked: encrypted,
position: index
};
})
.filter(Boolean);
}
function defaultCreateFields() {
return [
{ id: crypto.randomUUID(), name: "username", value: "", encrypted: false },
{ id: crypto.randomUUID(), name: "password", value: "", encrypted: true }
];
}
function openCreateModal() {
showCreateModal.value = true;
}
function addCreateField() {
formFields.value.push({
id: crypto.randomUUID(),
name: "",
value: "",
encrypted: true
});
}
function removeCreateField(index) {
if (formFields.value.length === 1) return;
formFields.value.splice(index, 1);
}
function parseCreateFields() {
return formFields.value
.map((field, index) => {
const name = field.name.trim();
if (!name) return null;
return {
name,
value: field.value,
encrypted: Boolean(field.encrypted),
masked: Boolean(field.encrypted),
position: index
};
})
.filter(Boolean);
}
function resetCreateForm() {
form.title = "";
form.purpose = "";
form.category = "";
form.source = "";
form.notes = "";
form.tags = [];
form.allow_ui = true;
form.allow_rest_api = true;
form.allow_mcp = false;
formFields.value = defaultCreateFields();
}
function canUseTab(tab) {
if (tab === "admin") {
return me.value?.role === "admin";
}
return baseTabs.some((item) => item.id === tab);
}
function routeToTab(pathname = window.location.pathname) {
if (pathname.startsWith("/secrets/")) {
return "secrets";
}
if (pathname.startsWith("/audit/")) {
return "audit";
}
return routeTabs[pathname] || (pathname === "/" ? "secrets" : null);
}
function routeSecretId(pathname = window.location.pathname) {
const match = pathname.match(/^\/secrets\/([^/]+)$/);
return match ? decodeURIComponent(match[1]) : null;
}
function routeAuditSecretId(pathname = window.location.pathname) {
const match = pathname.match(/^\/audit\/([^/]+)$/);
return match ? decodeURIComponent(match[1]) : null;
}
function setRoute(path, replace = false, state = {}) {
if (window.location.pathname === path) return;
const method = replace ? "replaceState" : "pushState";
window.history[method](state, "", path);
}
function setRouteForTab(tab, replace = false) {
const path = tabRoutes[tab] || tabRoutes.secrets;
setRoute(path, replace, { tab });
}
function setRouteForSecret(secretId, replace = false) {
setRoute(`/secrets/${encodeURIComponent(secretId)}`, replace, { tab: "secrets", secretId });
}
function setRouteForAudit(secretId, replace = false) {
const path = secretId ? `/audit/${encodeURIComponent(secretId)}` : "/audit";
setRoute(path, replace, { tab: "audit", secretId });
}
async function syncTabFromRoute(replace = false) {
const routedTab = routeToTab();
const nextTab = routedTab && canUseTab(routedTab) ? routedTab : "secrets";
if (activeTab.value !== nextTab) {
suppressNextRouteSync = true;
activeTab.value = nextTab;
}
if (!routedTab || routedTab !== nextTab || window.location.pathname === "/") {
setRouteForTab(nextTab, replace);
}
if (nextTab === "secrets") {
const secretId = routeSecretId();
if (secretId) {
try {
await openSecretById(secretId, replace);
} catch {
selected.value = null;
secretsView.value = "list";
setRouteForTab("secrets", true);
}
} else {
secretsView.value = "list";
}
}
if (nextTab === "audit") {
const auditSecretId = routeAuditSecretId();
if (auditSecretId) {
try {
const cached = secrets.value.find((s) => s.id === auditSecretId);
selected.value = cached || (await api.getSecret(auditSecretId));
auditMode.value = "secret";
} catch {
auditMode.value = "all";
setRouteForAudit(null, true);
}
} else {
auditMode.value = "all";
}
}
}
function onPopState() {
void syncTabFromRoute(true);
}
function serializeFields(fields) {
return fields
.map((field) => `${field.name}=${field.encrypted ? "*" : ""}${field.value || ""}`)
.join("\n");
}
function fillEditForm(secret) {
if (!secret) return;
editForm.title = secret.title || "";
editForm.purpose = secret.purpose || "";
editForm.category = secret.category || "";
editForm.source = secret.source || "";
editForm.notes = secret.notes || "";
editForm.tags = secret.tags || [];
editForm.status = secret.status || "actual";
editForm.archived = Boolean(secret.archived);
editForm.allow_ui = Boolean(secret.allow_ui);
editForm.allow_rest_api = Boolean(secret.allow_rest_api);
editForm.allow_mcp = Boolean(secret.allow_mcp);
editForm.fieldsText = serializeFields(secret.fields || []);
}
function selectSecret(secret) {
selected.value = secret;
secretsView.value = "detail";
revealed.value = null;
revealedVersion.value = null;
editing.value = false;
editFields.value = false;
fillEditForm(secret);
setRouteForSecret(secret.id);
}
async function openSecretById(secretId, replace = false) {
const cached = secrets.value.find((secret) => secret.id === secretId);
const secret = cached || (await api.getSecret(secretId));
selected.value = secret;
secretsView.value = "detail";
revealed.value = null;
revealedVersion.value = null;
editing.value = false;
editFields.value = false;
fillEditForm(secret);
setRouteForSecret(secret.id, replace);
}
function showSecretsList() {
secretsView.value = "list";
setRouteForTab("secrets");
}
function openEditMetadata() {
if (!selected.value) return;
fillEditForm(selected.value);
editing.value = true;
}
function cancelEditMetadata() {
if (selected.value) {
fillEditForm(selected.value);
}
editing.value = false;
}
function toggleEditFields() {
editFields.value = !editFields.value;
}
async function loadSecrets() {
loading.value = true;
error.value = "";
try {
const payload = await api.listSecrets({
q: query.value,
offset: (secretsPage.value - 1) * secretsLimit,
limit: secretsLimit,
sort_by: secretsSort.value.by,
sort_dir: secretsSort.value.dir
});
secrets.value = payload.items;
total.value = payload.total;
if (selected.value) {
selected.value = secrets.value.find((secret) => secret.id === selected.value.id) || null;
} else {
selected.value = secrets.value[0] || null;
}
fillEditForm(selected.value);
revealed.value = null;
} catch (err) {
error.value = err.message;
toastProvider.value?.error({ title: t("common.error"), text: err.message, lifetime: 3000 });
} finally {
loading.value = false;
}
}
async function createSecret() {
const fields = parseCreateFields();
await api.createSecret({
title: form.title,
purpose: form.purpose || null,
category: form.category || null,
source: form.source || null,
notes: form.notes || null,
tags: form.tags,
allow_ui: form.allow_ui,
allow_rest_api: form.allow_rest_api,
allow_mcp: form.allow_mcp,
fields
});
resetCreateForm();
showCreateModal.value = false;
await loadSecrets();
}
async function saveSecretMetadata() {
if (!selected.value) return;
const updated = await api.updateSecret(selected.value.id, {
title: editForm.title,
purpose: editForm.purpose || null,
category: editForm.category || null,
source: editForm.source || null,
notes: editForm.notes || null,
tags: editForm.tags,
status: editForm.status,
archived: editForm.archived,
allow_ui: editForm.allow_ui,
allow_rest_api: editForm.allow_rest_api,
allow_mcp: editForm.allow_mcp
});
selected.value = updated;
editing.value = false;
await loadSecrets();
}
async function saveSecretFields() {
if (!selected.value) return;
const updated = await api.updateSecret(selected.value.id, {
fields: parseFieldsText(editForm.fieldsText)
});
selected.value = updated;
revealed.value = null;
editFields.value = false;
await loadSecrets();
}
async function setStatus(status) {
if (!selected.value) return;
selected.value = await api.updateSecret(selected.value.id, { status });
await loadSecrets();
}
async function archiveSelected() {
if (!selected.value) return;
selected.value = await api.updateSecret(selected.value.id, { archived: true });
await loadSecrets();
}
function confirmDeleteSecret(secret) {
pendingDeleteSecret.value = secret;
}
async function deleteSelectedSecret() {
if (!pendingDeleteSecret.value) return;
await api.deleteSecret(pendingDeleteSecret.value.id);
pendingDeleteSecret.value = null;
selected.value = null;
secretsView.value = "list";
revealed.value = null;
await loadSecrets();
}
async function reveal(secret) {
if (!(await biometryAllowed())) return;
revealed.value = await api.revealSecret(secret.id);
revealedVisibleFields.value = new Set();
}
function hideRevealed() {
revealed.value = null;
}
function maskValue(value) {
return "*".repeat(Math.max(8, String(value || "").length));
}
function isRevealedFieldVisible(key) {
return revealedVisibleFields.value.has(key);
}
function toggleRevealedField(key) {
const next = new Set(revealedVisibleFields.value);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
revealedVisibleFields.value = next;
}
async function loadVersions(secretId) {
revealedVersion.value = null;
versions.value = await api.versions(secretId);
}
async function revealVersion(version) {
if (!selected.value) return;
if (!(await biometryAllowed())) return;
revealedVersion.value = await api.revealVersion(selected.value.id, version.id);
}
function bioGateUserLabel() {
return me.value?.email || me.value?.display_name || "GNEXUS CREDS user";
}
// The one choke point in front of both reveal entry points; a failure of any
// kind (cancelled prompt, tampered claim, deleted passkey) blocks the API call.
async function biometryAllowed() {
const result = await assertGate(bioGateUserLabel());
if (result.ok) return true;
if (result.code === "cancelled") {
toastProvider.value?.info({
title: t("bio.canceled"),
text: t("bio.canceledText"),
lifetime: 2500
});
} else if (result.code === "no-credential") {
toastProvider.value?.error({
title: t("bio.gateTitle"),
text: t("bio.noCredential"),
lifetime: 4000
});
} else if (result.code !== "no-gate") {
toastProvider.value?.error({
title: t("bio.gateTitle"),
text: t("bio.failedText"),
lifetime: 3500
});
}
return false;
}
async function toggleBioGate() {
const store = bioGateStore();
if (store?.enabled) {
// Turning the gate off requires the same biometric proof as a reveal:
// nobody should be able to switch it off from an unattended session.
const proof = await assertGate(bioGateUserLabel());
if (!proof.ok) {
toastProvider.value?.error({
title: t("bio.gateTitle"),
text: proof.code === "cancelled" ? t("bio.stayOnCanceled") : t("bio.stayOnFailed"),
lifetime: 3000
});
return;
}
clearStore();
await refreshGateState();
toastProvider.value?.success({
title: t("bio.saved"),
text: t("bio.disabled"),
lifetime: 2500
});
return;
}
const result = await enrollGate(bioGateUserLabel());
if (!result.ok) {
toastProvider.value?.info({
title: t("bio.gateTitle"),
text:
result.code === "cancelled"
? t("bio.enrollCanceled")
: result.message || t("bio.enrollFailed"),
lifetime: 3500
});
await refreshGateState();
return;
}
await refreshGateState();
toastProvider.value?.success({
title: t("bio.saved"),
text: t("bio.enabled"),
lifetime: 3000
});
}
async function loadAudit() {
auditLoading.value = true;
try {
const params = {
offset: (auditPage.value - 1) * auditLimit,
limit: auditLimit
};
let payload;
if (auditMode.value === "secret" && selected.value) {
payload = await api.secretAudit(selected.value.id, params);
} else {
payload = await api.audit(params);
}
auditEvents.value = payload.items;
auditTotal.value = payload.total;
} catch (err) {
toastProvider.value?.error({ title: t("common.error"), text: err.message, lifetime: 3000 });
} finally {
auditLoading.value = false;
}
}
async function loadTokens() {
tokensLoading.value = true;
try {
tokens.value = await api.tokens();
} catch (err) {
toastProvider.value?.error({ title: t("common.error"), text: err.message, lifetime: 3000 });
} finally {
tokensLoading.value = false;
}
}
async function createApiToken() {
const scopes = ["read", "reveal", "write", "admin", "mcp"].filter((scope) => tokenForm[scope]);
newToken.value = await api.createToken({
name: tokenForm.name || "API token",
scopes
});
await loadTokens();
}
function closeCreateTokenModal() {
newToken.value = null;
showCreateTokenModal.value = false;
}
async function changeLanguage(value) {
try {
// empty/"auto" clears the service-level override; the next /me's
// locale_effective decides the language again (profile -> auth account)
// override and the effective locale falls back to the auth account
const updated = await api.updateMe({ locale: value || "auto" });
me.value = updated;
setLocale(updated.locale_effective);
toastProvider.value?.success({ title: t("bio.saved"), text: t("profile.saved"), lifetime: 2000 });
} catch (err) {
toastProvider.value?.error({ title: t("common.error"), text: err.message, lifetime: 3000 });
}
}
function confirmRevokeToken(token) {
pendingRevokeToken.value = token;
showRevokeConfirm.value = true;
}
async function doRevokeToken() {
if (!pendingRevokeToken.value) return;
await api.revokeToken(pendingRevokeToken.value.id);
pendingRevokeToken.value = null;
showRevokeConfirm.value = false;
await loadTokens();
}
async function exportData() {
const payload = await api.exportData();
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "gnexus-creds-export.json";
link.click();
URL.revokeObjectURL(url);
}
async function handleImportFiles(files) {
importError.value = "";
importPreview.value = null;
const file = files?.[0];
importFile.value = file || null;
if (!file) return;
try {
const text = await file.text();
const payload = JSON.parse(text);
if (payload.format !== "gnexus-creds-export" || payload.version !== 1) {
throw new Error(t("import.unsupportedFile"));
}
importPreview.value = payload;
} catch (err) {
importError.value = err.message;
}
}
async function importData() {
if (!importPreview.value) return;
await api.importData(importPreview.value);
importFiles.value = [];
importFile.value = null;
importPreview.value = null;
await loadSecrets();
}
async function deleteAllData() {
try {
await api.deleteAccountData();
showDeleteConfirm.value = false;
await loadSecrets();
toastProvider.value?.success({ title: t("toast.deleted"), text: t("toast.allDataRemoved"), lifetime: 2000 });
} catch (err) {
toastProvider.value?.error({ title: t("common.error"), text: err.message, lifetime: 3000 });
}
}
async function loadAdminUsers() {
try {
activeTab.value = "admin";
const payload = await api.adminUsers({
offset: (adminUsersPage.value - 1) * adminUsersLimit,
limit: adminUsersLimit
});
adminUsers.value = payload.items;
adminUsersTotal.value = payload.total;
} catch (err) {
toastProvider.value?.error({ title: t("common.error"), text: err.message, lifetime: 3000 });
}
}
async function loadBackups() {
backupsLoading.value = true;
try {
backups.value = await api.listBackups();
} catch (err) {
toastProvider.value?.error({ title: t("common.error"), text: err.message, lifetime: 3000 });
} finally {
backupsLoading.value = false;
}
}
async function createBackup() {
try {
await api.createBackup();
toastProvider.value?.success({ title: t("toast.backup"), text: t("toast.backupCreated"), lifetime: 2000 });
await loadBackups();
} catch (err) {
toastProvider.value?.error({ title: t("common.error"), text: err.message, lifetime: 3000 });
}
}
function downloadBackup(filename) {
window.location.href = api.downloadBackup(filename);
}
function confirmRestore(filename) {
pendingRestoreFile.value = filename;
showRestoreConfirm.value = true;
}
async function restoreBackup() {
if (!pendingRestoreFile.value) return;
try {
await api.restoreBackup(pendingRestoreFile.value);
showRestoreConfirm.value = false;
pendingRestoreFile.value = null;
toastProvider.value?.success({ title: t("toast.restored"), text: t("toast.databaseRestored"), lifetime: 5000 });
} catch (err) {
toastProvider.value?.error({ title: t("common.error"), text: err.message, lifetime: 3000 });
}
}
async function loadDiagnostics() {
const [health, ready] = await Promise.allSettled([api.health(), api.ready()]);
diagnostics.health =
health.status === "fulfilled" ? health.value.status || "ok" : "error";
diagnostics.ready = ready.status === "fulfilled" ? ready.value.status || "ok" : "error";
}
async function loadStats() {
const payload = await api.stats();
stats.total_secrets = payload.total_secrets;
stats.active_secrets = payload.active_secrets;
stats.mcp_enabled_secrets = payload.mcp_enabled_secrets;
}
async function loadExtension() {
if (extensionMeta.value || extensionLoading.value) return;
extensionLoading.value = true;
extensionError.value = "";
try {
extensionMeta.value = await api.extension();
} catch (err) {
extensionError.value = err.message || t("extension.loadFailed");
} finally {
extensionLoading.value = false;
}
}
function formatBytes(bytes) {
if (!bytes && bytes !== 0) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function downloadExtension(filename) {
window.location.href = api.downloadExtension(filename);
}
function goToTokens() {
activeTab.value = "tokens";
}
// Sessions end mid-flight too — a global logout webhook, a TTL, a revoked
// session. Any 401 that api.js sees drops the user onto the sign-in screen
// immediately, without a page reload, so nothing looks like it still works.
const onUnauthenticated = () => {
authState.value = "denied";
// the app shell is unmounting; route-sync handlers have nothing to sync
window.removeEventListener("popstate", onPopState);
};
onMounted(async () => {
window.addEventListener("gncreds:unauthenticated", onUnauthenticated);
try {
me.value = await api.me();
// the backend decides the language: settings override -> auth account.
// The profile modal's switcher can override it for this service only.
setLocale(me.value.locale_effective);
await refreshGateState();
await loadSecrets();
await syncTabFromRoute(true);
window.addEventListener("popstate", onPopState);
authState.value = "ready";
} catch (err) {
if (err?.status === 401) {
authState.value = "denied";
} else {
window.location.href = "/auth/login";
}
}
});
onBeforeUnmount(() => {
window.removeEventListener("popstate", onPopState);
window.removeEventListener("gncreds:unauthenticated", onUnauthenticated);
});
watch(activeTab, (tab) => {
if (!canUseTab(tab)) {
activeTab.value = "secrets";
return;
}
if (suppressNextRouteSync) {
suppressNextRouteSync = false;
} else {
if (tab === "secrets") {
secretsView.value = "list";
}
if (tab === "audit" && auditMode.value === "secret" && selected.value) {
setRouteForAudit(selected.value.id);
} else {
setRouteForTab(tab);
}
}
if (tab === "history" && selected.value) {
loadVersions(selected.value.id);
}
if (tab === "audit") {
if (auditMode.value === "secret" && !selected.value) {
auditMode.value = "all";
}
loadAudit();
}
if (tab === "tokens") {
loadTokens();
}
if (tab === "extension") {
loadExtension();
}
if (tab === "settings") {
loadStats();
}
if (tab === "admin" && me.value?.role === "admin" && !adminUsers.value.length) {
loadAdminUsers();
}
if (tab === "admin" && me.value?.role === "admin") {
loadDiagnostics();
loadBackups();
}
});
watch(pageTitle, (title) => {
document.title = title;
}, { immediate: true });
let searchDebounce = null;
watch(query, () => {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {
if (activeTab.value === "secrets") {
loadSecrets();
}
}, 250);
});
</script>
<template>
<GnToastProvider ref="toastProvider">
<div v-if="authState === 'checking'" class="login-screen" aria-hidden="true">
<article class="card login-screen-card">
<img src="/logo.svg" alt="" class="login-screen-logo" />
<GnLoader circle class="login-screen-loader" />
</article>
</div>
<div v-else-if="authState === 'denied'" class="login-screen">
<article class="card login-screen-card">
<img src="/logo.svg" alt="" class="login-screen-logo" />
<h1 class="login-screen-title">{{ t("login.title") }}</h1>
<p class="login-screen-subtitle">{{ t("login.subtitle") }}</p>
<GnButton variant="primary" icon="ph ph-sign-in" @click="loginNow">
{{ t("login.action") }}
</GnButton>
</article>
</div>
<GnNavigationShell
v-else-if="authState === 'ready'"
brand="GNEXUS CREDS"
logo-src="/logo.svg"
title="GNEXUS CREDS"
:current="currentNavLabel"
:items="navItems"
@select="onNavSelect"
>
<template #footer>
<a
:href="me?.auth_profile_url || undefined"
target="_blank"
rel="noopener noreferrer"
class="profile-identity"
>
<GnIdentity
:title="me?.display_name || me?.email || t('common.user')"
:meta="me?.email || ''"
:avatar="{
src: me?.avatar_url || '',
initials: userInitials,
size: 'sm',
variant: 'primary'
}"
/>
</a>
<GnIconButton
icon="ph ph-sign-out"
:label="t('profile.logout')"
@click="logout"
/>
</template>
<template #content>
<main class="creds-app">
<section v-if="activeTab === 'secrets'" class="secrets-page">
<section v-if="secretsView === 'list'" class="workspace-surface list-panel">
<GnAlert v-if="error" variant="danger" role="alert">{{ error }}</GnAlert>
<GnToolbar :title="t('nav.secrets')" :meta="loading ? t('common.loading') : t('common.records', { n: total })">
<template #actions>
<GnSearchField
v-model="query"
:placeholder="t('secrets.searchPlaceholder')"
/>
</template>
</GnToolbar>
<div class="toolbar-actions-row">
<GnDropdown
class="sort-dropdown"
:items="sortItems"
@select="(item) => { secretsSort = item.value; loadSecrets(); }"
>
<template #trigger="{ toggle }">
<GnIconButton icon="ph ph-sort-ascending" :label="t('secrets.sort')" @click="toggle" />
</template>
</GnDropdown>
<GnButton size="sm" variant="success" icon="ph ph-plus-circle" @click="openCreateModal">
{{ t("secrets.create") }}
</GnButton>
</div>
<GnSkeleton v-if="loading" />
<GnTable
v-else-if="secrets.length"
:columns="secretColumns"
:rows="secretRows"
class="secrets-table"
:empty-text="t('secrets.empty')"
>
<template #cell-title="{ row }">
<strong
:class="{ 'selected-secret-title': selected?.id === row.id }"
class="secret-title-link"
@click="selectSecret(row.raw)"
>
{{ row.title }}
</strong>
<span class="secret-title-sub">{{ row.category }}</span>
</template>
<template #cell-status="{ row }">
<GnBadge :variant="row.status === 'actual' ? 'success' : 'warning'">
{{ row.status }}
</GnBadge>
</template>
<template #cell-actions="{ row }">
<GnIconButton icon="ph ph-caret-right" :label="t('secrets.openSecret')" @click="selectSecret(row.raw)" />
</template>
</GnTable>
<AppPagination
v-if="total > secretsLimit"
:page="secretsPage"
:total-pages="Math.ceil(total / secretsLimit)"
@update:page="secretsPage = $event; loadSecrets()"
/>
<GnEmptyState
v-if="!secrets.length"
:title="t('secrets.empty')"
:text="t('secrets.emptyText')"
icon="ph ph-vault"
/>
</section>
<section v-else class="workspace-surface detail-panel">
<template v-if="selected">
<SecretDetailPanel
show-back
:secret="selected"
:fields="revealed?.fields"
:visible-field-keys="revealedVisibleFields"
@back="showSecretsList"
@reveal="reveal(selected)"
@hide="hideRevealed"
@edit-metadata="openEditMetadata"
@toggle-field-visibility="toggleRevealedField"
@status-change="setStatus"
@archive="archiveSelected"
@delete="confirmDeleteSecret(selected)"
@navigate="onPanelNavigate"
/>
<section v-if="editFields" class="workspace-surface inset-surface">
<GnToolbar :title="t('detail.editFields')" />
<GnTextarea
v-model="editForm.fieldsText"
:label="t('create.fieldsLabel')"
icon="ph ph-brackets-curly"
rows="8"
/>
<div class="actions">
<GnButton variant="primary" icon="ph ph-floppy-disk" @click="saveSecretFields">
{{ t("create.saveNewVersion") }}
</GnButton>
<GnButton variant="secondary" @click="editFields = false">{{ t("common.cancel") }}</GnButton>
</div>
</section>
</template>
<GnEmptyState
v-else
class="large"
:title="t('detail.selectSecret')"
:text="t('detail.selectSecretText')"
icon="ph ph-lock-key"
/>
</section>
</section>
<section v-if="activeTab === 'history'" class="panel-stack">
<section class="workspace-surface">
<GnToolbar :title="t('history.toolbar')" :meta="selected?.title || t('history.selectSecret')" />
<GnTimeline v-if="versionItems.length" :items="versionItems">
<template #meta="{ item }">
<div class="version-actions">
<GnButton
v-if="revealedVersion?.version_id === item.raw.id"
size="sm"
icon="ph ph-eye-slash"
@click="revealedVersion = null"
>
{{ t("common.hide") }}
</GnButton>
<GnButton
v-else
size="sm"
icon="ph ph-eye"
@click="revealVersion(item.raw)"
>
{{ t("common.reveal") }}
</GnButton>
</div>
</template>
<template
v-for="item in versionItems"
:key="item.key"
#[item.key]="{ item }"
>
<SecretDetailPanel
v-if="revealedVersion?.version_id === item.raw.id"
:show-header="false"
:show-actions="false"
:secret="revealedVersion"
:fields="revealedVersion.fields"
:visible-field-keys="revealedVisibleFields"
@toggle-field-visibility="toggleRevealedField"
/>
<!-- A truthy dynamic slot always beats GnTimeline's
item.text, so spell the fallback out here -->
<span v-else>{{ item.text }}</span>
</template>
</GnTimeline>
<GnEmptyState
v-else
:title="t('history.noVersions')"
:text="t('history.noVersionsText')"
icon="ph ph-clock-counter-clockwise"
/>
</section>
</section>
<section v-if="activeTab === 'audit'" class="panel-stack">
<section class="workspace-surface">
<GnToolbar
:title="t('audit.toolbar')"
:meta="auditMode === 'secret' && selected ? selected.title : t('audit.allSecrets')"
/>
<GnSkeleton v-if="auditLoading" />
<GnTable
v-else-if="auditRows.length"
:columns="auditColumns"
:rows="auditRows"
class="audit-table"
:empty-text="t('audit.empty')"
>
<template #cell-channel="{ value }">
<GnBadge :variant="value === 'ui' ? 'info' : value === 'rest' ? 'warning' : 'success'">
{{ value }}
</GnBadge>
</template>
<template #cell-metadata="{ row }">
<GnDescriptionList v-if="row.metadataItems.length" :items="row.metadataItems" compact />
<span v-else>—</span>
</template>
</GnTable>
<!-- The table's own pagination must stay OUTSIDE the loading /
rows / empty v-if chain — a v-else here would bind to the
pagination's v-if and phantom-render alongside real rows -->
<GnEmptyState
v-else
:title="t('audit.empty')"
:text="t('audit.emptyText')"
icon="ph ph-list-checks"
/>
<AppPagination
v-if="!auditLoading && auditTotal > auditLimit"
:page="auditPage"
:total-pages="Math.ceil(auditTotal / auditLimit)"
@update:page="auditPage = $event; loadAudit()"
/>
</section>
</section>
<section v-if="activeTab === 'tokens'" class="panel-stack">
<section class="workspace-surface token-panel">
<GnToolbar :title="t('tokens.toolbar')">
<template #actions>
<GnButton variant="primary" icon="ph ph-plus-circle" @click="showCreateTokenModal = true">
{{ t("tokens.create") }}
</GnButton>
</template>
</GnToolbar>
<GnSkeleton v-if="tokensLoading" />
<GnTable
v-else-if="tokenItems.length"
:columns="tokenColumns"
:rows="tokenItems"
class="tokens-table"
>
<template #cell-name="{ row }">
<strong>{{ row.name }}</strong>
</template>
<template #cell-public_id="{ row }">
<code>{{ row.public_id }}</code>
</template>
<template #cell-scopes="{ row }">
{{ row.scopes.join(", ") }}
</template>
<template #cell-actions="{ row }">
<GnButton size="sm" variant="danger" icon="ph ph-trash" @click="confirmRevokeToken(row.raw)">
{{ t("common.revoke") }}
</GnButton>
</template>
</GnTable>
<GnEmptyState
v-else
:title="t('tokens.empty')"
:text="t('tokens.emptyText')"
icon="ph ph-key"
/>
</section>
</section>
<section v-if="activeTab === 'extension'" class="panel-stack">
<GnAlert v-if="extensionError" variant="danger" role="alert">{{ extensionError }}</GnAlert>
<section class="workspace-surface">
<GnToolbar
:title="t('extension.toolbar')"
:meta="extensionMeta ? `v${extensionMeta.version}` : (extensionLoading ? t('common.loading') : '')"
/>
<p class="input-info">{{ t("extension.description") }}</p>
<p v-if="extensionMeta?.notes" class="input-info muted">{{ extensionMeta.notes }}</p>
<div v-if="extensionLoading && !extensionMeta" class="extension-loading">
<GnSkeleton />
</div>
<div v-if="extensionMeta" class="extension-builds">
<div v-for="build in extensionMeta.builds" :key="build.filename" class="extension-card">
<div class="extension-card-head">
<GnBadge variant="info">{{ build.browser }}</GnBadge>
<span class="muted">{{ formatBytes(build.size) }}</span>
</div>
<div class="extension-card-sha">
<span class="muted">SHA-256</span>
<GnCopyButton :value="build.sha256" />
</div>
<div class="create-actions">
<GnButton
variant="primary"
icon="ph ph-download-simple"
@click="downloadExtension(build.filename)"
>
{{ t("extension.download", { browser: build.browser }) }}
</GnButton>
</div>
</div>
</div>
<div class="create-actions">
<GnButton icon="ph ph-key" @click="goToTokens">{{ t("extension.createToken") }}</GnButton>
</div>
</section>
</section>
<section v-if="activeTab === 'settings'" class="panel-stack">
<section class="settings-metrics" :aria-label="t('settings.overview')">
<GnMetricCard :label="t('settings.totalSecrets')" :value="stats.total_secrets" icon="ph ph-vault" />
<GnMetricCard :label="t('settings.activeSecrets')" :value="stats.active_secrets" icon="ph ph-check-circle" />
<GnMetricCard :label="t('settings.mcpEnabled')" :value="stats.mcp_enabled_secrets" icon="ph ph-plugs-connected" />
</section>
<section class="workspace-surface">
<GnToolbar :title="t('settings.languageToolbar')" />
<p class="input-info">{{ t("settings.languageDescription") }}</p>
<div class="create-actions">
<GnSelect
class="language-select"
:model-value="me?.locale || ''"
:options="localeOptions"
@update:model-value="changeLanguage"
/>
</div>
</section>
<section class="workspace-surface">
<GnToolbar :title="t('settings.importToolbar')" />
<GnFileUpload
v-model="importFiles"
:title="t('settings.importTitle')"
:description="t('settings.importDescription')"
:primary="t('settings.importPrimary')"
:secondary="t('settings.importSecondary')"
badge="JSON"
:multiple="false"
accept="application/json,.json"
@change="handleImportFiles"
>
<template #actions>
<GnButton
variant="primary"
icon="ph ph-upload-simple"
:disabled="!importPreview"
@click="importData"
>
{{ t("settings.importAction") }}
</GnButton>
</template>
</GnFileUpload>
<GnAlert v-if="importError" variant="danger" role="alert">{{ importError }}</GnAlert>
<div v-if="importPreview" class="import-preview">
<GnBadge variant="success">{{ t("settings.importReady", { n: importPreview.secrets?.length || 0 }) }}</GnBadge>
<span class="muted">{{ importFile?.name }}</span>
</div>
</section>
<section class="workspace-surface">
<GnToolbar :title="t('settings.exportToolbar')" />
<p class="input-info">{{ t("settings.exportDescription") }}</p>
<div class="create-actions">
<GnButton icon="ph ph-export" @click="exportData">{{ t("settings.exportAction") }}</GnButton>
</div>
</section>
<section class="workspace-surface">
<GnToolbar :title="t('settings.gateToolbar')" />
<p class="input-info">{{ t("settings.gateDescription") }}</p>
<GnAlert v-if="gateState.status === 'unavailable'" variant="info" role="status">
{{ gateState.detail || t("settings.gateUnavailable") }}
</GnAlert>
<GnAlert v-else-if="gateState.status === 'broken'" variant="danger" role="alert">
{{ t("settings.gateBroken") }}
</GnAlert>
<div class="create-actions">
<GnButton
icon="ph ph-fingerprint"
variant="primary"
:disabled="gateState.status === 'unavailable' || gateState.status === 'unknown'"
@click="toggleBioGate"
>
{{ gateState.status === 'enrolled' ? t("settings.gateDisable") : t("settings.gateEnable") }}
</GnButton>
</div>
</section>
<section class="workspace-surface danger-zone">
<GnToolbar :title="t('settings.dangerToolbar')" />
<p class="input-info">{{ t("settings.dangerDescription") }}</p>
<div class="create-actions">
<GnButton variant="danger" icon="ph ph-trash" @click="showDeleteConfirm = true">
{{ t("settings.deleteAll") }}
</GnButton>
</div>
</section>
</section>
<section v-if="activeTab === 'admin' && me?.role === 'admin'" class="panel-stack admin-stack">
<section class="workspace-surface">
<GnToolbar :title="t('admin.usersToolbar')" :meta="t('common.records', { n: adminUsersTotal })">
<template #actions>
<GnButton icon="ph ph-arrows-clockwise" @click="loadAdminUsers">{{ t("admin.refresh") }}</GnButton>
</template>
</GnToolbar>
<GnDescriptionList :items="diagnosticsItems" compact>
<template #health>
<GnBadge :variant="diagnostics.health === 'ok' ? 'success' : 'danger'">
{{ diagnostics.health }}
</GnBadge>
</template>
<template #ready>
<GnBadge :variant="diagnostics.ready === 'ok' ? 'success' : 'danger'">
{{ diagnostics.ready }}
</GnBadge>
</template>
</GnDescriptionList>
<GnButton size="sm" icon="ph ph-heartbeat" @click="loadDiagnostics">{{ t("admin.checkDiagnostics") }}</GnButton>
<div v-if="adminUsers.length" class="admin-user-cards">
<GnUserCard
v-for="user in adminUsers"
:key="user.id"
:name="user.display_name || user.email"
:email="user.email"
:role="user.role"
:avatar="{ initials: (user.display_name || user.email).split(/[\s._-]+/).filter(Boolean).slice(0, 2).map(w => w[0].toUpperCase()).join(''), size: 'sm' }"
:compact="true"
>
<template #actions>
<GnBadge :variant="user.status === 'enabled' ? 'success' : 'danger'">{{ user.status }}</GnBadge>
<GnBadge :variant="user.role === 'admin' ? 'warning' : 'info'">{{ user.role }}</GnBadge>
</template>
</GnUserCard>
</div>
<GnEmptyState
v-else
:title="t('admin.noUsers')"
:text="t('admin.noUsersText')"
icon="ph ph-users"
/>
<AppPagination
v-if="adminUsersTotal > adminUsersLimit"
:page="adminUsersPage"
:total-pages="Math.ceil(adminUsersTotal / adminUsersLimit)"
@update:page="adminUsersPage = $event; loadAdminUsers()"
/>
</section>
<section class="workspace-surface">
<GnToolbar :title="t('admin.backups')">
<template #actions>
<GnButton icon="ph ph-floppy-disk" @click="createBackup">{{ t("admin.createBackup") }}</GnButton>
</template>
</GnToolbar>
<GnSkeleton v-if="backupsLoading" />
<GnTable
v-else-if="backups.length"
:columns="backupColumns"
:rows="backups.map(b => ({ ...b, size: `${(b.size / 1024).toFixed(1)} KB` }))"
:empty-text="t('admin.noBackupsYet')"
>
<template #cell-actions="{ row }">
<GnButton size="sm" icon="ph ph-download-simple" @click="downloadBackup(row.filename)">{{ t("common.download") }}</GnButton>
<GnButton size="sm" variant="danger" icon="ph ph-arrow-u-up-left" @click="confirmRestore(row.filename)">{{ t("common.restore") }}</GnButton>
</template>
</GnTable>
<GnEmptyState
v-else
:title="t('admin.noBackups')"
:text="t('admin.noBackupsText')"
icon="ph ph-floppy-disk"
/>
</section>
</section>
<GnModal v-model:open="showCreateModal" :title="t('create.title')">
<div class="create-modal-body">
<section class="modal-section">
<div class="form-grid two">
<GnInput
v-model="form.title"
:label="t('col.title')"
icon="ph ph-text-t"
:help="t('create.titleHelp')"
/>
<GnInput
v-model="form.purpose"
:label="t('col.purpose')"
icon="ph ph-target"
:help="t('create.purposeHelp')"
/>
<div class="form-row-two">
<GnInput
v-model="form.category"
:label="t('col.category')"
icon="ph ph-folder"
:help="t('create.categoryHelp')"
/>
<GnInput
v-model="form.source"
:label="t('create.sourceLabel')"
icon="ph ph-signpost"
:help="t('create.sourceHelp')"
/>
</div>
<GnTagInput
v-model="form.tags"
:label="t('detail.tags')"
icon="ph ph-tag"
separator=","
unique
:help="t('create.tagsHelp')"
class="form-col-full"
/>
<GnTextarea
v-model="form.notes"
:label="t('create.notesLabel')"
icon="ph ph-note-pencil"
rows="3"
maxlength="140"
:help="t('create.notesHelp')"
class="form-col-full"
/>
</div>
</section>
<section class="modal-section">
<GnToolbar :title="t('create.fieldsTitle')" :meta="t('create.fieldsMeta')">
<template #actions>
<GnButton size="sm" variant="secondary" icon="ph ph-plus" @click="addCreateField">
{{ t("create.addField") }}
</GnButton>
</template>
</GnToolbar>
<p class="input-info modal-help">{{ t("create.fieldsHelp") }}</p>
<div class="create-fields">
<div v-for="(field, index) in formFields" :key="field.id" class="create-field-row">
<GnInput
v-model="field.name"
:label="t('create.fieldName')"
icon="ph ph-brackets-curly"
:help="t('create.fieldNameHelp')"
/>
<GnInput
v-model="field.value"
:label="t('create.fieldValue')"
icon="ph ph-key"
:help="t('create.fieldValueHelp')"
/>
<div class="field-row-actions">
<GnCheckbox v-model="field.encrypted" :label="t('create.encrypt')" />
<GnIconButton
icon="ph ph-trash"
:label="t('create.removeField')"
:disabled="formFields.length === 1"
@click="removeCreateField(index)"
/>
</div>
</div>
</div>
</section>
<section class="modal-section">
<GnToolbar :title="t('create.accessTitle')" :meta="t('create.accessMeta')" />
<div class="create-access-grid">
<div class="access-option">
<GnSwitch v-model="form.allow_ui" :label="t('create.channelUi')" />
<div class="input-info">{{ t("create.uiInfo") }}</div>
</div>
<div class="access-option">
<GnSwitch v-model="form.allow_rest_api" :label="t('create.channelRest')" />
<div class="input-info">{{ t("create.restInfo") }}</div>
</div>
<div class="access-option">
<GnSwitch v-model="form.allow_mcp" :label="t('create.channelMcp')" />
<div class="input-info">{{ t("create.mcpInfo") }}</div>
</div>
</div>
</section>
</div>
<template #actions>
<GnButton variant="secondary" @click="showCreateModal = false">{{ t("common.cancel") }}</GnButton>
<GnButton variant="success" icon="ph ph-check-circle" :disabled="!canCreateSecret" @click="createSecret">
{{ t("secrets.create") }}
</GnButton>
</template>
</GnModal>
<GnModal v-model:open="editing" :title="t('detail.editMetadata')" @close="cancelEditMetadata">
<div v-if="selected" class="create-modal-body">
<section class="modal-section">
<div class="form-grid two">
<GnInput v-model="editForm.title" :label="t('col.title')" icon="ph ph-text-t" />
<GnInput v-model="editForm.purpose" :label="t('col.purpose')" icon="ph ph-target" />
<div class="form-row-two">
<GnInput v-model="editForm.category" :label="t('col.category')" icon="ph ph-folder" />
<GnInput v-model="editForm.source" :label="t('create.sourceLabel')" icon="ph ph-signpost" />
</div>
<GnTagInput
v-model="editForm.tags"
:label="t('detail.tags')"
icon="ph ph-tag"
separator=","
unique
class="form-col-full"
/>
<GnTextarea
v-model="editForm.notes"
:label="t('create.notesLabel')"
icon="ph ph-note-pencil"
rows="3"
maxlength="140"
class="form-col-full"
/>
</div>
</section>
<section class="modal-section">
<GnToolbar :title="t('create.statusTitle')" :meta="t('create.statusMeta')" />
<div class="controls-line">
<GnButton
:variant="editForm.status === 'actual' ? 'primary' : 'secondary'"
@click="editForm.status = 'actual'"
>
{{ t("create.statusActual") }}
</GnButton>
<GnButton
:variant="editForm.status === 'outdated' ? 'primary' : 'secondary'"
@click="editForm.status = 'outdated'"
>
{{ t("create.statusOutdated") }}
</GnButton>
<GnSwitch v-model="editForm.archived" :label="t('create.archived')" />
</div>
</section>
<section class="modal-section">
<GnToolbar :title="t('create.accessTitle')" :meta="t('create.accessMetaEdit')" />
<div class="access-grid">
<div class="access-option">
<GnSwitch v-model="editForm.allow_ui" :label="t('create.channelUi')" />
<div class="input-info">{{ t("create.uiInfo") }}</div>
</div>
<div class="access-option">
<GnSwitch v-model="editForm.allow_rest_api" :label="t('create.channelRest')" />
<div class="input-info">{{ t("create.restInfo") }}</div>
</div>
<div class="access-option">
<GnSwitch v-model="editForm.allow_mcp" :label="t('create.channelMcp')" />
<div class="input-info">{{ t("create.mcpInfo") }}</div>
</div>
</div>
</section>
</div>
<template #actions>
<GnButton variant="secondary" @click="cancelEditMetadata">{{ t("common.cancel") }}</GnButton>
<GnButton variant="primary" icon="ph ph-floppy-disk" @click="saveSecretMetadata">
{{ t("create.saveMetadata") }}
</GnButton>
</template>
</GnModal>
<GnConfirmDialog
v-model:open="showDeleteConfirm"
:title="t('confirm.deleteAllTitle')"
:message="t('confirm.deleteAllText')"
:confirm-text="t('common.delete')"
confirm-variant="danger"
@confirm="deleteAllData"
/>
<GnConfirmDialog
:open="Boolean(pendingDeleteSecret)"
:title="t('confirm.deleteSecretTitle')"
:message="t('confirm.deleteSecretText', { title: pendingDeleteSecret?.title || t('detail.selectSecret') })"
:confirm-text="t('common.delete')"
confirm-variant="danger"
@confirm="deleteSelectedSecret"
@update:open="pendingDeleteSecret = null"
/>
<GnModal v-model:open="showCreateTokenModal" :title="t('tokens.create')" @close="closeCreateTokenModal">
<div v-if="!newToken" class="create-modal-body">
<p class="input-info">
{{ t("tokens.intro") }}
</p>
<GnInput v-model="tokenForm.name" :label="t('tokens.tokenName')" icon="ph ph-key" />
<div class="controls-line">
<GnCheckbox v-model="tokenForm.read" :label="t('tokens.scopeRead')" />
<GnCheckbox v-model="tokenForm.reveal" :label="t('tokens.scopeReveal')" />
<GnCheckbox v-model="tokenForm.write" :label="t('tokens.scopeWrite')" />
<GnCheckbox v-model="tokenForm.admin" :label="t('tokens.scopeAdmin')" />
<GnCheckbox v-model="tokenForm.mcp" :label="t('create.channelMcp')" />
</div>
</div>
<div v-else class="create-modal-body">
<GnTextarea :model-value="newToken.token" :label="t('tokens.token')" icon="ph ph-key" readonly />
<p class="input-info">
{{ t("tokens.copyNotice") }}
</p>
</div>
<template #actions>
<GnButton
v-if="!newToken"
variant="primary"
icon="ph ph-plus-circle"
@click="createApiToken"
>
{{ t("tokens.create") }}
</GnButton>
<template v-else>
<GnCopyButton :text="newToken.token" size="sm" />
<GnButton variant="secondary" @click="closeCreateTokenModal">{{ t("common.close") }}</GnButton>
</template>
</template>
</GnModal>
<GnConfirmDialog
v-model:open="showRestoreConfirm"
:title="t('confirm.restoreTitle')"
:message="t('confirm.restoreText', { file: pendingRestoreFile })"
:confirm-text="t('common.restore')"
confirm-variant="danger"
@confirm="restoreBackup"
/>
<GnConfirmDialog
v-model:open="showRevokeConfirm"
:title="t('tokens.revokeTitle')"
:message="t('tokens.revokeText', { name: pendingRevokeToken?.name || '' })"
:confirm-text="t('common.revoke')"
confirm-variant="danger"
@confirm="doRevokeToken"
/>
</main>
</template>
</GnNavigationShell>
</GnToastProvider>
</template>