1"use client";
2
3
14import { Fragment, useState } from "react";
15import { Check, Copy, ChevronDown, ChevronRight, KeyRound, Loader2, Trash2 } from "lucide-react";
16import { ScopesBadge } from "./ScopesBadge";
17import { cn } from "@/lib/utils";
18import { curlExample, expiryState, keyStatus, relativeTime, shortDate } from "@/lib/api-keys";
19
20export interface ApiKeyRow {
21 id: string;
22 name: string;
23 prefix: string;
24 scopes: string[];
25 rateLimit: number | null;
26 lastUsedAt: string | null;
27 expiresAt: string | null;
28 revokedAt: string | null;
29 createdAt: string;
30}
31
32interface ApiKeyTableProps {
33 rows: ApiKeyRow[];
34 isLoading: boolean;
35 baseApiUrl: string;
36 workspaceId: string;
37 onRevoke: (row: ApiKeyRow) => void;
38 revokingId: string | null;
39 onCreate: () => void;
40}
41
42const NOW = Date.now();
43
44export function ApiKeyTable({
45 rows,
46 isLoading,
47 baseApiUrl,
48 workspaceId,
49 onRevoke,
50 revokingId,
51 onCreate,
52}: ApiKeyTableProps) {
53 const [expandedId, setExpandedId] = useState<string | null>(null);
54 const [confirmId, setConfirmId] = useState<string | null>(null);
55 const [copiedId, setCopiedId] = useState<string | null>(null);
56
57 const copyPrefix = async (row: ApiKeyRow) => {
58 try {
59 await navigator.clipboard.writeText(row.prefix);
60 setCopiedId(row.id);
61 setTimeout(() => setCopiedId((c) => (c === row.id ? null : c)), 2000);
62 } catch {
63
64 }
65 };
66
67 const curl = (row: ApiKeyRow) =>
68 curlExample(
69 row.prefix,
70 "POST",
71 `${baseApiUrl}/workspaces/${workspaceId}/pages`,
72 JSON.stringify({ title: "New page" }),
73 );
74
75 return (
76 <div>
77 {isLoading ? (
78 <div className="space-y-2" aria-label="Loading API keys">
79 {[0, 1, 2].map((i) => (
80 <div key={i} className="h-12 animate-pulse rounded-md bg-muted/60" />
81 ))}
82 </div>
83 ) : rows.length === 0 ? (
84 <div className="flex flex-col items-center gap-2 rounded-md border border-dashed border-border py-8 text-center">
85 <KeyRound className="size-6 text-muted-foreground/60" aria-hidden />
86 <p className="text-sm text-muted-foreground">
87 No API keys yet — create one to connect integrations.
88 </p>
89 <button
90 type="button"
91 onClick={onCreate}
92 className="mt-1 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90"
93 data-testid="apikey-empty-create"
94 >
95 Create API key
96 </button>
97 </div>
98 ) : (
99 <div className="overflow-x-auto">
100 <table className="w-full text-sm">
101 <caption className="sr-only">API keys for this workspace</caption>
102 <thead>
103 <tr className="border-b border-border text-left text-xs text-muted-foreground">
104 <th className="py-2 pr-2 font-medium">Key</th>
105 <th className="py-2 pr-2 font-medium">Scopes</th>
106 <th className="py-2 pr-2 font-medium">Last used</th>
107 <th className="py-2 pr-2 font-medium">Expiry</th>
108 <th className="py-2 pr-2 font-medium">Status</th>
109 <th className="py-2 font-medium">
110 <span className="sr-only">Actions</span>
111 </th>
112 </tr>
113 </thead>
114 <tbody>
115 {rows.map((row) => {
116 const status = keyStatus(row, NOW);
117 const exp = expiryState(row.expiresAt, NOW);
118 const expanded = expandedId === row.id;
119 return (
120 <>
121 <tr key={row.id} className="border-b border-border/60 align-top">
122 <td className="py-2.5 pr-2">
123 <button
124 type="button"
125 className="flex items-center gap-1 text-left"
126 aria-expanded={expanded}
127 onClick={() => setExpandedId(expanded ? null : row.id)}
128 >
129 {expanded ? (
130 <ChevronDown
131 className="size-3.5 shrink-0 text-muted-foreground"
132 aria-hidden
133 />
134 ) : (
135 <ChevronRight
136 className="size-3.5 shrink-0 text-muted-foreground"
137 aria-hidden
138 />
139 )}
140 <span className="min-w-0">
141 <span className="block truncate font-medium">{row.name}</span>
142 <span className="flex items-center gap-1 text-xs text-muted-foreground">
143 <span className="font-mono">{row.prefix}•••</span>
144 <button
145 type="button"
146 onClick={(e) => {
147 e.stopPropagation();
148 void copyPrefix(row);
149 }}
150 aria-label={`Copy prefix of ${row.name}`}
151 className="rounded p-0.5 hover:bg-muted"
152 >
153 {copiedId === row.id ? (
154 <Check className="size-3 text-emerald-600" aria-hidden />
155 ) : (
156 <Copy className="size-3" aria-hidden />
157 )}
158 </button>
159 </span>
160 </span>
161 </button>
162 </td>
163 <td className="py-2.5 pr-2">
164 <ScopesBadge scopes={row.scopes} idPrefix={`row-${row.id}`} />
165 {row.rateLimit != null ? (
166 <span className="mt-1 block text-[11px] text-muted-foreground">
167 {row.rateLimit}/min
168 </span>
169 ) : null}
170 </td>
171 <td className="py-2.5 pr-2 text-xs text-muted-foreground">
172 {relativeTime(row.lastUsedAt, NOW)}
173 </td>
174 <td className="py-2.5 pr-2 text-xs">
175 {exp === "never" ? (
176 <span className="text-muted-foreground">never</span>
177 ) : exp === "expired" ? (
178 <span
179 className="text-muted-foreground/70 line-through"
180 title={`Expired ${shortDate(row.expiresAt!)}`}
181 >
182 {shortDate(row.expiresAt!)} (expired)
183 </span>
184 ) : exp === "soon" ? (
185 <span
186 className="font-medium text-destructive"
187 title="Expires in less than 7 days"
188 data-testid={`apikey-expiry-soon-${row.id}`}
189 >
190 {shortDate(row.expiresAt!)}
191 </span>
192 ) : (
193 <span>{shortDate(row.expiresAt!)}</span>
194 )}
195 </td>
196 <td className="py-2.5 pr-2">
197 <span
198 className={cn(
199 "inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium",
200 status === "Active" &&
201 "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
202 status === "Revoked" && "bg-muted text-muted-foreground",
203 status === "Expired" && "bg-muted text-muted-foreground",
204 )}
205 >
206 {status}
207 </span>
208 </td>
209 <td className="py-2.5 text-right">
210 {status === "Active" && confirmId !== row.id && (
211 <button
212 type="button"
213 onClick={() => setConfirmId(row.id)}
214 className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs hover:bg-destructive/10 hover:text-destructive"
215 data-testid={`apikey-revoke-${row.id}`}
216 >
217 <Trash2 className="size-3" aria-hidden />
218 Revoke
219 </button>
220 )}
221 </td>
222 </tr>
223
224 {}
225 {confirmId === row.id && status === "Active" && (
226 <tr
227 key={`${row.id}-confirm`}
228 className="border-b border-destructive/30 bg-destructive/5"
229 >
230 <td colSpan={6} className="py-2 pl-8 pr-2 text-xs text-destructive">
231 <span className="font-medium">
232 Revoke this key? Requests will fail immediately with 401.
233 </span>{" "}
234 <button
235 type="button"
236 disabled={revokingId === row.id}
237 onClick={() => {
238 setConfirmId(null);
239 onRevoke(row);
240 }}
241 className="ml-2 rounded-md bg-destructive px-2 py-0.5 font-medium text-white hover:opacity-90 disabled:opacity-50"
242 data-testid={`apikey-revoke-confirm-${row.id}`}
243 >
244 {revokingId === row.id ? (
245 <span className="inline-flex items-center gap-1">
246 <Loader2 className="size-3 animate-spin" aria-hidden /> Revoke…
247 </span>
248 ) : (
249 "Revoke"
250 )}
251 </button>{" "}
252 <button
253 type="button"
254 onClick={() => setConfirmId(null)}
255 className="ml-1 underline underline-offset-2"
256 >
257 Cancel
258 </button>
259 </td>
260 </tr>
261 )}
262
263 {}
264 {expanded && (
265 <tr key={`${row.id}-curl`} className="border-b border-border/60 bg-muted/30">
266 <td colSpan={6} className="py-2 pl-8 pr-2">
267 <p className="mb-1 text-[11px] text-muted-foreground">
268 Example request (replace the bearer placeholder with the full key):
269 </p>
270 <pre className="overflow-x-auto rounded-md border border-border bg-background p-2 font-mono text-[11px] leading-relaxed">
271 {curl(row)}
272 </pre>
273 </td>
274 </tr>
275 )}
276 </>
277 );
278 })}
279 </tbody>
280 </table>
281 </div>
282 )}
283 </div>
284 );
285}
286