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
209 lines
6.4 KiB
TypeScript
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>
|
|
)
|
|
}
|