diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a12314c..900a0df 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1428,3 +1428,54 @@ export function deleteMetricAlertRule(id: number): Promise { return del(`/api/metric-alert-rules/${id}`); } +// ── Shared secrets ────────────────────────────────────────────────── +// A shared secret is a named env KEY whose value the reconciler injects +// into matching workloads. Scope model: scope="global" applies to every +// app; scope="app" + app_id narrows it to one app grouping. +// +// The value is WRITE-ONLY: the API never returns it, only a `has_value` +// presence flag. On update, omit `value` to keep the stored secret; +// provide it to set/rotate. Flipping `encrypted` requires resubmitting +// `value` (the server 400s otherwise). IDs are uuid strings. + +export interface SharedSecret { + id: string; + name: string; // the env KEY + has_value: boolean; // true if a value is stored (value itself is write-only, never returned) + encrypted: boolean; + scope: 'global' | 'app'; + app_id: string; // set when scope === 'app' + description: string; + enabled: boolean; + created_at: string; + updated_at: string; +} +export interface SharedSecretInput { + name?: string; + value?: string; // omit to keep the existing value on update; provide to set/rotate + encrypted?: boolean; + scope?: 'global' | 'app'; + app_id?: string; + description?: string; + enabled?: boolean; +} +export function listSharedSecrets(opts?: { + appID?: string; + signal?: AbortSignal; +}): Promise { + const params = opts?.appID ? `?app_id=${encodeURIComponent(opts.appID)}` : ''; + return get(`/api/shared-secrets${params}`, opts?.signal); +} +export function getSharedSecret(id: string, signal?: AbortSignal): Promise { + return get(`/api/shared-secrets/${id}`, signal); +} +export function createSharedSecret(data: SharedSecretInput): Promise { + return post('/api/shared-secrets', data); +} +export function updateSharedSecret(id: string, data: SharedSecretInput): Promise { + return patch(`/api/shared-secrets/${id}`, data); +} +export function deleteSharedSecret(id: string): Promise { + return del(`/api/shared-secrets/${id}`); +} + diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index b098f99..98450b1 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -18,6 +18,7 @@ "eventTriggers": "Event Triggers", "logScanRules": "Log Rules", "metricAlertRules": "Metric Alerts", + "sharedSecrets": "Shared Secrets", "triggers": "Triggers", "proxies": "Proxies", "events": "Events", @@ -987,6 +988,96 @@ "disabled": "disabled" } }, + "sharedsecrets": { + "eyebrow": "System", + "title": "Shared secrets", + "titleNew": "Forge a new secret", + "titleSingular": "Shared secret", + "lede": "Named environment keys the reconciler injects into matching workloads. Scope a secret globally to reach every app, or to a single app to keep it contained. Values are write-only — they are stored once and never returned. {enabled} of {total} enabled.", + "ledeNew": "Give the secret an env key and a value. Scope it globally to apply to every app, or to a single app to keep it contained. The value is write-only — once saved it is never shown again.", + "stat": { + "total": "TOTAL", + "global": "GLOBAL", + "app": "APP", + "enabled": "ENABLED" + }, + "toolbar": { + "newButton": "New secret", + "backToList": "Back to secrets" + }, + "filter": { + "all": "ALL", + "global": "GLOBAL", + "app": "APP" + }, + "empty": { + "heading": "No shared secrets yet", + "body": "Start with a global secret like DATABASE_URL, then narrow per-app by scoping a secret to a single app.", + "cta": "Create the first secret" + }, + "list": { + "name": "Name", + "scope": "Scope", + "value": "Value", + "encrypted": "Encrypted", + "status": "Status", + "open": "Open", + "valueSet": "set", + "valueNone": "—", + "encOn": "encrypted", + "encOff": "plain" + }, + "detail": { + "config": "Configuration", + "configSub": "scope {scope}", + "dangerZone": "Danger zone", + "dangerZoneSub": "Deleting a shared secret removes it immediately and stops it from being injected.", + "deleteButton": "Delete secret", + "deleteTitle": "Delete shared secret?", + "deleteMessage": "Secret \"{name}\" will be removed immediately and will stop being injected." + }, + "form": { + "name": "Name", + "namePlaceholder": "e.g. DATABASE_URL", + "nameHint": "The environment variable key injected into matching workloads.", + "value": "Value", + "valuePlaceholder": "Enter the secret value", + "valuePlaceholderEdit": "Leave blank to keep the stored value", + "valueHintNew": "The secret value. Stored write-only — once saved it is never shown again.", + "valueHintEditSet": "A value is set. Leave blank to keep it, or enter a new value to rotate it.", + "valueHintEditUnset": "No value is stored yet. Enter one to set it.", + "encrypted": "Encrypted", + "encryptedHint": "Encrypted secrets are stored sealed at rest and decrypted only when injected. Disable only for non-sensitive config values.", + "encryptedFlipWarning": "Changing the encrypted setting requires re-entering the value. Enter a value above to save.", + "scope": "Scope", + "scopeGlobalOption": "Global — every app", + "scopeAppOption": "App — a single app", + "scopeAppEmpty": "No app selected", + "scopePick": "Pick app…", + "scopePickTitle": "Pick an app", + "scopeClear": "Clear app", + "scopeSelected": "App", + "scopeUnknown": "Unknown app", + "scopeHint": "Global secrets apply to every app. App-scoped secrets apply only to that app's workloads.", + "description": "Description", + "descriptionPlaceholder": "Optional note about what this secret is for", + "enabled": "Enabled", + "enabledHint": "Disabled secrets stay in the table but are never injected.", + "required": "REQUIRED", + "optional": "OPTIONAL", + "submit": "Forge secret", + "submitting": "Forging…", + "conflict": "A shared secret with this scope and name already exists." + }, + "scope": { + "global": "Global", + "app": "App · {name}" + }, + "status": { + "enabled": "enabled", + "disabled": "disabled" + } + }, "logscan": { "title": "Log scan rules", "titleNew": "Forge a new rule", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 2365dc2..44f88cc 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -18,6 +18,7 @@ "eventTriggers": "Триггеры событий", "logScanRules": "Лог-правила", "metricAlertRules": "Метрик-алерты", + "sharedSecrets": "Общие секреты", "triggers": "Триггеры", "proxies": "Прокси", "events": "События", @@ -987,6 +988,96 @@ "disabled": "отключено" } }, + "sharedsecrets": { + "eyebrow": "Система", + "title": "Общие секреты", + "titleNew": "Создать новый секрет", + "titleSingular": "Общий секрет", + "lede": "Именованные переменные окружения, которые реконсилятор внедряет в подходящие нагрузки. Задайте секрету глобальную область, чтобы он применялся ко всем приложениям, или ограничьте его одним приложением. Значения доступны только для записи — они сохраняются один раз и никогда не возвращаются. Включено {enabled} из {total}.", + "ledeNew": "Укажите секрету ключ переменной окружения и значение. Задайте глобальную область, чтобы применить его ко всем приложениям, или ограничьте одним приложением. Значение доступно только для записи — после сохранения оно больше не отображается.", + "stat": { + "total": "ВСЕГО", + "global": "ГЛОБАЛЬНЫЕ", + "app": "ПРИЛОЖЕНИЕ", + "enabled": "ВКЛЮЧЕНО" + }, + "toolbar": { + "newButton": "Новый секрет", + "backToList": "К списку секретов" + }, + "filter": { + "all": "ВСЕ", + "global": "ГЛОБАЛЬНЫЕ", + "app": "ПРИЛОЖЕНИЕ" + }, + "empty": { + "heading": "Пока нет общих секретов", + "body": "Начните с глобального секрета, например DATABASE_URL, затем сузьте его, ограничив секрет отдельным приложением.", + "cta": "Создать первый секрет" + }, + "list": { + "name": "Название", + "scope": "Область", + "value": "Значение", + "encrypted": "Шифрование", + "status": "Статус", + "open": "Открыть", + "valueSet": "задано", + "valueNone": "—", + "encOn": "зашифровано", + "encOff": "открыто" + }, + "detail": { + "config": "Конфигурация", + "configSub": "область {scope}", + "dangerZone": "Опасная зона", + "dangerZoneSub": "Удаление общего секрета немедленно убирает его и прекращает внедрение.", + "deleteButton": "Удалить секрет", + "deleteTitle": "Удалить общий секрет?", + "deleteMessage": "Секрет «{name}» будет удалён немедленно и перестанет внедряться." + }, + "form": { + "name": "Название", + "namePlaceholder": "напр. DATABASE_URL", + "nameHint": "Ключ переменной окружения, внедряемый в подходящие нагрузки.", + "value": "Значение", + "valuePlaceholder": "Введите значение секрета", + "valuePlaceholderEdit": "Оставьте пустым, чтобы сохранить текущее значение", + "valueHintNew": "Значение секрета. Хранится только для записи — после сохранения больше не отображается.", + "valueHintEditSet": "Значение задано. Оставьте пустым, чтобы сохранить его, или введите новое для замены.", + "valueHintEditUnset": "Значение ещё не сохранено. Введите его, чтобы задать.", + "encrypted": "Шифрование", + "encryptedHint": "Зашифрованные секреты хранятся запечатанными и расшифровываются только при внедрении. Отключайте только для не секретных значений конфигурации.", + "encryptedFlipWarning": "Изменение настройки шифрования требует повторного ввода значения. Введите значение выше, чтобы сохранить.", + "scope": "Область", + "scopeGlobalOption": "Глобально — все приложения", + "scopeAppOption": "Приложение — одно приложение", + "scopeAppEmpty": "Приложение не выбрано", + "scopePick": "Выбрать приложение…", + "scopePickTitle": "Выберите приложение", + "scopeClear": "Очистить приложение", + "scopeSelected": "Приложение", + "scopeUnknown": "Неизвестное приложение", + "scopeHint": "Глобальные секреты применяются ко всем приложениям. Секреты с областью приложения применяются только к нагрузкам этого приложения.", + "description": "Описание", + "descriptionPlaceholder": "Необязательная заметка о назначении секрета", + "enabled": "Включено", + "enabledHint": "Отключённые секреты остаются в таблице, но не внедряются.", + "required": "ОБЯЗАТЕЛЬНО", + "optional": "НЕОБЯЗАТЕЛЬНО", + "submit": "Создать секрет", + "submitting": "Создаём…", + "conflict": "Общий секрет с такой областью и названием уже существует." + }, + "scope": { + "global": "Глобально", + "app": "Приложение · {name}" + }, + "status": { + "enabled": "включено", + "disabled": "отключено" + } + }, "logscan": { "title": "Правила сканирования логов", "titleNew": "Новое правило", diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 26c1884..1fbda8a 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -6,7 +6,7 @@ import Toast from '$lib/components/Toast.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte'; import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte'; - import { IconDashboard, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout, IconBox, IconContainer, IconAlert } from '$lib/components/icons'; + import { IconDashboard, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout, IconBox, IconContainer, IconAlert, IconKey } from '$lib/components/icons'; import { goto } from '$app/navigation'; import { resolvedTheme, applyTheme } from '$lib/stores/theme'; import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth'; @@ -50,6 +50,7 @@ { href: '/event-triggers', labelKey: 'nav.eventTriggers', icon: 'events', section: 'observe' }, { href: '/log-scan-rules', labelKey: 'nav.logScanRules', icon: 'events', section: 'observe' }, { href: '/metric-alert-rules', labelKey: 'nav.metricAlertRules', icon: 'alert', section: 'observe' }, + { href: '/shared-secrets', labelKey: 'nav.sharedSecrets', icon: 'key', section: 'system' }, { href: '/settings', labelKey: 'nav.settings', icon: 'settings', section: 'system' } ]; @@ -319,6 +320,8 @@ {:else if item.icon === 'alert'} + {:else if item.icon === 'key'} + {:else if item.icon === 'settings'} {/if} diff --git a/web/src/routes/shared-secrets/+page.svelte b/web/src/routes/shared-secrets/+page.svelte new file mode 100644 index 0000000..dacfba8 --- /dev/null +++ b/web/src/routes/shared-secrets/+page.svelte @@ -0,0 +1,553 @@ + + + + {$t('sharedsecrets.title')} · Tinyforge + + +
+ {#snippet toolbar()} + + + + {$t('sharedsecrets.toolbar.newButton')} + + {/snippet} + + {#snippet stats()} +
+
{$t('sharedsecrets.stat.total')}
+
{loading ? '—' : String(secrets.length).padStart(2, '0')}
+
+
+
{$t('sharedsecrets.stat.global')}
+
{loading ? '—' : String(globals.length).padStart(2, '0')}
+
+
+
{$t('sharedsecrets.stat.app')}
+
{loading ? '—' : String(appScoped.length).padStart(2, '0')}
+
+
+
{$t('sharedsecrets.stat.enabled')}
+
{loading ? '—' : String(enabledCount).padStart(2, '0')}
+
+ {/snippet} + + {#snippet lede()} + {$t('sharedsecrets.lede', { enabled: String(enabledCount), total: String(secrets.length) })} + {/snippet} + + + + {#if error} + + {/if} + + {#if !loading && secrets.length > 0} +
+ {#each [['all', $t('sharedsecrets.filter.all'), secrets.length], ['global', $t('sharedsecrets.filter.global'), globals.length], ['app', $t('sharedsecrets.filter.app'), appScoped.length]] as [key, label, count]} + + {/each} +
+ {/if} + + {#if loading} +
+ {#each Array(4) as _, i} +
+ {/each} +
+ {:else if secrets.length === 0} +
+ +

{$t('sharedsecrets.empty.heading')}

+

{$t('sharedsecrets.empty.body')}

+ + {$t('sharedsecrets.empty.cta')} + +
+ {:else} +
+ + + + + + + + + + + + + {#each filtered as s, i (s.id)} + + + + + + + + + {/each} + +
{$t('sharedsecrets.list.name')}{$t('sharedsecrets.list.scope')}{$t('sharedsecrets.list.value')}{$t('sharedsecrets.list.encrypted')}{$t('sharedsecrets.list.status')}{$t('sharedsecrets.list.open')}
+ + {String(i + 1).padStart(2, '0')} + {s.name} + + + {scopeLabel(s)} + + {#if s.has_value} + {$t('sharedsecrets.list.valueSet')} + {:else} + {$t('sharedsecrets.list.valueNone')} + {/if} + + {#if s.encrypted} + {$t('sharedsecrets.list.encOn')} + {:else} + {$t('sharedsecrets.list.encOff')} + {/if} + + + + {s.enabled ? $t('sharedsecrets.status.enabled') : $t('sharedsecrets.status.disabled')} + + + + {$t('observability.open')} + +
+
+ {/if} +
+ + diff --git a/web/src/routes/shared-secrets/[id]/+page.svelte b/web/src/routes/shared-secrets/[id]/+page.svelte new file mode 100644 index 0000000..6225692 --- /dev/null +++ b/web/src/routes/shared-secrets/[id]/+page.svelte @@ -0,0 +1,658 @@ + + + + {secret?.name ?? $t('sharedsecrets.titleSingular')} · Tinyforge + + +
+ {#snippet detailLede()} + {#if secret} + + {$t('sharedsecrets.list.scope')} {scopeLabel(secret)} · + {$t('sharedsecrets.list.value')} + {secret.has_value ? $t('sharedsecrets.list.valueSet') : $t('sharedsecrets.list.valueNone')} + + {/if} + {/snippet} + + + + {#if error} + + {/if} + + {#if loading || !secret} +
+ {#each Array(3) as _, i} +
+ {/each} +
+ {:else} +
+
+

{$t('sharedsecrets.detail.config')}.

+ + {$t('sharedsecrets.detail.configSub', { scope: scopeLabel(secret) })} + +
+ +
+ + +
+ +
+ + +

+ {secret.has_value + ? $t('sharedsecrets.form.valueHintEditSet') + : $t('sharedsecrets.form.valueHintEditUnset')} +

+
+ +
+
+ +

{$t('sharedsecrets.form.encryptedHint')}

+ {#if encryptedFlipBlocked} +

{$t('sharedsecrets.form.encryptedFlipWarning')}

+ {/if} +
+ +
+ +
+ + + {#if scope === 'app'} +
+ {#if appID === ''} +
+ + {$t('sharedsecrets.form.scopeAppEmpty')} + +
+ {:else} +
+ {$t('sharedsecrets.form.scopeSelected')} + {#if selectedApp} + {selectedApp.name} + {#if selectedApp.description} + {selectedApp.description} + {/if} + {:else} + {$t('sharedsecrets.form.scopeUnknown')} + {appID} + {/if} + + +
+ {/if} +
+ {/if} +
+ +
+ + +
+ +
+
+ +

{$t('sharedsecrets.form.enabledHint')}

+
+ +
+ +
+ +
+
+ +
+
+

{$t('sharedsecrets.detail.dangerZone')}.

+ {$t('sharedsecrets.detail.dangerZoneSub')} +
+
+ +
+
+ + (confirmDelete = false)} + /> + + (pickerOpen = false)} + /> + {/if} +
+ + diff --git a/web/src/routes/shared-secrets/new/+page.svelte b/web/src/routes/shared-secrets/new/+page.svelte new file mode 100644 index 0000000..8fa0aec --- /dev/null +++ b/web/src/routes/shared-secrets/new/+page.svelte @@ -0,0 +1,542 @@ + + + + {$t('sharedsecrets.titleNew')} · Tinyforge + + +
+ {#snippet lede()} + {$t('sharedsecrets.ledeNew')} + {/snippet} + + + +
+ {#if error} + + {/if} + +
+ + +

{$t('sharedsecrets.form.nameHint')}

+
+ +
+
+ + {$t('sharedsecrets.form.value')} + {$t('sharedsecrets.form.optional')} +
+ +

{$t('sharedsecrets.form.valueHintNew')}

+
+
+ +

{$t('sharedsecrets.form.encryptedHint')}

+
+ +
+
+ +
+ + + {#if scope === 'app'} +
+ {#if appID === ''} +
+ + {$t('sharedsecrets.form.scopeAppEmpty')} + +
+ {:else} +
+ {$t('sharedsecrets.form.scopeSelected')} + {#if selectedApp} + {selectedApp.name} + {#if selectedApp.description} + {selectedApp.description} + {/if} + {:else} + {$t('sharedsecrets.form.scopeUnknown')} + {appID} + {/if} + + +
+ {/if} +
+ {/if} +

{$t('sharedsecrets.form.scopeHint')}

+
+ +
+ + +
+ +
+
+ +

{$t('sharedsecrets.form.enabledHint')}

+
+ +
+ +
+ {$t('observability.cancel')} + +
+
+ + (pickerOpen = false)} + /> +
+ +