TypeScript 97.5%
SQL 1.4%
Python 0.8%
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