diff --git a/frontend/src/lib/api/endpoints/admin.ts b/frontend/src/lib/api/endpoints/admin.ts index 9f9da765..f7a8f6e2 100644 --- a/frontend/src/lib/api/endpoints/admin.ts +++ b/frontend/src/lib/api/endpoints/admin.ts @@ -466,6 +466,25 @@ export function saveOidc(body: Record): Promise { // ── Storage settings + migration ─────────────────────────────────────────── +/** + * One `:` pair rendered for the admin storage panel. + * Never carries key material — only cipher name + SSH-style + * fingerprint safe to show operators. + */ +export interface StorageEncryptionPair { + /** `"aes-256-gcm"` for a real-cipher pair, `"none"` for a `none:` sentinel. */ + cipher: string; + /** + * SSH-style colon-hex 8-byte fingerprint of the key. Matches + * `storage_rotate`'s `head_key_fp` and the `oxicloud --fingerprint` + * CLI output — enables one-glance identification of which key is + * which. `undefined` for `none:` pairs (no key material). + */ + fingerprint?: string; + /** True for the LAST pair in the list — the write pair (head). */ + is_head: boolean; +} + export interface StorageEntrySummary { name: string; backend: string; @@ -473,6 +492,14 @@ export interface StorageEntrySummary { encryption_enabled: boolean; /** Human-readable physical hint (root_dir / bucket / container). */ location_hint?: string | null; + /** + * Ordered pair-list summary — oldest first, head last. Empty when + * the entry has no `_ENCRYPTION_KEY` declared at all. Used by the + * entry card to render the pair chain so admins can identify + * which key is the current head + which are safe to remove after + * a completed rotation. + */ + encryption_pairs: StorageEncryptionPair[]; } export interface StorageSettings { diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index 561a0d82..f7085c27 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -409,19 +409,24 @@ } } + // Entry-card action confirmations use `ui.notify()` (viewport + // toast) rather than `storageMsg` (top-of-tab banner). The + // buttons live on cards that can be scrolled far below the + // storage-msg region — a banner confirmation is invisible + // when the user is looking at the card that triggered it. async function doAuditEntry(name: string) { try { await triggerJob('blobs_consistency', { storage: name }); - storageMsg = { - text: t( + ui.notify( + t( 'admin.storage_audit_triggered', { name }, 'blobs_consistency triggered for `{{name}}` — watch it on the Jobs tab.' ), - ok: true - }; + 'success' + ); } catch (e) { - storageMsg = { text: errorMessage(e), ok: false }; + ui.notify(errorMessage(e), 'error'); } } @@ -433,16 +438,16 @@ async function doStorageConsistency(name: string) { try { await triggerJob('backend_consistency', { storage: name }); - storageMsg = { - text: t( + ui.notify( + t( 'admin.storage_backend_audit_triggered', { name }, 'backend_consistency triggered for `{{name}}` — watch it on the Jobs tab.' ), - ok: true - }; + 'success' + ); } catch (e) { - storageMsg = { text: errorMessage(e), ok: false }; + ui.notify(errorMessage(e), 'error'); } } @@ -477,16 +482,16 @@ return; try { await rotateStorageEntry(name); - storageMsg = { - text: t( + ui.notify( + t( 'admin.storage_rotate_triggered', { name }, 'Rotation started on `{{name}}` — watch it on the Jobs tab (`storage_rotate`).' ), - ok: true - }; + 'success' + ); } catch (e) { - storageMsg = { text: errorMessage(e), ok: false }; + ui.notify(errorMessage(e), 'error'); } } @@ -2214,6 +2219,41 @@ {/if} + + {#if entry.encryption_pairs?.length} +
+

+ {t('admin.storage_pair_list', 'Encryption keys')} +

+
    + {#each entry.encryption_pairs as pair, i (i)} +
  1. + key{i + 1}: + {pair.cipher} + + {pair.fingerprint ?? '—'} + + {#if pair.is_head} + + {t('admin.storage_pair_head', 'head')} + + {/if} +
  2. + {/each} +
+

+ {t( + 'admin.storage_pair_help', + 'Head is the write key. After a successful rotation with 0 failures, any non-head key can be safely removed from `.env`.' + )} +

+
+ {/if} {#if test?.result != null || test?.error != null}