|
1 | | -"use client"; |
2 | | - |
3 | | -import { Badge } from "@starter-saas/ui/components/badge"; |
4 | | -import { Button } from "@starter-saas/ui/components/button"; |
5 | | -import { |
6 | | - Card, |
7 | | - CardContent, |
8 | | - CardHeader, |
9 | | - CardTitle, |
10 | | -} from "@starter-saas/ui/components/card"; |
11 | | -import { |
12 | | - Dialog, |
13 | | - DialogContent, |
14 | | - DialogDescription, |
15 | | - DialogFooter, |
16 | | - DialogHeader, |
17 | | - DialogTitle, |
18 | | -} from "@starter-saas/ui/components/dialog"; |
19 | | -import { EmptyState } from "@starter-saas/ui/components/empty-state"; |
20 | | -import { Input } from "@starter-saas/ui/components/input"; |
21 | | -import { Label } from "@starter-saas/ui/components/label"; |
22 | | -import { Skeleton } from "@starter-saas/ui/components/skeleton"; |
23 | | -import { Copy, Key, Plus, Trash2 } from "lucide-react"; |
24 | | -import { useEffect, useState } from "react"; |
25 | | -import { toast } from "sonner"; |
26 | | -import { PageHeader } from "@/components/app/page-header"; |
27 | | - |
28 | | -type Row = { |
29 | | - id: string; |
30 | | - name: string; |
31 | | - prefix: string; |
32 | | - scopes: string[]; |
33 | | - lastUsedAt: string | null; |
34 | | - expiresAt: string | null; |
35 | | - revokedAt: string | null; |
36 | | - createdAt: string; |
37 | | -}; |
38 | | - |
39 | | -type IssuedKey = { |
40 | | - id: string; |
41 | | - prefix: string; |
42 | | - plaintext: string; |
43 | | -}; |
| 1 | +import { redirect } from "next/navigation"; |
44 | 2 |
|
| 3 | +// Consolidated into /dashboard/settings (API keys tab). |
45 | 4 | export default function ApiKeysPage() { |
46 | | - const [rows, setRows] = useState<Row[] | null>(null); |
47 | | - const [newName, setNewName] = useState(""); |
48 | | - const [busy, setBusy] = useState(false); |
49 | | - const [issued, setIssued] = useState<IssuedKey | null>(null); |
50 | | - |
51 | | - const load = async () => { |
52 | | - try { |
53 | | - const res = await fetch("/api/api-keys", { cache: "no-store" }); |
54 | | - if (!res.ok) { |
55 | | - throw new Error(`status ${res.status}`); |
56 | | - } |
57 | | - const data = (await res.json()) as { rows: Row[] }; |
58 | | - setRows(data.rows); |
59 | | - } catch (err) { |
60 | | - toast.error("Couldn't load API keys", { |
61 | | - description: err instanceof Error ? err.message : "?", |
62 | | - }); |
63 | | - setRows([]); |
64 | | - } |
65 | | - }; |
66 | | - |
67 | | - useEffect(() => { |
68 | | - void load(); |
69 | | - }, []); |
70 | | - |
71 | | - const create = async () => { |
72 | | - if (newName.trim().length < 1) { |
73 | | - toast.error("Give the key a name"); |
74 | | - return; |
75 | | - } |
76 | | - setBusy(true); |
77 | | - const toastId = toast.loading("Generating key…"); |
78 | | - try { |
79 | | - const res = await fetch("/api/api-keys", { |
80 | | - method: "POST", |
81 | | - headers: { "content-type": "application/json" }, |
82 | | - body: JSON.stringify({ name: newName.trim() }), |
83 | | - }); |
84 | | - if (!res.ok) { |
85 | | - const text = await res.text().catch(() => ""); |
86 | | - throw new Error(text || `status ${res.status}`); |
87 | | - } |
88 | | - const data = (await res.json()) as IssuedKey; |
89 | | - setIssued(data); |
90 | | - setNewName(""); |
91 | | - toast.success("Key created", { id: toastId }); |
92 | | - await load(); |
93 | | - } catch (err) { |
94 | | - toast.error("Couldn't create key", { |
95 | | - id: toastId, |
96 | | - description: err instanceof Error ? err.message : "?", |
97 | | - }); |
98 | | - } finally { |
99 | | - setBusy(false); |
100 | | - } |
101 | | - }; |
102 | | - |
103 | | - const revoke = async (row: Row) => { |
104 | | - if ( |
105 | | - !confirm(`Revoke ${row.name}? Apps using this key will stop working.`) |
106 | | - ) { |
107 | | - return; |
108 | | - } |
109 | | - const toastId = toast.loading("Revoking…"); |
110 | | - try { |
111 | | - const res = await fetch(`/api/api-keys/${row.id}`, { method: "DELETE" }); |
112 | | - if (!res.ok) { |
113 | | - throw new Error(`status ${res.status}`); |
114 | | - } |
115 | | - toast.success("Revoked", { id: toastId }); |
116 | | - await load(); |
117 | | - } catch (err) { |
118 | | - toast.error("Couldn't revoke", { |
119 | | - id: toastId, |
120 | | - description: err instanceof Error ? err.message : "?", |
121 | | - }); |
122 | | - } |
123 | | - }; |
124 | | - |
125 | | - return ( |
126 | | - <> |
127 | | - <PageHeader |
128 | | - title="API keys" |
129 | | - description="Personal tokens for hitting /api/v1 from scripts, integrations, and dev tools." |
130 | | - /> |
131 | | - |
132 | | - <Card> |
133 | | - <CardHeader> |
134 | | - <CardTitle className="text-base">Generate a key</CardTitle> |
135 | | - </CardHeader> |
136 | | - <CardContent> |
137 | | - <form |
138 | | - className="grid gap-3 sm:grid-cols-[1fr_auto]" |
139 | | - onSubmit={(e) => { |
140 | | - e.preventDefault(); |
141 | | - void create(); |
142 | | - }} |
143 | | - > |
144 | | - <div className="grid gap-1.5"> |
145 | | - <Label htmlFor="key-name" className="sr-only"> |
146 | | - Key name |
147 | | - </Label> |
148 | | - <Input |
149 | | - id="key-name" |
150 | | - value={newName} |
151 | | - onChange={(e) => setNewName(e.target.value)} |
152 | | - placeholder="laptop-cli, ci-bot, etc." |
153 | | - disabled={busy} |
154 | | - /> |
155 | | - </div> |
156 | | - <Button type="submit" disabled={busy}> |
157 | | - <Plus className="mr-1.5 h-4 w-4" /> Create |
158 | | - </Button> |
159 | | - </form> |
160 | | - </CardContent> |
161 | | - </Card> |
162 | | - |
163 | | - <div className="mt-6"> |
164 | | - {rows === null ? ( |
165 | | - <div className="grid gap-2"> |
166 | | - {Array.from({ length: 3 }).map((_, i) => ( |
167 | | - <Skeleton key={i} className="h-16 w-full" /> |
168 | | - ))} |
169 | | - </div> |
170 | | - ) : rows.length === 0 ? ( |
171 | | - <EmptyState |
172 | | - illustration="grid" |
173 | | - title="No keys yet" |
174 | | - description="Generate your first key above. We show the full token once — you'll need to copy it then." |
175 | | - /> |
176 | | - ) : ( |
177 | | - <ul className="divide-y rounded-lg border"> |
178 | | - {rows.map((row) => ( |
179 | | - <li key={row.id} className="flex items-center gap-3 px-4 py-3"> |
180 | | - <div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted"> |
181 | | - <Key className="h-4 w-4 text-muted-foreground" /> |
182 | | - </div> |
183 | | - <div className="min-w-0 flex-1"> |
184 | | - <p className="font-medium text-sm">{row.name}</p> |
185 | | - <p className="font-mono text-muted-foreground text-xs"> |
186 | | - {row.prefix}… |
187 | | - </p> |
188 | | - <p className="text-muted-foreground text-xs"> |
189 | | - {row.lastUsedAt |
190 | | - ? `Last used ${new Date(row.lastUsedAt).toLocaleString()}` |
191 | | - : "Never used"} |
192 | | - {row.expiresAt |
193 | | - ? ` · expires ${new Date(row.expiresAt).toLocaleDateString()}` |
194 | | - : ""} |
195 | | - </p> |
196 | | - </div> |
197 | | - {row.revokedAt ? ( |
198 | | - <Badge variant="outline" className="capitalize"> |
199 | | - Revoked |
200 | | - </Badge> |
201 | | - ) : ( |
202 | | - <Button |
203 | | - variant="ghost" |
204 | | - size="sm" |
205 | | - onClick={() => revoke(row)} |
206 | | - className="text-destructive hover:text-destructive" |
207 | | - > |
208 | | - <Trash2 className="h-4 w-4" /> |
209 | | - <span className="sr-only">Revoke</span> |
210 | | - </Button> |
211 | | - )} |
212 | | - </li> |
213 | | - ))} |
214 | | - </ul> |
215 | | - )} |
216 | | - </div> |
217 | | - |
218 | | - <IssuedKeyDialog |
219 | | - keyData={issued} |
220 | | - onOpenChange={(open) => { |
221 | | - if (!open) { |
222 | | - setIssued(null); |
223 | | - } |
224 | | - }} |
225 | | - /> |
226 | | - </> |
227 | | - ); |
228 | | -} |
229 | | - |
230 | | -function IssuedKeyDialog({ |
231 | | - keyData, |
232 | | - onOpenChange, |
233 | | -}: { |
234 | | - keyData: IssuedKey | null; |
235 | | - onOpenChange: (open: boolean) => void; |
236 | | -}) { |
237 | | - const open = keyData !== null; |
238 | | - const copy = async () => { |
239 | | - if (!keyData) { |
240 | | - return; |
241 | | - } |
242 | | - try { |
243 | | - await navigator.clipboard.writeText(keyData.plaintext); |
244 | | - toast.success("Copied"); |
245 | | - } catch { |
246 | | - toast.error("Clipboard blocked — copy manually"); |
247 | | - } |
248 | | - }; |
249 | | - return ( |
250 | | - <Dialog open={open} onOpenChange={onOpenChange}> |
251 | | - <DialogContent> |
252 | | - <DialogHeader> |
253 | | - <DialogTitle>Copy this key now</DialogTitle> |
254 | | - <DialogDescription> |
255 | | - We won't show it again. Store it somewhere safe — anyone with this |
256 | | - token can act as you on the API. |
257 | | - </DialogDescription> |
258 | | - </DialogHeader> |
259 | | - {keyData ? ( |
260 | | - <div className="grid gap-3"> |
261 | | - <div className="rounded-md border bg-muted/40 p-3 font-mono text-sm"> |
262 | | - {keyData.plaintext} |
263 | | - </div> |
264 | | - <Button type="button" onClick={copy} className="w-full"> |
265 | | - <Copy className="mr-1.5 h-4 w-4" /> |
266 | | - Copy to clipboard |
267 | | - </Button> |
268 | | - </div> |
269 | | - ) : null} |
270 | | - <DialogFooter> |
271 | | - <Button variant="ghost" onClick={() => onOpenChange(false)}> |
272 | | - I've stored it |
273 | | - </Button> |
274 | | - </DialogFooter> |
275 | | - </DialogContent> |
276 | | - </Dialog> |
277 | | - ); |
| 5 | + redirect("/dashboard/settings#api-keys" as never); |
278 | 6 | } |
0 commit comments