15e5b186cd
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).
543 lines
14 KiB
Svelte
543 lines
14 KiB
Svelte
<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>
|