index.tsx515 lines · main
| 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 | |
| 33 | import { StartUsingJwtSigningKeysBanner } from '../start-using-keys-banner' |
| 34 | import { ActionPanel } from './action-panel' |
| 35 | import { CreateKeyDialog } from './create-key-dialog' |
| 36 | import { KeyDetailsDialog } from './key-details-dialog' |
| 37 | import { RotateKeyDialog } from './rotate-key-dialog' |
| 38 | import { SigningKeyRow } from './signing-key-row' |
| 39 | import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper' |
| 40 | import { useLegacyAPIKeysStatusQuery } from '@/data/api-keys/legacy-api-keys-status-query' |
| 41 | import { useJWTSigningKeyDeleteMutation } from '@/data/jwt-signing-keys/jwt-signing-key-delete-mutation' |
| 42 | import { useJWTSigningKeyUpdateMutation } from '@/data/jwt-signing-keys/jwt-signing-key-update-mutation' |
| 43 | import { |
| 44 | JWTSigningKey, |
| 45 | useJWTSigningKeysQuery, |
| 46 | } from '@/data/jwt-signing-keys/jwt-signing-keys-query' |
| 47 | import { useLegacyJWTSigningKeyCreateMutation } from '@/data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation' |
| 48 | import { useLegacyJWTSigningKeyQuery } from '@/data/jwt-signing-keys/legacy-jwt-signing-key-query' |
| 49 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 50 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 51 | |
| 52 | type DialogType = 'legacy' | 'create' | 'rotate' | 'key-details' | 'revoke' | 'delete' |
| 53 | |
| 54 | export 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 | } |