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
@@ -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>