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); +});