diff --git a/apps/studio/components/grid/components/formatter/ReferenceRecordPeek.tsx b/apps/studio/components/grid/components/formatter/ReferenceRecordPeek.tsx index 20b11ea6139f0..6594b4c0a3201 100644 --- a/apps/studio/components/grid/components/formatter/ReferenceRecordPeek.tsx +++ b/apps/studio/components/grid/components/formatter/ReferenceRecordPeek.tsx @@ -9,11 +9,11 @@ import { getColumnDefaultWidth, } from 'components/grid/utils/gridColumns' import { convertByteaToHex } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id' import { useTableRowsQuery } from 'data/table-rows/table-rows-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' interface ReferenceRecordPeekProps { table: PostgresTable diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/index.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/index.tsx index 178a94789a61c..53b90bae078e8 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/index.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/index.tsx @@ -9,7 +9,7 @@ import { } from 'components/layouts/Scaffold' import AlertError from 'components/ui/AlertError' import { ResourceList } from 'components/ui/Resource/ResourceList' -import { HorizontalShimmerWithIcon } from 'components/ui/Shimmers/Shimmers' +import { HorizontalShimmerWithIcon } from 'components/ui/Shimmers' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { Alert_Shadcn_, diff --git a/apps/studio/components/interfaces/Auth/OAuthApps/CreateOAuthAppSheet.tsx b/apps/studio/components/interfaces/Auth/OAuthApps/CreateOAuthAppSheet.tsx new file mode 100644 index 0000000000000..934930c4134ca --- /dev/null +++ b/apps/studio/components/interfaces/Auth/OAuthApps/CreateOAuthAppSheet.tsx @@ -0,0 +1,306 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import type { CreateOAuthClientParams, OAuthClient } from '@supabase/supabase-js' +import { Plus, Trash2, X } from 'lucide-react' +import Link from 'next/link' +import { useEffect } from 'react' +import { useFieldArray, useForm } from 'react-hook-form' +import { toast } from 'sonner' +import * as z from 'zod' + +import { useParams } from 'common' +import { useOAuthServerAppCreateMutation } from 'data/oauth-server-apps/oauth-server-app-create-mutation' +import { useSupabaseClientQuery } from 'hooks/use-supabase-client-query' +import { + Button, + FormControl_Shadcn_, + FormDescription_Shadcn_, + FormField_Shadcn_, + FormItem_Shadcn_, + FormLabel_Shadcn_, + FormMessage_Shadcn_, + Form_Shadcn_, + Input_Shadcn_, + Separator, + Sheet, + SheetClose, + SheetContent, + SheetFooter, + SheetHeader, + SheetSection, + SheetTitle, + Switch, + cn, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +interface CreateOAuthAppSheetProps { + visible: boolean + onSuccess: (app: OAuthClient) => void + onCancel: () => void +} + +const FormSchema = z.object({ + name: z + .string() + .min(1, 'Please provide a name for your OAuth app') + .max(100, 'Name must be less than 100 characters'), + type: z.enum(['manual', 'dynamic']).default('manual'), + // scope: z.string().min(1, 'Please select a scope'), + redirect_uris: z + .object({ + value: z.string().trim().url('Please provide a valid URL'), + }) + .array() + .min(1, 'At least one redirect URI is required'), + is_public: z.boolean().default(false), +}) + +const FORM_ID = 'create-or-update-oauth-app-form' + +const initialValues = { + name: '', + type: 'manual' as const, + // scope: 'email', + redirect_uris: [{ value: '' }], + is_public: false, +} + +export const CreateOAuthAppSheet = ({ visible, onSuccess, onCancel }: CreateOAuthAppSheetProps) => { + const { ref: projectRef } = useParams() + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: initialValues, + }) + + const { + fields: redirectUriFields, + append: appendRedirectUri, + remove: removeRedirectUri, + } = useFieldArray({ + name: 'redirect_uris', + control: form.control, + }) + + const { data: supabaseClientData } = useSupabaseClientQuery({ projectRef }) + + const { mutateAsync: createOAuthApp, isLoading: isCreating } = useOAuthServerAppCreateMutation({ + onSuccess: (data) => { + toast.success(`Successfully created OAuth app "${data.client_name}"`) + onSuccess(data) + }, + onError: (error) => { + toast.error(error.message) + }, + }) + + useEffect(() => { + if (visible) { + form.reset(initialValues) + } + }, [visible]) + + const onSubmit = async (data: z.infer) => { + // Filter out empty redirect URIs + const validRedirectUris = data.redirect_uris + .map((uri) => uri.value.trim()) + .filter((uri) => uri !== '') + + const payload: CreateOAuthClientParams = { + client_name: data.name, + client_uri: '', + // scope: data.scope, + redirect_uris: validRedirectUris, + } + + createOAuthApp({ + projectRef, + supabaseClient: supabaseClientData?.supabaseClient, + ...payload, + }) + } + + const onClose = () => { + form.reset(initialValues) + onCancel() + } + + return ( + <> + onCancel()}> + + +
+ + + Close + + Create a new OAuth app +
+
+ + +
+ ( + + + + + + )} + /> + + {/* ( + + Select the permissions your app will request from users.{' '} + + Learn more + + + } + className={'px-5'} + > + + + + + + + {OAUTH_APP_SCOPE_OPTIONS.map((scope) => ( + + {scope.name} + + ))} + + + + + )} + /> */} + +
+ Redirect URIs + +
+ {redirectUriFields.map((fieldItem, index) => ( + ( + +
+ + { + inputField.onChange(e) + }} + /> + + {redirectUriFields.length > 1 && ( +
+ +
+ )} + /> + ))} +
+
+ +
+ + URLs where users will be redirected after authentication. + +
+ + + ( + + If enabled, the Authorization Code with PKCE (Proof Key for Code Exchange) + flow can be used, particularly beneficial for applications that cannot + securely store Client Secrets, such as native and mobile apps.{' '} + + Learn more + + + } + className={'px-5'} + > + + + + + )} + /> + +
+
+ + + + +
+
+ + ) +} diff --git a/apps/studio/components/interfaces/Auth/OAuthApps/DeleteOAuthAppModal.tsx b/apps/studio/components/interfaces/Auth/OAuthApps/DeleteOAuthAppModal.tsx new file mode 100644 index 0000000000000..990dc47cba1e3 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/OAuthApps/DeleteOAuthAppModal.tsx @@ -0,0 +1,77 @@ +import type { OAuthClient } from '@supabase/supabase-js' +import { useParams } from 'common' +import { useOAuthServerAppDeleteMutation } from 'data/oauth-server-apps/oauth-server-app-delete-mutation' +import { useSupabaseClientQuery } from 'hooks/use-supabase-client-query' +import { useState } from 'react' +import { toast } from 'sonner' + +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' + +interface DeleteOAuthAppModalProps { + visible: boolean + selectedApp?: OAuthClient + onClose: () => void +} + +export const DeleteOAuthAppModal = ({ + visible, + selectedApp, + onClose, +}: DeleteOAuthAppModalProps) => { + const { ref: projectRef } = useParams() + const [isDeleting, setIsDeleting] = useState(false) + + const { data: supabaseClientData } = useSupabaseClientQuery({ projectRef }) + + const { mutateAsync: deleteOAuthApp } = useOAuthServerAppDeleteMutation() + + const onConfirmDeleteApp = async () => { + if (!selectedApp) return console.error('No OAuth app selected') + + setIsDeleting(true) + + try { + await deleteOAuthApp({ + projectRef, + supabaseClient: supabaseClientData?.supabaseClient, + clientId: selectedApp.client_id, + }) + + toast.success(`Successfully deleted OAuth app "${selectedApp.client_name}"`) + onClose() + } catch (error) { + toast.error('Failed to delete OAuth app') + console.error('Error deleting OAuth app:', error) + } finally { + setIsDeleting(false) + } + } + + return ( + + Confirm to delete OAuth app {selectedApp?.client_name} + + } + confirmLabel="Confirm delete" + confirmLabelLoading="Deleting..." + onCancel={onClose} + onConfirm={() => onConfirmDeleteApp()} + alert={{ + title: 'This action cannot be undone', + description: 'You will need to re-create the OAuth app if you want to revert the deletion.', + }} + > +

Before deleting this OAuth app, consider:

+
    +
  • Any applications using this OAuth app will lose access
  • +
  • This OAuth app is no longer in use by any applications
  • +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Auth/OAuthApps/NewOAuthAppBanner.tsx b/apps/studio/components/interfaces/Auth/OAuthApps/NewOAuthAppBanner.tsx new file mode 100644 index 0000000000000..9718ffcc6476a --- /dev/null +++ b/apps/studio/components/interfaces/Auth/OAuthApps/NewOAuthAppBanner.tsx @@ -0,0 +1,59 @@ +import type { OAuthClient } from '@supabase/supabase-js' +import { X } from 'lucide-react' +import { toast } from 'sonner' + +import { Button } from 'ui' +import { Admonition } from 'ui-patterns/admonition' +import { Input } from 'ui-patterns/DataInputs/Input' + +interface NewOAuthAppBannerProps { + oauthApp: OAuthClient + onClose: () => void +} + +export const NewOAuthAppBanner = ({ oauthApp, onClose }: NewOAuthAppBannerProps) => { + return ( + +

+ Do copy this client id and client secret and store it in a secure place - you will not + be able to see it again. +

+
+ {}} + onCopy={() => toast.success('Client Id copied to clipboard')} + /> +
+
+ {}} + onCopy={() => toast.success('Client secret copied to clipboard')} + /> +
+ + } + > + + + + + )} +
+
+ } + value={filterString} + className="w-full lg:w-52" + onChange={(e) => setFilterString(e.target.value)} + /> + + +
+
+ } + onClick={() => setShowCreateSheet(true)} + className="flex-grow" + tooltip={{ + content: { + side: 'bottom', + text: !isOAuthServerEnabled + ? 'OAuth server must be enabled in settings' + : undefined, + }, + }} + > + New OAuth App + +
+
+ +
+ + + + + Name + Client ID + Type + Scope + Created + + + + + {oAuthApps.length === 0 && ( + + +

No OAuth apps found

+
+
+ )} + {oAuthApps.length > 0 && + oAuthApps.map((app) => ( + + + {app.client_name} + + + {app.client_id} + + + {app.client_type === 'public' ? 'Public' : 'Private'} + + + {app.scope ? ( + {app.scope} + ) : ( + N/A + )} + + + + + +
+ + +
+
+
+ ))} +
+
+
+
+ + + { + setShowCreateSheet(false) + setSelectedApp(undefined) + setNewOAuthApp(app) + }} + onCancel={() => { + setShowCreateSheet(false) + setSelectedApp(undefined) + }} + /> + + setShowDeleteModal(false)} + selectedApp={selectedApp} + /> + + setShowRegenerateDialog(false)} + onConfirm={() => { + regenerateSecret({ + projectRef, + supabaseClient: supabaseClientData?.supabaseClient, + clientId: selectedApp?.client_id!, + }) + setShowRegenerateDialog(false) + }} + > +

+ Are you sure you wish to regenerate the client secret for "{selectedApp?.client_name}"? + All existing sessions will be invalidated. This action cannot be undone. +

+
+ + ) +} diff --git a/apps/studio/components/interfaces/Auth/OAuthApps/OAuthServerSettingsForm.tsx b/apps/studio/components/interfaces/Auth/OAuthApps/OAuthServerSettingsForm.tsx new file mode 100644 index 0000000000000..9342d5d164e1e --- /dev/null +++ b/apps/studio/components/interfaces/Auth/OAuthApps/OAuthServerSettingsForm.tsx @@ -0,0 +1,416 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import Link from 'next/link' +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import * as z from 'zod' + +import { useParams } from 'common' +import { + ScaffoldSection, + ScaffoldSectionContent, + ScaffoldSectionTitle, +} from 'components/layouts/Scaffold' +import NoPermission from 'components/ui/NoPermission' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useAuthConfigQuery } from 'data/auth/auth-config-query' +import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' +import { useOAuthServerAppsQuery } from 'data/oauth-server-apps/oauth-server-apps-query' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useSupabaseClientQuery } from 'hooks/use-supabase-client-query' +import { + Button, + Card, + CardContent, + CardFooter, + FormControl_Shadcn_, + FormField_Shadcn_, + Form_Shadcn_, + Input_Shadcn_, + Switch, +} from 'ui' +import { Admonition } from 'ui-patterns/admonition' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +const configUrlSchema = z.object({ + id: z.string(), + name: z.string(), + value: z.string(), + description: z.string().optional(), +}) + +const schema = z + .object({ + OAUTH_SERVER_ENABLED: z.boolean().default(false), + OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: z.boolean().default(false), + OAUTH_SERVER_AUTHORIZATION_PATH: z.string().default(''), + availableScopes: z.array(z.string()).default(['openid', 'email', 'profile']), + config_urls: z.array(configUrlSchema).optional(), + }) + .superRefine((data, ctx) => { + if (data.OAUTH_SERVER_ENABLED && data.OAUTH_SERVER_AUTHORIZATION_PATH.trim() === '') { + ctx.addIssue({ + path: ['OAUTH_SERVER_AUTHORIZATION_PATH'], + code: z.ZodIssueCode.custom, + message: 'Authorization Path is required when OAuth Server is enabled.', + }) + } + }) + +interface ConfigUrl { + id: string + name: string + value: string + description?: string +} + +interface OAuthServerSettings { + OAUTH_SERVER_ENABLED: boolean + OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: boolean + OAUTH_SERVER_AUTHORIZATION_PATH?: string + availableScopes: string[] + config_urls?: ConfigUrl[] +} + +export const OAuthServerSettingsForm = () => { + const { ref: projectRef } = useParams() + const { + data: authConfig, + isLoading: isAuthConfigLoading, + isSuccess, + } = useAuthConfigQuery({ projectRef }) + const { mutate: updateAuthConfig, isLoading } = useAuthConfigUpdateMutation({ + onSuccess: () => { + toast.success('OAuth server settings updated successfully') + }, + onError: (error) => { + toast.error(`Failed to update OAuth server settings: ${error?.message}`) + }, + }) + + const [showDynamicAppsConfirmation, setShowDynamicAppsConfirmation] = useState(false) + const [showDisableOAuthServerConfirmation, setShowDisableOAuthServerConfirmation] = + useState(false) + + const { + can: canReadConfig, + isLoading: isLoadingPermissions, + isSuccess: isPermissionsLoaded, + } = useAsyncCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') + + const { data: supabaseClientData } = useSupabaseClientQuery({ projectRef }) + + const { data: oAuthAppsData } = useOAuthServerAppsQuery({ + projectRef, + supabaseClient: supabaseClientData?.supabaseClient, + }) + + const oauthApps = oAuthAppsData?.clients || [] + + const { can: canUpdateConfig } = useAsyncCheckPermissions( + PermissionAction.UPDATE, + 'custom_config_gotrue' + ) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + OAUTH_SERVER_ENABLED: true, + OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: false, + OAUTH_SERVER_AUTHORIZATION_PATH: '/oauth/consent', + availableScopes: ['openid', 'email', 'profile'], + }, + }) + + // Reset the values when the authConfig is loaded + useEffect(() => { + if (isSuccess && authConfig) { + form.reset({ + OAUTH_SERVER_ENABLED: authConfig.OAUTH_SERVER_ENABLED ?? false, + OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: + authConfig.OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION ?? false, + OAUTH_SERVER_AUTHORIZATION_PATH: + authConfig.OAUTH_SERVER_AUTHORIZATION_PATH ?? '/oauth/consent', + availableScopes: ['openid', 'email', 'profile'], // Keep default scopes + }) + } + }, [isSuccess]) + + const onSubmit = async (values: OAuthServerSettings) => { + if (!projectRef) return console.error('Project ref is required') + + const config = { + OAUTH_SERVER_ENABLED: values.OAUTH_SERVER_ENABLED, + OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: values.OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION, + OAUTH_SERVER_AUTHORIZATION_PATH: values.OAUTH_SERVER_AUTHORIZATION_PATH, + } + + updateAuthConfig({ projectRef, config }) + } + + const handleDynamicAppsToggle = (checked: boolean) => { + if (checked) { + setShowDynamicAppsConfirmation(true) + } else { + form.setValue('OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION', false, { shouldDirty: true }) + } + } + + const confirmDynamicApps = () => { + form.setValue('OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION', true, { shouldDirty: true }) + setShowDynamicAppsConfirmation(false) + } + + const cancelDynamicApps = () => { + setShowDynamicAppsConfirmation(false) + } + + const handleOAuthServerToggle = (checked: boolean) => { + if (!checked && oauthApps.length > 0) { + setShowDisableOAuthServerConfirmation(true) + } else { + form.setValue('OAUTH_SERVER_ENABLED', checked, { shouldDirty: true }) + } + } + + const confirmDisableOAuthServer = () => { + form.setValue('OAUTH_SERVER_ENABLED', false, { shouldDirty: true }) + setShowDisableOAuthServerConfirmation(false) + } + + const cancelDisableOAuthServer = () => { + setShowDisableOAuthServerConfirmation(false) + } + + if (isPermissionsLoaded && !canReadConfig) { + return ( + + OAuth Server +
+ +
+
+ ) + } + + if (isAuthConfigLoading || isLoadingPermissions) { + return ( +
+ +
+ ) + } + + return ( + <> + + + +
+ + + ( + + Enable OAuth server functionality for your project to create and manage + OAuth applications.{' '} + + Learn more + + + } + > + + + + + )} + /> + + {/* Site URL and Authorization Path - Only show when OAuth Server is enabled */} + {form.watch('OAUTH_SERVER_ENABLED') && ( + <> + + + The base URL of your application, configured in{' '} + + Auth URL Configuration + {' '} + settings. + + } + > + + + + ( + + + + + + )} + /> + + + + ( + + Enable dynamic OAuth app registration. Apps can be registered + programmatically via apis.{' '} + + Learn more + + + } + > + + + + + )} + /> + + + )} + + + + + + +
+
+
+
+ + {/* Dynamic Apps Confirmation Modal */} + +

+ Enabling dynamic client registration will open up a public API endpoint that anyone can + use to register OAuth applications with your app. This can be a security concern, as + attackers could register OAuth applications with legitimate-sounding names and send them + to your users for approval. +

+

+ If your users don't look carefully and accept, the attacker could potentially take over + the user's account. Attackers can also flood your application with thousands of OAuth + applications that cannot be attributed to anyone (as it's a public endpoint), and make it + difficult for you to find and shut them down, or even find legitimate ones. +

+

+ If dynamic client registration is enabled, the consent screen is forced to be enabled for + all OAuth flows and can no longer be disabled. Disabling the consent screen opens up a + CSRF vulnerability in your app. +

+
+ + {/* Disable OAuth Server Confirmation Modal */} + 1 ? 's' : ''} that will be deactivated.`, + }} + > +

+ Disabling the OAuth Server will immediately deactivate all OAuth applications and prevent + new authentication flows from working. This action will affect all users currently using + your OAuth applications. +

+

+ What will happen: +

+
    +
  • All OAuth apps will be deactivated
  • +
  • Existing access tokens will become invalid
  • +
  • Users won't be able to sign in through OAuth flows
  • +
  • Third-party integrations will stop working
  • +
+

+ You can re-enable the OAuth Server at any time. +

+
+ + ) +} diff --git a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx index a9bb25d96aa98..8c0fb20193c2b 100644 --- a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx +++ b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx @@ -2,7 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { Globe, Trash } from 'lucide-react' import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { EmptyListState } from 'components/ui/States' +import { EmptyListState } from 'components/ui/EmptyListState' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, Checkbox_Shadcn_ } from 'ui' import { ValueContainer } from './ValueContainer' diff --git a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx index 76c31220454fa..4db5346402040 100644 --- a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx +++ b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx @@ -9,7 +9,7 @@ import { } from 'components/layouts/Scaffold' import AlertError from 'components/ui/AlertError' import { DocsButton } from 'components/ui/DocsButton' -import { HorizontalShimmerWithIcon } from 'components/ui/Shimmers/Shimmers' +import { HorizontalShimmerWithIcon } from 'components/ui/Shimmers' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' import { DOCS_URL } from 'lib/constants' diff --git a/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationPanels.tsx b/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationPanels.tsx index 01624ba609238..4e28f74b065b8 100644 --- a/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationPanels.tsx +++ b/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationPanels.tsx @@ -72,7 +72,7 @@ const Avatar = ({ src }: { src: string | undefined }) => { ) } -const IntegrationInstallation = forwardRef( +export const IntegrationInstallation = forwardRef( ({ integration, disabled, ...props }, ref) => { const IntegrationIconBlock = () => { return ( @@ -144,7 +144,7 @@ export interface IntegrationConnectionProps extends HTMLAttributes( +export const IntegrationConnection = forwardRef( ( { connection, type, actions, showNode = true, orientation = 'horizontal', className, ...props }, ref @@ -233,7 +233,7 @@ const IntegrationConnection = forwardRef( +export const IntegrationConnectionOption = forwardRef( ({ connection, type, ...props }, ref) => { const { data: project } = useProjectDetailQuery({ ref: connection.supabase_project_ref }) @@ -266,7 +266,7 @@ const IntegrationConnectionOption = forwardRef & { showNode?: boolean @@ -319,7 +319,7 @@ interface IntegrationConnectionHeader extends React.HTMLAttributes( +export const IntegrationConnectionHeader = forwardRef( ({ className, markdown = '', showNode = true, ...props }, ref) => { return (
void>(callback: T, delay: num ) as T } -const PreviewFilterPanelWithUniversal = ({ +export const PreviewFilterPanelWithUniversal = ({ isLoading, newCount, onRefresh, @@ -380,5 +380,3 @@ const PreviewFilterPanelWithUniversal = ({
) } - -export { PreviewFilterPanelWithUniversal } diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx index ebbaee5a51695..0434db1514adc 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx @@ -2,11 +2,11 @@ import { Transition } from '@headlessui/react' import { PermissionAction } from '@supabase/shared-types/out/constants' import { get, noop, sum } from 'lodash' import { Upload } from 'lucide-react' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useContextMenu } from 'react-contexify' import { toast } from 'sonner' -import InfiniteList from 'components/ui/InfiniteList' +import { InfiniteListDefault, LoaderForIconMenuItems } from 'components/ui/InfiniteList' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { BASE_PATH } from 'lib/constants' @@ -146,6 +146,23 @@ export const FileExplorerColumn = ({ /> ) + const getItemKey = useCallback( + (index: number) => { + const item = columnItems[index] + return item?.id || `file-explorer-item-${index}` + }, + [columnItems] + ) + + const itemProps = useMemo( + () => ({ + view: snap.view, + columnIndex: index, + selectedItems, + }), + [snap.view, index, selectedItems] + ) + return (
(index !== 0 && index === columnItems.length ? 85 : 37)} - hasNextPage={column.status !== STORAGE_ROW_STATUS.LOADING && column.hasMoreItems} - isLoadingNextPage={column.isLoadingMoreItems} - onLoadNextPage={() => onColumnLoadMore(index, column)} - /> + {columnItems.length > 0 && ( + (index !== 0 && index === columnItems.length ? 85 : 37)} + ItemComponent={FileExplorerRow} + LoaderComponent={LoaderForIconMenuItems} + hasNextPage={column.status !== STORAGE_ROW_STATUS.LOADING && column.hasMoreItems} + isLoadingNextPage={column.isLoadingMoreItems} + onLoadNextPage={() => onColumnLoadMore(index, column)} + /> + )} {/* Drag drop upload CTA for when column is empty */} {!(snap.isSearching && itemSearchString.length > 0) && diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx index 4634275532aa7..2db50f2dc8837 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx @@ -18,10 +18,10 @@ import { useContextMenu } from 'react-contexify' import SVG from 'react-inlinesvg' import { useParams } from 'common' -import type { ItemRenderer } from 'components/ui/InfiniteList' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { BASE_PATH } from 'lib/constants' import { formatBytes } from 'lib/helpers' +import type { CSSProperties } from 'react' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' import { Checkbox, @@ -46,7 +46,7 @@ import { STORAGE_VIEWS, URL_EXPIRY_DURATION, } from '../Storage.constants' -import { StorageItem, StorageItemWithColumn } from '../Storage.types' +import { StorageItemWithColumn, type StorageItem } from '../Storage.types' import { FileExplorerRowEditing } from './FileExplorerRowEditing' import { copyPathToFolder, downloadFile } from './StorageExplorer.utils' import { useCopyUrl } from './useCopyUrl' @@ -99,18 +99,22 @@ export const RowIcon = ({ } interface FileExplorerRowProps { + index: number + item: StorageItem view: STORAGE_VIEWS columnIndex: number selectedItems: StorageItemWithColumn[] + style?: CSSProperties } -export const FileExplorerRow: ItemRenderer = ({ +export const FileExplorerRow = ({ index: itemIndex, item, view = STORAGE_VIEWS.COLUMNS, columnIndex = 0, selectedItems = [], -}) => { + style, +}: FileExplorerRowProps) => { const { ref: projectRef, bucketId } = useParams() const { @@ -141,7 +145,7 @@ export const FileExplorerRow: ItemRenderer = const isPreviewed = !isEmpty(selectedFilePreview) && isEqual(selectedFilePreview?.id, item.id) const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') - const onSelectFile = async (columnIndex: number, file: StorageItem) => { + const onSelectFile = async (columnIndex: number) => { popColumnAtIndex(columnIndex) popOpenedFoldersAtIndex(columnIndex - 1) setSelectedFilePreview(itemWithColumnIndex) @@ -299,11 +303,14 @@ export const FileExplorerRow: ItemRenderer = : '100%' if (item.status === STORAGE_ROW_STATUS.EDITING) { - return + return ( + + ) } return (
{ event.stopPropagation() @@ -326,7 +333,7 @@ export const FileExplorerRow: ItemRenderer = if (item.status !== STORAGE_ROW_STATUS.LOADING && !isOpened && !isPreviewed) { item.type === STORAGE_ROW_TYPES.FOLDER || item.type === STORAGE_ROW_TYPES.BUCKET ? openFolder(columnIndex, item) - : onSelectFile(columnIndex, item) + : onSelectFile(columnIndex) } }} > diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRowEditing.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRowEditing.tsx index 2ee1a7f02c31e..9c666b337e705 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRowEditing.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRowEditing.tsx @@ -1,5 +1,5 @@ import { has } from 'lodash' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, type CSSProperties } from 'react' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES, STORAGE_VIEWS } from '../Storage.constants' @@ -10,12 +10,14 @@ export interface FileExplorerRowEditingProps { item: StorageItem view: STORAGE_VIEWS columnIndex: number + style?: CSSProperties } export const FileExplorerRowEditing = ({ item, view, columnIndex, + style, }: FileExplorerRowEditingProps) => { const { renameFile, renameFolder, addNewFolder, updateRowStatus } = useStorageExplorerStateSnapshot() @@ -86,7 +88,10 @@ export const FileExplorerRowEditing = ({ }, []) return ( -
+
) => { - const bucket = data.buckets[index] - const isSelected = data.selectedBucketId === bucket.id + ({ item, projectRef, selectedBucketId, style }: VirtualizedBucketRowProps) => { + const isSelected = selectedBucketId === item.id return ( ) - }, - (prev, next) => { - if (!areEqual(prev, next)) return false - - const prevBucket = prev.data.buckets[prev.index] - const nextBucket = next.data.buckets[next.index] - - if (prevBucket !== nextBucket) return false - - const wasSelected = prev.data.selectedBucketId === prevBucket.id - const isSelected = next.data.selectedBucketId === nextBucket.id - - return wasSelected === isSelected } ) VirtualizedBucketRow.displayName = 'VirtualizedBucketRow' const BucketListVirtualized = ({ buckets, selectedBucketId, projectRef = '' }: BucketListProps) => { - const [listHeight, setListHeight] = useState(500) - const sizerRef = useRef(null) - - useLayoutEffect(() => { - if (sizerRef.current) { - const resizeObserver = new ResizeObserver(([entry]) => { - const { height } = entry.contentRect - setListHeight(height) - }) - - resizeObserver.observe(sizerRef.current) - setListHeight(sizerRef.current.getBoundingClientRect().height) - - return () => { - resizeObserver.disconnect() - } - } - }, []) - - const itemData = useMemo( + const itemData = useMemo( () => ({ - buckets, projectRef, selectedBucketId, }), - [buckets, projectRef, selectedBucketId] + [projectRef, selectedBucketId] + ) + + const getItemKey = useCallback( + (index: number) => { + const item = buckets[index] + return item?.id || `bucket-${index}` + }, + [buckets] ) return ( -
- buckets[index].id} - height={listHeight} - // itemSize should match the height of BucketRow + any gap/margin - itemSize={28} - width="100%" - > - {VirtualizedBucketRow} - -
+ 28} + ItemComponent={VirtualizedBucketRow} + // There is no loader because all buckets load from backend at once + LoaderComponent={() => null} + /> ) } +type BucketListProps = { + buckets: Bucket[] + selectedBucketId?: string + projectRef?: string +} + export const BucketList = ({ buckets, selectedBucketId, projectRef = '' }: BucketListProps) => { const numBuckets = buckets.length diff --git a/apps/studio/components/interfaces/Storage/StorageMenu.tsx b/apps/studio/components/interfaces/Storage/StorageMenu.tsx index 0ee63944eb88e..7b0c795f94df6 100644 --- a/apps/studio/components/interfaces/Storage/StorageMenu.tsx +++ b/apps/studio/components/interfaces/Storage/StorageMenu.tsx @@ -74,7 +74,11 @@ export const StorageMenu = () => { return ( <> - +
@@ -107,12 +111,12 @@ export const StorageMenu = () => {
-
+
0 ? 'mb-3' : 'mb-5' diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/ServiceBlocks.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/ServiceBlocks.tsx index d4971cc420c2a..e2bbe6b4a08cc 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/ServiceBlocks.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/ServiceBlocks.tsx @@ -9,31 +9,20 @@ import { import { createBlock } from './shared/Block' // Generate all block components -const MemoizedGoTrueBlock = createBlock(authBlockConfig) - -const MemoizedPostgRESTBlock = createBlock(postgrestBlockConfig) - -const MemoizedNetworkBlock = createBlock(networkBlockConfig) - -const MemoizedEdgeFunctionBlock = createBlock(edgeFunctionBlockConfig) - -const MemoizedStorageBlock = createBlock(storageBlockConfig) - -const MemoizedPostgresBlock = createBlock(postgresBlockConfig) - -// Set display names for debugging +export const MemoizedGoTrueBlock = createBlock(authBlockConfig) MemoizedGoTrueBlock.displayName = 'MemoizedGoTrueBlock' + +export const MemoizedPostgRESTBlock = createBlock(postgrestBlockConfig) MemoizedPostgRESTBlock.displayName = 'MemoizedPostgRESTBlock' + +export const MemoizedNetworkBlock = createBlock(networkBlockConfig) MemoizedNetworkBlock.displayName = 'MemoizedNetworkBlock' + +export const MemoizedEdgeFunctionBlock = createBlock(edgeFunctionBlockConfig) MemoizedEdgeFunctionBlock.displayName = 'MemoizedEdgeFunctionBlock' + +export const MemoizedStorageBlock = createBlock(storageBlockConfig) MemoizedStorageBlock.displayName = 'MemoizedStorageBlock' -MemoizedPostgresBlock.displayName = 'MemoizedPostgresBlock' -export { - MemoizedEdgeFunctionBlock, - MemoizedGoTrueBlock, - MemoizedNetworkBlock, - MemoizedPostgresBlock, - MemoizedPostgRESTBlock, - MemoizedStorageBlock, -} +export const MemoizedPostgresBlock = createBlock(postgresBlockConfig) +MemoizedPostgresBlock.displayName = 'MemoizedPostgresBlock' diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/BlockField.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/BlockField.tsx index 6f78aff95f3c4..f824420dd9fe6 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/BlockField.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/BlockField.tsx @@ -5,7 +5,7 @@ import { getStatusLevel } from '../../../UnifiedLogs.utils' import { BlockFieldProps } from '../../types' import { TruncatedTextWithPopover } from './TruncatedTextWithPopover' -const BlockField = ({ +export const BlockField = ({ config, data, enrichedData, @@ -84,5 +84,3 @@ const BlockField = ({ return
{fieldContent}
} - -export { BlockField } diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/CollapsibleSection.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/CollapsibleSection.tsx index 01c4f920568a6..39e0aaa26ba56 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/CollapsibleSection.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/CollapsibleSection.tsx @@ -23,7 +23,7 @@ interface CollapsibleSectionProps { defaultOpen?: boolean } -const CollapsibleSection = ({ +export const CollapsibleSection = ({ title, fields, data, @@ -67,5 +67,3 @@ const CollapsibleSection = ({
) } - -export { CollapsibleSection } diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/FieldWithSeeMore.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/FieldWithSeeMore.tsx index 2bfbeb3884b05..4428a6868665b 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/FieldWithSeeMore.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/FieldWithSeeMore.tsx @@ -14,7 +14,7 @@ import { BlockFieldConfig } from '../../types' import { BlockField } from './BlockField' // Single source of truth for field row styling -const FieldRow = ({ +export const FieldRow = ({ label, value, expandButton, @@ -44,7 +44,7 @@ interface FieldWithSeeMoreProps { } // Primary field with expandable additional details -const FieldWithSeeMore = ({ +export const FieldWithSeeMore = ({ primaryField, additionalFields, data, @@ -131,5 +131,3 @@ const FieldWithSeeMore = ({
) } - -export { FieldRow, FieldWithSeeMore } diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/TimelineStep.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/TimelineStep.tsx index cde1d34e1f135..ea2bc00f796a6 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/TimelineStep.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/TimelineStep.tsx @@ -8,13 +8,13 @@ import { getStatusLevel } from '../../../UnifiedLogs.utils' type IconComponent = LucideIcon | React.ComponentType // Reusable styled icon component -const StyledIcon = ({ icon: Icon, title }: { icon: IconComponent; title: string }) => ( +export const StyledIcon = ({ icon: Icon, title }: { icon: IconComponent; title: string }) => (
) -const TimelineStep = ({ +export const TimelineStep = ({ title, status, statusText, @@ -69,5 +69,3 @@ const TimelineStep = ({
) - -export { StyledIcon, TimelineStep } diff --git a/apps/studio/components/layouts/AuthLayout/AuthLayout.tsx b/apps/studio/components/layouts/AuthLayout/AuthLayout.tsx index f0060141017fc..32817eda82f0b 100644 --- a/apps/studio/components/layouts/AuthLayout/AuthLayout.tsx +++ b/apps/studio/components/layouts/AuthLayout/AuthLayout.tsx @@ -15,6 +15,7 @@ const AuthProductMenu = () => { const { ref: projectRef = 'default' } = useParams() const authenticationShowOverview = useFlag('authOverviewPage') + const authenticationOauth21 = useFlag('EnableOAuth21') const authenticationShowSecurityNotifications = useIsSecurityNotificationsEnabled() const { @@ -48,6 +49,7 @@ const AuthProductMenu = () => { authenticationAdvanced, authenticationShowOverview, authenticationShowSecurityNotifications, + authenticationOauth21, })} /> ) diff --git a/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.ts b/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.ts index 27f0a52e396c5..95a2ea263a889 100644 --- a/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.ts +++ b/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.ts @@ -12,6 +12,7 @@ export const generateAuthMenu = ( authenticationAdvanced: boolean authenticationShowOverview: boolean authenticationShowSecurityNotifications: boolean + authenticationOauth21: boolean } ): ProductMenuGroup[] => { const { @@ -23,6 +24,7 @@ export const generateAuthMenu = ( authenticationAdvanced, authenticationShowOverview, authenticationShowSecurityNotifications, + authenticationOauth21, } = flags ?? {} return [ @@ -33,6 +35,16 @@ export const generateAuthMenu = ( ? [{ name: 'Overview', key: 'overview', url: `/project/${ref}/auth/overview`, items: [] }] : []), { name: 'Users', key: 'users', url: `/project/${ref}/auth/users`, items: [] }, + ...(authenticationOauth21 + ? [ + { + name: 'OAuth Apps', + key: 'oauth-apps', + url: `/project/${ref}/auth/oauth-apps`, + items: [], + }, + ] + : []), ], }, ...(authenticationEmails && authenticationShowSecurityNotifications && IS_PLATFORM @@ -77,6 +89,16 @@ export const generateAuthMenu = ( }, ] : []), + ...(authenticationOauth21 + ? [ + { + name: 'OAuth Server', + key: 'oauth-server', + url: `/project/${ref}/auth/oauth-server`, + label: 'BETA', + }, + ] + : []), { name: 'Sessions', key: 'sessions', diff --git a/apps/studio/components/layouts/IntegrationsLayout/IntegrationWindowLayout.tsx b/apps/studio/components/layouts/IntegrationsLayout/IntegrationWindowLayout.tsx index 3e80496149dfb..621e1b2c0882e 100644 --- a/apps/studio/components/layouts/IntegrationsLayout/IntegrationWindowLayout.tsx +++ b/apps/studio/components/layouts/IntegrationsLayout/IntegrationWindowLayout.tsx @@ -4,8 +4,8 @@ import { LoadingLine, cn } from 'ui' import { withAuth } from 'hooks/misc/withAuth' import { BASE_PATH } from 'lib/constants' -import { ScaffoldContainer } from '../Scaffold' import { Book, LifeBuoy, X } from 'lucide-react' +import { ScaffoldContainer } from '../Scaffold' export type IntegrationWindowLayoutProps = { title: string @@ -87,7 +87,7 @@ const Header = ({ title, integrationIcon }: HeaderProps) => { const maxWidthClasses = 'mx-auto w-full max-w-[1600px]' const paddingClasses = 'px-6 lg:px-14 xl:px-28 2xl:px-32' -const IntegrationScaffoldContainer = forwardRef< +export const IntegrationScaffoldContainer = forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => { @@ -95,5 +95,3 @@ const IntegrationScaffoldContainer = forwardRef< }) IntegrationScaffoldContainer.displayName = 'IntegrationScaffoldContainer' - -export { IntegrationScaffoldContainer } diff --git a/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx b/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx index 8ee67ce66ebf8..5093a6af9b3f5 100644 --- a/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx @@ -6,7 +6,8 @@ import { ClientLibrary } from 'components/interfaces/Home/ClientLibrary' import { ExampleProject } from 'components/interfaces/Home/ExampleProject' import { EXAMPLE_PROJECTS } from 'components/interfaces/Home/Home.constants' import { SupportLink } from 'components/interfaces/Support/SupportLink' -import { DisplayApiSettings, DisplayConfigSettings } from 'components/ui/ProjectSettings' +import { DisplayApiSettings } from 'components/ui/ProjectSettings/DisplayApiSettings' +import { DisplayConfigSettings } from 'components/ui/ProjectSettings/DisplayConfigSettings' import { useInvalidateProjectsInfiniteQuery } from 'data/projects/org-projects-infinite-query' import { useInvalidateProjectDetailsQuery } from 'data/projects/project-detail-query' import { useProjectStatusQuery } from 'data/projects/project-status-query' diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx index a1779c5aa5cdb..574b9aa472128 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx @@ -1,37 +1,36 @@ import dayjs from 'dayjs' import { Archive, ArchiveRestoreIcon, ExternalLink } from 'lucide-react' import Link from 'next/link' -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' import { useInView } from 'react-intersection-observer' import { Button, cn } from 'ui' import { Markdown } from 'components/interfaces/Markdown' import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import type { ItemRenderer } from 'components/ui/InfiniteList' -import { Notification, NotificationData } from 'data/notifications/notifications-v2-query' +import { useVirtualizerContext } from 'components/ui/InfiniteList' +import { NotificationData, type NotificationsData } from 'data/notifications/notifications-v2-query' import { useProjectDetailQuery } from 'data/projects/project-detail-query' import type { Organization } from 'types' import { CriticalIcon, WarningIcon } from 'ui' interface NotificationRowProps { - setRowHeight: (idx: number, height: number) => void + index: number + item: NotificationsData[number] getOrganizationById: (id: number) => Organization getOrganizationBySlug: (slug: string) => Organization onUpdateNotificationStatus: (id: string, status: 'archived' | 'seen') => void queueMarkRead: (id: string) => void } -const NotificationRow: ItemRenderer = ({ +const NotificationRow = ({ index, - listRef, item: notification, - setRowHeight, getOrganizationById, getOrganizationBySlug, onUpdateNotificationStatus, queueMarkRead, -}) => { - const ref = useRef(null) +}: NotificationRowProps) => { + const { virtualizer } = useVirtualizerContext() const { ref: viewRef, inView } = useInView() const { status, priority } = notification @@ -55,13 +54,6 @@ const NotificationRow: ItemRenderer = ({ console.log('Action', type) } - useEffect(() => { - if (ref.current) { - listRef?.current?.resetAfterIndex(0) - setRowHeight(index, ref.current.clientHeight) - } - }, [ref]) - useEffect(() => { if (inView && notification.status === 'new') { queueMarkRead(notification.id) @@ -70,7 +62,7 @@ const NotificationRow: ItemRenderer = ({ return (
{ // Storing in ref as no re-rendering required const markedRead = useRef([]) - // [Joshen] Just FYI this variable row heights logic should ideally live in InfiniteList - // but I ran into some infinite loops issues when I was trying to implement it there - // so opting to simplify and implement it here for now - const rowHeights = useRef<{ [key: number]: number }>({}) - const { data: organizations } = useOrganizationsQuery({ enabled: open }) const { data, @@ -197,20 +192,16 @@ export const NotificationsPopoverV2 = () => {
{notifications.length > 0 && !(activeTab === 'archived' && snap.filterStatuses.includes('unread')) ? ( - + LoaderComponent={({ style }) => ( +
- } + )} itemProps={{ - setRowHeight: (idx: number, height: number) => { - if (rowHeights.current) { - rowHeights.current = { ...rowHeights.current, [idx]: height } - } - }, getOrganizationById: (id: number) => organizations?.find((org) => org.id === id)!, getOrganizationBySlug: (slug: string) => @@ -224,7 +215,7 @@ export const NotificationsPopoverV2 = () => { } }, }} - getItemSize={(idx: number) => rowHeights?.current?.[idx] ?? 56} + getItemSize={() => 56} hasNextPage={hasNextPage} isLoadingNextPage={isFetchingNextPage} onLoadNextPage={() => fetchNextPage()} diff --git a/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx b/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx index 4b0ce98a50ab0..57ad52e21c5f9 100644 --- a/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx +++ b/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx @@ -4,11 +4,11 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { PropsWithChildren, useEffect, useState } from 'react' -import { useFlag } from 'common' +import { getAccessToken, useFlag } from 'common' import { DocsButton } from 'components/ui/DocsButton' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { BASE_PATH, DOCS_URL } from 'lib/constants' -import { auth, buildPathWithParams, getAccessToken, getReturnToPath } from 'lib/gotrue' +import { auth, buildPathWithParams, getReturnToPath } from 'lib/gotrue' import { tweets } from 'shared-data' type SignInLayoutProps = { diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index c7db3f26739d8..06fc58a7840d1 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -15,7 +15,6 @@ import { getEntityLintDetails, } from 'components/interfaces/TableGridEditor/TableEntity.utils' import { EntityTypeIcon } from 'components/ui/EntityTypeIcon' -import type { ItemRenderer } from 'components/ui/InfiniteList' import { InlineLink } from 'components/ui/InlineLink' import { getTableDefinition } from 'data/database/table-definition-query' import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' @@ -28,6 +27,7 @@ import { fetchAllTableRows } from 'data/table-rows/table-rows-query' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { formatSql } from 'lib/formatSql' +import type { CSSProperties } from 'react' import { useTableEditorStateSnapshot } from 'state/table-editor' import { createTabId, useTabsStateSnapshot } from 'state/tabs' import { @@ -52,8 +52,10 @@ import { export interface EntityListItemProps { id: number | string projectRef: string + item: Entity isLocked: boolean isActive?: boolean + style?: CSSProperties onExportCLI: () => void } @@ -62,14 +64,15 @@ function isTableLikeEntityListItem(entity: { type?: string }) { return entity?.type === ENTITY_TYPE.TABLE || entity?.type === ENTITY_TYPE.PARTITIONED_TABLE } -const EntityListItem: ItemRenderer = ({ +const EntityListItem = ({ id, projectRef, item: entity, isLocked, isActive: _isActive, + style, onExportCLI, -}) => { +}: EntityListItemProps) => { const { data: project } = useSelectedProjectQuery() const snap = useTableEditorStateSnapshot() const { selectedSchema } = useQuerySchemaState() @@ -236,6 +239,7 @@ const EntityListItem: ItemRenderer = ({ return ( { const tableEditorTabsCleanUp = useTableEditorTabsCleanUp() - const onSelectExportCLI = async (id: number) => { - const table = await getTableEditor({ - id: id, - projectRef, - connectionString: project?.connectionString, - }) - const supaTable = table && parseSupaTable(table) - setTableToExport(supaTable) - } + const onSelectExportCLI = useCallback( + async (id: number) => { + const table = await getTableEditor({ + id: id, + projectRef, + connectionString: project?.connectionString, + }) + const supaTable = table && parseSupaTable(table) + setTableToExport(supaTable) + }, + [project?.connectionString, projectRef] + ) + + const getItemKey = useCallback( + (index: number) => { + const item = entityTypes?.[index] + return item?.id ? String(item.id) : `table-editor-entity-${index}` + }, + [entityTypes] + ) + + const entityProps = useMemo( + () => ({ + projectRef: project?.ref!, + id: Number(id), + isLocked: isSchemaLocked, + onExportCLI: () => onSelectExportCLI(Number(id)), + }), + [project?.ref, id, isSchemaLocked, onSelectExportCLI] + ) useEffect(() => { // Clean up tabs + recent items for any tables that might have been removed outside of the dashboard session @@ -164,7 +185,7 @@ export const TableEditorMenu = () => { )}
-
+
{ /> )} {(entityTypes?.length ?? 0) > 0 && ( -
- + onSelectExportCLI(Number(id)), - }} - getItemSize={() => 28} + LoaderComponent={LoaderForIconMenuItems} + itemProps={entityProps} + getItemKey={getItemKey} + getItemSize={(index) => + index !== 0 && index === entityTypes!.length ? 85 : 28 + } hasNextPage={hasNextPage} isLoadingNextPage={isFetchingNextPage} - onLoadNextPage={() => fetchNextPage()} + onLoadNextPage={fetchNextPage} />
)} diff --git a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx index 25cd293a1edb9..21ecbe5580be3 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx @@ -1,11 +1,11 @@ 'use client' import dayjs from 'dayjs' +import { formatBytes } from 'lib/helpers' import { useState } from 'react' import { cn, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui' import { CHART_COLORS, DateTimeFormats } from './Charts.constants' import { numberFormatter } from './Charts.utils' -import { formatBytes } from 'lib/helpers' export interface ReportAttributes { id?: string @@ -129,7 +129,7 @@ export const calculateTotalChartAggregate = ( ?.filter((p) => !ignoreAttributes?.includes(p.dataKey)) .reduce((acc, curr) => acc + curr.value, 0) -const CustomTooltip = ({ +export const CustomTooltip = ({ active, payload, label, @@ -259,7 +259,7 @@ interface CustomLabelProps { hiddenAttributes?: Set } -const CustomLabel = ({ +export const CustomLabel = ({ payload, attributes, showMaxValue, @@ -341,5 +341,3 @@ const CustomLabel = ({
) } - -export { CustomLabel, CustomTooltip } diff --git a/apps/studio/components/ui/DataTable/Table.tsx b/apps/studio/components/ui/DataTable/Table.tsx index efd78e2613ade..ab2ab23c95861 100644 --- a/apps/studio/components/ui/DataTable/Table.tsx +++ b/apps/studio/components/ui/DataTable/Table.tsx @@ -12,7 +12,7 @@ import { } from 'ui/src/components/shadcn/ui/table' // Only create a custom component for Table with the added props -const Table = forwardRef>( +export const Table = forwardRef>( ({ className, onScroll, ...props }, ref) => ( >(({ className, ...props }, ref) => ( @@ -35,7 +35,7 @@ const TableHeader = forwardRef< )) TableHeader.displayName = 'TableHeader' -const TableBody = forwardRef< +export const TableBody = forwardRef< HTMLTableSectionElement, ComponentPropsWithRef >(({ className, ...props }, ref) => ( @@ -49,7 +49,7 @@ const TableBody = forwardRef< )) TableBody.displayName = 'TableBody' -const TableFooter = forwardRef< +export const TableFooter = forwardRef< HTMLTableSectionElement, ComponentPropsWithRef >(({ className, ...props }, ref) => ( @@ -57,50 +57,51 @@ const TableFooter = forwardRef< )) TableFooter.displayName = 'TableFooter' -const TableRow = forwardRef>( - ({ className, ...props }, ref) => ( - - ) -) +export const TableRow = forwardRef< + HTMLTableRowElement, + ComponentPropsWithRef +>(({ className, ...props }, ref) => ( + +)) TableRow.displayName = 'TableRow' -const TableHead = forwardRef>( - ({ className, ...props }, ref) => ( - .cursor-col-resize]:last:opacity-0', - 'text-muted-foreground h-8 px-2 text-left align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', - className - )} - {...props} - /> - ) -) +export const TableHead = forwardRef< + HTMLTableCellElement, + ComponentPropsWithRef +>(({ className, ...props }, ref) => ( + .cursor-col-resize]:last:opacity-0', + 'text-muted-foreground h-8 px-2 text-left align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', + className + )} + {...props} + /> +)) TableHead.displayName = 'TableHead' -const TableCell = forwardRef>( - ({ className, ...props }, ref) => ( - [role=checkbox]]:translate-y-[2px] truncate', className)} - {...props} - /> - ) -) +export const TableCell = forwardRef< + HTMLTableCellElement, + ComponentPropsWithRef +>(({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px] truncate', className)} + {...props} + /> +)) TableCell.displayName = 'TableCell' -const TableCaption = forwardRef< +export const TableCaption = forwardRef< HTMLTableCaptionElement, ComponentPropsWithRef >(({ className, ...props }, ref) => ( )) TableCaption.displayName = 'TableCaption' - -export { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } diff --git a/apps/studio/components/ui/DataTable/primitives/Slider.tsx b/apps/studio/components/ui/DataTable/primitives/Slider.tsx index b5d90eff3a6d3..6809ead1423cf 100644 --- a/apps/studio/components/ui/DataTable/primitives/Slider.tsx +++ b/apps/studio/components/ui/DataTable/primitives/Slider.tsx @@ -6,7 +6,7 @@ import { ComponentPropsWithoutRef, ElementRef, forwardRef, Fragment } from 'reac import { cn } from 'ui' -const Slider = forwardRef< +export const Slider = forwardRef< ElementRef, ComponentPropsWithoutRef >(({ className, ...props }, ref) => { @@ -30,5 +30,3 @@ const Slider = forwardRef< ) }) Slider.displayName = SliderPrimitive.Root.displayName - -export { Slider } diff --git a/apps/studio/components/ui/DataTable/primitives/Sortable.tsx b/apps/studio/components/ui/DataTable/primitives/Sortable.tsx index 24b45191a9520..60d95096fe61e 100644 --- a/apps/studio/components/ui/DataTable/primitives/Sortable.tsx +++ b/apps/studio/components/ui/DataTable/primitives/Sortable.tsx @@ -120,7 +120,7 @@ interface SortableProps extends DndConte overlay?: React.ReactNode | null } -function Sortable({ +export function Sortable({ value, onValueChange, onDragStart, @@ -202,7 +202,7 @@ interface SortableOverlayProps extends React.ComponentPropsWithRef( +export const SortableOverlay = forwardRef( ({ activeId, dropAnimation = dropAnimationOpts, children, ...props }, ref) => { return ( @@ -262,7 +262,7 @@ interface SortableItemProps extends SlotProps { asChild?: boolean } -const SortableItem = forwardRef( +export const SortableItem = forwardRef( ({ value, asTrigger, asChild, className, ...props }, ref) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: value, @@ -309,7 +309,7 @@ interface SortableDragHandleProps extends ButtonProps { withHandle?: boolean } -const SortableDragHandle = forwardRef( +export const SortableDragHandle = forwardRef( ({ className, ...props }, ref) => { const { attributes, listeners, isDragging } = useSortableItem() @@ -326,5 +326,3 @@ const SortableDragHandle = forwardRef ) } - -export { FormActions } diff --git a/apps/studio/components/ui/Forms/FormHeader.tsx b/apps/studio/components/ui/Forms/FormHeader.tsx index ccf5288b272c0..aa7da61dd0e82 100644 --- a/apps/studio/components/ui/Forms/FormHeader.tsx +++ b/apps/studio/components/ui/Forms/FormHeader.tsx @@ -2,7 +2,7 @@ import { ReactNode } from 'react' import { cn } from 'ui' import { DocsButton } from '../DocsButton' -const FormHeader = ({ +export const FormHeader = ({ title, description, docsUrl, @@ -32,5 +32,3 @@ const FormHeader = ({
) } - -export { FormHeader } diff --git a/apps/studio/components/ui/Forms/FormPanel.tsx b/apps/studio/components/ui/Forms/FormPanel.tsx index 7e7c88fff662f..f780aab0524ef 100644 --- a/apps/studio/components/ui/Forms/FormPanel.tsx +++ b/apps/studio/components/ui/Forms/FormPanel.tsx @@ -12,7 +12,7 @@ interface Props { } /** @deprecated Use Card instead, refer to BasicAuthSettingsForm.tsx for reference */ -const FormPanel = ({ children, header, footer }: Props) => ( +export const FormPanel = ({ children, header, footer }: Props) => ( {header && {header}} {children} @@ -20,7 +20,7 @@ const FormPanel = ({ children, header, footer }: Props) => ( ) -const FormPanelContainer = forwardRef>( +export const FormPanelContainer = forwardRef>( ({ children, ...props }, ref) => (
>( +export const FormPanelHeader = forwardRef>( ({ children, ...props }, ref) => (
{children} @@ -47,7 +47,7 @@ const FormPanelHeader = forwardRef>( +export const FormPanelContent = forwardRef>( ({ children, ...props }, ref) => (
{children} @@ -57,7 +57,7 @@ const FormPanelContent = forwardRef>( +export const FormPanelFooter = forwardRef>( ({ children, ...props }, ref) => (
{children} @@ -66,5 +66,3 @@ const FormPanelFooter = forwardRef (
) -const FormSectionContent = ({ +export const FormSectionContent = ({ children, loading = true, loaders, @@ -91,5 +91,3 @@ const FormSectionContent = ({
) } - -export { FormSection, FormSectionContent, FormSectionLabel } diff --git a/apps/studio/components/ui/InfiniteList.tsx b/apps/studio/components/ui/InfiniteList.tsx index 8ed657cb897d1..eae0fb05c5970 100644 --- a/apps/studio/components/ui/InfiniteList.tsx +++ b/apps/studio/components/ui/InfiniteList.tsx @@ -1,157 +1,373 @@ -import { propsAreEqual } from 'lib/helpers' -import memoize from 'memoize-one' -import { CSSProperties, ComponentType, MutableRefObject, ReactNode, memo, useRef } from 'react' -import AutoSizer from 'react-virtualized-auto-sizer' -import { VariableSizeList, areEqual } from 'react-window' -import InfiniteLoader from 'react-window-infinite-loader' -import { Skeleton } from 'ui' - -/** - * Note that the loading more logic of this component works best with a cursor-based - * pagination API such that each payload response from the API returns a structure like - * { cursor, items, hasNext, hasPrevious } - */ - -const createItemData = memoize((items, itemProps) => ({ items, ...itemProps })) - -export type ItemRenderer = ComponentType< - { - item: T - listRef: MutableRefObject | null | undefined> - index: number - } & P -> +import { Virtualizer, useVirtualizer } from '@tanstack/react-virtual' +import { + CSSProperties, + ComponentPropsWithRef, + ComponentType, + ElementType, + ReactNode, + Ref, + createContext, + createElement, + memo, + useContext, + useEffect, + useMemo, + useRef, + type ComponentProps, + type PropsWithChildren, +} from 'react' + +import { Skeleton, cn } from 'ui' + +// Regular memo erases generics, so this helper adds them back +const typedMemo = JSX.Element | null>( + component: Component, + propsAreEqual?: ( + prevProps: Readonly[0]>, + nextProps: Readonly[0]> + ) => boolean +) => memo(component, propsAreEqual) as unknown as Component & { displayName?: string } + +const createStyleObject = ({ size, start }: { size: number; start: number }): CSSProperties => ({ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: `${size}px`, + transform: `translateY(${start}px)`, +}) + +type VirtualizerInstance = Virtualizer +type VirtualItems = ReturnType + +type VirtualizerContextValue = { + virtualizer: VirtualizerInstance + virtualItems: VirtualItems +} + +const VirtualizerContext = createContext(null) + +export const VirtualizerProvider = ({ + children, + value, +}: PropsWithChildren<{ value: VirtualizerContextValue }>) => { + return {children} +} -export interface ItemProps { - data: { - items: T[] - itemProps: P - ItemComponent: ItemRenderer - listRef: MutableRefObject | null | undefined> - LoaderComponent?: ReactNode +export const useVirtualizerContext = () => { + const context = useContext(VirtualizerContext) + if (!context) { + throw new Error('useVirtualizerContext must be used within a VirtualizerProvider') } - index: number - style: CSSProperties + return context } -export interface InfiniteListProps { - items?: T[] - itemProps?: P +type ExtractRefType = + ComponentPropsWithRef extends { ref?: Ref } ? RefType : never + +type ExtractScrollElementFromRefComponent = Extract< + ExtractRefType, + Element +> + +type ScrollWrapperComponentConstraints = + ComponentPropsWithRef extends { className?: string } + ? ComponentPropsWithRef extends { children?: ReactNode | ReactNode[] } + ? ExtractRefType extends never + ? { ERROR_WRAPPER_COMPONENT_REQUIRES_REF_SUPPORT: never } + : ExtractRefType extends Element + ? {} + : { ERROR_WRAPPER_COMPONENT_REF_MUST_EXTEND_ELEMENT: never } + : { ERROR_WRAPPER_COMPONENT_REQUIRES_CHILDREN: never } + : { ERROR_WRAPPER_COMPONENT_REQUIRES_CLASSNAME: never } + +type InfiniteListWrapperProps = { + className?: string + items: Item[] + getItemKey?: (index: number) => string + getItemSize: (index: number) => number hasNextPage?: boolean isLoadingNextPage?: boolean - getItemSize?: (index: number) => number onLoadNextPage?: () => void - ItemComponent?: ItemRenderer - LoaderComponent?: ReactNode + Component?: Component +} & ScrollWrapperComponentConstraints + +export const InfiniteListScrollWrapper = ({ + children, + items, + getItemKey, + getItemSize, + hasNextPage = false, + isLoadingNextPage = false, + onLoadNextPage = () => {}, + className, + Component, +}: PropsWithChildren>) => { + const scrollRef = useRef | null>(null) + + const rowVirtualizer = useVirtualizer, Element>({ + count: hasNextPage ? items.length + 1 : items.length, + getScrollElement: () => scrollRef.current, + getItemKey, + estimateSize: getItemSize, + overscan: 5, + }) + + const virtualItems = rowVirtualizer.getVirtualItems() + const virtualizerContextValue = useMemo( + () => ({ + virtualizer: rowVirtualizer as unknown as Virtualizer, + virtualItems, + }), + [rowVirtualizer, virtualItems] + ) + + useEffect(() => { + const lastItem = virtualItems[virtualItems.length - 1] + if (!lastItem) return + + if (lastItem.index >= items.length - 1 && hasNextPage && !isLoadingNextPage) { + onLoadNextPage() + } + }, [virtualItems, items.length, hasNextPage, isLoadingNextPage, onLoadNextPage]) + + const WrapperToRender: Wrapper = Component ?? ('div' as Wrapper) + const wrapperProps = { + ref: (node: ExtractScrollElementFromRefComponent | null) => { + scrollRef.current = node + }, + className: cn('overflow-auto', className), + children, + } as ComponentPropsWithRef + + return ( + + + + ) } -const Item = memo(({ data, index, style }: ItemProps) => { - const { items, itemProps, ItemComponent, listRef, LoaderComponent } = data - const item = index < items.length ? items[index] : undefined +type ComponentWithStylePropConstraint = + ComponentProps extends { style?: CSSProperties } + ? {} + : { ERROR_SIZER_COMPONENT_MUST_TAKE_STYLE_PROP: never } - return item ? ( -
- -
- ) : LoaderComponent !== undefined ? ( -
{LoaderComponent}
- ) : ( -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
+type InfiniteListSizerProps = { + Component?: ElementType +} & ComponentWithStylePropConstraint + +export const InfiniteListSizer = ({ + children, + Component = 'div', +}: PropsWithChildren) => { + const { virtualizer } = useVirtualizerContext() + + return ( + + {children} + ) -}, areEqual) +} + +type RowComponentBaseProps = { + index: number + item: Item + style?: CSSProperties +} -Item.displayName = 'Item' +type InfiniteListItemProps< + Item, + ExtraProps extends object = Record, + RowComponent extends ComponentType & ExtraProps> = ComponentType< + RowComponentBaseProps & ExtraProps + >, +> = { + index: number + start: number + size: number + item: Item + itemProps?: ExtraProps + ItemComponent: RowComponent +} -function InfiniteList({ - items = [], - itemProps, - hasNextPage = false, - isLoadingNextPage = false, - getItemSize = () => 40, - onLoadNextPage = () => {}, - ItemComponent = () => null, - LoaderComponent, -}: InfiniteListProps) { - const listRef = useRef | null>() +const MemoizedInfiniteListItem = typedMemo( + < + Item, + ExtraProps extends object = Record, + RowComponent extends ComponentType & ExtraProps> = ComponentType< + RowComponentBaseProps & ExtraProps + >, + >({ + index, + start, + size, + item, + itemProps, + ItemComponent, + }: InfiniteListItemProps) => { + const styleObject = useMemo( + () => createStyleObject({ size, start }), + [size, start] + ) - // Only load 1 page of items at a time - // Pass an empty callback to InfiniteLoader in case it asks to load more than once - const loadMoreItems = isLoadingNextPage ? () => {} : onLoadNextPage + const baseProps = useMemo>( + () => ({ + index, + item, + style: styleObject, + }), + [index, item, styleObject] + ) - // Every row is loaded except for our loading indicator row - const isItemLoaded = (index: number) => { - return !hasNextPage || index < items.length + const combinedProps = useMemo( + () => + ({ + ...baseProps, + ...(itemProps ?? ({} as ExtraProps)), + }) as RowComponentBaseProps & ExtraProps, + [baseProps, itemProps] + ) + + // Not JSX to avoid type error with generic function component + return createElement(ItemComponent, combinedProps) } +) +MemoizedInfiniteListItem.displayName = 'MemoizedInfiniteListItem' + +type InfiniteListItemsProps< + Item, + ExtraProps extends object = Record, + RowComponent extends ComponentType & ExtraProps> = ComponentType< + RowComponentBaseProps & ExtraProps + >, +> = { + items: Item[] + itemProps?: ExtraProps + ItemComponent: RowComponent + LoaderComponent: ComponentType<{ style?: CSSProperties }> +} - const itemCount = hasNextPage ? items.length + 1 : items.length - const itemData = createItemData(items, { itemProps, ItemComponent, LoaderComponent, listRef }) +export const InfiniteListItems = < + Item, + ExtraProps extends object = Record, + RowComponent extends ComponentType & ExtraProps> = ComponentType< + RowComponentBaseProps & ExtraProps + >, +>({ + items, + itemProps, + ItemComponent, + LoaderComponent, +}: InfiniteListItemsProps) => { + const { virtualItems } = useVirtualizerContext() return ( <> -
- - {({ height, width }: { height: number; width: number }) => ( - - {({ onItemsRendered, ref }) => ( - { - ref(refy) - listRef.current = refy - }} - height={height ?? 0} - width={width ?? 0} - itemCount={itemCount} - itemData={itemData} - itemSize={getItemSize} - onItemsRendered={onItemsRendered} - > - {Item} - - )} - - )} - -
-
+ {virtualItems.map((virtualRow) => { + const isLoaderRow = virtualRow.index > items.length - 1 + const item = items[virtualRow.index] + + return isLoaderRow ? ( + + ) : ( + // Not JSX so we can pass type arguments to the generic function component + createElement(MemoizedInfiniteListItem, { + key: virtualRow.index, + index: virtualRow.index, + start: virtualRow.start, + size: virtualRow.size, + item, + itemProps, + ItemComponent, + }) + ) + })} ) } -// memo erases generics so this magic is needed -export default memo(InfiniteList, propsAreEqual) as typeof InfiniteList +type InfiniteListDefaultProps> = { + className?: string + items: Item[] + itemProps?: ItemComponentProps + getItemKey?: (index: number) => string + getItemSize: (index: number) => number + hasNextPage?: boolean + isLoadingNextPage?: boolean + onLoadNextPage?: () => void + ItemComponent: ComponentType & ItemComponentProps> + LoaderComponent: ComponentType<{ style?: CSSProperties }> +} + +export const InfiniteListDefault = < + Item, + ItemComponentProps extends object = Record, +>({ + className, + items, + itemProps, + getItemKey, + getItemSize, + hasNextPage = false, + isLoadingNextPage = false, + onLoadNextPage = () => {}, + ItemComponent, + LoaderComponent, +}: InfiniteListDefaultProps) => { + return ( + + + + + + ) +} + +export const LoaderForIconMenuItems = ({ style }: { style?: CSSProperties }) => ( +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+) diff --git a/apps/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx b/apps/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx index e4ffdb9ad7982..8ad33c4f4d401 100644 --- a/apps/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx +++ b/apps/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx @@ -9,7 +9,7 @@ import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-co import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { Input } from 'ui' -const DisplayConfigSettings = () => { +export const DisplayConfigSettings = () => { const { ref: projectRef } = useParams() const { data: settings, @@ -91,8 +91,6 @@ const DisplayConfigSettings = () => { ) } -export default DisplayConfigSettings - const ConfigContentWrapper = ({ children }: PropsWithChildren<{}>) => { return ( ( +export const GenericSkeletonLoader = () => (
) - -export { GenericSkeletonLoader } export default ShimmeringLoader diff --git a/apps/studio/components/ui/Shimmers/Shimmers.tsx b/apps/studio/components/ui/Shimmers.tsx similarity index 81% rename from apps/studio/components/ui/Shimmers/Shimmers.tsx rename to apps/studio/components/ui/Shimmers.tsx index 88ee66fde8897..1aa9f0d8bafc7 100644 --- a/apps/studio/components/ui/Shimmers/Shimmers.tsx +++ b/apps/studio/components/ui/Shimmers.tsx @@ -1,4 +1,4 @@ -const HorizontalShimmerWithIcon = () => ( +export const HorizontalShimmerWithIcon = () => (
@@ -7,5 +7,3 @@ const HorizontalShimmerWithIcon = () => (
) - -export { HorizontalShimmerWithIcon } diff --git a/apps/studio/components/ui/States/NoTableState.tsx b/apps/studio/components/ui/States/NoTableState.tsx deleted file mode 100644 index fed3f31d7ecca..0000000000000 --- a/apps/studio/components/ui/States/NoTableState.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useRouter } from 'next/router' -import ProductEmptyState from '../../to-be-cleaned/ProductEmptyState' - -interface Props { - message: string -} - -const NoTableState: React.FC = ({ message }) => { - const router = useRouter() - const { ref } = router.query - - return ( - { - router.push(`/project/${ref}/editor`) - }} - > -

{message}

-
- ) -} - -export default NoTableState diff --git a/apps/studio/components/ui/States/index.tsx b/apps/studio/components/ui/States/index.tsx deleted file mode 100644 index 9f93bba474190..0000000000000 --- a/apps/studio/components/ui/States/index.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import { EmptyListState } from './EmptyListState' -import NoTableState from './NoTableState' - -export { EmptyListState, NoTableState } diff --git a/apps/studio/data/api-keys/keys.ts b/apps/studio/data/api-keys/keys.ts index e0bfd8932647d..3a8d7e0c7385f 100644 --- a/apps/studio/data/api-keys/keys.ts +++ b/apps/studio/data/api-keys/keys.ts @@ -3,4 +3,5 @@ export const apiKeysKeys = { ['projects', projectRef, 'api-keys', reveal].filter(Boolean), single: (projectRef?: string, id?: string) => ['projects', projectRef, 'api-keys', id] as const, status: (projectRef?: string) => ['projects', projectRef, 'api-keys', 'legacy'] as const, + temporary: (projectRef?: string) => ['projects', projectRef, 'api-keys', 'temporary'] as const, } diff --git a/apps/studio/data/api-keys/temp-api-keys-query.ts b/apps/studio/data/api-keys/temp-api-keys-query.ts index 5a15846d269c6..5d27625ef3a77 100644 --- a/apps/studio/data/api-keys/temp-api-keys-query.ts +++ b/apps/studio/data/api-keys/temp-api-keys-query.ts @@ -1,4 +1,8 @@ +import { type UseQueryOptions, useQuery } from '@tanstack/react-query' + import { handleError, post } from 'data/fetchers' +import type { ResponseError } from 'types' +import { apiKeysKeys } from './keys' interface getTemporaryAPIKeyVariables { projectRef?: string @@ -6,8 +10,7 @@ interface getTemporaryAPIKeyVariables { expiry?: number } -// [Joshen] This one specifically shouldn't need a useQuery hook since the expiry is meant to be short lived -// Used in storage explorer and realtime inspector. +// Used in storage explorer, realtime inspector and OAuth Server apps. export async function getTemporaryAPIKey( { projectRef, expiry = 300 }: getTemporaryAPIKeyVariables, signal?: AbortSignal @@ -28,3 +31,18 @@ export async function getTemporaryAPIKey( if (error) handleError(error) return data } + +export type TemporaryAPIKeyData = Awaited> + +export const useTemporaryAPIKeyQuery = ( + { projectRef, expiry = 300 }: getTemporaryAPIKeyVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => { + return useQuery({ + queryKey: apiKeysKeys.temporary(projectRef), + queryFn: ({ signal }) => getTemporaryAPIKey({ projectRef, expiry }, signal), + enabled: enabled && typeof projectRef !== 'undefined', + refetchInterval: expiry * 1000, // convert to ms + ...options, + }) +} diff --git a/apps/studio/data/auth/session-access-token-query.ts b/apps/studio/data/auth/session-access-token-query.ts index c5f042e128846..d335e3ed6a65a 100644 --- a/apps/studio/data/auth/session-access-token-query.ts +++ b/apps/studio/data/auth/session-access-token-query.ts @@ -1,7 +1,7 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { getAccessToken } from 'common' import { authKeys } from './keys' -import { getAccessToken } from 'lib/gotrue' export async function getSessionAccessToken() { // ignore if server-side diff --git a/apps/studio/data/fetchers.ts b/apps/studio/data/fetchers.ts index aa8d66e428c96..57ea115c056c7 100644 --- a/apps/studio/data/fetchers.ts +++ b/apps/studio/data/fetchers.ts @@ -3,9 +3,8 @@ import * as Sentry from '@sentry/nextjs' import createClient from 'openapi-fetch' import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants' -import { IS_PLATFORM } from 'common' +import { IS_PLATFORM, getAccessToken } from 'common' import { API_URL } from 'lib/constants' -import { getAccessToken } from 'lib/gotrue' import { uuidv4 } from 'lib/helpers' import { ResponseError } from 'types' import type { paths } from './api' // generated from openapi-typescript diff --git a/apps/studio/data/oauth-server-apps/keys.ts b/apps/studio/data/oauth-server-apps/keys.ts new file mode 100644 index 0000000000000..34df6f8cca124 --- /dev/null +++ b/apps/studio/data/oauth-server-apps/keys.ts @@ -0,0 +1,4 @@ +export const oauthServerAppKeys = { + // temporaryApiKey has to be added to reset the query when it changes + list: (projectRef: string | undefined) => ['projects', projectRef, 'oauth-server-apps'] as const, +} diff --git a/apps/studio/data/oauth-server-apps/oauth-server-app-create-mutation.ts b/apps/studio/data/oauth-server-apps/oauth-server-app-create-mutation.ts new file mode 100644 index 0000000000000..019e43cb73adb --- /dev/null +++ b/apps/studio/data/oauth-server-apps/oauth-server-app-create-mutation.ts @@ -0,0 +1,58 @@ +import { CreateOAuthClientParams, SupabaseClient } from '@supabase/supabase-js' +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { oauthServerAppKeys } from './keys' + +export type OAuthServerAppCreateVariables = CreateOAuthClientParams & { + projectRef?: string + supabaseClient?: SupabaseClient +} + +export async function createOAuthServerApp({ + projectRef, + supabaseClient, + ...params +}: OAuthServerAppCreateVariables) { + if (!projectRef) throw new Error('Project reference is required') + if (!supabaseClient) throw new Error('Supabase client is required') + + const { data, error } = await supabaseClient.auth.admin.oauth.createClient(params) + + if (error) return handleError(error) + return data +} + +type OAuthAppCreateData = Awaited> + +export const useOAuthServerAppCreateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => createOAuthServerApp(vars), + onSuccess: async (data, variables, context) => { + const { projectRef } = variables + await queryClient.invalidateQueries({ + queryKey: oauthServerAppKeys.list(projectRef), + }) + await onSuccess?.(data, variables, context) + }, + onError: async (data, variables, context) => { + if (onError === undefined) { + toast.error(`Failed to create OAuth Server application: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/oauth-server-apps/oauth-server-app-delete-mutation.ts b/apps/studio/data/oauth-server-apps/oauth-server-app-delete-mutation.ts new file mode 100644 index 0000000000000..460de8fbe6d78 --- /dev/null +++ b/apps/studio/data/oauth-server-apps/oauth-server-app-delete-mutation.ts @@ -0,0 +1,60 @@ +import { SupabaseClient } from '@supabase/supabase-js' +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { oauthServerAppKeys } from './keys' + +export type OAuthServerAppDeleteVariables = { + clientId?: string + projectRef?: string + supabaseClient?: SupabaseClient +} + +export async function deleteOAuthServerApp({ + projectRef, + supabaseClient, + clientId, +}: OAuthServerAppDeleteVariables) { + if (!projectRef) throw new Error('Project reference is required') + if (!supabaseClient) throw new Error('Supabase client is required') + if (!clientId) throw new Error('Client ID is required') + + const { data, error } = await supabaseClient.auth.admin.oauth.deleteClient(clientId) + console.log(data, error) + if (error) return handleError(error) + return null +} + +type OAuthAppDeleteData = Awaited> + +export const useOAuthServerAppDeleteMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => deleteOAuthServerApp(vars), + onSuccess: async (data, variables, context) => { + const { projectRef } = variables + await queryClient.invalidateQueries({ + queryKey: oauthServerAppKeys.list(projectRef), + }) + await onSuccess?.(data, variables, context) + }, + onError: async (data, variables, context) => { + if (onError === undefined) { + toast.error(`Failed to delete OAuth Server application: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/oauth-server-apps/oauth-server-app-regenerate-secret-mutation.ts b/apps/studio/data/oauth-server-apps/oauth-server-app-regenerate-secret-mutation.ts new file mode 100644 index 0000000000000..e9e4c2c862cb9 --- /dev/null +++ b/apps/studio/data/oauth-server-apps/oauth-server-app-regenerate-secret-mutation.ts @@ -0,0 +1,68 @@ +import { SupabaseClient } from '@supabase/supabase-js' +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { oauthServerAppKeys } from './keys' + +export type OAuthServerAppRegenerateSecretVariables = { + projectRef?: string + supabaseClient?: SupabaseClient + clientId: string +} + +export async function regenerateSecret({ + projectRef, + supabaseClient, + clientId, +}: OAuthServerAppRegenerateSecretVariables) { + if (!projectRef) throw new Error('Project reference is required') + if (!supabaseClient) throw new Error('Supabase client is required') + if (!clientId) throw new Error('Oauth app client id is required') + + const { data, error } = await supabaseClient.auth.admin.oauth.regenerateClientSecret(clientId) + + if (error) handleError(error) + return data +} + +type OAuthAppRegenerateSecretData = Awaited> + +export const useOAuthServerAppRegenerateSecretMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions< + OAuthAppRegenerateSecretData, + ResponseError, + OAuthServerAppRegenerateSecretVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + OAuthAppRegenerateSecretData, + ResponseError, + OAuthServerAppRegenerateSecretVariables + >({ + mutationFn: (vars) => regenerateSecret(vars), + onSuccess: async (data, variables, context) => { + const { projectRef } = variables + await queryClient.invalidateQueries({ + queryKey: oauthServerAppKeys.list(projectRef), + }) + await onSuccess?.(data, variables, context) + }, + onError: async (data, variables, context) => { + if (onError === undefined) { + toast.error(`Failed to regenerate OAuth Server application secret: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/oauth-server-apps/oauth-server-apps-query.ts b/apps/studio/data/oauth-server-apps/oauth-server-apps-query.ts new file mode 100644 index 0000000000000..7360db608cc7f --- /dev/null +++ b/apps/studio/data/oauth-server-apps/oauth-server-apps-query.ts @@ -0,0 +1,52 @@ +import { SupabaseClient } from '@supabase/supabase-js' +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { components } from 'api-types' +import { handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { oauthServerAppKeys } from './keys' + +export type OAuthServerAppsVariables = { + projectRef?: string + supabaseClient?: SupabaseClient + page?: number +} + +const APPS_PER_PAGE = 100 + +export type OAuthApp = components['schemas']['OAuthAppResponse'] + +export async function getOAuthServerApps({ + projectRef, + supabaseClient, + page = 1, +}: OAuthServerAppsVariables) { + if (!projectRef) throw new Error('Project reference is required') + if (!supabaseClient) throw new Error('Supabase client is required') + + const { data, error } = await supabaseClient.auth.admin.oauth.listClients({ + page, + perPage: APPS_PER_PAGE, + }) + + if (error) handleError(error) + return data +} + +export type OAuthServerAppsData = Awaited> +export type OAuthServerAppsError = ResponseError + +export const useOAuthServerAppsQuery = ( + { projectRef, supabaseClient }: OAuthServerAppsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => { + return useQuery({ + queryKey: oauthServerAppKeys.list(projectRef), + queryFn: () => getOAuthServerApps({ projectRef, supabaseClient }), + enabled: enabled && typeof projectRef !== 'undefined' && !!supabaseClient, + ...options, + }) +} diff --git a/apps/studio/hooks/custom-content/useCustomContent.ts b/apps/studio/hooks/custom-content/useCustomContent.ts index 8f26fe1f3971f..f125025178ed2 100644 --- a/apps/studio/hooks/custom-content/useCustomContent.ts +++ b/apps/studio/hooks/custom-content/useCustomContent.ts @@ -24,7 +24,7 @@ function contentToCamelCase(feature: CustomContent) { .join('') as CustomContentToCamelCase } -const useCustomContent = ( +export const useCustomContent = ( contents: T ): { [key in CustomContentToCamelCase]: @@ -40,5 +40,3 @@ const useCustomContent = ( | null } } - -export { useCustomContent } diff --git a/apps/studio/hooks/use-supabase-client-query.ts b/apps/studio/hooks/use-supabase-client-query.ts new file mode 100644 index 0000000000000..acada318d47b1 --- /dev/null +++ b/apps/studio/hooks/use-supabase-client-query.ts @@ -0,0 +1,54 @@ +import { createClient } from '@supabase/supabase-js' +import { useQuery } from '@tanstack/react-query' + +import { useTemporaryAPIKeyQuery } from 'data/api-keys/temp-api-keys-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' + +const getSupabaseClient = ({ + projectRef, + endpoint, + temporaryApiKey, +}: { + projectRef?: string + endpoint?: string + temporaryApiKey?: string +}) => { + if (!projectRef) { + return undefined + } + if (!endpoint) { + return undefined + } + + if (temporaryApiKey === undefined) { + return undefined + } + + const supabaseClient = createClient(endpoint, temporaryApiKey) + + return { supabaseClient } +} + +/** + * The client uses a temporary API key to authenticate requests. The API key expires after one hour which may cause + * 401 errors for all requests made with the client. It's easily fixable by refreshing the page. + */ +export const useSupabaseClientQuery = ( + { projectRef }: { projectRef?: string }, + { enabled = true, ...options } = {} +) => { + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const { data: temporaryApiKeyData } = useTemporaryAPIKeyQuery({ projectRef, expiry: 3600 }) + + const endpoint = settings + ? `${settings?.app_config?.protocol ?? 'https'}://${settings?.app_config?.endpoint}` + : undefined + const temporaryApiKey = temporaryApiKeyData?.api_key + + return useQuery({ + queryKey: [projectRef, 'supabase-client', endpoint, temporaryApiKey], + queryFn: () => getSupabaseClient({ projectRef, endpoint, temporaryApiKey }), + enabled: enabled && typeof projectRef !== 'undefined' && !!endpoint && !!temporaryApiKey, + ...options, + }) +} diff --git a/apps/studio/lib/gotrue.ts b/apps/studio/lib/gotrue.ts index b51fdd29e7111..a80ab433d6e1e 100644 --- a/apps/studio/lib/gotrue.ts +++ b/apps/studio/lib/gotrue.ts @@ -1,9 +1,8 @@ import type { JwtPayload } from '@supabase/supabase-js' -import { getAccessToken, type User } from 'common/auth' +import { type User } from 'common/auth' import { gotrueClient } from 'common/gotrue' export const auth = gotrueClient -export { getAccessToken } export const DEFAULT_FALLBACK_PATH = '/organizations' diff --git a/apps/studio/lib/type-helpers.ts b/apps/studio/lib/type-helpers.ts new file mode 100644 index 0000000000000..72551b9d9ca43 --- /dev/null +++ b/apps/studio/lib/type-helpers.ts @@ -0,0 +1 @@ +export type PlainObject = Record diff --git a/apps/studio/package.json b/apps/studio/package.json index 35535056282f3..008217919c597 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -57,7 +57,7 @@ "@stripe/react-stripe-js": "^3.7.0", "@stripe/stripe-js": "^7.5.0", "@supabase/auth-js": "catalog:", - "@supabase/mcp-server-supabase": "^0.5.6", + "@supabase/mcp-server-supabase": "^0.5.8", "@supabase/mcp-utils": "^0.2.0", "@supabase/pg-meta": "workspace:*", "@supabase/realtime-js": "catalog:", @@ -67,6 +67,7 @@ "@tanstack/react-query": "4.35.7", "@tanstack/react-query-devtools": "4.35.7", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.12", "@uidotdev/usehooks": "^2.4.1", "@vercel/functions": "^2.1.0", "@vitejs/plugin-react": "^4.3.4", @@ -125,9 +126,6 @@ "react-resizable": "3.0.5", "react-simple-maps": "4.0.0-beta.6", "react-use": "^17.5.0", - "react-virtualized-auto-sizer": "^1.0.20", - "react-window": "^1.8.6", - "react-window-infinite-loader": "^1.0.7", "reactflow": "^11.10.1", "recharts": "^2.8.0", "remark-gfm": "^3.0.1", @@ -175,9 +173,6 @@ "@types/react-dom": "catalog:", "@types/react-grid-layout": "^1.3.0", "@types/react-simple-maps": "^3.0.1", - "@types/react-virtualized-auto-sizer": "^1.0.1", - "@types/react-window": "^1.8.5", - "@types/react-window-infinite-loader": "^1.0.5", "@types/recharts": "^1.8.23", "@types/sqlstring": "^2.3.0", "@types/uuid": "^8.3.4", diff --git a/apps/studio/pages/forgot-password-mfa.tsx b/apps/studio/pages/forgot-password-mfa.tsx index 82b0c1f3ef084..341485c511cfc 100644 --- a/apps/studio/pages/forgot-password-mfa.tsx +++ b/apps/studio/pages/forgot-password-mfa.tsx @@ -4,10 +4,11 @@ import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import { toast } from 'sonner' +import { getAccessToken } from 'common' import { SignInMfaForm } from 'components/interfaces/SignIn/SignInMfaForm' import ForgotPasswordLayout from 'components/layouts/SignInLayout/ForgotPasswordLayout' import { Loading } from 'components/ui/Loading' -import { auth, buildPathWithParams, getAccessToken, getReturnToPath } from 'lib/gotrue' +import { auth, buildPathWithParams, getReturnToPath } from 'lib/gotrue' import type { NextPageWithLayout } from 'types' const ForgotPasswordMfa: NextPageWithLayout = () => { diff --git a/apps/studio/pages/project/[ref]/auth/oauth-apps.tsx b/apps/studio/pages/project/[ref]/auth/oauth-apps.tsx new file mode 100644 index 0000000000000..9034ea70138dc --- /dev/null +++ b/apps/studio/pages/project/[ref]/auth/oauth-apps.tsx @@ -0,0 +1,28 @@ +import { OAuthAppsList } from 'components/interfaces/Auth/OAuthApps/OAuthAppsList' +import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' +import { FormHeader } from 'components/ui/Forms/FormHeader' +import type { NextPageWithLayout } from 'types' + +const OAuthApps: NextPageWithLayout = () => ( + + +
+ + +
+
+
+) + +OAuthApps.getLayout = (page) => ( + + {page} + +) + +export default OAuthApps diff --git a/apps/studio/pages/project/[ref]/auth/oauth-server.tsx b/apps/studio/pages/project/[ref]/auth/oauth-server.tsx new file mode 100644 index 0000000000000..58e88fa86364d --- /dev/null +++ b/apps/studio/pages/project/[ref]/auth/oauth-server.tsx @@ -0,0 +1,29 @@ +import { OAuthServerSettingsForm } from 'components/interfaces/Auth/OAuthApps/OAuthServerSettingsForm' +import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { PageLayout } from 'components/layouts/PageLayout/PageLayout' +import { ScaffoldContainer } from 'components/layouts/Scaffold' +import type { NextPageWithLayout } from 'types' + +const ProvidersPage: NextPageWithLayout = () => { + return ( + + + + ) +} + +ProvidersPage.getLayout = (page) => ( + + + + {page} + + + +) + +export default ProvidersPage 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 adb2080fe26dd..373744e45115b 100644 --- a/apps/studio/pages/project/[ref]/settings/api-keys/index.tsx +++ b/apps/studio/pages/project/[ref]/settings/api-keys/index.tsx @@ -1,7 +1,7 @@ import ApiKeysLayout from 'components/layouts/APIKeys/APIKeysLayout' import DefaultLayout from 'components/layouts/DefaultLayout' import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' -import { DisplayApiSettings } from 'components/ui/ProjectSettings' +import { DisplayApiSettings } from 'components/ui/ProjectSettings/DisplayApiSettings' import { ToggleLegacyApiKeysPanel } from 'components/ui/ProjectSettings/ToggleLegacyApiKeys' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import type { NextPageWithLayout } from 'types' diff --git a/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx b/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx index 8965ff4b64e45..7881db3345d15 100644 --- a/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx +++ b/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx @@ -21,7 +21,7 @@ const PageLayout: NextPageWithLayout = () => { if (!project || !projectRef) return null return ( -
+
{isError && } {isSuccess ? ( diff --git a/apps/studio/pages/sign-in-mfa.tsx b/apps/studio/pages/sign-in-mfa.tsx index 4003a9931ad1e..7c67408c4ae98 100644 --- a/apps/studio/pages/sign-in-mfa.tsx +++ b/apps/studio/pages/sign-in-mfa.tsx @@ -4,14 +4,14 @@ import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import { toast } from 'sonner' -import { useParams } from 'common' +import { getAccessToken, useParams } from 'common' import { SignInMfaForm } from 'components/interfaces/SignIn/SignInMfaForm' import SignInLayout from 'components/layouts/SignInLayout/SignInLayout' import { Loading } from 'components/ui/Loading' import { useAddLoginEvent } from 'data/misc/audit-login-mutation' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import useLatest from 'hooks/misc/useLatest' -import { auth, buildPathWithParams, getAccessToken, getReturnToPath } from 'lib/gotrue' +import { auth, buildPathWithParams, getReturnToPath } from 'lib/gotrue' import type { NextPageWithLayout } from 'types' const SignInMfaPage: NextPageWithLayout = () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b8fb8f6c994e..66acebfa293f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -826,8 +826,8 @@ importers: specifier: 'catalog:' version: 2.78.0 '@supabase/mcp-server-supabase': - specifier: ^0.5.6 - version: 0.5.6(supports-color@8.1.1) + specifier: ^0.5.8 + version: 0.5.8(supports-color@8.1.1) '@supabase/mcp-utils': specifier: ^0.2.0 version: 0.2.1(supports-color@8.1.1) @@ -855,6 +855,9 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-virtual': + specifier: ^3.13.12 + version: 3.13.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@uidotdev/usehooks': specifier: ^2.4.1 version: 2.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1029,15 +1032,6 @@ importers: react-use: specifier: ^17.5.0 version: 17.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-virtualized-auto-sizer: - specifier: ^1.0.20 - version: 1.0.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-window: - specifier: ^1.8.6 - version: 1.8.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-window-infinite-loader: - specifier: ^1.0.7 - version: 1.0.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) reactflow: specifier: ^11.10.1 version: 11.10.1(@types/react@18.3.3)(immer@10.1.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1174,15 +1168,6 @@ importers: '@types/react-simple-maps': specifier: ^3.0.1 version: 3.0.4 - '@types/react-virtualized-auto-sizer': - specifier: ^1.0.1 - version: 1.0.1 - '@types/react-window': - specifier: ^1.8.5 - version: 1.8.6 - '@types/react-window-infinite-loader': - specifier: ^1.0.5 - version: 1.0.7 '@types/recharts': specifier: ^1.8.23 version: 1.8.25 @@ -8811,15 +8796,15 @@ packages: '@supabase/functions-js@2.78.0': resolution: {integrity: sha512-t1jOvArBsOINyqaRee1xJ3gryXLvkBzqnKfi6q3YRzzhJbGS6eXz0pXR5fqmJeB01fLC+1njpf3YhMszdPEF7g==} - '@supabase/mcp-server-supabase@0.5.6': - resolution: {integrity: sha512-pLVukhxx0oxwAcvAEaJEwNngcPIEieFyd4bWK4phpXpbxqs4xp3i3f7nwrnflkSmG5PnRhLCKcurw3fwQHnCKQ==} + '@supabase/mcp-server-supabase@0.5.8': + resolution: {integrity: sha512-+kpHomRCKyK1D1nEM2s89qGAfN4zN8JrDpcyAvZd19Z/KexNvaByRKpwBWnE3GEtcEx9H+XUFM72Z5O7rC802Q==} hasBin: true '@supabase/mcp-utils@0.2.1': resolution: {integrity: sha512-T3LEAEKXOxHGVzhPvxqbAYbxluUKNxQpFnYVyRIazQJOQzZ03tCg+pp3LUYQi0HkWPIo+u+AgtULJVEvgeNr/Q==} - '@supabase/mcp-utils@0.2.2': - resolution: {integrity: sha512-hg4IR1iw2k3zdCiB5abvROSsVK/rOdUoyai3N97uG7c3NSQjWp0M6xPJEoH4TJE63pwY0oTc4eQAjXSmTlNK4Q==} + '@supabase/mcp-utils@0.2.3': + resolution: {integrity: sha512-tw+DENThCaf5PrD5VrD9Gej8K0mi76mmrILdxpCnmJSaga1jFMpOpEaIjkQBXG5YKlhUqlyKfcaqx9bTuz8Bxg==} '@supabase/node-fetch@2.6.15': resolution: {integrity: sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==} @@ -9073,8 +9058,8 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-virtual@3.13.6': - resolution: {integrity: sha512-WT7nWs8ximoQ0CDx/ngoFP7HbQF9Q2wQe4nh2NB+u2486eX3nZRE40P9g6ccCVq7ZfTSH5gFOuCoVH5DLNS/aA==} + '@tanstack/react-virtual@3.13.12': + resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -9160,8 +9145,8 @@ packages: resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} - '@tanstack/virtual-core@3.13.6': - resolution: {integrity: sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==} + '@tanstack/virtual-core@3.13.12': + resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==} '@tanstack/virtual-file-routes@1.114.12': resolution: {integrity: sha512-aR13V1kSE/kUkP4a8snmqvj82OUlR5Q/rzxICmObLCsERGfzikUc4wquOy1d/RzJgsLb8o+FiOjSWynt4T7Jhg==} @@ -9601,15 +9586,6 @@ packages: peerDependencies: '@types/react': '*' - '@types/react-virtualized-auto-sizer@1.0.1': - resolution: {integrity: sha512-GH8sAnBEM5GV9LTeiz56r4ZhMOUSrP43tAQNSRVxNexDjcNKLCEtnxusAItg1owFUFE6k0NslV26gqVClVvong==} - - '@types/react-window-infinite-loader@1.0.7': - resolution: {integrity: sha512-+CG6szhzP7akjbZ5v85yyZqS78XsJ/VVjccNFPR0bGFQf8jzL238aoNJYBP7qZDbcROIx6tiFJcT9PHzw8LAVg==} - - '@types/react-window@1.8.6': - resolution: {integrity: sha512-AVJr3A5rIO9dQQu5TwTN0lP2c1RtuqyyZGCt7PGP8e5gUpn1PuQRMJb/u3UpdbwTHh4wbEi33UMW5NI0IXt1Mg==} - '@types/react@18.3.3': resolution: {integrity: sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==} @@ -17111,26 +17087,6 @@ packages: react: '*' react-dom: '*' - react-virtualized-auto-sizer@1.0.20: - resolution: {integrity: sha512-OdIyHwj4S4wyhbKHOKM1wLSj/UDXm839Z3Cvfg2a9j+He6yDa6i5p0qQvEiCnyQlGO/HyfSnigQwuxvYalaAXA==} - peerDependencies: - react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc - react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc - - react-window-infinite-loader@1.0.9: - resolution: {integrity: sha512-5Hg89IdU4Vrp0RT8kZYKeTIxWZYhNkVXeI1HbKo01Vm/Z7qztDvXljwx16sMzsa9yapRJQW3ODZfMUw38SOWHw==} - engines: {node: '>8.0.0'} - peerDependencies: - react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 - react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 - - react-window@1.8.9: - resolution: {integrity: sha512-+Eqx/fj1Aa5WnhRfj9dJg4VYATGwIUP2ItwItiJ6zboKWA6EX3lYDAXfGF2hyNqplEprhbtjbipiADEcwQ823Q==} - engines: {node: '>8.0.0'} - peerDependencies: - react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 - react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 - react-wrap-balancer@1.1.0: resolution: {integrity: sha512-EhF3jOZm5Fjx+Cx41e423qOv2c2aOvXAtym2OHqrGeMUnwERIyNsRBgnfT3plB170JmuYvts8K2KSPEIerKr5A==} peerDependencies: @@ -22705,7 +22661,7 @@ snapshots: '@graphql-tools/executor-legacy-ws@1.1.17(graphql@16.11.0)': dependencies: '@graphql-tools/utils': 10.8.6(graphql@16.11.0) - '@types/ws': 8.5.10 + '@types/ws': 8.18.1 graphql: 16.11.0 isomorphic-ws: 5.0.0(ws@8.18.3) tslib: 2.8.1 @@ -22964,7 +22920,7 @@ snapshots: '@floating-ui/react': 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@react-aria/focus': 3.20.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@react-aria/interactions': 3.25.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@tanstack/react-virtual': 3.13.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-virtual': 3.13.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) use-sync-external-store: 1.5.0(react@18.3.1) @@ -28415,11 +28371,11 @@ snapshots: '@supabase/node-fetch': 2.6.15 tslib: 2.8.1 - '@supabase/mcp-server-supabase@0.5.6(supports-color@8.1.1)': + '@supabase/mcp-server-supabase@0.5.8(supports-color@8.1.1)': dependencies: '@mjackson/multipart-parser': 0.10.1 '@modelcontextprotocol/sdk': 1.18.0(supports-color@8.1.1) - '@supabase/mcp-utils': 0.2.2(supports-color@8.1.1) + '@supabase/mcp-utils': 0.2.3(supports-color@8.1.1) common-tags: 1.8.2 graphql: 16.11.0 openapi-fetch: 0.13.8 @@ -28435,7 +28391,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@supabase/mcp-utils@0.2.2(supports-color@8.1.1)': + '@supabase/mcp-utils@0.2.3(supports-color@8.1.1)': dependencies: '@modelcontextprotocol/sdk': 1.18.0(supports-color@8.1.1) zod: 3.25.76 @@ -28968,9 +28924,9 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@tanstack/react-virtual@3.13.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@tanstack/react-virtual@3.13.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@tanstack/virtual-core': 3.13.6 + '@tanstack/virtual-core': 3.13.12 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -29192,7 +29148,7 @@ snapshots: '@tanstack/table-core@8.21.3': {} - '@tanstack/virtual-core@3.13.6': {} + '@tanstack/virtual-core@3.13.12': {} '@tanstack/virtual-file-routes@1.114.12': {} @@ -29723,19 +29679,6 @@ snapshots: dependencies: '@types/react': 18.3.3 - '@types/react-virtualized-auto-sizer@1.0.1': - dependencies: - '@types/react': 18.3.3 - - '@types/react-window-infinite-loader@1.0.7': - dependencies: - '@types/react': 18.3.3 - '@types/react-window': 1.8.6 - - '@types/react-window@1.8.6': - dependencies: - '@types/react': 18.3.3 - '@types/react@18.3.3': dependencies: '@types/prop-types': 15.7.8 @@ -39021,23 +38964,6 @@ snapshots: ts-easing: 0.2.0 tslib: 2.6.2 - react-virtualized-auto-sizer@1.0.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - react-window-infinite-loader@1.0.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - react-window@1.8.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@babel/runtime': 7.26.10 - memoize-one: 5.2.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-wrap-balancer@1.1.0(react@18.3.1): dependencies: react: 18.3.1