427 lines
13 KiB
Svelte
427 lines
13 KiB
Svelte
<script lang="ts">
|
||
import FileIcon from "@lucide/svelte/icons/file";
|
||
import PlusIcon from "@lucide/svelte/icons/plus";
|
||
import { toast } from "svelte-sonner";
|
||
import type { SecretRow, SecretWrite } from "$lib/api/types";
|
||
import EmptyState from "$lib/components/empty-state.svelte";
|
||
import ErrorNote from "$lib/components/error-note.svelte";
|
||
import { Button } from "$lib/components/ui/button";
|
||
import * as Dialog from "$lib/components/ui/dialog";
|
||
import { Input } from "$lib/components/ui/input";
|
||
import { Label } from "$lib/components/ui/label";
|
||
import * as Tabs from "$lib/components/ui/tabs";
|
||
import { fmtBytes, fmtDateTime, fmtRelative } from "$lib/format";
|
||
import { session } from "$lib/session.svelte";
|
||
|
||
const NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
|
||
const MAX_SIZE = 1024 * 1024;
|
||
|
||
let secrets = $state<SecretRow[] | null>(null);
|
||
let enabled = $state(true);
|
||
let failure = $state<string | null>(null);
|
||
|
||
let editOpen = $state(false);
|
||
let editing = $state<SecretRow | null>(null);
|
||
let secretName = $state("");
|
||
let description = $state("");
|
||
let mode = $state<"value" | "file">("value");
|
||
let value = $state("");
|
||
let files = $state<FileList | undefined>();
|
||
let busy = $state(false);
|
||
|
||
let doomed = $state<SecretRow | null>(null);
|
||
|
||
const file = $derived(files?.[0]);
|
||
const nameError = $derived(
|
||
!editing && secretName && !NAME.test(secretName)
|
||
? "Capital letters, digits and _, starting with a letter."
|
||
: null
|
||
);
|
||
const fileError = $derived(
|
||
file && file.size > MAX_SIZE ? "Files are capped at 1 MB." : null
|
||
);
|
||
const hasPayload = $derived(mode === "value" ? value !== "" : !!file);
|
||
const canSave = $derived(
|
||
!(busy || nameError || fileError) &&
|
||
(editing ? true : NAME.test(secretName) && hasPayload)
|
||
);
|
||
|
||
async function load() {
|
||
const { client } = session;
|
||
if (!client) {
|
||
return;
|
||
}
|
||
failure = null;
|
||
try {
|
||
({ secrets, enabled } = await client.secrets());
|
||
} catch (cause) {
|
||
failure = cause instanceof Error ? cause.message : String(cause);
|
||
}
|
||
}
|
||
|
||
$effect(() => {
|
||
load();
|
||
});
|
||
|
||
function openEditor(row: SecretRow | null) {
|
||
editing = row;
|
||
secretName = row?.name ?? "";
|
||
description = row?.description ?? "";
|
||
mode = row?.kind ?? "value";
|
||
value = "";
|
||
files = undefined;
|
||
editOpen = true;
|
||
}
|
||
|
||
function base64Of(blob: File): Promise<string> {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const url = String(reader.result);
|
||
resolve(url.slice(url.indexOf(",") + 1));
|
||
};
|
||
reader.onerror = () => reject(reader.error);
|
||
reader.readAsDataURL(blob);
|
||
});
|
||
}
|
||
|
||
async function save() {
|
||
const { client } = session;
|
||
if (!client) {
|
||
return;
|
||
}
|
||
busy = true;
|
||
try {
|
||
const body: SecretWrite = { description };
|
||
if (mode === "value" && value) {
|
||
body.value = value;
|
||
}
|
||
if (mode === "file" && file) {
|
||
body.file = { content: await base64Of(file), name: file.name };
|
||
}
|
||
const target = editing?.name ?? secretName;
|
||
await client.putSecret(target, body);
|
||
toast.success(editing ? `Updated ${target}` : `Saved ${target}`);
|
||
editOpen = false;
|
||
value = "";
|
||
files = undefined;
|
||
await load();
|
||
} catch (cause) {
|
||
toast.error(cause instanceof Error ? cause.message : String(cause));
|
||
} finally {
|
||
busy = false;
|
||
}
|
||
}
|
||
|
||
async function remove(row: SecretRow) {
|
||
const { client } = session;
|
||
if (!client) {
|
||
return;
|
||
}
|
||
busy = true;
|
||
try {
|
||
await client.deleteSecret(row.name);
|
||
toast.success(`Deleted ${row.name}`);
|
||
doomed = null;
|
||
await load();
|
||
} catch (cause) {
|
||
toast.error(cause instanceof Error ? cause.message : String(cause));
|
||
} finally {
|
||
busy = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<div class="flex flex-wrap items-center gap-4">
|
||
<h2 class="font-medium text-sm">Secrets</h2>
|
||
<span class="text-muted-foreground text-xs">
|
||
write-only · the agent runs
|
||
<code class="rounded bg-muted px-1 py-0.5">secret run NAME -- cmd</code>
|
||
</span>
|
||
<Button
|
||
class="ml-auto"
|
||
disabled={!enabled}
|
||
onclick={() => openEditor(null)}
|
||
size="sm"
|
||
variant="outline"
|
||
>
|
||
<PlusIcon class="size-4" />
|
||
New secret
|
||
</Button>
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-6">
|
||
{#if !enabled}
|
||
<div
|
||
class="flex flex-col gap-1 rounded-lg border border-warn/40 bg-warn/5 p-3 text-sm"
|
||
role="status"
|
||
>
|
||
<p class="font-medium">Secrets are off: SECRETS_KEY is not set.</p>
|
||
<p class="text-muted-foreground">
|
||
Put a key in the gateway's environment (<code
|
||
class="rounded bg-muted px-1 py-0.5"
|
||
>openssl rand -hex 32</code
|
||
>) and recreate the container. Existing names stay listed; values cannot
|
||
be set or read until then.
|
||
</p>
|
||
</div>
|
||
{/if}
|
||
{#if failure}
|
||
<ErrorNote message={failure} retry={() => load()} />
|
||
{:else if secrets === null}
|
||
<p class="text-muted-foreground text-sm">Loading…</p>
|
||
{:else if secrets.length === 0}
|
||
<EmptyState
|
||
hint="Keys and files the agent runs commands with. They are sealed with SECRETS_KEY in the gateway's database, reach a command as environment variables, and are never shown again after saving."
|
||
title="No secrets"
|
||
>
|
||
{#if enabled}
|
||
<Button onclick={() => openEditor(null)} size="sm" variant="outline">
|
||
Add the first secret
|
||
</Button>
|
||
{/if}
|
||
</EmptyState>
|
||
{:else}
|
||
<div class="overflow-x-auto rounded-lg border bg-strip">
|
||
<table class="w-full text-sm">
|
||
<thead class="text-muted-foreground text-xs">
|
||
<tr class="border-b text-left">
|
||
<th class="px-3 py-2 font-medium">name</th>
|
||
<th class="hidden px-3 py-2 font-medium sm:table-cell">
|
||
description
|
||
</th>
|
||
<th class="hidden px-3 py-2 font-medium sm:table-cell">kind</th>
|
||
<th class="hidden px-3 py-2 font-medium sm:table-cell">changed</th>
|
||
<th class="hidden px-3 py-2 font-medium sm:table-cell">
|
||
last used
|
||
</th>
|
||
<th class="px-3 py-2 text-right font-medium"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{#each secrets as row (row.name)}
|
||
<tr class="row-hover border-b last:border-b-0">
|
||
<td class="px-3 py-1.5">
|
||
<code class="break-all font-medium">{row.name}</code>
|
||
<div
|
||
class="flex flex-col gap-0.5 pt-0.5 text-muted-foreground text-xs sm:hidden"
|
||
>
|
||
{#if row.description}
|
||
<span class="text-foreground/80">{row.description}</span>
|
||
{/if}
|
||
<span class="tabular">
|
||
{row.filename
|
||
? `${row.filename} · ${fmtBytes(row.size)}`
|
||
: "value"}
|
||
· changed {fmtRelative(row.updated_at)} ·
|
||
{row.used_at
|
||
? `used ${fmtRelative(row.used_at)}`
|
||
: "never used"}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td class="hidden min-w-48 px-3 py-1.5 sm:table-cell">
|
||
{#if row.description}
|
||
{row.description}
|
||
{:else}
|
||
<span class="text-muted-foreground">–</span>
|
||
{/if}
|
||
</td>
|
||
<td
|
||
class="hidden whitespace-nowrap px-3 py-1.5 text-xs sm:table-cell"
|
||
>
|
||
{#if row.filename}
|
||
<span class="inline-flex items-center gap-1">
|
||
<FileIcon class="size-3.5 text-icon" />
|
||
{row.filename}
|
||
<span class="tabular text-muted-foreground"
|
||
>{fmtBytes(row.size)}</span
|
||
>
|
||
</span>
|
||
{:else}
|
||
<span class="text-muted-foreground">value</span>
|
||
{/if}
|
||
</td>
|
||
<td
|
||
class="tabular hidden whitespace-nowrap px-3 py-1.5 text-xs sm:table-cell"
|
||
title={fmtDateTime(row.updated_at)}
|
||
>
|
||
{fmtRelative(row.updated_at)}
|
||
</td>
|
||
<td
|
||
class="tabular hidden whitespace-nowrap px-3 py-1.5 text-xs sm:table-cell"
|
||
title={row.used_at ? fmtDateTime(row.used_at) : undefined}
|
||
>
|
||
{#if row.used_at}
|
||
{fmtRelative(row.used_at)}
|
||
{:else}
|
||
<span class="text-muted-foreground">never</span>
|
||
{/if}
|
||
</td>
|
||
<td
|
||
class="flex flex-col items-end px-1 py-1.5 sm:table-cell sm:whitespace-nowrap sm:px-3 sm:text-right"
|
||
>
|
||
<Button
|
||
disabled={!enabled}
|
||
onclick={() => openEditor(row)}
|
||
size="sm"
|
||
variant="ghost"
|
||
>
|
||
Replace
|
||
</Button>
|
||
<Button
|
||
class="text-destructive hover:text-destructive"
|
||
onclick={() => {
|
||
doomed = row;
|
||
}}
|
||
size="sm"
|
||
variant="ghost"
|
||
>
|
||
Delete
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<Dialog.Root bind:open={editOpen}>
|
||
<Dialog.Content>
|
||
<Dialog.Header>
|
||
<Dialog.Title>
|
||
{editing ? `Replace ${editing.name}` : "New secret"}
|
||
</Dialog.Title>
|
||
<Dialog.Description>
|
||
{editing
|
||
? "Leave the value empty to change only the description."
|
||
: "Stored encrypted. After saving, the value is not shown anywhere."}
|
||
</Dialog.Description>
|
||
</Dialog.Header>
|
||
<form
|
||
autocomplete="off"
|
||
class="flex flex-col gap-3"
|
||
onsubmit={(event) => {
|
||
event.preventDefault();
|
||
save();
|
||
}}
|
||
>
|
||
{#if !editing}
|
||
<div class="flex flex-col gap-1.5">
|
||
<Label for="secret-name">Name</Label>
|
||
<Input
|
||
aria-invalid={nameError ? true : undefined}
|
||
autocapitalize="characters"
|
||
class="font-mono"
|
||
id="secret-name"
|
||
oninput={() => {
|
||
secretName = secretName.toUpperCase().replaceAll(/[\s-]/g, "_");
|
||
}}
|
||
placeholder="NODEREAL_BSC"
|
||
required
|
||
spellcheck={false}
|
||
bind:value={secretName}
|
||
/>
|
||
<p class="text-muted-foreground text-xs">
|
||
{nameError ?? `The command reads it as $${secretName || "NAME"}.`}
|
||
</p>
|
||
</div>
|
||
{/if}
|
||
<div class="flex flex-col gap-1.5">
|
||
<Label for="secret-description">Description</Label>
|
||
<Input
|
||
id="secret-description"
|
||
placeholder="What it opens and who issued it"
|
||
bind:value={description}
|
||
/>
|
||
</div>
|
||
<Tabs.Root bind:value={mode}>
|
||
<Tabs.List class="w-full">
|
||
<Tabs.Trigger value="value">Value</Tabs.Trigger>
|
||
<Tabs.Trigger value="file">File</Tabs.Trigger>
|
||
</Tabs.List>
|
||
<Tabs.Content class="flex flex-col gap-1.5 pt-2" value="value">
|
||
<Label class="sr-only" for="secret-value">Value</Label>
|
||
<Input
|
||
autocomplete="new-password"
|
||
class="font-mono"
|
||
id="secret-value"
|
||
placeholder={editing ? "New value" : "Paste the value"}
|
||
spellcheck={false}
|
||
type="password"
|
||
bind:value
|
||
/>
|
||
<p class="text-muted-foreground text-xs">
|
||
One line. Multi-line keys and JSON go in as a file.
|
||
</p>
|
||
</Tabs.Content>
|
||
<Tabs.Content class="flex flex-col gap-1.5 pt-2" value="file">
|
||
<Label class="sr-only" for="secret-file">File</Label>
|
||
<Input
|
||
aria-invalid={fileError ? true : undefined}
|
||
id="secret-file"
|
||
type="file"
|
||
bind:files
|
||
/>
|
||
<p class="text-muted-foreground text-xs">
|
||
{fileError ??
|
||
"Up to 1 MB. The command gets $NAME as the path to a private copy, removed when it exits."}
|
||
</p>
|
||
</Tabs.Content>
|
||
</Tabs.Root>
|
||
<Dialog.Footer>
|
||
<Button
|
||
onclick={() => {
|
||
editOpen = false;
|
||
}}
|
||
type="button"
|
||
variant="ghost"
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button disabled={!canSave} type="submit">
|
||
{editing && !hasPayload ? "Save description" : "Save"}
|
||
</Button>
|
||
</Dialog.Footer>
|
||
</form>
|
||
</Dialog.Content>
|
||
</Dialog.Root>
|
||
|
||
<Dialog.Root
|
||
onOpenChange={(open) => {
|
||
if (!open) {
|
||
doomed = null;
|
||
}
|
||
}}
|
||
open={doomed !== null}
|
||
>
|
||
<Dialog.Content>
|
||
<Dialog.Header>
|
||
<Dialog.Title>Delete {doomed?.name}?</Dialog.Title>
|
||
<Dialog.Description>
|
||
Commands that ask for it will fail with “unknown secret”. The value
|
||
cannot be recovered.
|
||
</Dialog.Description>
|
||
</Dialog.Header>
|
||
<Dialog.Footer>
|
||
<Button
|
||
onclick={() => {
|
||
doomed = null;
|
||
}}
|
||
variant="ghost"
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
disabled={busy}
|
||
onclick={() => doomed && remove(doomed)}
|
||
variant="destructive"
|
||
>
|
||
Delete
|
||
</Button>
|
||
</Dialog.Footer>
|
||
</Dialog.Content>
|
||
</Dialog.Root>
|