index.tsx515 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { useParams } from 'common'
3import { AnimatePresence } from 'framer-motion'
4import { AlertCircle, RotateCw, Timer } from 'lucide-react'
5import { useMemo, useState } from 'react'
6import { toast } from 'sonner'
7import {
8 AlertDialog,
9 AlertDialogCancel,
10 AlertDialogContent,
11 AlertDialogDescription,
12 AlertDialogFooter,
13 AlertDialogHeader,
14 AlertDialogTitle,
15 Button,
16 Card,
17 CardContent,
18 Dialog,
19 DialogContent,
20 DialogFooter,
21 DialogHeader,
22 DialogSection,
23 DialogSectionSeparator,
24 DialogTitle,
25 Table,
26 TableBody,
27 TableHead,
28 TableHeader,
29 TableRow,
30} from 'ui'
31import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
32
33import { StartUsingJwtSigningKeysBanner } from '../start-using-keys-banner'
34import { ActionPanel } from './action-panel'
35import { CreateKeyDialog } from './create-key-dialog'
36import { KeyDetailsDialog } from './key-details-dialog'
37import { RotateKeyDialog } from './rotate-key-dialog'
38import { SigningKeyRow } from './signing-key-row'
39import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
40import { useLegacyAPIKeysStatusQuery } from '@/data/api-keys/legacy-api-keys-status-query'
41import { useJWTSigningKeyDeleteMutation } from '@/data/jwt-signing-keys/jwt-signing-key-delete-mutation'
42import { useJWTSigningKeyUpdateMutation } from '@/data/jwt-signing-keys/jwt-signing-key-update-mutation'
43import {
44 JWTSigningKey,
45 useJWTSigningKeysQuery,
46} from '@/data/jwt-signing-keys/jwt-signing-keys-query'
47import { useLegacyJWTSigningKeyCreateMutation } from '@/data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation'
48import { useLegacyJWTSigningKeyQuery } from '@/data/jwt-signing-keys/legacy-jwt-signing-key-query'
49import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
50import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
51
52type DialogType = 'legacy' | 'create' | 'rotate' | 'key-details' | 'revoke' | 'delete'
53
54export const JWTSecretKeysTable = () => {
55 const { ref: projectRef } = useParams()
56 const { data: project, isPending: isProjectLoading } = useSelectedProjectQuery()
57
58 const [selectedKey, setSelectedKey] = useState<JWTSigningKey>()
59 const [selectedKeyToUpdate, setSelectedKeyToUpdate] = useState<string>()
60 const [shownDialog, setShownDialog] = useState<DialogType>()
61
62 const { can: canReadAPIKeys, isLoading: isLoadingCanReadAPIKeys } = useAsyncCheckPermissions(
63 PermissionAction.SECRETS_READ,
64 '*'
65 )
66 const { data: signingKeys, isPending: isLoadingSigningKeys } = useJWTSigningKeysQuery(
67 {
68 projectRef,
69 },
70 { enabled: canReadAPIKeys }
71 )
72 const { data: legacyKey, isPending: isLoadingLegacyKey } = useLegacyJWTSigningKeyQuery(
73 {
74 projectRef,
75 },
76 { enabled: canReadAPIKeys }
77 )
78 const { data: legacyAPIKeysStatus, isPending: isLoadingLegacyAPIKeysStatus } =
79 useLegacyAPIKeysStatusQuery({ projectRef }, { enabled: canReadAPIKeys })
80
81 const { mutate: migrateJWTSecret, isPending: isMigrating } = useLegacyJWTSigningKeyCreateMutation(
82 {
83 onSuccess: () => {
84 setShownDialog(undefined)
85 toast.success('Successfully migrated JWT secret!')
86 },
87 }
88 )
89
90 const { mutate: updateJWTSigningKey, isPending: isUpdatingJWTSigningKey } =
91 useJWTSigningKeyUpdateMutation({
92 onSuccess: () => {
93 resetDialog()
94 setSelectedKeyToUpdate(undefined)
95 },
96 })
97 const { mutate: deleteJWTSigningKey, isPending: isDeletingJWTSigningKey } =
98 useJWTSigningKeyDeleteMutation({ onSuccess: () => resetDialog(), onError: () => resetDialog() })
99
100 const isPendingMutation = isUpdatingJWTSigningKey || isDeletingJWTSigningKey || isMigrating
101 const isLoading =
102 isProjectLoading || isLoadingSigningKeys || isLoadingLegacyKey || isLoadingLegacyAPIKeysStatus
103
104 const sortedKeys = useMemo(() => {
105 if (!signingKeys || !Array.isArray(signingKeys.keys)) return []
106
107 return signingKeys.keys.sort((a: JWTSigningKey, b: JWTSigningKey) => {
108 const order: Record<JWTSigningKey['status'], number> = {
109 standby: 0,
110 in_use: 1,
111 previously_used: 2,
112 revoked: 3,
113 }
114 return (
115 order[a.status] - order[b.status] ||
116 new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
117 )
118 })
119 }, [signingKeys])
120
121 const standbyKey = useMemo(() => sortedKeys.find((key) => key.status === 'standby'), [sortedKeys])
122 const inUseKey = useMemo(() => sortedKeys.find((key) => key.status === 'in_use'), [sortedKeys])
123 const previouslyUsedKeys = useMemo(
124 () => sortedKeys.filter((key) => key.status === 'previously_used'),
125 [sortedKeys]
126 )
127 const revokedKeys = useMemo(
128 () => sortedKeys.filter((key) => key.status === 'revoked'),
129 [sortedKeys]
130 )
131
132 const resetDialog = () => {
133 setSelectedKey(undefined)
134 setShownDialog(undefined)
135 }
136
137 const handlePreviouslyUsedKey = async (keyId: string) => {
138 setSelectedKeyToUpdate(keyId)
139 updateJWTSigningKey(
140 { projectRef, keyId, status: 'previously_used' },
141 { onSuccess: () => toast.success('Successfully moved key to previously used') }
142 )
143 }
144
145 const handleStandbyKey = (keyId: string) => {
146 setSelectedKeyToUpdate(keyId)
147 updateJWTSigningKey(
148 { projectRef: projectRef!, keyId, status: 'standby' },
149 { onSuccess: () => toast.success('Successfully moved key to standby') }
150 )
151 }
152
153 const handleRevokeKey = (keyId: string) => {
154 updateJWTSigningKey(
155 { projectRef: projectRef!, keyId, status: 'revoked' },
156 { onSuccess: () => toast.success('Successfully revoked key') }
157 )
158 }
159
160 const handleDeleteKey = (keyId: string) => {
161 deleteJWTSigningKey(
162 { projectRef: projectRef!, keyId },
163 { onSuccess: () => toast.success('Successfully deleted key') }
164 )
165 }
166
167 if (!canReadAPIKeys && !isLoadingCanReadAPIKeys) {
168 return (
169 <div className="bg-surface-100 rounded-md border shadow-xs">
170 <div className="flex items-center py-8 px-8 space-x-2">
171 <AlertCircle size={16} strokeWidth={1.5} />
172 <p className="text-sm text-foreground-light">
173 You don't have permission to view JWT signing keys. These keys are restricted to users
174 with higher access levels.
175 </p>
176 </div>
177 </div>
178 )
179 }
180
181 if (isLoading) {
182 return <GenericSkeletonLoader />
183 }
184
185 return (
186 <>
187 <div className="-space-y-px">
188 {!canReadAPIKeys ? null : legacyKey ? (
189 <>
190 {standbyKey ? (
191 <ActionPanel
192 title="Rotate Signing Key"
193 description="Switch the standby key to in use. All new JSON Web Tokens issued by Briven Auth will be signed with this key."
194 buttonLabel="Rotate keys"
195 onClick={() => setShownDialog('rotate')}
196 loading={isUpdatingJWTSigningKey}
197 icon={<RotateCw className="size-4" />}
198 type="primary"
199 />
200 ) : (
201 <ActionPanel
202 title="Create standby key"
203 description="Set up a new key which you can switch to once it has been picked up by all components of your application."
204 buttonLabel="Create Standby Key"
205 onClick={() => setShownDialog('create')}
206 loading={isPendingMutation}
207 type="primary"
208 icon={<Timer className="size-4" />}
209 />
210 )}
211 </>
212 ) : (
213 <StartUsingJwtSigningKeysBanner
214 onClick={() => setShownDialog('legacy')}
215 isLoading={isMigrating}
216 />
217 )}
218 </div>
219
220 {sortedKeys.length > 0 && (
221 <>
222 <div>
223 <Card className="w-full overflow-hidden bg-surface-100 border rounded-md">
224 <CardContent className="p-0">
225 <Table className="p-5">
226 <TableHeader className="bg-200">
227 <TableRow>
228 <TableHead className="text-left font-mono uppercase text-xs text-foreground-muted h-auto py-2 pr-0 w-20">
229 Status
230 </TableHead>
231 <TableHead className="text-left font-mono uppercase text-xs text-foreground-muted h-auto py-2 pl-0">
232 Key ID
233 </TableHead>
234 <TableHead className="text-left font-mono uppercase text-xs text-foreground-muted h-auto py-2">
235 Type
236 </TableHead>
237 <TableHead />
238 <TableHead className="text-right font-mono uppercase text-xs text-foreground-muted h-auto py-2">
239 Actions
240 </TableHead>
241 </TableRow>
242 </TableHeader>
243 <TableBody>
244 <AnimatePresence>
245 {standbyKey && (
246 <SigningKeyRow
247 key={standbyKey.id}
248 signingKey={standbyKey}
249 legacyKey={legacyKey}
250 standbyKey={standbyKey}
251 isLoading={
252 selectedKeyToUpdate === standbyKey.id && isUpdatingJWTSigningKey
253 }
254 setSelectedKey={setSelectedKey}
255 setShownDialog={setShownDialog}
256 handleStandbyKey={handleStandbyKey}
257 handlePreviouslyUsedKey={handlePreviouslyUsedKey}
258 />
259 )}
260 {inUseKey && (
261 <SigningKeyRow
262 key={inUseKey.id}
263 signingKey={inUseKey}
264 setSelectedKey={setSelectedKey}
265 setShownDialog={setShownDialog}
266 handleStandbyKey={handleStandbyKey}
267 handlePreviouslyUsedKey={handlePreviouslyUsedKey}
268 legacyKey={legacyKey}
269 standbyKey={standbyKey}
270 />
271 )}
272 </AnimatePresence>
273 </TableBody>
274 </Table>
275 </CardContent>
276 </Card>
277 </div>
278
279 <div className="flex flex-col gap-4">
280 <div className="flex flex-col gap-2">
281 <h2>Previously used keys</h2>
282 <p className="text-sm text-foreground-lighter">
283 These JWT signing keys are still used to{' '}
284 <em className="text-brand not-italic">verify tokens</em> that are yet to expire.
285 Revoke once all tokens have expired.
286 </p>
287 </div>
288 <Card className="overflow-hidden">
289 <CardContent className="p-0">
290 {previouslyUsedKeys.length > 0 ? (
291 <Table className="p-5">
292 <TableHeader className="bg-200">
293 <TableRow>
294 <TableHead className="text-left font-mono uppercase text-xs text-foreground-muted h-auto py-2 pr-0 w-20">
295 Status
296 </TableHead>
297 <TableHead className="text-left font-mono uppercase text-xs text-foreground-muted h-auto py-2 pl-0">
298 Key ID
299 </TableHead>
300 <TableHead className="text-left font-mono uppercase text-xs text-foreground-muted h-auto py-2">
301 Type
302 </TableHead>
303 <TableHead className="text-right font-mono uppercase text-xs text-foreground-muted h-auto py-2 hidden lg:table-cell">
304 Last rotated at
305 </TableHead>
306 <TableHead className="text-right font-mono uppercase text-xs text-foreground-muted h-auto py-2">
307 Actions
308 </TableHead>
309 </TableRow>
310 </TableHeader>
311 <TableBody>
312 <AnimatePresence>
313 {previouslyUsedKeys.map((key) => (
314 <SigningKeyRow
315 key={key.id}
316 signingKey={key}
317 legacyKey={legacyKey}
318 standbyKey={standbyKey}
319 isLoading={selectedKeyToUpdate === key.id && isUpdatingJWTSigningKey}
320 setSelectedKey={setSelectedKey}
321 setShownDialog={setShownDialog}
322 handleStandbyKey={handleStandbyKey}
323 handlePreviouslyUsedKey={handlePreviouslyUsedKey}
324 />
325 ))}
326 </AnimatePresence>
327 </TableBody>
328 </Table>
329 ) : (
330 <div className="flex flex-col items-center justify-center text-center text-foreground-light p-8 gap-2">
331 <Timer className="size-6 text-foreground-lighter" />
332 <div className="flex flex-col gap-1">
333 <p className="text-sm font-medium">No previously used keys</p>
334 <p className="text-xs text-foreground-lighter">
335 Rotated keys will appear here for verification of existing tokens
336 </p>
337 </div>
338 </div>
339 )}
340 </CardContent>
341 </Card>
342 </div>
343 </>
344 )}
345
346 {revokedKeys.length > 0 && (
347 <div className="flex flex-col gap-4">
348 <div className="flex flex-col gap-2">
349 <h2>Revoked keys</h2>
350 <p className="text-sm text-foreground-lighter">
351 These keys are no longer used to verify or sign JWTs.
352 </p>
353 </div>
354 <Card className="overflow-hidden">
355 <CardContent className="p-0">
356 <Table className="p-5">
357 <TableHeader className="bg-200">
358 <TableRow>
359 <TableHead className="text-left font-mono uppercase text-xs text-foreground-muted h-auto py-2 pr-0 w-20">
360 Status
361 </TableHead>
362 <TableHead className="text-left font-mono uppercase text-xs text-foreground-muted h-auto py-2 pl-0">
363 Key ID
364 </TableHead>
365 <TableHead className="text-left font-mono uppercase text-xs text-foreground-muted h-auto py-2">
366 Type
367 </TableHead>
368 <TableHead className="text-right font-mono uppercase text-xs text-foreground-muted h-auto py-2 hidden lg:table-cell">
369 Last rotated at
370 </TableHead>
371 <TableHead className="text-right font-mono uppercase text-xs text-foreground-muted h-auto py-2">
372 Actions
373 </TableHead>
374 </TableRow>
375 </TableHeader>
376 <TableBody>
377 <AnimatePresence>
378 {revokedKeys.map((key) => (
379 <SigningKeyRow
380 key={key.id}
381 signingKey={key}
382 setSelectedKey={setSelectedKey}
383 setShownDialog={setShownDialog}
384 handleStandbyKey={handleStandbyKey}
385 handlePreviouslyUsedKey={handlePreviouslyUsedKey}
386 legacyKey={legacyKey}
387 standbyKey={standbyKey}
388 />
389 ))}
390 </AnimatePresence>
391 </TableBody>
392 </Table>
393 </CardContent>
394 </Card>
395 </div>
396 )}
397
398 <Dialog open={shownDialog === 'legacy'} onOpenChange={resetDialog}>
399 <DialogContent className="sm:max-w-[425px]">
400 <DialogHeader>
401 <DialogTitle>Start using new JWT signing keys</DialogTitle>
402 </DialogHeader>
403 <DialogSectionSeparator />
404 <DialogSection className="flex flex-col gap-2 text-sm text-foreground-light">
405 <p>
406 Your project today uses a legacy symmetric JWT secret to create JWTs. To be able to
407 use an asymmetric JWT signing key you first have to migrate it to the new approach.
408 </p>
409 <p>This change does not cause any downtime on your project.</p>
410 </DialogSection>
411 <DialogFooter>
412 <Button
413 loading={isMigrating}
414 onClick={() => migrateJWTSecret({ projectRef: projectRef! })}
415 >
416 Migrate JWT secret
417 </Button>
418 </DialogFooter>
419 </DialogContent>
420 </Dialog>
421
422 <Dialog open={shownDialog === 'create'} onOpenChange={resetDialog}>
423 <DialogContent className="sm:max-w-[425px]">
424 <CreateKeyDialog projectRef={projectRef!} onClose={resetDialog} />
425 </DialogContent>
426 </Dialog>
427
428 {standbyKey && inUseKey && projectRef && (
429 <Dialog open={shownDialog === 'rotate'} onOpenChange={resetDialog}>
430 <DialogContent className="sm:max-w-[425px]">
431 <RotateKeyDialog
432 projectRef={projectRef}
433 standbyKey={standbyKey}
434 inUseKey={inUseKey}
435 onClose={resetDialog}
436 />
437 </DialogContent>
438 </Dialog>
439 )}
440
441 {selectedKey && project && (
442 <Dialog open={shownDialog === 'key-details'} onOpenChange={resetDialog}>
443 <DialogContent className="sm:max-w-lg">
444 <KeyDetailsDialog
445 selectedKey={selectedKey}
446 restURL={project.restUrl}
447 onClose={resetDialog}
448 />
449 </DialogContent>
450 </Dialog>
451 )}
452
453 {selectedKey &&
454 selectedKey.status === 'previously_used' &&
455 (legacyKey?.id !== selectedKey.id || !(legacyAPIKeysStatus?.enabled ?? false)) && (
456 <TextConfirmModal
457 visible={shownDialog === 'revoke'}
458 loading={isPendingMutation}
459 onConfirm={() => handleRevokeKey(selectedKey.id)}
460 onCancel={resetDialog}
461 title={`Revoke ${selectedKey.id}`}
462 confirmString={selectedKey.id}
463 confirmLabel="Yes, revoke this signing key"
464 confirmPlaceholder="Type the ID of the key to confirm"
465 variant="destructive"
466 alert={{
467 title: 'This key will no longer be trusted!',
468 description:
469 'By revoking a signing key, all applications trusting it will no longer do so. If there are JWTs (access tokens) that are valid at the time of revocation, they will no longer be trusted, causing users with such JWTs to be signed out.',
470 }}
471 />
472 )}
473
474 {selectedKey &&
475 selectedKey.status === 'previously_used' &&
476 legacyKey?.id === selectedKey.id &&
477 (legacyAPIKeysStatus?.enabled ?? true) && (
478 <AlertDialog open={shownDialog === 'revoke'} onOpenChange={() => resetDialog()}>
479 <AlertDialogContent>
480 <AlertDialogHeader>
481 <AlertDialogTitle>Disable JWT-based legacy API keys first</AlertDialogTitle>
482 </AlertDialogHeader>
483 <AlertDialogDescription>
484 It's not possible to revoke the legacy JWT secret unless you have already disabled
485 JWT-based legacy API keys. This is because revoking the JWT secret invalidates the
486 JWT-based legacy API keys.
487 </AlertDialogDescription>
488 <AlertDialogFooter>
489 <AlertDialogCancel>OK</AlertDialogCancel>
490 </AlertDialogFooter>
491 </AlertDialogContent>
492 </AlertDialog>
493 )}
494
495 {selectedKey && selectedKey.status === 'revoked' && (
496 <TextConfirmModal
497 visible={shownDialog === 'delete'}
498 loading={isPendingMutation}
499 onConfirm={() => handleDeleteKey(selectedKey.id)}
500 onCancel={resetDialog}
501 title={`Permanently delete ${selectedKey.id}`}
502 confirmString={selectedKey.id}
503 confirmLabel="Yes, permanently delete this key"
504 confirmPlaceholder="Type the ID of the key to confirm"
505 variant="destructive"
506 alert={{
507 title: 'This key will be permanently deleted.',
508 description:
509 'The private key and all information about this key will be permanently deleted from our records. This action cannot be undone.',
510 }}
511 />
512 )}
513 </>
514 )
515}