From 4945ba186109f4bfb7e03368de59f0590f667cf9 Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Sun, 22 Jun 2025 23:36:07 +0200 Subject: [PATCH] feat: add asymmetric JWT UI (#36519) * original files from previous PR * get rid of router in jwt-secrets to make it show up * rename jwt-secrets page to jwt-signing-keys * add jwt-signing-keys query * add legacy jwt signing keys query * wire-up JwtSecretKeysTable with queries * add jwt signing keys page to settings menu * deduplicate labels, descriptions, etc * add create, update jwt-signing-key mutations * update types * remove unused components, to be refactored later * make everything into a mostly working state * legacy migration added * put jwt keys page like api keys * fully migrate legacy jwt secret page * fix prettier * fix typecheck ts-expect-error * rm unneeded file * Fix compile errors. * Rename the files and move them to the same folder. * Merge the two constant files. * Fix the imports. * Fix a bug in the API keys page when opening it in a new tab. * Change the page to be at /signing-keys * Fix some minor types. * Break apart some of the components in the signing keys UI. * Use a feature banner for the initial action. * Make a create key dialog and move functionality there. * Fix some cosmetic issues. * Minor cosmetic fixes. * Remove extra keys in RQ cache. * Add a missing link * Add a banner when the feature flag is false. * Minor type fix. * more tiny type fix * fix error on create standby key * add alert to prevent revoking legacy jwt secret without disabling legacy api keys first --------- Co-authored-by: Ivan Vasilov --- .../APIKeys/ApiKeysIllustrations.tsx | 10 +- .../JwtSecrets/algorithm-details.ts | 97 ++ .../JwtSecrets/algorithm-hover-card.tsx | 24 + .../interfaces/JwtSecrets/illustrations.tsx | 384 ++++++++ .../jwt-secret-keys-table/action-panel.tsx | 37 + .../create-key-dialog.tsx | 90 ++ .../jwt-secret-keys-table/index.tsx | 861 ++++++++++++++++++ .../jwt-secret-keys-table/signing-key-row.tsx | 147 +++ .../jwt-settings.tsx} | 5 +- .../jwt.constants.ts} | 15 + .../JwtSecrets/signing-keys-coming-soon.tsx | 28 + .../JwtSecrets/start-using-keys-banner.tsx | 28 + .../NewPaymentMethodElement.tsx | 12 +- .../interfaces/Settings/API/ApiKeysMoved.tsx | 18 +- .../interfaces/Settings/API/ServiceList.tsx | 41 - .../layouts/JWTKeys/JWTKeysLayout.tsx | 36 + .../SettingsMenu.utils.tsx | 7 + apps/studio/components/ui/InfoPill.tsx | 61 ++ .../ProjectSettings/ToggleLegacyApiKeys.tsx | 10 +- .../jwt-signing-key-create-mutation.ts | 64 ++ .../jwt-signing-key-delete-mutation.ts | 58 ++ .../jwt-signing-key-update-mutation.ts | 62 ++ .../jwt-signing-keys-query.ts | 43 + apps/studio/data/jwt-signing-keys/keys.ts | 4 + .../legacy-jwt-signing-key-create-mutation.ts | 64 ++ .../legacy-jwt-signing-key-query.ts | 42 + .../organization-project-claim-mutation.ts | 1 - .../organization-project-claim-query.ts | 1 - .../project/[ref]/settings/jwt/index.tsx | 58 ++ .../[ref]/settings/jwt/signing-keys.tsx | 36 + packages/api-types/types/api.d.ts | 583 +++++++++++- packages/api-types/types/platform.d.ts | 10 +- 32 files changed, 2824 insertions(+), 113 deletions(-) create mode 100644 apps/studio/components/interfaces/JwtSecrets/algorithm-details.ts create mode 100644 apps/studio/components/interfaces/JwtSecrets/algorithm-hover-card.tsx create mode 100644 apps/studio/components/interfaces/JwtSecrets/illustrations.tsx create mode 100644 apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/action-panel.tsx create mode 100644 apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/create-key-dialog.tsx create mode 100644 apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx create mode 100644 apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx rename apps/studio/components/interfaces/{Settings/API/JWTSettings.tsx => JwtSecrets/jwt-settings.tsx} (99%) rename apps/studio/components/interfaces/{Settings/API/API.constants.ts => JwtSecrets/jwt.constants.ts} (66%) create mode 100644 apps/studio/components/interfaces/JwtSecrets/signing-keys-coming-soon.tsx create mode 100644 apps/studio/components/interfaces/JwtSecrets/start-using-keys-banner.tsx create mode 100644 apps/studio/components/layouts/JWTKeys/JWTKeysLayout.tsx create mode 100644 apps/studio/components/ui/InfoPill.tsx create mode 100644 apps/studio/data/jwt-signing-keys/jwt-signing-key-create-mutation.ts create mode 100644 apps/studio/data/jwt-signing-keys/jwt-signing-key-delete-mutation.ts create mode 100644 apps/studio/data/jwt-signing-keys/jwt-signing-key-update-mutation.ts create mode 100644 apps/studio/data/jwt-signing-keys/jwt-signing-keys-query.ts create mode 100644 apps/studio/data/jwt-signing-keys/keys.ts create mode 100644 apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation.ts create mode 100644 apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-query.ts create mode 100644 apps/studio/pages/project/[ref]/settings/jwt/index.tsx create mode 100644 apps/studio/pages/project/[ref]/settings/jwt/signing-keys.tsx diff --git a/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx b/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx index 8d3e2e43aa6da..c60ecf73031d4 100644 --- a/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx +++ b/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx @@ -1,4 +1,4 @@ -import { ExternalLink, Github, Mail } from 'lucide-react' +import { ExternalLink, Github } from 'lucide-react' import { LOCAL_STORAGE_KEYS } from 'common' import { FeatureBanner } from 'components/ui/FeatureBanner' @@ -119,7 +119,13 @@ export const ApiKeysComingSoonBanner = () => {

diff --git a/apps/studio/components/interfaces/JwtSecrets/algorithm-details.ts b/apps/studio/components/interfaces/JwtSecrets/algorithm-details.ts new file mode 100644 index 0000000000000..66b1f5896978c --- /dev/null +++ b/apps/studio/components/interfaces/JwtSecrets/algorithm-details.ts @@ -0,0 +1,97 @@ +export interface AlgorithmDetail { + name: string + description: string + pros: string[] + cons: string[] + label: string + shortDescription: string + links: { url: string; label: string }[] +} + +export const algorithmDetails: Record = { + HS256: { + label: 'HS256 (Symmetric)', + name: 'HMAC with SHA-256', + description: 'Symmetric algorithm using a shared secret key', + pros: [ + 'Fast and simple to use', + 'Requires less computational power', + 'Suitable for server-to-server communication', + ], + cons: [ + 'Requires secure key exchange', + "Not suitable when the verifier shouldn't be able to sign tokens", + 'Key needs to be kept secret on both sides', + ], + shortDescription: 'HMAC with SHA-256: Fast, simple, requires secure key exchange', + links: [ + { url: 'https://jwt.io/introduction', label: 'JWT.io Introduction' }, + { + url: 'https://datatracker.ietf.org/doc/html/rfc7518#section-3.2', + label: 'RFC 7518 Specification', + }, + ], + }, + RS256: { + label: 'RSA 2048', + name: 'RSA with SHA-256', + description: 'Asymmetric algorithm using a public/private key pair', + pros: [ + 'Allows public key to be distributed freely', + 'Private key can be kept secret on the signing side', + "Suitable for scenarios where the verifier shouldn't be able to sign tokens", + ], + cons: [ + 'Slower than HS256', + 'Requires more computational power', + 'Keys are larger than ECDSA keys', + ], + shortDescription: 'RSA with SHA-256: Allows public key distribution, slower', + links: [ + { url: 'https://jwt.io/introduction', label: 'JWT.io Introduction' }, + { + url: 'https://datatracker.ietf.org/doc/html/rfc7518#section-3.3', + label: 'RFC 7518 Specification', + }, + ], + }, + ES256: { + label: 'ECC (P-256)', + name: 'ECDSA with SHA-256', + description: 'Asymmetric algorithm using elliptic curve cryptography', + pros: [ + 'Faster than RSA', + 'Smaller key and signature sizes compared to RSA', + 'Provides forward secrecy', + ], + cons: [ + 'Less widely supported than RSA', + 'More complex to implement correctly', + 'Requires careful implementation to avoid timing attacks', + ], + shortDescription: 'ECDSA with SHA-256: Compact keys, fast, modern alternative to RSA', + links: [ + { url: 'https://jwt.io/introduction', label: 'JWT.io Introduction' }, + { + url: 'https://datatracker.ietf.org/doc/html/rfc7518#section-3.4', + label: 'RFC 7518 Specification', + }, + ], + }, +} + +export const algorithmLabels = Object.keys(algorithmDetails).reduce( + (a, i) => { + a[i] = algorithmDetails[i].label + return a + }, + {} as { [name: keyof typeof algorithmDetails]: string } +) + +export const algorithmDescriptions = Object.keys(algorithmDetails).reduce( + (a, i) => { + a[i] = algorithmDetails[i].shortDescription + return a + }, + {} as { [name: keyof typeof algorithmDetails]: string } +) diff --git a/apps/studio/components/interfaces/JwtSecrets/algorithm-hover-card.tsx b/apps/studio/components/interfaces/JwtSecrets/algorithm-hover-card.tsx new file mode 100644 index 0000000000000..79d9cf31777b4 --- /dev/null +++ b/apps/studio/components/interfaces/JwtSecrets/algorithm-hover-card.tsx @@ -0,0 +1,24 @@ +import { GlobeLock } from 'lucide-react' +import React from 'react' + +import { InfoPill } from '../../ui/InfoPill' +import { AlgorithmDetail, algorithmDetails } from './algorithm-details' + +interface AlgorithmHoverCardProps { + algorithm: keyof typeof algorithmDetails + legacy?: boolean +} + +export const AlgorithmHoverCard: React.FC = ({ algorithm, legacy }) => { + const details: AlgorithmDetail = algorithmDetails[algorithm] + + return ( + } + title={details.name} + description={details.description} + links={details.links} + /> + ) +} diff --git a/apps/studio/components/interfaces/JwtSecrets/illustrations.tsx b/apps/studio/components/interfaces/JwtSecrets/illustrations.tsx new file mode 100644 index 0000000000000..f261c0bdc5f20 --- /dev/null +++ b/apps/studio/components/interfaces/JwtSecrets/illustrations.tsx @@ -0,0 +1,384 @@ +export const StandbyKeyIllustration = () => ( + + + + + + + + + + + + + + + + + + + + + +) + +export const WhyRotateKeysIllustration = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +) + +export const WhyUseStandbyKeysIllustration = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +) diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/action-panel.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/action-panel.tsx new file mode 100644 index 0000000000000..cfebd69aa98a5 --- /dev/null +++ b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/action-panel.tsx @@ -0,0 +1,37 @@ +import { ComponentProps, forwardRef } from 'react' +import { Button, Card, CardDescription, CardHeader, CardTitle } from 'ui' + +interface ActionPanelProps extends Omit, 'onClick' | 'type'> { + title: string + description: string + buttonLabel: ComponentProps['children'] + onClick: ComponentProps['onClick'] + loading: ComponentProps['loading'] + icon?: ComponentProps['icon'] + type?: ComponentProps['type'] +} + +export const ActionPanel = forwardRef( + ({ title, description, buttonLabel, onClick, loading, icon, type, ...props }, ref) => { + return ( + + +
+ {title} + {description} +
+
+ +
+
+
+ ) + } +) +ActionPanel.displayName = 'ActionPanel' diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/create-key-dialog.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/create-key-dialog.tsx new file mode 100644 index 0000000000000..e26c5935c13d3 --- /dev/null +++ b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/create-key-dialog.tsx @@ -0,0 +1,90 @@ +import { useJWTSigningKeyCreateMutation } from 'data/jwt-signing-keys/jwt-signing-key-create-mutation' +import { JWTAlgorithm } from 'data/jwt-signing-keys/jwt-signing-keys-query' +import { useState } from 'react' +import { toast } from 'sonner' +import { + Button, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + Label_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + Select_Shadcn_, +} from 'ui' +import { algorithmDescriptions } from '../algorithm-details' + +export const CreateKeyDialog = ({ + projectRef, + onClose, +}: { + projectRef: string + onClose: () => void +}) => { + const [newKeyAlgorithm, setNewKeyAlgorithm] = useState('RS256') + + const { mutate, isLoading: isLoadingMutation } = useJWTSigningKeyCreateMutation({ + onSuccess: () => { + onClose() + }, + onError: (error) => { + toast.error(`Failed to add new standby key: ${error.message}`) + }, + }) + + const handleAddNewStandbyKey = async () => { + mutate({ + projectRef: projectRef!, + algorithm: newKeyAlgorithm, + status: 'standby', + }) + } + + return ( + <> + + Create a new Standby Key + + + +
+ Choose the key type to use: + setNewKeyAlgorithm(value)} + > + + + + + HS256 (Symmetric) + RS256 (RSA) + + ES256 (ECC) + + + EdDSA (Ed25519) + + + +

+ {algorithmDescriptions[newKeyAlgorithm]} +

+
+
+ + + + + ) +} diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx new file mode 100644 index 0000000000000..222edf7b24a9a --- /dev/null +++ b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx @@ -0,0 +1,861 @@ +import { AnimatePresence, motion } from 'framer-motion' +import { + ArrowRight, + CircleArrowUp, + Eye, + FileKey, + Key, + MoreVertical, + RotateCw, + ShieldOff, + Timer, + Trash2, +} from 'lucide-react' +import { useMemo, useState } from 'react' + +import { useParams } from 'common' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useJWTSigningKeyDeleteMutation } from 'data/jwt-signing-keys/jwt-signing-key-delete-mutation' +import { useJWTSigningKeyUpdateMutation } from 'data/jwt-signing-keys/jwt-signing-key-update-mutation' +import { + JWTAlgorithm, + JWTSigningKey, + useJWTSigningKeysQuery, +} from 'data/jwt-signing-keys/jwt-signing-keys-query' +import { useLegacyJWTSigningKeyCreateMutation } from 'data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation' +import { useLegacyJWTSigningKeyQuery } from 'data/jwt-signing-keys/legacy-jwt-signing-key-query' +import { useLegacyAPIKeysStatusQuery } from 'data/api-keys/legacy-api-keys-status-query' +import { useFlag } from 'hooks/ui/useFlag' +import { + Badge, + Button, + Card, + CardContent, + cn, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Select_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from 'ui' +import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' +import { algorithmDescriptions, algorithmLabels } from '../algorithm-details' +import { AlgorithmHoverCard } from '../algorithm-hover-card' +import { statusColors, statusLabels } from '../jwt.constants' +import { SigningKeysComingSoonBanner } from '../signing-keys-coming-soon' +import { StartUsingJwtSigningKeysBanner } from '../start-using-keys-banner' +import { ActionPanel } from './action-panel' +import { CreateKeyDialog } from './create-key-dialog' +import { SigningKeyRow } from './signing-key-row' + +const MotionTableRow = motion(TableRow) + +export default function JWTSecretKeysTable() { + const { ref: projectRef } = useParams() + const newJwtSecrets = useFlag('newJwtSecrets') + + const [selectedKey, setSelectedKey] = useState(null) + const [shownDialog, setShownDialog] = useState< + 'legacy' | 'create' | 'rotate' | 'confirm-rotate' | 'key-details' | 'revoke' | 'delete' | null + >(null) + + const resetDialog = () => { + setSelectedKey(null) + setShownDialog(null) + } + + const [newKeyAlgorithm, setNewKeyAlgorithm] = useState('RS256') + + const { data: signingKeys, isLoading: isLoadingSigningKeys } = useJWTSigningKeysQuery({ + projectRef, + }) + const { data: legacyKey, isLoading: isLoadingLegacyKey } = useLegacyJWTSigningKeyQuery({ + projectRef, + }) + const { data: legacyAPIKeysStatus, isLoading: isLoadingLegacyAPIKeysStatus } = + useLegacyAPIKeysStatusQuery({ projectRef }) + + const legacyMutation = useLegacyJWTSigningKeyCreateMutation() + + const updateMutation = useJWTSigningKeyUpdateMutation() + const deleteMutation = useJWTSigningKeyDeleteMutation() + + const isLoadingMutation = + updateMutation.isLoading || deleteMutation.isLoading || legacyMutation.isLoading + const isLoading = isLoadingSigningKeys || isLoadingLegacyKey || isLoadingLegacyAPIKeysStatus + + const sortedKeys = useMemo(() => { + if (!signingKeys || !Array.isArray(signingKeys.keys)) return [] + + return signingKeys.keys.sort((a: JWTSigningKey, b: JWTSigningKey) => { + const order: Record = { + standby: 0, + in_use: 1, + previously_used: 2, + revoked: 3, + } + return ( + order[a.status] - order[b.status] || + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ) + }) + }, [signingKeys]) + + const standbyKey = useMemo(() => sortedKeys.find((key) => key.status === 'standby'), [sortedKeys]) + const inUseKey = useMemo(() => sortedKeys.find((key) => key.status === 'in_use'), [sortedKeys]) + const previouslyUsedKeys = useMemo( + () => sortedKeys.filter((key) => key.status === 'previously_used'), + [sortedKeys] + ) + const revokedKeys = useMemo( + () => sortedKeys.filter((key) => key.status === 'revoked'), + [sortedKeys] + ) + + const handleLegacyMigration = async () => { + try { + await legacyMutation.mutateAsync({ + projectRef: projectRef!, + }) + } catch (error) { + console.error('Failed to migrate legacy JWT secret to new JWT signing keys', error) + } + } + + const handlePreviouslyUsedKey = async (keyId: string) => { + if (!projectRef) { + return + } + try { + await updateMutation.mutateAsync({ projectRef, keyId, status: 'previously_used' }) + resetDialog() + } catch (error) { + console.error('Failed to move key to previously used', error) + } + } + + const handleStandbyKey = async (keyId: string) => { + try { + await updateMutation.mutateAsync({ projectRef: projectRef!, keyId, status: 'standby' }) + resetDialog() + } catch (error) { + console.error('Failed to move key to standby', error) + } + } + + const handleRevokeKey = async (keyId: string) => { + try { + await updateMutation.mutateAsync({ projectRef: projectRef!, keyId, status: 'revoked' }) + resetDialog() + } catch (error) { + console.error('Failed to revoke key', error) + } + } + + const handleDeleteKey = async (keyId: string) => { + try { + await deleteMutation.mutateAsync({ projectRef: projectRef!, keyId }) + resetDialog() + } catch (error) { + console.error('Failed to delete key', error) + } + } + + const handleRotateKey = async () => { + try { + await updateMutation.mutateAsync({ + projectRef: projectRef!, + keyId: standbyKey!.id, + status: 'in_use', + }) + resetDialog() + } catch (error) { + console.error('Failed to rotate key', error) + } + } + + if (isLoading) { + return + } + + if (!newJwtSecrets) { + return + } + + return ( + <> +
+ {legacyKey ? ( + <> + {standbyKey && ( + setShownDialog('rotate')} + loading={isLoadingMutation} + icon={} + type="warning" + /> + )} + + {!standbyKey && ( + setShownDialog('create')} + loading={isLoadingMutation} + type="primary" + icon={} + /> + )} + + ) : ( + setShownDialog('legacy')} + isLoading={isLoadingMutation} + /> + )} +
+ + {sortedKeys.length > 0 && ( + <> +
+ + + + + + + Status + + + Key ID + + + Type + + + Actions + + + + + + {standbyKey && ( + + )} + {inUseKey && ( + + )} + + +
+
+
+
+ +
+
+

Previously used keys

+

+ These JWT signing keys are still used to verify JWTs already + issued. Revoke them once all JWTs have expired. +

+
+ + + {previouslyUsedKeys.length > 0 ? ( + + + + + Status + + + Key ID + + + Type + + + Actions + + + + + + {previouslyUsedKeys.map((key) => ( + + +
+ + + {statusLabels[key.status]} + +
+
+ +
+ + {key.id} + + +
+
+ + + + + + +
+ ) : ( +
+ No previously used keys +
+ )} +
+
+
+ + )} + + {revokedKeys.length > 0 && ( +
+
+

Revoked keys

+

+ These keys are no longer used to verify or sign JWTs. +

+
+ + + + + + + Status + + + Key ID + + + Type + + + Actions + + + + + + {revokedKeys.map((key) => ( + + +
+ + + {statusLabels[key.status]} + +
+
+ +
+ + {key.id} + + +
+
+ + + + + + +
+
+
+
+ )} + + {/* TODO(hf): For launch
+

Resources

+ +
+ +
+
+ +
+
+

Why Rotate keys?

+

+ Create Standby keys ahead of time which can then be promoted to 'In use' at any + time. +

+ +
+
+
+ + +
+
+ +
+
+

Why use a Standby key?

+

+ Create Standby keys ahead of time which can then be promoted to 'In use' at any + time. +

+ +
+
+
+
+
*/} + + + + + Start using new JWT signing keys + + + +

+ Your project today uses a legacy symmetric JWT secret to create JWTs. To be able to + use an asymmetric JWT signing key you first have to migrate it to the new system. This + change does not cause any downtime on your project. +

+
+ + + +
+
+ + + + + + + + + + + Rotate Key + + + + + {standbyKey ? ( + <> + The standby key ({algorithmLabels[standbyKey.algorithm]}) will be promoted to 'In + use'. This will: +
    +
  • Change the current standby key to 'In use'
  • +
  • Move the current 'In use' key to 'Previously used'
  • +
  • Move any 'Previously used' key to 'Revoked'
  • +
+ + ) : ( + <> + Since there is no standby key, you need to choose an algorithm for the new key: +
+ setNewKeyAlgorithm(value)} + > + + + + + HS256 (Symmetric) + ES256 (ECC) + RS256 (RSA) + EdDSA (Ed25519) + + +

+ {algorithmDescriptions[newKeyAlgorithm]} +

+
+ + )} +
+
+ + + +
+
+ + + + + Confirm key rotation + + Review the key rotation process below. Ensure your application's components have + already picked up and are trusting your standby key to avoid downtime. + + + + +
+ {standbyKey ? ( +
+ + + STANDBY KEY + + {algorithmLabels[standbyKey!.algorithm]} + + + + + + CURRENTLY USED + +
+ ) : ( +
+ + + New Key + + {algorithmLabels[newKeyAlgorithm]} + + + + + + CURRENTLY USED + +
+ )} + +
+ + + CURRENTLY USED + + {inUseKey?.algorithm && algorithmLabels[inUseKey.algorithm]} + + + + + + PREVIOUS KEY + +
+
+
+ + + + +
+
+ + + + + Key Details + + + + {selectedKey && ( + <> +
+
+ +

Public Key (PEM format)

+
+
+                    {typeof selectedKey.public_jwk === 'string'
+                      ? selectedKey.public_jwk
+                      : JSON.stringify(selectedKey.public_jwk ?? '', null, 2)}
+                  
+
+
+

JWKS URL

+
+                    {`${window.location.origin}/jwt/v1/jwks.json`}
+                  
+
+ + )} +
+
+
+ + {selectedKey && + selectedKey.status === 'previously_used' && + (legacyKey?.id !== selectedKey.id || !(legacyAPIKeysStatus?.enabled ?? false)) && ( + handleRevokeKey(selectedKey.id)} + onCancel={resetDialog} + title={`Revoke ${selectedKey.id}`} + confirmString={selectedKey.id} + confirmLabel="Yes, revoke this signing key" + confirmPlaceholder="Type the ID of the key to confirm" + variant="destructive" + alert={{ + title: 'This key will no longer be trusted!', + description: + '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.', + }} + /> + )} + + {selectedKey && + selectedKey.status === 'previously_used' && + legacyKey?.id === selectedKey.id && + (legacyAPIKeysStatus?.enabled ?? true) && ( + resetDialog()}> + + + Disable JWT-based legacy API keys first + + + It's not possible to revoke the legacy JWT secret unless you have already disabled + JWT-based legacy API keys. This is because revoking the JWT secret invalidates the + JWT-based legacy API keys. + + + OK + + + + )} + + {selectedKey && selectedKey.status === 'revoked' && ( + handleDeleteKey(selectedKey.id)} + onCancel={resetDialog} + title={`Permanently delete ${selectedKey.id}`} + confirmString={selectedKey.id} + confirmLabel="Yes, permanently delete this key" + confirmPlaceholder="Type the ID of the key to confirm" + variant="destructive" + alert={{ + title: 'This key will be permanently deleted.', + description: + 'The private key and all information about this key will be permanently deleted from our records. This action cannot be undone.', + }} + /> + )} + + ) +} diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx new file mode 100644 index 0000000000000..e8395ed57b032 --- /dev/null +++ b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx @@ -0,0 +1,147 @@ +import { motion } from 'framer-motion' +import { CircleArrowDown, Eye, Key, MoreVertical, ShieldOff, Timer } from 'lucide-react' + +import { components } from 'api-types' +import { JWTSigningKey } from 'data/jwt-signing-keys/jwt-signing-keys-query' +import { + Badge, + Button, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + TableCell, + TableRow, +} from 'ui' +import { AlgorithmHoverCard } from '../algorithm-hover-card' +import { statusColors, statusLabels } from '../jwt.constants' + +interface SigningKeyRowProps { + signingKey: components['schemas']['SigningKeyResponse'] + setSelectedKey: (key: JWTSigningKey | null) => void + setShownDialog: ( + dialog: + | 'legacy' + | 'create' + | 'rotate' + | 'confirm-rotate' + | 'key-details' + | 'revoke' + | 'delete' + | null + ) => void + handlePreviouslyUsedKey: (keyId: string) => void + legacyKey?: components['schemas']['SigningKeyResponse'] | null +} + +const MotionTableRow = motion(TableRow) + +export const SigningKeyRow = ({ + signingKey, + setSelectedKey, + setShownDialog, + handlePreviouslyUsedKey, + legacyKey, +}: SigningKeyRowProps) => ( + + +
+ + {signingKey.status === 'standby' ? : } + {statusLabels[signingKey.status]} + +
+
+ +
+ + {signingKey.id} + + +
+
+ + + + + + + + + + + ) +} diff --git a/apps/studio/components/interfaces/JwtSecrets/start-using-keys-banner.tsx b/apps/studio/components/interfaces/JwtSecrets/start-using-keys-banner.tsx new file mode 100644 index 0000000000000..d0460153ee905 --- /dev/null +++ b/apps/studio/components/interfaces/JwtSecrets/start-using-keys-banner.tsx @@ -0,0 +1,28 @@ +import { FeatureBanner } from 'components/ui/FeatureBanner' +import { Import } from 'lucide-react' +import { Button } from 'ui' + +export const StartUsingJwtSigningKeysBanner = ({ + onClick, + isLoading, +}: { + onClick: () => void + isLoading: boolean +}) => { + return ( + +
+

Start using JWT signing keys

+

+ Right now your project is using the legacy JWT secret. To start taking advantage of the + new JWT signing keys, migrate your project's secret to the new set up. +

+
+ +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx index 5123b3bca2c2e..3ef69b313e25e 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx @@ -16,7 +16,11 @@ const NewPaymentMethodElement = forwardRef( pending_subscription_flow_enabled, email, readOnly, - }: { pending_subscription_flow_enabled: boolean; email?: string; readOnly: boolean }, + }: { + pending_subscription_flow_enabled: boolean + email?: string | null | undefined + readOnly: boolean + }, ref ) => { const stripe = useStripe() @@ -59,7 +63,11 @@ const NewPaymentMethodElement = forwardRef( createPaymentMethod, })) - return + return ( + + ) } ) diff --git a/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx b/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx index 869b3a48efe9d..bf00d51029cf5 100644 --- a/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx +++ b/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx @@ -19,6 +19,7 @@ export const ApiKeysMoved = () => { { name: 'Infrastructure', key: 'infrastructure', url: '#' }, { name: 'Integrations', key: 'integrations', url: '#' }, { name: 'API Keys', key: 'api-keys', url: '#', label: 'NEW' }, + { name: 'JWT Keys', key: 'jwt-keys', url: '#', label: 'NEW' }, { name: 'Add Ons', key: 'addons', url: '#' }, { name: 'Vault', @@ -37,14 +38,19 @@ export const ApiKeysMoved = () => { return (
-
-

API keys have moved

+
+

API keys and JWT settings have moved

- They can now be found in the new API Keys page + They can now be found in their own respective pages.

- - - +
+ + + + + + +
{/* Menu illustration using the actual ProductMenu component */} diff --git a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx index 56b402af1095a..0b1ed99ab3aeb 100644 --- a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx +++ b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx @@ -1,4 +1,3 @@ -import { JwtSecretUpdateError, JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' import { useQueryClient } from '@tanstack/react-query' import { AlertCircle } from 'lucide-react' import { useEffect, useRef } from 'react' @@ -9,7 +8,6 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte import DatabaseSelector from 'components/ui/DatabaseSelector' import Panel from 'components/ui/Panel' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' -import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' import { configKeys } from 'data/config/keys' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useLoadBalancersQuery } from 'data/read-replicas/load-balancers-query' @@ -17,9 +15,7 @@ import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' import { PROJECT_STATUS } from 'lib/constants' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { Badge, Input } from 'ui' -import { JWT_SECRET_UPDATE_ERROR_MESSAGES } from './API.constants' import { ApiKeysMoved } from './ApiKeysMoved' -import JWTSettings from './JWTSettings' import { PostgrestConfig } from './PostgrestConfig' const ServiceList = () => { @@ -32,15 +28,6 @@ const ServiceList = () => { const { data: databases, isError } = useReadReplicasQuery({ projectRef }) const { data: loadBalancers } = useLoadBalancersQuery({ projectRef }) - const { data } = useJwtSecretUpdatingStatusQuery({ projectRef }) - const jwtSecretUpdateStatus = data?.jwtSecretUpdateStatus - const jwtSecretUpdateError = data?.jwtSecretUpdateError - - const previousJwtSecretUpdateStatus = useRef() - const { Failed, Updated, Updating } = JwtSecretUpdateStatus - const jwtSecretUpdateErrorMessage = - JWT_SECRET_UPDATE_ERROR_MESSAGES[jwtSecretUpdateError as JwtSecretUpdateError] - // Get the API service const isCustomDomainActive = customDomainData?.customDomain?.status === 'active' const selectedDatabase = databases?.find((db) => db.identifier === state.selectedDatabaseId) @@ -54,30 +41,6 @@ const ServiceList = () => { ? loadBalancers?.[0].endpoint ?? '' : selectedDatabase?.restUrl - useEffect(() => { - if (previousJwtSecretUpdateStatus.current === Updating) { - switch (jwtSecretUpdateStatus) { - case Updated: - client.invalidateQueries(configKeys.api(projectRef)) - client.invalidateQueries(configKeys.settings(projectRef)) - client.invalidateQueries(configKeys.postgrest(projectRef)) - toast.success('Successfully updated JWT secret') - break - case Failed: - toast.error(`JWT secret update failed: ${jwtSecretUpdateErrorMessage}`) - break - } - } - - previousJwtSecretUpdateStatus.current = jwtSecretUpdateStatus - }, [jwtSecretUpdateStatus]) - - useEffect(() => { - if (source !== undefined) { - state.setSelectedDatabaseId('load-balancer') - } - }, [source]) - return (
{isLoading ? ( @@ -147,10 +110,6 @@ const ServiceList = () => { -
- -
-
diff --git a/apps/studio/components/layouts/JWTKeys/JWTKeysLayout.tsx b/apps/studio/components/layouts/JWTKeys/JWTKeysLayout.tsx new file mode 100644 index 0000000000000..9b3825af5581d --- /dev/null +++ b/apps/studio/components/layouts/JWTKeys/JWTKeysLayout.tsx @@ -0,0 +1,36 @@ +import { PageLayout } from 'components/layouts/PageLayout/PageLayout' +import { ScaffoldContainer } from 'components/layouts/Scaffold' +import { PropsWithChildren } from 'react' + +import { useParams } from 'common' + +const JWTKeysLayout = ({ children }: PropsWithChildren) => { + const { ref: projectRef } = useParams() + + const navigationItems = [ + { + label: 'JWT Secret', + href: `/project/${projectRef}/settings/jwt`, + id: 'legacy-jwt-keys', + }, + { + label: 'JWT Signing Keys (Coming Soon)', + href: `/project/${projectRef}/settings/jwt/signing-keys`, + id: 'signing-keys', + }, + ] + + return ( + + + {children} + + + ) +} + +export default JWTKeysLayout diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx index 6f847292c24aa..f7f7138e9564b 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx @@ -64,6 +64,13 @@ export const generateSettingsMenu = ( items: [], label: 'NEW', }, + { + name: 'JWT Keys', + key: 'jwt', + url: `/project/${ref}/settings/jwt`, + items: [], + label: 'NEW', + }, ] : []), { diff --git a/apps/studio/components/ui/InfoPill.tsx b/apps/studio/components/ui/InfoPill.tsx new file mode 100644 index 0000000000000..be2b35973bc53 --- /dev/null +++ b/apps/studio/components/ui/InfoPill.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from 'ui/src/components/shadcn/ui/hover-card' +import { Badge } from 'ui/src/components/shadcn/ui/badge' +import { ExternalLink } from 'lucide-react' + +interface Link { + url: string + label: string +} + +interface InfoPillProps { + label: string + icon: React.ReactNode + title: string + description: string + links?: Link[] +} + +export const InfoPill: React.FC = ({ label, icon, title, description, links }) => { + return ( + + + + {icon} {label} + + + +
+ {React.cloneElement(icon as React.ReactElement, { + className: 'w-5 h-5', + strokeWidth: 1.5, + })} +
+
+

{title}

+

{description}

+ {links && links.length > 0 && ( +
+ {links.map((link, index) => ( + + + {link.label} + + ))} +
+ )} +
+
+
+ ) +} diff --git a/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx b/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx index 0c17e4a676f6e..9a8e4f1856348 100644 --- a/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx +++ b/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { toast } from 'sonner' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useToggleLegacyAPIKeysMutation } from 'data/api-keys/legacy-api-key-toggle-mutation' import { useLegacyAPIKeysStatusQuery } from 'data/api-keys/legacy-api-keys-status-query' @@ -11,11 +11,11 @@ import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, CriticalIc import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' export const ToggleLegacyApiKeysPanel = () => { - const { project } = useProjectContext() + const { ref: projectRef } = useParams() const [isConfirmOpen, setIsConfirmOpen] = useState(false) const { data: legacyAPIKeysStatusData, isSuccess: isLegacyAPIKeysStatusSuccess } = - useLegacyAPIKeysStatusQuery({ projectRef: project!.ref }) + useLegacyAPIKeysStatusQuery({ projectRef }) const { can: canUpdateAPIKeys, isSuccess: isPermissionsSuccess } = useAsyncCheckProjectPermissions(PermissionAction.SECRETS_WRITE, '*') @@ -77,7 +77,7 @@ const ToggleApiKeysModal = ({ onClose: () => void legacyAPIKeysStatusData: { enabled: boolean } }) => { - const { project } = useProjectContext() + const { ref: projectRef } = useParams() const { mutate: toggleLegacyAPIKey, isLoading: isTogglingLegacyAPIKey } = useToggleLegacyAPIKeysMutation() @@ -86,7 +86,7 @@ const ToggleApiKeysModal = ({ const enabled = !legacyAPIKeysStatusData?.enabled toggleLegacyAPIKey( - { projectRef: project!.ref, enabled }, + { projectRef, enabled }, { onSuccess: () => { toast.success( diff --git a/apps/studio/data/jwt-signing-keys/jwt-signing-key-create-mutation.ts b/apps/studio/data/jwt-signing-keys/jwt-signing-key-create-mutation.ts new file mode 100644 index 0000000000000..91ad4e4d66e94 --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/jwt-signing-key-create-mutation.ts @@ -0,0 +1,64 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, post } from 'data/fetchers' +import type { ResponseError } from 'types' +import { JWTAlgorithm } from './jwt-signing-keys-query' +import { jwtSigningKeysKeys } from './keys' + +interface JWTSigningKeyCreateVariables { + projectRef?: string + algorithm: JWTAlgorithm + status: 'in_use' | 'standby' +} + +export async function createJWTSigningKey(payload: JWTSigningKeyCreateVariables) { + if (!payload.projectRef) throw new Error('projectRef is required') + + const { data, error } = await post('/v1/projects/{ref}/config/auth/signing-keys', { + params: { + path: { ref: payload.projectRef }, + }, + body: { + algorithm: payload.algorithm, + status: payload.status, + }, + }) + + if (error) handleError(error) + return data +} + +type JWTSigningKeyCreateData = Awaited> + +export const useJWTSigningKeyCreateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => createJWTSigningKey(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await queryClient.invalidateQueries(jwtSigningKeysKeys.list(projectRef)) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to create new JWT signing key: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/jwt-signing-keys/jwt-signing-key-delete-mutation.ts b/apps/studio/data/jwt-signing-keys/jwt-signing-key-delete-mutation.ts new file mode 100644 index 0000000000000..d39179436c9ca --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/jwt-signing-key-delete-mutation.ts @@ -0,0 +1,58 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { del, handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { jwtSigningKeysKeys } from './keys' + +interface JWTSigningKeyDeleteVariables { + projectRef?: string + keyId: string +} + +export async function deleteJWTSigningKey(payload: JWTSigningKeyDeleteVariables) { + if (!payload.projectRef) throw new Error('projectRef is required') + + const { data, error } = await del('/v1/projects/{ref}/config/auth/signing-keys/{id}', { + params: { + path: { ref: payload.projectRef, id: payload.keyId }, + }, + }) + + if (error) handleError(error) + return data +} + +type JWTSigningKeyDeleteData = Awaited> + +export const useJWTSigningKeyDeleteMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => deleteJWTSigningKey(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await queryClient.invalidateQueries(jwtSigningKeysKeys.list(projectRef)) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to delete JWT signing key: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/jwt-signing-keys/jwt-signing-key-update-mutation.ts b/apps/studio/data/jwt-signing-keys/jwt-signing-key-update-mutation.ts new file mode 100644 index 0000000000000..a406194980e07 --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/jwt-signing-key-update-mutation.ts @@ -0,0 +1,62 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, patch } from 'data/fetchers' +import type { ResponseError } from 'types' +import { jwtSigningKeysKeys } from './keys' + +interface JWTSigningKeyUpdateVariables { + projectRef?: string + keyId: string + status: 'in_use' | 'standby' | 'previously_used' | 'revoked' +} + +export async function updateJWTSigningKey(payload: JWTSigningKeyUpdateVariables) { + if (!payload.projectRef) throw new Error('projectRef is required') + + const { data, error } = await patch('/v1/projects/{ref}/config/auth/signing-keys/{id}', { + params: { + path: { ref: payload.projectRef, id: payload.keyId }, + }, + body: { + status: payload.status, + }, + }) + + if (error) handleError(error) + return data +} + +type JWTSigningKeyUpdateData = Awaited> + +export const useJWTSigningKeyUpdateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => updateJWTSigningKey(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await queryClient.invalidateQueries(jwtSigningKeysKeys.list(projectRef)) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to update new JWT signing key: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/jwt-signing-keys/jwt-signing-keys-query.ts b/apps/studio/data/jwt-signing-keys/jwt-signing-keys-query.ts new file mode 100644 index 0000000000000..83cf2a5addcfc --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/jwt-signing-keys-query.ts @@ -0,0 +1,43 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { components } from 'api-types' +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types' +import { jwtSigningKeysKeys } from './keys' + +export type JWTSigningKey = components['schemas']['SigningKeyResponse'] + +export type JWTAlgorithm = components['schemas']['SigningKeyResponse']['algorithm'] + +interface JWTSigningKeysVariables { + projectRef?: string +} + +async function getJWTSigningKeys({ projectRef }: JWTSigningKeysVariables, signal?: AbortSignal) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await get(`/v1/projects/{ref}/config/auth/signing-keys`, { + params: { path: { ref: projectRef } }, + signal, + }) + + if (error) { + handleError(error) + } + + return data +} + +export type JWTSigningKeysData = Awaited> + +export const useJWTSigningKeysQuery = ( + { projectRef }: JWTSigningKeysVariables, + { enabled, ...options }: UseQueryOptions = {} +) => + useQuery( + jwtSigningKeysKeys.list(projectRef), + ({ signal }) => getJWTSigningKeys({ projectRef }, signal), + { + enabled: enabled && !!projectRef, + ...options, + } + ) diff --git a/apps/studio/data/jwt-signing-keys/keys.ts b/apps/studio/data/jwt-signing-keys/keys.ts new file mode 100644 index 0000000000000..4d2c9a01a6335 --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/keys.ts @@ -0,0 +1,4 @@ +export const jwtSigningKeysKeys = { + list: (projectRef?: string) => ['projects', projectRef, 'jwt-signing-keys'] as const, + legacy: (projectRef?: string) => ['projects', projectRef, 'legacy-jwt-signing-key'] as const, +} diff --git a/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation.ts b/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation.ts new file mode 100644 index 0000000000000..3ec3c15fc1043 --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation.ts @@ -0,0 +1,64 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, post } from 'data/fetchers' +import type { ResponseError } from 'types' +import { jwtSigningKeysKeys } from './keys' + +interface LegacyJWTSigningKeyCreateVariables { + projectRef?: string +} + +export async function createLegacyJWTSigningKey(payload: LegacyJWTSigningKeyCreateVariables) { + if (!payload.projectRef) throw new Error('projectRef is required') + + const { data, error } = await post('/v1/projects/{ref}/config/auth/signing-keys/legacy', { + params: { + path: { ref: payload.projectRef }, + }, + }) + + if (error) handleError(error) + return data +} + +type LegacyJWTSigningKeyCreateData = Awaited> + +export const useLegacyJWTSigningKeyCreateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions< + LegacyJWTSigningKeyCreateData, + ResponseError, + LegacyJWTSigningKeyCreateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + LegacyJWTSigningKeyCreateData, + ResponseError, + LegacyJWTSigningKeyCreateVariables + >((vars) => createLegacyJWTSigningKey(vars), { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await queryClient.invalidateQueries(jwtSigningKeysKeys.legacy(projectRef)) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error( + `Failed to enable use of JWT signing keys with legacy JWT secret: ${data.message}` + ) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-query.ts b/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-query.ts new file mode 100644 index 0000000000000..069b7504776ee --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-query.ts @@ -0,0 +1,42 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types' + +import { jwtSigningKeysKeys } from './keys' + +interface LegacyJWTSigningKeyVariables { + projectRef?: string +} + +async function getLegacyJWTSigningKey( + { projectRef }: LegacyJWTSigningKeyVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await get(`/v1/projects/{ref}/config/auth/signing-keys/legacy`, { + params: { path: { ref: projectRef } }, + signal, + }) + + if (error) { + handleError(error) + } + + return data +} + +export type LegacyJWTSigningKeyData = Awaited> + +export const useLegacyJWTSigningKeyQuery = ( + { projectRef }: LegacyJWTSigningKeyVariables, + { enabled, ...options }: UseQueryOptions = {} +) => + useQuery( + jwtSigningKeysKeys.legacy(projectRef), + ({ signal }) => getLegacyJWTSigningKey({ projectRef }, signal), + { + enabled: enabled && !!projectRef, + ...options, + } + ) diff --git a/apps/studio/data/organizations/organization-project-claim-mutation.ts b/apps/studio/data/organizations/organization-project-claim-mutation.ts index 25b29c20ea4b3..16d42a3157170 100644 --- a/apps/studio/data/organizations/organization-project-claim-mutation.ts +++ b/apps/studio/data/organizations/organization-project-claim-mutation.ts @@ -10,7 +10,6 @@ type OrganizationProjectClaimVariables = { } async function claimOrganizationProject({ slug, token }: OrganizationProjectClaimVariables) { - // @ts-expect-error the endpoint is hidden in the spec for now const { data, error } = await post('/v1/organizations/{slug}/project-claim/{token}', { params: { path: { slug, token } }, }) diff --git a/apps/studio/data/organizations/organization-project-claim-query.ts b/apps/studio/data/organizations/organization-project-claim-query.ts index f620074a041c8..169043d7fdcd8 100644 --- a/apps/studio/data/organizations/organization-project-claim-query.ts +++ b/apps/studio/data/organizations/organization-project-claim-query.ts @@ -51,7 +51,6 @@ async function getOrganizationProjectClaim( ) { if (!slug || !token) throw new Error('Slug and token are required') - // @ts-expect-error the endpoint is hidden in the spec for now const { data, error } = await get(`/v1/organizations/{slug}/project-claim/{token}`, { params: { path: { slug, token } }, signal, diff --git a/apps/studio/pages/project/[ref]/settings/jwt/index.tsx b/apps/studio/pages/project/[ref]/settings/jwt/index.tsx new file mode 100644 index 0000000000000..07f3565d8572c --- /dev/null +++ b/apps/studio/pages/project/[ref]/settings/jwt/index.tsx @@ -0,0 +1,58 @@ +import JWTSettings from 'components/interfaces/JwtSecrets/jwt-settings' +import DefaultLayout from 'components/layouts/DefaultLayout' +import JWTKeysLayout from 'components/layouts/JWTKeys/JWTKeysLayout' +import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' +import type { NextPageWithLayout } from 'types' + +import { JwtSecretUpdateError, JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' +import { useQueryClient } from '@tanstack/react-query' +import { useParams } from 'common' +import { JWT_SECRET_UPDATE_ERROR_MESSAGES } from 'components/interfaces/JwtSecrets/jwt.constants' +import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' +import { configKeys } from 'data/config/keys' +import { useEffect, useRef } from 'react' +import { toast } from 'sonner' + +const JWTKeysLegacyPage: NextPageWithLayout = () => { + const { ref: projectRef, source } = useParams() + const client = useQueryClient() + + const { data } = useJwtSecretUpdatingStatusQuery({ projectRef }) + const jwtSecretUpdateStatus = data?.jwtSecretUpdateStatus + const jwtSecretUpdateError = data?.jwtSecretUpdateError + + const previousJwtSecretUpdateStatus = useRef() + const { Failed, Updated, Updating } = JwtSecretUpdateStatus + const jwtSecretUpdateErrorMessage = + JWT_SECRET_UPDATE_ERROR_MESSAGES[jwtSecretUpdateError as JwtSecretUpdateError] + + useEffect(() => { + if (previousJwtSecretUpdateStatus.current === Updating) { + switch (jwtSecretUpdateStatus) { + case Updated: + client.invalidateQueries(configKeys.api(projectRef)) + client.invalidateQueries(configKeys.settings(projectRef)) + client.invalidateQueries(configKeys.postgrest(projectRef)) + toast.success('Successfully updated JWT secret') + break + case Failed: + toast.error(`JWT secret update failed: ${jwtSecretUpdateErrorMessage}`) + break + } + } + + previousJwtSecretUpdateStatus.current = jwtSecretUpdateStatus + }, [jwtSecretUpdateStatus]) + + return +} + +JWTKeysLegacyPage.getLayout = (page) => ( + + + {page} + + +) + +export default JWTKeysLegacyPage diff --git a/apps/studio/pages/project/[ref]/settings/jwt/signing-keys.tsx b/apps/studio/pages/project/[ref]/settings/jwt/signing-keys.tsx new file mode 100644 index 0000000000000..406f8d9ae011a --- /dev/null +++ b/apps/studio/pages/project/[ref]/settings/jwt/signing-keys.tsx @@ -0,0 +1,36 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import JWTSecretKeysTable from 'components/interfaces/JwtSecrets/jwt-secret-keys-table' +import DefaultLayout from 'components/layouts/DefaultLayout' +import JWTKeysLayout from 'components/layouts/JWTKeys/JWTKeysLayout' +import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' +import NoPermission from 'components/ui/NoPermission' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' +import type { NextPageWithLayout } from 'types' + +const JWTSigningKeysPage: NextPageWithLayout = () => { + const isPermissionsLoaded = usePermissionsLoaded() + const canReadAPIKeys = useCheckPermissions(PermissionAction.READ, 'auth_signing_keys') + + return ( + <> + {!isPermissionsLoaded ? ( + + ) : !canReadAPIKeys ? ( + + ) : ( + + )} + + ) +} + +JWTSigningKeysPage.getLayout = (page) => ( + + + {page} + + +) + +export default JWTSigningKeysPage diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 88d51e24ff2dd..4af05d16429cf 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -129,6 +129,26 @@ export interface paths { patch?: never trace?: never } + '/v1/oauth/authorize/project-claim': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** + * Authorize user through oauth and claim a project + * @description Initiates the OAuth authorization flow for the specified provider. After successful authentication, the user can claim ownership of the specified project. + */ + get: operations['v1-oauth-authorize-project-claim'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/v1/oauth/revoke': { parameters: { query?: never @@ -218,6 +238,24 @@ export interface paths { patch?: never trace?: never } + '/v1/organizations/{slug}/project-claim/{token}': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Gets project details for the specified organization and claim token */ + get: operations['v1-get-organization-project-claim'] + put?: never + /** Claims project for the specified organization */ + post: operations['v1-claim-project-for-organization'] + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/v1/projects': { parameters: { query?: never @@ -269,7 +307,7 @@ export interface paths { * @deprecated * @description This is an **experimental** endpoint. It is subject to change or removal in future versions. Use it with caution, as it may not remain supported or stable. */ - get: operations['getPerformanceAdvisors'] + get: operations['v1-get-performance-advisors'] put?: never post?: never delete?: never @@ -290,7 +328,7 @@ export interface paths { * @deprecated * @description This is an **experimental** endpoint. It is subject to change or removal in future versions. Use it with caution, as it may not remain supported or stable. */ - get: operations['getSecurityAdvisors'] + get: operations['v1-get-security-advisors'] put?: never post?: never delete?: never @@ -315,7 +353,7 @@ export interface paths { * The timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown. * */ - get: operations['getLogs'] + get: operations['v1-get-project-logs'] put?: never post?: never delete?: never @@ -332,7 +370,7 @@ export interface paths { cookie?: never } /** Gets project's usage api counts */ - get: operations['getApiCounts'] + get: operations['v1-get-project-usage-api-count'] put?: never post?: never delete?: never @@ -349,7 +387,7 @@ export interface paths { cookie?: never } /** Gets project's usage api requests count */ - get: operations['getApiRequestsCount'] + get: operations['v1-get-project-usage-request-count'] put?: never post?: never delete?: never @@ -369,7 +407,7 @@ export interface paths { get: operations['v1-get-project-api-keys'] put?: never /** Creates a new API key for the project */ - post: operations['createApiKey'] + post: operations['v1-create-project-api-key'] delete?: never options?: never head?: never @@ -384,15 +422,15 @@ export interface paths { cookie?: never } /** Get API key */ - get: operations['getApiKey'] + get: operations['v1-get-project-api-key'] put?: never post?: never /** Deletes an API key for the project */ - delete: operations['deleteApiKey'] + delete: operations['v1-delete-project-api-key'] options?: never head?: never /** Updates an API key for the project */ - patch: operations['updateApiKey'] + patch: operations['v1-update-project-api-key'] trace?: never } '/v1/projects/{ref}/api-keys/legacy': { @@ -403,9 +441,9 @@ export interface paths { cookie?: never } /** Check whether JWT based legacy (anon, service_role) API keys are enabled. This API endpoint will be removed in the future, check for HTTP 404 Not Found. */ - get: operations['checkLegacyApiKeys'] + get: operations['v1-get-project-legacy-api-keys'] /** Disable or re-enable JWT based legacy (anon, service_role) API keys. This API endpoint will be removed in the future, check for HTTP 404 Not Found. */ - put: operations['updateLegacyApiKeys'] + put: operations['v1-update-project-legacy-api-keys'] post?: never delete?: never options?: never @@ -476,6 +514,25 @@ export interface paths { patch?: never trace?: never } + '/v1/projects/{ref}/claim-token': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Gets project claim token */ + get: operations['v1-get-project-claim-token'] + put?: never + /** Creates project claim token */ + post: operations['v1-create-project-claim-token'] + /** Revokes project claim token */ + delete: operations['v1-delete-project-claim-token'] + options?: never + head?: never + patch?: never + trace?: never + } '/v1/projects/{ref}/config/auth': { parameters: { query?: never @@ -501,11 +558,11 @@ export interface paths { path?: never cookie?: never } - /** [Alpha] List all signing keys for the project */ - get: operations['listSigningKeysForProject'] + /** List all signing keys for the project */ + get: operations['v1-get-project-signing-keys'] put?: never - /** [Alpha] Create a new signing key for the project in standby status */ - post: operations['createSigningKeyForProject'] + /** Create a new signing key for the project in standby status */ + post: operations['v1-create-project-signing-key'] delete?: never options?: never head?: never @@ -519,16 +576,34 @@ export interface paths { path?: never cookie?: never } - /** [Alpha] Get information about a signing key */ - get: operations['getSigningKeyForProject'] + /** Get information about a signing key */ + get: operations['v1-get-project-signing-key'] put?: never post?: never - /** [Alpha] Remove a signing key from a project, where the status is previously_used */ - delete: operations['deleteSigningKey'] + /** Remove a signing key from a project. Only possible if the key has been in revoked status for a while. */ + delete: operations['v1-remove-project-signing-key'] options?: never head?: never - /** [Alpha] Update a signing key, mainly its status */ - patch: operations['patchSigningKey'] + /** Update a signing key, mainly its status */ + patch: operations['v1-update-project-signing-key'] + trace?: never + } + '/v1/projects/{ref}/config/auth/signing-keys/legacy': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Get the signing key information for the JWT secret imported as signing key for this project. This endpoint will be removed in the future, check for HTTP 404 Not Found. */ + get: operations['v1-get-legacy-signing-key'] + put?: never + /** Set up the project's existing JWT secret as an in_use JWT signing key. This endpoint will be removed in the future always check for HTTP 404 Not Found. */ + post: operations['v1-create-legacy-signing-key'] + delete?: never + options?: never + head?: never + patch?: never trace?: never } '/v1/projects/{ref}/config/auth/sso/providers': { @@ -576,10 +651,10 @@ export interface paths { cookie?: never } /** Lists all third-party auth integrations */ - get: operations['listTPAForProject'] + get: operations['v1-list-project-tpa-integrations'] put?: never /** Creates a new third-party auth integration */ - post: operations['createTPAForProject'] + post: operations['v1-create-project-tpa-integration'] delete?: never options?: never head?: never @@ -594,11 +669,11 @@ export interface paths { cookie?: never } /** Get a third-party integration */ - get: operations['getTPAForProject'] + get: operations['v1-get-project-tpa-integration'] put?: never post?: never /** Removes a third-party auth integration */ - delete: operations['deleteTPAForProject'] + delete: operations['v1-delete-project-tpa-integration'] options?: never head?: never patch?: never @@ -778,6 +853,41 @@ export interface paths { patch?: never trace?: never } + '/v1/projects/{ref}/database/backups/restore-point': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Get restore points for project */ + get: operations['v1-get-restore-point'] + put?: never + /** Initiates a creation of a restore point for a database */ + post: operations['v1-create-restore-point'] + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/v1/projects/{ref}/database/backups/undo': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + /** Initiates an undo to a given restore point */ + post: operations['v1-undo'] + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/v1/projects/{ref}/database/context': { parameters: { query?: never @@ -790,7 +900,7 @@ export interface paths { * @deprecated * @description This is an **experimental** endpoint. It is subject to change or removal in future versions. Use it with caution, as it may not remain supported or stable. */ - get: operations['getDatabaseMetadata'] + get: operations['v1-get-database-metadata'] put?: never post?: never delete?: never @@ -1849,6 +1959,14 @@ export interface components { CreateOrganizationV1: { name: string } + CreateProjectClaimTokenResponse: { + created_at: string + /** Format: uuid */ + created_by: string + expires_at: string + token: string + token_alias: string + } CreateProviderBody: { attribute_mapping?: { keys: { @@ -2271,6 +2389,41 @@ export interface components { /** @enum {string} */ token_type: 'Bearer' } + OrganizationProjectClaimResponse: { + created_at: string + /** Format: uuid */ + created_by: string + expires_at: string + preview: { + errors: { + key: string + message: string + }[] + info: { + key: string + message: string + }[] + members_exceeding_free_project_limit: { + limit: number + name: string + }[] + /** @enum {string} */ + source_subscription_plan: 'free' | 'pro' | 'team' | 'enterprise' + target_organization_eligible: boolean | null + target_organization_has_free_project_slots: boolean | null + /** @enum {string|null} */ + target_subscription_plan: 'free' | 'pro' | 'team' | 'enterprise' | null + valid: boolean + warnings: { + key: string + message: string + }[] + } + project: { + name: string + ref: string + } + } OrganizationResponseV1: { id: string name: string @@ -2312,6 +2465,13 @@ export interface components { jwt_secret?: string max_rows: number } + ProjectClaimTokenResponse: { + created_at: string + /** Format: uuid */ + created_by: string + expires_at: string + token_alias: string + } ProjectUpgradeEligibilityResponse: { current_app_version: string /** @enum {string} */ @@ -3153,6 +3313,14 @@ export interface components { /** Format: int64 */ recovery_time_target_unix: number } + V1RestorePointPostBody: { + name: string + } + V1RestorePointResponse: { + name: string + /** @enum {string} */ + status: 'AVAILABLE' | 'PENDING' | 'REMOVED' + } V1RunQueryBody: { query: string read_only?: boolean @@ -3185,6 +3353,9 @@ export interface components { public: boolean updated_at: string } + V1UndoBody: { + name: string + } V1UpdateFunctionBody: { body?: string name?: string @@ -3464,6 +3635,33 @@ export interface operations { } } } + 'v1-oauth-authorize-project-claim': { + parameters: { + query: { + client_id: string + code_challenge?: string + code_challenge_method?: 'plain' | 'sha256' | 'S256' + /** @description Project ref */ + project_ref: string + redirect_uri: string + response_mode?: string + response_type: 'code' | 'token' | 'id_token token' + state?: string + } + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + 204: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } 'v1-revoke-token': { parameters: { query?: never @@ -3620,6 +3818,62 @@ export interface operations { } } } + 'v1-get-organization-project-claim': { + parameters: { + query?: never + header?: never + path: { + /** @description Organization slug */ + slug: string + token: string + } + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['OrganizationProjectClaimResponse'] + } + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + 'v1-claim-project-for-organization': { + parameters: { + query?: never + header?: never + path: { + /** @description Organization slug */ + slug: string + token: string + } + cookie?: never + } + requestBody?: never + responses: { + 204: { + headers: { + [name: string]: unknown + } + content?: never + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } 'v1-list-all-projects': { parameters: { query?: never @@ -3725,7 +3979,7 @@ export interface operations { } } } - getPerformanceAdvisors: { + 'v1-get-performance-advisors': { parameters: { query?: never header?: never @@ -3753,9 +4007,11 @@ export interface operations { } } } - getSecurityAdvisors: { + 'v1-get-security-advisors': { parameters: { - query?: never + query?: { + lint_type?: 'sql' + } header?: never path: { /** @description Project ref */ @@ -3781,7 +4037,7 @@ export interface operations { } } } - getLogs: { + 'v1-get-project-logs': { parameters: { query?: { iso_timestamp_end?: string @@ -3813,7 +4069,7 @@ export interface operations { } } } - getApiCounts: { + 'v1-get-project-usage-api-count': { parameters: { query?: { interval?: '15min' | '30min' | '1hr' | '3hr' | '1day' | '3day' | '7day' @@ -3850,7 +4106,7 @@ export interface operations { } } } - getApiRequestsCount: { + 'v1-get-project-usage-request-count': { parameters: { query?: never header?: never @@ -3910,7 +4166,7 @@ export interface operations { } } } - createApiKey: { + 'v1-create-project-api-key': { parameters: { query?: { /** @description Boolean string, true or false */ @@ -3945,7 +4201,7 @@ export interface operations { } } } - getApiKey: { + 'v1-get-project-api-key': { parameters: { query?: { /** @description Boolean string, true or false */ @@ -3977,7 +4233,7 @@ export interface operations { } } } - deleteApiKey: { + 'v1-delete-project-api-key': { parameters: { query?: { reason?: string @@ -4012,7 +4268,7 @@ export interface operations { } } } - updateApiKey: { + 'v1-update-project-api-key': { parameters: { query?: { /** @description Boolean string, true or false */ @@ -4048,7 +4304,7 @@ export interface operations { } } } - checkLegacyApiKeys: { + 'v1-get-project-legacy-api-keys': { parameters: { query?: never header?: never @@ -4076,7 +4332,7 @@ export interface operations { } } } - updateLegacyApiKeys: { + 'v1-update-project-legacy-api-keys': { parameters: { query: { /** @description Boolean string, true or false */ @@ -4320,6 +4576,88 @@ export interface operations { } } } + 'v1-get-project-claim-token': { + parameters: { + query?: never + header?: never + path: { + /** @description Project ref */ + ref: string + } + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['ProjectClaimTokenResponse'] + } + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + 'v1-create-project-claim-token': { + parameters: { + query?: never + header?: never + path: { + /** @description Project ref */ + ref: string + } + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['CreateProjectClaimTokenResponse'] + } + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + 'v1-delete-project-claim-token': { + parameters: { + query?: never + header?: never + path: { + /** @description Project ref */ + ref: string + } + cookie?: never + } + requestBody?: never + responses: { + 204: { + headers: { + [name: string]: unknown + } + content?: never + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } 'v1-get-auth-service-config': { parameters: { query?: never @@ -4394,7 +4732,7 @@ export interface operations { } } } - listSigningKeysForProject: { + 'v1-get-project-signing-keys': { parameters: { query?: never header?: never @@ -4422,7 +4760,7 @@ export interface operations { } } } - createSigningKeyForProject: { + 'v1-create-project-signing-key': { parameters: { query?: never header?: never @@ -4454,7 +4792,7 @@ export interface operations { } } } - getSigningKeyForProject: { + 'v1-get-project-signing-key': { parameters: { query?: never header?: never @@ -4483,7 +4821,7 @@ export interface operations { } } } - deleteSigningKey: { + 'v1-remove-project-signing-key': { parameters: { query?: never header?: never @@ -4512,7 +4850,7 @@ export interface operations { } } } - patchSigningKey: { + 'v1-update-project-signing-key': { parameters: { query?: never header?: never @@ -4545,6 +4883,62 @@ export interface operations { } } } + 'v1-get-legacy-signing-key': { + parameters: { + query?: never + header?: never + path: { + /** @description Project ref */ + ref: string + } + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['SigningKeyResponse'] + } + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + 'v1-create-legacy-signing-key': { + parameters: { + query?: never + header?: never + path: { + /** @description Project ref */ + ref: string + } + cookie?: never + } + requestBody?: never + responses: { + 201: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['SigningKeyResponse'] + } + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } 'v1-list-all-sso-provider': { parameters: { query?: never @@ -4731,7 +5125,7 @@ export interface operations { } } } - listTPAForProject: { + 'v1-list-project-tpa-integrations': { parameters: { query?: never header?: never @@ -4759,7 +5153,7 @@ export interface operations { } } } - createTPAForProject: { + 'v1-create-project-tpa-integration': { parameters: { query?: never header?: never @@ -4791,7 +5185,7 @@ export interface operations { } } } - getTPAForProject: { + 'v1-get-project-tpa-integration': { parameters: { query?: never header?: never @@ -4820,7 +5214,7 @@ export interface operations { } } } - deleteTPAForProject: { + 'v1-delete-project-tpa-integration': { parameters: { query?: never header?: never @@ -5340,7 +5734,100 @@ export interface operations { } } } - getDatabaseMetadata: { + 'v1-get-restore-point': { + parameters: { + query?: { + name?: string + } + header?: never + path: { + /** @description Project ref */ + ref: string + } + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['V1RestorePointResponse'] + } + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Failed to get requested restore points */ + 500: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + 'v1-create-restore-point': { + parameters: { + query?: never + header?: never + path: { + /** @description Project ref */ + ref: string + } + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['V1RestorePointPostBody'] + } + } + responses: { + 201: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['V1RestorePointResponse'] + } + } + } + } + 'v1-undo': { + parameters: { + query?: never + header?: never + path: { + /** @description Project ref */ + ref: string + } + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['V1UndoBody'] + } + } + responses: { + 201: { + headers: { + [name: string]: unknown + } + content?: never + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + 'v1-get-database-metadata': { parameters: { query?: never header?: never diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index 2dfaf2c706b3e..208765a7f5e26 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -4299,7 +4299,7 @@ export interface components { token: string token_alias: string } - CreateBackendParams: { + CreateBackendParamsOpenapi: { config: | { hostname: string @@ -7713,7 +7713,7 @@ export interface components { | 'auth_mfa_web_authn_default' | 'log_drain_default' } - UpdateBackendParams: { + UpdateBackendParamsOpenapi: { config?: | { hostname: string @@ -7755,6 +7755,8 @@ export interface components { } description?: string name?: string + /** @enum {string} */ + type: 'postgres' | 'bigquery' | 'webhook' | 'datadog' | 'elastic' | 'loki' } UpdateCollectionBody: { name: string @@ -14637,7 +14639,7 @@ export interface operations { } requestBody: { content: { - 'application/json': components['schemas']['CreateBackendParams'] + 'application/json': components['schemas']['CreateBackendParamsOpenapi'] } } responses: { @@ -14678,7 +14680,7 @@ export interface operations { } requestBody: { content: { - 'application/json': components['schemas']['UpdateBackendParams'] + 'application/json': components['schemas']['UpdateBackendParamsOpenapi'] } } responses: {