mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
## What Adds a **Settings → Secrets** page so secrets can be managed from the browser, backed by the existing secrets HTTP API and generated TS client. - **`/settings/secrets`** — lists stored secrets (name, type badge, description, last-updated) and deletes them through the shared confirm dialog. - **`/settings/secrets/new`** — the create form for **token** and **file** secrets. OAuth secrets still list and delete here, but are created by provider sign-in flows, not typed by hand (matching the CLI's `secret set`). - The **Secrets** entry is added to the settings sidebar nav. ## How - `secretsApi` wired into `api-client.ts`; `useSecrets()` SWR hook + `secrets` query key. - New sibling routes `secrets` and `secrets/new` under `settings` (same pattern as `runs` / `runs/:id`). - The settings layout gains optional **handle-driven** `description` and `headerAction`. When a page declares them, the layout renders title + subheading + a vertically-centered header action button as one unified header. Other settings pages are unaffected — they fall back to the existing title-only header. ## Notes - Values are write-only: the API never returns secret values, and the UI never displays them. - Reuses existing primitives throughout (`Panel`, `Badge`, `ConfirmDialog`, `useToast`, button/input classes) — no new shared components. - Verified: `bun run typecheck` and `bun run build` pass; routes serve 200. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
163 lines
4.8 KiB
TypeScript
163 lines
4.8 KiB
TypeScript
import { useState } from "react";
|
|
import { Link } from "react-router";
|
|
import { useSWRConfig } from "swr";
|
|
import { PlusIcon } from "@heroicons/react/16/solid";
|
|
import type { SecretMetadata } from "@qltysh/fabro-api-client";
|
|
|
|
import { ApiError, apiData, secretsApi } from "../lib/api-client";
|
|
import { useSecrets } from "../lib/queries";
|
|
import { queryKeys } from "../lib/query-keys";
|
|
import {
|
|
Badge,
|
|
Panel,
|
|
PanelSkeleton,
|
|
SettingsPageIntro,
|
|
} from "../components/settings-panel";
|
|
import { COMPACT_SECONDARY_BUTTON_CLASS, ConfirmDialog } from "../components/ui";
|
|
import { useToast } from "../components/toast";
|
|
import { formatAbsoluteTs, formatRelativeTime } from "../lib/format";
|
|
|
|
export function meta() {
|
|
return [{ title: "Secrets — Fabro" }];
|
|
}
|
|
|
|
const DESCRIPTION =
|
|
"Secrets are values stored on this Fabro server and made available to workflow runs. Values are write-only — they can be replaced or deleted, but never read back through the UI.";
|
|
|
|
export default function SettingsSecrets() {
|
|
const query = useSecrets();
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<SettingsPageIntro
|
|
description={DESCRIPTION}
|
|
action={
|
|
<Link
|
|
to="/settings/secrets/new"
|
|
className="inline-flex items-center gap-1.5 rounded-md border border-line bg-panel/80 px-2.5 py-1 text-sm font-medium text-fg-3 transition-colors hover:border-line-strong hover:bg-panel hover:text-fg"
|
|
>
|
|
<PlusIcon className="size-3.5" aria-hidden="true" />
|
|
New secret
|
|
</Link>
|
|
}
|
|
/>
|
|
{query.data ? (
|
|
<SecretsPanel secrets={query.data.data} />
|
|
) : query.error ? (
|
|
<Panel title="Stored secrets">
|
|
<div className="px-4 py-6 text-sm text-fg-2">
|
|
Couldn't load secrets. Please try again.
|
|
</div>
|
|
</Panel>
|
|
) : (
|
|
<PanelSkeleton />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SecretsPanel({ secrets }: { secrets: SecretMetadata[] }) {
|
|
const { mutate } = useSWRConfig();
|
|
const toast = useToast();
|
|
const [pendingDeleteName, setPendingDeleteName] = useState<string | null>(null);
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
async function confirmDelete() {
|
|
if (!pendingDeleteName) return;
|
|
const name = pendingDeleteName;
|
|
setDeleting(true);
|
|
try {
|
|
await apiData(() => secretsApi.deleteSecretByName({ name }));
|
|
await mutate(queryKeys.secrets.list());
|
|
toast.push({ message: `Secret “${name}” deleted.` });
|
|
setPendingDeleteName(null);
|
|
} catch (cause) {
|
|
toast.push({
|
|
tone: "error",
|
|
message:
|
|
cause instanceof ApiError && cause.message
|
|
? cause.message
|
|
: "Couldn't delete the secret. Please try again.",
|
|
});
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Panel title="Stored secrets">
|
|
{secrets.length === 0 ? (
|
|
<div className="px-4 py-6 text-sm text-fg-muted">
|
|
No secrets stored yet.
|
|
</div>
|
|
) : (
|
|
secrets.map((secret) => (
|
|
<SecretRow
|
|
key={secret.name}
|
|
secret={secret}
|
|
disabled={deleting}
|
|
onDelete={() => setPendingDeleteName(secret.name)}
|
|
/>
|
|
))
|
|
)}
|
|
</Panel>
|
|
<ConfirmDialog
|
|
open={pendingDeleteName !== null}
|
|
title="Delete secret"
|
|
description={
|
|
<>
|
|
Delete{" "}
|
|
<span className="font-mono text-fg-2">{pendingDeleteName}</span>? Workflow
|
|
runs that depend on it will no longer have access.
|
|
</>
|
|
}
|
|
confirmLabel="Delete"
|
|
pendingLabel="Deleting…"
|
|
pending={deleting}
|
|
onConfirm={confirmDelete}
|
|
onCancel={() => {
|
|
if (!deleting) setPendingDeleteName(null);
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function SecretRow({
|
|
secret,
|
|
disabled,
|
|
onDelete,
|
|
}: {
|
|
secret: SecretMetadata;
|
|
disabled: boolean;
|
|
onDelete: () => void;
|
|
}) {
|
|
return (
|
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4 px-4 py-3.5">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="truncate font-mono text-sm text-fg" title={secret.name}>
|
|
{secret.name}
|
|
</span>
|
|
<Badge>{secret.type}</Badge>
|
|
</div>
|
|
<div className="mt-0.5 text-xs/5 text-fg-3">
|
|
{secret.description ? <span>{secret.description} · </span> : null}
|
|
<span title={formatAbsoluteTs(secret.updated_at)}>
|
|
Updated {formatRelativeTime(secret.updated_at)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onDelete}
|
|
disabled={disabled}
|
|
aria-label={`Delete secret ${secret.name}`}
|
|
className={COMPACT_SECONDARY_BUTTON_CLASS}
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|