diff --git a/.gitignore b/.gitignore
index 87c7e9d..438c88b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,4 @@
frontend/dist/
var/
+backups/
diff --git a/Dockerfile b/Dockerfile
index 47410c9..d9f3682 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -21,7 +21,7 @@
WORKDIR /app
RUN apt-get update \
- && apt-get install -y --no-install-recommends ca-certificates git \
+ && apt-get install -y --no-install-recommends ca-certificates git postgresql-client \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml README.md ./
diff --git a/README.md b/README.md
index 4202402..13263a8 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,8 @@
GET /ready
```
+Interactive API docs (Swagger UI) are available at `/docs` when the server is running.
+
## MCP
Two MCP-facing surfaces are available:
diff --git a/docker-compose.yml b/docker-compose.yml
index 2b2c35a..a2875a4 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,6 +1,6 @@
services:
postgres:
- image: postgres:18
+ image: postgres:17
environment:
POSTGRES_DB: gnexus_creds
POSTGRES_USER: gnexus_creds
@@ -24,6 +24,15 @@
GNEXUS_CREDS_DATABASE_URL: postgresql+psycopg://gnexus_creds:gnexus_creds@postgres:5432/gnexus_creds
ports:
- "8000:8000"
+ volumes:
+ - backups:/app/backups
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8000/ready"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
volumes:
postgres-data:
+ backups:
diff --git a/docs/deployment-and-mcp.md b/docs/deployment-and-mcp.md
index 92ab981..688c03e 100644
--- a/docs/deployment-and-mcp.md
+++ b/docs/deployment-and-mcp.md
@@ -362,11 +362,111 @@
- `set_secret_status`
- `archive_secret`
+`search_secrets` supports pagination with `offset` and `limit`. The maximum
+`limit` is 50. If many secrets match, iterate with `offset` increments.
+
Search returns metadata and unencrypted fields only. Encrypted values are
returned only by `reveal_secret`.
Reveal and write actions create audit events with `channel=mcp`.
+## Backup and Restore
+
+Backups are managed from the admin panel (`Admin` tab → `Backups`).
+
+### Backup directory
+
+By default, backups are written to `./backups`. In Docker Compose, this is a
+persistent volume mounted at `/app/backups`.
+
+Set a custom path via environment variable:
+
+```env
+GNEXUS_CREDS_BACKUP_DIR=/var/backups/gnexus-creds
+```
+
+### Create backup
+
+From the UI:
+
+1. Open the `Admin` tab.
+2. Click `Create backup`.
+
+From the command line (inside the container or on the host with `pg_dump`):
+
+```bash
+docker-compose exec app python -c "from gnexus_creds.backup import create_backup; print(create_backup())"
+```
+
+Or via the admin API (admin user required):
+
+```bash
+curl -fsS -X POST \
+ -H "Cookie: gnexus_creds_session=..." \
+ http://localhost:8000/api/v1/admin/backup
+```
+
+### List backups
+
+UI: `Admin` tab shows the backup table with size and creation time.
+
+API:
+
+```bash
+curl -fsS \
+ -H "Cookie: gnexus_creds_session=..." \
+ http://localhost:8000/api/v1/admin/backups
+```
+
+### Download backup
+
+Click `Download` in the UI, or use the direct link:
+
+```bash
+curl -fsS \
+ -H "Cookie: gnexus_creds_session=..." \
+ -O \
+ http://localhost:8000/api/v1/admin/backups/backup_20260520_120000.sql
+```
+
+### Restore backup
+
+**Warning:** restore overwrites the current database. All existing data is
+replaced.
+
+From the UI:
+
+1. Open the `Admin` tab.
+2. Click `Restore` next to the desired backup.
+3. Confirm in the modal.
+
+After restore, reload the page to refresh the UI state.
+
+From the command line:
+
+```bash
+docker-compose exec app python -c "
+from gnexus_creds.backup import restore_backup
+restore_backup('/app/backups/backup_20260520_120000.sql')
+"
+```
+
+Or via API:
+
+```bash
+curl -fsS -X POST \
+ -H "Cookie: gnexus_creds_session=..." \
+ -H "Content-Type: application/json" \
+ -d '{"filename": "backup_20260520_120000.sql"}' \
+ http://localhost:8000/api/v1/admin/restore
+```
+
+### Supported engines
+
+- **PostgreSQL** — dumps with `pg_dump`, restores with `psql`. The `pg_dump`
+ binary is included in the Docker image.
+- **SQLite** — file copy (not recommended for production).
+
## Troubleshooting
`401 Unauthorized`:
diff --git a/frontend/index.html b/frontend/index.html
index 88059ea..d2da435 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,7 +3,7 @@
- gnexus-creds
+ GNEXUS CREDS
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 4a5d88a..b6e15ff 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -2,7 +2,6 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import {
GnActionList,
- GnActivityLog,
GnBadge,
GnButton,
GnCheckbox,
@@ -11,19 +10,23 @@
GnEmptyState,
GnFileUpload,
GnIconButton,
+ GnIdentity,
GnInput,
GnMetricCard,
- GnPageHeader,
+ GnNavigationShell,
+ GnPagination,
GnSearchField,
+ GnSkeleton,
GnTable,
- GnTabs,
GnTextarea,
+ GnTimeline,
GnToastProvider,
GnToolbar
} from "gnexus-ui-kit/vue";
import { api } from "./api";
import GnModal from "./components/GnModal.vue";
+import SecretDetailPanel from "./components/SecretDetailPanel.vue";
const baseTabs = [
{ id: "secrets", label: "Secrets" },
@@ -48,9 +51,13 @@
const loading = ref(false);
const error = ref("");
const toastProvider = ref(null);
+const copiedFieldKey = ref("");
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);
@@ -58,10 +65,15 @@
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 showProfileModal = ref(false);
const showDeleteConfirm = ref(false);
const editing = ref(false);
const editFields = ref(false);
@@ -72,10 +84,23 @@
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 form = reactive({
title: "",
@@ -115,12 +140,90 @@
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 profileForm = reactive({
+ display_name: "",
+ locale: "en"
+});
+
+const navItems = computed(() => {
+ const items = [
+ { id: "secrets", label: "Secrets", icon: "ph-vault" },
+ { id: "history", label: "History", icon: "ph-clock-counter-clockwise" },
+ { id: "audit", label: "Audit", icon: "ph-list-checks" },
+ { id: "tokens", label: "Tokens", icon: "ph-key" },
+ { id: "settings", label: "Settings", icon: "ph-gear" }
+ ];
+ if (me.value?.role === "admin") {
+ items.push({ id: "admin", label: "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 || "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
+ }
+ window.location.href = "/auth/login";
+}
const canCreateSecret = computed(() => Boolean(form.title.trim() && parseCreateFields().length));
+const sortItems = computed(() => [
+ { label: "Updated (newest)", value: { by: "updated_at", dir: "desc" } },
+ { label: "Updated (oldest)", value: { by: "updated_at", dir: "asc" } },
+ { label: "Title (A–Z)", value: { by: "title", dir: "asc" } },
+ { label: "Title (Z–A)", value: { by: "title", dir: "desc" } },
+ { label: "Status", value: { by: "status", dir: "asc" } }
+]);
const secretRows = computed(() =>
secrets.value.map((secret) => ({
id: secret.id,
@@ -138,72 +241,35 @@
{ 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) => ({
+ key: String(version.id),
title: `Version ${version.version_number}`,
- subtitle: `${version.created_at} · ${version.fields.length} fields`,
+ time: version.created_at,
+ text: `${version.fields.length} fields`,
+ icon: "ph-clock-counter-clockwise",
raw: version
}))
);
-const revealedVersionItems = computed(() =>
- (revealedVersion.value?.fields || []).map((field) => ({
- title: field.name,
- subtitle: field.encrypted ? "encrypted" : "plain",
- raw: field
- }))
-);
-const auditLogItems = computed(() =>
+const auditRows = computed(() =>
auditEvents.value.map((event) => ({
- key: String(event.id),
+ id: event.id,
time: event.created_at,
- title: `${event.action} · ${event.channel}`,
+ action: event.action,
+ channel: event.channel,
+ ip: event.ip_address || "—",
+ metadataItems: formatAuditMetadata(event.metadata),
raw: event
}))
);
const tokenItems = computed(() =>
- tokens.value.map((token) => ({
- title: token.name,
- subtitle: token.scopes.join(", "),
- raw: token
- }))
+ tokens.value
+ .filter((token) => !token.revoked_at)
+ .map((token) => ({
+ title: token.name,
+ subtitle: token.scopes.join(", "),
+ raw: token
+ }))
);
const diagnosticsItems = computed(() => [
{ label: "/health", key: "health", value: diagnostics.health },
@@ -215,6 +281,40 @@
{ key: "role", label: "Role" },
{ key: "last_seen_at", label: "Last seen" }
];
+const auditColumns = [
+ { key: "time", label: "Time" },
+ { key: "action", label: "Action" },
+ { key: "channel", label: "Channel" },
+ { key: "ip", label: "IP" },
+ { key: "metadata", label: "Metadata" }
+];
+
+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
@@ -301,6 +401,9 @@
if (pathname.startsWith("/secrets/")) {
return "secrets";
}
+ if (pathname.startsWith("/audit/")) {
+ return "audit";
+ }
return routeTabs[pathname] || (pathname === "/" ? "secrets" : null);
}
@@ -309,6 +412,11 @@
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";
@@ -324,6 +432,11 @@
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";
@@ -348,6 +461,21 @@
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() {
@@ -426,7 +554,13 @@
loading.value = true;
error.value = "";
try {
- const payload = await api.listSecrets({ q: query.value, limit: 50 });
+ 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) {
@@ -438,6 +572,7 @@
revealed.value = null;
} catch (err) {
error.value = err.message;
+ toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 });
} finally {
loading.value = false;
}
@@ -524,10 +659,15 @@
revealedVisibleFields.value = new Set();
}
-async function copyValue(value) {
- if (value) {
- await navigator.clipboard.writeText(value);
- showCopyToast();
+async function copyValue(value, key) {
+ if (!value) return;
+ await navigator.clipboard.writeText(value);
+ toastProvider.value?.success({ title: "Copied", text: "Value copied to clipboard.", lifetime: 1800 });
+ if (key) {
+ copiedFieldKey.value = key;
+ setTimeout(() => {
+ if (copiedFieldKey.value === key) copiedFieldKey.value = "";
+ }, 2000);
}
}
@@ -549,11 +689,9 @@
revealedVisibleFields.value = next;
}
-async function loadVersions(secret) {
- selected.value = secret;
- activeTab.value = "history";
+async function loadVersions(secretId) {
revealedVersion.value = null;
- versions.value = await api.versions(secret.id);
+ versions.value = await api.versions(secretId);
}
async function revealVersion(version) {
@@ -562,17 +700,36 @@
}
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;
+ 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: "Error", text: err.message, lifetime: 3000 });
+ } finally {
+ auditLoading.value = false;
}
}
async function loadTokens() {
- activeTab.value = "tokens";
- tokens.value = await api.tokens();
+ tokensLoading.value = true;
+ try {
+ tokens.value = await api.tokens();
+ } catch (err) {
+ toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 });
+ } finally {
+ tokensLoading.value = false;
+ }
}
async function createApiToken() {
@@ -584,24 +741,38 @@
await loadTokens();
}
+function closeCreateTokenModal() {
+ newToken.value = null;
+ showCreateTokenModal.value = false;
+}
+
+function openProfileModal() {
+ profileForm.display_name = me.value?.display_name || "";
+ profileForm.locale = me.value?.locale || "en";
+ showProfileModal.value = true;
+}
+
+async function saveProfile() {
+ try {
+ const updated = await api.updateMe({
+ display_name: profileForm.display_name || null,
+ locale: profileForm.locale || null
+ });
+ me.value = updated;
+ showProfileModal.value = false;
+ toastProvider.value?.success({ title: "Saved", text: "Profile updated.", lifetime: 2000 });
+ } catch (err) {
+ toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 });
+ }
+}
+
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
- });
+ await copyValue(value);
}
async function exportData() {
@@ -643,16 +814,70 @@
}
async function deleteAllData() {
- await api.deleteAccountData();
- showDeleteConfirm.value = false;
- await loadSecrets();
+ try {
+ await api.deleteAccountData();
+ showDeleteConfirm.value = false;
+ await loadSecrets();
+ toastProvider.value?.success({ title: "Deleted", text: "All data has been removed.", lifetime: 2000 });
+ } catch (err) {
+ toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 });
+ }
}
async function loadAdminUsers() {
- activeTab.value = "admin";
- const payload = await api.adminUsers({ limit: 50 });
- adminUsers.value = payload.items;
- adminUsersTotal.value = payload.total;
+ 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: "Error", text: err.message, lifetime: 3000 });
+ }
+}
+
+async function loadBackups() {
+ backupsLoading.value = true;
+ try {
+ backups.value = await api.listBackups();
+ } catch (err) {
+ toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 });
+ } finally {
+ backupsLoading.value = false;
+ }
+}
+
+async function createBackup() {
+ try {
+ await api.createBackup();
+ toastProvider.value?.success({ title: "Backup", text: "Database backup created.", lifetime: 2000 });
+ await loadBackups();
+ } catch (err) {
+ toastProvider.value?.error({ title: "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: "Restored", text: "Database restored. Please reload the page.", lifetime: 5000 });
+ } catch (err) {
+ toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 });
+ }
}
async function loadDiagnostics() {
@@ -662,6 +887,13 @@
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;
+}
+
onMounted(async () => {
try {
me.value = await api.me();
@@ -688,515 +920,695 @@
if (tab === "secrets") {
secretsView.value = "list";
}
- setRouteForTab(tab);
+ 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 === "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);
+});
-
-