414 lines · 13.4 KB · TypeScript
RawDownload
import { useState, Suspense } from "react";import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react";import { secretsAtom, setSecret, removeSecret } from "../api/atoms";import { secretWriteKeys } from "../api/reactivity-keys";import type { SecretProviderPlugin } from "../plugins/secret-provider-plugin";import { SecretId } from "@executor/sdk";import { useScope } from "../hooks/use-scope";import {  Dialog,  DialogContent,  DialogHeader,  DialogTitle,  DialogDescription,  DialogFooter,  DialogClose,} from "../components/dialog";import { Button } from "../components/button";import { Input } from "../components/input";import { Label } from "../components/label";import {  DropdownMenu,  DropdownMenuContent,  DropdownMenuItem,  DropdownMenuTrigger,} from "../components/dropdown-menu";import {  Select,  SelectContent,  SelectItem,  SelectTrigger,  SelectValue,} from "../components/select";import {  CardStack,  CardStackContent,  CardStackEntry,  CardStackEntryActions,  CardStackEntryContent,  CardStackEntryDescription,  CardStackEntryTitle,  CardStackHeader,} from "../components/card-stack";import { Badge } from "../components/badge"; type SecretStorageOption = {  readonly label: string;  readonly value: string;}; const defaultStorageOptions: readonly SecretStorageOption[] = [  { value: "auto", label: "Auto" },  { value: "keychain", label: "Keychain" },  { value: "file", label: "File" },]; // ---------------------------------------------------------------------------// Add secret dialog// --------------------------------------------------------------------------- function AddSecretDialog(props: {  open: boolean;  onOpenChange: (v: boolean) => void;  description: string;  storageOptions: readonly SecretStorageOption[];}) {  const initialProvider = props.storageOptions[0]?.value ?? "auto";  const [id, setId] = useState("");  const [name, setName] = useState("");  const [value, setValue] = useState("");  const [provider, setProvider] = useState(initialProvider);  const [saving, setSaving] = useState(false);  const [error, setError] = useState<string | null>(null);   const scopeId = useScope();  const doSet = useAtomSet(setSecret, { mode: "promise" });   const reset = () => {    setId("");    setName("");    setValue("");    setProvider(initialProvider);    setError(null);    setSaving(false);  };   const handleSave = async () => {    if (!id.trim() || !name.trim() || !value.trim()) return;    setSaving(true);    setError(null);    try {      await doSet({        path: { scopeId },        payload: {          id: SecretId.make(id.trim()),          name: name.trim(),          value: value.trim(),          provider: provider === "auto" ? undefined : provider,        },        reactivityKeys: secretWriteKeys,      });      reset();      props.onOpenChange(false);    } catch (e) {      setError(e instanceof Error ? e.message : "Failed to save secret");      setSaving(false);    }  };   return (    <Dialog      open={props.open}      onOpenChange={(v) => {        if (!v) reset();        props.onOpenChange(v);      }}    >      <DialogContent className="sm:max-w-[440px]">        <DialogHeader>          <DialogTitle className="font-display text-xl">New secret</DialogTitle>          <DialogDescription className="text-sm leading-relaxed">            {props.description}          </DialogDescription>        </DialogHeader>         <div className="grid gap-5 py-3">          <div className="grid grid-cols-2 gap-3">            <div className="grid gap-1.5">              <Label                htmlFor="secret-id"                className="text-sm font-medium uppercase tracking-wider text-muted-foreground"              >                ID              </Label>              <Input                id="secret-id"                placeholder="github-token"                value={id}                onChange={(e) => setId((e.target as HTMLInputElement).value)}                className="font-mono text-xs h-9"              />            </div>            <div className="grid gap-1.5">              <Label                htmlFor="secret-name"                className="text-sm font-medium uppercase tracking-wider text-muted-foreground"              >                Name              </Label>              <Input                id="secret-name"                placeholder="GitHub PAT"                value={name}                onChange={(e) => setName((e.target as HTMLInputElement).value)}                className="text-sm h-9"              />            </div>          </div>           <div className="grid gap-1.5">            <Label              htmlFor="secret-value"              className="text-sm font-medium uppercase tracking-wider text-muted-foreground"            >              Value            </Label>            <Input              id="secret-value"              type="password"              placeholder="ghp_xxxxxxxxxxxxxxxxxxxx"              value={value}              onChange={(e) => setValue((e.target as HTMLInputElement).value)}              className="font-mono text-xs h-9"            />          </div>           <div className="grid gap-3">            {props.storageOptions.length > 1 && (              <div className="grid gap-1.5">                <Label                  htmlFor="secret-provider"                  className="text-sm font-medium uppercase tracking-wider text-muted-foreground"                >                  Storage                </Label>                <Select value={provider} onValueChange={setProvider}>                  <SelectTrigger id="secret-provider" className="h-9 text-sm">                    <SelectValue />                  </SelectTrigger>                  <SelectContent>                    {props.storageOptions.map((option) => (                      <SelectItem key={option.value} value={option.value}>                        {option.label}                      </SelectItem>                    ))}                  </SelectContent>                </Select>              </div>            )}          </div>           {error && (            <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">              <p className="text-sm text-destructive">{error}</p>            </div>          )}        </div>         <DialogFooter>          <DialogClose asChild>            <Button variant="ghost" size="sm">              Cancel            </Button>          </DialogClose>          <Button            size="sm"            onClick={handleSave}            disabled={!id.trim() || !name.trim() || !value.trim() || saving}          >            {saving ? "Saving…" : "Save secret"}          </Button>        </DialogFooter>      </DialogContent>    </Dialog>  );} // ---------------------------------------------------------------------------// Secret row// --------------------------------------------------------------------------- function SecretRow(props: {  showProvider: boolean;  secret: { id: string; name: string; provider?: string };  onRemove: () => void;}) {  const { secret, showProvider } = props;   return (    <CardStackEntry>      <CardStackEntryContent>        <CardStackEntryTitle className="flex items-center gap-2">          <span className="truncate">{secret.name}</span>        </CardStackEntryTitle>      </CardStackEntryContent>      <CardStackEntryActions>        {showProvider && secret.provider && <Badge variant="outline">{secret.provider}</Badge>}        <DropdownMenu>          <DropdownMenuTrigger asChild>            <Button              variant="ghost"              size="icon"              className="size-7 opacity-0 transition-opacity group-hover/card-stack-entry:opacity-100 group-focus-within/card-stack-entry:opacity-100 data-[state=open]:opacity-100"            >              <svg viewBox="0 0 16 16" className="size-3">                <circle cx="8" cy="3" r="1.2" fill="currentColor" />                <circle cx="8" cy="8" r="1.2" fill="currentColor" />                <circle cx="8" cy="13" r="1.2" fill="currentColor" />              </svg>            </Button>          </DropdownMenuTrigger>          <DropdownMenuContent align="end" className="w-40">            <DropdownMenuItem              className="text-destructive focus:text-destructive text-sm"              onClick={props.onRemove}            >              Remove secret            </DropdownMenuItem>          </DropdownMenuContent>        </DropdownMenu>      </CardStackEntryActions>    </CardStackEntry>  );} // ---------------------------------------------------------------------------// Page// --------------------------------------------------------------------------- export function SecretsPage(props: {  addSecretDescription?: string;  showProviderInfo?: boolean;  secretProviderPlugins: readonly SecretProviderPlugin[];  storageOptions?: readonly SecretStorageOption[];}) {  const storageOptions = props.storageOptions ?? defaultStorageOptions;  const showProviderInfo = props.showProviderInfo ?? true;  const addSecretDescription =    props.addSecretDescription ??    "Store a credential or API key. Values are kept in your system keychain when available, with a local encrypted file fallback.";  const { secretProviderPlugins } = props;  const [addOpen, setAddOpen] = useState(false);  const scopeId = useScope();  const secrets = useAtomValue(secretsAtom(scopeId));  const doRemove = useAtomSet(removeSecret, { mode: "promise" });   const handleRemove = async (secretId: string) => {    try {      await doRemove({        path: {          scopeId,          secretId: SecretId.make(secretId),        },        reactivityKeys: secretWriteKeys,      });    } catch {      // TODO: toast    }  };   return (    <div className="min-h-0 flex-1 overflow-y-auto">      <div className="mx-auto max-w-3xl px-6 py-10 lg:px-8 lg:py-14">        {/* Header */}        <div className="flex items-end justify-between mb-10">          <div>            <h1 className="font-display text-[2rem] tracking-tight text-foreground leading-none">              Secrets            </h1>            <p className="mt-2 text-sm text-muted-foreground leading-relaxed">              Credentials and API keys used by your connected sources.            </p>          </div>          <Button size="sm" onClick={() => setAddOpen(true)}>            Add secret          </Button>        </div>         {/* Provider plugins */}        {showProviderInfo && secretProviderPlugins.length > 0 && (          <div className="mb-10">            <CardStack>              <CardStackHeader>Providers</CardStackHeader>              <CardStackContent>                {secretProviderPlugins.map((plugin) => (                  <Suspense                    key={plugin.key}                    fallback={                      <div className="px-4 py-3 animate-pulse">                        <div className="h-4 w-24 rounded bg-muted" />                      </div>                    }                  >                    <plugin.settings />                  </Suspense>                ))}              </CardStackContent>            </CardStack>          </div>        )}         {/* Secrets list */}        {Result.match(secrets, {          onInitial: () => (            <div className="flex items-center gap-2 py-8">              <div className="size-1.5 rounded-full bg-muted-foreground/30 animate-pulse" />              <p className="text-sm text-muted-foreground">Loading secrets…</p>            </div>          ),          onFailure: () => (            <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3">              <p className="text-sm text-destructive">Failed to load secrets</p>            </div>          ),          onSuccess: ({ value }) => (            <CardStack>              <CardStackHeader>Secrets</CardStackHeader>              <CardStackContent>                {value.length === 0 ? (                  <CardStackEntry>                    <CardStackEntryContent>                      <CardStackEntryDescription>                        Add API keys and credentials to authenticate your sources.                      </CardStackEntryDescription>                    </CardStackEntryContent>                    <CardStackEntryActions>                      <Button                        variant="link"                        size="sm"                        className="h-7 px-0 text-xs"                        onClick={() => setAddOpen(true)}                      >                        Add your first secret                      </Button>                    </CardStackEntryActions>                  </CardStackEntry>                ) : (                  value.map((s) => (                    <SecretRow                      key={s.id}                      showProvider={showProviderInfo}                      secret={{                        id: s.id,                        name: s.name,                        provider: s.provider ? String(s.provider) : undefined,                      }}                      onRemove={() => handleRemove(s.id)}                    />                  ))                )}              </CardStackContent>            </CardStack>          ),        })}         <AddSecretDialog          open={addOpen}          onOpenChange={setAddOpen}          description={addSecretDescription}          storageOptions={storageOptions}        />      </div>    </div>  );}