1"use client";
2
3
16import { useEffect, useState } from "react";
17import { Dialog, DialogFooter } from "@/components/dialogs/dialog";
18import { inputCls } from "@/components/settings/settings-shell";
19import { toast } from "@/stores/ui-store";
20import {
21 API_KEY_SCOPES,
22 SCOPE_GRANTS,
23 KEY_NAME_MAX,
24 RATE_LIMIT_MIN,
25 RATE_LIMIT_MAX,
26 expiryBounds,
27 minimalScopes,
28} from "@/lib/api-keys";
29import type { ApiKeyScope } from "@/lib/api-keys";
30
31interface ApiKeyCreateDialogProps {
32 open: boolean;
33 onClose: () => void;
34 onCreated: (key: { rawKey: string; name: string }) => void;
35 create: (input: {
36 name: string;
37 scopes: string[];
38 rateLimit?: number;
39 expiresAt?: string;
40 }) => Promise<{ key: string }>;
41 isPending: boolean;
42}
43
44export function ApiKeyCreateDialog({
45 open,
46 onClose,
47 onCreated,
48 create,
49 isPending,
50}: ApiKeyCreateDialogProps) {
51 const [name, setName] = useState("");
52 const [selected, setSelected] = useState<ApiKeyScope[]>(["READ"]);
53 const [expiry, setExpiry] = useState("");
54 const [rate, setRate] = useState("");
55 const [nameError, setNameError] = useState<string | null>(null);
56 const now = open ? Date.now() : 0;
57 const bounds = open ? expiryBounds(now) : { min: "", max: "" };
58
59
60
61 useEffect(() => {
62 if (open) {
63 setName("");
64 setSelected(["READ"]);
65 setExpiry("");
66 setRate("");
67 setNameError(null);
68 }
69 }, [open]);
70
71 const toggle = (s: ApiKeyScope) =>
72 setSelected((prev) => (prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]));
73
74 const submit = async () => {
75 const trimmed = name.trim();
76 if (!trimmed) return;
77 if (selected.length === 0) {
78 setNameError("Select at least one scope.");
79 return;
80 }
81 const scopes = minimalScopes(selected);
82 const rateNum = rate.trim() === "" ? undefined : Number(rate);
83 if (
84 rateNum !== undefined &&
85 (!Number.isInteger(rateNum) || rateNum < RATE_LIMIT_MIN || rateNum > RATE_LIMIT_MAX)
86 ) {
87 toast(
88 "error",
89 `Rate limit must be a whole number between ${RATE_LIMIT_MIN} and ${RATE_LIMIT_MAX}.`,
90 );
91 return;
92 }
93 const expiresAt = expiry ? new Date(expiry).toISOString() : undefined;
94 try {
95 const res = await create({
96 name: trimmed,
97 scopes,
98 ...(rateNum !== undefined ? { rateLimit: rateNum } : {}),
99 ...(expiresAt ? { expiresAt } : {}),
100 });
101 onCreated({ rawKey: res.key, name: trimmed });
102 } catch (err) {
103 const e = err as { code?: string; status?: number; details?: unknown; message?: string };
104 if (e?.status === 409 || e?.code === "CONFLICT") {
105 setNameError(`An active key named “${trimmed}” already exists.`);
106 } else {
107 toast("error", e?.message ?? "Could not create the key.");
108 }
109 }
110 };
111
112 return (
113 <Dialog
114 open={open}
115 title="Create API key"
116 description="Authenticate server-to-server calls to the Factory API."
117 onClose={onClose}
118 footer={
119 <DialogFooter
120 onCancel={onClose}
121 onAction={submit}
122 actionLabel="Create key"
123 busy={isPending}
124 disabled={!name.trim() || selected.length === 0}
125 />
126 }
127 >
128 <div className="space-y-4">
129 <div>
130 <label
131 className="mb-1 block text-xs font-medium text-muted-foreground"
132 htmlFor="apikey-name"
133 >
134 Name
135 </label>
136 <input
137 id="apikey-name"
138 className={inputCls}
139 placeholder="CI"
140 value={name}
141 maxLength={KEY_NAME_MAX}
142 autoFocus
143 onChange={(e) => {
144 setName(e.target.value);
145 setNameError(null);
146 }}
147 aria-invalid={nameError ? true : undefined}
148 aria-describedby={nameError ? "apikey-name-error" : undefined}
149 />
150 {nameError ? (
151 <span
152 id="apikey-name-error"
153 role="alert"
154 className="mt-1 block text-xs text-destructive"
155 >
156 {nameError}
157 </span>
158 ) : (
159 <span className="mt-1 block text-[11px] text-muted-foreground">
160 Unique within this workspace · up to {KEY_NAME_MAX} characters.
161 </span>
162 )}
163 </div>
164
165 <fieldset>
166 <legend className="mb-1 block text-xs font-medium text-muted-foreground">Scopes</legend>
167 <div className="space-y-1.5">
168 {API_KEY_SCOPES.map((s) => (
169 <label
170 key={s}
171 className="flex cursor-pointer items-start gap-2 rounded-md border border-border px-2.5 py-2 text-sm hover:bg-accent/50"
172 >
173 <input
174 type="checkbox"
175 className="mt-0.5 accent-primary"
176 checked={selected.includes(s)}
177 onChange={() => toggle(s)}
178 aria-describedby={`apikey-scope-${s}`}
179 />
180 <span>
181 <span className="font-medium">{s}</span>
182 <span id={`apikey-scope-${s}`} className="ml-1.5 text-xs text-muted-foreground">
183 {SCOPE_GRANTS[s]}
184 </span>
185 </span>
186 </label>
187 ))}
188 </div>
189 <p className="mt-1.5 text-[11px] text-muted-foreground">
190 Implication: ADMIN → WRITE → READ. The key stores the minimal set you select.
191 </p>
192 </fieldset>
193
194 <div className="grid grid-cols-2 gap-3">
195 <div>
196 <label
197 className="mb-1 block text-xs font-medium text-muted-foreground"
198 htmlFor="apikey-expiry"
199 >
200 Expiry (optional)
201 </label>
202 <input
203 id="apikey-expiry"
204 type="datetime-local"
205 className={inputCls}
206 value={expiry}
207 min={bounds.min}
208 max={bounds.max}
209 onChange={(e) => setExpiry(e.target.value)}
210 />
211 <span className="mt-1 block text-[11px] text-muted-foreground">
212 Now + 1 h … + 1 year.
213 </span>
214 </div>
215 <div>
216 <label
217 className="mb-1 block text-xs font-medium text-muted-foreground"
218 htmlFor="apikey-rate"
219 >
220 Rate limit (req/min)
221 </label>
222 <input
223 id="apikey-rate"
224 type="number"
225 className={inputCls}
226 placeholder="600 (default)"
227 value={rate}
228 min={RATE_LIMIT_MIN}
229 max={RATE_LIMIT_MAX}
230 onChange={(e) => setRate(e.target.value)}
231 />
232 <span className="mt-1 block text-[11px] text-muted-foreground">
233 {RATE_LIMIT_MIN}–{RATE_LIMIT_MAX}.
234 </span>
235 </div>
236 </div>
237 </div>
238 </Dialog>
239 );
240}
241