SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
9.0 KB · 237 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { useRouter } from "next/navigation";4import { Plus } from "lucide-react";5import { API_KEY_SCOPES } from "@fetcha/core/client";6import { createApiKey, type CreatedKey } from "@/actions/api-keys";7import { Button } from "@/components/ui/button";8import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";9import { Input } from "@/components/ui/input";10import { Field, Hint, Label } from "@/components/ui/label";11import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";12import { Alert } from "@/components/ui/alert";13import { Badge } from "@/components/ui/badge";14import { DEFAULT_SCOPES, SCOPE_INFO } from "@/lib/account-snippets";15import { cn } from "@/lib/utils";16import { KeyReveal } from "./key-reveal";1718export interface ProjectOption {19  id: string;20  name: string;21  environment: string;22}2324const EXPIRATIONS = [25  { value: "0", label: "Never expires" },26  { value: "30", label: "30 days" },27  { value: "90", label: "90 days" },28  { value: "365", label: "1 year" },29];3031export function CreateKeyDialog({32  projects,33  defaultProjectId,34  trigger,35  defaultOpen,36  onCreated,37}: {38  projects: ProjectOption[];39  defaultProjectId: string;40  trigger?: React.ReactNode;41  defaultOpen?: boolean;42  onCreated?: (key: CreatedKey) => void;43}) {44  const router = useRouter();45  const [open, setOpen] = React.useState(Boolean(defaultOpen));46  const [created, setCreated] = React.useState<CreatedKey | null>(null);4748  function handleOpenChange(next: boolean) {49    // While the plaintext is on screen, closing must go through the explicit "Done" button.50    if (!next && created) return;51    setOpen(next);52    if (!next) setCreated(null);53  }5455  return (56    <Dialog open={open} onOpenChange={handleOpenChange}>57      <DialogTrigger asChild>58        {trigger ?? (59          <Button variant="primary">60            <Plus /> Create API key61          </Button>62        )}63      </DialogTrigger>64      <DialogContent size="lg" onEscapeKeyDown={(e) => created && e.preventDefault()} onPointerDownOutside={(e) => created && e.preventDefault()} onInteractOutside={(e) => created && e.preventDefault()}>65        {created ? (66          <>67            <DialogHeader>68              <DialogTitle>API key created</DialogTitle>69              <DialogDescription>Your new key for {created.name} is ready.</DialogDescription>70            </DialogHeader>71            <KeyReveal72              plaintext={created.plaintext}73              name={created.name}74              onDone={() => {75                setCreated(null);76                setOpen(false);77                router.refresh();78              }}79            />80          </>81        ) : (82          <>83            <DialogHeader>84              <DialogTitle>Create API key</DialogTitle>85              <DialogDescription>Keys authenticate requests to the Fetcha API. The secret is shown once, then stored hashed.</DialogDescription>86            </DialogHeader>87            <CreateKeyForm88              projects={projects}89              defaultProjectId={defaultProjectId}90              onCreated={(k) => {91                setCreated(k);92                onCreated?.(k);93              }}94            />95          </>96        )}97      </DialogContent>98    </Dialog>99  );100}101102/** The form alone (also used inline by onboarding). */103export function CreateKeyForm({104  projects,105  defaultProjectId,106  onCreated,107  compact,108  defaultName = "",109  submitLabel = "Create key",110}: {111  projects: ProjectOption[];112  defaultProjectId: string;113  onCreated: (key: CreatedKey) => void;114  compact?: boolean;115  defaultName?: string;116  submitLabel?: string;117}) {118  const [pending, startTransition] = React.useTransition();119  const [name, setName] = React.useState(defaultName);120  const [projectId, setProjectId] = React.useState(defaultProjectId);121  const [mode, setMode] = React.useState<"live" | "test">("live");122  const [scopes, setScopes] = React.useState<string[]>(DEFAULT_SCOPES);123  const [expires, setExpires] = React.useState("0");124  const [error, setError] = React.useState<string | null>(null);125126  function toggleScope(s: string) {127    setScopes((cur) => (cur.includes(s) ? cur.filter((x) => x !== s) : [...cur, s]));128  }129130  function submit(e: React.FormEvent) {131    e.preventDefault();132    setError(null);133    startTransition(async () => {134      const res = await createApiKey({ name, projectId, mode, scopes, expiresInDays: Number(expires) || undefined });135      if (!res.ok) {136        setError(res.error);137        return;138      }139      if (res.data) onCreated(res.data);140    });141  }142143  return (144    <form onSubmit={submit} className="grid gap-4" noValidate>145      {error ? <Alert variant="danger">{error}</Alert> : null}146      <div className={cn("grid gap-4", !compact && "sm:grid-cols-2")}>147        <Field>148          <Label htmlFor="key-name">Name</Label>149          <Input id="key-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Production backend" maxLength={64} required autoFocus />150          <Hint>Only for you — appears in the key list and audit log.</Hint>151        </Field>152        <Field>153          <Label htmlFor="key-project">Project</Label>154          <Select value={projectId} onValueChange={setProjectId}>155            <SelectTrigger id="key-project">156              <SelectValue placeholder="Select a project" />157            </SelectTrigger>158            <SelectContent>159              {projects.map((p) => (160                <SelectItem key={p.id} value={p.id}>161                  {p.name} <span className="text-fg-subtle">· {p.environment}</span>162                </SelectItem>163              ))}164            </SelectContent>165          </Select>166          <Hint>Requests, usage and limits are tracked per project.</Hint>167        </Field>168      </div>169170      <fieldset className="grid gap-2">171        <legend className="mb-1.5 text-[13px] font-medium">Mode</legend>172        <div className={cn("grid gap-2", !compact && "sm:grid-cols-2")}>173          {(174            [175              { v: "live", title: "Live", desc: "fch_live_… — real traffic through the network, billed against your plan." },176              { v: "test", title: "Test", desc: "fch_test_… — for integration tests and CI. Same API, clearly separated in logs and usage." },177            ] as const178          ).map((o) => (179            <label key={o.v} className={cn("flex cursor-pointer items-start gap-2.5 rounded-md border px-3 py-2.5 transition-colors", mode === o.v ? "border-accent bg-accent-soft/40" : "border-border hover:border-border-strong")}>180              <input type="radio" name="mode" value={o.v} checked={mode === o.v} onChange={() => setMode(o.v)} className="mt-0.5 accent-[var(--accent)]" />181              <span className="grid gap-0.5">182                <span className="text-[13px] font-medium">{o.title}</span>183                <span className="text-[12px] leading-relaxed text-fg-muted">{o.desc}</span>184              </span>185            </label>186          ))}187        </div>188      </fieldset>189190      <fieldset className="grid gap-2">191        <legend className="mb-1.5 text-[13px] font-medium">Scopes</legend>192        <div className="grid gap-1.5">193          {API_KEY_SCOPES.map((s) => {194            const info = SCOPE_INFO[s];195            const disabled = Boolean(info?.comingSoon);196            return (197              <label key={s} className={cn("flex items-start gap-2.5 rounded-md border border-border px-3 py-2", disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer hover:border-border-strong")}>198                <input type="checkbox" checked={!disabled && scopes.includes(s)} disabled={disabled} onChange={() => toggleScope(s)} className="mt-0.5 size-4 rounded border-border accent-[var(--accent)]" />199                <span className="grid gap-0.5">200                  <span className="flex items-center gap-2 font-mono text-[12.5px]">201                    {info?.label ?? s}202                    {disabled ? <Badge variant="outline">Coming soon</Badge> : null}203                  </span>204                  <span className="text-[12px] text-fg-muted">{info?.description}</span>205                </span>206              </label>207            );208          })}209        </div>210      </fieldset>211212      <Field>213        <Label htmlFor="key-expires">Expiration</Label>214        <Select value={expires} onValueChange={setExpires}>215          <SelectTrigger id="key-expires" className="sm:max-w-xs">216            <SelectValue />217          </SelectTrigger>218          <SelectContent>219            {EXPIRATIONS.map((o) => (220              <SelectItem key={o.value} value={o.value}>221                {o.label}222              </SelectItem>223            ))}224          </SelectContent>225        </Select>226        <Hint>Expired keys are rejected with INVALID_API_KEY. Short-lived keys are safer for CI and contractors.</Hint>227      </Field>228229      <div className="flex items-center justify-end gap-2 border-t border-border pt-4">230        <Button type="submit" variant="primary" loading={pending} disabled={!name.trim() || scopes.length === 0}>231          {submitLabel}232        </Button>233      </div>234    </form>235  );236}237