Files
tiny-forge/web/src/routes/shared-secrets/new/+page.svelte
T
alexei.dolgolyov 15e5b186cd 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).
2026-05-29 16:11:46 +03:00

543 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>