diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 2b7d9e57a0f3b..9b318b31690dc 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -2639,6 +2639,10 @@ export const self_hosting: NavMenuConstant = { items: [ { name: 'Overview', url: '/guides/self-hosting' }, { name: 'Self-Hosting with Docker', url: '/guides/self-hosting/docker' }, + { + name: 'Configuration', + items: [{ name: 'Enabling MCP server', url: '/guides/self-hosting/enable-mcp' }], + }, { name: 'Auth Server', items: [ diff --git a/apps/docs/content/guides/self-hosting/enable-mcp.mdx b/apps/docs/content/guides/self-hosting/enable-mcp.mdx new file mode 100644 index 0000000000000..1549191d5bf94 --- /dev/null +++ b/apps/docs/content/guides/self-hosting/enable-mcp.mdx @@ -0,0 +1,147 @@ +--- +title: 'Enabling MCP Server Access' +description: 'Configure secure access to the MCP server in your self-hosted Supabase instance.' +subtitle: 'Configure secure access to the MCP server in your self-hosted Supabase instance.' +--- + +The MCP (Model Context Protocol) server in [self-hosted Supabase](/docs/guides/self-hosting/docker) runs behind the internal API. Currently, it does not offer OAuth 2.1 authentication, and is not intended to be exposed to the Internet. The corresponding API route has to be protected by restricting network connections from the outside. By default, all connections to the MCP server are denied. + +This guide explains how to securely enable access to your self-hosted MCP server. + +## Security considerations + + + +Do not allow connections to the self-hosted MCP server from the Internet. Only access it via: + +- A VPN connection to the server running the Studio container +- An SSH tunnel from your local machine + + + +## Accessing via SSH tunnel + +### Step 1: Determine the local IP address that will be used to access the MCP server + +When connecting via an SSH tunnel to the Studio Docker container, the source IP will be that of the Docker bridge gateway. You need to allow connections from this IP address. + +Determine the Docker bridge gateway IP on the host running your Supabase containers: + +```bash +docker inspect supabase-kong \ + --format '{{range .NetworkSettings.Networks}}{{println .Gateway}}{{end}}' +``` + +This command will output an IP address, e.g., `172.18.0.1`. + +### Step 2: Allow connections from the gateway IP + +Add the IP address you discovered to the Kong configuration by editing the following section in `./volumes/api/kong.yml`: + +1. Comment out the request-termination section +2. Remove the # symbols from the entire section starting with `- name: cors`, including `deny: []` +3. Add your local IP to the 'allow' list. +4. Your edited configuration should look like the example below. + +```yaml +## MCP endpoint - local access +- name: mcp + _comment: 'MCP: /mcp -> http://studio:3000/api/mcp (local access)' + url: http://studio:3000/api/mcp + routes: + - name: mcp + strip_path: true + paths: + - /mcp + plugins: + # Block access to /mcp by default + #- name: request-termination + # config: + # status_code: 403 + # message: "Access is forbidden." + # Enable local access (danger zone!) + # 1. Comment out the 'request-termination' section above + # 2. Uncomment the entire section below, including 'deny' + # 3. Add your local IPs to the 'allow' list + - name: cors + - name: ip-restriction + config: + allow: + - 127.0.0.1 + - ::1 + # Add your Docker bridge gateway IP below + - 172.18.0.1 + # Do not remove deny! + deny: [] +``` + +### Step 3: Restart API gateway + +After you've added the local IP address as above, restart the Kong container: + +```bash +docker compose restart kong +``` + +### Step 4: Create the SSH tunnel + +From your local machine, create an SSH tunnel to your Supabase host: + +```bash +ssh -L localhost:8080:localhost:8000 you@your-supabase-host +``` + +This command forwards local port `8080` to port `8000` on your Supabase host. + +### Step 5: Configure your MCP client + +Edit the settings for your MCP client and add the following to `"mcpServers": {}` or `"servers": {}`: + +```json +{ + "mcpServers": { + "supabase-self-hosted": { + "url": "http://localhost:8080/mcp" + } + } +} +``` + +### Step 6: Start using the self-hosted MCP server + +From your local machine, check that the MCP server is reachable: + +```bash +curl http://localhost:8080/mcp \ + -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: 2025-06-18" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": { + "elicitation": {} + }, + "clientInfo": { + "name": "test-client", + "title": "Test Client", + "version": "1.0.0" + } + } + }' +``` + +Start your MCP client (Claude Code, Cursor, etc.) and verify access to the MCP tools. For example, you can ask: "What is Supabase anon key? Use the Supabase MCP server tools." + +## Troubleshooting + +If you are unable to connect to the MCP server: + +1. Update Kong configuration file to the [latest version](https://github.com/supabase/supabase/blob/master/docker/volumes/api/kong.yml) and edit carefully +2. Confirm the Docker bridge gateway IP is correctly added in `./volumes/api/kong.yml` +3. Check Kong's logs for errors: `docker compose logs kong` +4. Make sure your SSH tunnel is active diff --git a/apps/docs/lib/userAuth.ts b/apps/docs/lib/userAuth.ts index 973c5eb8bb177..12fec885fe721 100644 --- a/apps/docs/lib/userAuth.ts +++ b/apps/docs/lib/userAuth.ts @@ -1,11 +1,6 @@ -import * as Sentry from '@sentry/nextjs' -import { gotrueClient, setCaptureException } from 'common' +import { gotrueClient } from 'common' import { useEffect } from 'react' -setCaptureException((e: any) => { - Sentry.captureException(e) -}) - export const auth = gotrueClient export async function getAccessToken() { diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 5a1292cb1b39d..3c5239228cd28 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -74,6 +74,7 @@ Ignacio Dobronich Illia Basalaiev Inian P Ivan Vasilov +Jean-Paul Argudo Jeff Smick Jenny Kibiri Jess Shears @@ -119,6 +120,7 @@ Paul Copplestone Pavel Borisov Paweł Gulbinowicz Peter Lyn +Peter Soderberg Qiao Han Rafael Chacón Raminder Singh diff --git a/apps/studio/components/interfaces/Auth/AuthTemplatesValidation.tsx b/apps/studio/components/interfaces/Auth/AuthTemplatesValidation.tsx index bf09377ebed02..7d35614d2e9dd 100644 --- a/apps/studio/components/interfaces/Auth/AuthTemplatesValidation.tsx +++ b/apps/studio/components/interfaces/Auth/AuthTemplatesValidation.tsx @@ -1,6 +1,4 @@ import { object, string } from 'yup' - -import { DOCS_URL } from 'lib/constants' import type { FormSchema } from 'types' const JSON_SCHEMA_VERSION = 'http://json-schema.org/draft-07/schema#' @@ -32,12 +30,10 @@ const CONFIRMATION: FormSchema = { }, }, validationSchema: object().shape({ - MAILER_SUBJECTS_CONFIRMATION: string().required('"Subject heading is required.'), + MAILER_SUBJECTS_CONFIRMATION: string().required('Subject heading is required.'), }), misc: { - iconKey: 'email-icon2', - helper: `To complete setup, add this authorisation callback URL to your app's configuration in the Apple Developer Console. -[Learn more](${DOCS_URL}/guides/auth/social-login/auth-apple#configure-your-services-id)`, + emailTemplateType: 'authentication', }, } @@ -68,12 +64,10 @@ const INVITE: FormSchema = { }, }, validationSchema: object().shape({ - MAILER_SUBJECTS_INVITE: string().required('"Subject heading is required.'), + MAILER_SUBJECTS_INVITE: string().required('Subject heading is required.'), }), misc: { - iconKey: 'email-icon2', - helper: `To complete setup, add this authorisation callback URL to your app's configuration in the Apple Developer Console. -[Learn more](${DOCS_URL}/guides/auth/social-login/auth-apple#configure-your-services-id)`, + emailTemplateType: 'authentication', }, } @@ -104,12 +98,10 @@ const MAGIC_LINK: FormSchema = { }, }, validationSchema: object().shape({ - MAILER_SUBJECTS_MAGIC_LINK: string().required('"Subject heading is required.'), + MAILER_SUBJECTS_MAGIC_LINK: string().required('Subject heading is required.'), }), misc: { - iconKey: 'email-icon2', - helper: `To complete setup, add this authorisation callback URL to your app's configuration in the Apple Developer Console. -[Learn more](${DOCS_URL}/guides/auth/social-login/auth-apple#configure-your-services-id)`, + emailTemplateType: 'authentication', }, } @@ -141,12 +133,10 @@ const EMAIL_CHANGE: FormSchema = { }, }, validationSchema: object().shape({ - MAILER_SUBJECTS_EMAIL_CHANGE: string().required('"Subject heading is required.'), + MAILER_SUBJECTS_EMAIL_CHANGE: string().required('Subject heading is required.'), }), misc: { - iconKey: 'email-icon2', - helper: `To complete setup, add this authorisation callback URL to your app's configuration in the Apple Developer Console. -[Learn more](${DOCS_URL}/guides/auth/social-login/auth-apple#configure-your-services-id)`, + emailTemplateType: 'authentication', }, } @@ -177,12 +167,10 @@ const RECOVERY: FormSchema = { }, }, validationSchema: object().shape({ - MAILER_SUBJECTS_RECOVERY: string().required('"Subject heading is required.'), + MAILER_SUBJECTS_RECOVERY: string().required('Subject heading is required.'), }), misc: { - iconKey: 'email-icon2', - helper: `To complete setup, add this authorisation callback URL to your app's configuration in the Apple Developer Console. -[Learn more](${DOCS_URL}/guides/auth/social-login/auth-apple#configure-your-services-id)`, + emailTemplateType: 'authentication', }, } const REAUTHENTICATION: FormSchema = { @@ -210,12 +198,229 @@ const REAUTHENTICATION: FormSchema = { }, }, validationSchema: object().shape({ - MAILER_SUBJECTS_REAUTHENTICATION: string().required('"Subject heading is required.'), + MAILER_SUBJECTS_REAUTHENTICATION: string().required('Subject heading is required.'), + }), + misc: { + emailTemplateType: 'authentication', + }, +} + +// Notifications +const PASSWORD_CHANGED_NOTIFICATION: FormSchema = { + $schema: JSON_SCHEMA_VERSION, + id: 'PASSWORD_CHANGED_NOTIFICATION', + type: 'object', + title: 'Password changed notification', + purpose: 'Notify a user when their password has been changed', + properties: { + MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION: { + title: 'Subject heading', + type: 'string', + }, + MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION_CONTENT: { + title: 'Message body', + descriptionOptional: 'HTML body of your email', + type: 'code', + description: ` +- \`{{ .Email }}\` : The user's email address +- \`{{ .Data }}\` : The user's \`user_metadata\` +`, + }, + }, + validationSchema: object().shape({ + MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION: string().required( + 'Subject heading is required.' + ), + }), + misc: { + emailTemplateType: 'security', + }, +} + +const EMAIL_CHANGED_NOTIFICATION: FormSchema = { + $schema: JSON_SCHEMA_VERSION, + id: 'EMAIL_CHANGED_NOTIFICATION', + type: 'object', + title: 'Email changed notification', + purpose: 'Notify a user when their email address has been changed', + properties: { + MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION: { + title: 'Subject heading', + type: 'string', + }, + MAILER_TEMPLATES_EMAIL_CHANGED_NOTIFICATION_CONTENT: { + title: 'Message body', + descriptionOptional: 'HTML body of your email', + type: 'code', + description: ` +- \`{{ .Email }}\` : The user's new email address +- \`{{ .OldEmail }}\` : The user's old email address +- \`{{ .Data }}\` : The user's \`user_metadata\` +`, + }, + }, + validationSchema: object().shape({ + MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION: string().required('Subject heading is required.'), + }), + misc: { + emailTemplateType: 'security', + }, +} + +const PHONE_CHANGED_NOTIFICATION: FormSchema = { + $schema: JSON_SCHEMA_VERSION, + id: 'PHONE_CHANGED_NOTIFICATION', + type: 'object', + title: 'Phone changed notification', + purpose: 'Notify a user when the phone number has been changed', + properties: { + MAILER_SUBJECTS_PHONE_CHANGED_NOTIFICATION: { + title: 'Subject heading', + type: 'string', + }, + MAILER_TEMPLATES_PHONE_CHANGED_NOTIFICATION_CONTENT: { + title: 'Message body', + descriptionOptional: 'HTML body of your email', + type: 'code', + description: ` +- \`{{ .Email }}\` : The user's email address +- \`{{ .Phone }}\` : The user's new phone number +- \`{{ .OldPhone }}\` : The user's old phone number +- \`{{ .Data }}\` : The user's \`user_metadata\` +`, + }, + }, + validationSchema: object().shape({ + MAILER_SUBJECTS_PHONE_CHANGED_NOTIFICATION: string().required('Subject heading is required.'), + }), + misc: { + emailTemplateType: 'security', + }, +} + +const IDENTITY_LINKED_NOTIFICATION: FormSchema = { + $schema: JSON_SCHEMA_VERSION, + id: 'IDENTITY_LINKED_NOTIFICATION', + type: 'object', + title: 'Identity linked notification', + purpose: 'Notify a user when a new identity has been linked to their account', + properties: { + MAILER_SUBJECTS_IDENTITY_LINKED_NOTIFICATION: { + title: 'Subject heading', + type: 'string', + }, + MAILER_TEMPLATES_IDENTITY_LINKED_NOTIFICATION_CONTENT: { + title: 'Message body', + descriptionOptional: 'HTML body of your email', + type: 'code', + description: ` +- \`{{ .Email }}\` : The user's email address +- \`{{ .Provider }}\` : The provider of the newly linked identity +- \`{{ .Data }}\` : The user's \`user_metadata\` +`, + }, + }, + validationSchema: object().shape({ + MAILER_SUBJECTS_IDENTITY_LINKED_NOTIFICATION: string().required('Subject heading is required.'), + }), + misc: { + emailTemplateType: 'security', + }, +} + +const IDENTITY_UNLINKED_NOTIFICATION: FormSchema = { + $schema: JSON_SCHEMA_VERSION, + id: 'IDENTITY_UNLINKED_NOTIFICATION', + type: 'object', + title: 'Identity unlinked notification', + purpose: 'Notify a user when an identity has been unlinked from their account', + properties: { + MAILER_SUBJECTS_IDENTITY_UNLINKED_NOTIFICATION: { + title: 'Subject heading', + type: 'string', + }, + MAILER_TEMPLATES_IDENTITY_UNLINKED_NOTIFICATION_CONTENT: { + title: 'Message body', + descriptionOptional: 'HTML body of your email', + type: 'code', + description: ` +- \`{{ .Email }}\` : The user's email address +- \`{{ .Provider }}\` : The provider of the unlinked identity +- \`{{ .Data }}\` : The user's \`user_metadata\` +`, + }, + }, + validationSchema: object().shape({ + MAILER_SUBJECTS_IDENTITY_UNLINKED_NOTIFICATION: string().required( + 'Subject heading is required.' + ), + }), + misc: { + emailTemplateType: 'security', + }, +} + +const MFA_FACTOR_ENROLLED_NOTIFICATION: FormSchema = { + $schema: JSON_SCHEMA_VERSION, + id: 'MFA_FACTOR_ENROLLED_NOTIFICATION', + type: 'object', + title: 'MFA factor enrolled notification', + purpose: 'Notify a user when a new MFA factor has been enrolled for their account', + properties: { + MAILER_SUBJECTS_MFA_FACTOR_ENROLLED_NOTIFICATION: { + title: 'Subject heading', + type: 'string', + }, + MAILER_TEMPLATES_MFA_FACTOR_ENROLLED_NOTIFICATION_CONTENT: { + title: 'Message body', + descriptionOptional: 'HTML body of your email', + type: 'code', + description: ` +- \`{{ .Email }}\` : The user's email address +- \`{{ .FactorType }}\` : The type of the newly enrolled MFA factor +- \`{{ .Data }}\` : The user's \`user_metadata\` +`, + }, + }, + validationSchema: object().shape({ + MAILER_SUBJECTS_MFA_FACTOR_ENROLLED_NOTIFICATION: string().required( + 'Subject heading is required.' + ), + }), + misc: { + emailTemplateType: 'security', + }, +} + +const MFA_FACTOR_UNENROLLED_NOTIFICATION: FormSchema = { + $schema: JSON_SCHEMA_VERSION, + id: 'MFA_FACTOR_UNENROLLED_NOTIFICATION', + type: 'object', + title: 'MFA factor unenrolled notification', + purpose: 'Notify a user when an MFA factor has been unenrolled from their account', + properties: { + MAILER_SUBJECTS_MFA_FACTOR_UNENROLLED_NOTIFICATION: { + title: 'Subject heading', + type: 'string', + }, + MAILER_TEMPLATES_MFA_FACTOR_UNENROLLED_NOTIFICATION_CONTENT: { + title: 'Message body', + descriptionOptional: 'HTML body of your email', + type: 'code', + description: ` +- \`{{ .Email }}\` : The user's email address +- \`{{ .FactorType }}\` : The type of the newly enrolled MFA factor +- \`{{ .Data }}\` : The user's \`user_metadata\` +`, + }, + }, + validationSchema: object().shape({ + MAILER_SUBJECTS_MFA_FACTOR_UNENROLLED_NOTIFICATION: string().required( + 'Subject heading is required.' + ), }), misc: { - iconKey: 'email-icon2', - helper: `To complete setup, add this authorisation callback URL to your app's configuration in the Apple Developer Console. -[Learn more](${DOCS_URL}/guides/auth/social-login/auth-apple#configure-your-services-id)`, + emailTemplateType: 'security', }, } @@ -226,4 +431,12 @@ export const TEMPLATES_SCHEMAS = [ EMAIL_CHANGE, RECOVERY, REAUTHENTICATION, + // Notifications + PASSWORD_CHANGED_NOTIFICATION, + EMAIL_CHANGED_NOTIFICATION, + PHONE_CHANGED_NOTIFICATION, + IDENTITY_LINKED_NOTIFICATION, + IDENTITY_UNLINKED_NOTIFICATION, + MFA_FACTOR_ENROLLED_NOTIFICATION, + MFA_FACTOR_UNENROLLED_NOTIFICATION, ] diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx index 1f5c9921a48bd..26ddf458cbb89 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx @@ -1,14 +1,29 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { ChevronRight } from 'lucide-react' +import Link from 'next/link' +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { z } from 'zod' + import { useParams } from 'common' import { useIsSecurityNotificationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' -import { ScaffoldSection } from 'components/layouts/Scaffold' +import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold' import AlertError from 'components/ui/AlertError' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useAuthConfigQuery } from 'data/auth/auth-config-query' -import { ChevronRight } from 'lucide-react' -import Link from 'next/link' +import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { + Button, Card, CardContent, + CardFooter, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + Switch, Tabs_Shadcn_, TabsContent_Shadcn_, TabsList_Shadcn_, @@ -17,11 +32,32 @@ import { import { TEMPLATES_SCHEMAS } from '../AuthTemplatesValidation' import EmailRateLimitsAlert from '../EmailRateLimitsAlert' import { slugifyTitle } from './EmailTemplates.utils' -import TemplateEditor from './TemplateEditor' +import { TemplateEditor } from './TemplateEditor' + +const notificationEnabledKeys = TEMPLATES_SCHEMAS.filter( + (t) => t.misc?.emailTemplateType === 'security' +).map((template) => { + return `MAILER_NOTIFICATIONS_${template.id?.replace('_NOTIFICATION', '')}_ENABLED` +}) + +const NotificationsFormSchema = z.object({ + ...notificationEnabledKeys.reduce( + (acc, key) => { + acc[key] = z.boolean() + return acc + }, + {} as Record + ), +}) export const EmailTemplates = () => { - const isSecurityNotificationsEnabled = useIsSecurityNotificationsEnabled() const { ref: projectRef } = useParams() + const isSecurityNotificationsEnabled = useIsSecurityNotificationsEnabled() + const { can: canUpdateConfig } = useAsyncCheckPermissions( + PermissionAction.UPDATE, + 'custom_config_gotrue' + ) + const { data: authConfig, error: authConfigError, @@ -30,11 +66,45 @@ export const EmailTemplates = () => { isSuccess, } = useAuthConfigQuery({ projectRef }) + const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation({ + onError: (error) => { + toast.error(`Failed to update settings: ${error?.message}`) + }, + onSuccess: () => { + toast.success('Successfully updated settings') + }, + }) + const builtInSMTP = isSuccess && authConfig && (!authConfig.SMTP_HOST || !authConfig.SMTP_USER || !authConfig.SMTP_PASS) + const defaultValues = notificationEnabledKeys.reduce( + (acc, key) => { + acc[key] = authConfig ? Boolean(authConfig[key as keyof typeof authConfig]) : false + return acc + }, + {} as Record + ) + + const notificationsForm = useForm>({ + resolver: zodResolver(NotificationsFormSchema), + defaultValues, + }) + + const onSubmit = (values: any) => { + if (!projectRef) return console.error('Project ref is required') + updateAuthConfig({ projectRef: projectRef, config: { ...values } }) + } + + useEffect(() => { + if (authConfig) { + notificationsForm.reset(defaultValues) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authConfig]) + return ( {isError && ( @@ -52,51 +122,145 @@ export const EmailTemplates = () => { {isSuccess && (
{builtInSMTP ? ( -
+
) : null} {isSecurityNotificationsEnabled ? ( - - {TEMPLATES_SCHEMAS.map((template) => { - const templateSlug = slugifyTitle(template.title) - return ( - - -
-

{template.title}

- {template.purpose && ( -

{template.purpose}

+
+
+ Authentication + + {TEMPLATES_SCHEMAS.filter( + (t) => t.misc?.emailTemplateType === 'authentication' + ).map((template) => { + const templateSlug = slugifyTitle(template.title) + + return ( + + +
+

{template.title}

+ {template.purpose && ( +

{template.purpose}

+ )} +
+ +
+ +
+ +
+ ) + })} +
+
+ +
+ Security + +
+ + {TEMPLATES_SCHEMAS.filter( + (t) => t.misc?.emailTemplateType === 'security' + ).map((template) => { + const templateSlug = slugifyTitle(template.title) + const templateEnabledKey = + `MAILER_NOTIFICATIONS_${template.id?.replace('_NOTIFICATION', '')}_ENABLED` as keyof typeof authConfig + + return ( + + +

{template.title}

+ {template.purpose && ( +

+ {template.purpose} +

+ )} + + +
+ ( + + + + )} + /> + + + + +
+
+ ) + })} + + {notificationsForm.formState.isDirty && ( + )} -
- - - - ) - })} - + + + + + +
+
) : ( - {TEMPLATES_SCHEMAS.map((template) => { - return ( - - {template.title} - - ) - })} + {TEMPLATES_SCHEMAS.filter( + (t) => t.misc?.emailTemplateType === 'authentication' + ).map((template) => ( + + {template.title} + + ))} - {TEMPLATES_SCHEMAS.map((template) => { + {TEMPLATES_SCHEMAS.filter( + (t) => t.misc?.emailTemplateType === 'authentication' + ).map((template) => { const panelId = slugifyTitle(template.title) return ( diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx index 06d3619bc9f70..89d69e61ed8f1 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx @@ -1,6 +1,7 @@ +import { Check, MailWarning } from 'lucide-react' + import { Markdown } from 'components/interfaces/Markdown' import { ValidateSpamResponse } from 'data/auth/validate-spam-mutation' -import { Check, MailWarning } from 'lucide-react' import { Separator, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' interface SpamValidationProps { diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx index e70e547934be4..47334435239c0 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx @@ -38,7 +38,7 @@ interface TemplateEditorProps { template: FormSchema } -const TemplateEditor = ({ template }: TemplateEditorProps) => { +export const TemplateEditor = ({ template }: TemplateEditorProps) => { const { ref: projectRef } = useParams() const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, @@ -383,5 +383,3 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => { ) } - -export default TemplateEditor diff --git a/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx b/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx index d91ae01541efc..2865b092f7b75 100644 --- a/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx +++ b/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx @@ -14,7 +14,6 @@ import { basename } from 'path' import { Card, CardContent, CardHeader, CardTitle, cn, Skeleton } from 'ui' const EMPTY_FUNCTION_BODY: EdgeFunctionBodyData = { - version: 0, files: EMPTY_ARR, } @@ -95,8 +94,12 @@ const FunctionDiff = ({ const language = useMemo(() => { if (!activeFileKey) return 'plaintext' - if (activeFileKey.endsWith('.ts') || activeFileKey.endsWith('.tsx')) return 'typescript' - if (activeFileKey.endsWith('.js') || activeFileKey.endsWith('.jsx')) return 'javascript' + if (activeFileKey.endsWith('.ts') || activeFileKey.endsWith('.tsx')) { + return 'typescript' + } + if (activeFileKey.endsWith('.js') || activeFileKey.endsWith('.jsx')) { + return 'javascript' + } if (activeFileKey.endsWith('.json')) return 'json' if (activeFileKey.endsWith('.sql')) return 'sql' return 'plaintext' diff --git a/apps/studio/components/interfaces/Connect/Connect.tsx b/apps/studio/components/interfaces/Connect/Connect.tsx index 446c704d3769b..792661303b9d0 100644 --- a/apps/studio/components/interfaces/Connect/Connect.tsx +++ b/apps/studio/components/interfaces/Connect/Connect.tsx @@ -353,7 +353,7 @@ export const Connect = () => { {connectionTypes.length === 1 ? ` via ${connectionTypes[0].label.toLowerCase()}` : null} - Get the connection strings and environment variables for your app + Get the connection strings and environment variables for your app. diff --git a/apps/studio/components/interfaces/Connect/ConnectionPanel.tsx b/apps/studio/components/interfaces/Connect/ConnectionPanel.tsx index da75b672c078e..abd8f1bad7224 100644 --- a/apps/studio/components/interfaces/Connect/ConnectionPanel.tsx +++ b/apps/studio/components/interfaces/Connect/ConnectionPanel.tsx @@ -15,6 +15,7 @@ import { Collapsible_Shadcn_, CollapsibleContent_Shadcn_, CollapsibleTrigger_Shadcn_, + Separator, WarningIcon, } from 'ui' import { Admonition } from 'ui-patterns' @@ -25,6 +26,7 @@ interface ConnectionPanelProps { badge?: string title: string description: string + contentFooter?: ReactNode connectionString: string ipv4Status: { type: 'error' | 'success' @@ -105,6 +107,7 @@ export const ConnectionPanel = ({ badge, title, description, + contentFooter, connectionString, ipv4Status, notice, @@ -123,16 +126,24 @@ export const ConnectionPanel = ({ const links = ipv4Status.links ?? [] + const isTransactionDedicatedPooler = type === 'transaction' && badge === 'Dedicated Pooler' + return (

{title}

- {!!badge && {badge}} + {!!badge && !isTransactionDedicatedPooler && {badge}}

{description}

+ {contentFooter}
+ {isTransactionDedicatedPooler && ( +
+ Using the Dedicated Pooler: +
+ )}
{fileTitle && } {type === 'transaction' && isSessionMode ? ( @@ -177,7 +188,6 @@ export const ConnectionPanel = ({ {parameters.length > 0 && } )} - {children}
{IS_PLATFORM && ( @@ -278,6 +288,8 @@ export const ConnectionPanel = ({ )}
+ {isTransactionDedicatedPooler && } + {children}
) diff --git a/apps/studio/components/interfaces/Connect/ConnectionParameters.tsx b/apps/studio/components/interfaces/Connect/ConnectionParameters.tsx index d3654d9377cd9..8edc2c3335842 100644 --- a/apps/studio/components/interfaces/Connect/ConnectionParameters.tsx +++ b/apps/studio/components/interfaces/Connect/ConnectionParameters.tsx @@ -28,7 +28,7 @@ export const ConnectionParameters = ({ parameters }: ConnectionParametersProps) + + + handleCopy(selectedTab, 'transaction_pooler')} + /> +

+ Only recommended when your network does not support IPv6. Added latency + compared to dedicated pooler. +

+
+
+ )} + )} {selectedMethod === 'session' && IS_PLATFORM && ( @@ -587,14 +646,30 @@ const ConnectionStringMethodSelectItem = ({ poolerBadge, }: { method: ConnectionStringMethod - poolerBadge: string -}) => ( - -
-
{connectionStringMethodOptions[method].label}
-
- {connectionStringMethodOptions[method].description} + poolerBadge?: string +}) => { + const badges: ReactNode[] = [] + + if (method !== 'direct') { + badges.push(Shared Pooler) + } + if (poolerBadge === 'Dedicated Pooler') { + badges.push({poolerBadge}) + } + + return ( + +
+
+ {connectionStringMethodOptions[method].label} +
+
+ {connectionStringMethodOptions[method].description} +
+
+ {badges.map((badge) => badge)} +
-
- -) + + ) +} diff --git a/apps/studio/components/interfaces/EdgeFunctions/DeployEdgeFunctionWarningModal.tsx b/apps/studio/components/interfaces/EdgeFunctions/DeployEdgeFunctionWarningModal.tsx index dd143783bab6e..06ef8e594125b 100644 --- a/apps/studio/components/interfaces/EdgeFunctions/DeployEdgeFunctionWarningModal.tsx +++ b/apps/studio/components/interfaces/EdgeFunctions/DeployEdgeFunctionWarningModal.tsx @@ -17,7 +17,7 @@ export const DeployEdgeFunctionWarningModal = ({ { ) const snap = useAiAssistantStateSnapshot() const { openSidebar } = useSidebarManagerSnapshot() + const { setSelectedItemId } = useAdvisorStateSnapshot() const securityLints = useMemo( () => (lints ?? []).filter((lint: Lint) => lint.categories.includes('SECURITY')), @@ -76,6 +78,14 @@ export const AdvisorWidget = () => { [slowestQueriesData] ) + const handleLintClick = useCallback( + (lint: Lint) => { + setSelectedItemId(lint.cache_key) + openSidebar(SIDEBAR_KEYS.ADVISOR_PANEL) + }, + [setSelectedItemId, openSidebar] + ) + const totalIssues = securityErrorCount + securityWarningCount + performanceErrorCount + performanceWarningCount const hasErrors = securityErrorCount > 0 || performanceErrorCount > 0 @@ -134,15 +144,15 @@ export const AdvisorWidget = () => { className="text-sm w-full border-b my-0 last:border-b-0 group px-4 " >
- handleLintClick(lint)} + className="flex items-center gap-2 transition truncate flex-1 min-w-0 py-3 text-left" >

{lintText.replace(/\\`/g, '`')}

- + { openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) snap.newChat({ name: 'Summarize lint', - initialInput: `Summarize the issue and suggest fixes for the following lint item: - Title: ${lintInfoMap.find((item) => item.name === lint.name)?.title ?? lint.title} - Entity: ${(lint.metadata && (lint.metadata.entity || (lint.metadata.schema && lint.metadata.name && `${lint.metadata.schema}.${lint.metadata.name}`))) ?? 'N/A'} - Schema: ${lint.metadata?.schema ?? 'N/A'} - Issue Details: ${lint.detail ? lint.detail.replace(/\`/g, '`') : 'N/A'} - Description: ${lint.description ? lint.description.replace(/\`/g, '`') : 'N/A'}`, + initialInput: createLintSummaryPrompt(lint), }) }} tooltip={{ - content: { side: 'bottom', text: 'What is this issue?' }, + content: { side: 'bottom', text: 'Help me fix this issue' }, }} />
diff --git a/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx b/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx index 492e484a6618c..3bccc7f130b0f 100644 --- a/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx +++ b/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx @@ -1,34 +1,18 @@ import { BarChart, Shield } from 'lucide-react' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo } from 'react' import { useParams } from 'common' -import LintDetail from 'components/interfaces/Linter/LintDetail' import { LINTER_LEVELS } from 'components/interfaces/Linter/Linter.constants' -import { - createLintSummaryPrompt, - LintCategoryBadge, - lintInfoMap, -} from 'components/interfaces/Linter/Linter.utils' +import { createLintSummaryPrompt } from 'components/interfaces/Linter/Linter.utils' import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { Lint, useProjectLintsQuery } from 'data/lint/lint-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { useAdvisorStateSnapshot } from 'state/advisor-state' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' -import { - AiIconAnimation, - Button, - Card, - CardContent, - CardHeader, - CardTitle, - Sheet, - SheetContent, - SheetHeader, - SheetSection, - SheetTitle, -} from 'ui' +import { AiIconAnimation, Button, Card, CardContent, CardHeader, CardTitle } from 'ui' import { Row } from 'ui-patterns' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' @@ -46,8 +30,7 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo const { mutate: sendEvent } = useSendEventMutation() const { data: organization } = useSelectedOrganizationQuery() const { openSidebar } = useSidebarManagerSnapshot() - - const [selectedLint, setSelectedLint] = useState(null) + const { setSelectedItemId } = useAdvisorStateSnapshot() const errorLints: Lint[] = useMemo(() => { return lints?.filter((lint) => lint.level === LINTER_LEVELS.ERROR) ?? [] @@ -84,7 +67,8 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo const handleCardClick = useCallback( (lint: Lint) => { - setSelectedLint(lint) + setSelectedItemId(lint.cache_key) + openSidebar(SIDEBAR_KEYS.ADVISOR_PANEL) if (projectRef && organization?.slug) { sendEvent({ action: 'home_advisor_issue_card_clicked', @@ -100,7 +84,7 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo }) } }, - [sendEvent, projectRef, organization] + [sendEvent, setSelectedItemId, openSidebar, projectRef, organization, totalErrors] ) if (showEmptyState) { @@ -185,32 +169,6 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo ) })} - setSelectedLint(null)}> - - {selectedLint && ( - <> - -
- - {lintInfoMap.find((item) => item.name === selectedLint.name)?.title ?? - 'Unknown'} - - -
-
- - {selectedLint && projectRef && ( - setSelectedLint(null)} - /> - )} - - - )} -
-
) : ( diff --git a/apps/studio/components/interfaces/Linter/Linter.utils.tsx b/apps/studio/components/interfaces/Linter/Linter.utils.tsx index 84d512d441ee4..5f9b61f65786b 100644 --- a/apps/studio/components/interfaces/Linter/Linter.utils.tsx +++ b/apps/studio/components/interfaces/Linter/Linter.utils.tsx @@ -312,7 +312,7 @@ export const LintCTA = ({ return ( diff --git a/apps/studio/components/layouts/AdvisorsLayout/AdvisorsLayout.tsx b/apps/studio/components/layouts/AdvisorsLayout/AdvisorsLayout.tsx index 14635287b9335..3daa22f996d06 100644 --- a/apps/studio/components/layouts/AdvisorsLayout/AdvisorsLayout.tsx +++ b/apps/studio/components/layouts/AdvisorsLayout/AdvisorsLayout.tsx @@ -1,21 +1,15 @@ import { useRouter } from 'next/router' import { PropsWithChildren } from 'react' -import { useIsAdvisorRulesEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' -import { ProductMenu } from 'components/ui/ProductMenu' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { withAuth } from 'hooks/misc/withAuth' import { ProjectLayout } from '../ProjectLayout/ProjectLayout' -import { generateAdvisorsMenu } from './AdvisorsMenu.utils' +import { AdvisorsSidebarMenu } from './AdvisorsSidebarMenu' export interface AdvisorsLayoutProps { title?: string } const AdvisorsLayout = ({ children }: PropsWithChildren) => { - const { data: project } = useSelectedProjectQuery() - const advisorRules = useIsAdvisorRulesEnabled() - const router = useRouter() const page = router.pathname.split('/')[4] @@ -23,9 +17,7 @@ const AdvisorsLayout = ({ children }: PropsWithChildren) => - } + productMenu={} > {children} diff --git a/apps/studio/components/layouts/AdvisorsLayout/AdvisorsSidebarMenu.tsx b/apps/studio/components/layouts/AdvisorsLayout/AdvisorsSidebarMenu.tsx new file mode 100644 index 0000000000000..06e6395b4ae8a --- /dev/null +++ b/apps/studio/components/layouts/AdvisorsLayout/AdvisorsSidebarMenu.tsx @@ -0,0 +1,40 @@ +import { ProductMenu } from 'components/ui/ProductMenu' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { Badge, Button } from 'ui' +import { FeaturePreviewSidebarPanel } from '../../ui/FeaturePreviewSidebarPanel' +import { generateAdvisorsMenu } from './AdvisorsMenu.utils' +import { useIsAdvisorRulesEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' +import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' + +interface AdvisorsSidebarMenuProps { + page?: string +} + +export function AdvisorsSidebarMenu({ page }: AdvisorsSidebarMenuProps) { + const { data: project } = useSelectedProjectQuery() + const advisorRules = useIsAdvisorRulesEnabled() + const { toggleSidebar } = useSidebarManagerSnapshot() + + const handleOpenAdvisor = () => { + toggleSidebar(SIDEBAR_KEYS.ADVISOR_PANEL) + } + + return ( +
+ New Location} + actions={ + + } + /> + + +
+ ) +} diff --git a/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx b/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx new file mode 100644 index 0000000000000..6b3c3c3fccc91 --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx @@ -0,0 +1,54 @@ +import { Lightbulb } from 'lucide-react' + +import { useParams } from 'common' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { useProjectLintsQuery } from 'data/lint/lint-query' +import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' +import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { cn } from 'ui' + +export const AdvisorButton = () => { + const { ref: projectRef } = useParams() + const { toggleSidebar, activeSidebar } = useSidebarManagerSnapshot() + const { data: lints } = useProjectLintsQuery({ projectRef }) + + const hasCriticalIssues = Array.isArray(lints) && lints.some((lint) => lint.level === 'ERROR') + + const isOpen = activeSidebar?.id === SIDEBAR_KEYS.ADVISOR_PANEL + + const handleClick = () => { + toggleSidebar(SIDEBAR_KEYS.ADVISOR_PANEL) + } + + return ( +
+ + + + {hasCriticalIssues && ( + + )} +
+ ) +} diff --git a/apps/studio/components/layouts/AppLayout/AssistantButton.tsx b/apps/studio/components/layouts/AppLayout/AssistantButton.tsx index d9e8b835dd9fb..8f550e237864b 100644 --- a/apps/studio/components/layouts/AppLayout/AssistantButton.tsx +++ b/apps/studio/components/layouts/AppLayout/AssistantButton.tsx @@ -2,23 +2,28 @@ import { LOCAL_STORAGE_KEYS } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' -import { AiIconAnimation, KeyboardShortcut } from 'ui' -import { SIDEBAR_KEYS } from '../ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' +import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { AiIconAnimation, cn, KeyboardShortcut } from 'ui' export const AssistantButton = () => { - const { toggleSidebar } = useSidebarManagerSnapshot() + const { activeSidebar, toggleSidebar } = useSidebarManagerSnapshot() const [isAIAssistantHotkeyEnabled] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.AI_ASSISTANT), true ) + const isOpen = activeSidebar?.id === SIDEBAR_KEYS.AI_ASSISTANT + return ( { toggleSidebar(SIDEBAR_KEYS.AI_ASSISTANT) }} @@ -33,7 +38,11 @@ export const AssistantButton = () => { }, }} > - + ) } diff --git a/apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx b/apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx index 57c39df7a54c6..db0a6d6bffa2d 100644 --- a/apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx +++ b/apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx @@ -1,27 +1,44 @@ +import { LOCAL_STORAGE_KEYS } from 'common' +import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { SqlEditor } from 'icons' -import { KeyboardShortcut } from 'ui' +import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' +import { cn, KeyboardShortcut } from 'ui' + +const InlineEditorKeyboardTooltip = () => { + const [hotkeyEnabled] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.EDITOR_PANEL), + true + ) + + return hotkeyEnabled ? : null +} + +export const InlineEditorButton = () => { + const { activeSidebar, toggleSidebar } = useSidebarManagerSnapshot() + const isOpen = activeSidebar?.id === SIDEBAR_KEYS.EDITOR_PANEL + + const handleClick = () => { + toggleSidebar(SIDEBAR_KEYS.EDITOR_PANEL) + } -export const InlineEditorButton = ({ - onClick, - showShortcut = true, -}: { - onClick: () => void - showShortcut?: boolean -}) => { return ( SQL Editor - {showShortcut && } +
), }, diff --git a/apps/studio/components/layouts/DefaultLayout.tsx b/apps/studio/components/layouts/DefaultLayout.tsx index b72a61f215255..8763b5e1b00a1 100644 --- a/apps/studio/components/layouts/DefaultLayout.tsx +++ b/apps/studio/components/layouts/DefaultLayout.tsx @@ -12,6 +12,7 @@ import { SidebarProvider } from 'ui' import { LayoutHeader } from './ProjectLayout/LayoutHeader' import MobileNavigationBar from './ProjectLayout/NavigationBar/MobileNavigationBar' import { ProjectContextProvider } from './ProjectLayout/ProjectContext' +import { LayoutSidebarProvider } from './ProjectLayout/LayoutSidebar/LayoutSidebarProvider' export interface DefaultLayoutProps { headerTitle?: string @@ -55,29 +56,31 @@ const DefaultLayout = ({ return ( - -
- {/* Top Banner */} - -
- - + + +
+ {/* Top Banner */} + +
+ + +
+ {/* Main Content Area */} +
+ {/* Sidebar - Only show for project pages, not account pages */} + {!router.pathname.startsWith('/account') && } + {/* Main Content */} +
{children}
+
- {/* Main Content Area */} -
- {/* Sidebar - Only show for project pages, not account pages */} - {!router.pathname.startsWith('/account') && } - {/* Main Content */} -
{children}
-
-
- + + ) diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx index c73d79a056201..824965c256c33 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx @@ -25,8 +25,8 @@ export const FeedbackDropdown = ({ className }: { className?: string }) => { setIsOpen((isOpen) => !isOpen) setStage('select') }} - type="outline" - className="rounded-full h-[32px] border-border" + type="text" + className="rounded-full h-[32px] text-foreground-light hover:text-foreground" > Feedback diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPopover.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPopover.tsx index 02b69a0b7a18f..3b8c00a168705 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPopover.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPopover.tsx @@ -1,6 +1,7 @@ import { Activity, BookOpen, HelpCircle, Mail, Wrench } from 'lucide-react' import Image from 'next/legacy/image' import { useRouter } from 'next/router' +import { useState } from 'react' import SVG from 'react-inlinesvg' import { IS_PLATFORM } from 'common' @@ -17,12 +18,13 @@ import { Button, ButtonGroup, ButtonGroupItem, + cn, Popover, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, } from 'ui' -import { SIDEBAR_KEYS } from '../LayoutSidebar/LayoutSidebarProvider' +import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' export const HelpPopover = () => { const router = useRouter() @@ -30,33 +32,39 @@ export const HelpPopover = () => { const { data: org } = useSelectedOrganizationQuery() const snap = useAiAssistantStateSnapshot() const { openSidebar } = useSidebarManagerSnapshot() - const { mutate: sendEvent } = useSendEventMutation() + const [isOpen, setIsOpen] = useState(false) const projectRef = project?.parent_project_ref ?? (router.query.ref as string | undefined) return ( - + - } - tooltip={{ content: { side: 'bottom', text: 'Help' } }} + type="outline" + size="tiny" + className={cn( + 'rounded-full w-[32px] h-[32px] flex items-center justify-center p-0 group', + isOpen && 'bg-foreground text-background' + )} onClick={() => { sendEvent({ action: 'help_button_clicked', groups: { project: project?.ref, organization: org?.slug }, }) }} - /> + tooltip={{ content: { side: 'bottom', text: 'Help' } }} + > + +
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx index b7128895db8fe..f4142e8ae02d9 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx @@ -3,7 +3,7 @@ import { ChevronLeft } from 'lucide-react' import Link from 'next/link' import { ReactNode, useMemo } from 'react' -import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { useParams } from 'common' import { useIsBranching2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { Connect } from 'components/interfaces/Connect/Connect' import { LocalDropdown } from 'components/interfaces/LocalDropdown' @@ -15,15 +15,12 @@ import { OrganizationDropdown } from 'components/layouts/AppLayout/OrganizationD import { ProjectDropdown } from 'components/layouts/AppLayout/ProjectDropdown' import { getResourcesExceededLimitsOrg } from 'components/ui/OveragesBanner/OveragesBanner.utils' import { useOrgUsageQuery } from 'data/usage/org-usage-query' -import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { IS_PLATFORM } from 'lib/constants' import { useRouter } from 'next/router' import { useAppStateSnapshot } from 'state/app-state' -import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import { Badge, cn } from 'ui' -import { SIDEBAR_KEYS } from '../LayoutSidebar/LayoutSidebarProvider' import { BreadcrumbsView } from './BreadcrumbsView' import { FeedbackDropdown } from './FeedbackDropdown/FeedbackDropdown' import { HelpPopover } from './HelpPopover' @@ -31,6 +28,8 @@ import { HomeIcon } from './HomeIcon' import { LocalVersionPopover } from './LocalVersionPopover' import MergeRequestButton from './MergeRequestButton' import { NotificationsPopoverV2 } from './NotificationsPopoverV2/NotificationsPopover' +import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' +import { AdvisorButton } from 'components/layouts/AppLayout/AdvisorButton' const LayoutHeaderDivider = ({ className, ...props }: React.HTMLProps) => ( @@ -71,13 +70,8 @@ const LayoutHeader = ({ const { data: selectedOrganization } = useSelectedOrganizationQuery() const { setMobileMenuOpen } = useAppStateSnapshot() const gitlessBranching = useIsBranching2Enabled() - const { toggleSidebar } = useSidebarManagerSnapshot() const isAccountPage = router.pathname.startsWith('/account') - const [inlineEditorHotkeyEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.EDITOR_PANEL), - true - ) // We only want to query the org usage and check for possible over-ages for plans without usage billing enabled (free or pro with spend cap) const { data: orgUsage } = useOrgUsageQuery( @@ -216,16 +210,14 @@ const LayoutHeader = ({ <> -
+
{!!projectRef && ( <> - toggleSidebar(SIDEBAR_KEYS.EDITOR_PANEL)} - showShortcut={inlineEditorHotkeyEnabled} - /> + + )} @@ -236,14 +228,12 @@ const LayoutHeader = ({ ) : ( <> -
+
{!!projectRef && ( <> - toggleSidebar(SIDEBAR_KEYS.EDITOR_PANEL)} - showShortcut={inlineEditorHotkeyEnabled} - /> + + )} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx index bf3b1dd16a3e3..17cfdcb8d34c5 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx @@ -99,29 +99,34 @@ export const NotificationsPopoverV2 = () => { text: 'Notifications', }, }} - type="text" - className={cn('rounded-none h-[30px] w-[32px] group relative')} + type="outline" + size="tiny" + className={cn( + 'rounded-full w-[32px] h-[32px] flex items-center justify-center p-0 group', + open && 'bg-foreground text-background' + )} icon={
{hasCritical && ( -
+
)} {hasWarning && !hasCritical && ( -
+
)} {!!hasNewNotifications && !hasCritical && !hasWarning && ( -
+
)} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx index bc946ffbabe76..85b8d86d5ea0b 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx @@ -1,20 +1,49 @@ import { useRouter } from 'next/router' import { PropsWithChildren, useEffect } from 'react' -import { useRegisterSidebar, useSidebarManagerSnapshot } from 'state/sidebar-manager-state' + +import { AdvisorPanel } from 'components/ui/AdvisorPanel/AdvisorPanel' import { AIAssistant } from 'components/ui/AIAssistantPanel/AIAssistant' import { EditorPanel } from 'components/ui/EditorPanel/EditorPanel' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useRegisterSidebar, useSidebarManagerSnapshot } from 'state/sidebar-manager-state' export const SIDEBAR_KEYS = { AI_ASSISTANT: 'ai-assistant', EDITOR_PANEL: 'editor-panel', + ADVISOR_PANEL: 'advisor-panel', } as const +// LayoutSidebars are meant to be used within a project, but rendered within DefaultLayout +// to prevent unnecessary registering / unregistering of sidebars with every route change export const LayoutSidebarProvider = ({ children }: PropsWithChildren) => { + const { data: project } = useSelectedProjectQuery() + const { data: org } = useSelectedOrganizationQuery() + const { mutate: sendEvent } = useSendEventMutation() + useRegisterSidebar(SIDEBAR_KEYS.AI_ASSISTANT, () => , {}, 'i') useRegisterSidebar(SIDEBAR_KEYS.EDITOR_PANEL, () => , {}, 'e') + useRegisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL, () => ) const router = useRouter() - const { openSidebar } = useSidebarManagerSnapshot() + const { openSidebar, activeSidebar } = useSidebarManagerSnapshot() + + useEffect(() => { + if (!!project && activeSidebar) { + // add event tracking + sendEvent({ + action: 'sidebar_opened', + properties: { + sidebar: activeSidebar.id as (typeof SIDEBAR_KEYS)[keyof typeof SIDEBAR_KEYS], + }, + groups: { + project: project?.ref ?? 'Unknown', + organization: org?.slug ?? 'Unknown', + }, + }) + } + }, [activeSidebar]) // Handle sidebar URL parameter useEffect(() => { diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index 507121413df39..cb259ffca56f6 100644 --- a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -21,7 +21,6 @@ import { useEditorType } from '../editors/EditorsLayout.hooks' import BuildingState from './BuildingState' import ConnectingState from './ConnectingState' import { LayoutSidebar } from './LayoutSidebar' -import { LayoutSidebarProvider } from './LayoutSidebar/LayoutSidebarProvider' import { LoadingState } from './LoadingState' import { ProjectPausedState } from './PausedState/ProjectPausedState' import { PauseFailedState } from './PauseFailedState' @@ -216,9 +215,7 @@ export const ProjectLayout = forwardRef - - - + diff --git a/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx b/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx index 0785b981c5c45..4b0ce98a50ab0 100644 --- a/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx +++ b/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx @@ -86,9 +86,24 @@ const SignInLayout = ({ } | null>(null) useEffect(() => { - const randomQuote = tweets[Math.floor(Math.random() * tweets.length)] - - setQuote(randomQuote) + // Weighted random selection + // Calculate total weight (default weight is fallbackWeight for tweets without weight specified) + const fallbackWeight = 1 + const totalWeight = tweets.reduce((sum, tweet) => sum + (tweet.weight ?? fallbackWeight), 0) + + // Generate random number between 0 and totalWeight + const random = Math.random() * totalWeight + + // Find the selected tweet based on cumulative weights + let accumulatedWeight = 0 + for (const tweet of tweets) { + const weight = tweet.weight ?? fallbackWeight + accumulatedWeight += weight + if (random <= accumulatedWeight) { + setQuote(tweet) + break + } + } }, []) return ( diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx index b14e147105906..4f4bf8a8bfc72 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -61,7 +61,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { const disablePrompts = useFlag('disableAssistantPrompts') const { snippets } = useSqlEditorV2StateSnapshot() const snap = useAiAssistantStateSnapshot() - const { closeSidebar, isSidebarOpen } = useSidebarManagerSnapshot() + const { closeSidebar, activeSidebar } = useSidebarManagerSnapshot() const isPaidPlan = selectedOrganization?.plan?.id !== 'free' @@ -435,11 +435,12 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { }, [snap.initialInput]) useEffect(() => { - if (isSidebarOpen(SIDEBAR_KEYS.AI_ASSISTANT) && isInSQLEditor && !!snippetContent) { + const isOpen = activeSidebar?.id === SIDEBAR_KEYS.AI_ASSISTANT + if (isOpen && isInSQLEditor && !!snippetContent) { snap.setSqlSnippets([{ label: 'Current Query', content: snippetContent }]) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isSidebarOpen, isInSQLEditor, snippetContent]) + }, [activeSidebar?.id, isInSQLEditor, snippetContent]) return ( + source: 'lint' + original: Lint +} + +const severityOptions = [ + { label: 'Critical', value: 'critical' }, + { label: 'Warning', value: 'warning' }, + { label: 'Info', value: 'info' }, +] + +const severityOrder: Record = { + critical: 0, + warning: 1, + info: 2, +} + +const severityLabels: Record = { + critical: 'Critical', + warning: 'Warning', + info: 'Info', +} + +const severityBadgeVariants: Record = { + critical: 'destructive', + warning: 'warning', + info: 'default', +} + +const severityColorClasses: Record = { + critical: 'text-destructive', + warning: 'text-warning', + info: 'text-foreground-light', +} + +const tabIconMap: Record, React.ElementType> = { + security: Shield, + performance: Gauge, + messages: Inbox, +} + +const lintLevelToSeverity = (level: Lint['level']): AdvisorSeverity => { + switch (level) { + case 'ERROR': + return 'critical' + case 'WARN': + return 'warning' + default: + return 'info' + } +} + +export const AdvisorPanel = () => { + const { + activeTab, + severityFilters, + selectedItemId, + setActiveTab, + setSeverityFilters, + clearSeverityFilters, + setSelectedItemId, + } = useAdvisorStateSnapshot() + const { data: project } = useSelectedProjectQuery() + const { activeSidebar, closeSidebar } = useSidebarManagerSnapshot() + + const isSidebarOpen = activeSidebar?.id === SIDEBAR_KEYS.ADVISOR_PANEL + + const { + data: lintData, + isLoading: isLintsLoading, + isError: isLintsError, + } = useProjectLintsQuery( + { projectRef: project?.ref }, + { enabled: isSidebarOpen && !!project?.ref } + ) + + const lintItems = useMemo(() => { + if (!lintData) return [] + + return lintData + .map((lint): AdvisorItem | null => { + const categories = lint.categories || [] + const tab = categories.includes('SECURITY') + ? ('security' as const) + : categories.includes('PERFORMANCE') + ? ('performance' as const) + : undefined + + if (!tab) return null + + return { + id: lint.cache_key, + title: lint.detail, + severity: lintLevelToSeverity(lint.level), + createdAt: undefined, + tab, + source: 'lint' as const, + original: lint, + } + }) + .filter((item): item is AdvisorItem => item !== null) + }, [lintData]) + + const combinedItems = useMemo(() => { + const all = [...lintItems] + + return all.sort((a, b) => { + const severityDiff = severityOrder[a.severity] - severityOrder[b.severity] + if (severityDiff !== 0) return severityDiff + + const createdDiff = (b.createdAt ?? 0) - (a.createdAt ?? 0) + if (createdDiff !== 0) return createdDiff + + return a.title.localeCompare(b.title) + }) + }, [lintItems]) + + const filteredItems = useMemo(() => { + return combinedItems.filter((item) => { + if (severityFilters.length > 0 && !severityFilters.includes(item.severity)) { + return false + } + + if (activeTab === 'all') return true + + return item.tab === activeTab + }) + }, [combinedItems, severityFilters, activeTab]) + + const itemsFilteredByTabOnly = useMemo(() => { + return combinedItems.filter((item) => { + if (activeTab === 'all') return true + return item.tab === activeTab + }) + }, [combinedItems, activeTab]) + + const hiddenItemsCount = itemsFilteredByTabOnly.length - filteredItems.length + + const selectedItem = combinedItems.find((item) => item.id === selectedItemId) + const isDetailView = !!selectedItem + + const isLoading = isLintsLoading + const isError = isLintsError + + const handleTabChange = (tab: string) => { + setActiveTab(tab as AdvisorTab) + } + + const handleBackToList = () => { + setSelectedItemId(undefined) + } + + const handleClose = () => { + closeSidebar(SIDEBAR_KEYS.ADVISOR_PANEL) + } + + return ( +
+ {isDetailView ? ( + <> +
+ } + onClick={handleBackToList} + tooltip={{ content: { side: 'bottom', text: 'Back to list' } }} + /> +
+
+ {selectedItem?.title} +
+ {selectedItem && ( + + {severityLabels[selectedItem.severity]} + + )} +
+ } + onClick={handleClose} + tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }} + /> +
+
+ {selectedItem ? ( + + ) : ( +
+

+ Select an advisor item to view more details. +

+
+ )} +
+ + ) : ( + <> +
+
+ + + + All + + + Security + + + Performance + + + +
+ setSeverityFilters(values as AdvisorSeverity[])} + /> + } + onClick={handleClose} + tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }} + /> +
+
+
+
+ {isLoading ? ( +
+ +
+ ) : isError ? ( +
+ +

Error loading advisories

+

Please try again later.

+
+ ) : filteredItems.length === 0 ? ( + 0} + onClearFilters={clearSeverityFilters} + /> + ) : ( + <> +
+ {filteredItems.map((item) => { + const SeverityIcon = tabIconMap[item.tab] + const severityClass = severityColorClasses[item.severity] + return ( +
+ +
+ ) + })} +
+ {severityFilters.length > 0 && hiddenItemsCount > 0 && ( +
+ +
+ )} + + )} +
+ + )} +
+ ) +} + +interface AdvisorDetailProps { + item: AdvisorItem + projectRef: string +} + +const AdvisorDetail = ({ item, projectRef }: AdvisorDetailProps) => { + if (item.source === 'lint') { + const lint = item.original as Lint + return ( +
+ +
+ ) + } +} diff --git a/apps/studio/components/ui/AdvisorPanel/EmptyAdvisor.tsx b/apps/studio/components/ui/AdvisorPanel/EmptyAdvisor.tsx new file mode 100644 index 0000000000000..3096b0b19a9c1 --- /dev/null +++ b/apps/studio/components/ui/AdvisorPanel/EmptyAdvisor.tsx @@ -0,0 +1,56 @@ +import { TextSearch } from 'lucide-react' +import { Button } from 'ui' +import { AdvisorTab } from 'state/advisor-state' + +interface EmptyAdvisorProps { + activeTab: AdvisorTab + hasFilters: boolean + onClearFilters: () => void +} + +export const EmptyAdvisor = ({ activeTab, hasFilters, onClearFilters }: EmptyAdvisorProps) => { + const getHeading = () => { + if (hasFilters) return 'No items found' + + switch (activeTab) { + case 'security': + return 'No security issues detected' + case 'performance': + return 'No performance issues detected' + case 'messages': + return 'No messages' + default: + return 'No issues detected' + } + } + + const getMessage = () => { + if (hasFilters) return 'No advisor items match your current filters' + + switch (activeTab) { + case 'security': + return 'Congrats! There are no security issues detected for this project' + case 'performance': + return 'Congrats! There are no performance issues detected for this project' + case 'messages': + return 'There are no messages for this project' + default: + return 'Congrats! There are no issues detected for this project' + } + } + + return ( +
+ +
+

{getHeading()}

+

{getMessage()}

+
+ {hasFilters && ( + + )} +
+ ) +} diff --git a/apps/studio/components/ui/DatabaseSelector.tsx b/apps/studio/components/ui/DatabaseSelector.tsx index ab61c00f67678..ccffbffc34959 100644 --- a/apps/studio/components/ui/DatabaseSelector.tsx +++ b/apps/studio/components/ui/DatabaseSelector.tsx @@ -38,6 +38,7 @@ interface DatabaseSelectorProps { onSelectId?: (id: string) => void // Optional callback onCreateReplicaClick?: () => void portal?: boolean + className?: string } const DatabaseSelector = ({ @@ -48,6 +49,7 @@ const DatabaseSelector = ({ buttonProps, onCreateReplicaClick = noop, portal = true, + className, }: DatabaseSelectorProps) => { const router = useRouter() const { ref: projectRef } = useParams() @@ -79,7 +81,7 @@ const DatabaseSelector = ({ return ( -
+
Source diff --git a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx index e18c0b3ebd9de..ea19b47c2da74 100644 --- a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx +++ b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx @@ -147,7 +147,7 @@ export const EditorPanel = () => { return (
-
{label}
+
{label}
{templates.length > 0 && ( diff --git a/apps/studio/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.utils.ts b/apps/studio/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.utils.ts new file mode 100644 index 0000000000000..5e2403521f575 --- /dev/null +++ b/apps/studio/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.utils.ts @@ -0,0 +1,49 @@ +export const isBinaryFile = (fileName: string): boolean => { + const extension = fileName.split('.').pop()?.toLowerCase() + const binaryExtensions = [ + 'wasm', + 'jpg', + 'jpeg', + 'png', + 'gif', + 'bmp', + 'ico', + 'svg', + 'mp3', + 'mp4', + 'avi', + 'mov', + 'zip', + 'rar', + '7z', + 'tar', + 'gz', + 'bz2', + 'pdf', + ] + return binaryExtensions.includes(extension || '') +} + +export const getLanguageFromFileName = (fileName: string): string => { + const extension = fileName.split('.').pop()?.toLowerCase() + switch (extension) { + case 'ts': + case 'tsx': + return 'typescript' + case 'js': + case 'jsx': + return 'javascript' + case 'json': + return 'json' + case 'html': + return 'html' + case 'css': + return 'css' + case 'md': + return 'markdown' + case 'csv': + return 'csv' + default: + return 'plaintext' // Default to plaintext + } +} diff --git a/apps/studio/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.tsx b/apps/studio/components/ui/FileExplorerAndEditor/index.tsx similarity index 58% rename from apps/studio/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.tsx rename to apps/studio/components/ui/FileExplorerAndEditor/index.tsx index f425a22b38eec..f1988cff2aa7d 100644 --- a/apps/studio/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.tsx +++ b/apps/studio/components/ui/FileExplorerAndEditor/index.tsx @@ -1,3 +1,4 @@ +import { AnimatePresence, motion } from 'framer-motion' import { Edit, File, Plus, Trash } from 'lucide-react' import { useEffect, useState } from 'react' @@ -13,6 +14,7 @@ import { TreeView, TreeViewItem, } from 'ui' +import { getLanguageFromFileName, isBinaryFile } from './FileExplorerAndEditor.utils' interface FileData { id: number @@ -32,35 +34,16 @@ interface FileExplorerAndEditorProps { } } -const getLanguageFromFileName = (fileName: string): string => { - const extension = fileName.split('.').pop()?.toLowerCase() - switch (extension) { - case 'ts': - case 'tsx': - return 'typescript' - case 'js': - case 'jsx': - return 'javascript' - case 'json': - return 'json' - case 'html': - return 'html' - case 'css': - return 'css' - case 'md': - return 'markdown' - default: - return 'typescript' // Default to typescript - } -} +const denoJsonDefaultContent = JSON.stringify({ imports: {} }, null, '\t') -const FileExplorerAndEditor = ({ +export const FileExplorerAndEditor = ({ files, onFilesChange, aiEndpoint, aiMetadata, }: FileExplorerAndEditorProps) => { const selectedFile = files.find((f) => f.selected) ?? files[0] + const [isDragOver, setIsDragOver] = useState(false) const [treeData, setTreeData] = useState({ name: '', @@ -95,9 +78,55 @@ const FileExplorerAndEditor = ({ ]) } + const addDroppedFiles = async (droppedFiles: FileList) => { + const newFiles: FileData[] = [] + const updatedFiles = files.map((f) => ({ ...f, selected: false })) + + for (let i = 0; i < droppedFiles.length; i++) { + const file = droppedFiles[i] + const newId = Math.max(0, ...files.map((f) => f.id), ...newFiles.map((f) => f.id)) + 1 + + try { + let content: string + if (isBinaryFile(file.name)) { + // For binary files, read as ArrayBuffer and convert to base64 or keep as binary data + const arrayBuffer = await file.arrayBuffer() + const bytes = new Uint8Array(arrayBuffer) + content = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('') + } else { + content = await file.text() + } + + newFiles.push({ + id: newId, + name: file.name, + content, + selected: i === droppedFiles.length - 1, // Select the last dropped file + }) + } catch (error) { + console.error(`Failed to read file ${file.name}:`, error) + } + } + + if (newFiles.length > 0) { + onFilesChange([...updatedFiles, ...newFiles]) + } + } + const handleFileNameChange = (id: number, newName: string) => { if (!newName.trim()) return // Don't allow empty names - const updatedFiles = files.map((file) => (file.id === id ? { ...file, name: newName } : file)) + const updatedFiles = files.map((file) => + file.id === id + ? { + ...file, + name: newName, + content: + newName === 'deno.json' && file.content === '' + ? denoJsonDefaultContent + : file.content, + } + : file + ) onFilesChange(updatedFiles) } @@ -145,6 +174,26 @@ const FileExplorerAndEditor = ({ setTreeData(updatedTreeData) } + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(false) + } + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(false) + + const droppedFiles = e.dataTransfer.files + if (droppedFiles.length > 0) { + await addDroppedFiles(droppedFiles) + } + } + // Update treeData when files change useEffect(() => { setTreeData({ @@ -161,7 +210,27 @@ const FileExplorerAndEditor = ({ }, [files]) return ( -
+
+ + {isDragOver && ( + +
+
Drop files here to add them
+
+
+ )} +

@@ -197,13 +266,17 @@ const FileExplorerAndEditor = ({ icon={} isEditing={Boolean(element.metadata?.isEditing)} onEditSubmit={(value) => { - if (originalId !== null) handleFileNameChange(originalId, value) + if (originalId !== null) { + handleFileNameChange(originalId, value) + } }} onClick={() => { if (originalId !== null) handleFileSelect(originalId) }} onDoubleClick={() => { - if (originalId !== null) handleStartRename(originalId) + if (originalId !== null) { + handleStartRename(originalId) + } }} />

@@ -226,7 +299,9 @@ const FileExplorerAndEditor = ({ { - if (originalId !== null) handleFileDelete(originalId) + if (originalId !== null) { + handleFileDelete(originalId) + } }} onFocusCapture={(e) => e.stopPropagation()} > @@ -243,26 +318,36 @@ const FileExplorerAndEditor = ({
- + {selectedFile && isBinaryFile(selectedFile.name) ? ( +
+
+
Cannot Edit Selected File
+
+ Binary files like .{selectedFile.name.split('.').pop()} cannot be edited in the text + editor +
+
+
+ ) : ( + + )}
) } - -export default FileExplorerAndEditor diff --git a/apps/studio/components/ui/FilterPopover.tsx b/apps/studio/components/ui/FilterPopover.tsx index e8260e71fafb1..4ae0b2365a5e0 100644 --- a/apps/studio/components/ui/FilterPopover.tsx +++ b/apps/studio/components/ui/FilterPopover.tsx @@ -89,7 +89,7 @@ export const FilterPopover = >({ }) useEffect(() => { - if (!open && activeOptions.length > 0) setSelectedOptions(activeOptions) + if (!open) setSelectedOptions(activeOptions) if (!open) setSearch('') }, [open, activeOptions]) diff --git a/apps/studio/data/edge-functions/edge-function-body-query.ts b/apps/studio/data/edge-functions/edge-function-body-query.ts index 2f9b921ad2e14..7fe5947f63393 100644 --- a/apps/studio/data/edge-functions/edge-function-body-query.ts +++ b/apps/studio/data/edge-functions/edge-function-body-query.ts @@ -1,6 +1,7 @@ +import { getMultipartBoundary, parseMultipartStream } from '@mjackson/multipart-parser' import { useQuery, UseQueryOptions } from '@tanstack/react-query' -import { constructHeaders, fetchHandler, handleError } from 'data/fetchers' -import { BASE_PATH, IS_PLATFORM } from 'lib/constants' +import { get, handleError } from 'data/fetchers' +import { IS_PLATFORM } from 'lib/constants' import { ResponseError } from 'types' import { edgeFunctionsKeys } from './keys' @@ -15,10 +16,29 @@ export type EdgeFunctionFile = { } export type EdgeFunctionBodyResponse = { - version: number files: EdgeFunctionFile[] } +async function streamToString(stream: ReadableStream) { + const reader = stream.getReader() + const decoder = new TextDecoder() + let result = '' + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + result += decoder.decode(value, { stream: true }) + } + // Final decode to handle any remaining bytes + result += decoder.decode() + return result + } catch (error) { + console.error('Error reading stream:', error) + throw error + } +} + export async function getEdgeFunctionBody( { projectRef, slug }: EdgeFunctionBodyVariables, signal?: AbortSignal @@ -26,41 +46,31 @@ export async function getEdgeFunctionBody( if (!projectRef) throw new Error('projectRef is required') if (!slug) throw new Error('slug is required') - try { - // Get authorization headers - const headers = await constructHeaders({ - 'Content-Type': 'application/json', - }) + const { data, response, error } = await get('/v1/projects/{ref}/functions/{function_slug}/body', { + params: { path: { ref: projectRef, function_slug: slug } }, + headers: { Accept: 'multipart/form-data' }, + parseAs: 'stream', + signal, + }) - // Send to our API for processing (the API will handle the fetch from v1 endpoint) - const parseResponse = await fetchHandler(`${BASE_PATH}/api/edge-functions/body`, { - method: 'POST', - body: JSON.stringify({ projectRef, slug }), - headers, - credentials: 'include', - signal, - }) + if (error) handleError(error) - if (!parseResponse.ok) { - const { error } = await parseResponse.json() - handleError( - typeof error === 'object' - ? error - : typeof error === 'string' - ? { message: error } - : { message: 'Unknown error' } - ) - } + const contentTypeHeader = response.headers.get('content-type') ?? '' + const boundary = getMultipartBoundary(contentTypeHeader) + const files = [] - const response = (await parseResponse.json()) as EdgeFunctionBodyResponse - return response - } catch (error) { - handleError(error) - return { - version: 0, - files: [], - } as EdgeFunctionBodyResponse + if (!data || !boundary) return { files: [] } + + for await (let part of parseMultipartStream(data, { boundary })) { + if (part.isFile) { + files.push({ + name: part.filename, + content: part.text, + }) + } } + + return { files: files as EdgeFunctionFile[] } } export type EdgeFunctionBodyData = Awaited> diff --git a/apps/studio/data/fetchers.ts b/apps/studio/data/fetchers.ts index 6b2969b8217fe..aa8d66e428c96 100644 --- a/apps/studio/data/fetchers.ts +++ b/apps/studio/data/fetchers.ts @@ -24,7 +24,7 @@ export const fetchHandler: typeof fetch = async (input, init) => { } } -const client = createClient({ +export const client = createClient({ fetch: fetchHandler, // [Joshen] Just FYI, the replace is temporary until we update env vars API_URL to remove /platform or /v1 - should just be the base URL baseUrl: API_URL?.replace('/platform', ''), diff --git a/apps/studio/lib/eszip-parser.test.ts b/apps/studio/lib/eszip-parser.test.ts deleted file mode 100644 index ec38531a19d73..0000000000000 --- a/apps/studio/lib/eszip-parser.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { Parser } from '@deno/eszip' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { parseEszip } from './eszip-parser' - -vi.mock('@deno/eszip', () => ({ - Parser: { - createInstance: vi.fn(), - }, -})) - -vi.stubGlobal( - 'File', - class MockFile { - name: string - content: string - - constructor(content: string[], name: string) { - this.name = name - this.content = content[0] - } - - async text() { - return this.content - } - } -) - -vi.stubGlobal( - 'URL', - class MockURL { - pathname: string - - constructor(url: string) { - this.pathname = url - } - } -) - -describe('eszip-parser', () => { - const mockParser = { - parseBytes: vi.fn(), - load: vi.fn(), - getModuleSource: vi.fn(), - } - - beforeEach(() => { - vi.clearAllMocks() - ;(Parser.createInstance as any).mockResolvedValue(mockParser) - }) - - afterEach(() => { - vi.resetAllMocks() - }) - - describe('parseEszip', () => { - it('should successfully parse and extract files from eszip', async () => { - const mockBytes = new Uint8Array([1, 2, 3]) - const mockSpecifiers = ['file1.ts', 'file2.ts'] - const mockModuleSource1 = 'export const hello = "world"' - const mockModuleSource2 = 'export const foo = "bar"' - - mockParser.parseBytes.mockResolvedValue(mockSpecifiers) - mockParser.load.mockResolvedValue(undefined) - mockParser.getModuleSource - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(mockModuleSource1) - .mockResolvedValueOnce(mockModuleSource2) - - const result = await parseEszip(mockBytes) - - expect(Parser.createInstance).toHaveBeenCalledTimes(1) - expect(mockParser.parseBytes).toHaveBeenCalledWith(mockBytes) - expect(mockParser.load).toHaveBeenCalled() - expect(result.version).toEqual(0) - expect(result.files).toHaveLength(2) - expect(result.files[0]).toEqual({ - name: 'file1.ts', - content: mockModuleSource1, - }) - expect(result.files[1]).toEqual({ - name: 'file2.ts', - content: mockModuleSource2, - }) - }) - - it('should handle parseBytes failure', async () => { - mockParser.parseBytes.mockRejectedValue(new Error('Parse error')) - await expect(parseEszip(new Uint8Array())).rejects.toThrow('Parse error') - }) - - it('should handle load failure', async () => { - mockParser.parseBytes.mockResolvedValue(['file1.ts']) - mockParser.load.mockRejectedValue(new Error('Load error')) - - await expect(parseEszip(new Uint8Array())).rejects.toThrow('Load error') - }) - - it('should filter out unwanted specifiers', async () => { - const mockBytes = new Uint8Array([1, 2, 3]) - const mockSpecifiers = [ - 'file1.ts', - 'npm:package', - 'https://example.com/file.ts', - 'file2.ts', - '---internal', - 'jsr:package', - ] - const mockModuleSource = 'export const test = "test"' - - mockParser.parseBytes.mockResolvedValue(mockSpecifiers) - mockParser.load.mockResolvedValue(undefined) - mockParser.getModuleSource.mockResolvedValue(mockModuleSource) - - const result = await parseEszip(mockBytes) - // Only file1.ts and file2.ts should be included - expect(result.version).toEqual(0) - expect(result.files).toHaveLength(2) - expect(result.files[0].name).toBe('file1.ts') - expect(result.files[1].name).toBe('file2.ts') - }) - }) -}) diff --git a/apps/studio/lib/eszip-parser.ts b/apps/studio/lib/eszip-parser.ts deleted file mode 100644 index 7aa5428e7478e..0000000000000 --- a/apps/studio/lib/eszip-parser.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { Parser } from '@deno/eszip' -import path from 'path' - -function url2path(url: string) { - try { - // Parse the URL - return new URL(url).pathname - } catch (error) { - // If URL parsing fails, fallback to extracting just the filename - console.warn('Failed to parse URL:', url) - try { - // Try to extract just the filename part - const parts = url.split('/').filter(Boolean) - if (parts.length > 0) { - return parts[parts.length - 1] // Return just the filename - } - } catch (e) { - // Last resort: use the original path joining - console.error('Failed to extract filename:', e) - } - return path.join(...new URL(url).pathname.split('/').filter(Boolean)) - } -} - -// Initialize parser outside of request handler -let parserPromise: Promise | null = null - -async function getParser() { - if (!parserPromise) { - parserPromise = Parser.createInstance().catch((err) => { - console.error('Failed to create parser instance:', err) - parserPromise = null - throw err - }) - } - return parserPromise -} - -export async function parseEszip(bytes: Uint8Array) { - try { - const parser = await getParser() - if (!parser) { - throw new Error('Failed to initialize parser') - } - - // Parse bytes in a try-catch block - let specifiers: string[] = [] - try { - specifiers = await parser.parseBytes(bytes) - } catch (parseError) { - console.error('Error parsing bytes:', parseError) - // Reset parser on parse error - parserPromise = null - throw parseError - } - - // Load in a separate try-catch - try { - await parser.load() - } catch (loadError) { - console.error('Error loading parser:', loadError) - parserPromise = null - throw loadError - } - - // Extract version - let version = parseInt(await parser.getModuleSource('---SUPABASE-ESZIP-VERSION-ESZIP---')) - if (isNaN(version)) { - version = 0 - } - - // Extract files from the eszip - const files = await extractEszip(parser, specifiers, version >= 2) - - // Convert files to the expected format - const responseFiles = await Promise.all( - files.map(async (file) => { - const content = await file.text() - return { - name: file.name, - content: content, - } - }) - ) - - return { - version, - files: responseFiles, - } - } catch (error) { - console.error('Error in parseEszip:', error) - throw error - } -} - -async function extractEszip(parser: any, specifiers: string[], isDeno2: boolean) { - const files = [] - - // First, filter out the specifiers we want to keep - const filteredSpecifiers = specifiers.filter((specifier) => { - const shouldSkip = - specifier.startsWith('---') || - specifier.startsWith('npm:') || - specifier.startsWith('static:') || - specifier.startsWith('vfs:') || - specifier.startsWith('https:') || - specifier.startsWith('jsr:') - - if (shouldSkip) { - console.log('Skipping specifier:', specifier) - } else { - console.log('Keeping specifier:', specifier) - } - - return !shouldSkip - }) - - console.log('Filtered specifiers count:', filteredSpecifiers.length) - console.log('Filtered specifiers:', JSON.stringify(filteredSpecifiers)) - - // Then process each one - for (const specifier of filteredSpecifiers) { - try { - // Try to get the module source - const moduleSource = await parser.getModuleSource(specifier) - let qualifiedSpecifier = specifier - - // Get the file path - if (isDeno2 && !specifier.startsWith('file://')) { - qualifiedSpecifier = `file://${specifier}` - } - const filePath = url2path(qualifiedSpecifier) - - // Create a file object - const file = new File([moduleSource], filePath) - - files.push(file) - } catch (error) { - console.error('Error processing specifier:', specifier, error) - } - } - - return files -} diff --git a/apps/studio/lib/gotrue.ts b/apps/studio/lib/gotrue.ts index ab350710682dd..b51fdd29e7111 100644 --- a/apps/studio/lib/gotrue.ts +++ b/apps/studio/lib/gotrue.ts @@ -1,11 +1,6 @@ -import * as Sentry from '@sentry/nextjs' import type { JwtPayload } from '@supabase/supabase-js' import { getAccessToken, type User } from 'common/auth' -import { gotrueClient, setCaptureException } from 'common/gotrue' - -setCaptureException((e: any) => { - Sentry.captureException(e) -}) +import { gotrueClient } from 'common/gotrue' export const auth = gotrueClient export { getAccessToken } diff --git a/apps/studio/package.json b/apps/studio/package.json index 2518fbf3c5f37..35535056282f3 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -32,7 +32,6 @@ "@ai-sdk/react": "2.0.45", "@aws-sdk/credential-providers": "^3.804.0", "@dagrejs/dagre": "^1.0.4", - "@deno/eszip": "0.83.0", "@dnd-kit/core": "^6.1.0", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^8.0.0", @@ -45,6 +44,7 @@ "@headlessui/react": "^1.7.17", "@heroicons/react": "^2.1.3", "@hookform/resolvers": "^3.1.1", + "@mjackson/multipart-parser": "^0.10.1", "@modelcontextprotocol/sdk": "^1.18.0", "@monaco-editor/react": "^4.6.0", "@next/bundle-analyzer": "15.3.1", diff --git a/apps/studio/pages/api/edge-functions/body.ts b/apps/studio/pages/api/edge-functions/body.ts deleted file mode 100644 index ed220a07144ed..0000000000000 --- a/apps/studio/pages/api/edge-functions/body.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { API_URL } from 'lib/constants' -import { parseEszip } from 'lib/eszip-parser' -import { NextApiRequest, NextApiResponse } from 'next' - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const { method } = req - - switch (method) { - case 'POST': - return handlePost(req, res) - default: - return new Response( - JSON.stringify({ data: null, error: { message: `Method ${method} Not Allowed` } }), - { - status: 405, - headers: { 'Content-Type': 'application/json', Allow: 'POST' }, - } - ) - } -} - -async function handlePost(req: NextApiRequest, res: NextApiResponse) { - try { - const { projectRef, slug } = req.body || {} - - if (!projectRef) { - return res.status(400).json({ error: 'projectRef is required' }) - } - if (!slug) { - return res.status(400).json({ error: 'slug is required' }) - } - - // Get authorization token from the request - const authToken = req.headers.authorization - - if (!authToken) { - return res.status(401).json({ error: 'No authorization token was found' }) - } - - // Fetch the eszip data - const headers = new Headers() - headers.set('Accept', 'application/octet-stream') - headers.set('Authorization', typeof authToken === 'string' ? authToken : authToken[0]) - - // Forward other important headers - if (req.headers.cookie) { - headers.set('Cookie', req.headers.cookie) - } - - const baseUrl = API_URL?.replace('/platform', '') - const url = `${baseUrl}/v1/projects/${projectRef}/functions/${slug}/body` - - const response = await fetch(url, { - method: 'GET', - headers, - credentials: 'include', - referrerPolicy: 'no-referrer-when-downgrade', - }) - - if (!response.ok) { - const error = await response.json() - return res.status(response.status).json(error) - } - - // Verify content type is binary/eszip - const contentType = response.headers.get('content-type') - if (!contentType || !contentType.includes('application/octet-stream')) { - return res.status(400).json({ - error: - 'Invalid response: Expected eszip file but received ' + (contentType || 'unknown format'), - }) - } - - // Get the eszip data as ArrayBuffer - const arrayBuffer = await response.arrayBuffer() - - if (arrayBuffer.byteLength === 0) { - return res.status(400).json({ error: 'Invalid eszip: File is empty' }) - } - - const uint8Array = new Uint8Array(arrayBuffer) - - // Parse the eszip file using our utility - const parsed = await parseEszip(uint8Array) - - return res.status(200).json(parsed) - } catch (error) { - console.error('Error processing edge function body:', error) - return res.status(500).json({ error: 'Internal server error' }) - } -} diff --git a/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx b/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx index f3097485c6b12..30951b5af5b7a 100644 --- a/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx +++ b/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx @@ -1,9 +1,12 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useEffect } from 'react' import { useIsSecurityNotificationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { TEMPLATES_SCHEMAS } from 'components/interfaces/Auth/AuthTemplatesValidation' import { slugifyTitle } from 'components/interfaces/Auth/EmailTemplates/EmailTemplates.utils' -import TemplateEditor from 'components/interfaces/Auth/EmailTemplates/TemplateEditor' +import { TemplateEditor } from 'components/interfaces/Auth/EmailTemplates/TemplateEditor' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' import DefaultLayout from 'components/layouts/DefaultLayout' import { PageLayout } from 'components/layouts/PageLayout/PageLayout' @@ -12,9 +15,6 @@ import { DocsButton } from 'components/ui/DocsButton' import NoPermission from 'components/ui/NoPermission' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { DOCS_URL } from 'lib/constants' -import Link from 'next/link' -import { useRouter } from 'next/router' -import { useEffect } from 'react' import type { NextPageWithLayout } from 'types' import { Button, Card } from 'ui' import { Admonition, GenericSkeletonLoader } from 'ui-patterns' @@ -33,6 +33,16 @@ const RedirectToTemplates = () => { 'custom_config_gotrue' ) + // Find template whose slug matches the URL slug + const template = + templateId && typeof templateId === 'string' + ? TEMPLATES_SCHEMAS.find((template) => slugifyTitle(template.title) === templateId) + : null + + // Convert templateId slug to one lowercase word to match docs anchor tag + const templateIdForDocs = + typeof templateId === 'string' ? templateId.replace(/-/g, '').toLowerCase() : '' + useEffect(() => { if (isPermissionsLoaded && !isSecurityNotificationsEnabled) { router.replace(`/project/${ref}/auth/templates/`) @@ -43,18 +53,12 @@ const RedirectToTemplates = () => { return } - if (!isSecurityNotificationsEnabled) { + if (!isSecurityNotificationsEnabled || !templateId) { return null } - // Find template whose slug matches the URL slug - const template = - templateId && typeof templateId === 'string' - ? TEMPLATES_SCHEMAS.find((template) => slugifyTitle(template.title) === templateId) - : null - // Show error if templateId is invalid or template is not found - if (!template || !templateId || typeof templateId !== 'string') { + if (!template) { return (
{ ) } - // Convert templateId slug to one lowercase word to match docs anchor tag - const templateIdForDocs = templateId.replace(/-/g, '').toLowerCase() - return ( { const { can: canDeployFunction } = useAsyncCheckPermissions(PermissionAction.FUNCTIONS_WRITE, '*') - const { data: selectedFunction } = useEdgeFunctionQuery({ projectRef: ref, slug: functionSlug }) + const { data: selectedFunction } = useEdgeFunctionQuery({ + projectRef: ref, + slug: functionSlug, + }) const { data: functionBody, isLoading: isLoadingFiles, @@ -110,6 +113,9 @@ const CodePage = () => { import_map_path: files.some(({ name }) => name === newImportMapPath) ? newImportMapPath : fallbackImportMapPath(), + static_patterns: files + .filter(({ name }) => !name.match(/\.(js|ts|jsx|tsx|json|wasm)$/i)) + .map(({ name }) => name), }, files: files.map(({ name, content }) => ({ name, content })), }) @@ -120,31 +126,23 @@ const CodePage = () => { } } - function getBasePath( - entrypoint: string | undefined, - fileNames: string[], - version: number - ): string { + function getBasePath(entrypoint: string | undefined, fileNames: string[]): string { if (!entrypoint) { return '/' } - let qualifiedEntrypoint = entrypoint + let candidate = fileNames.find((name) => entrypoint.endsWith(name)) - if (version >= 2) { - const candidate = fileNames.find((name) => entrypoint.endsWith(name)) - if (candidate) { - qualifiedEntrypoint = `file://${candidate}` - } else { - qualifiedEntrypoint = entrypoint + if (candidate) { + return dirname(candidate) + } else { + try { + return dirname(new URL(entrypoint).pathname) + } catch (e) { + console.error('Failed to parse entrypoint', entrypoint) + return '/' } } - try { - return dirname(new URL(qualifiedEntrypoint).pathname) - } catch (e) { - console.error('Failed to parse entrypoint', qualifiedEntrypoint) - return '/' - } } const handleDeployClick = () => { @@ -152,14 +150,20 @@ const CodePage = () => { setShowDeployWarning(true) sendEvent({ action: 'edge_function_deploy_updates_button_clicked', - groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, + groups: { + project: ref ?? 'Unknown', + organization: org?.slug ?? 'Unknown', + }, }) } const handleDeployConfirm = () => { sendEvent({ action: 'edge_function_deploy_updates_confirm_clicked', - groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, + groups: { + project: ref ?? 'Unknown', + organization: org?.slug ?? 'Unknown', + }, }) onUpdate() } @@ -169,12 +173,9 @@ const CodePage = () => { if (selectedFunction?.entrypoint_path && functionBody) { const base_path = getBasePath( selectedFunction?.entrypoint_path, - functionBody.files.map((file) => file.name), - functionBody.version + functionBody.files.map((file) => file.name) ) const filesWithRelPath = functionBody.files - // ignore empty files - .filter((file: { name: string; content: string }) => !!file.content.length) // set file paths relative to entrypoint .map((file: { name: string; content: string }) => { try { @@ -185,7 +186,8 @@ const CodePage = () => { return file } - file.name = relative(base_path, file.name) + // prepend "/" to turn relative paths to absolute + file.name = relative('/' + base_path, '/' + file.name) return file } catch (e) { console.error(e) diff --git a/apps/studio/pages/project/[ref]/functions/new.tsx b/apps/studio/pages/project/[ref]/functions/new.tsx index b813c3babb6be..507a532cfe0ee 100644 --- a/apps/studio/pages/project/[ref]/functions/new.tsx +++ b/apps/studio/pages/project/[ref]/functions/new.tsx @@ -11,7 +11,8 @@ import { EDGE_FUNCTION_TEMPLATES } from 'components/interfaces/Functions/Functio import DefaultLayout from 'components/layouts/DefaultLayout' import EdgeFunctionsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout' import { PageLayout } from 'components/layouts/PageLayout/PageLayout' -import FileExplorerAndEditor from 'components/ui/FileExplorerAndEditor/FileExplorerAndEditor' +import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { FileExplorerAndEditor } from 'components/ui/FileExplorerAndEditor' import { useEdgeFunctionDeployMutation } from 'data/edge-functions/edge-functions-deploy-mutation' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' @@ -19,6 +20,7 @@ import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { BASE_PATH } from 'lib/constants' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' +import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import { AiIconAnimation, Button, @@ -42,8 +44,6 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' -import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' // Array of adjectives and nouns for random function name generation const ADJECTIVES = [ diff --git a/apps/studio/state/advisor-state.ts b/apps/studio/state/advisor-state.ts new file mode 100644 index 0000000000000..a5839330a32ec --- /dev/null +++ b/apps/studio/state/advisor-state.ts @@ -0,0 +1,40 @@ +import { proxy, snapshot, useSnapshot } from 'valtio' + +export type AdvisorTab = 'all' | 'security' | 'performance' | 'messages' +export type AdvisorSeverity = 'critical' | 'warning' | 'info' + +const initialState = { + activeTab: 'all' as AdvisorTab, + severityFilters: ['critical'] as AdvisorSeverity[], + selectedItemId: undefined as string | undefined, +} + +export const advisorState = proxy({ + ...initialState, + setActiveTab(tab: AdvisorTab) { + advisorState.activeTab = tab + }, + setSeverityFilters(severities: AdvisorSeverity[]) { + advisorState.severityFilters = severities + }, + clearSeverityFilters() { + advisorState.severityFilters = [] + }, + setSelectedItemId(id: string | undefined) { + advisorState.selectedItemId = id + }, + focusItem({ id, tab }: { id: string; tab?: AdvisorTab }) { + if (tab) { + advisorState.activeTab = tab + } + advisorState.selectedItemId = id + }, + reset() { + Object.assign(advisorState, initialState) + }, +}) + +export const getAdvisorStateSnapshot = () => snapshot(advisorState) + +export const useAdvisorStateSnapshot = (options?: Parameters[1]) => + useSnapshot(advisorState, options) diff --git a/apps/studio/state/sidebar-manager-state.tsx b/apps/studio/state/sidebar-manager-state.tsx index bb455845ec491..e0e48404090a4 100644 --- a/apps/studio/state/sidebar-manager-state.tsx +++ b/apps/studio/state/sidebar-manager-state.tsx @@ -151,12 +151,16 @@ export const useRegisterSidebar = ( ) useEffect(() => { - const { registerSidebar, unregisterSidebar } = sidebarManagerState + const { registerSidebar, unregisterSidebar, sidebars } = sidebarManagerState - registerSidebar(id, component, handlers) + if (!sidebars[id]) { + registerSidebar(id, component, handlers) + } return () => { - unregisterSidebar(id) + if (sidebars[id]) { + unregisterSidebar(id) + } } }, [id]) diff --git a/apps/studio/types/form.ts b/apps/studio/types/form.ts index 8a4bf1380d6a1..0bd0bce02a156 100644 --- a/apps/studio/types/form.ts +++ b/apps/studio/types/form.ts @@ -32,5 +32,6 @@ export interface FormSchema { title?: string description?: string } + emailTemplateType?: 'authentication' | 'security' } } diff --git a/apps/www/components/Sections/TwitterSocialProof.tsx b/apps/www/components/Sections/TwitterSocialProof.tsx index 1c436bdc959fb..615072fe1a0fc 100644 --- a/apps/www/components/Sections/TwitterSocialProof.tsx +++ b/apps/www/components/Sections/TwitterSocialProof.tsx @@ -1,22 +1,23 @@ +import { range } from 'lib/helpers' import Link from 'next/link' import { useRouter } from 'next/router' import { cn } from 'ui' import { TweetCard } from 'ui-patterns/TweetCard' -import { range } from 'lib/helpers' -import tweets from 'shared-data/tweets' import { useBreakpoint } from 'common' import React from 'react' +import { topTweets } from 'shared-data/tweets' interface Props { className?: string } +const tweetsData = topTweets + const TwitterSocialProof: React.FC = ({ className }) => { const { basePath } = useRouter() const isSm = useBreakpoint() const isMd = useBreakpoint(1024) - const tweetsData = tweets.slice(0, 18) return ( <> diff --git a/apps/www/data/home/content.tsx b/apps/www/data/home/content.tsx index fcfc96dd66ea6..0e081811b880f 100644 --- a/apps/www/data/home/content.tsx +++ b/apps/www/data/home/content.tsx @@ -5,7 +5,7 @@ import { Button } from 'ui' import ProductModules from '../ProductModules' import MainProducts from 'data/MainProducts' -import tweets from 'shared-data/tweets' +import { topTweets } from 'shared-data/tweets' import { IconDiscord } from 'ui' export default () => { @@ -188,7 +188,7 @@ export default () => { ), - tweets: tweets.slice(0, 18), + tweets: topTweets, }, } } diff --git a/apps/www/data/solutions/beginners.tsx b/apps/www/data/solutions/beginners.tsx index e217af26e7255..cd9b89ee0304c 100644 --- a/apps/www/data/solutions/beginners.tsx +++ b/apps/www/data/solutions/beginners.tsx @@ -21,7 +21,7 @@ import { import { useBreakpoint } from 'common' import { useSendTelemetryEvent } from 'lib/telemetry' -import { tweets } from 'shared-data' +import { topTweets } from 'shared-data' import { PRODUCT_SHORTNAMES } from 'shared-data/products' const AuthVisual = dynamic(() => import('components/Products/AuthVisual')) @@ -495,7 +495,7 @@ const data: () => { ), - tweets: tweets.slice(0, 18), + tweets: topTweets, }, platformStarterSection: { id: 'platform-starter', diff --git a/apps/www/public/images/twitter-profiles/2SQwtv8c_400x400.jpg b/apps/www/public/images/twitter-profiles/2SQwtv8c_400x400.jpg new file mode 100644 index 0000000000000..8e86078de9d61 Binary files /dev/null and b/apps/www/public/images/twitter-profiles/2SQwtv8c_400x400.jpg differ diff --git a/apps/www/public/images/twitter-profiles/UCBhUBZl_400x400.jpg b/apps/www/public/images/twitter-profiles/UCBhUBZl_400x400.jpg deleted file mode 100644 index 86a1b942657c4..0000000000000 Binary files a/apps/www/public/images/twitter-profiles/UCBhUBZl_400x400.jpg and /dev/null differ diff --git a/apps/www/public/images/twitter-profiles/Y1swF6ef_400x400.jpg b/apps/www/public/images/twitter-profiles/Y1swF6ef_400x400.jpg new file mode 100644 index 0000000000000..9a9cdcd3f266c Binary files /dev/null and b/apps/www/public/images/twitter-profiles/Y1swF6ef_400x400.jpg differ diff --git a/apps/www/public/images/twitter-profiles/_iAaSUQf_400x400.jpg b/apps/www/public/images/twitter-profiles/_iAaSUQf_400x400.jpg new file mode 100644 index 0000000000000..88c7f46728788 Binary files /dev/null and b/apps/www/public/images/twitter-profiles/_iAaSUQf_400x400.jpg differ diff --git a/apps/www/public/images/twitter-profiles/k0aPYRHF_400x400.jpg b/apps/www/public/images/twitter-profiles/k0aPYRHF_400x400.jpg new file mode 100644 index 0000000000000..4997a4c0584fd Binary files /dev/null and b/apps/www/public/images/twitter-profiles/k0aPYRHF_400x400.jpg differ diff --git a/apps/www/public/images/twitter-profiles/w8HLdlC7_400x400.jpg b/apps/www/public/images/twitter-profiles/w8HLdlC7_400x400.jpg deleted file mode 100644 index be3de358bc69c..0000000000000 Binary files a/apps/www/public/images/twitter-profiles/w8HLdlC7_400x400.jpg and /dev/null differ diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index 3d3762b524626..802ab87b7d34c 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -3764,7 +3764,11 @@ export interface paths { } get?: never put?: never - post?: never + /** + * Update publication for source + * @description Update a publication for a source. Requires bearer auth and an active, healthy project. + */ + post: operations['ReplicationSourcesController_updatePublication'] /** * Delete publication for source * @description Delete a publication for a source. Requires bearer auth and an active, healthy project. @@ -10062,6 +10066,21 @@ export interface components { */ version_id: number } + UpdateReplicationPublicationBody: { + /** @description Publication tables */ + tables: { + /** + * @description Table name + * @example orders + */ + name: string + /** + * @description Table schema + * @example public + */ + schema: string + }[] + } UpdateSecretsConfigBody: { change_tracking_id: string jwt_secret: string @@ -23587,6 +23606,63 @@ export interface operations { } } } + ReplicationSourcesController_updatePublication: { + parameters: { + query?: never + header?: never + path: { + /** @description Publication name */ + publication_name: string + /** @description Project ref */ + ref: string + /** @description Source id */ + source_id: number + } + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['UpdateReplicationPublicationBody'] + } + } + responses: { + /** @description Publication updated. */ + 200: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Unexpected error while updating publication. */ + 500: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } ReplicationSourcesController_deletePublication: { parameters: { query?: never diff --git a/packages/common/gotrue.ts b/packages/common/gotrue.ts index 7a241cf4ce1ab..d2e4ef089419d 100644 --- a/packages/common/gotrue.ts +++ b/packages/common/gotrue.ts @@ -115,6 +115,9 @@ const logIndexedDB = (message: string, ...args: any[]) => { })() } +/** + * Reference to a function that captures exceptions for debugging purposes to be sent to Sentry. + */ let captureException: ((e: any) => any) | null = null export function setCaptureException(fn: typeof captureException) { diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 9c1e97d46a299..3afd9419f2e89 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -2010,6 +2010,24 @@ export interface CommandMenuCommandClickedEvent { groups: Partial } +/** + * User opened a sidebar panel. + * + * @group Events + * @source studio + * @page Various pages with sidebar buttons + */ +export interface SidebarOpenedEvent { + action: 'sidebar_opened' + properties: { + /** + * The sidebar panel that was opened, e.g. ai-assistant, editor-panel, advisor-panel + */ + sidebar: 'ai-assistant' | 'editor-panel' | 'advisor-panel' + } + groups: TelemetryGroups +} + /** * User was exposed to the table quickstart experiment. * @@ -2286,3 +2304,4 @@ export type TelemetryEvent = | CommandMenuOpenedEvent | CommandMenuSearchSubmittedEvent | CommandMenuCommandClickedEvent + | SidebarOpenedEvent diff --git a/packages/shared-data/index.ts b/packages/shared-data/index.ts index 976896e2fe9cc..9f334c453e849 100644 --- a/packages/shared-data/index.ts +++ b/packages/shared-data/index.ts @@ -3,23 +3,24 @@ import extensions from './extensions.json' import logConstants from './logConstants' import { plans, PricingInformation } from './plans' import { pricing } from './pricing' -import { products, PRODUCT_MODULES } from './products' +import { PRODUCT_MODULES, products } from './products' import questions from './questions' import type { AWS_REGIONS_KEYS, CloudProvider, Region } from './regions' import { AWS_REGIONS, FLY_REGIONS } from './regions' -import tweets from './tweets' +import tweets, { topTweets } from './tweets' -export type { AWS_REGIONS_KEYS, CloudProvider, PricingInformation, Region } export { AWS_REGIONS, - FLY_REGIONS, config, extensions, + FLY_REGIONS, logConstants, plans, pricing, - products, PRODUCT_MODULES, + products, questions, + topTweets, tweets, } +export type { AWS_REGIONS_KEYS, CloudProvider, PricingInformation, Region } diff --git a/packages/shared-data/tweets.ts b/packages/shared-data/tweets.ts index cba89c1a39bf0..92120a4123b09 100644 --- a/packages/shared-data/tweets.ts +++ b/packages/shared-data/tweets.ts @@ -60,7 +60,7 @@ const tweets = [ img_url: '/images/twitter-profiles/ukFtCkww_400x400.jpg', }, { - text: 'Lately been using Supabase over AWS/ GCP for products to save on costs and rapid builds(Vibe Code) that do not need all the Infra and the hefty costs that come with AWS/ GCP out the door. Great solution overall. Love the new Feature stack thats implemented', + text: 'Lately been using Supabase over AWS/ GCP for products to save on costs and rapid builds(Vibe Code) that do not need all the Infra and the hefty costs that come with AWS/ GCP out the door. Great solution overall.', url: 'https://x.com/xthemadgeniusx/status/1960049950110384250', handle: 'xthemadgeniusx', img_url: '/images/twitter-profiles/XE8Oyngj_400x400.jpg', @@ -78,7 +78,7 @@ const tweets = [ img_url: '/images/twitter-profiles/GtrVV2dD_400x400.jpg', }, { - text: '@supabase is just 🤯 Now I see why a lot of people love using it as a backend for their applications. I am really impressed with how easy it is to set up an Auth and then just code it together for the frontend. @IngoKpp now I see your joy with Supabase #coding #fullstackwebdev', + text: '@supabase is just 🤯 Now I see why a lot of people love using it as a backend for their applications. I am really impressed with how easy it is to set up an Auth and then just code it together for the frontend.', url: 'https://twitter.com/IxoyeDesign/status/1497473731777728512', handle: 'IxoyeDesign', img_url: '/images/twitter-profiles/C8opIL-g_400x400.jpg', @@ -138,7 +138,7 @@ const tweets = [ img_url: '/images/twitter-profiles/rWX8Jzp5_400x400.jpg', }, { - text: 'There are a lot of indie hackers building in public, but it’s rare to see a startup shipping as consistently and transparently as Supabase. Their upcoming March releases look to be 🔥 Def worth a follow! also opened my eyes as to how to value add in open source.', + text: 'There are a lot of indie hackers building in public, but it’s rare to see a startup shipping as consistently and transparently as Supabase. Their upcoming March releases look to be 🔥 Def worth a follow!', url: 'https://twitter.com/swyx/status/1366685025047994373', handle: 'swyx', img_url: '/images/twitter-profiles/qhvO9V6x_400x400.jpg', @@ -162,7 +162,7 @@ const tweets = [ img_url: '/images/twitter-profiles/7NITI8Z3_400x400.jpg', }, { - text: 'This community is STRONG and will continue to be the reason why developers flock to @supabase over an alternative. Keep up the good work! ⚡️', + text: 'This community is STRONG and will continue to be the reason why developers flock to @supabase over an alternative.', url: 'https://twitter.com/_wilhelm__/status/1524074865107488769', handle: '_wilhelm__', img_url: '/images/twitter-profiles/CvqDy6YF_400x400.jpg', @@ -174,7 +174,7 @@ const tweets = [ img_url: '/images/twitter-profiles/bJlKtSxz_400x400.jpg', }, { - text: '@supabase Putting a ton of well-explained example API queries in a self-building documentation is just a classy move all around. I also love having GraphQL-style nested queries with traditional SQL filtering. This is pure DX delight. A+++. #backend', + text: '@supabase Putting a ton of well-explained example API queries in a self-building documentation is just a classy move all around. I also love having GraphQL-style nested queries with traditional SQL filtering. This is pure DX delight. A+++.', url: 'https://twitter.com/CodiferousCoder/status/1522233113207836675', handle: 'CodiferousCoder', img_url: '/images/twitter-profiles/t37cVLwy_400x400.jpg', @@ -191,12 +191,6 @@ const tweets = [ handle: 'JP__Gallegos', img_url: '/images/twitter-profiles/1PH2mt6v_400x400.jpg', }, - { - text: 'Check out this amazing product @supabase. A must give try #newidea #opportunity', - url: 'https://twitter.com/digitaldaswani/status/1364447219642814464', - handle: 'digitaldaswani', - img_url: '/images/twitter-profiles/w8HLdlC7_400x400.jpg', - }, { text: "I gave @supabase a try this weekend and I was able to create a quick dashboard to visualize the data from the PostgreSQL instance. It's super easy to use Supabase's API or the direct DB connection. Check out the tutorial 📖", url: 'https://twitter.com/razvanilin/status/1363770020581412867', @@ -215,12 +209,6 @@ const tweets = [ handle: 'razvanilin', img_url: '/images/twitter-profiles/AiaH9vJ2_400x400.jpg', }, - { - text: "Wait. Is it so easy to write queries for @supabase ? It's like simple SQL stuff!", - url: 'https://twitter.com/T0ny_Boy/status/1362911838908911617', - handle: 'T0ny_Boy', - img_url: '/images/twitter-profiles/UCBhUBZl_400x400.jpg', - }, { text: 'Jeez, and @supabase have native support for magic link login?! I was going to use http://magic.link for this But if I can get my whole DB + auth + magic link support in one... Awesome', url: 'https://twitter.com/louisbarclay/status/1362016666868154371', @@ -256,6 +244,7 @@ const tweets = [ url: 'https://twitter.com/nerdburn/status/1356857261495214085', handle: 'nerdburn', img_url: '/images/twitter-profiles/66VSV9Mm_400x400.png', + weight: 10, }, { text: 'Now things are starting to get interesting! Firebase has long been the obvious choice for many #flutter devs for the ease of use. But their databases are NoSQL, which has its downsides... Seems like @supabase is working on something interesting here!', @@ -287,6 +276,76 @@ const tweets = [ handle: '0xBanana', img_url: '/images/twitter-profiles/pgHIGqZ0_400x400.jpg', }, + { + text: `Very impressed by @supabase's growth. For new startups, they seem to have gone from "promising" to "standard" in remarkably short order.`, + url: 'https://x.com/patrickc/status/1979157875600617913', + handle: 'patrickc', + img_url: '/images/twitter-profiles/_iAaSUQf_400x400.jpg', + weight: 10, + }, + { + text: `Okay, I finally tried Supabase today and wow... why did I wait so long? 😅 Went from 'how do I even start' to having auth + database + real-time updates working in like 20 minutes. Sometimes the hype is actually justified! #Supabase`, + url: 'https://x.com/Aliahsan_sfv/status/1967167095894098210', + handle: 'Aliahsan_sfv', + img_url: '/images/twitter-profiles/2SQwtv8c_400x400.jpg', + weight: 9, + }, + { + text: `Supabase is the best product experience I've had in years.\nNot just tech - taste.\nFrom docs to latency to the URL structure that makes you think "oh, that's obvious"\nFeels like every other platform should study how they built it\n@supabase I love you`, + url: 'https://x.com/yatsiv_yuriy/status/1979182362480071162', + handle: 'yatsiv_yuriy', + img_url: '/images/twitter-profiles/Y1swF6ef_400x400.jpg', + weight: 9, + }, + { + text: "@supabase shout out, their MCP is awesome. It's helping me create better row securities and telling me best practises for setting up a supabase app", + url: 'https://x.com/adeelibr/status/1981356783818985774', + handle: 'adeelibr', + img_url: '/images/twitter-profiles/k0aPYRHF_400x400.jpg', + weight: 6, + }, ] +export const getWeightedTweets = (count: number): typeof tweets => { + const fallbackWeight = 1 + const availableTweets = [...tweets] + const selectedTweets: typeof tweets = [] + let remainingWeight = availableTweets.reduce( + (sum, tweet) => sum + (tweet.weight ?? fallbackWeight), + 0 + ) + + for (let i = 0; i < count && availableTweets.length > 0; i++) { + // Generate random number between 0 and remainingWeight + const random = Math.random() * remainingWeight + + // Find the selected tweet based on cumulative weights + let accumulatedWeight = 0 + let selectedIndex = -1 + + for (let j = 0; j < availableTweets.length; j++) { + const tweet = availableTweets[j] + const weight = tweet.weight ?? fallbackWeight + accumulatedWeight += weight + if (random <= accumulatedWeight) { + selectedTweets.push(tweet) + selectedIndex = j + break + } + } + + // Remove the selected tweet and update remaining weight + if (selectedIndex !== -1) { + const removedWeight = availableTweets[selectedIndex].weight ?? fallbackWeight + remainingWeight -= removedWeight + availableTweets.splice(selectedIndex, 1) + } + } + + return selectedTweets +} + +// Sort by weight (highest first), then take first 18 for static pages +export const topTweets = [...tweets].sort((a, b) => (b.weight ?? 1) - (a.weight ?? 1)).slice(0, 18) + export default tweets diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e90cc80e0b287..49e35824adb69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -397,7 +397,7 @@ importers: version: 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sentry/nextjs': specifier: ^10.3.0 - version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0) + version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0) '@supabase/supabase-js': specifier: 'catalog:' version: 2.75.1 @@ -750,9 +750,6 @@ importers: '@dagrejs/dagre': specifier: ^1.0.4 version: 1.0.4 - '@deno/eszip': - specifier: 0.83.0 - version: 0.83.0 '@dnd-kit/core': specifier: ^6.1.0 version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -789,6 +786,9 @@ importers: '@hookform/resolvers': specifier: ^3.1.1 version: 3.3.1(react-hook-form@7.47.0(react@18.3.1)) + '@mjackson/multipart-parser': + specifier: ^0.10.1 + version: 0.10.1 '@modelcontextprotocol/sdk': specifier: ^1.18.0 version: 1.18.0(supports-color@8.1.1) @@ -812,7 +812,7 @@ importers: version: 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sentry/nextjs': specifier: ^10.3.0 - version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0) + version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0) '@std/path': specifier: npm:@jsr/std__path@^1.0.8 version: '@jsr/std__path@1.0.8' @@ -1227,7 +1227,7 @@ importers: version: 2.11.3(@types/node@22.13.14)(typescript@5.9.2) next-router-mock: specifier: ^0.9.13 - version: 0.9.13(next@15.5.2(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1) + version: 0.9.13(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1) node-mocks-http: specifier: ^1.17.2 version: 1.17.2(@types/node@22.13.14) @@ -1591,7 +1591,7 @@ importers: version: 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sentry/nextjs': specifier: ^10 - version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0) + version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0) '@supabase/supabase-js': specifier: 'catalog:' version: 2.75.1 @@ -2576,7 +2576,7 @@ importers: version: link:../api-types next-router-mock: specifier: ^0.9.13 - version: 0.9.13(next@15.5.2(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1) + version: 0.9.13(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1) tsx: specifier: 'catalog:' version: 4.20.3 @@ -3429,15 +3429,9 @@ packages: '@date-fns/tz@1.2.0': resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==} - '@deno/eszip@0.83.0': - resolution: {integrity: sha512-gTKYMQ+uv20IUJuEBYkjovMPflFjX7caJ8cwA/sZVqic0L/PFP2gZMFt/GiCHc8eVejhlJLGxg0J4qehDq/f2A==} - '@deno/shim-deno-test@0.5.0': resolution: {integrity: sha512-4nMhecpGlPi0cSzT67L+Tm+GOJqvuk8gqHBziqcUQOarnuIax1z96/gJHCSIz2Z0zhxE6Rzwb3IZXPtFh51j+w==} - '@deno/shim-deno@0.18.2': - resolution: {integrity: sha512-oQ0CVmOio63wlhwQF75zA4ioolPvOwAoK0yuzcS5bDC1JUvH3y1GS8xPh8EOpcoDQRU4FTG8OQfxhpR+c6DrzA==} - '@deno/shim-deno@0.19.2': resolution: {integrity: sha512-q3VTHl44ad8T2Tw2SpeAvghdGOjlnLPDNO2cpOxwMrBE/PVas6geWpbpIgrM+czOCH0yejp0yi8OaTuB+NU40Q==} @@ -21852,18 +21846,8 @@ snapshots: '@date-fns/tz@1.2.0': {} - '@deno/eszip@0.83.0': - dependencies: - '@deno/shim-deno': 0.18.2 - undici: 6.21.2 - '@deno/shim-deno-test@0.5.0': {} - '@deno/shim-deno@0.18.2': - dependencies: - '@deno/shim-deno-test': 0.5.0 - which: 4.0.0 - '@deno/shim-deno@0.19.2': dependencies: '@deno/shim-deno-test': 0.5.0 @@ -27630,7 +27614,7 @@ snapshots: '@sentry/core@10.3.0': {} - '@sentry/nextjs@10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)': + '@sentry/nextjs@10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.36.0 @@ -36735,7 +36719,7 @@ snapshots: dependencies: js-yaml-loader: 1.2.2 - next-router-mock@0.9.13(next@15.5.2(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1): + next-router-mock@0.9.13(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1): dependencies: next: 15.5.2(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) react: 18.3.1