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:
@@ -1428,3 +1428,54 @@ export function deleteMetricAlertRule(id: number): Promise<void> {
|
|||||||
return del<void>(`/api/metric-alert-rules/${id}`);
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"eventTriggers": "Event Triggers",
|
"eventTriggers": "Event Triggers",
|
||||||
"logScanRules": "Log Rules",
|
"logScanRules": "Log Rules",
|
||||||
"metricAlertRules": "Metric Alerts",
|
"metricAlertRules": "Metric Alerts",
|
||||||
|
"sharedSecrets": "Shared Secrets",
|
||||||
"triggers": "Triggers",
|
"triggers": "Triggers",
|
||||||
"proxies": "Proxies",
|
"proxies": "Proxies",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
@@ -987,6 +988,96 @@
|
|||||||
"disabled": "disabled"
|
"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": {
|
"logscan": {
|
||||||
"title": "Log scan rules",
|
"title": "Log scan rules",
|
||||||
"titleNew": "Forge a new rule",
|
"titleNew": "Forge a new rule",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"eventTriggers": "Триггеры событий",
|
"eventTriggers": "Триггеры событий",
|
||||||
"logScanRules": "Лог-правила",
|
"logScanRules": "Лог-правила",
|
||||||
"metricAlertRules": "Метрик-алерты",
|
"metricAlertRules": "Метрик-алерты",
|
||||||
|
"sharedSecrets": "Общие секреты",
|
||||||
"triggers": "Триггеры",
|
"triggers": "Триггеры",
|
||||||
"proxies": "Прокси",
|
"proxies": "Прокси",
|
||||||
"events": "События",
|
"events": "События",
|
||||||
@@ -987,6 +988,96 @@
|
|||||||
"disabled": "отключено"
|
"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": {
|
"logscan": {
|
||||||
"title": "Правила сканирования логов",
|
"title": "Правила сканирования логов",
|
||||||
"titleNew": "Новое правило",
|
"titleNew": "Новое правило",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import Toast from '$lib/components/Toast.svelte';
|
import Toast from '$lib/components/Toast.svelte';
|
||||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||||
import LocaleSwitcher from '$lib/components/LocaleSwitcher.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 { goto } from '$app/navigation';
|
||||||
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
|
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
|
||||||
import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth';
|
import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth';
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
{ href: '/event-triggers', labelKey: 'nav.eventTriggers', icon: 'events', section: 'observe' },
|
{ href: '/event-triggers', labelKey: 'nav.eventTriggers', icon: 'events', section: 'observe' },
|
||||||
{ href: '/log-scan-rules', labelKey: 'nav.logScanRules', 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: '/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' }
|
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings', section: 'system' }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -319,6 +320,8 @@
|
|||||||
<IconEvents size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
<IconEvents size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||||
{:else if item.icon === 'alert'}
|
{:else if item.icon === 'alert'}
|
||||||
<IconAlert size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
<IconAlert size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||||
|
{:else if item.icon === 'key'}
|
||||||
|
<IconKey size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||||
{:else if item.icon === 'settings'}
|
{:else if item.icon === 'settings'}
|
||||||
<IconSettings size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
<IconSettings size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,553 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import * as api from '$lib/api';
|
||||||
|
import type { SharedSecret } from '$lib/api';
|
||||||
|
import type { App } from '$lib/types';
|
||||||
|
import { IconPlus, IconRefresh } from '$lib/components/icons';
|
||||||
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
|
let secrets = $state<SharedSecret[]>([]);
|
||||||
|
let apps = $state<App[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let filter = $state<'all' | 'global' | 'app'>('all');
|
||||||
|
|
||||||
|
const globals = $derived(secrets.filter((s) => s.scope === 'global'));
|
||||||
|
const appScoped = $derived(secrets.filter((s) => s.scope === 'app'));
|
||||||
|
const enabledCount = $derived(secrets.filter((s) => s.enabled).length);
|
||||||
|
|
||||||
|
// app id → name lookup so app-scoped rows render the human label
|
||||||
|
// instead of the raw uuid. A missing app falls back to a truncated
|
||||||
|
// id (the app may have been deleted out from under the secret).
|
||||||
|
const appNames = $derived.by(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
for (const a of apps) m.set(a.id, a.name);
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
switch (filter) {
|
||||||
|
case 'global':
|
||||||
|
return globals;
|
||||||
|
case 'app':
|
||||||
|
return appScoped;
|
||||||
|
default:
|
||||||
|
return secrets;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
// Load apps alongside secrets so app-scoped rows resolve their
|
||||||
|
// name. App load failure is non-fatal — the row falls back to
|
||||||
|
// the truncated id.
|
||||||
|
const [s, a] = await Promise.all([
|
||||||
|
api.listSharedSecrets(),
|
||||||
|
api.listApps().catch(() => [] as App[])
|
||||||
|
]);
|
||||||
|
secrets = s;
|
||||||
|
apps = a;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to load shared secrets';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeLabel(s: SharedSecret): string {
|
||||||
|
if (s.scope === 'app') {
|
||||||
|
const label = appNames.get(s.app_id) || s.app_id.slice(0, 8);
|
||||||
|
return $t('sharedsecrets.scope.app', { name: label });
|
||||||
|
}
|
||||||
|
return $t('sharedsecrets.scope.global');
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeClass(s: SharedSecret): string {
|
||||||
|
return s.scope === 'app' ? 'scope-app' : 'scope-global';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$t('sharedsecrets.title')} · Tinyforge</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="forge" aria-busy={loading}>
|
||||||
|
{#snippet toolbar()}
|
||||||
|
<button
|
||||||
|
class="forge-btn-icon"
|
||||||
|
onclick={load}
|
||||||
|
aria-label={$t('observability.refresh')}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<IconRefresh size={16} />
|
||||||
|
</button>
|
||||||
|
<a href="/shared-secrets/new" class="forge-btn">
|
||||||
|
<IconPlus size={14} />
|
||||||
|
<span>{$t('sharedsecrets.toolbar.newButton')}</span>
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet stats()}
|
||||||
|
<div>
|
||||||
|
<dt>{$t('sharedsecrets.stat.total')}</dt>
|
||||||
|
<dd>{loading ? '—' : String(secrets.length).padStart(2, '0')}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{$t('sharedsecrets.stat.global')}</dt>
|
||||||
|
<dd>{loading ? '—' : String(globals.length).padStart(2, '0')}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{$t('sharedsecrets.stat.app')}</dt>
|
||||||
|
<dd>{loading ? '—' : String(appScoped.length).padStart(2, '0')}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{$t('sharedsecrets.stat.enabled')}</dt>
|
||||||
|
<dd class="accent">{loading ? '—' : String(enabledCount).padStart(2, '0')}</dd>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet lede()}
|
||||||
|
{$t('sharedsecrets.lede', { enabled: String(enabledCount), total: String(secrets.length) })}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<ForgeHero
|
||||||
|
eyebrowSuffix={$t('sharedsecrets.eyebrow').toUpperCase()}
|
||||||
|
title={$t('sharedsecrets.title')}
|
||||||
|
size="lg"
|
||||||
|
toolbar={toolbar}
|
||||||
|
lede_html={lede}
|
||||||
|
stats={stats}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert" role="alert">
|
||||||
|
<span class="alert-tag">ERR</span><span>{error}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !loading && secrets.length > 0}
|
||||||
|
<div class="filter-row" role="group" aria-label={$t('sharedsecrets.list.scope')}>
|
||||||
|
{#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]}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip"
|
||||||
|
class:active={filter === key}
|
||||||
|
aria-pressed={filter === key}
|
||||||
|
onclick={() => (filter = key as typeof filter)}
|
||||||
|
>
|
||||||
|
<span class="chip-label">{label}</span>
|
||||||
|
<span class="chip-count">{String(count).padStart(2, '0')}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="skeleton-rows" aria-busy="true" aria-live="polite" aria-label={$t('observability.loading')}>
|
||||||
|
{#each Array(4) as _, i}
|
||||||
|
<div class="skeleton-row" style:--i={i}></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if secrets.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<div class="empty-mark" aria-hidden="true">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
<h2>{$t('sharedsecrets.empty.heading')}</h2>
|
||||||
|
<p>{$t('sharedsecrets.empty.body')}</p>
|
||||||
|
<a href="/shared-secrets/new" class="forge-btn">
|
||||||
|
<IconPlus size={14} /><span>{$t('sharedsecrets.empty.cta')}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="forge-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{$t('sharedsecrets.list.name')}</th>
|
||||||
|
<th>{$t('sharedsecrets.list.scope')}</th>
|
||||||
|
<th>{$t('sharedsecrets.list.value')}</th>
|
||||||
|
<th>{$t('sharedsecrets.list.encrypted')}</th>
|
||||||
|
<th>{$t('sharedsecrets.list.status')}</th>
|
||||||
|
<th class="t-right">{$t('sharedsecrets.list.open')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filtered as s, i (s.id)}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a class="row-link" href={`/shared-secrets/${s.id}`}>
|
||||||
|
<span class="row-ref">{String(i + 1).padStart(2, '0')}</span>
|
||||||
|
<span class="row-name mono">{s.name}</span>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {scopeClass(s)}">{scopeLabel(s)}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if s.has_value}
|
||||||
|
<span class="value-state set">{$t('sharedsecrets.list.valueSet')}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="value-state none">{$t('sharedsecrets.list.valueNone')}</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if s.encrypted}
|
||||||
|
<span class="enc-pill on">{$t('sharedsecrets.list.encOn')}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="enc-pill off">{$t('sharedsecrets.list.encOff')}</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="status" class:on={s.enabled} class:off={!s.enabled}>
|
||||||
|
<span class="status-dot" aria-hidden="true"></span>
|
||||||
|
{s.enabled ? $t('sharedsecrets.status.enabled') : $t('sharedsecrets.status.disabled')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<a class="row-action" href={`/shared-secrets/${s.id}`}>
|
||||||
|
{$t('observability.open')} <span class="arrow" aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.forge {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Alert ─────────────────────────────────────── */
|
||||||
|
.alert {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.7rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
background: var(--color-danger-light);
|
||||||
|
color: var(--color-danger-dark);
|
||||||
|
border: 1px solid var(--color-danger);
|
||||||
|
border-left-width: 4px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.alert-tag {
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
background: var(--color-danger);
|
||||||
|
color: var(--surface-card);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
:global([data-theme='dark']) .alert {
|
||||||
|
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||||
|
color: color-mix(in srgb, var(--color-danger) 60%, var(--text-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Filter chips ──────────────────────────────── */
|
||||||
|
.filter-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: border-color 150ms ease, background 150ms ease, color 150ms ease,
|
||||||
|
transform 150ms ease;
|
||||||
|
}
|
||||||
|
.chip:hover {
|
||||||
|
background: var(--surface-card-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.chip:focus-visible {
|
||||||
|
outline: 2px solid var(--border-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.chip.active {
|
||||||
|
background: var(--text-primary);
|
||||||
|
color: var(--surface-card);
|
||||||
|
border-color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.chip-label {
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
.chip-count {
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Skeleton ──────────────────────────────────── */
|
||||||
|
.skeleton-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
.skeleton-row {
|
||||||
|
height: 52px;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: linear-gradient(
|
||||||
|
110deg,
|
||||||
|
var(--surface-card) 20%,
|
||||||
|
var(--surface-card-hover) 50%,
|
||||||
|
var(--surface-card) 80%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.6s linear infinite;
|
||||||
|
animation-delay: calc(var(--i) * 120ms);
|
||||||
|
}
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty ─────────────────────────────────────── */
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
border: 1px dashed var(--border-primary);
|
||||||
|
border-radius: var(--radius-2xl);
|
||||||
|
background: var(--surface-card);
|
||||||
|
}
|
||||||
|
.empty-mark {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.empty-mark span {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--border-input);
|
||||||
|
}
|
||||||
|
.empty-mark span:nth-child(2) {
|
||||||
|
background: var(--forge-accent);
|
||||||
|
animation: ember 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes ember {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 3px var(--forge-accent-soft);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 18%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.empty h2 {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.empty p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
max-width: 52ch;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Table ─────────────────────────────────────── */
|
||||||
|
.table-wrap {
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: var(--surface-card);
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.table-wrap :global(.forge-table) {
|
||||||
|
min-width: 720px;
|
||||||
|
}
|
||||||
|
.t-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Row link / action ────────────────────────── */
|
||||||
|
.row-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.6rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 120ms ease;
|
||||||
|
}
|
||||||
|
.row-link:hover {
|
||||||
|
color: var(--forge-accent);
|
||||||
|
}
|
||||||
|
.row-link:focus-visible {
|
||||||
|
outline: 2px solid var(--border-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.row-ref {
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.row-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.row-action {
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--forge-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.row-action:hover {
|
||||||
|
color: var(--color-brand-500);
|
||||||
|
}
|
||||||
|
.row-action:focus-visible {
|
||||||
|
outline: 2px solid var(--border-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.arrow {
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 150ms ease;
|
||||||
|
}
|
||||||
|
.row-action:hover .arrow {
|
||||||
|
transform: translateX(3px);
|
||||||
|
}
|
||||||
|
.actions-cell {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Badges ────────────────────────────────────── */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.18rem 0.55rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge.scope-global {
|
||||||
|
background: var(--surface-card-hover);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.badge.scope-app {
|
||||||
|
background: color-mix(in srgb, var(--color-brand-500) 10%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--color-brand-500) 30%, transparent);
|
||||||
|
color: var(--color-brand-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Value state ───────────────────────────────── */
|
||||||
|
.value-state {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.value-state.set {
|
||||||
|
color: var(--color-success-dark);
|
||||||
|
}
|
||||||
|
.value-state.none {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Encrypted pill ────────────────────────────── */
|
||||||
|
.enc-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.enc-pill.on {
|
||||||
|
background: color-mix(in srgb, var(--color-brand-500) 10%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--color-brand-500) 30%, transparent);
|
||||||
|
color: var(--color-brand-600);
|
||||||
|
}
|
||||||
|
.enc-pill.off {
|
||||||
|
background: var(--surface-card-hover);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status ────────────────────────────────────── */
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.status.on {
|
||||||
|
color: var(--color-success-dark);
|
||||||
|
}
|
||||||
|
.status.on .status-dot {
|
||||||
|
background: var(--color-success);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 20%, transparent);
|
||||||
|
}
|
||||||
|
.status.off {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.status.off .status-dot {
|
||||||
|
background: var(--text-tertiary);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,658 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import * as api from '$lib/api';
|
||||||
|
import type { SharedSecret, SharedSecretInput } from '$lib/api';
|
||||||
|
import type { EntityPickerItem, App } from '$lib/types';
|
||||||
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
|
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||||
|
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
import { IconX } from '$lib/components/icons';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
|
// IDs are uuid strings — pass through untouched. An empty segment is
|
||||||
|
// the only invalid case worth guarding.
|
||||||
|
const id = $derived($page.params.id ?? '');
|
||||||
|
|
||||||
|
let secret = $state<SharedSecret | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let saving = $state(false);
|
||||||
|
let deleting = $state(false);
|
||||||
|
let confirmDelete = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
let name = $state('');
|
||||||
|
// The value field starts blank and is NEVER pre-filled — the API
|
||||||
|
// doesn't return the stored secret, only `has_value`. A blank field
|
||||||
|
// on save means "keep the stored value"; a typed value rotates it.
|
||||||
|
let value = $state('');
|
||||||
|
let encrypted = $state(true);
|
||||||
|
// The encrypted flag as it was loaded. Flipping it requires a new
|
||||||
|
// value (the server 400s otherwise), so we compare against this.
|
||||||
|
let loadedEncrypted = $state(true);
|
||||||
|
let scope = $state<'global' | 'app'>('global');
|
||||||
|
let appID = $state('');
|
||||||
|
let description = $state('');
|
||||||
|
let enabled = $state(true);
|
||||||
|
|
||||||
|
// App picker state.
|
||||||
|
let apps = $state<App[]>([]);
|
||||||
|
let pickerOpen = $state(false);
|
||||||
|
|
||||||
|
const pickerItems = $derived<EntityPickerItem[]>(
|
||||||
|
apps.map((a) => ({
|
||||||
|
value: a.id,
|
||||||
|
label: a.name,
|
||||||
|
description: a.description
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedApp = $derived(apps.find((a) => a.id === appID));
|
||||||
|
|
||||||
|
// The encrypted toggle changed from its loaded state. Flipping it is
|
||||||
|
// only valid alongside a fresh value — the server rejects a bare flip.
|
||||||
|
const encryptedChanged = $derived(encrypted !== loadedEncrypted);
|
||||||
|
|
||||||
|
// Flipping encrypted without entering a value is the one blocked
|
||||||
|
// combination. We surface a hint and disable Save for it.
|
||||||
|
const encryptedFlipBlocked = $derived(encryptedChanged && value === '');
|
||||||
|
|
||||||
|
// app scope needs an app_id; the encrypted-flip rule must hold.
|
||||||
|
const canSave = $derived(
|
||||||
|
name.trim() !== '' &&
|
||||||
|
(scope === 'global' || appID.trim() !== '') &&
|
||||||
|
!encryptedFlipBlocked
|
||||||
|
);
|
||||||
|
|
||||||
|
function onScopeChange(): void {
|
||||||
|
if (scope === 'global') appID = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickApp(v: string): void {
|
||||||
|
appID = v;
|
||||||
|
pickerOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearApp(): void {
|
||||||
|
appID = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
if (id === '') {
|
||||||
|
error = 'Invalid secret id';
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
// Load apps alongside the secret so the scope chip + picker
|
||||||
|
// resolve names. App load failure is non-fatal.
|
||||||
|
const [s, a] = await Promise.all([
|
||||||
|
api.getSharedSecret(id),
|
||||||
|
api.listApps().catch(() => [] as App[])
|
||||||
|
]);
|
||||||
|
secret = s;
|
||||||
|
name = s.name;
|
||||||
|
// value intentionally NOT populated — write-only.
|
||||||
|
value = '';
|
||||||
|
encrypted = s.encrypted;
|
||||||
|
loadedEncrypted = s.encrypted;
|
||||||
|
scope = s.scope;
|
||||||
|
appID = s.app_id;
|
||||||
|
description = s.description;
|
||||||
|
enabled = s.enabled;
|
||||||
|
apps = a;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to load secret';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(e?: Event): Promise<void> {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (!secret || id === '' || saving || !canSave) return;
|
||||||
|
saving = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const body: SharedSecretInput = {
|
||||||
|
name: name.trim(),
|
||||||
|
encrypted,
|
||||||
|
scope,
|
||||||
|
app_id: scope === 'app' ? appID.trim() : '',
|
||||||
|
description: description.trim(),
|
||||||
|
enabled
|
||||||
|
};
|
||||||
|
// PATCH semantics: omit `value` to keep the stored secret;
|
||||||
|
// include it only when the operator typed a new one (rotate).
|
||||||
|
if (value !== '') body.value = value;
|
||||||
|
secret = await api.updateSharedSecret(id, body);
|
||||||
|
// Re-baseline after a successful save: the new encrypted flag
|
||||||
|
// is now the loaded state, and the value field clears so a
|
||||||
|
// stale rotation can't be re-submitted.
|
||||||
|
loadedEncrypted = secret.encrypted;
|
||||||
|
encrypted = secret.encrypted;
|
||||||
|
scope = secret.scope;
|
||||||
|
appID = secret.app_id;
|
||||||
|
value = '';
|
||||||
|
} catch (e) {
|
||||||
|
error = conflictMessage(e);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDelete(): Promise<void> {
|
||||||
|
if (id === '') return;
|
||||||
|
deleting = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await api.deleteSharedSecret(id);
|
||||||
|
goto('/shared-secrets');
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Delete failed';
|
||||||
|
deleting = false;
|
||||||
|
confirmDelete = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A duplicate (scope, app_id, name) returns 409 — surface a friendly
|
||||||
|
// message instead of the raw backend error.
|
||||||
|
function conflictMessage(e: unknown): string {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Save failed';
|
||||||
|
if (/\b409\b/.test(msg) || /conflict/i.test(msg) || /already exists/i.test(msg)) {
|
||||||
|
return $t('sharedsecrets.form.conflict');
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeLabel(s: SharedSecret | null): string {
|
||||||
|
if (!s) return '';
|
||||||
|
if (s.scope === 'app') {
|
||||||
|
const label = selectedApp?.name || s.app_id.slice(0, 8);
|
||||||
|
return $t('sharedsecrets.scope.app', { name: label });
|
||||||
|
}
|
||||||
|
return $t('sharedsecrets.scope.global');
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{secret?.name ?? $t('sharedsecrets.titleSingular')} · Tinyforge</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="forge" aria-busy={loading}>
|
||||||
|
{#snippet detailLede()}
|
||||||
|
{#if secret}
|
||||||
|
<span class="lede-meta">
|
||||||
|
{$t('sharedsecrets.list.scope')} <code>{scopeLabel(secret)}</code> ·
|
||||||
|
{$t('sharedsecrets.list.value')}
|
||||||
|
<code>{secret.has_value ? $t('sharedsecrets.list.valueSet') : $t('sharedsecrets.list.valueNone')}</code>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<ForgeHero
|
||||||
|
backHref="/shared-secrets"
|
||||||
|
backLabel={$t('sharedsecrets.toolbar.backToList')}
|
||||||
|
eyebrowSuffix={$t('sharedsecrets.titleSingular').toUpperCase()}
|
||||||
|
title={secret?.name ?? $t('observability.loading')}
|
||||||
|
size="lg"
|
||||||
|
lede_html={secret ? detailLede : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert" role="alert">
|
||||||
|
<span class="alert-tag">ERR</span><span>{error}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading || !secret}
|
||||||
|
<div class="skeleton-rows" aria-busy="true" aria-live="polite" aria-label={$t('observability.loading')}>
|
||||||
|
{#each Array(3) as _, i}
|
||||||
|
<div class="skeleton-row" style:--i={i}></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<form class="panel" onsubmit={save} aria-busy={saving}>
|
||||||
|
<header class="panel-head">
|
||||||
|
<h2 class="panel-title">{$t('sharedsecrets.detail.config')}<span class="title-accent">.</span></h2>
|
||||||
|
<span class="panel-sub">
|
||||||
|
{$t('sharedsecrets.detail.configSub', { scope: scopeLabel(secret) })}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="s-name" class="sub-label">{$t('sharedsecrets.form.name')}</label>
|
||||||
|
<input id="s-name" type="text" class="input mono" bind:value={name} required autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="s-value" class="sub-label">{$t('sharedsecrets.form.value')}</label>
|
||||||
|
<input
|
||||||
|
id="s-value"
|
||||||
|
type="password"
|
||||||
|
class="input mono"
|
||||||
|
bind:value
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder={$t('sharedsecrets.form.valuePlaceholderEdit')}
|
||||||
|
/>
|
||||||
|
<p class="hint">
|
||||||
|
{secret.has_value
|
||||||
|
? $t('sharedsecrets.form.valueHintEditSet')
|
||||||
|
: $t('sharedsecrets.form.valueHintEditUnset')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row-toggle">
|
||||||
|
<div class="toggle-copy">
|
||||||
|
<span class="lbl" aria-hidden="true">{$t('sharedsecrets.form.encrypted')}</span>
|
||||||
|
<p class="hint">{$t('sharedsecrets.form.encryptedHint')}</p>
|
||||||
|
{#if encryptedFlipBlocked}
|
||||||
|
<p class="hint warn">{$t('sharedsecrets.form.encryptedFlipWarning')}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch bind:checked={encrypted} label={$t('sharedsecrets.form.encrypted')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="s-scope" class="sub-label">{$t('sharedsecrets.form.scope')}</label>
|
||||||
|
<select id="s-scope" class="input" bind:value={scope} onchange={onScopeChange}>
|
||||||
|
<option value="global">{$t('sharedsecrets.form.scopeGlobalOption')}</option>
|
||||||
|
<option value="app">{$t('sharedsecrets.form.scopeAppOption')}</option>
|
||||||
|
</select>
|
||||||
|
{#if scope === 'app'}
|
||||||
|
<div class="scope-picker">
|
||||||
|
{#if appID === ''}
|
||||||
|
<div class="scope-state global">
|
||||||
|
<span class="scope-icon" aria-hidden="true">●</span>
|
||||||
|
<span class="scope-text muted">{$t('sharedsecrets.form.scopeAppEmpty')}</span>
|
||||||
|
<button type="button" class="forge-btn-ghost xs" onclick={() => (pickerOpen = true)}>
|
||||||
|
{$t('sharedsecrets.form.scopePick')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="scope-state app">
|
||||||
|
<span class="scope-tag">{$t('sharedsecrets.form.scopeSelected')}</span>
|
||||||
|
{#if selectedApp}
|
||||||
|
<span class="scope-text">{selectedApp.name}</span>
|
||||||
|
{#if selectedApp.description}
|
||||||
|
<code class="scope-meta">{selectedApp.description}</code>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<span class="scope-text muted">{$t('sharedsecrets.form.scopeUnknown')}</span>
|
||||||
|
<code class="scope-meta">{appID}</code>
|
||||||
|
{/if}
|
||||||
|
<button type="button" class="forge-btn-ghost xs" onclick={() => (pickerOpen = true)}>
|
||||||
|
{$t('observability.edit')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="scope-clear"
|
||||||
|
onclick={clearApp}
|
||||||
|
aria-label={$t('sharedsecrets.form.scopeClear')}
|
||||||
|
title={$t('sharedsecrets.form.scopeClear')}
|
||||||
|
>
|
||||||
|
<IconX size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="s-description" class="sub-label">{$t('sharedsecrets.form.description')}</label>
|
||||||
|
<input id="s-description" type="text" class="input" bind:value={description} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row-toggle">
|
||||||
|
<div class="toggle-copy">
|
||||||
|
<span class="lbl" aria-hidden="true">{$t('sharedsecrets.form.enabled')}</span>
|
||||||
|
<p class="hint">{$t('sharedsecrets.form.enabledHint')}</p>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch bind:checked={enabled} label={$t('sharedsecrets.form.enabled')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="forge-btn"
|
||||||
|
disabled={saving || !canSave}
|
||||||
|
aria-busy={saving}
|
||||||
|
>
|
||||||
|
{saving ? $t('observability.saving') : $t('observability.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="panel danger-panel" aria-labelledby="danger-heading">
|
||||||
|
<header class="panel-head">
|
||||||
|
<h2 class="panel-title" id="danger-heading">{$t('sharedsecrets.detail.dangerZone')}<span class="title-accent">.</span></h2>
|
||||||
|
<span class="panel-sub">{$t('sharedsecrets.detail.dangerZoneSub')}</span>
|
||||||
|
</header>
|
||||||
|
<div class="danger-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="forge-btn-ghost forge-btn-danger"
|
||||||
|
onclick={() => (confirmDelete = true)}
|
||||||
|
>
|
||||||
|
{$t('sharedsecrets.detail.deleteButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDelete}
|
||||||
|
title={$t('sharedsecrets.detail.deleteTitle')}
|
||||||
|
message={$t('sharedsecrets.detail.deleteMessage', { name: name.trim() || secret.name })}
|
||||||
|
confirmLabel={deleting ? $t('observability.deleting') : $t('observability.delete')}
|
||||||
|
confirmVariant="danger"
|
||||||
|
onconfirm={doDelete}
|
||||||
|
oncancel={() => (confirmDelete = false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EntityPicker
|
||||||
|
bind:open={pickerOpen}
|
||||||
|
items={pickerItems}
|
||||||
|
current={appID}
|
||||||
|
title={$t('sharedsecrets.form.scopePickTitle')}
|
||||||
|
onselect={pickApp}
|
||||||
|
onclose={() => (pickerOpen = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.forge {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
max-width: 820px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Alert ─────────────────────────────────────── */
|
||||||
|
.alert {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.7rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
background: var(--color-danger-light);
|
||||||
|
color: var(--color-danger-dark);
|
||||||
|
border: 1px solid var(--color-danger);
|
||||||
|
border-left-width: 4px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.alert-tag {
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
background: var(--color-danger);
|
||||||
|
color: var(--surface-card);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
:global([data-theme='dark']) .alert {
|
||||||
|
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||||
|
color: color-mix(in srgb, var(--color-danger) 60%, var(--text-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Skeleton ──────────────────────────────────── */
|
||||||
|
.skeleton-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
.skeleton-row {
|
||||||
|
height: 64px;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-2xl);
|
||||||
|
background: linear-gradient(
|
||||||
|
110deg,
|
||||||
|
var(--surface-card) 20%,
|
||||||
|
var(--surface-card-hover) 50%,
|
||||||
|
var(--surface-card) 80%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.6s linear infinite;
|
||||||
|
animation-delay: calc(var(--i) * 120ms);
|
||||||
|
}
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Panel ─────────────────────────────────────── */
|
||||||
|
.panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.9rem;
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-2xl);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.panel {
|
||||||
|
padding: 1.1rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.panel.danger-panel {
|
||||||
|
border-color: color-mix(in srgb, var(--color-danger) 35%, var(--border-primary));
|
||||||
|
}
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
.panel-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.title-accent {
|
||||||
|
color: var(--forge-accent);
|
||||||
|
}
|
||||||
|
.panel-sub {
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Fields ────────────────────────────────────── */
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.sub-label {
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface-input);
|
||||||
|
border: 1px solid var(--border-input);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||||
|
}
|
||||||
|
.input.mono {
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
}
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
box-shadow: 0 0 0 3px var(--forge-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hints ──────────────────────────────────────── */
|
||||||
|
.hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.hint.warn {
|
||||||
|
color: var(--color-warning-dark, #b45309);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toggle row ─────────────────────────────────── */
|
||||||
|
.row-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px dashed var(--border-primary);
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
.toggle-copy {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.lbl {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Action footers ─────────────────────────────── */
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding-top: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.danger-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.actions {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.actions :global(.forge-btn),
|
||||||
|
.actions :global(.forge-btn-ghost) {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.danger-actions :global(.forge-btn-ghost) {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scope picker chip ──────────────────────────── */
|
||||||
|
.scope-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
.scope-state {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
background: var(--surface-card-hover);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
.scope-state.global {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
.scope-state.app {
|
||||||
|
background: color-mix(in srgb, var(--color-brand-500) 8%, var(--surface-card-hover));
|
||||||
|
border-color: color-mix(in srgb, var(--color-brand-500) 35%, var(--border-primary));
|
||||||
|
}
|
||||||
|
.scope-icon {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.scope-text {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.scope-text.muted {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.scope-tag {
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.12rem 0.4rem;
|
||||||
|
background: var(--color-brand-500);
|
||||||
|
color: var(--surface-card);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.scope-meta {
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.scope-state .forge-btn-ghost {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.scope-state.app .forge-btn-ghost {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
.scope-clear {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 120ms ease, border-color 120ms ease;
|
||||||
|
}
|
||||||
|
.scope-clear:hover {
|
||||||
|
color: var(--color-danger);
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
:global(.forge-btn-ghost.xs) {
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import * as api from '$lib/api';
|
||||||
|
import type { SharedSecretInput } from '$lib/api';
|
||||||
|
import type { EntityPickerItem, App } from '$lib/types';
|
||||||
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
|
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||||
|
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||||
|
import { IconX } from '$lib/components/icons';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
|
let name = $state('');
|
||||||
|
// The secret value lives only in this in-memory field — never logged
|
||||||
|
// and never persisted client-side beyond the form lifetime.
|
||||||
|
let value = $state('');
|
||||||
|
let encrypted = $state(true);
|
||||||
|
let scope = $state<'global' | 'app'>('global');
|
||||||
|
let appID = $state('');
|
||||||
|
let description = $state('');
|
||||||
|
let enabled = $state(true);
|
||||||
|
|
||||||
|
let submitting = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
// App picker state. Loaded once on mount so the modal is instant.
|
||||||
|
// Failure to load is non-fatal — surfaced in the page-level alert.
|
||||||
|
let apps = $state<App[]>([]);
|
||||||
|
let pickerOpen = $state(false);
|
||||||
|
|
||||||
|
// Map each app grouping to a picker item.
|
||||||
|
const pickerItems = $derived<EntityPickerItem[]>(
|
||||||
|
apps.map((a) => ({
|
||||||
|
value: a.id,
|
||||||
|
label: a.name,
|
||||||
|
description: a.description
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedApp = $derived(apps.find((a) => a.id === appID));
|
||||||
|
|
||||||
|
// CREATE requires: name + scope; for app scope, an app_id too.
|
||||||
|
const canSubmit = $derived(
|
||||||
|
name.trim() !== '' && (scope === 'global' || appID.trim() !== '')
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
apps = await api.listApps();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to load apps';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function pickApp(value: string): void {
|
||||||
|
appID = value;
|
||||||
|
pickerOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearApp(): void {
|
||||||
|
appID = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switching back to global drops any selected app so we never send a
|
||||||
|
// stale app_id with a global secret.
|
||||||
|
function onScopeChange(): void {
|
||||||
|
if (scope === 'global') appID = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(e: Event): Promise<void> {
|
||||||
|
e.preventDefault();
|
||||||
|
if (submitting || !canSubmit) return;
|
||||||
|
error = '';
|
||||||
|
submitting = true;
|
||||||
|
try {
|
||||||
|
const body: SharedSecretInput = {
|
||||||
|
name: name.trim(),
|
||||||
|
encrypted,
|
||||||
|
scope,
|
||||||
|
app_id: scope === 'app' ? appID.trim() : '',
|
||||||
|
description: description.trim(),
|
||||||
|
enabled
|
||||||
|
};
|
||||||
|
// Only send a value if the operator typed one. An empty secret
|
||||||
|
// is technically allowed; omitting the field keeps the body lean.
|
||||||
|
if (value !== '') body.value = value;
|
||||||
|
const created = await api.createSharedSecret(body);
|
||||||
|
goto(`/shared-secrets/${created.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
error = conflictMessage(e);
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A duplicate (scope, app_id, name) returns 409 — surface a friendly
|
||||||
|
// message instead of the raw backend error.
|
||||||
|
function conflictMessage(e: unknown): string {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Create failed';
|
||||||
|
if (/\b409\b/.test(msg) || /conflict/i.test(msg) || /already exists/i.test(msg)) {
|
||||||
|
return $t('sharedsecrets.form.conflict');
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$t('sharedsecrets.titleNew')} · Tinyforge</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="forge">
|
||||||
|
{#snippet lede()}
|
||||||
|
{$t('sharedsecrets.ledeNew')}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<ForgeHero
|
||||||
|
backHref="/shared-secrets"
|
||||||
|
backLabel={$t('sharedsecrets.toolbar.backToList')}
|
||||||
|
eyebrowSuffix={$t('sharedsecrets.toolbar.newButton').toUpperCase()}
|
||||||
|
title={$t('sharedsecrets.titleNew')}
|
||||||
|
size="lg"
|
||||||
|
lede_html={lede}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form onsubmit={submit} class="form" aria-busy={submitting}>
|
||||||
|
{#if error}
|
||||||
|
<div class="alert" role="alert">
|
||||||
|
<span class="alert-tag">ERR</span><span>{error}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="s-name" class="field-label">
|
||||||
|
<span class="num" aria-hidden="true">01</span>
|
||||||
|
<span class="lbl">{$t('sharedsecrets.form.name')}</span>
|
||||||
|
<span class="req">{$t('sharedsecrets.form.required')}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="s-name"
|
||||||
|
type="text"
|
||||||
|
class="input mono"
|
||||||
|
bind:value={name}
|
||||||
|
required
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder={$t('sharedsecrets.form.namePlaceholder')}
|
||||||
|
/>
|
||||||
|
<p class="hint">{$t('sharedsecrets.form.nameHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field group">
|
||||||
|
<div class="field-label">
|
||||||
|
<span class="num" aria-hidden="true">02</span>
|
||||||
|
<span class="lbl">{$t('sharedsecrets.form.value')}</span>
|
||||||
|
<span class="opt">{$t('sharedsecrets.form.optional')}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="s-value"
|
||||||
|
type="password"
|
||||||
|
class="input mono"
|
||||||
|
bind:value
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder={$t('sharedsecrets.form.valuePlaceholder')}
|
||||||
|
/>
|
||||||
|
<p class="hint">{$t('sharedsecrets.form.valueHintNew')}</p>
|
||||||
|
<div class="row-toggle inline">
|
||||||
|
<div class="toggle-copy">
|
||||||
|
<span class="lbl small" aria-hidden="true">{$t('sharedsecrets.form.encrypted')}</span>
|
||||||
|
<p class="hint">{$t('sharedsecrets.form.encryptedHint')}</p>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch bind:checked={encrypted} label={$t('sharedsecrets.form.encrypted')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="s-scope" class="field-label">
|
||||||
|
<span class="num" aria-hidden="true">03</span>
|
||||||
|
<span class="lbl">{$t('sharedsecrets.form.scope')}</span>
|
||||||
|
<span class="req">{$t('sharedsecrets.form.required')}</span>
|
||||||
|
</label>
|
||||||
|
<select id="s-scope" class="input" bind:value={scope} onchange={onScopeChange}>
|
||||||
|
<option value="global">{$t('sharedsecrets.form.scopeGlobalOption')}</option>
|
||||||
|
<option value="app">{$t('sharedsecrets.form.scopeAppOption')}</option>
|
||||||
|
</select>
|
||||||
|
{#if scope === 'app'}
|
||||||
|
<div class="scope-picker">
|
||||||
|
{#if appID === ''}
|
||||||
|
<div class="scope-state global">
|
||||||
|
<span class="scope-icon" aria-hidden="true">●</span>
|
||||||
|
<span class="scope-text muted">{$t('sharedsecrets.form.scopeAppEmpty')}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="forge-btn-ghost xs"
|
||||||
|
onclick={() => (pickerOpen = true)}
|
||||||
|
>
|
||||||
|
{$t('sharedsecrets.form.scopePick')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="scope-state app">
|
||||||
|
<span class="scope-tag">{$t('sharedsecrets.form.scopeSelected')}</span>
|
||||||
|
{#if selectedApp}
|
||||||
|
<span class="scope-text">{selectedApp.name}</span>
|
||||||
|
{#if selectedApp.description}
|
||||||
|
<code class="scope-meta">{selectedApp.description}</code>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<span class="scope-text muted">{$t('sharedsecrets.form.scopeUnknown')}</span>
|
||||||
|
<code class="scope-meta">{appID}</code>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="forge-btn-ghost xs"
|
||||||
|
onclick={() => (pickerOpen = true)}
|
||||||
|
>
|
||||||
|
{$t('observability.edit')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="scope-clear"
|
||||||
|
onclick={clearApp}
|
||||||
|
aria-label={$t('sharedsecrets.form.scopeClear')}
|
||||||
|
title={$t('sharedsecrets.form.scopeClear')}
|
||||||
|
>
|
||||||
|
<IconX size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<p class="hint">{$t('sharedsecrets.form.scopeHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="s-description" class="field-label">
|
||||||
|
<span class="num" aria-hidden="true">04</span>
|
||||||
|
<span class="lbl">{$t('sharedsecrets.form.description')}</span>
|
||||||
|
<span class="opt">{$t('sharedsecrets.form.optional')}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="s-description"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
bind:value={description}
|
||||||
|
placeholder={$t('sharedsecrets.form.descriptionPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field row-toggle">
|
||||||
|
<div class="toggle-copy">
|
||||||
|
<span class="lbl small" aria-hidden="true">{$t('sharedsecrets.form.enabled')}</span>
|
||||||
|
<p class="hint">{$t('sharedsecrets.form.enabledHint')}</p>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch bind:checked={enabled} label={$t('sharedsecrets.form.enabled')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<a href="/shared-secrets" class="forge-btn-ghost">{$t('observability.cancel')}</a>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="forge-btn"
|
||||||
|
disabled={submitting || !canSubmit}
|
||||||
|
aria-busy={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? $t('sharedsecrets.form.submitting') : $t('sharedsecrets.form.submit')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<EntityPicker
|
||||||
|
bind:open={pickerOpen}
|
||||||
|
items={pickerItems}
|
||||||
|
current={appID}
|
||||||
|
title={$t('sharedsecrets.form.scopePickTitle')}
|
||||||
|
onselect={pickApp}
|
||||||
|
onclose={() => (pickerOpen = false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.forge {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: 820px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-2xl);
|
||||||
|
padding: 1.75rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.form {
|
||||||
|
padding: 1.1rem;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Alert ─────────────────────────────────────── */
|
||||||
|
.alert {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.7rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
background: var(--color-danger-light);
|
||||||
|
color: var(--color-danger-dark);
|
||||||
|
border: 1px solid var(--color-danger);
|
||||||
|
border-left-width: 4px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.alert-tag {
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
background: var(--color-danger);
|
||||||
|
color: var(--surface-card);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
:global([data-theme='dark']) .alert {
|
||||||
|
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||||
|
color: color-mix(in srgb, var(--color-danger) 60%, var(--text-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Field structure ────────────────────────────── */
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.field.group {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.field-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
.num {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--text-primary);
|
||||||
|
color: var(--surface-card);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.lbl {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.lbl.small {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.req,
|
||||||
|
.opt {
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
font-size: 0.58rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
}
|
||||||
|
.req {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
.opt {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Inputs ─────────────────────────────────────── */
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface-input);
|
||||||
|
border: 1px solid var(--border-input);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||||
|
}
|
||||||
|
.input.mono {
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
}
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
box-shadow: 0 0 0 3px var(--forge-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hints ──────────────────────────────────────── */
|
||||||
|
.hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toggle row ─────────────────────────────────── */
|
||||||
|
.row-toggle {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-top: 0.6rem;
|
||||||
|
border-top: 1px dashed var(--border-primary);
|
||||||
|
}
|
||||||
|
.row-toggle.inline {
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
.toggle-copy {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Action footer ──────────────────────────────── */
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.55rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.actions {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.actions :global(.forge-btn),
|
||||||
|
.actions :global(.forge-btn-ghost) {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scope picker chip ────────────────────────────────────
|
||||||
|
Renders the selected app as a single state row: either
|
||||||
|
"● [Pick app…]" or "App · <name> <desc> [Edit] [×]".
|
||||||
|
Visual style mirrors the metric-alert scope chip. */
|
||||||
|
.scope-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.scope-state {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
background: var(--surface-card-hover);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
.scope-state.global {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
.scope-state.app {
|
||||||
|
background: color-mix(in srgb, var(--color-brand-500) 8%, var(--surface-card-hover));
|
||||||
|
border-color: color-mix(in srgb, var(--color-brand-500) 35%, var(--border-primary));
|
||||||
|
}
|
||||||
|
.scope-icon {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.scope-text {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.scope-text.muted {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.scope-tag {
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.12rem 0.4rem;
|
||||||
|
background: var(--color-brand-500);
|
||||||
|
color: var(--surface-card);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.scope-meta {
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.scope-state .forge-btn-ghost {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.scope-state.app .forge-btn-ghost {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
.scope-clear {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 120ms ease, border-color 120ms ease;
|
||||||
|
}
|
||||||
|
.scope-clear:hover {
|
||||||
|
color: var(--color-danger);
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
:global(.forge-btn-ghost.xs) {
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user