diff --git a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx index f6d0086fca5..56b402af109 100644 --- a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx +++ b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx @@ -8,7 +8,6 @@ import { useParams } from 'common' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import DatabaseSelector from 'components/ui/DatabaseSelector' import Panel from 'components/ui/Panel' -import { DisplayApiSettings } from 'components/ui/ProjectSettings' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' import { configKeys } from 'data/config/keys' @@ -19,9 +18,9 @@ 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' -import { ApiKeysMoved } from './ApiKeysMoved' const ServiceList = () => { const client = useQueryClient() diff --git a/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx b/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx index d0044b2823b..e6e1589c44b 100644 --- a/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx @@ -122,8 +122,8 @@ const BuildingState = () => { -
- +
+
diff --git a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx index 02161bf0e4a..d6367fc2a7b 100644 --- a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx +++ b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx @@ -1,23 +1,27 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' +import { AlertCircle, Loader2 } from 'lucide-react' +import { useMemo } from 'react' +import { toast } from 'sonner' + import { useParams } from 'common' import Panel from 'components/ui/Panel' import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useFlag } from 'hooks/ui/useFlag' -import { AlertCircle, Loader2 } from 'lucide-react' -import { useMemo } from 'react' -import { toast } from 'sonner' import { Input } from 'ui' import { getLastUsedAPIKeys, useLastUsedAPIKeysLogQuery } from './DisplayApiSettings.utils' +import { ToggleLegacyApiKeysPanel } from './ToggleLegacyApiKeys' -const DisplayApiSettings = ({ - legacy, +export const DisplayApiSettings = ({ + showTitle = true, showNotice = true, + showLegacyText = true, }: { - legacy?: boolean + showTitle?: boolean showNotice?: boolean + showLegacyText?: boolean }) => { const { ref: projectRef } = useParams() @@ -35,13 +39,16 @@ const DisplayApiSettings = ({ } = useJwtSecretUpdatingStatusQuery({ projectRef }) const jwtSecretUpdateStatus = data?.jwtSecretUpdateStatus - const { isLoading, can: canReadAPIKeys } = useAsyncCheckProjectPermissions( + const { isLoading: isLoadingPermissions, can: canReadAPIKeys } = useAsyncCheckProjectPermissions( PermissionAction.READ, 'service_api_keys' ) + + const isLoading = isProjectSettingsLoading || isLoadingPermissions + const isNotUpdatingJwtSecret = jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated - const apiKeys = settings?.service_api_keys ?? [] + const apiKeys = useMemo(() => settings?.service_api_keys ?? [], [settings]) // api keys should not be empty. However it can be populated with a delay on project creation const isApiKeysEmpty = apiKeys.length === 0 @@ -66,8 +73,9 @@ const DisplayApiSettings = ({ return ( <>
Project API Keys

@@ -159,9 +167,9 @@ const DisplayApiSettings = ({ descriptionText={ x.tags === 'service_role' ? 'This key has the ability to bypass Row Level Security. Never share it publicly. If leaked, generate a new JWT secret immediately. ' + - (legacy ? 'Prefer using Publishable API keys instead.' : '') + (showLegacyText ? 'Prefer using Publishable API keys instead.' : '') : 'This key is safe to use in a browser if you have enabled Row Level Security for your tables and configured policies. ' + - (legacy ? 'Prefer using Secret API keys instead.' : '') + (showLegacyText ? 'Prefer using Secret API keys instead.' : '') } /> @@ -201,7 +209,7 @@ const DisplayApiSettings = ({ ) ) : null} + {newApiKeysInRollOut && !showNotice && } ) } -export default DisplayApiSettings diff --git a/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx b/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx new file mode 100644 index 00000000000..0c17e4a676f --- /dev/null +++ b/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx @@ -0,0 +1,130 @@ +import { useState } from 'react' +import { toast } from 'sonner' + +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +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' +import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, CriticalIcon } from 'ui' +import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' + +export const ToggleLegacyApiKeysPanel = () => { + const { project } = useProjectContext() + const [isConfirmOpen, setIsConfirmOpen] = useState(false) + + const { data: legacyAPIKeysStatusData, isSuccess: isLegacyAPIKeysStatusSuccess } = + useLegacyAPIKeysStatusQuery({ projectRef: project!.ref }) + + const { can: canUpdateAPIKeys, isSuccess: isPermissionsSuccess } = + useAsyncCheckProjectPermissions(PermissionAction.SECRETS_WRITE, '*') + + if (!(isLegacyAPIKeysStatusSuccess && isPermissionsSuccess)) { + return null + } + + return ( +

+ + + + {legacyAPIKeysStatusData.enabled + ? 'Disabling your legacy API keys may cause your applications to break.' + : 'Re-enabling your legacy API keys may expose your applications to security risks.'} + + + {legacyAPIKeysStatusData.enabled + ? 'Make sure you are no longer using your legacy API keys before proceeding.' + : "Make sure you've tested your RLS policies."} + +
+ setIsConfirmOpen(true)} + disabled={!canUpdateAPIKeys} + tooltip={{ + content: { + side: 'bottom', + text: !canUpdateAPIKeys + ? 'You need additional permissions to enable or disable JWT-based API keys' + : undefined, + }, + }} + > + {legacyAPIKeysStatusData.enabled + ? 'Disable JWT-based API keys' + : 'Re-enable JWT-based API keys'} + +
+
+ + setIsConfirmOpen(false)} + legacyAPIKeysStatusData={legacyAPIKeysStatusData} + /> +
+ ) +} + +const ToggleApiKeysModal = ({ + visible, + onClose, + legacyAPIKeysStatusData, +}: { + visible: boolean + onClose: () => void + legacyAPIKeysStatusData: { enabled: boolean } +}) => { + const { project } = useProjectContext() + + const { mutate: toggleLegacyAPIKey, isLoading: isTogglingLegacyAPIKey } = + useToggleLegacyAPIKeysMutation() + + const onToggleLegacyAPIKeysEnabled = () => { + const enabled = !legacyAPIKeysStatusData?.enabled + + toggleLegacyAPIKey( + { projectRef: project!.ref, enabled }, + { + onSuccess: () => { + toast.success( + enabled + ? 'Your anon and service_role keys have been re-enabled!' + : 'Your anon and service_role keys have been disabled!' + ) + onClose() + }, + } + ) + } + + return ( + onClose()} + onConfirm={onToggleLegacyAPIKeysEnabled} + title={ + legacyAPIKeysStatusData.enabled ? 'Disable JWT-based keys' : 'Re-enable JWT-based keys' + } + confirmString={legacyAPIKeysStatusData.enabled ? 'disable' : 're-enable'} + confirmLabel={`Yes, ${legacyAPIKeysStatusData.enabled ? 'disable' : 're-enable'} anon and service_role`} + confirmPlaceholder={legacyAPIKeysStatusData.enabled ? 'disable' : 're-enable'} + loading={isTogglingLegacyAPIKey} + variant={legacyAPIKeysStatusData.enabled ? 'destructive' : 'default'} + alert={ + legacyAPIKeysStatusData.enabled + ? { + title: 'Disabling can cause downtime!', + description: `If you disable your anon and service_role keys while they are in use, your applications will stop functioning. All API endpoints will receive HTTP 401 Unauthorized. Make sure you are no longer using them before proceeding.`, + } + : { + title: 'Prefer publishable and secret keys', + description: + 'While re-enabling anon and service_role keys makes sense in some cases, a better and more secure alternative is the publishable or secret key. Consider using those before proceeding!', + } + } + /> + ) +} diff --git a/apps/studio/components/ui/ProjectSettings/index.tsx b/apps/studio/components/ui/ProjectSettings/index.tsx index 37e2dcc5b8b..2d41ad6aaf5 100644 --- a/apps/studio/components/ui/ProjectSettings/index.tsx +++ b/apps/studio/components/ui/ProjectSettings/index.tsx @@ -1,4 +1,4 @@ -import DisplayApiSettings from './DisplayApiSettings' +import { DisplayApiSettings } from './DisplayApiSettings' import DisplayConfigSettings from './DisplayConfigSettings' export { DisplayApiSettings, DisplayConfigSettings } diff --git a/apps/studio/data/api-keys/api-keys-query.ts b/apps/studio/data/api-keys/api-keys-query.ts index 2ade5311c9d..de87ed9c42b 100644 --- a/apps/studio/data/api-keys/api-keys-query.ts +++ b/apps/studio/data/api-keys/api-keys-query.ts @@ -43,12 +43,12 @@ type PublishableKeys = { updated_at?: string } -export interface APIKeysVariables { +interface APIKeysVariables { projectRef?: string reveal: boolean } -export async function getAPIKeys({ projectRef, reveal }: APIKeysVariables, signal?: AbortSignal) { +async function getAPIKeys({ projectRef, reveal }: APIKeysVariables, signal?: AbortSignal) { if (!projectRef) throw new Error('projectRef is required') const { data, error } = await get(`/v1/projects/{ref}/api-keys`, { diff --git a/apps/studio/data/api-keys/keys.ts b/apps/studio/data/api-keys/keys.ts index 96d50623291..84d9c41d1d1 100644 --- a/apps/studio/data/api-keys/keys.ts +++ b/apps/studio/data/api-keys/keys.ts @@ -1,4 +1,5 @@ export const apiKeysKeys = { list: (projectRef?: string) => ['projects', projectRef, 'api-keys'] as const, single: (projectRef?: string, id?: string) => ['projects', projectRef, 'api-keys', id] as const, + status: (projectRef?: string) => ['projects', projectRef, 'api-keys', 'legacy'] as const, } diff --git a/apps/studio/data/api-keys/legacy-api-key-toggle-mutation.ts b/apps/studio/data/api-keys/legacy-api-key-toggle-mutation.ts new file mode 100644 index 00000000000..0f0a07c43e3 --- /dev/null +++ b/apps/studio/data/api-keys/legacy-api-key-toggle-mutation.ts @@ -0,0 +1,60 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { handleError, put } from 'data/fetchers' +import { toast } from 'sonner' +import type { ResponseError } from 'types' +import { apiKeysKeys } from './keys' + +export type ToggleLegacyAPIKeysVariables = { + projectRef?: string + enabled: boolean +} + +export async function toggleLegacyAPIKeys(payload: ToggleLegacyAPIKeysVariables) { + if (!payload.projectRef) throw new Error('projectRef is required') + + const { data, error } = await put('/v1/projects/{ref}/api-keys/legacy', { + params: { + path: { ref: payload.projectRef }, + query: { enabled: payload.enabled }, + }, + }) + + if (error) handleError(error) + return data +} + +type ToggleLegacyAPIKeysData = Awaited> + +export const useToggleLegacyAPIKeysMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => toggleLegacyAPIKeys(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await queryClient.invalidateQueries(apiKeysKeys.status(projectRef)) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error( + `Failed to ${variables.enabled ? 're-enable' : 'disable'} JWT-based API keys: ${data.message}` + ) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/api-keys/legacy-api-keys-status-query.ts b/apps/studio/data/api-keys/legacy-api-keys-status-query.ts new file mode 100644 index 00000000000..964f2f49d54 --- /dev/null +++ b/apps/studio/data/api-keys/legacy-api-keys-status-query.ts @@ -0,0 +1,41 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types' +import { apiKeysKeys } from './keys' + +interface LegacyAPIKeysStatusVariables { + projectRef?: string +} + +async function getLegacyAPIKeysStatus( + { projectRef }: LegacyAPIKeysStatusVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await get(`/v1/projects/{ref}/api-keys/legacy`, { + params: { path: { ref: projectRef } }, + signal, + }) + + if (error) { + handleError(error) + } + + return data +} + +type LegacyAPIKeysStatusData = Awaited> + +export const useLegacyAPIKeysStatusQuery = ( + { projectRef }: LegacyAPIKeysStatusVariables, + { ...options }: UseQueryOptions = {} +) => + useQuery( + apiKeysKeys.status(projectRef), + ({ signal }) => getLegacyAPIKeysStatus({ projectRef }, signal), + { + enabled: !!projectRef, + ...options, + } + ) diff --git a/apps/studio/pages/project/[ref]/settings/api-keys/index.tsx b/apps/studio/pages/project/[ref]/settings/api-keys/index.tsx index 723cc7fb139..89db964ba92 100644 --- a/apps/studio/pages/project/[ref]/settings/api-keys/index.tsx +++ b/apps/studio/pages/project/[ref]/settings/api-keys/index.tsx @@ -5,7 +5,7 @@ import { DisplayApiSettings } from 'components/ui/ProjectSettings' import type { NextPageWithLayout } from 'types' const ApiKeysLegacyPage: NextPageWithLayout = () => { - return + return } ApiKeysLegacyPage.getLayout = (page) => ( diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index e1ec2f4d4ba..8a43fd88679 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -395,6 +395,24 @@ export interface paths { patch: operations['updateApiKey'] trace?: never } + '/v1/projects/{ref}/api-keys/legacy': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** [Beta] 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'] + /** [Beta] 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'] + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/v1/projects/{ref}/billing/addons': { parameters: { query?: never @@ -2062,6 +2080,9 @@ export interface components { } updated_at?: string } + LegacyApiKeysResponse: { + enabled: boolean + } ListProjectAddonsResponse: { available_addons: { name: string @@ -2967,7 +2988,11 @@ export interface components { allowed_release_channels: ('internal' | 'alpha' | 'beta' | 'ga' | 'withdrawn' | 'preview')[] id: string name: string - opt_in_tags: 'AI_SQL_GENERATOR_OPT_IN'[] + opt_in_tags: ( + | 'AI_SQL_GENERATOR_OPT_IN' + | 'AI_DATA_GENERATOR_OPT_IN' + | 'AI_LOG_GENERATOR_OPT_IN' + )[] /** @enum {string} */ plan?: 'free' | 'pro' | 'team' | 'enterprise' } @@ -3955,8 +3980,11 @@ export interface operations { deleteApiKey: { parameters: { query?: { + reason?: string /** @description Boolean string, true or false */ reveal?: boolean + /** @description Boolean string, true or false */ + was_compromised?: boolean } header?: never path: { @@ -4020,6 +4048,65 @@ export interface operations { } } } + checkLegacyApiKeys: { + 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']['LegacyApiKeysResponse'] + } + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + updateLegacyApiKeys: { + parameters: { + query: { + /** @description Boolean string, true or false */ + enabled: boolean + } + header?: never + path: { + /** @description Project ref */ + ref: string + } + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['LegacyApiKeysResponse'] + } + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } 'v1-list-project-addons': { parameters: { query?: never diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index db25f31163c..5b677bd4128 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -4251,7 +4251,7 @@ export interface components { /** @default 0 */ recoveryTimeTarget?: number } - ConfirmSubscriptionChangeBody: { + ConfirmCreateSubscriptionChangeBody: { kind?: string name: string payment_intent_id: string @@ -4554,6 +4554,7 @@ export interface components { is_owner: boolean name: string opt_in_tags: string[] + organization_requires_mfa: boolean plan: { /** @enum {string} */ id: 'free' | 'pro' | 'team' | 'enterprise' @@ -6151,6 +6152,7 @@ export interface components { is_owner: boolean name: string opt_in_tags: string[] + organization_requires_mfa: boolean plan: { /** @enum {string} */ id: 'free' | 'pro' | 'team' | 'enterprise' @@ -7274,8 +7276,9 @@ export interface components { }[] } RunQueryBody: { - query: string + /** @default false */ disable_statement_timeout?: boolean + query: string } SearchProfileBody: { keywords: string @@ -8032,7 +8035,11 @@ export interface components { /** Format: email */ billing_email?: string name?: string - opt_in_tags?: 'AI_SQL_GENERATOR_OPT_IN'[] + opt_in_tags?: ( + | 'AI_SQL_GENERATOR_OPT_IN' + | 'AI_DATA_GENERATOR_OPT_IN' + | 'AI_LOG_GENERATOR_OPT_IN' + )[] } UpdateOrganizationResponse: { billing_email?: string @@ -12047,7 +12054,7 @@ export interface operations { } requestBody: { content: { - 'application/json': components['schemas']['ConfirmSubscriptionChangeBody'] + 'application/json': components['schemas']['ConfirmCreateSubscriptionChangeBody'] } } responses: {