diff --git a/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx b/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx index a41a838b7a9..fb2bde73338 100644 --- a/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx +++ b/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx @@ -1,28 +1,16 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useQueryClient } from '@tanstack/react-query' -import { InputVariants } from '@ui/components/shadcn/ui/input' -import { Eye, MoreVertical } from 'lucide-react' -import { useState } from 'react' -import { toast } from 'sonner' - -import { useParams } from 'common' -import CopyButton from 'components/ui/CopyButton' -import { useAPIKeyIdQuery } from 'data/api-keys/[id]/api-key-id-query' -import { APIKeysData } from 'data/api-keys/api-keys-query' -import { apiKeysKeys } from 'data/api-keys/keys' -import { AnimatePresence, motion } from 'framer-motion' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { MoreVertical } from 'lucide-react' +import { motion } from 'framer-motion' import { Button, DropdownMenu, DropdownMenuContent, - DropdownMenuSeparator, DropdownMenuTrigger, TableCell, TableRow, - cn, } from 'ui' import { APIKeyDeleteDialog } from './APIKeyDeleteDialog' +import { ApiKeyPill } from './ApiKeyPill' +import { APIKeysData } from 'data/api-keys/api-keys-query' export const APIKeyRow = ({ apiKey, @@ -44,12 +32,13 @@ export const APIKeyRow = ({ mass: 1, }} > - {apiKey.description || '/'} + {apiKey.name}
- +
+ {apiKey.description || '/'} @@ -70,124 +59,3 @@ export const APIKeyRow = ({ ) } - -function Input({ - apiKey, -}: { - apiKey: Extract -}) { - const queryClient = useQueryClient() - const { ref: projectRef } = useParams() - - const [show, setShowState] = useState(false) - - // to do - // const canReadAPIKeys = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, '*') - - const isSecret = apiKey.type === 'secret' - - const { - data, - isLoading: isLoadingApiKey, - error, - refetch: refetchApiKey, - } = useAPIKeyIdQuery( - { - projectRef, - id: apiKey.id as string, - reveal: true, - }, - { - enabled: show, - staleTime: 0, // Data is considered stale immediately - cacheTime: 0, // Cache is cleared immediately after query becomes stale - } - ) - - async function onSubmitShow() { - setShowState(true) - // Set a timeout to invalidate the cache after a certain amount of time - setTimeout(() => { - setShowState(false) - queryClient.removeQueries({ - queryKey: apiKeysKeys.single(projectRef, apiKey.id as string), - exact: true, - }) - }, 10000) // Destroy query after 10 seconds - } - - async function onCopy() { - // if ID already exists from a reveal action, return that - if (data?.api_key) return data?.api_key - - try { - // fetch ID and then destroy query immediately - const result = await refetchApiKey() - queryClient.removeQueries({ - queryKey: apiKeysKeys.single(projectRef, apiKey.id as string), - exact: true, - }) - - if (result.isSuccess) return result.data.api_key - - if (error) { - toast.error('Failed to copy secret API key') - } - } catch (error) { - console.error('Failed to fetch API key:', error) - } - - // Fallback to the masked version - return apiKey.api_key - } - - return ( - <> -
- - - {show ? data?.api_key : apiKey?.api_key} - - -
- {isSecret && ( - - {!show && ( - - + + + + ) +} + +/** + * Create API Keys callout for users who have the feature flag enabled but no keys yet + */ +export const ApiKeysCreateCallout = () => { + const { canInitApiKeys } = useApiKeysVisibility() + + if (!canInitApiKeys) return null + + return ( + } bgAlt> +
+

Create API keys

+

+ Use keys to authenticate requests to your app +

+
+ +
+
+
+ ) +} + +/** + * Feedback banner for users who have API keys and the feature is rolled out to them + */ +export const ApiKeysFeedbackBanner = () => { + const { hasApiKeys, isInRollout } = useApiKeysVisibility() + + // Don't show anything if not in rollout or if keys don't exist + if (!isInRollout || !hasApiKeys) { + return null + } + + return ( + +
+

Your new API keys are here

+

+ We've updated our API keys to better support your application needs.{' '} + + Join the discussion on GitHub + +

+
+ + + +
+

+ Having trouble with the new API keys?{' '} + + Contact support + +

+
+
+ ) +} diff --git a/apps/studio/components/interfaces/APIKeys/CreateNewAPIKeysButton.tsx b/apps/studio/components/interfaces/APIKeys/CreateNewAPIKeysButton.tsx new file mode 100644 index 00000000000..1a932864101 --- /dev/null +++ b/apps/studio/components/interfaces/APIKeys/CreateNewAPIKeysButton.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react' + +import { useParams } from 'common' +import { useAPIKeyCreateMutation } from 'data/api-keys/api-key-create-mutation' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, +} from 'ui' + +export const CreateNewAPIKeysButton = () => { + const { ref: projectRef } = useParams() + const [createKeysDialogOpen, setCreateKeysDialogOpen] = useState(false) + const [isCreatingKeys, setIsCreatingKeys] = useState(false) + + const { mutate: createAPIKey } = useAPIKeyCreateMutation() + + const handleCreateNewApiKeys = async () => { + if (!projectRef) return + setIsCreatingKeys(true) + + try { + // Create publishable key + await createAPIKey({ + projectRef, + type: 'publishable', + name: 'default', + }) + + // Create secret key + await createAPIKey({ + projectRef, + type: 'secret', + name: 'default', + }) + + setCreateKeysDialogOpen(false) + } catch (error) { + console.error('Failed to create API keys:', error) + } finally { + setIsCreatingKeys(false) + } + } + + return ( + + + + + Create new API keys + + This will create a default publishable key and a default secret key named{' '} + default. These keys are required to connect your application to your + Supabase project. + + + + Cancel + + {isCreatingKeys ? 'Creating...' : 'Create keys'} + + + + + ) +} diff --git a/apps/studio/components/interfaces/APIKeys/CreatePublishableAPIKeyModal.tsx b/apps/studio/components/interfaces/APIKeys/CreatePublishableAPIKeyDialog.tsx similarity index 78% rename from apps/studio/components/interfaces/APIKeys/CreatePublishableAPIKeyModal.tsx rename to apps/studio/components/interfaces/APIKeys/CreatePublishableAPIKeyDialog.tsx index ff749d9ff24..8a837bf9d47 100644 --- a/apps/studio/components/interfaces/APIKeys/CreatePublishableAPIKeyModal.tsx +++ b/apps/studio/components/interfaces/APIKeys/CreatePublishableAPIKeyDialog.tsx @@ -1,6 +1,5 @@ -import { useState } from 'react' -import * as z from 'zod' import { zodResolver } from '@hookform/resolvers/zod' +import { useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { Button, @@ -17,25 +16,24 @@ import { FormField_Shadcn_, Form_Shadcn_, Input_Shadcn_, - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, - Alert_Shadcn_, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import * as z from 'zod' import { useAPIKeyCreateMutation } from 'data/api-keys/api-key-create-mutation' import { useParams } from 'next/navigation' const FORM_ID = 'create-publishable-api-key' const SCHEMA = z.object({ - description: z.string(), + name: z.string(), + description: z.string().trim(), }) -export interface CreatePublishableAPIKeyModalProps { +export interface CreatePublishableAPIKeyDialogProps { projectRef: string } -function CreatePublishableAPIKeyModal() { +function CreatePublishableAPIKeyDialog() { const params = useParams() const projectRef = params?.ref as string @@ -48,6 +46,7 @@ function CreatePublishableAPIKeyModal() { const form = useForm>({ resolver: zodResolver(SCHEMA), defaultValues: { + name: '', description: '', }, }) @@ -59,7 +58,8 @@ function CreatePublishableAPIKeyModal() { { projectRef, type: 'publishable', - description: values.description.trim(), + name: values.name, + description: values.description, }, { onSuccess: () => { @@ -93,6 +93,21 @@ function CreatePublishableAPIKeyModal() { id={FORM_ID} onSubmit={form.handleSubmit(onSubmit)} > + ( + + + + + + )} + /> { +const CreateSecretAPIKeyDialog = () => { const [visible, setVisible] = useState(false) const { ref: projectRef } = useParams() @@ -40,6 +41,7 @@ const CreateSecretAPIKeyModal = () => { const form = useForm>({ resolver: zodResolver(SCHEMA), defaultValues: { + name: '', description: '', }, }) @@ -51,7 +53,8 @@ const CreateSecretAPIKeyModal = () => { { projectRef, type: 'secret', - description: values.description.trim(), + name: values.name, + description: values.description, }, { onSuccess: () => { @@ -71,10 +74,15 @@ const CreateSecretAPIKeyModal = () => { Create new secret API key - - Secret API keys are used to authorize requests to your project from servers, functions, - workers or other backend components of your application. Keep them secret, don't publish - them online and don't check them in source code. + +

+ Secret API keys are used to authorize requests to your project from servers, + functions, workers or other backend components of your application.{' '} +

+ +

+ Keep these keys private. Don't publish them online or commit them to source control. +

@@ -85,6 +93,21 @@ const CreateSecretAPIKeyModal = () => { id={FORM_ID} onSubmit={form.handleSubmit(onSubmit)} > + ( + + + + + + )} + /> { ) } -export default CreateSecretAPIKeyModal +export default CreateSecretAPIKeyDialog diff --git a/apps/studio/components/interfaces/APIKeys/LegacyAPIKeys.tsx b/apps/studio/components/interfaces/APIKeys/LegacyAPIKeys.tsx index 387b501b2cc..8cac94b5af4 100644 --- a/apps/studio/components/interfaces/APIKeys/LegacyAPIKeys.tsx +++ b/apps/studio/components/interfaces/APIKeys/LegacyAPIKeys.tsx @@ -1,17 +1,10 @@ -import { FormHeader } from 'components/ui/Forms/FormHeader' import DisplayApiSettings from 'components/ui/ProjectSettings/DisplayApiSettings' -const LegacyAPIKeys = () => { +export function LegacyAPIKeys() { return (
-
) return } - -export default LegacyAPIKeys diff --git a/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx b/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx index 4a660756c81..e187ac8b8cd 100644 --- a/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx +++ b/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx @@ -8,9 +8,8 @@ import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPe import { Link } from 'lucide-react' import { useMemo } from 'react' import { cn, EyeOffIcon, Input_Shadcn_, Skeleton, WarningIcon } from 'ui' -import QuickKeyCopyWrapper from './QuickKeyCopy' // to add in later with follow up PR -// import CreatePublishableAPIKeyModal from './CreatePublishableAPIKeyModal' +// import CreatePublishableAPIKeyDialog from './CreatePublishableAPIKeyDialog' // to add in later with follow up PR // import ShowPublicJWTsDialogComposer from '../JwtSecrets/ShowPublicJWTsDialogComposer' @@ -37,12 +36,12 @@ export const PublishableAPIKeys = () => {
- Default publishable key + Publishable key
{ )}
-
- Show Supabase Url -
{/* */} {/* @mildtomato - To add in later with follow up PR */} {/* */}
{/* */} - -
) @@ -98,7 +92,7 @@ function ApiKeyInput() { // The default publisahble key will always be the first one const apiKey = publishableApiKeys[0] - const baseClasses = 'flex-1 grow gap-1 rounded-full min-w-[300px] truncate' + const baseClasses = 'flex-1 grow gap-1 rounded-full min-w-[32em]' const size = 'tiny' if (isApiKeysLoading || isPermissionsLoading) { diff --git a/apps/studio/components/interfaces/APIKeys/QuickKeyCopy.tsx b/apps/studio/components/interfaces/APIKeys/QuickKeyCopy.tsx deleted file mode 100644 index 1de291da97b..00000000000 --- a/apps/studio/components/interfaces/APIKeys/QuickKeyCopy.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import React, { useState } from 'react' -import { Button, EyeIcon, EyeOffIcon, Skeleton, WarningIcon } from 'ui' -import { ChevronsUpDownIcon } from 'lucide-react' -import { useParams } from 'common' -import { useAPIKeysQuery } from 'data/api-keys/api-keys-query' -import { SimpleCodeBlock } from 'ui/src/components/SimpleCodeBlock' -import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' - -const frameworkOptions = ['React', 'Vue', 'Angular', 'Svelte'] // Add more as needed - -const QuickKeyCopyWrapper = () => { - const [selectedFramework, setSelectedFramework] = useState('React') - - return ( -
-
-
-

Quick key copy

-

- Choose your framework and paste the code into your environment file. -

-
- -
- -
- ) -} - -const QuickKeyCopyContent = ({ selectedFramework }: { selectedFramework: string }) => { - const { ref: projectRef } = useParams() - const { - data: projectAPI, - isLoading: isProjectApiLoading, - error: projectApiError, - } = useProjectSettingsV2Query({ - projectRef: projectRef as string, - }) - const { - data: apiKeysData, - isLoading: isApiKeysLoading, - error: apiKeysError, - } = useAPIKeysQuery({ - projectRef: projectRef as string, - reveal: false, - }) - const isPermissionsLoading = !usePermissionsLoaded() - const canReadAPIKeys = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, '*') - - const publishableApiKey = apiKeysData?.find(({ type }) => type === 'publishable')?.api_key - const dataErrors = projectApiError || apiKeysError - const permissionErrors = !canReadAPIKeys && !isPermissionsLoading - - if (isProjectApiLoading || isApiKeysLoading || isPermissionsLoading) { - return ( -
-
- - -
- -
- ) - } - - const EmptyContainer = ({ children }: { children: React.ReactNode }) => { - return ( -
- {children} -
- ) - } - - // if (!canReadAPIKeys) { - // return ( - // - //
- // - //

You do not have permission to read API Keys

- //
- //

- // Please contact your project admin/owner to request access. - //

- //
- // ) - // } - - // TO DO : this needs to be changed to just if(error) - // currently it's not working as API returns an error. - if (dataErrors) { - return ( - -
- -

Error loading Secret API Keys

-
-

- {projectApiError?.message ?? apiKeysError?.message ?? 'Error: Failed to load API keys'} -

-
- ) - } - - const getEnvContent = () => { - return ` -NEXT_PUBLIC_SUPABASE_URL=${projectAPI?.app_config?.endpoint || ''} -NEXT_PUBLIC_SUPABASE_PUBLISHABLE_API_KEY=${!canReadAPIKeys ? 'You do not have permission to read the Publishable API key' : publishableApiKey || ''} -` - } - - return ( -
-
-
- env -
- .env.local -
- {getEnvContent()} -
- ) -} - -export default QuickKeyCopyWrapper diff --git a/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx b/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx index 4a52c82178a..39f988cf189 100644 --- a/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx +++ b/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx @@ -15,7 +15,7 @@ import { TableRow, } from 'ui/src/components/shadcn/ui/table' import { APIKeyRow } from './APIKeyRow' -import CreateSecretAPIKeyModal from './CreateSecretAPIKeyModal' +import CreateSecretAPIKeyDialog from './CreateSecretAPIKeyDialog' export const SecretAPIKeys = () => { const { ref: projectRef } = useParams() @@ -56,23 +56,29 @@ export const SecretAPIKeys = () => {
} + description="These API keys allow privileged access to your project's APIs. Use in servers, functions, workers or other backend components of your application." + actions={} /> - +
Name API Key + + Description + { return (
-

No secret API keys exist

+

No secret API keys found

- Your project can't be accessed using secret API keys. + Your project is not accessible via secret keys—there are no active secret keys created.

diff --git a/apps/studio/components/interfaces/APIKeys/hooks/useApiKeysVisibility.ts b/apps/studio/components/interfaces/APIKeys/hooks/useApiKeysVisibility.ts new file mode 100644 index 00000000000..0033abcd1fd --- /dev/null +++ b/apps/studio/components/interfaces/APIKeys/hooks/useApiKeysVisibility.ts @@ -0,0 +1,54 @@ +import { useMemo } from 'react' + +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useParams } from 'common' +import { useAPIKeysQuery } from 'data/api-keys/api-keys-query' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useFlag } from 'hooks/ui/useFlag' + +interface ApiKeysVisibilityState { + isInRollout: boolean + hasApiKeys: boolean + isLoading: boolean + canReadAPIKeys: boolean + canInitApiKeys: boolean + shouldDisableUI: boolean +} + +/** + * A hook that provides visibility states for API keys UI components + * Consolidates logic for determining access to API keys functionality + */ +export function useApiKeysVisibility(): ApiKeysVisibilityState { + const { ref: projectRef } = useParams() + const canReadAPIKeys = useCheckPermissions(PermissionAction.READ, 'api_keys') + const isInRollout = useFlag('basicApiKeys') + + const { data: apiKeysData, isLoading } = useAPIKeysQuery({ + projectRef, + reveal: false, + }) + + const publishableApiKeys = useMemo( + () => apiKeysData?.filter(({ type }) => type === 'publishable') ?? [], + [apiKeysData] + ) + + // Check if there are any publishable API keys + // we don't check for secret keys because they can be optionally deleted + const hasApiKeys = publishableApiKeys.length > 0 + + // Can initialize API keys when in rollout, has permissions, not loading, and no API keys yet + const canInitApiKeys = hasApiKeys ? false : isInRollout && canReadAPIKeys && !isLoading + + const shouldDisableUI = hasApiKeys ? false : !isInRollout + + return { + isInRollout, + hasApiKeys, + isLoading, + canReadAPIKeys, + canInitApiKeys, + shouldDisableUI, + } +} diff --git a/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx b/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx new file mode 100644 index 00000000000..869b3a48efe --- /dev/null +++ b/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx @@ -0,0 +1,64 @@ +import { useParams } from 'common' +import Link from 'next/link' +import { Button, Badge, Menu } from 'ui' +import { ArrowUpRight } from 'lucide-react' +import { ProductMenu } from 'components/ui/ProductMenu' +import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' +import { Sidebar } from 'components/interfaces/Sidebar' + +export const ApiKeysMoved = () => { + const { ref: projectRef } = useParams() + + // Create menu structure matching the ProductMenu expected format + const mockMenu: ProductMenuGroup[] = [ + { + title: 'Project Settings', + items: [ + { name: 'General', key: 'general', url: '#' }, + { name: 'Compute and Disk', key: 'compute-and-disk', url: '#' }, + { name: 'Infrastructure', key: 'infrastructure', url: '#' }, + { name: 'Integrations', key: 'integrations', url: '#' }, + { name: 'API Keys', key: 'api-keys', url: '#', label: 'NEW' }, + { name: 'Add Ons', key: 'addons', url: '#' }, + { + name: 'Vault', + key: 'vault', + url: '#', + label: 'Alpha', + rightIcon: , + }, + ], + }, + { + title: 'Configuration', + items: [{ name: 'Database', key: 'database', url: '#' }], + }, + ] + + return ( +
+
+

API keys have moved

+

+ They can now be found in the new API Keys page +

+ + + +
+ + {/* 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 71ba7a2e70a..f6d0086fca5 100644 --- a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx +++ b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx @@ -21,6 +21,7 @@ import { Badge, Input } from 'ui' import { JWT_SECRET_UPDATE_ERROR_MESSAGES } from './API.constants' import JWTSettings from './JWTSettings' import { PostgrestConfig } from './PostgrestConfig' +import { ApiKeysMoved } from './ApiKeysMoved' const ServiceList = () => { const client = useQueryClient() @@ -143,8 +144,8 @@ const ServiceList = () => { -
- +
+
diff --git a/apps/studio/components/layouts/APIKeys/APIKeysLayout.tsx b/apps/studio/components/layouts/APIKeys/APIKeysLayout.tsx new file mode 100644 index 00000000000..3b8e6d79ca7 --- /dev/null +++ b/apps/studio/components/layouts/APIKeys/APIKeysLayout.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 ApiKeysLayout = ({ children }: PropsWithChildren) => { + const { ref: projectRef } = useParams() + + const navigationItems = [ + { + label: 'API Keys', + href: `/project/${projectRef}/settings/api-keys`, + id: 'legacy-keys', + }, + { + label: 'New API Keys (Coming Soon)', + href: `/project/${projectRef}/settings/api-keys/new`, + id: 'new-keys', + }, + ] + + return ( + + + {children} + + + ) +} + +export default ApiKeysLayout diff --git a/apps/studio/components/layouts/PageLayout/PageLayout.tsx b/apps/studio/components/layouts/PageLayout/PageLayout.tsx index 3b2683eb784..a8bc30fa780 100644 --- a/apps/studio/components/layouts/PageLayout/PageLayout.tsx +++ b/apps/studio/components/layouts/PageLayout/PageLayout.tsx @@ -3,7 +3,7 @@ import { useRouter } from 'next/router' import { ReactNode } from 'react' import { useParams } from 'common' -import { Button, cn, NavMenu, NavMenuItem } from 'ui' +import { Badge, Button, cn, NavMenu, NavMenuItem } from 'ui' import { ScaffoldContainer } from '../Scaffold' import { PageHeader } from './PageHeader' @@ -13,6 +13,7 @@ export interface NavigationItem { href?: string icon?: ReactNode onClick?: () => void + badge?: string } interface PageLayoutProps { @@ -118,6 +119,7 @@ export const PageLayout = ({ > {item.icon && {item.icon}} {item.label} + {item.badge && {item.badge}} ) : ( )} diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx index d18ca4281be..07d11fac4cf 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx @@ -58,17 +58,13 @@ export const generateSettingsMenu = ( url: `/project/${ref}/settings/integrations`, items: [], }, - ...(features?.newApiKeys - ? [ - { - name: 'API Keys', - key: 'api-keys', - url: `/project/${ref}/settings/api-keys`, - items: [], - label: 'NEW', - }, - ] - : []), + { + name: 'API Keys', + key: 'api-keys', + url: `/project/${ref}/settings/api-keys`, + items: [], + label: 'NEW', + }, ] : []), { diff --git a/apps/studio/components/layouts/project/[ref]/settings/APIKeysLayout.tsx b/apps/studio/components/layouts/project/[ref]/settings/APIKeysLayout.tsx deleted file mode 100644 index f813d15b207..00000000000 --- a/apps/studio/components/layouts/project/[ref]/settings/APIKeysLayout.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { - ScaffoldContainer, - ScaffoldDescription, - ScaffoldHeader, - ScaffoldTitle, -} from 'components/layouts/Scaffold' -import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' -import { Sparkles } from 'lucide-react' -import { PropsWithChildren } from 'react' -import { - buttonVariants, - cn, - Separator, - Tabs_Shadcn_, - TabsList_Shadcn_, - TabsTrigger_Shadcn_, -} from 'ui' - -import { LOCAL_STORAGE_KEYS, useParams } from 'common' - -const ApiKeysLayout = ({ children }: PropsWithChildren) => { - const { ref: projectRef } = useParams() - const [apiKeysView, setApiKeysViewState] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.API_KEYS_VIEW(projectRef ?? ''), - 'new-keys' - ) - - return ( - <> - - - API Keys - - Configure API keys that help secure your project - - - - - -
- - - - New Keys - - - Legacy Keys - - -
-
-
- - - - {children} - - - ) -} - -export default ApiKeysLayout diff --git a/apps/studio/components/ui/CopyButton.tsx b/apps/studio/components/ui/CopyButton.tsx index 31b52903962..38e2848a9f3 100644 --- a/apps/studio/components/ui/CopyButton.tsx +++ b/apps/studio/components/ui/CopyButton.tsx @@ -1,8 +1,8 @@ import { Check, Clipboard } from 'lucide-react' -import { useEffect, useState } from 'react' +import React, { forwardRef, useEffect, useState } from 'react' import { copyToClipboard } from 'lib/helpers' -import { Button, ButtonProps, cn } from 'ui' +import { Button, cn } from 'ui' type CopyButtonBaseProps = { iconOnly?: boolean @@ -21,48 +21,62 @@ type CopyButtonWithAsyncText = CopyButtonBaseProps & { asyncText: () => Promise | string } -export type CopyButtonProps = (CopyButtonWithText | CopyButtonWithAsyncText) & ButtonProps +export type CopyButtonProps = (CopyButtonWithText | CopyButtonWithAsyncText) & + React.ComponentProps -const CopyButton = ({ - text, - asyncText, - iconOnly = false, - children, - onClick, - copyLabel = 'Copy', - copiedLabel = 'Copied', - ...props -}: CopyButtonProps) => { - const [showCopied, setShowCopied] = useState(false) +const CopyButton = forwardRef( + ( + { + text, + asyncText, + iconOnly = false, + children, + onClick, + copyLabel = 'Copy', + copiedLabel = 'Copied', + ...props + }, + ref + ) => { + const [showCopied, setShowCopied] = useState(false) - useEffect(() => { - if (!showCopied) return - const timer = setTimeout(() => setShowCopied(false), 2000) - return () => clearTimeout(timer) - }, [showCopied]) + useEffect(() => { + if (!showCopied) return + const timer = setTimeout(() => setShowCopied(false), 2000) + return () => clearTimeout(timer) + }, [showCopied]) - return ( - - ) -} + return ( + + ) + } +) + +CopyButton.displayName = 'CopyButton' export default CopyButton diff --git a/apps/studio/components/ui/DisableInteraction.tsx b/apps/studio/components/ui/DisableInteraction.tsx new file mode 100644 index 00000000000..75da1a91887 --- /dev/null +++ b/apps/studio/components/ui/DisableInteraction.tsx @@ -0,0 +1,38 @@ +import React, { forwardRef } from 'react' +import { cn } from 'ui' + +interface DisableInteractionProps extends React.HTMLAttributes { + disabled?: boolean +} + +/** + * DisableInteraction component + * + * A utility component that wraps content and prevents all user interactions when disabled + * including clicking, hovering, and text selection. + * + * @example + * + * + * + */ +export const DisableInteraction = forwardRef( + ({ disabled, style, className, ...props }, ref) => ( +
+ ) +) + +DisableInteraction.displayName = 'DisableInteraction' diff --git a/apps/studio/components/ui/FeatureBanner.tsx b/apps/studio/components/ui/FeatureBanner.tsx new file mode 100644 index 00000000000..a55e8d8daf2 --- /dev/null +++ b/apps/studio/components/ui/FeatureBanner.tsx @@ -0,0 +1,88 @@ +import { X } from 'lucide-react' +import { ReactNode } from 'react' +import { motion, HTMLMotionProps } from 'framer-motion' + +import { useParams } from 'common/hooks' +import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' +import { Button, cn } from 'ui' + +// Base props common to all feature banners +interface BaseFeatureBannerProps extends HTMLMotionProps<'div'> { + children: ReactNode + className?: string + dismissClassName?: string + defaultDismissed?: boolean + illustration?: ReactNode + bgAlt?: boolean +} + +// Type for non-dismissable banners (no storageKey needed) +interface NonDismissableFeatureBannerProps extends BaseFeatureBannerProps { + dismissable?: false + storageKey?: never +} + +// Type for dismissable banners (requires storageKey) +interface DismissableFeatureBannerProps extends BaseFeatureBannerProps { + dismissable: true + storageKey: string | ((ref: string) => string) +} + +// Union type that enforces the constraint +export type FeatureBannerProps = NonDismissableFeatureBannerProps | DismissableFeatureBannerProps + +export const FeatureBanner = ({ + storageKey, + children, + className, + dismissClassName, + defaultDismissed = false, + illustration, + dismissable = false, + bgAlt = false, + ...props +}: FeatureBannerProps) => { + const { ref } = useParams() + const key = storageKey && typeof storageKey === 'function' ? storageKey(ref ?? '') : storageKey + + const [isDismissed, setIsDismissed] = useLocalStorageQuery( + key || 'feature-banner-dismissed', + defaultDismissed + ) + + if (dismissable && storageKey && isDismissed) return null + + return ( + + {children} + {illustration} + {dismissable && storageKey && ( +
+
+ )} +
+ ) +} diff --git a/apps/studio/components/ui/Forms/FormHeader.tsx b/apps/studio/components/ui/Forms/FormHeader.tsx index c4bb47c22ee..ccf5288b272 100644 --- a/apps/studio/components/ui/Forms/FormHeader.tsx +++ b/apps/studio/components/ui/Forms/FormHeader.tsx @@ -23,7 +23,7 @@ const FormHeader = ({ >

{title}

- {description &&
{description}
} + {description &&
{description}
}
{docsUrl !== undefined && } diff --git a/apps/studio/data/api-keys/api-key-create-mutation.ts b/apps/studio/data/api-keys/api-key-create-mutation.ts index 431c7677601..b550dfa90d5 100644 --- a/apps/studio/data/api-keys/api-key-create-mutation.ts +++ b/apps/studio/data/api-keys/api-key-create-mutation.ts @@ -7,7 +7,8 @@ import { apiKeysKeys } from './keys' export type APIKeyCreateVariables = { projectRef?: string - description: string + name: string + description?: string } & ( | { type: 'publishable' @@ -30,20 +31,20 @@ export async function createAPIKey(payload: APIKeyCreateVariables) { reveal: false, }, }, - body: - payload.type === 'secret' + body: { + ...(payload.type === 'secret' ? { - type: payload.type, - description: payload.description || null, // secret_jwt_template: payload?.secret_jwt_template || null, secret_jwt_template: { role: 'service_role', // @mildtomato (Jonny) this should be default in API for type secret }, } - : { - type: payload.type, - description: payload.description || null, - }, + : name), + + type: payload.type, + name: payload.name, + description: payload.description || null, + }, }) if (error) handleError(error) diff --git a/apps/studio/pages/project/[ref]/settings/api-keys.tsx b/apps/studio/pages/project/[ref]/settings/api-keys.tsx index 8eb1be93ded..a09bad7b80c 100644 --- a/apps/studio/pages/project/[ref]/settings/api-keys.tsx +++ b/apps/studio/pages/project/[ref]/settings/api-keys.tsx @@ -1,49 +1,25 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' import { LOCAL_STORAGE_KEYS, useParams } from 'common' - -import LegacyAPIKeys from 'components/interfaces/APIKeys/LegacyAPIKeys' -import { PublishableAPIKeys } from 'components/interfaces/APIKeys/PublishableAPIKeys' -import { SecretAPIKeys } from 'components/interfaces/APIKeys/SecretAPIKeys' -import DefaultLayout from 'components/layouts/DefaultLayout' -import ApiKeysLayout from 'components/layouts/project/[ref]/settings/APIKeysLayout' -import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useRouter } from 'next/router' +import { useEffect } from 'react' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' -import type { NextPageWithLayout } from 'types' -import { Separator } from 'ui' -const ApiKeysSettings: NextPageWithLayout = () => { - const { ref: projectRef } = useParams() - const [apiKeysView] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.API_KEYS_VIEW(projectRef ?? ''), - undefined - ) +export default function ApiKeysLegacyPage() { + const router = useRouter() + const { ref } = useParams() - // const isPermissionsLoaded = usePermissionsLoaded() - // TODO: check if these permissions cover third party auth as well - const canReadAPIKeys = useCheckPermissions(PermissionAction.READ, 'api_keys') + // For backward compatibility, check if there's a view query parameter + const view = router.query.view as string - return ( - <> - {apiKeysView !== 'legacy-keys' ? ( - <> - - - - - ) : ( - - )} - - ) + // Get the view from local storage if no query parameter + const [storedView] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.API_KEYS_VIEW(ref ?? ''), 'new-keys') + + // Determine which page to redirect to + const targetView = view || storedView || 'new-keys' + + useEffect(() => { + // Redirect to the appropriate page + router.replace(`/project/${ref}/settings/api-keys/${targetView}`) + }, []) + + return null } - -ApiKeysSettings.getLayout = (page) => ( - - - {page} - - -) - -export default ApiKeysSettings diff --git a/apps/studio/pages/project/[ref]/settings/api-keys/index.tsx b/apps/studio/pages/project/[ref]/settings/api-keys/index.tsx new file mode 100644 index 00000000000..45dc2517158 --- /dev/null +++ b/apps/studio/pages/project/[ref]/settings/api-keys/index.tsx @@ -0,0 +1,19 @@ +import { LegacyAPIKeys } from 'components/interfaces/APIKeys/LegacyAPIKeys' +import ApiKeysLayout from 'components/layouts/APIKeys/APIKeysLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' +import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' +import type { NextPageWithLayout } from 'types' + +const ApiKeysLegacyPage: NextPageWithLayout = () => { + return +} + +ApiKeysLegacyPage.getLayout = (page) => ( + + + {page} + + +) + +export default ApiKeysLegacyPage diff --git a/apps/studio/pages/project/[ref]/settings/api-keys/new.tsx b/apps/studio/pages/project/[ref]/settings/api-keys/new.tsx new file mode 100644 index 00000000000..9ceadc7cba2 --- /dev/null +++ b/apps/studio/pages/project/[ref]/settings/api-keys/new.tsx @@ -0,0 +1,41 @@ +import { + ApiKeysComingSoonBanner, + ApiKeysCreateCallout, + ApiKeysFeedbackBanner, +} from 'components/interfaces/APIKeys/ApiKeysIllustrations' +import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility' +import { PublishableAPIKeys } from 'components/interfaces/APIKeys/PublishableAPIKeys' +import { SecretAPIKeys } from 'components/interfaces/APIKeys/SecretAPIKeys' +import ApiKeysLayout from 'components/layouts/APIKeys/APIKeysLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' +import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' +import { DisableInteraction } from 'components/ui/DisableInteraction' +import type { NextPageWithLayout } from 'types' +import { Separator } from 'ui' + +const ApiKeysNewPage: NextPageWithLayout = () => { + const { isInRollout, shouldDisableUI, canInitApiKeys } = useApiKeysVisibility() + + return ( + <> + {!isInRollout && } + {canInitApiKeys && } + + + + + + + + ) +} + +ApiKeysNewPage.getLayout = (page) => ( + + + {page} + + +) + +export default ApiKeysNewPage diff --git a/apps/studio/pages/project/[ref]/settings/api.tsx b/apps/studio/pages/project/[ref]/settings/api.tsx index 2b0ad1b5c19..1892bf682e5 100644 --- a/apps/studio/pages/project/[ref]/settings/api.tsx +++ b/apps/studio/pages/project/[ref]/settings/api.tsx @@ -1,3 +1,4 @@ +import { ApiKeysMoved } from 'components/interfaces/Settings/API/ApiKeysMoved' import ServiceList from 'components/interfaces/Settings/API/ServiceList' import DefaultLayout from 'components/layouts/DefaultLayout' import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 4363f5c0e10..ddce403d98d 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -52,6 +52,7 @@ export const LOCAL_STORAGE_KEYS = { GITHUB_AUTHORIZATION_STATE: 'supabase-github-authorization-state', // Notice banner keys FLY_POSTGRES_DEPRECATION_WARNING: 'fly-postgres-deprecation-warning-dismissed', + API_KEYS_FEEDBACK_DISMISSED: (ref: string) => `supabase-api-keys-feedback-dismissed-${ref}`, MIDDLEWARE_OUTAGE_BANNER: 'middleware-outage-banner-2025-05-16', AUTH_USERS_COLUMNS_CONFIGURATION: (ref: string) => `supabase-auth-users-columns-${ref}`,