Files
app/frontend/src/routes/_layout/secrets.tsx
T
stroblmeandClaude Opus 5.5 b086d5d22b Show Secrets to superusers only, and guard its route as Admin is
The page reads GET /secrets/, which is a superuser's, so any other account
opened it onto a permission error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
2026-10-06 12:33:00 +02:00

209 lines
6.4 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, redirect } from "@tanstack/react-router"
import { KeyRound, Plus, Trash2 } from "lucide-react"
import { useState } from "react"
import { SecretsService, UsersService } from "@/client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
export const Route = createFileRoute("/_layout/secrets")({
component: Secrets,
// `GET /secrets/` is a superuser's, as Admin is.
beforeLoad: async () => {
const user = await UsersService.readUserMe()
if (!user.is_superuser) {
throw redirect({
to: "/",
})
}
},
head: () => ({
meta: [
{
title: "Secrets - Fluksio",
},
],
}),
})
/** The flow editor's credential picker reads this too, so saving refreshes it. */
const secretsKey = ["secrets"]
/** A name that survives being a URL path segment, and reads well in a flow. */
const NAME_PATTERN = /^[\w.-]+$/
function Secrets() {
const { data } = useQuery({
queryKey: secretsKey,
queryFn: () => SecretsService.readSecrets(),
})
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
const [name, setName] = useState("")
const [value, setValue] = useState("")
const [pendingDelete, setPendingDelete] = useState<string | null>(null)
const refresh = () => {
queryClient.invalidateQueries({ queryKey: secretsKey })
}
const save = useMutation({
mutationFn: (secret: { name: string; value: string }) =>
SecretsService.saveSecret({
name: secret.name,
requestBody: { value: secret.value },
}),
onSuccess: (_result, secret) => {
showSuccessToast(`Saved '${secret.name}'`)
setName("")
setValue("")
},
onError: handleError.bind(showErrorToast),
onSettled: refresh,
})
const remove = useMutation({
mutationFn: (secret: string) =>
SecretsService.deleteSecret({ name: secret }),
onSuccess: (_result, secret) => {
showSuccessToast(`Deleted '${secret}'`)
setPendingDelete(null)
},
onError: handleError.bind(showErrorToast),
onSettled: refresh,
})
const names = data?.data ?? []
const trimmed = name.trim()
const valid = NAME_PATTERN.test(trimmed) && value.length > 0
const replacing = names.includes(trimmed)
return (
<div className="grid gap-6">
<div className="grid gap-1">
<h1 className="text-2xl">Secrets</h1>
<p className="text-sm text-muted-foreground">
Credentials your nodes reach for by name, stored encrypted outside the
flow store. A value goes in and never comes back out: replace one you
have rotated, delete one you no longer use, but nothing here can read
it back.
</p>
</div>
<form
className="grid gap-2 sm:grid-cols-[1fr_1fr_auto]"
onSubmit={(event) => {
event.preventDefault()
if (valid) save.mutate({ name: trimmed, value })
}}
>
<Input
value={name}
placeholder="Name"
aria-label="Secret name"
data-testid="secret-name"
onChange={(event) => setName(event.target.value)}
/>
<Input
type="password"
value={value}
placeholder="Value"
aria-label="Secret value"
data-testid="secret-value"
onChange={(event) => setValue(event.target.value)}
/>
<Button
type="submit"
variant="secondary"
disabled={!valid || save.isPending}
data-testid="save-secret"
>
<Plus />
{replacing ? "Replace" : "Add"}
</Button>
</form>
{names.length === 0 ? (
<p className="text-sm text-muted-foreground">
No secrets yet. A node parameter written as{" "}
<code className="font-mono">{`{"$secret": "name"}`}</code> picks its
value up from here.
</p>
) : (
<div className="grid gap-2">
{names.map((secret) => (
<div
key={secret}
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card p-3 shadow-e1"
data-testid="secret-row"
>
<span className="flex items-center gap-2 font-mono text-sm">
<KeyRound className="size-4 text-muted-foreground" />
{secret}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setName(secret)}
title="Put this name in the form to store a new value"
>
Replace
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Delete ${secret}`}
className="text-destructive hover:text-destructive"
onClick={() => setPendingDelete(secret)}
>
<Trash2 />
</Button>
</div>
</div>
))}
</div>
)}
<Dialog
open={pendingDelete !== null}
onOpenChange={(open) => !open && setPendingDelete(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete '{pendingDelete}'?</DialogTitle>
<DialogDescription>
Any node whose parameter points at this name stops working until a
value is stored under it again. The value cannot be recovered.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setPendingDelete(null)}>
Keep it
</Button>
<Button
variant="destructive"
disabled={remove.isPending}
onClick={() => pendingDelete && remove.mutate(pendingDelete)}
data-testid="confirm-delete-secret"
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}