diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ac07158..fc71bcb 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -34,6 +34,9 @@ const tokens = ref([]); const newToken = ref(null); const showDeleteConfirm = ref(false); +const editing = ref(false); +const editFields = ref(false); +const pendingDeleteSecret = ref(null); const form = reactive({ title: "", @@ -48,12 +51,40 @@ fieldsText: "username=\npassword=*" }); +const editForm = reactive({ + title: "", + purpose: "", + category: "", + source: "", + notes: "", + tags: "", + status: "actual", + archived: false, + allow_ui: true, + allow_rest_api: true, + allow_mcp: false, + fieldsText: "" +}); + +const tokenForm = reactive({ + name: "MCP client", + read: true, + reveal: true, + write: true, + admin: false, + mcp: true +}); + const visibleFields = computed(() => selected.value?.fields || []); const activeSecrets = computed(() => secrets.value.filter((secret) => !secret.archived).length); const mcpSecrets = computed(() => secrets.value.filter((secret) => secret.allow_mcp).length); function parseFields() { - return form.fieldsText + return parseFieldsText(form.fieldsText); +} + +function parseFieldsText(value) { + return value .split("\n") .map((line, index) => { const [rawName, ...rest] = line.split("="); @@ -72,6 +103,36 @@ .filter(Boolean); } +function serializeFields(fields) { + return fields + .map((field) => `${field.name}=${field.encrypted ? "*" : ""}${field.value || ""}`) + .join("\n"); +} + +function fillEditForm(secret) { + if (!secret) return; + editForm.title = secret.title || ""; + editForm.purpose = secret.purpose || ""; + editForm.category = secret.category || ""; + editForm.source = secret.source || ""; + editForm.notes = secret.notes || ""; + editForm.tags = secret.tags?.join(", ") || ""; + editForm.status = secret.status || "actual"; + editForm.archived = Boolean(secret.archived); + editForm.allow_ui = Boolean(secret.allow_ui); + editForm.allow_rest_api = Boolean(secret.allow_rest_api); + editForm.allow_mcp = Boolean(secret.allow_mcp); + editForm.fieldsText = serializeFields(secret.fields || []); +} + +function selectSecret(secret) { + selected.value = secret; + revealed.value = null; + editing.value = false; + editFields.value = false; + fillEditForm(secret); +} + async function loadSecrets() { loading.value = true; error.value = ""; @@ -79,7 +140,12 @@ const payload = await api.listSecrets({ q: query.value, limit: 50 }); secrets.value = payload.items; total.value = payload.total; - selected.value = secrets.value[0] || null; + 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; @@ -110,6 +176,62 @@ await loadSecrets(); } +async function saveSecretMetadata() { + if (!selected.value) return; + const updated = await api.updateSecret(selected.value.id, { + title: editForm.title, + purpose: editForm.purpose || null, + category: editForm.category || null, + source: editForm.source || null, + notes: editForm.notes || null, + tags: editForm.tags.split(",").map((tag) => tag.trim()).filter(Boolean), + status: editForm.status, + archived: editForm.archived, + allow_ui: editForm.allow_ui, + allow_rest_api: editForm.allow_rest_api, + allow_mcp: editForm.allow_mcp + }); + selected.value = updated; + editing.value = false; + await loadSecrets(); +} + +async function saveSecretFields() { + if (!selected.value) return; + const updated = await api.updateSecret(selected.value.id, { + fields: parseFieldsText(editForm.fieldsText) + }); + selected.value = updated; + revealed.value = null; + editFields.value = false; + await loadSecrets(); +} + +async function setStatus(status) { + if (!selected.value) return; + selected.value = await api.updateSecret(selected.value.id, { status }); + await loadSecrets(); +} + +async function archiveSelected() { + if (!selected.value) return; + selected.value = await api.updateSecret(selected.value.id, { archived: true }); + await loadSecrets(); +} + +function confirmDeleteSecret(secret) { + pendingDeleteSecret.value = secret; +} + +async function deleteSelectedSecret() { + if (!pendingDeleteSecret.value) return; + await api.deleteSecret(pendingDeleteSecret.value.id); + pendingDeleteSecret.value = null; + selected.value = null; + revealed.value = null; + await loadSecrets(); +} + async function reveal(secret) { revealed.value = await api.revealSecret(secret.id); } @@ -139,13 +261,25 @@ } async function createApiToken() { + const scopes = ["read", "reveal", "write", "admin", "mcp"].filter((scope) => tokenForm[scope]); newToken.value = await api.createToken({ - name: "MCP client", - scopes: ["read", "reveal", "write", "mcp"] + name: tokenForm.name || "API token", + scopes }); await loadTokens(); } +async function revokeToken(token) { + await api.revokeToken(token.id); + await loadTokens(); +} + +async function copyText(value) { + if (value) { + await navigator.clipboard.writeText(value); + } +} + async function exportData() { const payload = await api.exportData(); const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); @@ -225,7 +359,7 @@ class="secret-row" :class="{ selected: selected?.id === secret.id }" type="button" - @click="selected = secret; revealed = null" + @click="selectSecret(secret)" > {{ secret.title }} @@ -247,11 +381,49 @@

{{ selected?.title || "No secret selected" }}

- - {{ selected.status }} - +
+ + {{ selected.status }} + + Edit +
+ + +

+ This permanently deletes + {{ pendingDeleteSecret?.title }} + and all its versions. Audit records remain. +

+ +
diff --git a/frontend/src/api.js b/frontend/src/api.js index f75dbb8..951353c 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -29,12 +29,14 @@ request("/api/v1/secrets", { method: "POST", body: JSON.stringify(payload) }), updateSecret: (id, payload) => request(`/api/v1/secrets/${id}`, { method: "PATCH", body: JSON.stringify(payload) }), + deleteSecret: (id) => request(`/api/v1/secrets/${id}`, { method: "DELETE" }), revealSecret: (id) => request(`/api/v1/secrets/${id}/reveal`, { method: "POST" }), versions: (id) => request(`/api/v1/secrets/${id}/versions`), audit: () => request("/api/v1/audit-events"), tokens: () => request("/api/v1/api-tokens"), createToken: (payload) => request("/api/v1/api-tokens", { method: "POST", body: JSON.stringify(payload) }), + revokeToken: (id) => request(`/api/v1/api-tokens/${id}`, { method: "DELETE" }), exportData: () => request("/api/v1/export", { method: "POST" }), importData: (payload) => request("/api/v1/import", { method: "POST", body: JSON.stringify(payload) }), diff --git a/frontend/src/styles.css b/frontend/src/styles.css index a815b70..2ad7f21 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -156,6 +156,14 @@ min-width: 0; } +.panel-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + .panel-title h2 { min-width: 0; overflow: hidden; @@ -265,6 +273,30 @@ gap: 12px; } +.form-grid.two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.edit-box, +.new-token-box { + display: grid; + gap: 12px; + padding: 12px; + border: 1px solid #30384f; + background: #151622; +} + +.status-row { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.token-panel { + max-width: 840px; +} + .checks { display: flex; gap: 14px; @@ -331,4 +363,8 @@ .description-grid { grid-template-columns: 1fr; } + + .form-grid.two { + grid-template-columns: 1fr; + } }