feat(secrets): scoped shared secrets rule-management UI (Phase 2)

Completes scoped shared secrets end-to-end: /shared-secrets list/new/edit
routes (mirroring metric-alert-rules) with an env-key name, a WRITE-ONLY
value (password input; never pre-filled — the API returns only has_value;
omitted on PATCH to keep the stored secret, provided to rotate; cleared
after save), an encrypted toggle (flipping it requires re-entering the
value, matching the server's 400 guard), a global|app scope with an
App-grouping picker (listApps), description, and enabled. 409 conflicts
surface a friendly message. New "System" nav entry (IconKey) + api.ts
client + full sharedsecrets.* i18n (en/ru parity).

Reviewed: typescript APPROVE (0 CRITICAL/HIGH).
This commit is contained in:
2026-05-29 16:11:46 +03:00
parent fa6d5bd3ba
commit 15e5b186cd
7 changed files with 1990 additions and 1 deletions
+51
View File
@@ -1428,3 +1428,54 @@ export function deleteMetricAlertRule(id: number): Promise<void> {
return del<void>(`/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<SharedSecret[]> {
const params = opts?.appID ? `?app_id=${encodeURIComponent(opts.appID)}` : '';
return get<SharedSecret[]>(`/api/shared-secrets${params}`, opts?.signal);
}
export function getSharedSecret(id: string, signal?: AbortSignal): Promise<SharedSecret> {
return get<SharedSecret>(`/api/shared-secrets/${id}`, signal);
}
export function createSharedSecret(data: SharedSecretInput): Promise<SharedSecret> {
return post<SharedSecret>('/api/shared-secrets', data);
}
export function updateSharedSecret(id: string, data: SharedSecretInput): Promise<SharedSecret> {
return patch<SharedSecret>(`/api/shared-secrets/${id}`, data);
}
export function deleteSharedSecret(id: string): Promise<void> {
return del<void>(`/api/shared-secrets/${id}`);
}
+91
View File
@@ -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",
+91
View File
@@ -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": "Новое правило",