index.tsx 20 KB

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