From 9c03ad6b0277fc445c21135fc5e8ec30fbfedeb5 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 18 May 2026 19:00:09 +1000 Subject: [PATCH 01/20] chore(docs): migrate Admonition labels to titles (#46053) ## What kind of change does this PR introduce? Docs update. Related to DEPR-551. ## What is the current behavior? Docs MDX still uses the legacy `label` prop for Admonitions, even though #45618 added `title` and kept `label` only as a backwards-compatible alias after #45302 was reverted in #45535. ## What is the new behavior? Migrates Docs-owned Admonitions from `label=` to `title=` without changing rendered copy, component APIs, Studio callsites, design-system examples, or the legacy `label` alias. ## Summary by CodeRabbit * **Documentation** * Standardized admonition headings across the docs by switching how admonition headings are provided (preserving all visible guidance and examples). Content and instructions remain unchanged; this ensures consistent rendering of callouts and improves uniformity across guides and reference pages. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46053?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --------- Co-authored-by: Chris Chinchilla --- apps/docs/CONTRIBUTING.md | 2 +- apps/docs/content/guides/auth/auth-anonymous.mdx | 8 ++++---- apps/docs/content/guides/auth/redirect-urls.mdx | 2 +- .../content/guides/auth/social-login/auth-apple.mdx | 8 ++++---- .../content/guides/database/custom-postgres-config.mdx | 2 +- .../docs/content/guides/database/extensions/pg_net.mdx | 2 +- apps/docs/content/guides/database/metabase.mdx | 2 +- .../guides/database/postgres/row-level-security.mdx | 4 ++-- apps/docs/content/guides/database/prisma.mdx | 10 +++++----- apps/docs/content/guides/database/replication.mdx | 2 +- .../content/guides/database/replication/bigquery.mdx | 2 +- .../database/replication/external-replication-faq.mdx | 2 +- .../replication/external-replication-monitoring.mdx | 2 +- .../replication/external-replication-setup.mdx | 2 +- apps/docs/content/guides/database/secure-data.mdx | 2 +- .../guides/deployment/branching/configuration.mdx | 4 ++-- .../content/guides/deployment/branching/dashboard.mdx | 2 +- .../guides/deployment/branching/integrations.mdx | 2 +- .../docs/content/guides/deployment/going-into-prod.mdx | 2 +- apps/docs/content/guides/functions/deploy.mdx | 2 +- .../docs/content/guides/functions/development-tips.mdx | 2 +- .../content/guides/functions/quickstart-dashboard.mdx | 10 +++++----- apps/docs/content/guides/functions/quickstart.mdx | 6 +++--- .../guides/getting-started/tutorials/with-angular.mdx | 2 +- .../guides/getting-started/tutorials/with-nextjs.mdx | 2 +- .../guides/getting-started/tutorials/with-react.mdx | 2 +- .../getting-started/tutorials/with-sveltekit.mdx | 2 +- .../guides/local-development/cli/getting-started.mdx | 2 +- apps/docs/content/guides/platform/backups.mdx | 2 +- apps/docs/content/guides/platform/clone-project.mdx | 2 +- apps/docs/content/guides/platform/compute-and-disk.mdx | 2 +- .../guides/platform/manage-your-usage/compute.mdx | 2 +- .../guides/platform/migrating-to-supabase/auth0.mdx | 2 +- apps/docs/content/guides/platform/sso.mdx | 8 ++++---- apps/docs/content/guides/platform/sso/azure.mdx | 2 +- .../guides/platform/sso/choosing-login-flow.mdx | 4 ++-- apps/docs/content/guides/platform/sso/login-flows.mdx | 6 +++--- .../content/guides/platform/sso/multiple-providers.mdx | 4 ++-- apps/docs/content/guides/realtime/limits.mdx | 2 +- apps/docs/content/guides/self-hosting/docker.mdx | 4 ++-- .../guides/self-hosting/postgres-upgrade-17.mdx | 2 +- .../guides/self-hosting/self-hosted-proxy-https.mdx | 4 ++-- apps/docs/content/guides/storage/s3/authentication.mdx | 2 +- .../docs/ref/self-hosting-analytics/introduction.mdx | 2 +- .../docs/ref/self-hosting-functions/introduction.mdx | 2 +- 45 files changed, 72 insertions(+), 72 deletions(-) diff --git a/apps/docs/CONTRIBUTING.md b/apps/docs/CONTRIBUTING.md index 2c95fb79a3b..f1fe7b92ba7 100644 --- a/apps/docs/CONTRIBUTING.md +++ b/apps/docs/CONTRIBUTING.md @@ -172,7 +172,7 @@ Choose the appropriate `type` for your admonition: - `note` for anything else ``` - + Your content here diff --git a/apps/docs/content/guides/auth/auth-anonymous.mdx b/apps/docs/content/guides/auth/auth-anonymous.mdx index 2c9e1e606b9..c8d7f823f92 100644 --- a/apps/docs/content/guides/auth/auth-anonymous.mdx +++ b/apps/docs/content/guides/auth/auth-anonymous.mdx @@ -6,7 +6,7 @@ subtitle: 'Create and use anonymous users to authenticate with Supabase' [Enable Anonymous Sign-Ins](/dashboard/project/_/auth/providers) to build apps which provide users an authenticated experience without requiring users to enter an email address, password, use an OAuth provider or provide any other PII (Personally Identifiable Information). Later, when ready, the user can link an authentication method to their account. - + Calling `signInAnonymously()` creates an anonymous user. It's just like a permanent user, except the user can't access their account if they sign out, clear browsing data, or use another device. @@ -30,13 +30,13 @@ See the [Access control section](#access-control) for more details. - + The Supabase team has received reports of user metadata being cached across unique anonymous users as a result of Next.js static page rendering. For the best user experience, utilize dynamic page rendering. - + For self-hosting, you can update your project configuration using the files and environment variables provided. See the [local development docs](/docs/guides/cli/config) for more details. @@ -293,7 +293,7 @@ to authenticated using ( true ); ``` - + RLS policies are permissive by default, which means that they are combined using an "OR" operator when multiple policies are applied. It is important to construct restrictive policies to ensure that the checks for an anonymous user are always enforced when combined with other policies. Be aware that a single 'restrictive' RLS policy alone will fail unless combined with another policy that returns true, ensuring the combined condition is met. diff --git a/apps/docs/content/guides/auth/redirect-urls.mdx b/apps/docs/content/guides/auth/redirect-urls.mdx index 1175fd86dd6..050cbe1cf5f 100644 --- a/apps/docs/content/guides/auth/redirect-urls.mdx +++ b/apps/docs/content/guides/auth/redirect-urls.mdx @@ -42,7 +42,7 @@ Supabase allows you to specify wildcards when adding redirect URLs to the [allow The separator characters in a URL are defined as `.` and `/`. Use [this tool](https://www.digitalocean.com/community/tools/glob?comments=true&glob=http%3A%2F%2Flocalhost%3A3000%2F%2A%2A&matches=false&tests=http%3A%2F%2Flocalhost%3A3000&tests=http%3A%2F%2Flocalhost%3A3000%2F&tests=http%3A%2F%2Flocalhost%3A3000%2F%3Ftest%3Dtest&tests=http%3A%2F%2Flocalhost%3A3000%2Ftest-test%3Ftest%3Dtest&tests=http%3A%2F%2Flocalhost%3A3000%2Ftest%2Ftest%3Ftest%3Dtest) to test your patterns. - + While the "globstar" (`**`) is useful for local development and preview URLs, we recommend setting the exact redirect URL path for your site URL in production. diff --git a/apps/docs/content/guides/auth/social-login/auth-apple.mdx b/apps/docs/content/guides/auth/social-login/auth-apple.mdx index 81ad16a50cc..a419aa82a8c 100644 --- a/apps/docs/content/guides/auth/social-login/auth-apple.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-apple.mdx @@ -22,7 +22,7 @@ In some cases you're able to use the OAuth flow within web-based native apps suc When developing with Expo, you can test Sign in with Apple via the Expo Go app, in all other cases you will need to obtain an [Apple Developer](https://developer.apple.com) account to enable the capability. - + If you're using the OAuth flow (web, Flutter web, Kotlin non-iOS platforms), Apple requires you to generate a new secret key every 6 months using the signing key (`.p8` file). This is a critical maintenance task that will cause authentication failures if missed. @@ -35,7 +35,7 @@ This requirement only applies if you're configuring OAuth settings (Services ID, - + Apple's identity token does not include the user's full name in its claims. This means the Supabase Auth server cannot automatically populate the user's name metadata when users sign in with Apple. @@ -97,7 +97,7 @@ The platform-specific examples below demonstrate how to implement this pattern f This call takes the user to Apple's consent screen. Once the flow ends, the user's profile information is exchanged and validated with Supabase Auth before it redirects back to your web application with an access and refresh token representing the user's session. - + When using the OAuth flow, the user's full name is not accessible from Apple's response. Apple only provides the full name through native authentication methods (Sign in with Apple JS, or native iOS/macOS SDKs) during the first sign-in. @@ -371,7 +371,7 @@ curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ } ``` - + - Sign in with Apple is not natively available on Android devices - The OAuth flow opens a browser window for authentication diff --git a/apps/docs/content/guides/database/custom-postgres-config.mdx b/apps/docs/content/guides/database/custom-postgres-config.mdx index 22c1dceae66..4a2c30b7db6 100644 --- a/apps/docs/content/guides/database/custom-postgres-config.mdx +++ b/apps/docs/content/guides/database/custom-postgres-config.mdx @@ -208,7 +208,7 @@ select pg_postmaster_start_time(); You can also pass the `--no-restart` flag to attempt a reload-only apply. If the parameter cannot be reloaded, the change stays pending until the next restart. - + Postgres requires several parameters to be synchronized between the Primary cluster and [Read Replicas](/docs/guides/platform/read-replicas). diff --git a/apps/docs/content/guides/database/extensions/pg_net.mdx b/apps/docs/content/guides/database/extensions/pg_net.mdx index 29e4e47e703..d1a4505a50c 100644 --- a/apps/docs/content/guides/database/extensions/pg_net.mdx +++ b/apps/docs/content/guides/database/extensions/pg_net.mdx @@ -309,7 +309,7 @@ order by "created" desc; ## Configuration - + Supabase supports reconfiguring pg*net starting from v0.12.0+. For the latest release, initiate a Postgres upgrade in the [Infrastructure Settings](/dashboard/project/*/settings/infrastructure). diff --git a/apps/docs/content/guides/database/metabase.mdx b/apps/docs/content/guides/database/metabase.mdx index a2101c9e203..f7f0f17f3bd 100644 --- a/apps/docs/content/guides/database/metabase.mdx +++ b/apps/docs/content/guides/database/metabase.mdx @@ -43,7 +43,7 @@ hideToc: true - On your project dashboard, click [Connect](/dashboard/project/_?showConnect=true) - View parameters under "Session pooler" - + If you're in an [IPv6 environment](/docs/guides/platform/ipv4-address#checking-your-network-ipv6-support) or have the [IPv4 Add-On](/docs/guides/platform/ipv4-address#understanding-ip-addresses), you can use the direct connection string instead of Supavisor in Session mode. diff --git a/apps/docs/content/guides/database/postgres/row-level-security.mdx b/apps/docs/content/guides/database/postgres/row-level-security.mdx index 40fed201f49..ca895abf839 100644 --- a/apps/docs/content/guides/database/postgres/row-level-security.mdx +++ b/apps/docs/content/guides/database/postgres/row-level-security.mdx @@ -107,7 +107,7 @@ EXECUTE FUNCTION rls_auto_enable(); Note that this applies to tables created after the trigger is installed. Existing tables still need RLS enabled manually. - + When a request is made without an authenticated user (e.g., no access token is provided or the session has expired), `auth.uid()` returns `null`. @@ -156,7 +156,7 @@ to authenticated using ( true ); ``` - + Using the `anon` Postgres role is different from an [anonymous user](/docs/guides/auth/auth-anonymous) in Supabase Auth. An anonymous user assumes the `authenticated` role to access the database and can be differentiated from a permanent user by checking the `is_anonymous` claim in the JWT. diff --git a/apps/docs/content/guides/database/prisma.mdx b/apps/docs/content/guides/database/prisma.mdx index 1da56f1cdc5..31ea39f16e5 100644 --- a/apps/docs/content/guides/database/prisma.mdx +++ b/apps/docs/content/guides/database/prisma.mdx @@ -19,7 +19,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t - In the [SQL Editor](/dashboard/project/_/sql/new), create a Prisma DB user with full privileges on the public schema. - This gives you better control over Prisma's access and makes it easier to monitor using Supabase tools like the [Query Performance Dashboard](/dashboard/project/_/advisors/query-performance) and [Log Explorer](/dashboard/project/_/logs/explorer). - + For security, consider using a [password generator](https://bitwarden.com/password-generator/) for the Prisma role. @@ -283,7 +283,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t --to-schema prisma/schema.prisma \ --script > prisma/migrations/0_init_supabase/migration.sql ``` - + If there are any conflicts, reference [Prisma's official doc](https://www.prisma.io/docs/orm/prisma-migrate/getting-started#work-around-features-not-supported-by-prisma-schema-language) or the [trouble shooting guide](/docs/guides/database/prisma/prisma-troubleshooting) for more details @@ -311,7 +311,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t --to-schema prisma/schema.prisma \ --script > prisma/migrations/0_init_supabase/migration.sql ``` - + If there are any conflicts, reference [Prisma's official doc](https://www.prisma.io/docs/orm/prisma-migrate/getting-started#work-around-features-not-supported-by-prisma-schema-language) or the [trouble shooting guide](/docs/guides/database/prisma/prisma-troubleshooting) for more details @@ -339,7 +339,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t --to-schema prisma/schema.prisma \ --script > prisma/migrations/0_init_supabase/migration.sql ``` - + If there are any conflicts, reference [Prisma's official doc](https://www.prisma.io/docs/orm/prisma-migrate/getting-started#work-around-features-not-supported-by-prisma-schema-language) or the [trouble shooting guide](/docs/guides/database/prisma/prisma-troubleshooting) for more details @@ -367,7 +367,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t --to-schema prisma/schema.prisma \ --script > prisma/migrations/0_init_supabase/migration.sql ``` - + If there are any conflicts, reference [Prisma's official doc](https://www.prisma.io/docs/orm/prisma-migrate/getting-started#work-around-features-not-supported-by-prisma-schema-language) or the [trouble shooting guide](/docs/guides/database/prisma-troubleshooting) for more details diff --git a/apps/docs/content/guides/database/replication.mdx b/apps/docs/content/guides/database/replication.mdx index 305b8d5336c..53a92e048be 100644 --- a/apps/docs/content/guides/database/replication.mdx +++ b/apps/docs/content/guides/database/replication.mdx @@ -28,7 +28,7 @@ Read replicas are additional Supabase Postgres databases kept in sync with your ### External replication - + External replication is currently in private alpha. Access is limited and features may change. diff --git a/apps/docs/content/guides/database/replication/bigquery.mdx b/apps/docs/content/guides/database/replication/bigquery.mdx index 54a6d996b2d..c583f3a095e 100644 --- a/apps/docs/content/guides/database/replication/bigquery.mdx +++ b/apps/docs/content/guides/database/replication/bigquery.mdx @@ -6,7 +6,7 @@ subtitle: 'Replicate Supabase Postgres tables to BigQuery.' sidebar_label: 'BigQuery' --- - + External replication is currently in private alpha. Access is limited and features may change. diff --git a/apps/docs/content/guides/database/replication/external-replication-faq.mdx b/apps/docs/content/guides/database/replication/external-replication-faq.mdx index 424bcf64872..9a4c92f495f 100644 --- a/apps/docs/content/guides/database/replication/external-replication-faq.mdx +++ b/apps/docs/content/guides/database/replication/external-replication-faq.mdx @@ -6,7 +6,7 @@ subtitle: 'Common questions and answers about external replication.' sidebar_label: 'FAQ' --- - + External replication is currently in private alpha. Access is limited and features may change. diff --git a/apps/docs/content/guides/database/replication/external-replication-monitoring.mdx b/apps/docs/content/guides/database/replication/external-replication-monitoring.mdx index 5a03db9deec..12e460f88cf 100644 --- a/apps/docs/content/guides/database/replication/external-replication-monitoring.mdx +++ b/apps/docs/content/guides/database/replication/external-replication-monitoring.mdx @@ -6,7 +6,7 @@ subtitle: 'Track replication status, view logs, and troubleshoot issues.' sidebar_label: 'Monitoring' --- - + External replication is currently in private alpha. Access is limited and features may change. diff --git a/apps/docs/content/guides/database/replication/external-replication-setup.mdx b/apps/docs/content/guides/database/replication/external-replication-setup.mdx index 2b13ab71850..f1c58b4303d 100644 --- a/apps/docs/content/guides/database/replication/external-replication-setup.mdx +++ b/apps/docs/content/guides/database/replication/external-replication-setup.mdx @@ -6,7 +6,7 @@ subtitle: 'Configure publications and destinations for external replication.' sidebar_label: 'Setting up' --- - + External replication is currently in private alpha. Access is limited and features may change. diff --git a/apps/docs/content/guides/database/secure-data.mdx b/apps/docs/content/guides/database/secure-data.mdx index 8ca58f985a9..b1539b5f513 100644 --- a/apps/docs/content/guides/database/secure-data.mdx +++ b/apps/docs/content/guides/database/secure-data.mdx @@ -32,7 +32,7 @@ Your publishable key is safe to expose with RLS enabled, because row access perm Older projects may also show an `anon` key. Treat it like a publishable key: it can identify your project, but it is not a secret and must be paired with RLS and least-privilege grants. - + Unlike your publishable key, your secret and service role keys are **never** safe to expose because they bypass RLS. Only use your secret and service role keys on the backend. Treat them as secrets (for example, import them as sensitive environment variables instead of hardcoding them). diff --git a/apps/docs/content/guides/deployment/branching/configuration.mdx b/apps/docs/content/guides/deployment/branching/configuration.mdx index 128f8695925..e6c9f9df3b4 100644 --- a/apps/docs/content/guides/deployment/branching/configuration.mdx +++ b/apps/docs/content/guides/deployment/branching/configuration.mdx @@ -95,7 +95,7 @@ user = "env(SMTP_USER)" password = "env(SMTP_PASSWORD)" ``` - + Secrets set for one branch are not automatically available in other branches. You'll need to set them separately for each branch that needs them. @@ -152,7 +152,7 @@ client_id = "env(SUPABASE_AUTH_EXTERNAL_GITHUB_CLIENT_ID)" secret = "env(SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET)" ``` - + The `encrypted:` syntax only works for designated "secret" fields in the configuration. Using encrypted values in other fields will not be automatically decrypted and may cause issues. For non-secret fields, use environment variables with the `env()` syntax instead. diff --git a/apps/docs/content/guides/deployment/branching/dashboard.mdx b/apps/docs/content/guides/deployment/branching/dashboard.mdx index a7577014d5a..e3367f382fe 100644 --- a/apps/docs/content/guides/deployment/branching/dashboard.mdx +++ b/apps/docs/content/guides/deployment/branching/dashboard.mdx @@ -5,7 +5,7 @@ subtitle: 'Create, manage, review, and merge branches directly in the dashboard' You can create, manage, review, and merge Supabase branches directly via the dashboard. This is useful for quick testing, prototyping, or when you prefer to work in a no-code way. You can also connect a Supabase branch to a GitHub branch at a later time if needed. - + Branch management via the dashboard is currently in public alpha. Features and functionality may change. diff --git a/apps/docs/content/guides/deployment/branching/integrations.mdx b/apps/docs/content/guides/deployment/branching/integrations.mdx index e1d89e75a9b..3e703dc892a 100644 --- a/apps/docs/content/guides/deployment/branching/integrations.mdx +++ b/apps/docs/content/guides/deployment/branching/integrations.mdx @@ -17,7 +17,7 @@ Install the Vercel integration: - From the [Vercel marketplace](https://vercel.com/integrations/supabase) or - By clicking the blue `Deploy` button in a Supabase example app's `README` file - + For branching to work with Vercel, you also need the [Vercel GitHub integration](https://vercel.com/docs/deployments/git/vercel-for-github). diff --git a/apps/docs/content/guides/deployment/going-into-prod.mdx b/apps/docs/content/guides/deployment/going-into-prod.mdx index a66683497cc..96bad598e91 100644 --- a/apps/docs/content/guides/deployment/going-into-prod.mdx +++ b/apps/docs/content/guides/deployment/going-into-prod.mdx @@ -76,7 +76,7 @@ Check and review issues in your database using [Performance Advisor](/dashboard/ ## Rate limiting, resource allocation, & abuse prevention - + Running databases is a shared responsibility between you and Supabase. There are some things that we can take care of for you, and some things that you are responsible for. diff --git a/apps/docs/content/guides/functions/deploy.mdx b/apps/docs/content/guides/functions/deploy.mdx index 038d379281f..e5ccb12e30d 100644 --- a/apps/docs/content/guides/functions/deploy.mdx +++ b/apps/docs/content/guides/functions/deploy.mdx @@ -34,7 +34,7 @@ Get the project ID associated with your function: supabase projects list ``` - + If you haven't yet created a Supabase project, you can do so by visiting [database.new](https://database.new). diff --git a/apps/docs/content/guides/functions/development-tips.mdx b/apps/docs/content/guides/functions/development-tips.mdx index 6a333357c42..edddd412140 100644 --- a/apps/docs/content/guides/functions/development-tips.mdx +++ b/apps/docs/content/guides/functions/development-tips.mdx @@ -11,7 +11,7 @@ Here are a few recommendations when you first start developing Edge Functions. Edge Functions support `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, and `OPTIONS`. A Function can be designed to perform different actions based on a request's HTTP method. See the [example on building a RESTful service](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/restful-tasks) to learn how to handle different HTTP methods in your Function. - + HTML content is not supported. `GET` requests that return `text/html` will be rewritten to `text/plain`. diff --git a/apps/docs/content/guides/functions/quickstart-dashboard.mdx b/apps/docs/content/guides/functions/quickstart-dashboard.mdx index 16ab71e2baf..7fae4fc918f 100644 --- a/apps/docs/content/guides/functions/quickstart-dashboard.mdx +++ b/apps/docs/content/guides/functions/quickstart-dashboard.mdx @@ -9,13 +9,13 @@ Supabase allows you to create Supabase Edge Functions directly from the Supabase This guide will walk you through creating, testing, and deploying your first Edge Function using the Supabase Dashboard. You'll have a working function running globally in under 10 minutes. - + You can also create and deploy functions using the Supabase CLI. Check out our [CLI Quickstart guide](/docs/guides/functions/quickstart). - + You'll need a Supabase project to get started. If you don't have one yet, create a new project at [database.new](https://database.new/). @@ -50,7 +50,7 @@ width={2338} height={926} /> - + The dashboard offers several pre-built templates for common use cases, such as Stripe Webhooks, OpenAI proxying, uploading files to Supabase Storage, and sending emails. @@ -139,7 +139,7 @@ To invoke this Edge Function from within your application, you'll need API keys. If you’d like to update the deployed function code, click on the function you want to edit, modify the code as needed, then click Deploy updates. This will overwrite the existing deployment with the newly edited function code. - + There is currently **no version control** for edits! The Dashboard's Edge Function editor currently does not support version control, versioning, or rollbacks. We recommend using it only for quick testing and prototypes. @@ -241,7 +241,7 @@ Now that your function is deployed, you can access it from your local developmen ### CLI - + Before getting started, make sure you have the **Supabase CLI installed**. Check out the [CLI installation guide](/docs/guides/cli) for installation methods and troubleshooting. diff --git a/apps/docs/content/guides/functions/quickstart.mdx b/apps/docs/content/guides/functions/quickstart.mdx index ef9fb77823f..3d9c3e064e4 100644 --- a/apps/docs/content/guides/functions/quickstart.mdx +++ b/apps/docs/content/guides/functions/quickstart.mdx @@ -53,7 +53,7 @@ supabase functions new hello-world {/* TODO: Link to parameter documentation */} - + When an HTTP request is sent to Edge Functions, you can use Supabase Auth to secure endpoints. By default, the `supabase functions new` command adds handling a valid publishable or secret key to the basic template. However, you can change this behavior with the `--auth` flag when creating a new function. @@ -132,7 +132,7 @@ After this step, you should have successfully tested your Edge Function locally To deploy your function globally, you need to connect your local project to a Supabase project. - + Create one at [database.new](https://database.new/). @@ -176,7 +176,7 @@ If you want to deploy all functions, run the `deploy` command without specifying supabase functions deploy ``` - + The CLI automatically falls back to API-based deployment if Docker isn't available. You can also explicitly use API deployment with the `--use-api` flag: diff --git a/apps/docs/content/guides/getting-started/tutorials/with-angular.mdx b/apps/docs/content/guides/getting-started/tutorials/with-angular.mdx index 9ad548dfa13..f6b075f0485 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-angular.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-angular.mdx @@ -58,7 +58,7 @@ Optionally, update `src/styles.css` to style the app. You can find the full cont You need an Angular component to manage logins and sign ups. The component uses [Magic Links](/docs/guides/auth/auth-email-passwordless#with-magic-link), so users can sign in with their email without using passwords. - + You can customize other emails sent out to new users, including the email's looks, content, and query parameters from [the **Authentication > Email**](/dashboard/project/_/auth/templates) section of the Dashboard. diff --git a/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx b/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx index 6487f311807..82e348a086d 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx @@ -182,7 +182,7 @@ Before proceeding, change the email template to support a server-side authentica - Select the **Confirm signup** template. - Change `{{ .ConfirmationURL }}` to `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email`. - + You can customize other emails sent out to new users, including the email's looks, content, and query parameters from [the **Authentication > Email**](/dashboard/project/_/auth/templates) section of the Dashboard. diff --git a/apps/docs/content/guides/getting-started/tutorials/with-react.mdx b/apps/docs/content/guides/getting-started/tutorials/with-react.mdx index f740c3a7c8e..690f32feb0b 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-react.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-react.mdx @@ -62,7 +62,7 @@ You can find the full contents of this file [in the example repository](https:// You need a React component to manage logins and sign-ups. It uses [Magic Links](/docs/guides/auth/auth-email-passwordless#with-magic-link), so users can sign in with their email without using passwords. - + You can customize other emails sent out to new users, including the email's looks, content, and query parameters from [the **Authentication > Email**](/dashboard/project/_/auth/templates) section of the Dashboard. diff --git a/apps/docs/content/guides/getting-started/tutorials/with-sveltekit.mdx b/apps/docs/content/guides/getting-started/tutorials/with-sveltekit.mdx index a6f8b9bd743..f46e2edd0bd 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-sveltekit.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-sveltekit.mdx @@ -169,7 +169,7 @@ Before proceeding, change the email template to support sending a token hash: - Change `{{ .ConfirmationURL }}` to `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email`. - Repeat the previous step for **Magic link** template. - + You can also customize emails sent out to new users, including the email's looks, content, and query parameters. Check out the [settings of your project](/dashboard/project/_/auth/templates). diff --git a/apps/docs/content/guides/local-development/cli/getting-started.mdx b/apps/docs/content/guides/local-development/cli/getting-started.mdx index f3675c13895..8b4fb90c14d 100644 --- a/apps/docs/content/guides/local-development/cli/getting-started.mdx +++ b/apps/docs/content/guides/local-development/cli/getting-started.mdx @@ -234,7 +234,7 @@ npm update supabase@beta --save-dev If you have any Supabase containers running locally, stop them and delete their data volumes before proceeding with the upgrade. This ensures that Supabase managed services can apply new migrations on a clean state of the local database. - + Remember to save any local schema and data changes before stopping because the `--no-backup` flag will delete them. diff --git a/apps/docs/content/guides/platform/backups.mdx b/apps/docs/content/guides/platform/backups.mdx index 82091f9a6cb..6d437b241f5 100644 --- a/apps/docs/content/guides/platform/backups.mdx +++ b/apps/docs/content/guides/platform/backups.mdx @@ -17,7 +17,7 @@ When you delete a project, we permanently remove all associated data, including Database backups can be categorized into two types: **logical** and **physical**. You can learn more about them [in this blog post](/blog/postgresql-physical-logical-backups). - + All projects on Postgres `15.8.1.079` and newer use the newer physical backup process. diff --git a/apps/docs/content/guides/platform/clone-project.mdx b/apps/docs/content/guides/platform/clone-project.mdx index dcc3abcdb06..833091c0f1b 100644 --- a/apps/docs/content/guides/platform/clone-project.mdx +++ b/apps/docs/content/guides/platform/clone-project.mdx @@ -3,7 +3,7 @@ title: Restore to a new project subtitle: How to clone your existing Supabase project --- - + You can clone your Supabase project by restoring your data from an existing project into a completely new one. This process creates a database-only copy and requires manual reconfiguration to fully replicate your original project. diff --git a/apps/docs/content/guides/platform/compute-and-disk.mdx b/apps/docs/content/guides/platform/compute-and-disk.mdx index 0c19b903841..84063513865 100644 --- a/apps/docs/content/guides/platform/compute-and-disk.mdx +++ b/apps/docs/content/guides/platform/compute-and-disk.mdx @@ -10,7 +10,7 @@ Every project on the Supabase Platform comes with its own dedicated Postgres ins The following table describes the base instances, Nano (free plan) and Micro (paid plans), with additional compute instance sizes available if you need extra performance when scaling up. - + In paid organizations, Nano Compute are billed at the same price as Micro Compute. It is recommended to upgrade your Project from Nano Compute to Micro Compute when it's convenient for you. Compute sizes are not auto-upgraded because of the downtime incurred. See [Supabase Pricing](/pricing) for more information. You cannot launch Nano instances on paid plans, only Micro and above - but you might have Nano instances after upgrading from Free Plan. diff --git a/apps/docs/content/guides/platform/manage-your-usage/compute.mdx b/apps/docs/content/guides/platform/manage-your-usage/compute.mdx index 525b5ec5b33..1e228e41f35 100644 --- a/apps/docs/content/guides/platform/manage-your-usage/compute.mdx +++ b/apps/docs/content/guides/platform/manage-your-usage/compute.mdx @@ -61,7 +61,7 @@ Paid plans include in Compute Credits, which cover one proj [^1]: Compute resources on the Free Plan are subject to change. - + In paid organizations, Nano Compute are billed at the same price as Micro Compute. It is recommended to upgrade your Project from Nano Compute to Micro Compute when it's convenient for you. Compute sizes are not auto-upgraded because of the downtime incurred. See [Supabase Pricing](/pricing) for more information. You cannot launch Nano instances on paid plans, only Micro and above - but you might have Nano instances after upgrading from Free Plan. diff --git a/apps/docs/content/guides/platform/migrating-to-supabase/auth0.mdx b/apps/docs/content/guides/platform/migrating-to-supabase/auth0.mdx index 5bd2ffc10ce..c8f9825d7ef 100644 --- a/apps/docs/content/guides/platform/migrating-to-supabase/auth0.mdx +++ b/apps/docs/content/guides/platform/migrating-to-supabase/auth0.mdx @@ -90,7 +90,7 @@ Migrate existing users to Supabase Auth. This requires two main steps: first, ch }) ``` - + Supabase supports bcrypt and Argon2 password hashes. diff --git a/apps/docs/content/guides/platform/sso.mdx b/apps/docs/content/guides/platform/sso.mdx index 655ff458d1f..8c5e8059cc1 100644 --- a/apps/docs/content/guides/platform/sso.mdx +++ b/apps/docs/content/guides/platform/sso.mdx @@ -29,7 +29,7 @@ Once configured, you can update your settings anytime from [the **SSO** section] ![SSO Example](/docs/img/sso-dashboard-enabled-idp.png) - + After configuring your SSO provider, thorough testing is essential. See our [SSO Testing and Best Practices](/docs/guides/platform/sso/testing-best-practices) guide for: @@ -100,13 +100,13 @@ When SSO is enabled for an organization: 6. Thoroughly test using our [SSO Testing and Best Practices](/docs/guides/platform/sso/testing-best-practices) guide 7. Invite users to the organization or let them auto-join on login - + If a user is already a member of the organization under a non-SSO account, they will need to be removed and invited again with an SSO-required invitation to join under their SSO account. SSO and non-SSO accounts with the same email are treated as separate accounts. - + Each user account verified using a SSO identity provider will not be automatically linked to existing user accounts in the system. That is, if a user `valid.email@supabase.io` had signed up with a password, and then uses their company SSO login with your project, there will be two `valid.email@supabase.io` user accounts in the system. @@ -118,7 +118,7 @@ Users will need to ensure they are logged in with the correct account when accep If you disable or delete the SSO provider for an organization, **all SSO users will immediately be unable to sign in**. - + The system requires at least one non-SSO owner account before allowing SSO provider deletion. This prevents complete organization lockout. When you delete an SSO provider, all SSO members are automatically removed from the organization. diff --git a/apps/docs/content/guides/platform/sso/azure.mdx b/apps/docs/content/guides/platform/sso/azure.mdx index 1794628df08..62d0a7ef3eb 100644 --- a/apps/docs/content/guides/platform/sso/azure.mdx +++ b/apps/docs/content/guides/platform/sso/azure.mdx @@ -164,7 +164,7 @@ Before rolling out SSO to your organization, we strongly recommend thorough test - Security best practices - Pre-launch checklist - + If your organization has an Azure sandbox or test tenant, consider testing your SSO configuration there first before applying to production. diff --git a/apps/docs/content/guides/platform/sso/choosing-login-flow.mdx b/apps/docs/content/guides/platform/sso/choosing-login-flow.mdx index 986bdbce3d5..8816d78ac1c 100644 --- a/apps/docs/content/guides/platform/sso/choosing-login-flow.mdx +++ b/apps/docs/content/guides/platform/sso/choosing-login-flow.mdx @@ -240,7 +240,7 @@ Do users need to start login at supabase.com? 4. Use **non-SSO invitations** for contractors 5. Consider disabling auto-join to control membership - + SSO and non-SSO accounts with the same email are treated as separate accounts. An employee with `alice@company.com` will have two accounts if they: @@ -306,7 +306,7 @@ If you're uncertain which configuration to use: 4. Enable SP-initiated if users request it 5. Monitor usage to see which flow is preferred - + If you need help choosing the right configuration for your organization, contact Supabase support with details about your use case. We're happy to provide personalized recommendations. diff --git a/apps/docs/content/guides/platform/sso/login-flows.mdx b/apps/docs/content/guides/platform/sso/login-flows.mdx index 54db12195a8..806efb9c9ea 100644 --- a/apps/docs/content/guides/platform/sso/login-flows.mdx +++ b/apps/docs/content/guides/platform/sso/login-flows.mdx @@ -5,7 +5,7 @@ description: 'Learn about IdP-initiated and SP-initiated SSO login flows and whe When configuring SSO for your organization, you can choose between two different login flows: **identity provider (IdP)-initiated** and **service provider (SP)-initiated**. Understanding the difference helps you provide the best experience for your users. - + Most enterprises use IdP-initiated flow for its simplicity and better user experience. Enable SP-initiated only if you need users to start their login journey at supabase.com. @@ -107,7 +107,7 @@ IdP-initiated flow is automatically enabled when you configure SSO. No additiona Users can now access Supabase through your IdP's app catalog. - + With IdP-initiated flow, you don't need to configure email domains. Your identity provider handles all authentication routing. @@ -131,7 +131,7 @@ To enable SP-initiated flow, you need to configure email domains: - Multiple domains supported (e.g., `company.com`, `subsidiary.com`) - Users with matching email domains will be routed to your IdP - + Only users with email addresses matching your configured domains can use SP-initiated login. Users with other domains cannot sign in via SSO at supabase.com (but can still use IdP-initiated flow if you configure it in your IdP). diff --git a/apps/docs/content/guides/platform/sso/multiple-providers.mdx b/apps/docs/content/guides/platform/sso/multiple-providers.mdx index c9b9dc54d9c..3c28ee06e70 100644 --- a/apps/docs/content/guides/platform/sso/multiple-providers.mdx +++ b/apps/docs/content/guides/platform/sso/multiple-providers.mdx @@ -21,7 +21,7 @@ The traditional challenge with multiple SAML apps is domain conflicts. With SP-i **Solution:** Use identity provider (IdP)-initiated flow, which doesn't require domain configuration. You can create unlimited SAML apps under the same domain. - + Configure each environment as IdP-initiated only (no domains). Users access each environment through different app tiles in your identity provider. @@ -126,7 +126,7 @@ Configure both organizations with SP-initiated enabled using the same domain: - System routes based on org membership (first match wins) - Also provide IDP tiles for explicit routing - + When multiple organizations use SP-initiated with the same domain, the first provider where the user is a member will be used. This can cause confusion. **IDP-initiated is recommended** for clarity. diff --git a/apps/docs/content/guides/realtime/limits.mdx b/apps/docs/content/guides/realtime/limits.mdx index 5aea01f8202..edea5d6dc2d 100644 --- a/apps/docs/content/guides/realtime/limits.mdx +++ b/apps/docs/content/guides/realtime/limits.mdx @@ -35,7 +35,7 @@ When you exceed a limit, errors will appear in the backend logs and client-side - **Logs**: check the [Realtime logs](/dashboard/project/_/database/realtime-logs) inside your project Dashboard. - **WebSocket errors**: Use your browser's developer tools to find the WebSocket initiation request and view individual messages. - + You can use the [Realtime Inspector](https://realtime.supabase.com/inspector/new) to reproduce an error and share those connection details with Supabase support. diff --git a/apps/docs/content/guides/self-hosting/docker.mdx b/apps/docs/content/guides/self-hosting/docker.mdx index 22fdba57a4f..7e906e6a4ef 100644 --- a/apps/docs/content/guides/self-hosting/docker.mdx +++ b/apps/docs/content/guides/self-hosting/docker.mdx @@ -172,7 +172,7 @@ Review and change URL configuration variables: - `API_EXTERNAL_URL`: used by the Auth service to configure callback URLs, e.g., `http://example.com:8000` - `SITE_URL`: default [redirect URL](/docs/guides/auth/redirect-urls) for Auth, e.g., `http://example.com:3000` - + Throughout the self-hosting guides, `` stands for the host where your Supabase instance is reachable: your domain name, your server's IP, or `localhost`, depending on your setup. @@ -236,7 +236,7 @@ To stop Supabase, use: docker compose down ``` - + If the API gateway (Kong) fails to start with an entrypoint error, your local files may have been checked out with CRLF line endings instead of LF. Re-clone the repository, or normalize everything in the `docker/` directory to LF, then restart Supabase. Fresh clones should already use LF because of `.gitattributes`. diff --git a/apps/docs/content/guides/self-hosting/postgres-upgrade-17.mdx b/apps/docs/content/guides/self-hosting/postgres-upgrade-17.mdx index e61b7e7bde6..ef94ac11a7a 100644 --- a/apps/docs/content/guides/self-hosting/postgres-upgrade-17.mdx +++ b/apps/docs/content/guides/self-hosting/postgres-upgrade-17.mdx @@ -58,7 +58,7 @@ Upgrading an existing deployment uses `pg_upgrade` to migrate data in place. The ### Create a backup - + You should create your own independent backup in case of disk failure or other issues. diff --git a/apps/docs/content/guides/self-hosting/self-hosted-proxy-https.mdx b/apps/docs/content/guides/self-hosting/self-hosted-proxy-https.mdx index 5d4df0ceda2..cbdaebfb1c4 100644 --- a/apps/docs/content/guides/self-hosting/self-hosted-proxy-https.mdx +++ b/apps/docs/content/guides/self-hosting/self-hosted-proxy-https.mdx @@ -18,7 +18,7 @@ You need: Below are two options for adding a reverse proxy with automatic HTTPS in front of your self-hosted Supabase: **Caddy** (simpler, zero-config TLS) and **Nginx + Let's Encrypt** (more control over proxy settings). Both sit in front of the API gateway and terminate TLS, so internal traffic stays on HTTP. - + If you already run [HAProxy](https://www.haproxy.com/), [Traefik](https://traefik.io/), [Nginx Proxy Manager](https://nginxproxymanager.com/), or another reverse proxy for your infrastructure, you can use it instead of Caddy or Nginx above. The key requirements are: @@ -31,7 +31,7 @@ If you already run [HAProxy](https://www.haproxy.com/), [Traefik](https://traefi - + Envoy is an optional [API gateway](/docs/guides/self-hosting/self-hosted-envoy), enabled via the `docker-compose.envoy.yml` override. If you already run Envoy instead of Kong, edit `docker-compose.caddy.yml` or `docker-compose.nginx.yml` to comment out the `kong:` block and uncomment the `api-gw:` block (and the matching `depends_on` entry) so the reverse proxy sits in front of Envoy. diff --git a/apps/docs/content/guides/storage/s3/authentication.mdx b/apps/docs/content/guides/storage/s3/authentication.mdx index bdd21c52270..879f3f11149 100644 --- a/apps/docs/content/guides/storage/s3/authentication.mdx +++ b/apps/docs/content/guides/storage/s3/authentication.mdx @@ -13,7 +13,7 @@ You have two options to authenticate with Supabase Storage S3: ## S3 access keys - + S3 access keys provide full access to all S3 operations across all buckets and bypass RLS policies. These are meant to be used only on the server. diff --git a/apps/docs/docs/ref/self-hosting-analytics/introduction.mdx b/apps/docs/docs/ref/self-hosting-analytics/introduction.mdx index f6ebb948d81..2c2c31ab4be 100644 --- a/apps/docs/docs/ref/self-hosting-analytics/introduction.mdx +++ b/apps/docs/docs/ref/self-hosting-analytics/introduction.mdx @@ -18,7 +18,7 @@ The Supabase Analytics server is a Logflare self-hostable instance that manages When self-hosting the Analytics server, the full logging experience matching that of the Supabase Platform is available in the Studio instance, allowing for an integrated and enhanced development experience. However, it's important to note that certain [differences](#differences) may arise due to the platform's infrastructure. - + All Logflare technical documentation is available at https://docs.logflare.app. diff --git a/apps/docs/docs/ref/self-hosting-functions/introduction.mdx b/apps/docs/docs/ref/self-hosting-functions/introduction.mdx index 64661800631..70ae44e906d 100644 --- a/apps/docs/docs/ref/self-hosting-functions/introduction.mdx +++ b/apps/docs/docs/ref/self-hosting-functions/introduction.mdx @@ -21,7 +21,7 @@ You can use it to: - Locally test and self-host Supabase's Edge Functions (or any Deno Edge Function) - As a programmable HTTP Proxy: You can intercept / route HTTP requests - + Self hosted Edge functions are in beta. There will be breaking changes to APIs / Configuration Options. From e65da2c3e747cffa517e796e686bc32ddb95d3f7 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 18 May 2026 19:02:51 +1000 Subject: [PATCH 02/20] feat(studio): move redeem credits to connect interstitial (#45909) ## What kind of change does this PR introduce? Feature. Part of DEPR-556. ## What is the current behavior? The `/redeem` credit redemption page uses the legacy redeem credits layout and scaffolded two-column UI. It is visually separate from the newer connect interstitial surfaces. ## What is the new behavior? `/redeem` now uses the shared connect interstitial layout with a Supabase logo, signed-in account row, organisation selector, and full-width redeem action. The existing `CreditCodeRedemption` modal still owns the actual code entry, hCaptcha, permission checks, mutation, and success/error handling. Creating a new organisation links to `/new` with a return URL, and the returned organisation is preselected when present. Temporary mock states are available on non-production and Vercel preview environments: - `/redeem?mock=loading` - `/redeem?mock=ready` - `/redeem?mock=redeeming` - `/redeem?mock=redeemed` - `/redeem?mock=already-redeemed` - `/redeem?mock=invalid` - `/redeem?mock=wrong-account` - `/redeem?mock=error` | Before | After | | --- | --- | | Redeem Credits
Supabase-66AF2C81-89A0-4F95-A591-3550AAD7112A | Redeem Credits
Supabase-7F9C59DD-73EC-4717-B2B7-3E6BB325273C | | Redeem Credits
Supabase-A77F2F93-98C3-42E9-B028-049E1AAC0CA3 | Redeem Credits
Supabase-4C4B7818-16A5-430E-B54F-E7FF34229CA0 | | Redeem Credits
Supabase-A77F2F93-98C3-42E9-B028-049E1AAC0CA3 | Redeem Credits
Supabase-4C4B7818-16A5-430E-B54F-E7FF34229CA0 | Redeem Credits
Supabase-49FC982C-2605-41B3-8216-B8F270825247 | | Redeem Credits
Supabase-6AF18EF8-1699-4341-A900-34E3AEB59703 | Redeem Credits
Supabase-4F8BBF66-6AF6-403F-B949-079F8E47200B | | Redeem Credits
Supabase-320E56E7-A291-426B-92B4-5CD4518C85B6 | Redeem Credits
Supabase-7E55274C-E39E-443B-B636-63B597E11494 | | Redeem Credits
Supabase-4D5A1985-D36B-48E1-8108-E95C10C52A12 | Redeem Credits
Supabase-B1296556-9DDF-48ED-A0DA-4BEFF5D20FBA | | Redeem Credits
Supabase-193C4266-29BC-4B03-A746-4C2CE3BD29EB | Redeem Credits
Supabase-751A900D-A8CF-425C-A745-1511B6DC81E3 | | Redeem Credits
Supabase-29305C15-19B7-4015-9041-62B79E0C95A8 | Redeem Credits
Supabase-A1628FDB-CFBE-47E7-83D7-3D3D426C3C1E | ## Summary by CodeRabbit ## Release Notes * **New Features** * Added organization selector to the credits redemption workflow. * New "Create Organization" card for quick access to organization creation. * **Refactor** * Streamlined the credits redemption page for improved user experience. * Enhanced organization card display with customizable descriptions. * **Style** * Removed "Coming soon" beta indicators from action cards. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45909?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --------- Co-authored-by: Joshen Lim --- .../Connect/OrganizationSelector.tsx | 180 +++++++++++++++ .../Organization/OrganizationCard.tsx | 87 +++++--- .../RedeemCredits/RedeemCredits.test.tsx | 145 ++++++++++++ .../RedeemCredits/RedeemCredits.tsx | 206 ++++++++++++++++++ .../layouts/RedeemCreditsLayout.tsx | 53 ----- .../components/layouts/Tabs/ActionCard.tsx | 8 +- apps/studio/components/ui/ActionCard.tsx | 18 +- apps/studio/pages/redeem.tsx | 119 ++-------- 8 files changed, 615 insertions(+), 201 deletions(-) create mode 100644 apps/studio/components/interfaces/Connect/OrganizationSelector.tsx create mode 100644 apps/studio/components/interfaces/RedeemCredits/RedeemCredits.test.tsx create mode 100644 apps/studio/components/interfaces/RedeemCredits/RedeemCredits.tsx delete mode 100644 apps/studio/components/layouts/RedeemCreditsLayout.tsx diff --git a/apps/studio/components/interfaces/Connect/OrganizationSelector.tsx b/apps/studio/components/interfaces/Connect/OrganizationSelector.tsx new file mode 100644 index 00000000000..7d2a98f7216 --- /dev/null +++ b/apps/studio/components/interfaces/Connect/OrganizationSelector.tsx @@ -0,0 +1,180 @@ +import { LOCAL_STORAGE_KEYS } from 'common' +import { Check, ChevronDown } from 'lucide-react' +import { useMemo, useState, type ReactNode } from 'react' +import { cn, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui' + +import { + CreateOrganizationCard, + OrganizationCard, +} from '@/components/interfaces/Organization/OrganizationCard' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' +import type { Organization } from '@/types' + +const VISIBLE_ORGANIZATIONS_LIMIT = 3 +const CONNECT_DISCLOSURE_TRIGGER_CLASSNAME = + 'mx-auto flex h-7 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2 text-xs text-foreground-lighter transition-colors hover:bg-surface-200 hover:text-foreground' + +export const OrganizationSelector = ({ + organizations, + selectedSlug, + disabled = false, + description, + createLabel, + createHrefParams, + onSelect, + getOrganizationDescription, +}: { + organizations: Organization[] + selectedSlug?: string | null + disabled?: boolean + description?: ReactNode + createLabel?: string + createHrefParams?: { [key: string]: string } + onSelect: (slug: string) => void + getOrganizationDescription?: (organization: Organization) => ReactNode +}) => { + const [showMore, setShowMore] = useState(false) + const [lastVisitedOrganization] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION, + '' + ) + + const { visibleOrganizations, overflowOrganizations } = useMemo(() => { + const lastVisitedOrg = organizations.find(({ slug }) => slug === lastVisitedOrganization) + const selectedIndex = organizations.findIndex(({ slug }) => slug === selectedSlug) + const selectedInOverflow = selectedIndex >= VISIBLE_ORGANIZATIONS_LIMIT + + if (!!lastVisitedOrg) { + const withoutLastVisited = organizations.filter( + ({ slug }) => slug !== lastVisitedOrganization + ) + return { + visibleOrganizations: [ + lastVisitedOrg, + ...withoutLastVisited.slice(0, VISIBLE_ORGANIZATIONS_LIMIT - 1), + ], + overflowOrganizations: withoutLastVisited.slice(VISIBLE_ORGANIZATIONS_LIMIT - 1), + } + } + + if (!selectedInOverflow || !selectedSlug) { + return { + visibleOrganizations: organizations.slice(0, VISIBLE_ORGANIZATIONS_LIMIT), + overflowOrganizations: organizations.slice(VISIBLE_ORGANIZATIONS_LIMIT), + } + } + + const selected = organizations[selectedIndex] + const withoutSelected = organizations.filter(({ slug }) => slug !== selectedSlug) + + return { + visibleOrganizations: [ + ...withoutSelected.slice(0, VISIBLE_ORGANIZATIONS_LIMIT - 1), + selected, + ], + overflowOrganizations: withoutSelected.slice(VISIBLE_ORGANIZATIONS_LIMIT - 1), + } + }, [lastVisitedOrganization, organizations, selectedSlug]) + + const hasOverflow = overflowOrganizations.length > 0 + + return ( +
+
+

+ Organization +

+ {description &&

{description}

} +
+
+ {visibleOrganizations.map((organization) => ( + onSelect(organization.slug)} + description={ + getOrganizationDescription?.(organization) ?? getPlanDescription(organization) + } + /> + ))} + + {!!createLabel && !!createHrefParams && ( + + )} + + {hasOverflow && ( + + + {showMore ? 'Show fewer' : `Show ${overflowOrganizations.length} more`} + + + +
+ {overflowOrganizations.map((organization) => ( + onSelect(organization.slug)} + description={ + getOrganizationDescription?.(organization) ?? getPlanDescription(organization) + } + /> + ))} +
+
+
+ )} +
+
+ ) +} + +const getPlanDescription = (organization: Organization) => `${organization.plan.name} Plan` + +const ConnectOrganizationButton = ({ + organization, + selected, + disabled, + onClick, + description, +}: { + organization: Organization + selected?: boolean + disabled?: boolean + onClick?: () => void + description?: ReactNode +}) => ( + +) diff --git a/apps/studio/components/interfaces/Organization/OrganizationCard.tsx b/apps/studio/components/interfaces/Organization/OrganizationCard.tsx index 417d24004c3..b33ecb67d0e 100644 --- a/apps/studio/components/interfaces/Organization/OrganizationCard.tsx +++ b/apps/studio/components/interfaces/Organization/OrganizationCard.tsx @@ -1,7 +1,7 @@ import { useIsMFAEnabled } from 'common' -import { Boxes, Lock } from 'lucide-react' +import { Boxes, Lock, Plus } from 'lucide-react' import Link from 'next/link' -import { Fragment } from 'react' +import { Fragment, type ReactNode } from 'react' import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { ActionCard } from '@/components/ui/ActionCard' @@ -15,18 +15,21 @@ export const OrganizationCard = ({ isLink = true, className, onClick, + description, }: { organization: Organization href?: string isLink?: boolean className?: string onClick?: () => void + description?: ReactNode }) => { const isUserMFAEnabled = useIsMFAEnabled() const isPlatformOrg = organization.plan?.id === 'platform' + const shouldRenderDefaultDescription = description === undefined const { data } = useOrgProjectsInfiniteQuery( { slug: organization.slug }, - { enabled: !isPlatformOrg } + { enabled: !isPlatformOrg && shouldRenderDefaultDescription } ) const numProjects = data?.pages[0].pagination.count ?? 0 const isMfaRequired = organization.organization_requires_mfa @@ -42,32 +45,36 @@ export const OrganizationCard = ({ title={organization.name} onClick={onClick} description={ -
-
- {organization.plan.name} Plan - {numProjects > 0 && ( - <> - · - - {numProjects} project{numProjects > 1 ? 's' : ''} - - - )} + shouldRenderDefaultDescription ? ( +
+
+ {organization.plan.name} Plan + {numProjects > 0 && ( + <> + · + + {numProjects} project{numProjects > 1 ? 's' : ''} + + + )} +
+
+ + {isMfaRequired && ( + + + + + + MFA enforced + + + )} +
-
- - {isMfaRequired && ( - - - - - - MFA enforced - - - )} -
-
+ ) : ( + description + ) } /> ) @@ -78,3 +85,27 @@ export const OrganizationCard = ({ return {renderContent()} } } + +export const CreateOrganizationCard = ({ + params = {}, + label = 'Create new organization', +}: { + params?: { [key: string]: string } + label?: string +}) => { + const createOrganizationHref = `/new${Object.keys(params).length > 0 ? `?${new URLSearchParams(params).toString()}` : ''}` + + return ( + + div]:w-full [&>div]:items-center max-h-min', + 'border-dashed shadow-none transition-colors group-hover:border-default group-hover:bg-surface-200' + )} + icon={} + title={label} + /> + + ) +} diff --git a/apps/studio/components/interfaces/RedeemCredits/RedeemCredits.test.tsx b/apps/studio/components/interfaces/RedeemCredits/RedeemCredits.test.tsx new file mode 100644 index 00000000000..d9842db90a2 --- /dev/null +++ b/apps/studio/components/interfaces/RedeemCredits/RedeemCredits.test.tsx @@ -0,0 +1,145 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FeatureFlagContext } from 'common' +import { HttpResponse } from 'msw' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { RedeemCreditsScreen } from './RedeemCredits' +import type { ProfileContextType } from '@/lib/profile' +import { createMockOrganization } from '@/tests/helpers' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' +import { routerMock } from '@/tests/lib/route-mock' + +const { creditRedemptionProps } = vi.hoisted(() => ({ + creditRedemptionProps: vi.fn(), +})) +const { creditRedemptionQueryCode } = vi.hoisted(() => ({ + creditRedemptionQueryCode: { current: undefined as string | undefined }, +})) + +vi.mock('@/components/interfaces/Organization/BillingSettings/CreditCodeRedemption', () => { + return { + CreditCodeRedemption: (props: { slug?: string }) => { + creditRedemptionProps({ ...props, queryCode: creditRedemptionQueryCode.current }) + return ( +
+ Credit redemption for {props.slug} with code {creditRedemptionQueryCode.current} +
+ ) + }, + } +}) + +const DEFAULT_PROFILE_CONTEXT: ProfileContextType = { + profile: { + id: 1, + auth0_id: 'auth0|test', + gotrue_id: 'gotrue-test', + username: 'testuser', + primary_email: 'test@example.com', + first_name: null, + last_name: null, + mobile: null, + is_alpha_user: false, + is_sso_user: false, + disabled_features: [], + free_project_limit: null, + }, + error: null, + isLoading: false, + isError: false, + isSuccess: true, +} + +const ORGANIZATION = createMockOrganization({ + id: 1, + name: 'Acme Production', + slug: 'acme-production', + plan: { id: 'pro', name: 'Pro' }, +}) + +function renderScreen() { + return customRender( + + + , + { profileContext: DEFAULT_PROFILE_CONTEXT } + ) +} + +describe('RedeemCreditsScreen', () => { + beforeEach(() => { + vi.clearAllMocks() + creditRedemptionQueryCode.current = undefined + routerMock.setCurrentUrl('/redeem') + }) + + test('renders ready state from organizations query and opens redemption for selected organization', async () => { + const user = userEvent.setup() + routerMock.setCurrentUrl('/redeem?code=SUPA-CREDIT-123') + creditRedemptionQueryCode.current = 'SUPA-CREDIT-123' + addAPIMock({ + method: 'get', + path: '/platform/organizations', + response: () => HttpResponse.json([ORGANIZATION]), + }) + + renderScreen() + + await user.click(await screen.findByRole('button', { name: /Acme Production/ })) + await user.click(screen.getByRole('button', { name: 'Redeem credits' })) + + expect(await screen.findByTestId('credit-redemption')).toHaveTextContent( + 'Credit redemption for acme-production with code SUPA-CREDIT-123' + ) + expect(creditRedemptionProps).toHaveBeenCalledWith( + expect.objectContaining({ + slug: 'acme-production', + queryCode: 'SUPA-CREDIT-123', + }) + ) + }) + + test('routes new organization creation back to the current redeem URL', async () => { + routerMock.setCurrentUrl('/redeem?code=SUPA-CREDIT-123') + addAPIMock({ + method: 'get', + path: '/platform/organizations', + response: () => HttpResponse.json([ORGANIZATION]), + }) + + renderScreen() + + const createOrganizationLink = await screen.findByRole('link', { + name: /Create new organization/, + }) + + expect(createOrganizationLink).toHaveAttribute( + 'href', + '/new?returnTo=%2Fredeem%3Fcode%3DSUPA-CREDIT-123&returnToOrgParam=selected_org' + ) + }) + + test('preselects an organization returned from new organization creation', async () => { + const user = userEvent.setup() + routerMock.setCurrentUrl('/redeem?code=SUPA-CREDIT-123&selected_org=acme-production') + creditRedemptionQueryCode.current = 'SUPA-CREDIT-123' + addAPIMock({ + method: 'get', + path: '/platform/organizations', + response: () => HttpResponse.json([ORGANIZATION]), + }) + + renderScreen() + + const redeemButton = await screen.findByRole('button', { name: 'Redeem credits' }) + + await waitFor(() => expect(redeemButton).toBeEnabled()) + await user.click(redeemButton) + + expect(await screen.findByTestId('credit-redemption')).toHaveTextContent( + 'Credit redemption for acme-production with code SUPA-CREDIT-123' + ) + }) +}) diff --git a/apps/studio/components/interfaces/RedeemCredits/RedeemCredits.tsx b/apps/studio/components/interfaces/RedeemCredits/RedeemCredits.tsx new file mode 100644 index 00000000000..3b622765a32 --- /dev/null +++ b/apps/studio/components/interfaces/RedeemCredits/RedeemCredits.tsx @@ -0,0 +1,206 @@ +import { FeatureFlagContext } from 'common' +import { useRouter } from 'next/router' +import { ReactNode, useContext, useEffect, useRef, useState } from 'react' +import { Button, Card, CardContent } from 'ui' +import { Admonition, ShimmeringLoader } from 'ui-patterns' + +import { OrganizationSelector } from '../Connect/OrganizationSelector' +import { CreditCodeRedemption } from '@/components/interfaces/Organization/BillingSettings/CreditCodeRedemption' +import { + InterstitialAccountRow, + InterstitialLayout, + SupabaseLogo, +} from '@/components/layouts/InterstitialLayout' +import { useOrganizationsQuery } from '@/data/organizations/organizations-query' +import { useProfile } from '@/lib/profile' +import { EMPTY_ARR } from '@/lib/void' + +const RETURN_TO_SELECTED_ORG_PARAM = 'selected_org' + +const RedeemCreditsInterstitial = ({ + title, + description, + children, +}: { + title: ReactNode + description?: ReactNode + children: ReactNode +}) => ( + } title={title} description={description}> +
{children}
+
+) + +export const RedeemCreditsScreen = () => { + const router = useRouter() + const { profile, isLoading: isLoadingProfile } = useProfile() + const { hasLoaded } = useContext(FeatureFlagContext) + + const [selectedOrgSlug, setSelectedOrgSlug] = useState(null) + const [redemptionModalOrgSlug, setRedemptionModalOrgSlug] = useState(null) + const appliedReturnSelectedOrgRef = useRef(null) + + const { + data: organizationOptions = EMPTY_ARR, + error: organizationsError, + isLoading: isLoadingOrganizations, + isError: isOrganizationsError, + } = useOrganizationsQuery() + + const returnSelectedOrgSlug = + router.isReady && typeof router.query[RETURN_TO_SELECTED_ORG_PARAM] === 'string' + ? router.query[RETURN_TO_SELECTED_ORG_PARAM] + : null + + const displayName = profile?.primary_email ?? profile?.username + + const isLoading = isLoadingProfile || isLoadingOrganizations || !hasLoaded + + useEffect(() => { + if (!returnSelectedOrgSlug) return + if (appliedReturnSelectedOrgRef.current === returnSelectedOrgSlug) return + + const hasReturnedOrganization = (organizationOptions ?? []).some( + (organization) => organization.slug === returnSelectedOrgSlug + ) + + if (hasReturnedOrganization) { + setSelectedOrgSlug(returnSelectedOrgSlug) + appliedReturnSelectedOrgRef.current = returnSelectedOrgSlug + } + }, [organizationOptions, returnSelectedOrgSlug]) + + if (isLoading) { + return ( + } + description={} + > + + + ) + } + + if (isOrganizationsError) { + return ( + +
+ + We could not load your organizations. + {organizationsError && ( + + Error: {organizationsError.message} + + )} + + } + /> +
+
+ ) + } + + const createOrganizationParams = { + returnTo: router.asPath || '/redeem', + returnToOrgParam: RETURN_TO_SELECTED_ORG_PARAM, + } + + const openRedemption = () => { + if (!selectedOrgSlug) return + setRedemptionModalOrgSlug(selectedOrgSlug) + } + + return ( + <> + +
+ + + `${organization.plan.name} Plan`} + createLabel={ + organizationOptions.length === 0 + ? 'Create your first organization' + : 'Create new organization' + } + createHrefParams={createOrganizationParams} + /> + + {organizationOptions.length === 0 && ( + + )} + +
+ +

+ Credits apply to one organization and are used toward future invoices before your + payment method is charged. +

+
+
+
+ + {redemptionModalOrgSlug && ( + setRedemptionModalOrgSlug(null)} + /> + )} + + ) +} + +const ConnectLoadingCards = () => ( +
+ + + +
+ + +
+
+
+
+ + {Array.from({ length: 3 }).map((_, index) => ( + + + +
+ + +
+
+
+ ))} +
+
+ + +
+
+) diff --git a/apps/studio/components/layouts/RedeemCreditsLayout.tsx b/apps/studio/components/layouts/RedeemCreditsLayout.tsx deleted file mode 100644 index 16c276802c5..00000000000 --- a/apps/studio/components/layouts/RedeemCreditsLayout.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useTheme } from 'next-themes' -import Head from 'next/head' -import Image from 'next/legacy/image' -import type { PropsWithChildren } from 'react' -import { Separator } from 'ui' - -import { withAuth } from '../../hooks/misc/withAuth' -import { useCustomContent } from '@/hooks/custom-content/useCustomContent' -import { BASE_PATH } from '@/lib/constants' - -export interface RedeemCreditsLayoutProps {} - -const RedeemCreditsLayout = ({ children }: PropsWithChildren) => { - const { resolvedTheme } = useTheme() - const { appTitle } = useCustomContent(['app:title']) - - return ( - <> - - Redeem Credits | {appTitle || 'Supabase'} - -
-
-
-
-
-
- Supabase - Supabase Logo -
-
-
-
-
- -
- {children} -
-
- - ) -} - -export default withAuth(RedeemCreditsLayout) diff --git a/apps/studio/components/layouts/Tabs/ActionCard.tsx b/apps/studio/components/layouts/Tabs/ActionCard.tsx index efc71d1e34f..24f4057997b 100644 --- a/apps/studio/components/layouts/Tabs/ActionCard.tsx +++ b/apps/studio/components/layouts/Tabs/ActionCard.tsx @@ -1,12 +1,11 @@ import type { ReactNode } from 'react' -import { Badge, Card } from 'ui' +import { Card } from 'ui' export const ActionCard = (card: { icon: ReactNode title: string description: string bgColor: string - isBeta?: boolean onClick?: () => void }) => { return ( @@ -15,11 +14,6 @@ export const ActionCard = (card: { onClick={card.onClick} >
- {card.isBeta && ( - - Coming soon - - )}
diff --git a/apps/studio/components/ui/ActionCard.tsx b/apps/studio/components/ui/ActionCard.tsx index d9555b0c374..76db9426ec0 100644 --- a/apps/studio/components/ui/ActionCard.tsx +++ b/apps/studio/components/ui/ActionCard.tsx @@ -1,12 +1,11 @@ import type { ReactNode } from 'react' -import { Badge, Card, cn } from 'ui' +import { Card, cn } from 'ui' export const ActionCard = (card: { icon: ReactNode title: string bgColor?: string description?: ReactNode - isBeta?: boolean className?: string onClick?: () => void }) => { @@ -18,21 +17,18 @@ export const ActionCard = (card: { )} onClick={card.onClick} > -
- {card.isBeta && ( - - Coming soon - - )} +
{card.icon}
-

- {card.title} -

+
+

+ {card.title} +

+
{typeof card.description === 'string' ? (
{card.description}
) : ( diff --git a/apps/studio/pages/redeem.tsx b/apps/studio/pages/redeem.tsx index 4e05b203559..546bce43052 100644 --- a/apps/studio/pages/redeem.tsx +++ b/apps/studio/pages/redeem.tsx @@ -1,111 +1,26 @@ -import { FeatureFlagContext } from 'common' -import Link from 'next/link' -import { useContext, useState } from 'react' -import { Button } from 'ui' -import { ShimmeringLoader } from 'ui-patterns' +import Head from 'next/head' +import { useRouter } from 'next/router' -import { CreditCodeRedemption } from '@/components/interfaces/Organization/BillingSettings/CreditCodeRedemption' -import { OrganizationCard } from '@/components/interfaces/Organization/OrganizationCard' -import RedeemCreditsLayout from '@/components/layouts/RedeemCreditsLayout' -import { - ScaffoldContainer, - ScaffoldDivider, - ScaffoldHeader, - ScaffoldTitle, -} from '@/components/layouts/Scaffold' -import AlertError from '@/components/ui/AlertError' -import { useOrganizationsQuery } from '@/data/organizations/organizations-query' -import { useProfile } from '@/lib/profile' +import { RedeemCreditsScreen } from '@/components/interfaces/RedeemCredits/RedeemCredits' +import { withAuth } from '@/hooks/misc/withAuth' +import { buildStudioPageTitle } from '@/lib/page-title' import type { NextPageWithLayout } from '@/types' -const RedeemCreditsContent = () => { - const { isLoading: isLoadingProfile } = useProfile() - const { hasLoaded } = useContext(FeatureFlagContext) - - const [selectedOrg, setSelectedOrg] = useState(null) - - const { - data: organizations, - error: errorOrganizations, - isLoading: isLoadingOrganizations, - isError: isErrorOrganizations, - } = useOrganizationsQuery() - - if (isErrorOrganizations) { - return ( - - ) - } - - return ( -
-
-
-

To redeem your credits, select one of your organizations.

-

- The credits will be applied to that organization only and cannot be transferred or - shared between organizations. -

-
- -
-

Want to start fresh?

-

- Create a new organization first. You will have to revisit this link after creating the - organization to redeem the code. -

- -
-
- -
- {/* [Joshen] Checking for profile as well as organizations query internally waits for profile to be loaded */} - {isLoadingProfile || isLoadingOrganizations || !hasLoaded ? ( - <> - - - - ) : ( - organizations?.map((org) => ( - setSelectedOrg(org.slug)} - /> - )) - )} -
- - {selectedOrg && ( - setSelectedOrg(null)} - /> - )} -
- ) -} +const PAGE_TITLE = buildStudioPageTitle({ section: 'Redeem Credits', brand: 'Supabase' }) const RedeemCreditsPage: NextPageWithLayout = () => { + const router = useRouter() + + if (!router.isReady) return null + return ( - - - Code Redemption - - - - + <> + + {PAGE_TITLE} + + + ) } -RedeemCreditsPage.getLayout = (page) => {page} - -export default RedeemCreditsPage +export default withAuth(RedeemCreditsPage) From 9dc3998fa0bd14e31465ca6c0432e539cc32b41a Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 18 May 2026 16:22:12 +0700 Subject: [PATCH 03/20] RLS Tester sandbox environment (#45839) ## Context Resolves FE-3221 Heavily inspired by what @filipecabaco has done previously here: https://github.com/supabase/supabase/pull/45360 This PR explores the use of pglite to set up a sandbox for RLS testing, which will pave the way for testing mutation based queries so to ensure no disruption to the actual database. Sandbox can be set up within the RLS tester panel as such: image Which the sandbox will mimic the project's database to the bare minimum required - entities from the `public` schema are copied over (types, tables, functions, policies) - `auth` schema is pseudo setup with `SANDBOX_SETUP_STATEMENTS` - Enough to support role impersonation + querying tables with references to the auth schema (e.g users table) - data is seeded up to 100 rows for each table - More info RE limitations in the last section below Once sandbox is ready, you'll see this UI where you can either leave the sandbox, or re-sync the sandbox from the actual database image Changes are currently feature flagged, so won't be available publicly just yet until things are ironed out and ready ## To test - [ ] Verify that setting up sandbox works - [ ] Verify that you can query your sandbox, and queries do not touch the actual database (can verify that we're not sending HTTP requests to the /query endpoint) - [ ] Verify correctness of RLS tester as well, should match correctness with testing against actual DB - [ ] Verify that re-syncing sandbox picks up changes - Can test by updating your policies that will affect the output of your select query - e.g SELECT for `authenticated`, change from just `true` to `false` - [ ] RLS tester should work as per normal (against actual DB) with the feature flag off with no additional overhead Let me know of any edge cases you might run into while testing ## Known quirks that will be addressed subsequently Leaving these for now just to not bloat this PR further - Pglite schema needs to be re-synced if updating RLS policies while testing, to ensure that pglite gets the updated policies. Will think about how to make this more seamless - Sandbox has its own limitations, will need to add a dialog to inform users how the sandbox works and what limitations to note of - e.g only the auth schema is mimicked - so policies that reference storage helpers won't work (although i think auth is probably the main use case and the rest might be niche) - We can slowly expand tho where required - Eventually we'll also move forward with figuring out testing mutation queries with this sandbox ## Summary by CodeRabbit * **New Features** * RLS tester gains an isolated Postgres sandbox with schema/seed import, start/refresh/exit controls, and pre-populated auth data. * Sandbox management UI with setup, loading, active, and error states; refresh and destroy actions. * **Bug Fixes** * Role impersonation now keeps the PostgREST role set to anon while the tester sheet is open. * **Chores** * Content Security Policy updated to allow sandbox/connectivity endpoints. * **Style** * Minor sheet styling adjustment (top border). [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45839) --- .../Auth/RLSTester/RLSTesterSheet.tsx | 28 ++- .../Auth/RLSTester/SandboxManagement.tsx | 105 +++++++++ .../Auth/RLSTester/useTestQueryRLS.ts | 39 ++-- apps/studio/csp.ts | 21 ++ apps/studio/data/rls-tester/get-schema-ddl.ts | 180 +++++++++++++++ apps/studio/data/rls-tester/get-seed-data.ts | 88 ++++++++ apps/studio/package.json | 2 + .../state/postgres-sandbox/pglite.worker.ts | 13 ++ .../postgres-sandbox/sandbox.constants.ts | 162 ++++++++++++++ .../state/postgres-sandbox/sandbox.core.ts | 74 ++++++ .../studio/state/postgres-sandbox/sandbox.tsx | 143 ++++++++++++ .../state/postgres-sandbox/sandbox.utils.ts | 210 ++++++++++++++++++ pnpm-lock.yaml | 56 +++-- 13 files changed, 1079 insertions(+), 42 deletions(-) create mode 100644 apps/studio/components/interfaces/Auth/RLSTester/SandboxManagement.tsx create mode 100644 apps/studio/data/rls-tester/get-schema-ddl.ts create mode 100644 apps/studio/data/rls-tester/get-seed-data.ts create mode 100644 apps/studio/state/postgres-sandbox/pglite.worker.ts create mode 100644 apps/studio/state/postgres-sandbox/sandbox.constants.ts create mode 100644 apps/studio/state/postgres-sandbox/sandbox.core.ts create mode 100644 apps/studio/state/postgres-sandbox/sandbox.tsx create mode 100644 apps/studio/state/postgres-sandbox/sandbox.utils.ts diff --git a/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx b/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx index 42a243b2a14..098a170034f 100644 --- a/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx +++ b/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx @@ -13,7 +13,7 @@ import { SelectTrigger, SelectValue, } from '@ui/components/shadcn/ui/select' -import { LOCAL_STORAGE_KEYS } from 'common' +import { LOCAL_STORAGE_KEYS, useFlag } from 'common' import { Code, ExternalLink } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { @@ -35,6 +35,7 @@ import { type ParseQueryResults } from './RLSTester.types' import { RLSTesterEmptyState } from './RLSTesterEmptyState' import { RLSTesterResults } from './RLSTesterResults' import { RoleSelector } from './RoleSelector' +import { SandboxManagement } from './SandboxManagement' import { UserSelector } from './UserSelector' import { UserSqlEditor } from './UserSqlEditor' import { useTestQueryRLS } from './useTestQueryRLS' @@ -44,6 +45,7 @@ import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' import { FeaturePreviewBadge } from '@/components/ui/FeaturePreviewBadge' import { useTrack } from '@/lib/telemetry/track' import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' +import { PostgresSandboxProvider } from '@/state/postgres-sandbox/sandbox' import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' @@ -51,11 +53,20 @@ interface RLSTesterSheetProps { handleSelectEditPolicy: (policy: Policy) => void } -export const RLSTesterSheet = ({ handleSelectEditPolicy }: RLSTesterSheetProps) => { +export const RLSTesterSheet = (props: RLSTesterSheetProps) => { + return ( + + + + ) +} + +const RLSTesterSheetContents = ({ handleSelectEditPolicy }: RLSTesterSheetProps) => { const track = useTrack() const aiSnap = useAiAssistantStateSnapshot() const { openSidebar } = useSidebarManagerSnapshot() const { setRole } = useRoleImpersonationStateSnapshot() + const sandboxEnabled = useFlag('rlsTesterSandbox') const [open, setOpen] = useState(false) const [selectedOption, setSelectedOption] = useState<'anon' | 'authenticated'>('anon') @@ -132,12 +143,13 @@ export const RLSTesterSheet = ({ handleSelectEditPolicy }: RLSTesterSheetProps) } useEffect(() => { - setRole({ type: 'postgrest', role: 'anon' }) - // Flip back to service role - return () => { + if (open) { + setRole({ type: 'postgrest', role: 'anon' }) + } else { + // Flip back to service role setRole(undefined) } - }, [setRole]) + }, [open, setRole]) return ( @@ -159,7 +171,9 @@ export const RLSTesterSheet = ({ handleSelectEditPolicy }: RLSTesterSheetProps)
- + {sandboxEnabled && } + +
{selectedOption === 'authenticated' && } diff --git a/apps/studio/components/interfaces/Auth/RLSTester/SandboxManagement.tsx b/apps/studio/components/interfaces/Auth/RLSTester/SandboxManagement.tsx new file mode 100644 index 00000000000..a7ce45ebcc9 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/RLSTester/SandboxManagement.tsx @@ -0,0 +1,105 @@ +import { Box, Loader2, LogOut, RefreshCw } from 'lucide-react' +import { Badge, Button } from 'ui' +import { Admonition } from 'ui-patterns' + +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { usePostgresSandbox } from '@/state/postgres-sandbox/sandbox' + +export const SandboxManagement = () => { + const { status, error, isSyncing, startSandbox, destroySandbox, syncSandbox } = + usePostgresSandbox() + + if (status === 'idle') { + return ( + startSandbox()}> + Set up sandbox + , + ]} + > +
+

Set up sandbox for testing

+ Recommended +
+

+ Ensure that queries do not affect your actual database +

+
+ ) + } + + if (status === 'loading') { + return ( + +
+
+ +
+

Setting up sandbox

+
+
+ ) + } + + if (status === 'error') { + return ( + startSandbox()}> + Retry set up + , + ]} + /> + ) + } + + return ( + } + className="w-7" + disabled={isSyncing} + tooltip={{ content: { side: 'bottom', text: 'Exit sandbox' } }} + onClick={() => destroySandbox()} + />, + } + className="w-7" + loading={isSyncing} + tooltip={{ content: { side: 'bottom', text: 'Refresh schema' } }} + onClick={() => syncSandbox()} + />, + ]} + > +
+
+ +
+

Sandbox active

+

Your database is never modified

+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Auth/RLSTester/useTestQueryRLS.ts b/apps/studio/components/interfaces/Auth/RLSTester/useTestQueryRLS.ts index 5ddf6516c7e..91ede81b5a0 100644 --- a/apps/studio/components/interfaces/Auth/RLSTester/useTestQueryRLS.ts +++ b/apps/studio/components/interfaces/Auth/RLSTester/useTestQueryRLS.ts @@ -12,6 +12,7 @@ import { useParseSQLQueryMutation } from '@/data/misc/parse-query-mutation' import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { wrapWithRoleImpersonation } from '@/lib/role-impersonation' +import { usePostgresSandbox } from '@/state/postgres-sandbox/sandbox' import { isRoleImpersonationEnabled, useGetImpersonatedRoleState, @@ -35,20 +36,23 @@ export const useTestQueryRLS = () => { const { data: project } = useSelectedProjectQuery() const { role } = useRoleImpersonationStateSnapshot() + const { sandbox } = usePostgresSandbox() const getImpersonatedRoleState = useGetImpersonatedRoleState() const impersonatedRoleState = getImpersonatedRoleState() const user = useImpersonatedUser() const [isLoading, setIsLoading] = useState(false) + const [sandboxError, setSandboxError] = useState() const { data: policies = [] } = useDatabasePoliciesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) - const { mutateAsync: executeSql, error: executeSqlError } = useExecuteSqlMutation({ + const { mutateAsync: executeSql, error: executeSqlMutationError } = useExecuteSqlMutation({ onError: () => {}, }) + const executeSqlError = sandbox ? sandboxError : executeSqlMutationError const { mutateAsync: parseClientCode, @@ -104,13 +108,14 @@ export const useTestQueryRLS = () => { try { setIsLoading(true) + setSandboxError(undefined) const { appendAutoLimit } = checkIfAppendLimitRequired(value, limit) const formattedSql = suffixWithLimit(value, limit) const data = await parseQuery({ sql: formattedSql }) if (data.operation !== 'SELECT') { - return toast('Only SELECT statements are supported for now') + return toast('Only SELECT statements are supported with the RLS Tester at the moment') } const formattedTables = data.tables.map((x) => { @@ -146,19 +151,25 @@ export const useTestQueryRLS = () => { }) const autoLimit = appendAutoLimit ? limit : undefined - const { result } = await executeSql({ - autoLimit, - projectRef: project.ref, - connectionString: project.connectionString, - sql: wrapWithRoleImpersonation(formattedSql, impersonatedRoleState), - isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRoleState.role), - isStatementTimeoutDisabled: true, - handleError: (error) => { - throw error - }, - queryKey: ['rls-tester'], - }) + const sql = wrapWithRoleImpersonation(formattedSql, impersonatedRoleState) + const { result } = sandbox + ? await sandbox.run({ sql }).catch((e) => { + setSandboxError(e instanceof Error ? e : new Error(String(e))) + throw e + }) + : await executeSql({ + sql, + autoLimit, + projectRef: project.ref, + connectionString: project.connectionString, + isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRoleState.role), + isStatementTimeoutDisabled: true, + handleError: (e) => { + throw e + }, + queryKey: ['rls-tester'], + }) onExecuteSQL({ result, isAutoLimit: !!autoLimit }) onParseQuery({ diff --git a/apps/studio/csp.ts b/apps/studio/csp.ts index 9c6218254c2..2501ba08f5a 100644 --- a/apps/studio/csp.ts +++ b/apps/studio/csp.ts @@ -192,7 +192,28 @@ export function getCSP() { const workerSrcDirective = [`worker-src 'self'`, `blob:`, `data:`].join(' ') + const connectSrcDirective = [ + `connect-src 'self'`, + `data:`, + `blob:`, + ...DEFAULT_SRC_URLS, + ...(isDevOrStaging + ? [ + SUPABASE_STAGING_PROJECTS_URL, + SUPABASE_STAGING_PROJECTS_URL_WS, + NIMBUS_STAGING_PROJECTS_URL, + NIMBUS_STAGING_PROJECTS_URL_WS, + VERCEL_LIVE_URL, + SUPABASE_DOCS_PROJECT_URL, + SUPABASE_CONTENT_API_URL, + ] + : []), + PUSHER_URL_WS, + SENTRY_URL, + ].join(' ') + const cspDirectives = [ + connectSrcDirective, defaultSrcDirective, imgSrcDirective, scriptSrcDirective, diff --git a/apps/studio/data/rls-tester/get-schema-ddl.ts b/apps/studio/data/rls-tester/get-schema-ddl.ts new file mode 100644 index 00000000000..cca3556bbc0 --- /dev/null +++ b/apps/studio/data/rls-tester/get-schema-ddl.ts @@ -0,0 +1,180 @@ +import pgMeta, { + getEntityDefinitionsSql, + joinSqlFragments, + literal, + safeSql, + type PGPolicy, +} from '@supabase/pg-meta' +import { z } from 'zod' + +import { executeSql } from '@/data/sql/execute-sql-query' +import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas' + +export interface RlsTableStatus { + schema: string + table: string + rls_enabled: boolean + rls_forced: boolean +} + +export interface CustomRole { + name: string +} + +export interface DatabaseSchemaDDL { + schemas: string[] + typeDefinitions: string[] + entityDefinitions: string[] + functionDefinitions: string[] + policies: PGPolicy[] + rlsStatuses: RlsTableStatus[] + customRoles: CustomRole[] +} + +const pgMetaRolesList = pgMeta.roles.list() +const pgMetaFunctionsZod = pgMeta.functions.list().zod +const pgMetaPoliciesZod = pgMeta.policies.list().zod +const pgMetaTablesZod = pgMeta.tables.list().zod + +// Extension-owned / platform-specific schemas whose DDL depends on C extensions, +// custom operators, and platform functions that PGlite cannot replicate. +// We skip entity/function/type DDL for these but still fetch their policies — +// those may reference user tables we do load. +const SUPABASE_INTERNAL_SCHEMAS = new Set([...INTERNAL_SCHEMAS, '_realtime']) + +const SYSTEM_ROLES = new Set([ + 'postgres', + 'anon', + 'authenticated', + 'service_role', + 'supabase_admin', + 'supabase_auth_admin', + 'supabase_storage_admin', + 'supabase_replication_admin', + 'supabase_read_only_user', + 'pg_monitor', + 'pg_read_all_settings', + 'pg_read_all_stats', + 'pg_stat_scan_tables', + 'pg_read_server_files', + 'pg_write_server_files', + 'pg_execute_server_program', + 'pg_signal_backend', + 'dashboard_user', + 'pgbouncer', +]) + +function getTypeDefinitionsSql(schemas: string[]) { + return safeSql` + SELECT + CASE t.typtype + WHEN 'e' THEN + 'CREATE TYPE ' || quote_ident(n.nspname) || '.' || quote_ident(t.typname) || + ' AS ENUM (' || + (SELECT string_agg(quote_literal(e.enumlabel), ', ' ORDER BY e.enumsortorder) + FROM pg_enum e WHERE e.enumtypid = t.oid) || + ')' + WHEN 'c' THEN + 'CREATE TYPE ' || quote_ident(n.nspname) || '.' || quote_ident(t.typname) || + ' AS (' || + (SELECT string_agg(quote_ident(a.attname) || ' ' || pg_catalog.format_type(a.atttypid, a.atttypmod), ', ' ORDER BY a.attnum) + FROM pg_attribute a WHERE a.attrelid = t.typrelid AND a.attnum > 0 AND NOT a.attisdropped) || + ')' + WHEN 'd' THEN + 'CREATE DOMAIN ' || quote_ident(n.nspname) || '.' || quote_ident(t.typname) || + ' AS ' || pg_catalog.format_type(t.typbasetype, t.typtypmod) + END AS definition + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + LEFT JOIN pg_class c ON c.oid = t.typrelid + LEFT JOIN pg_depend d ON d.objid = t.oid AND d.deptype = 'e' + WHERE n.nspname IN (${joinSqlFragments(schemas.map(literal), ', ')}) + AND t.typtype IN ('e', 'c', 'd') + AND d.objid IS NULL + AND (t.typtype != 'c' OR c.relkind = 'c') + ORDER BY t.typtype, n.nspname, t.typname + ` +} + +type Variables = { + projectRef?: string + connectionString?: string | null + schemas: string[] +} + +export async function getDatabaseSchemaDDL( + { projectRef, connectionString, schemas }: Variables, + signal?: AbortSignal +): Promise { + const userSchemas = schemas.filter((s) => !SUPABASE_INTERNAL_SCHEMAS.has(s)) + + const entitySql = getEntityDefinitionsSql({ schemas: userSchemas }) + const functionsSql = pgMeta.functions.list({ includedSchemas: userSchemas }).sql + const policiesSql = pgMeta.policies.list({ includedSchemas: schemas }).sql + const tablesSql = pgMeta.tables.list({ includedSchemas: userSchemas }).sql + + const [entityResult, policiesResult, rlsResult, rolesResult, functionsResult, typesResult] = + await Promise.all([ + executeSql( + { projectRef, connectionString, sql: entitySql, queryKey: ['rls-sandbox-ddl'] }, + signal + ), + executeSql( + { projectRef, connectionString, sql: policiesSql, queryKey: ['rls-sandbox-policies'] }, + signal + ), + executeSql( + { projectRef, connectionString, sql: tablesSql, queryKey: ['rls-sandbox-rls'] }, + signal + ), + executeSql( + { projectRef, connectionString, sql: pgMetaRolesList.sql, queryKey: ['rls-sandbox-roles'] }, + signal + ), + executeSql( + { + projectRef, + connectionString, + sql: functionsSql, + queryKey: ['rls-sandbox-functions'], + }, + signal + ), + executeSql( + { + projectRef, + connectionString, + sql: getTypeDefinitionsSql(userSchemas), + queryKey: ['rls-sandbox-types'], + }, + signal + ), + ]) + + const roles = (rolesResult.result as z.infer).filter( + (r) => !SYSTEM_ROLES.has(r.name) && !r.name.startsWith('pg_') && !r.name.startsWith('supabase_') + ) + + const functions = (functionsResult.result as z.infer).filter( + (f) => (f.language === 'sql' || f.language === 'plpgsql') && f.return_type !== 'trigger' + ) + + return { + schemas: userSchemas, + typeDefinitions: (typesResult.result as { definition: string }[]).map((r) => r.definition), + entityDefinitions: (entityResult.result[0]?.data?.definitions ?? []).map( + (d: { sql: string }) => d.sql + ), + functionDefinitions: functions.map((f) => f.complete_statement), + policies: policiesResult.result as z.infer as PGPolicy[], + rlsStatuses: (rlsResult.result as z.infer).map((t) => ({ + schema: t.schema, + table: t.name, + rls_enabled: t.rls_enabled, + rls_forced: t.rls_forced, + })), + customRoles: roles, + } +} + +export type DatabaseSchemaDDLData = Awaited> diff --git a/apps/studio/data/rls-tester/get-seed-data.ts b/apps/studio/data/rls-tester/get-seed-data.ts new file mode 100644 index 00000000000..1d13f852b73 --- /dev/null +++ b/apps/studio/data/rls-tester/get-seed-data.ts @@ -0,0 +1,88 @@ +import { ident, joinSqlFragments, literal, safeSql } from '@supabase/pg-meta' + +import { RlsTableStatus } from './get-schema-ddl' +import { executeSql } from '@/data/sql/execute-sql-query' + +export interface TableSeedData { + schema: string + table: string + rows: Record[] +} + +// Each entry can optionally restrict which columns are fetched. Used by the +// sandbox to avoid pulling secrets (e.g. auth.users encrypted_password / tokens) +// into the browser-side PGlite instance. +export type SeedTableEntry = RlsTableStatus & { columns?: readonly string[] } + +type Variables = { + projectRef?: string + connectionString?: string | null + tables: SeedTableEntry[] + rowLimit: number +} + +async function fetchTableSeed( + { + projectRef, + connectionString, + schema, + table, + columns, + rowLimit, + }: Omit & { + schema: string + table: string + columns?: readonly string[] + }, + signal?: AbortSignal +): Promise { + try { + const projection = + columns && columns.length > 0 ? joinSqlFragments(columns.map(ident), ', ') : safeSql`*` + const { result } = await executeSql( + { + projectRef, + connectionString, + sql: safeSql`SELECT ${projection} FROM ${ident(schema)}.${ident(table)} LIMIT ${literal(Number(rowLimit))}`, + queryKey: ['rls-sandbox-seed', schema, table], + }, + signal + ) + return { schema, table, rows: (result ?? []) as Record[] } + } catch { + return { schema, table, rows: [] } + } +} + +const SEED_CONCURRENCY = 8 + +export async function getProjectSeedData( + { projectRef, connectionString, tables, rowLimit }: Variables, + signal?: AbortSignal +): Promise { + const results: TableSeedData[] = [] + const queue = tables.slice() + const workers = Array.from({ length: Math.min(SEED_CONCURRENCY, queue.length) }, async () => { + while (queue.length > 0) { + const entry = queue.shift() + if (!entry) break + results.push( + await fetchTableSeed( + { + projectRef, + connectionString, + schema: entry.schema, + table: entry.table, + columns: entry.columns, + rowLimit, + }, + signal + ) + ) + } + }) + await Promise.all(workers) + return results.filter((t) => t.rows.length > 0) +} + +export type ProjectSeedDataData = TableSeedData[] diff --git a/apps/studio/package.json b/apps/studio/package.json index 39b6703f675..6a401404d68 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -43,6 +43,8 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", + "@electric-sql/pglite": "0.4.5", + "@electric-sql/pglite-tools": "^0.3.4", "@graphiql/react": "^0.37.3", "@graphiql/toolkit": "^0.11.3", "@hcaptcha/react-hcaptcha": "^1.12.0", diff --git a/apps/studio/state/postgres-sandbox/pglite.worker.ts b/apps/studio/state/postgres-sandbox/pglite.worker.ts new file mode 100644 index 00000000000..ab08903f83d --- /dev/null +++ b/apps/studio/state/postgres-sandbox/pglite.worker.ts @@ -0,0 +1,13 @@ +import { PGlite } from '@electric-sql/pglite' +import { pgcrypto } from '@electric-sql/pglite/contrib/pgcrypto' +import { uuid_ossp } from '@electric-sql/pglite/contrib/uuid_ossp' +import { worker } from '@electric-sql/pglite/worker' + +worker({ + async init() { + return new PGlite({ + dataDir: 'memory://', + extensions: { pgcrypto, uuid_ossp }, + }) + }, +}) diff --git a/apps/studio/state/postgres-sandbox/sandbox.constants.ts b/apps/studio/state/postgres-sandbox/sandbox.constants.ts new file mode 100644 index 00000000000..91e9f667507 --- /dev/null +++ b/apps/studio/state/postgres-sandbox/sandbox.constants.ts @@ -0,0 +1,162 @@ +// ALTER ROLE postgres SUPERUSER succeeds because the bootstrap connection owns the cluster. +// Each statement is individual so a single failure cannot abort the rest. +export const SANDBOX_SETUP_STATEMENTS = [ + `ALTER ROLE postgres SUPERUSER`, + `DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'anon') THEN + CREATE ROLE anon NOLOGIN; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticated') THEN + CREATE ROLE authenticated NOLOGIN; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'service_role') THEN + CREATE ROLE service_role NOLOGIN; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticator') THEN + CREATE ROLE authenticator NOLOGIN; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'dashboard_user') THEN + CREATE ROLE dashboard_user NOLOGIN; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'pgbouncer') THEN + CREATE ROLE pgbouncer NOLOGIN; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'supabase_admin') THEN + CREATE ROLE supabase_admin NOLOGIN; + END IF; + END $$`, + `ALTER ROLE service_role BYPASSRLS`, + `GRANT anon TO postgres WITH ADMIN OPTION`, + `GRANT authenticated TO postgres WITH ADMIN OPTION`, + `GRANT service_role TO postgres WITH ADMIN OPTION`, + `GRANT CONNECT ON DATABASE postgres TO anon, authenticated, service_role`, + `CREATE SCHEMA IF NOT EXISTS auth`, + `GRANT USAGE ON SCHEMA auth TO anon, authenticated, service_role`, + `GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role`, + // Read both the per-claim setting (request.jwt.claim.) and the JSON blob + // (request.jwt.claims) so these work whether the caller uses Studio's role + // impersonation (sets the JSON blob) or PostgREST-style per-claim settings. + // Mirrors how the real Supabase auth.* helpers are defined. + `CREATE OR REPLACE FUNCTION auth.uid() RETURNS uuid LANGUAGE sql STABLE AS + $fn$ SELECT COALESCE( + NULLIF(current_setting('request.jwt.claim.sub', true), ''), + (NULLIF(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub') + )::uuid $fn$`, + `CREATE OR REPLACE FUNCTION auth.role() RETURNS text LANGUAGE sql STABLE AS + $fn$ SELECT COALESCE( + NULLIF(current_setting('request.jwt.claim.role', true), ''), + (NULLIF(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role'), + 'anon' + ) $fn$`, + `CREATE OR REPLACE FUNCTION auth.email() RETURNS text LANGUAGE sql STABLE AS + $fn$ SELECT COALESCE( + NULLIF(current_setting('request.jwt.claim.email', true), ''), + (NULLIF(current_setting('request.jwt.claims', true), '')::jsonb ->> 'email') + ) $fn$`, + `GRANT EXECUTE ON FUNCTION auth.uid() TO anon, authenticated, service_role`, + `GRANT EXECUTE ON FUNCTION auth.role() TO anon, authenticated, service_role`, + `GRANT EXECUTE ON FUNCTION auth.email() TO anon, authenticated, service_role`, + // Minimal auth table stubs — enough for FK references and policy expressions. + // Projects commonly have FKs to auth.users from public schema tables (e.g. profiles), + // so without this stub those tables fail to create and their policies can't be tested. + `CREATE TABLE IF NOT EXISTS auth.users ( + instance_id uuid, + id uuid NOT NULL PRIMARY KEY, + aud varchar(255), + role varchar(255), + email varchar(255), + encrypted_password varchar(255), + email_confirmed_at timestamptz, + invited_at timestamptz, + confirmation_token varchar(255), + confirmation_sent_at timestamptz, + recovery_token varchar(255), + recovery_sent_at timestamptz, + email_change_token_new varchar(255), + email_change varchar(255), + email_change_sent_at timestamptz, + last_sign_in_at timestamptz, + raw_app_meta_data jsonb, + raw_user_meta_data jsonb, + is_super_admin boolean, + created_at timestamptz, + updated_at timestamptz, + phone text DEFAULT NULL, + phone_confirmed_at timestamptz, + phone_change text DEFAULT '', + phone_change_token varchar(255) DEFAULT '', + phone_change_sent_at timestamptz, + confirmed_at timestamptz, + email_change_token_current varchar(255) DEFAULT '', + email_change_confirm_status smallint DEFAULT 0, + banned_until timestamptz, + reauthentication_token varchar(255) DEFAULT '', + reauthentication_sent_at timestamptz, + is_sso_user boolean NOT NULL DEFAULT false, + deleted_at timestamptz, + is_anonymous boolean NOT NULL DEFAULT false + )`, + `CREATE TABLE IF NOT EXISTS auth.sessions ( + id uuid NOT NULL PRIMARY KEY, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + created_at timestamptz, + updated_at timestamptz, + factor_id uuid, + aal text, + not_after timestamptz, + refreshed_at timestamp, + user_agent text, + ip inet, + tag text + )`, + `CREATE TABLE IF NOT EXISTS auth.mfa_factors ( + id uuid NOT NULL PRIMARY KEY, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + friendly_name text, + factor_type text NOT NULL, + status text NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + secret text, + phone text, + last_challenged_at timestamptz, + web_authn_credential jsonb, + web_authn_aaguid uuid + )`, + `GRANT SELECT, INSERT, UPDATE, DELETE ON auth.users, auth.sessions, auth.mfa_factors TO anon, authenticated, service_role`, +] + +// Seeded alongside public tables so FK references from public → auth.users +// resolve to real rows. rls flags are ignored here — auth.users is set up by +// SANDBOX_SETUP_STATEMENTS, this entry is only used by the seed step. +// +// Columns are an explicit allow-list: enough to evaluate realistic RLS +// policies (id for FK matching, role/email/metadata for claim-style checks) +// while keeping secrets out of the browser-side PGlite instance — no +// encrypted_password, no *_token columns. +export const AUTH_USERS_SEED_TABLE = { + schema: 'auth', + table: 'users', + rls_enabled: false, + rls_forced: false, + columns: [ + 'id', + 'aud', + 'role', + 'email', + 'phone', + 'email_confirmed_at', + 'phone_confirmed_at', + 'last_sign_in_at', + 'confirmed_at', + 'raw_app_meta_data', + 'raw_user_meta_data', + 'is_super_admin', + 'is_sso_user', + 'is_anonymous', + 'banned_until', + 'deleted_at', + 'created_at', + 'updated_at', + ], +} as const diff --git a/apps/studio/state/postgres-sandbox/sandbox.core.ts b/apps/studio/state/postgres-sandbox/sandbox.core.ts new file mode 100644 index 00000000000..5751ecdb646 --- /dev/null +++ b/apps/studio/state/postgres-sandbox/sandbox.core.ts @@ -0,0 +1,74 @@ +import { PGliteWorker } from '@electric-sql/pglite/worker' + +import { SANDBOX_SETUP_STATEMENTS } from './sandbox.constants' +import { applySchema, applySeed } from './sandbox.utils' +import { type DatabaseSchemaDDLData } from '@/data/rls-tester/get-schema-ddl' +import { type TableSeedData } from '@/data/rls-tester/get-seed-data' +import { getErrorMessage } from '@/lib/get-error-message' + +type RLSTestResult = Record[] + +export interface SandboxCore { + setSchema(data: DatabaseSchemaDDLData): Promise + setSeed(tables: TableSeedData[]): Promise + destroy(): Promise + run: (props: { sql: string }) => Promise<{ result: RLSTestResult }> +} + +let instance: SandboxCore | null = null +let initPromise: Promise | null = null + +export const getSandboxCore = async () => { + if (instance) return instance + if (!initPromise) { + initPromise = boot().finally(() => { + initPromise = null + }) + } + return initPromise +} + +const boot = async (): Promise => { + const webWorker = new Worker(new URL('./pglite.worker.ts', import.meta.url), { type: 'module' }) + const pg = await PGliteWorker.create(webWorker) + + for (const sql of SANDBOX_SETUP_STATEMENTS) { + try { + await pg.exec(sql) + } catch (err) { + console.warn('[Postgres sandbox] setup:', (err as Error).message, `— ${sql.slice(0, 60)}`) + } + } + + function makeExecutor() { + return { execSql: (sql: string) => pg.exec(sql).then(() => undefined as void) } + } + + async function setSchema(data: DatabaseSchemaDDLData): Promise { + await applySchema(makeExecutor(), data) + } + + async function setSeed(tables: TableSeedData[]): Promise { + await applySeed(makeExecutor(), tables) + } + + const run = async ({ sql }: { sql: string }) => { + try { + // [Joshen] First 2 results will be from role impersonation, the actual result from the + // query will be returned as the 3rd result. + const results = await pg.exec(sql) + return { result: results[2].rows ?? [] } + } catch (error) { + await pg.exec('ROLLBACK').catch(() => {}) + throw error instanceof Error ? error : new Error(getErrorMessage(error) ?? String(error)) + } + } + + const destroy = async () => { + webWorker.terminate() + instance = null + } + + instance = { run, destroy, setSchema, setSeed } + return instance +} diff --git a/apps/studio/state/postgres-sandbox/sandbox.tsx b/apps/studio/state/postgres-sandbox/sandbox.tsx new file mode 100644 index 00000000000..f6a7e1b447a --- /dev/null +++ b/apps/studio/state/postgres-sandbox/sandbox.tsx @@ -0,0 +1,143 @@ +import { noop } from 'lodash' +import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' +import { toast } from 'sonner' + +import { AUTH_USERS_SEED_TABLE } from './sandbox.constants' +import { getSandboxCore, type SandboxCore } from './sandbox.core' +import { getDatabaseSchemaDDL } from '@/data/rls-tester/get-schema-ddl' +import { getProjectSeedData, TableSeedData } from '@/data/rls-tester/get-seed-data' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { getErrorMessage } from '@/lib/get-error-message' + +type SandboxStatus = 'idle' | 'loading' | 'ready' | 'error' + +const SandboxContext = createContext<{ + status: SandboxStatus + error?: string + sandbox: SandboxCore | null + isSyncing: boolean + startSandbox: () => void + destroySandbox: () => Promise + syncSandbox: () => Promise +}>({ + status: 'idle', + error: undefined, + sandbox: null, + isSyncing: false, + startSandbox: noop, + destroySandbox: async () => {}, + syncSandbox: async () => {}, +}) + +export const PostgresSandboxProvider = ({ children }: PropsWithChildren) => { + const { data: project } = useSelectedProjectQuery() + + const [start, setStart] = useState(false) + const [error, setError] = useState() + const [sandbox, setSandbox] = useState(null) + + const [status, setStatus] = useState('idle') + const [isSyncing, setIsSyncing] = useState(false) + + const destroySandbox = async () => { + if (isSyncing) return + if (!sandbox) return console.error('Sandbox is not set up') + + await sandbox.destroy() + setSandbox(null) + setStatus('idle') + setError(undefined) + setStart(false) + } + + // Internal — takes the target explicitly so the boot path can pass the + // freshly booted core before React state has caught up. Callers outside + // the provider use `syncSandbox()` which sources the target from state. + const applyToCore = async (target: SandboxCore) => { + setIsSyncing(true) + + try { + const schemaDDL = await getDatabaseSchemaDDL({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schemas: ['public'], + }) + + const seedData: TableSeedData[] = await getProjectSeedData({ + projectRef: project?.ref, + connectionString: project?.connectionString, + tables: [AUTH_USERS_SEED_TABLE, ...(schemaDDL.rlsStatuses ?? [])], + rowLimit: 100, + }) + + await target.setSchema(schemaDDL) + await target.setSeed(seedData) + } catch (e) { + const message = getErrorMessage(e) ?? String(e) + if (sandbox) { + // Refresh path — sandbox is still usable with the previous schema/data. + toast.error(`Failed to refresh sandbox: ${message}`) + } else { + // Boot path — propagate so the outer .catch sets status='error' and + // the SandboxManagement error branch renders. + throw e + } + } finally { + setIsSyncing(false) + } + } + + const syncSandbox = async () => { + if (isSyncing) return + if (!sandbox) return console.error('Sandbox has not been loaded') + await applyToCore(sandbox) + } + + useEffect(() => { + if (!start) return + + let cancelled = false + setStatus('loading') + + getSandboxCore() + .then(async (core) => { + if (cancelled) return + + await applyToCore(core) + setSandbox(core) + setStatus('ready') + }) + .catch((error) => { + if (cancelled) return + + setError(getErrorMessage(error) ?? '') + setStatus('error') + setStart(false) + }) + + return () => { + cancelled = true + } + // applyToCore intentionally omitted: this effect should fire once when + // `start` flips, not every time the helper identity changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [start]) + + return ( + setStart(true), + destroySandbox, + syncSandbox, + }} + > + {children} + + ) +} + +export const usePostgresSandbox = () => useContext(SandboxContext) diff --git a/apps/studio/state/postgres-sandbox/sandbox.utils.ts b/apps/studio/state/postgres-sandbox/sandbox.utils.ts new file mode 100644 index 00000000000..d92b4a0cb8c --- /dev/null +++ b/apps/studio/state/postgres-sandbox/sandbox.utils.ts @@ -0,0 +1,210 @@ +import { ident, literal, type PGPolicy } from '@supabase/pg-meta' + +import { DatabaseSchemaDDL } from '@/data/rls-tester/get-schema-ddl' +import { TableSeedData } from '@/data/rls-tester/get-seed-data' +import { getErrorMessage } from '@/lib/get-error-message' + +interface Executor { + execSql(sql: string): Promise +} + +function buildPolicySQL(policy: PGPolicy): string { + const name = ident(policy.name) + const target = `${ident(policy.schema)}.${ident(policy.table)}` + const permissiveness = policy.action === 'RESTRICTIVE' ? 'AS RESTRICTIVE' : '' + const command = policy.command === 'ALL' ? '' : `FOR ${policy.command}` + const roles = policy.roles?.length ? `TO ${policy.roles.map(ident).join(', ')}` : '' + const using = policy.definition ? `USING (${policy.definition})` : '' + const withCheck = policy.check ? `WITH CHECK (${policy.check})` : '' + + const drop = `DROP POLICY IF EXISTS ${name} ON ${target}` + const create = [ + `CREATE POLICY ${name}`, + `ON ${target}`, + permissiveness, + command, + roles, + using, + withCheck, + ] + .filter(Boolean) + .join(' ') + return `${drop}; ${create}` +} + +async function tryExec(sandbox: Executor, sql: string, label: string): Promise { + try { + await sandbox.execSql(sql) + } catch (err) { + console.warn(`[rls-sandbox] skipped ${label}:`, getErrorMessage(err) ?? err) + } +} + +// Retry items until no further progress can be made — handles ordering +// dependencies (e.g. table A references type B that hasn't been created yet). +// Each pass attempts every pending item; survivors carry forward. When a full +// pass makes zero progress, surviving items are reported as unresolved. +async function runUntilFixpoint( + items: T[], + attempt: (item: T) => Promise, + onUnresolved: (item: T, error: unknown) => void +): Promise { + let pending = items.slice() + while (pending.length > 0) { + const failed: Array<{ item: T; error: unknown }> = [] + for (const item of pending) { + try { + await attempt(item) + } catch (error) { + failed.push({ item, error }) + } + } + if (failed.length === pending.length) { + for (const { item, error } of failed) onUnresolved(item, error) + break + } + pending = failed.map((f) => f.item) + } +} + +async function applyDDLWithRetries(sandbox: Executor, ddlStatements: string[]): Promise { + await runUntilFixpoint( + ddlStatements, + (ddl) => sandbox.execSql(ddl), + (ddl, error) => + console.warn( + `[rls-sandbox] skipped DDL: ${ddl.slice(0, 80).replace(/\s+/g, ' ')} — ${getErrorMessage(error) ?? String(error)}` + ) + ) +} + +export async function applySchema( + sandbox: Executor, + { + schemas, + typeDefinitions, + entityDefinitions, + functionDefinitions, + policies, + rlsStatuses, + customRoles, + }: DatabaseSchemaDDL +): Promise { + // Reset each user schema so re-syncs pick up renames/drops/column changes and + // CREATE statements don't collide with the previous run's objects. + for (const schema of schemas) { + const schemaId = ident(schema) + await tryExec(sandbox, `DROP SCHEMA IF EXISTS ${schemaId} CASCADE`, `drop schema ${schema}`) + await tryExec(sandbox, `CREATE SCHEMA ${schemaId}`, `create schema ${schema}`) + await tryExec( + sandbox, + `GRANT USAGE ON SCHEMA ${schemaId} TO anon, authenticated, service_role`, + `grant schema ${schema}` + ) + } + + if (customRoles.length > 0) { + const checks = customRoles + .map( + ({ name }) => + `IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ${literal(name)}) THEN CREATE ROLE ${ident(name)} NOLOGIN; END IF;` + ) + .join('\n') + await tryExec(sandbox, `DO $$ BEGIN\n${checks}\nEND $$`, 'custom roles') + } + + await applyDDLWithRetries(sandbox, typeDefinitions) + await applyDDLWithRetries(sandbox, entityDefinitions) + + for (const schema of [...new Set(rlsStatuses.map((t) => t.schema))]) { + await tryExec( + sandbox, + `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ${ident(schema)} TO anon, authenticated, service_role`, + `grant tables in schema ${schema}` + ) + } + + for (const { schema, table, rls_enabled, rls_forced } of rlsStatuses) { + const actions: string[] = [] + if (rls_enabled) actions.push('ENABLE ROW LEVEL SECURITY') + if (rls_forced) actions.push('FORCE ROW LEVEL SECURITY') + if (actions.length === 0) continue + await tryExec( + sandbox, + `ALTER TABLE ${ident(schema)}.${ident(table)} ${actions.join(', ')}`, + `RLS on ${schema}.${table}` + ) + } + + // Disable check_function_bodies so functions referencing not-yet-created objects don't abort. + // Postgres resolves policy→function references at query time, not at CREATE POLICY time. + await tryExec(sandbox, `SET check_function_bodies = off`, 'set check_function_bodies') + for (const fn of functionDefinitions) { + await tryExec(sandbox, fn, `function ${fn.slice(0, 60).replace(/\s+/g, ' ')}`) + } + await tryExec(sandbox, `RESET check_function_bodies`, 'reset check_function_bodies') + + for (const policy of policies) { + await tryExec( + sandbox, + buildPolicySQL(policy), + `policy ${policy.schema}.${policy.table} "${policy.name}"` + ) + } +} + +function serializeValue(val: unknown): string { + if (val === null || val === undefined) return 'NULL' + if (typeof val === 'boolean') return val ? 'TRUE' : 'FALSE' + if (typeof val === 'number') return String(val) + if (val instanceof Date) return `'${val.toISOString()}'` + if (Array.isArray(val)) return `ARRAY[${val.map(serializeValue).join(', ')}]` + if (typeof val === 'object') return `'${JSON.stringify(val).replace(/'/g, "''")}'::jsonb` + return `'${String(val).replace(/'/g, "''")}'` +} + +function buildInsertSQL(schema: string, table: string, rows: Record[]): string { + if (rows.length === 0) throw new Error(`buildInsertSQL requires at least one row`) + const columns = Object.keys(rows[0]) + const colList = columns.map((c) => ident(c)).join(', ') + const valuesList = rows + .map((row) => `(${columns.map((c) => serializeValue(row[c])).join(', ')})`) + .join(',\n ') + return `INSERT INTO ${ident(schema)}.${ident(table)} (${colList}) VALUES\n ${valuesList};` +} + +export async function applySeed(sandbox: Executor, tables: TableSeedData[]): Promise { + // Disable FK triggers so we can delete and re-insert in any order. + // Requires superuser (ALTER ROLE postgres SUPERUSER in SANDBOX_SETUP_STATEMENTS). + // Falls back gracefully if the privilege is not available. + let triggersDisabled = false + try { + await sandbox.execSql(`SET session_replication_role = replica`) + triggersDisabled = true + } catch { + // postgres not yet a superuser in this PGlite build — proceed without it + } + + try { + // Always clear before inserting so re-seed reflects the latest data. + for (const { schema, table } of tables) { + try { + await sandbox.execSql(`DELETE FROM ${ident(schema)}.${ident(table)}`) + } catch { + // table may not exist yet — ignore + } + } + + // Retry loop handles any remaining FK ordering constraints. + await runUntilFixpoint( + tables.filter((t) => t.rows.length > 0), + (entry) => sandbox.execSql(buildInsertSQL(entry.schema, entry.table, entry.rows)), + (entry) => + console.warn(`[rls-sandbox] seed skipped ${entry.schema}.${entry.table}: unresolved FK`) + ) + } finally { + if (triggersDisabled) { + await sandbox.execSql(`SET session_replication_role = DEFAULT`) + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 400fb29d026..e2b4aa92deb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -890,6 +890,12 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.6) + '@electric-sql/pglite': + specifier: 0.4.5 + version: 0.4.5 + '@electric-sql/pglite-tools': + specifier: ^0.3.4 + version: 0.3.5(@electric-sql/pglite@0.4.5) '@graphiql/react': specifier: ^0.37.3 version: 0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@22.13.14)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@5.14.1(graphql@16.11.0))(graphql@16.11.0)(immer@10.1.1)(react-compiler-runtime@19.1.0-rc.1(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) @@ -1912,7 +1918,7 @@ importers: version: 0.562.0(vue@3.5.30(typescript@6.0.2)) nuxt: specifier: ^4.4.0 - version: 4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.2.15)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.2.15))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3) + version: 4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.4.5)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.5))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3) tailwind-merge: specifier: ^3.5.0 version: 3.5.0 @@ -3409,8 +3415,13 @@ packages: '@effect-ts/system@0.57.5': resolution: {integrity: sha512-/crHGujo0xnuHIYNc1VgP0HGJGFSoSqq88JFXe6FmFyXPpWt8Xu39LyLg7rchsxfXFeEdA9CrIZvLV5eswXV5g==} - '@electric-sql/pglite@0.2.15': - resolution: {integrity: sha512-Jiq31Dnk+rg8rMhcSxs4lQvHTyizNo5b269c1gCC3ldQ0sCLrNVPGzy+KnmonKy1ZArTUuXZf23/UamzFMKVaA==} + '@electric-sql/pglite-tools@0.3.5': + resolution: {integrity: sha512-4him0RnIyqrSqk0zzeBJKJ++VVFk6bFCwKSVV7DP3T8fOQgRSVZShlrHfAChLG2uA/T4JdQ8b/15CxBn+E34TQ==} + peerDependencies: + '@electric-sql/pglite': 0.4.5 + + '@electric-sql/pglite@0.4.5': + resolution: {integrity: sha512-aGG2zGEyZzGWKy8P+9ZoNUV0jxt1+hgbeTf+bVAYyxVZZLXg3/9aFlfLxb08AYZVAfAkQlQIysmWjhc5hwDG8g==} '@emnapi/core@1.9.2': resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} @@ -18994,8 +19005,11 @@ snapshots: '@effect-ts/system@0.57.5': {} - '@electric-sql/pglite@0.2.15': - optional: true + '@electric-sql/pglite-tools@0.3.5(@electric-sql/pglite@0.4.5)': + dependencies: + '@electric-sql/pglite': 0.4.5 + + '@electric-sql/pglite@0.4.5': {} '@emnapi/core@1.9.2': dependencies: @@ -20725,7 +20739,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/nitro-server@4.4.2(cad3000854b121359e207ff30aa7ffec)': + '@nuxt/nitro-server@4.4.2(2a462464d62fb8b7515044774e7d5187)': dependencies: '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1)) '@nuxt/devalue': 2.0.2 @@ -20743,8 +20757,8 @@ snapshots: impound: 1.1.5 klona: 2.0.6 mocked-exports: 0.1.1 - nitropack: 2.13.4(@electric-sql/pglite@0.2.15)(aws4fetch@1.0.20)(encoding@0.1.13)(oxc-parser@0.117.0)(rolldown@1.0.0-rc.15)(supports-color@8.1.1) - nuxt: 4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.2.15)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.2.15))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3) + nitropack: 2.13.4(@electric-sql/pglite@0.4.5)(aws4fetch@1.0.20)(encoding@0.1.13)(oxc-parser@0.117.0)(rolldown@1.0.0-rc.15)(supports-color@8.1.1) + nuxt: 4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.4.5)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.5))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3) nypm: 0.6.5 ohash: 2.0.11 pathe: 2.0.3 @@ -20753,7 +20767,7 @@ snapshots: std-env: 4.1.0 ufo: 1.6.3 unctx: 2.5.0 - unstorage: 1.17.5(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.2.15))(ioredis@5.10.1(supports-color@8.1.1)) + unstorage: 1.17.5(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.4.5))(ioredis@5.10.1(supports-color@8.1.1)) vue: 3.5.30(typescript@6.0.2) vue-bundle-renderer: 2.2.0 vue-devtools-stub: 0.1.0 @@ -20809,7 +20823,7 @@ snapshots: rc9: 3.0.0 std-env: 3.10.0 - '@nuxt/vite-builder@4.4.2(034982d584f76f7f1b97787c79a9bfb8)': + '@nuxt/vite-builder@4.4.2(d040f269d3a8e69eae621831338741ff)': dependencies: '@nuxt/kit': 4.4.2(magicast@0.5.2) '@rollup/plugin-replace': 6.0.3(rollup@4.60.3) @@ -20827,7 +20841,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.1 mocked-exports: 0.1.1 - nuxt: 4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.2.15)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.2.15))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3) + nuxt: 4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.4.5)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.5))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3) nypm: 0.6.5 pathe: 2.0.3 pkg-types: 2.3.0 @@ -26895,9 +26909,9 @@ snapshots: dayjs@1.11.20: {} - db0@0.3.4(@electric-sql/pglite@0.2.15): + db0@0.3.4(@electric-sql/pglite@0.4.5): optionalDependencies: - '@electric-sql/pglite': 0.2.15 + '@electric-sql/pglite': 0.4.5 dc-browser@1.0.4: {} @@ -31314,7 +31328,7 @@ snapshots: nice-try@1.0.5: {} - nitropack@2.13.4(@electric-sql/pglite@0.2.15)(aws4fetch@1.0.20)(encoding@0.1.13)(oxc-parser@0.117.0)(rolldown@1.0.0-rc.15)(supports-color@8.1.1): + nitropack@2.13.4(@electric-sql/pglite@0.4.5)(aws4fetch@1.0.20)(encoding@0.1.13)(oxc-parser@0.117.0)(rolldown@1.0.0-rc.15)(supports-color@8.1.1): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.60.3) @@ -31335,7 +31349,7 @@ snapshots: cookie-es: 2.0.1 croner: 10.0.1 crossws: 0.3.5 - db0: 0.3.4(@electric-sql/pglite@0.2.15) + db0: 0.3.4(@electric-sql/pglite@0.4.5) defu: 6.1.7 destr: 2.0.5 dot-prop: 10.1.0 @@ -31381,7 +31395,7 @@ snapshots: unenv: 2.0.0-rc.24 unimport: 6.2.0(oxc-parser@0.117.0) unplugin-utils: 0.3.1 - unstorage: 1.17.5(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.2.15))(ioredis@5.10.1(supports-color@8.1.1)) + unstorage: 1.17.5(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.4.5))(ioredis@5.10.1(supports-color@8.1.1)) untyped: 2.0.0 unwasm: 0.5.3 youch: 4.1.1 @@ -31591,16 +31605,16 @@ snapshots: next: 15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4) react-router: 7.13.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - nuxt@4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.2.15)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.2.15))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3): + nuxt@4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.4.5)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.5))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3): dependencies: '@dxup/nuxt': 0.4.0(magicast@0.5.2)(typescript@6.0.2) '@nuxt/cli': 3.34.0(@nuxt/schema@4.4.2)(cac@6.7.14)(magicast@0.5.2)(supports-color@8.1.1) '@nuxt/devtools': 3.2.4(supports-color@8.1.1)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(vue@3.5.30(typescript@6.0.2)) '@nuxt/kit': 4.4.2(magicast@0.5.2) - '@nuxt/nitro-server': 4.4.2(cad3000854b121359e207ff30aa7ffec) + '@nuxt/nitro-server': 4.4.2(2a462464d62fb8b7515044774e7d5187) '@nuxt/schema': 4.4.2 '@nuxt/telemetry': 2.7.0(@nuxt/kit@4.4.2(magicast@0.5.2)) - '@nuxt/vite-builder': 4.4.2(034982d584f76f7f1b97787c79a9bfb8) + '@nuxt/vite-builder': 4.4.2(d040f269d3a8e69eae621831338741ff) '@unhead/vue': 2.1.12(vue@3.5.30(typescript@6.0.2)) '@vue/shared': 3.5.30 c12: 3.3.4(magicast@0.5.2) @@ -35157,7 +35171,7 @@ snapshots: escape-string-regexp: 5.0.0 ufo: 1.6.3 - unstorage@1.17.5(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.2.15))(ioredis@5.10.1(supports-color@8.1.1)): + unstorage@1.17.5(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.4.5))(ioredis@5.10.1(supports-color@8.1.1)): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -35169,7 +35183,7 @@ snapshots: ufo: 1.6.4 optionalDependencies: aws4fetch: 1.0.20 - db0: 0.3.4(@electric-sql/pglite@0.2.15) + db0: 0.3.4(@electric-sql/pglite@0.4.5) ioredis: 5.10.1(supports-color@8.1.1) until-async@3.0.2: {} From 5950b6ca049f248ec963a89211647b2cfc506b1b Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Mon, 18 May 2026 17:31:40 +0800 Subject: [PATCH 04/20] test(e2e/studio): stabilize flaky and TZ/OS-sensitive specs (#46039) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backports a batch of e2e test stabilization fixes — each commit is scoped to a single failure class and only touches `e2e/studio/` files. **Changed:** - **`_global.setup` — playwright-locks cleanup was dead code**: the lock-cleanup block was at the bottom of `Global Setup`, but every branch above it returns early — so it never ran. Tests that use `withFileOnceSetup` (cron-jobs) would see a stale `setup.done.json` marker from the previous run and silently skip their setup, leaving e.g. `pg_cron` uninstalled and all 11 cron-jobs specs failing. Moved the cleanup to before any early return. - **filter-bar — Home key**: macOS Chromium doesn't honor a standalone `Home` keypress inside text inputs (macOS routes "go to line start" via `Cmd+ArrowLeft` / `Fn+ArrowLeft`). Tests that expected the cursor to jump to position 0 silently kept the previous selection. Replaced with `el.setSelectionRange(0, 0)` so the assertion runs against a known cursor position on every OS. - **filter-bar — date filters**: tests inserted rows with `CURRENT_DATE` / `NOW()` (postgres session TZ = UTC) and asserted with JS-local dates from `getDateValue()`. Near midnight the two diverged and the filter returned 0 rows. Switched the inserts to explicit `getDateValue()` strings so insert and assert use the same calendar day. - **queue-table-operations — `networkidle`**: Studio holds long-poll / SSE connections (PostHog, realtime), so `page.reload({ waitUntil: 'networkidle' })` never resolves and timed out. Replaced with a targeted `waitForTableToLoad` API waiter. - **sql-editor — RLS smoke test**: a hard-coded table name (`pw_rls_smoke_test`) collided across 3 parallel workers running against the same db. Suffixed with `test.info().parallelIndex`. - **table-editor — FK spec timeout**: `waitForApiResponseWithTimeout` for `query?key=table-update` returns `null` on timeout (silent), then the panel-close assertion fails. Bumped 15s → 30s to absorb parallel-load latency. - **table-editor / storage-helpers — URL encoding & redirect race**: post-action URL assertions were over-specific (`%20` vs `+` encoding) and the bucket-delete redirect could race other history updates. Relaxed the regex to accept both encodings; asserting the row removal directly is a more stable signal than the redirect URL. ## Summary by CodeRabbit * **Tests** * Improved end-to-end determinism with explicit dates/timestamps and stable cursor positioning * Prevented parallel-test collisions by using unique identifiers for resources * Made page reloads and API waits more robust for long-lived connections and increased timeouts * Strengthened assertions to rely on stable UI signals instead of transient navigation/network state * Ensured test setup reliably cleans up temporary locks before any setup steps run [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46039?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- e2e/studio/features/_global.setup.ts | 27 ++++--- e2e/studio/features/filter-bar.spec.ts | 81 ++++++++++++------- .../features/queue-table-operations.spec.ts | 10 ++- e2e/studio/features/sql-editor.spec.ts | 6 +- e2e/studio/features/table-editor.spec.ts | 24 ++++-- e2e/studio/utils/storage-helpers.ts | 11 +-- 6 files changed, 104 insertions(+), 55 deletions(-) diff --git a/e2e/studio/features/_global.setup.ts b/e2e/studio/features/_global.setup.ts index 420590efb19..2dc94a06edc 100644 --- a/e2e/studio/features/_global.setup.ts +++ b/e2e/studio/features/_global.setup.ts @@ -1,13 +1,13 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' import { test as setup } from '@playwright/test' import dotenv from 'dotenv' -import path from 'node:path' -import os from 'node:os' -import fs from 'node:fs/promises' import { env } from '../env.config.js' -import { setupProjectForTests } from '../scripts/setup-platform-tests.js' import { loginWithEmail } from '../scripts/login/email.js' import { loginWithGithubWithRetry } from '../scripts/login/github.js' +import { setupProjectForTests } from '../scripts/setup-platform-tests.js' /** * Run any setup tasks for the tests. @@ -30,6 +30,16 @@ setup('Global Setup', async ({ page }) => { - Is Platform: ${IS_PLATFORM} `) + // Cleanup once-per-file locks before any of the early returns below — + // tests using `withFileOnceSetup` rely on this running unconditionally. + const locksDirPath = path.join(os.tmpdir(), 'playwright-locks') + try { + await fs.access(locksDirPath) + await fs.rm(locksDirPath, { recursive: true, force: true }) + } catch { + // Silently catch, no directory + } + /** * Studio Check */ @@ -121,13 +131,4 @@ To start API locally, run: throw err } } - - // Cleanup locks as they may persist between runs especially locally - const locksDirPath = path.join(os.tmpdir(), 'playwright-locks') - try { - await fs.access(locksDirPath); - await fs.rm(locksDirPath, { recursive: true, force: true }) - } catch { - // Silently catch, no directory - } }) diff --git a/e2e/studio/features/filter-bar.spec.ts b/e2e/studio/features/filter-bar.spec.ts index 345441fd876..badaa786eb3 100644 --- a/e2e/studio/features/filter-bar.spec.ts +++ b/e2e/studio/features/filter-bar.spec.ts @@ -19,7 +19,8 @@ import { createApiResponseWaiter, waitForTableToLoad } from '../utils/wait-for-r const tableNamePrefix = 'pw_filter_bar' function getDateValue(daysAgo: number): string { - const date = new Date(Date.now() - daysAgo * 86400000) + const date = new Date() + date.setDate(date.getDate() - daysAgo) const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') @@ -489,7 +490,10 @@ test.describe('Filter Bar', () => { const lastNameValue = page.getByTestId('filter-value-last_name') await lastNameValue.click() - await page.keyboard.press('Home') + // Drive the cursor to position 0 directly rather than via `Home` — + // macOS Chromium doesn't honor the Home key inside text inputs (the + // OS-level binding is Cmd+ArrowLeft / Fn+ArrowLeft). + await lastNameValue.evaluate((el) => (el as HTMLInputElement).setSelectionRange(0, 0)) await page.keyboard.press('ArrowLeft') @@ -543,10 +547,9 @@ test.describe('Filter Bar', () => { const valueInput = page.getByTestId(`filter-value-${columnName}`) await valueInput.fill('HelloWorld') - await page.keyboard.press('Home') - for (let i = 0; i < 5; i++) { - await page.keyboard.press('ArrowRight') - } + // See "ArrowLeft at position 0" — macOS Chromium ignores the Home + // key inside text inputs, so set the cursor programmatically. + await valueInput.evaluate((el) => (el as HTMLInputElement).setSelectionRange(5, 5)) await page.keyboard.type('_Middle_') await expect(valueInput).toHaveValue('Hello_Middle_World') @@ -637,6 +640,8 @@ test.describe('Filter Bar', () => { test('filtering by Today shows only today rows', async ({ page, ref }) => { const tableName = `${tableNamePrefix}_date_today` const todayValue = getDateValue(0) + const yesterdayValue = getDateValue(1) + const lastWeekValue = getDateValue(7) await query( `CREATE TABLE IF NOT EXISTS ${tableName} ( @@ -644,11 +649,18 @@ test.describe('Filter Bar', () => { created_at date )` ) + // Insert exact date strings computed from the test's `getDateValue` + // (JS local TZ) rather than `CURRENT_DATE` (postgres session TZ ≈ UTC). + // The studio's "Today" dropdown is built from JS local time too, so + // aligning the inserted rows with the filter's expected value keeps + // these tests deterministic across timezones — otherwise on non-UTC + // hosts the local "today" can land on a different day than postgres' + // CURRENT_DATE and the filter matches zero rows. await query( - `INSERT INTO ${tableName} (created_at) VALUES - (CURRENT_DATE), - (CURRENT_DATE - INTERVAL '1 day'), - (CURRENT_DATE - INTERVAL '7 days')` + `INSERT INTO ${tableName} (created_at) VALUES + ('${todayValue}'), + ('${yesterdayValue}'), + ('${lastWeekValue}')` ) try { @@ -666,7 +678,9 @@ test.describe('Filter Bar', () => { test('filtering by Yesterday shows only yesterday rows', async ({ page, ref }) => { const tableName = `${tableNamePrefix}_date_yest` + const todayValue = getDateValue(0) const yesterdayValue = getDateValue(1) + const lastWeekValue = getDateValue(7) await query( `CREATE TABLE IF NOT EXISTS ${tableName} ( @@ -674,11 +688,12 @@ test.describe('Filter Bar', () => { created_at date )` ) + // See "filtering by Today" — align inserts with JS-local getDateValue. await query( - `INSERT INTO ${tableName} (created_at) VALUES - (CURRENT_DATE), - (CURRENT_DATE - INTERVAL '1 day'), - (CURRENT_DATE - INTERVAL '7 days')` + `INSERT INTO ${tableName} (created_at) VALUES + ('${todayValue}'), + ('${yesterdayValue}'), + ('${lastWeekValue}')` ) try { @@ -696,7 +711,10 @@ test.describe('Filter Bar', () => { test('filtering by Last 7 days with greater or equal operator', async ({ page, ref }) => { const tableName = `${tableNamePrefix}_date_7d` + const todayValue = getDateValue(0) + const threeDaysAgoValue = getDateValue(3) const last7DaysValue = getDateValue(7) + const lastMonthValue = getDateValue(30) await query( `CREATE TABLE IF NOT EXISTS ${tableName} ( @@ -704,12 +722,13 @@ test.describe('Filter Bar', () => { created_at date )` ) + // See "filtering by Today" — align inserts with JS-local getDateValue. await query( - `INSERT INTO ${tableName} (created_at) VALUES - (CURRENT_DATE), - (CURRENT_DATE - INTERVAL '3 days'), - (CURRENT_DATE - INTERVAL '7 days'), - (CURRENT_DATE - INTERVAL '30 days')` + `INSERT INTO ${tableName} (created_at) VALUES + ('${todayValue}'), + ('${threeDaysAgoValue}'), + ('${last7DaysValue}'), + ('${lastMonthValue}')` ) try { @@ -728,6 +747,8 @@ test.describe('Filter Bar', () => { test('date less than operator filters correctly', async ({ page, ref }) => { const tableName = `${tableNamePrefix}_date_lt` const todayValue = getDateValue(0) + const yesterdayValue = getDateValue(1) + const lastWeekValue = getDateValue(7) await query( `CREATE TABLE IF NOT EXISTS ${tableName} ( @@ -735,11 +756,12 @@ test.describe('Filter Bar', () => { created_at date )` ) + // See "filtering by Today" — align inserts with JS-local getDateValue. await query( - `INSERT INTO ${tableName} (created_at) VALUES - (CURRENT_DATE), - (CURRENT_DATE - INTERVAL '1 day'), - (CURRENT_DATE - INTERVAL '7 days')` + `INSERT INTO ${tableName} (created_at) VALUES + ('${todayValue}'), + ('${yesterdayValue}'), + ('${lastWeekValue}')` ) try { @@ -760,6 +782,8 @@ test.describe('Filter Bar', () => { test('timestamp column shows date preset dropdown options', async ({ page, ref }) => { const tableName = `${tableNamePrefix}_ts_today` const todayValue = getDateValue(0) + const yesterdayValue = getDateValue(1) + const lastWeekValue = getDateValue(7) await query( `CREATE TABLE IF NOT EXISTS ${tableName} ( @@ -767,11 +791,14 @@ test.describe('Filter Bar', () => { created_at timestamp )` ) + // See "filtering by Today" — pin timestamps to mid-day of each + // JS-local date so the row falls on the correct calendar day from + // postgres's perspective regardless of the host timezone. await query( - `INSERT INTO ${tableName} (created_at) VALUES - (NOW()), - (NOW() - INTERVAL '1 day'), - (NOW() - INTERVAL '7 days')` + `INSERT INTO ${tableName} (created_at) VALUES + ('${todayValue} 12:00:00'), + ('${yesterdayValue} 12:00:00'), + ('${lastWeekValue} 12:00:00')` ) try { diff --git a/e2e/studio/features/queue-table-operations.spec.ts b/e2e/studio/features/queue-table-operations.spec.ts index 12063f31674..b4c55f9cd01 100644 --- a/e2e/studio/features/queue-table-operations.spec.ts +++ b/e2e/studio/features/queue-table-operations.spec.ts @@ -28,7 +28,15 @@ test.describe('Queue Table Operations', () => { await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) await loadPromise await enableQueueOperations(page) - await page.reload({ waitUntil: 'networkidle' }) + // Wait on the table-list refetch that follows the reload rather than + // `networkidle` — studio keeps long-lived polling/SSE connections open + // (PostHog, Sentry, realtime) so `networkidle` never resolves and the + // beforeEach hits its 120 s test timeout. The reload's goal is "wait + // until the editor is loaded again with the new toggle applied" and + // the entity-types query is a precise signal for that. + const reloadWait = waitForTableToLoad(page, ref) + await page.reload() + await reloadWait }) test('cell edits are queued and can be saved', async ({ page, ref }) => { diff --git a/e2e/studio/features/sql-editor.spec.ts b/e2e/studio/features/sql-editor.spec.ts index dcd1d93394f..21a302d20a5 100644 --- a/e2e/studio/features/sql-editor.spec.ts +++ b/e2e/studio/features/sql-editor.spec.ts @@ -291,7 +291,11 @@ test.describe('SQL Editor', () => { }) test('warns on CREATE TABLE without RLS and "Run and enable RLS" enables it', async ({ ref }) => { - const tableName = 'pw_rls_smoke_test' + // Suffix with parallel worker index so parallel workers don't collide + // on the same table name — when they do, one worker's `dropTable` + // races another's "Run and enable RLS" and the post-action query + // sometimes finds the table missing. + const tableName = `pw_rls_smoke_test_${test.info().parallelIndex}` // Drop any leftover table from a previous failed run, and ensure cleanup // after the test regardless of pass/fail. diff --git a/e2e/studio/features/table-editor.spec.ts b/e2e/studio/features/table-editor.spec.ts index 47eb18424bb..a11adcf6c1c 100644 --- a/e2e/studio/features/table-editor.spec.ts +++ b/e2e/studio/features/table-editor.spec.ts @@ -502,7 +502,13 @@ testRunner('table editor', () => { await expect(page.locator('.view-lines')).toContainText(`security_invoker = true`) const openInSqlEditorLink = page.getByRole('link', { name: 'Open in SQL Editor' }) - await expect(openInSqlEditorLink).toHaveAttribute('href', /security_invoker%20%3D%20true/) + // Accept either percent-encoded (`%20`) or form-encoded (`+`) spaces — + // some routers serialize search params via URLSearchParams which emits + // `+` for spaces. Both decode to the same SQL. + await expect(openInSqlEditorLink).toHaveAttribute( + 'href', + /security_invoker(%20|\+)%3D(%20|\+)true/ + ) await openInSqlEditorLink.click() await page.waitForURL(/\/sql\/new/) await expect(page.locator('.view-lines')).toContainText(`create view public.${viewName}`) @@ -1142,7 +1148,7 @@ testRunner('table editor', () => { const saveTablePromise = waitForApiResponseWithTimeout( page, (response) => response.url().includes('query?key=table-update'), - 15000 + 30000 ) await page.getByRole('button', { name: 'Save' }).first().click() await saveTablePromise @@ -1170,7 +1176,7 @@ testRunner('table editor', () => { const removeFkPromise = waitForApiResponseWithTimeout( page, (response) => response.url().includes('query?key=table-update'), - 15000 + 30000 ) await page.getByRole('button', { name: 'Save' }).first().click() await removeFkPromise @@ -1263,9 +1269,15 @@ testRunner('table editor', () => { await expect(foreignKeySchemaSelect).not.toBeVisible() await expect(foreignKeyLink).toBeVisible() - const updateColumnPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=column-update', { - method: 'POST', - }) + const updateColumnPromise = waitForApiResponse( + page, + 'pg-meta', + ref, + 'query?key=column-update', + { + method: 'POST', + } + ) await columnEditor.getByRole('button', { name: 'Save' }).click() await updateColumnPromise await expect(columnEditor).not.toBeVisible() diff --git a/e2e/studio/utils/storage-helpers.ts b/e2e/studio/utils/storage-helpers.ts index e4065654f0b..97d59ce4f18 100644 --- a/e2e/studio/utils/storage-helpers.ts +++ b/e2e/studio/utils/storage-helpers.ts @@ -98,16 +98,13 @@ export const deleteBucket = async (page: Page, ref: string, bucketName: string) await page.getByRole('button', { name: 'Delete bucket' }).click() await apiPromise - // Verify bucket was deleted - should redirect to files page - await expect(page, 'Should redirect to storage files page after deletion').toHaveURL( - new RegExp(`/storage/files$`) - ) - - // Verify bucket is no longer in the list + // Verify bucket is no longer in the list. The post-delete redirect to + // /storage/files can race other history updates, so asserting the row + // is gone is a more stable signal that the delete actually succeeded. await expect( page.getByRole('row').filter({ hasText: bucketName }), `Bucket ${bucketName} should not be visible after deletion` - ).not.toBeVisible() + ).not.toBeVisible({ timeout: 10000 }) } /** From 0bed80b34044d5cd90741a6ed9b1c7fc89484ab8 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Mon, 18 May 2026 18:21:58 +0800 Subject: [PATCH 05/20] chore(telemetry): clean up frontend event catalog (#45964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Resolves 13 findings (2 HIGH, 5 MEDIUM, 6 LOW) from the frontend telemetry audit: 1 action-string collision, 1 camelCase experiment name, 9 dead events removed, 4 missing org groups attached, 1 ambiguous property renamed, 1 raw-string property narrowed, plus consolidations and a structural tightening on TABLE_EVENT_ACTIONS. ## Changes ### HIGH - Rename `EventPageCtaClickedEvent.action` to `www_event_page_cta_clicked` so it no longer collides with the pricing CTA event (which had a different schema sharing the same action string) - Snake_case the header-upgrade experiment exposure name (`headerUpgradeCta_experiment_exposed` → `header_upgrade_cta_experiment_exposed`); PostHog flag key and `?source=` URL param unchanged ### MEDIUM - Remove 4 dead `ProjectCreation*Step*` events (referenced a v2 route that doesn't exist; 0 emissions) - Remove 4 dead experiment exposure events: `ProjectCreationRlsOptionExperimentExposed`, `HomeNewExperimentExposed`, `TableCreateGeneratePoliciesExperimentExposed`, `TableCreateGeneratePoliciesExperimentConverted` (0 emissions) - Attach org group to `dpa_request_button_clicked` (0% had `$group_0` per Hex) - Delete `RegisterStateOfStartups2025NewsletterClicked` (interface naming outlier, 0 emissions, page renamed to 2026) - Rename `AssistantSuggestionRunQueryClickedEvent.category` to `mutationType` with tightened literal union (`'functions' | 'rls-policies' | 'unknown'`) - Attach org group to `project_creation_default_privileges_exposed` on Vercel surface via explicit `groupOverrides` (auto-injection misses because `useSelectedOrganizationQuery` is undefined on that page) ### LOW - Consolidate `IndexAdvisorBannerEnableButtonClickedEvent` + `IndexAdvisorDialogEnableButtonClickedEvent` into one event with `origin: 'banner' | 'dialog'` - Rename `ImportDataFileDroppedEvent` → `ImportDataFileAddedEvent` so the interface name matches the action and the verb is on the approved list - Rename `LogDrainConfirmButtonSubmittedEvent` → `LogDrainRemovedEvent` and action to `log_drain_removed` (fires on delete-confirm modal, matches `CronJobRemovedEvent` pattern) - Add `type` property to `CronJobRemovedEvent` (parsed from the job's command), matching the create/update event shape - Tighten `TABLE_EVENT_ACTIONS` values with `satisfies` against the event union so renames in the union fail typecheck here too - Attach org group to `www_pricing_plan_cta_clicked` at 5 emission sites when an org is available in the page context - Narrow `unified_logs_row_clicked.logType` from raw `string` to the 5-literal `LOG_TYPES` union (zod already validates server values) ### Bundled refactor Migrated 5 emission sites from deprecated `useSendEventMutation` to `useTrack` while their containing files were being edited: `DPA.tsx`, `DisplayBlockRenderer.tsx`, `Grid.tsx` (2 events), `DeleteCronJob.tsx`. Full sweep of the remaining ~79 files is a separate follow-up. ## Testing Mostly just renaming of events ## Linear - fixes GROWTH-798 ## Summary by CodeRabbit * **Refactor** * Standardized telemetry to a unified tracking system for more consistent analytics. * Simplified experiment exposure reporting for upgrade prompts. * **New Features** * More granular tracking for CSV import, cron job deletions, log drain removals, DPA downloads/requests, and pricing CTAs. * Assistant now classifies mutation queries more precisely. * **Bug Fixes** * Improved default-privileges exposure logic on Vercel deployments (skips when org missing). [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45964) --- .../components/grid/components/grid/Grid.tsx | 25 +- .../Integrations/CronJobs/DeleteCronJob.tsx | 15 +- .../interfaces/LogDrains/LogDrains.tsx | 2 +- .../interfaces/Organization/Documents/DPA.tsx | 15 +- .../IndexAdvisor/EnableIndexAdvisorButton.tsx | 4 +- .../LayoutHeader/HeaderUpgradeButton.tsx | 12 +- .../ui/AIAssistantPanel/AIAssistant.utils.ts | 5 +- .../AIAssistantPanel/DisplayBlockRenderer.tsx | 21 +- .../useDataApiRevokeOnCreateDefault.test.ts | 51 ++-- .../misc/useDataApiRevokeOnCreateDefault.ts | 20 +- apps/studio/hooks/ui/useCsvFileDrop.ts | 4 +- .../[slug]/deploy-button/new-project.tsx | 6 +- .../Pricing/PricingComparisonTable.tsx | 6 + apps/www/components/Pricing/PricingPlans.tsx | 2 + apps/www/pages/events/[slug].tsx | 2 +- packages/common/telemetry-constants.ts | 261 +++--------------- 16 files changed, 139 insertions(+), 312 deletions(-) diff --git a/apps/studio/components/grid/components/grid/Grid.tsx b/apps/studio/components/grid/components/grid/Grid.tsx index f6df2b27017..50f38de5c2a 100644 --- a/apps/studio/components/grid/components/grid/Grid.tsx +++ b/apps/studio/components/grid/components/grid/Grid.tsx @@ -25,10 +25,9 @@ import { formatForeignKeys } from '@/components/interfaces/TableGridEditor/SideP import { useForeignKeyConstraintsQuery } from '@/data/database/foreign-key-constraints-query' import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' import { isTableLike } from '@/data/table-editor/table-editor-types' -import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' -import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useCsvFileDrop } from '@/hooks/ui/useCsvFileDrop' +import { useTrack } from '@/lib/telemetry/track' import { useTableEditorStateSnapshot } from '@/state/table-editor' import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table' import { ResponseError } from '@/types' @@ -69,7 +68,6 @@ export const Grid = memo( const snap = useTableEditorTableStateSnapshot() const { filters, clearFilters } = useTableFilter() - const { data: org } = useSelectedOrganizationQuery() const { data: project } = useSelectedProjectQuery() const onRowsChange = useOnRowsChange(rows) @@ -92,20 +90,12 @@ export const Grid = memo( const isForeignTable = tableEntityType === ENTITY_TYPE.FOREIGN_TABLE const isTableEmpty = (rows ?? []).length === 0 - const { mutate: sendEvent } = useSendEventMutation() + const track = useTrack() const { isDraggedOver, onDragOver, onFileDrop } = useCsvFileDrop({ enabled: isTableEmpty && !isForeignTable, onFileDropped: (file) => tableEditorSnap.onImportData(valtioRef(file)), - onTelemetryEvent: (eventName) => { - sendEvent({ - action: eventName, - groups: { - project: project?.ref ?? 'Unknown', - organization: org?.slug ?? 'Unknown', - }, - }) - }, + onTelemetryEvent: (eventName) => track(eventName), }) const { data } = useForeignKeyConstraintsQuery({ @@ -339,14 +329,7 @@ export const Grid = memo( className="pointer-events-auto" onClick={() => { tableEditorSnap.onImportData() - sendEvent({ - action: 'import_data_button_clicked', - properties: { tableType: 'Existing Table' }, - groups: { - project: project?.ref ?? 'Unknown', - organization: org?.slug ?? 'Unknown', - }, - }) + track('import_data_button_clicked', { tableType: 'Existing Table' }) }} > Import data from CSV diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx index c59bfbef728..ae9a700053f 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx @@ -3,17 +3,16 @@ import { useEffect } from 'react' import { toast } from 'sonner' import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' +import { parseCronJobCommand } from './CronJobs.utils' import { useCronJobsData } from './CronJobsTab.useCronJobsData' import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper' import { useDatabaseCronJobDeleteMutation } from '@/data/database-cron-jobs/database-cron-jobs-delete-mutation' -import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' -import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { cleanPointerEventsNoneOnBody } from '@/lib/helpers' +import { useTrack } from '@/lib/telemetry/track' export const DeleteCronJob = () => { const { data: project } = useSelectedProjectQuery() - const { data: org } = useSelectedOrganizationQuery() const [searchQuery] = useQueryState('search', parseAsString.withDefault('')) const [cronJobIdForDeletion, setCronJobForDeletion] = useQueryState('delete', parseAsString) @@ -25,17 +24,17 @@ export const DeleteCronJob = () => { }) const cronJob = grid.rows.find((j) => j.jobid.toString() === cronJobIdForDeletion) - const { mutate: sendEvent } = useSendEventMutation() + const track = useTrack() const { mutate: deleteDatabaseCronJob, isPending, isSuccess: isSuccessDelete, } = useDatabaseCronJobDeleteMutation({ onSuccess: () => { - sendEvent({ - action: 'cron_job_removed', - groups: { project: project?.ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, - }) + if (cronJob && project) { + const { type } = parseCronJobCommand(cronJob.command, project.ref) + track('cron_job_removed', { type }) + } toast.success(`Successfully removed cron job`) setCronJobForDeletion(null) }, diff --git a/apps/studio/components/interfaces/LogDrains/LogDrains.tsx b/apps/studio/components/interfaces/LogDrains/LogDrains.tsx index af68874c678..0c773fc5b50 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrains.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrains.tsx @@ -210,7 +210,7 @@ export function LogDrains({ onConfirm={() => { if (selectedLogDrain && ref) { deleteLogDrain({ token: selectedLogDrain.token, projectRef: ref }) - track('log_drain_confirm_button_submitted', { + track('log_drain_removed', { destination: selectedLogDrain.type, }) } diff --git a/apps/studio/components/interfaces/Organization/Documents/DPA.tsx b/apps/studio/components/interfaces/Organization/Documents/DPA.tsx index a00b50aaab5..87a2b5b7ef6 100644 --- a/apps/studio/components/interfaces/Organization/Documents/DPA.tsx +++ b/apps/studio/components/interfaces/Organization/Documents/DPA.tsx @@ -10,9 +10,9 @@ import { import { InlineLink } from '@/components/ui/InlineLink' import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper' import { useDpaRequestMutation } from '@/data/documents/dpa-request-mutation' -import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useProfile } from '@/lib/profile' +import { useTrack } from '@/lib/telemetry/track' export const DPA = () => { const { profile } = useProfile() @@ -21,7 +21,7 @@ export const DPA = () => { const [isOpen, setIsOpen] = useState(false) - const { mutate: sendEvent } = useSendEventMutation() + const track = useTrack() const { mutate: requestDpa, isPending: isRequesting } = useDpaRequestMutation({ onSuccess: () => { toast.success('DPA request sent successfully') @@ -49,12 +49,7 @@ export const DPA = () => { You can review a static PDF version of our latest DPA document{' '} - sendEvent({ - action: 'dpa_pdf_opened', - properties: { source: 'studio' }, - }) - } + onClick={() => track('dpa_pdf_opened', { source: 'studio' })} > here @@ -67,9 +62,7 @@ export const DPA = () => { ) } From 9d460042100df4e23ecc6f56f25b2a2620cd7a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pozo?= Date: Mon, 18 May 2026 08:43:23 -0500 Subject: [PATCH 11/20] docs: rewrite Securing Edge Functions guide around @supabase/server (#45959) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Docs update. ## What is the current behavior? The "Securing Edge Functions" guide (`/docs/guides/functions/auth`) opens with two conceptual sections — "Understanding authorization headers" and "The `verify_jwt` platform check" — followed by a "Common auth patterns" section that re-implements the same four use cases twice: once without an SDK using `Deno.serve` + manual `createClient` + manual `Authorization` header forwarding, and again using `@supabase/server`. The recommended path is buried below background reading and a legacy-style implementation. Linear: COM-235. ## What is the new behavior? The guide now leads with practical how-tos built on `@supabase/server`: - Authenticated user calls (`auth: 'user'`) - Service-to-service calls (`auth: 'secret'`) - Public functions (`auth: 'none'`) - External webhooks (`auth: 'none'` + signature verification) - Combining modes - Custom error responses - Environment variables The two conceptual sections are extracted into a new sibling page at `/docs/guides/functions/auth-headers` ("Authorization headers"), linked from the top of the how-to page and added to the side nav between "Securing your functions" and "Legacy JWT secret". The legacy SDK-less examples are removed. The mode table uses the unnamed forms (`'secret'`, `'publishable'`), and a note in the service-to-service section introduces the `'secret:'` / `'publishable:'` syntax for callers that want to scope to a specific named key. ## Additional context Each section preserves the "who calls this and why" framing from the original (cron jobs, workers, and `pg_net` for service-to-service; `supabase.functions.invoke` for authenticated user calls; signed webhook providers for external webhooks). ## Summary by CodeRabbit * **Documentation** * Added a new guide explaining Edge Functions authentication headers, JWT validation, and API key handling * Redesigned core authentication guide to focus on the primary wrapper approach with clearer examples and common scenarios * Improved navigation and added redirects to make authentication docs easier to find and access [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45959?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --------- Co-authored-by: Chris Chinchilla --- .../NavigationMenu.constants.ts | 1 + .../content/guides/functions/auth-headers.mdx | 43 +++ apps/docs/content/guides/functions/auth.mdx | 255 +++++------------- apps/www/lib/redirects.js | 10 + 4 files changed, 116 insertions(+), 193 deletions(-) create mode 100644 apps/docs/content/guides/functions/auth-headers.mdx diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index c9735fd3375..f87815936c9 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1695,6 +1695,7 @@ export const functions: NavMenuConstant = { url: '/guides/functions/auth', items: [ { name: 'Securing your functions', url: '/guides/functions/auth' }, + { name: 'Authorization headers', url: '/guides/functions/auth-headers' }, { name: 'Legacy JWT secret', url: '/guides/functions/auth-legacy-jwt' }, ], }, diff --git a/apps/docs/content/guides/functions/auth-headers.mdx b/apps/docs/content/guides/functions/auth-headers.mdx new file mode 100644 index 00000000000..07efe0105f0 --- /dev/null +++ b/apps/docs/content/guides/functions/auth-headers.mdx @@ -0,0 +1,43 @@ +--- +id: 'auth-headers' +title: 'Authorization headers' +description: 'How the Authorization and apikey request headers and the verify_jwt platform check work for Edge Functions.' +subtitle: 'How the Authorization and apikey headers and the verify_jwt platform check work' +--- + +Every request to an Edge Function passes through two layers of auth. First, a platform-level check (`verify_jwt`) runs before your code executes. Then, once the request reaches your handler, you decide what to do with the credentials the caller sent. This page is the reference for both layers. For the practical patterns built on top of them, see [Securing Edge Functions](/guides/functions/auth). + +## Understanding authorization headers + +Edge Functions care about two request headers. Sending the wrong credential in the wrong header is the most common source of 401 errors. + +| Header | Value | Used for | +| --------------- | --------------------------------------- | -------------------------------------- | +| `Authorization` | `Bearer ` | A user signed in through Supabase Auth | +| `apikey` | `sb_publishable_...` or `sb_secret_...` | Calls from clients or services | + +A common mistake is sending a publishable or secret key as a bearer token: `Authorization: Bearer sb_publishable_...`. The new API keys are not JWTs. The platform check can't validate them, and your handler can't verify them as JWTs either. Instead, put API keys in the `apikey` header. + +You can send both headers together. A signed-in user calling your function through `supabase-js`, for example, sends their session JWT in `Authorization` and the project's publishable key in `apikey`. + +## The `verify_jwt` platform check + +When `verify_jwt` is enabled (the default), the platform inspects the `Authorization` header of every request before your function runs. It expects a valid user JWT. If the header is missing, malformed, or signed with a different key, the platform returns a 401 error, and your code never executes. + +The check validates legacy HS256 JWTs and JWTs signed with the new asymmetric [signing keys](/docs/guides/auth/signing-keys). + +The check does not accept an API key. Publishable and secret keys are not JWTs, so callers that send one in the `Authorization` header fail the check before their request reaches your handler. + +Use the `verify_jwt` flag to match how the function is called: + +- **Leave `verify_jwt` on** for functions that are only called with a user JWT, such as functions invoked from the client through `supabase.functions.invoke`. The platform rejects unauthenticated requests before they reach your code, and your handler can trust that a valid JWT is present. +- **Turn `verify_jwt` off** for functions that are called without an `Authorization` header, such as webhooks from external providers, or service-to-service calls that authenticate with an API key. These patterns are covered in [Securing Edge Functions](/guides/functions/auth). + +Set the flag per function in `supabase/config.toml`: + +```toml +[functions.stripe-webhook] +verify_jwt = false +``` + +For 401 failure modes and how to diagnose them, see [Edge Function 401 error response](/docs/troubleshooting/edge-function-401-error-response). diff --git a/apps/docs/content/guides/functions/auth.mdx b/apps/docs/content/guides/functions/auth.mdx index f6897e83ea6..071707aeaf1 100644 --- a/apps/docs/content/guides/functions/auth.mdx +++ b/apps/docs/content/guides/functions/auth.mdx @@ -5,218 +5,87 @@ description: 'Authentication patterns for Supabase Edge Functions.' subtitle: 'Authentication patterns for Edge Functions' --- - +The `withSupabase` wrapper from [`@supabase/server`](https://github.com/supabase/server) verifies the caller's credentials against a declared `auth` mode and hands you a pre-configured Supabase client on `ctx`. The sections below show how to use it for each common auth scenario. -The patterns in this guide assume your project uses the new [JWT signing keys](/docs/guides/getting-started/api-keys) and the new [API keys](https://github.com/orgs/supabase/discussions/29260). If you're still on legacy JWTs, see the [Legacy JWT Secret guide](/docs/guides/functions/auth-legacy-jwt). +For how authorization headers and the `verify_jwt` platform check work under the hood, see [Authorization headers](/docs/guides/functions/auth-headers). + +| Mode | Accepts | +| --------------- | ------------------------------------------ | +| `'user'` | A valid user JWT on `Authorization` | +| `'secret'` | A secret key on `apikey` | +| `'publishable'` | A publishable key on `apikey` | +| `'none'` | Any caller, no check (for signed webhooks) | + +## Authenticated user calls + +Functions called by signed-in users — typically through `supabase.functions.invoke` from the client — send the user's session JWT on the `Authorization` header. Keep `verify_jwt = true` (the default) so the platform validates the JWT before your handler runs, then use `auth: 'user'` to get `ctx.supabase` already scoped to the caller's RLS policies. + +```ts +import { withSupabase } from 'npm:@supabase/server' + +export default { + fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => { + const { supabase, supabaseAdmin, userClaims, jwtClaims, authMode } = ctx + // supabase — RLS-scoped to the authenticated user + // supabaseAdmin — bypasses RLS (service role) + // userClaims — user identity from JWT (id, email, role) + // jwtClaims — full JWT claims + // authMode — which auth mode matched + + // your business logic goes here + return Response.json({ email: ctx.userClaims?.email }) + }), +} +``` + +## Service-to-service calls + +Cron jobs, workers, `pg_net`, or another Edge Function make calls with a secret key on the `apikey` header rather than a user JWT. Disable `verify_jwt` and use `auth: 'secret'` to validate the key against any secret key from your [dashboard](/dashboard/project/_/settings/api-keys). You get `ctx.supabaseAdmin` for privileged work. + +```ts +import { withSupabase } from 'npm:@supabase/server' + +export default { + fetch: withSupabase({ auth: 'secret' }, async (_req, ctx) => { + // your business logic. ctx.supabaseAdmin bypasses RLS + return Response.json({ ok: true }) + }), +} +``` + + + +To accept only one specific key, use `auth: 'secret:'`. For example, `auth: 'secret:automations'` only accepts the secret key you named "automations" in the [**Settings > API keys**](/dashboard/project/_/settings/api-keys) section of the Dashboard. The same syntax works for publishable keys (`auth: 'publishable:'`). + +![A secret key named "automations" listed under Secret keys in the Supabase dashboard.](/docs/img/guides/functions/secret-keys-automations.png) -Every request to an Edge Function passes through two layers of auth. First, a platform-level check (`verify_jwt`) runs before your code executes. Then, once the request reaches your handler, you decide what to do with the credentials the caller sent. +## Public functions -## Understanding authorization headers - -Edge Functions care about two request headers. Sending the wrong credential in the wrong header is the most common source of 401 errors. - -| Header | Value | Used for | -| --------------- | --------------------------------------- | -------------------------------------- | -| `Authorization` | `Bearer ` | A user signed in through Supabase Auth | -| `apikey` | `sb_publishable_...` or `sb_secret_...` | Calls from clients or services | - -A common mistake is sending a publishable or secret key as a bearer token: `Authorization: Bearer sb_publishable_...`. The new API keys are not JWTs. The platform check can't validate them, and your handler can't verify them as JWTs either. Instead, put API keys in the `apikey` header. - -You can send both headers together. A signed-in user calling your function through `supabase-js`, for example, sends their session JWT in `Authorization` and the project's publishable key in `apikey`. - -## The `verify_jwt` platform check - -When `verify_jwt` is enabled (the default), the platform inspects the `Authorization` header of every request before your function runs. It expects a valid user JWT. If the header is missing, malformed, or signed with a different key, the platform returns a 401 error, and your code never executes. - -The check validates legacy HS256 JWTs and JWTs signed with the new asymmetric [signing keys](/docs/guides/auth/signing-keys). - -The check does not accept an API key. Publishable and secret keys are not JWTs, so callers that send one in the `Authorization` header fail the check before their request reaches your handler. - -Use the `verify_jwt` flag to match how the function is called: - -- **Leave `verify_jwt` on** for functions that are only called with a user JWT, such as functions invoked from the client through `supabase.functions.invoke`. The platform rejects unauthenticated requests before they reach your code, and your handler can trust that a valid JWT is present. -- **Turn `verify_jwt` off** for functions that are called without an `Authorization` header, such as webhooks from external providers, or service-to-service calls that authenticate with an API key. These patterns are covered later in the guide. - -Set the flag per function in `supabase/config.toml`: - -```toml -[functions.stripe-webhook] -verify_jwt = false -``` - -For 401 failure modes and how to diagnose them, see [Edge Function 401 error response](/docs/troubleshooting/edge-function-401-error-response). - -## Common auth patterns - -The sections below show the four patterns you'll reach for most often, written without an SDK so the auth moves are visible. Business logic is left as a placeholder. The next section shows the same four patterns using [`@supabase/server`](https://github.com/supabase/server). - -### Authenticated user calls - -Keep `verify_jwt` enabled. The platform validates the JWT before your handler runs. Forward the `Authorization` header to the Supabase client so queries run under the caller's RLS policies. - -```toml -[functions.notes] -verify_jwt = true -``` - -```ts -import { createClient } from 'npm:@supabase/supabase-js@2' - -const SUPABASE_PUBLISHABLE_KEYS = JSON.parse(Deno.env.get('SUPABASE_PUBLISHABLE_KEYS')!) - -Deno.serve((req) => { - const supabase = createClient( - Deno.env.get('SUPABASE_URL')!, - SUPABASE_PUBLISHABLE_KEYS['default'], - { global: { headers: { Authorization: req.headers.get('Authorization')! } } } - ) - - // your business logic. queries run as the caller - return Response.json({ ok: true }) -}) -``` - -### Service-to-service calls - -Cron jobs, workers, `pg_net`, or another Edge Functions make calls with a secret key on the `apikey` header. These callers don't send a user JWT, so disable `verify_jwt` and validate the key yourself. - -```toml -[functions.run-automations] -verify_jwt = false -``` - -```ts -import { createClient } from 'npm:@supabase/supabase-js@2' - -const SUPABASE_SECRET_KEYS = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!) - -Deno.serve((req) => { - if (req.headers.get('apikey') !== Deno.env.get('INTERNAL_AUTOMATIONS_KEY')) { - return Response.json({ error: 'forbidden' }, { status: 401 }) - } - - const supabase = createClient(Deno.env.get('SUPABASE_URL')!, SUPABASE_SECRET_KEYS['default']) - - // your business logic. queries run with the service role - return Response.json({ ok: true }) -}) -``` - - - -Never expose a secret key to the browser. Store it as a [function secret](/docs/guides/functions/secrets). - - - -### Public functions - -For a genuinely public function, like a health check, no credential is required. Disable `verify_jwt` so anonymous callers can reach the handler. +For a genuinely public function, like a health check, use `auth: 'none'` with `verify_jwt = false` so anonymous callers can reach the handler. ```toml [functions.health] verify_jwt = false ``` -```ts -Deno.serve(() => { - // your business logic - return Response.json({ ok: true }) -}) -``` - -### External webhooks - -External providers like Stripe or GitHub don't send Supabase credentials. They sign the request body with their own shared secret. Disable `verify_jwt` and verify the signature before acting on the payload. - -```toml -[functions.stripe-webhook] -verify_jwt = false -``` - -```ts -import Stripe from 'npm:stripe' - -const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!) - -Deno.serve(async (req) => { - const signature = req.headers.get('stripe-signature') ?? '' - const body = await req.text() - - try { - stripe.webhooks.constructEvent(body, signature, Deno.env.get('STRIPE_WEBHOOK_SECRET')!) - } catch { - return new Response('bad signature', { status: 400 }) - } - - // your business logic. handle the event - return Response.json({ received: true }) -}) -``` - -## Simplifying with `@supabase/server` - -The [`@supabase/server`](https://github.com/supabase/server) package wraps your handler, checks the caller's credentials against a declared `auth` mode, and hands you a pre-configured Supabase client on `ctx`. The same patterns above, written against the SDK, look like this. - -| Mode | Accepts | -| ---------------------- | ------------------------------------------ | -| `'user'` | A valid user JWT on `Authorization` | -| `'secret:'` | A named secret key on `apikey` | -| `'publishable:'` | A named publishable key on `apikey` | -| `'none'` | Any caller, no check (for signed webhooks) | - - - -See the [`@supabase/server` docs](https://github.com/supabase/server) for the full list of modes. - - - -### Authenticated user calls [#authenticated-user-calls-with-server-sdk] - -`auth: 'user'` pairs with `verify_jwt = true`. The platform validates the JWT, and the SDK hands you `ctx.supabase` already scoped to the caller. - ```ts import { withSupabase } from 'npm:@supabase/server' export default { - fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => { - // your business logic. ctx.supabase is scoped to the caller - return Response.json({ email: ctx.userClaims?.email }) - }), -} -``` - -### Service-to-service calls [#service-to-service-calls-with-server-sdk] - -`auth: 'secret:'` validates the `apikey` header against the named secret key from your [dashboard](/dashboard/project/_/settings/api-keys) and gives you `ctx.supabaseAdmin` for privileged work. The `` matches the name you gave the key. Keep `verify_jwt = false`. - -```ts -import { withSupabase } from 'npm:@supabase/server' - -export default { - fetch: withSupabase({ auth: 'secret:automations' }, async (_req, ctx) => { - // your business logic. ctx.supabaseAdmin bypasses RLS + fetch: withSupabase({ auth: 'none' }, async () => { + // your business logic return Response.json({ ok: true }) }), } ``` - +`auth: 'none'` skips every credential check — see the caution under [External webhooks](#external-webhooks) before using it on anything that reads or writes sensitive data. -Create a named secret key for each caller in the [**Settings > API keys**](/dashboard/project/_/settings/api-keys) section of the Dashboard. Give it a name like "automations", and share the generated `sb_secret_...` value with the service that calls this function. +## External webhooks -![A secret key named "automations" listed under Secret keys in the Supabase dashboard.](/docs/img/guides/functions/secret-keys-automations.png) - - - -### Public functions [#public-functions-with-server-sdk] - -The SDK adds nothing to a truly public function. Use the raw pattern from the previous section. If you need a Supabase client anyway, `auth: 'none'` with `verify_jwt = false` skips every check and treats every caller as anonymous. - -### External webhooks [#external-webhooks-with-server-sdk] - -Use `auth: 'none'` to skip the SDK's credential check, then verify the provider's signature inside the handler. Keep `verify_jwt = false`. +External providers like Stripe or GitHub don't send Supabase credentials. They sign the request body with their own shared secret. Use `auth: 'none'` to skip the SDK's credential check, then verify the provider's signature inside the handler. Keep `verify_jwt = false`. ```ts import { withSupabase } from 'npm:@supabase/server' @@ -247,7 +116,7 @@ export default { -### Combining modes +## Combining modes Functions that answer both users and internal callers take an array on `auth`. Modes are tried in order. The first match wins, and `ctx.authMode` tells you which matched. @@ -255,7 +124,7 @@ Functions that answer both users and internal callers take an array on `auth`. M import { withSupabase } from 'npm:@supabase/server' export default { - fetch: withSupabase({ auth: ['user', 'secret:automations'] }, async (req, ctx) => { + fetch: withSupabase({ auth: ['user', 'secret'] }, async (req, ctx) => { if (ctx.authMode === 'user') { // your business logic for user calls. ctx.supabase is scoped to them return Response.json({ ok: true }) @@ -267,7 +136,7 @@ export default { } ``` -### Custom error responses +## Custom error responses To shape the 401 response yourself, use `createSupabaseContext` instead of `withSupabase`. It returns a `{ data, error }` tuple so you stay in control. diff --git a/apps/www/lib/redirects.js b/apps/www/lib/redirects.js index e1082899b0a..e821024b7c7 100644 --- a/apps/www/lib/redirects.js +++ b/apps/www/lib/redirects.js @@ -1577,6 +1577,16 @@ module.exports = [ source: '/docs/guides/storage-cdn', destination: '/docs/guides/storage/cdn', }, + { + permanent: true, + source: '/docs/guides/functions/auth#understanding-authorization-headers', + destination: '/docs/guides/functions/auth-headers', + }, + { + permanent: true, + source: '/docs/guides/functions/auth#the-verify_jwt-platform-check', + destination: '/docs/guides/functions/auth-headers', + }, { permanent: true, source: '/docs/guides/functions/examples', From f8f948a2a46dfe3a2100eafb25dacad5adc65591 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Mon, 18 May 2026 19:47:20 +0530 Subject: [PATCH 12/20] fix: policy urls (#46064) ## TL;DR fixes `Table Editor -> View policies` for schema names with special characters tested with schema `sales&ops` : ## ex: | b4 url: `schema=sales&ops` | after url: `schema=sales%26ops` | | --- | --- | | image | image | ## ref - closes #46061 ## Summary by CodeRabbit * **Bug Fixes** * Improved URL handling for the "View policies" link to ensure proper encoding and reliability. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46064?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --- .../components/layouts/TableEditorLayout/EntityListItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index 699b144679e..8308876173c 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -318,7 +318,7 @@ export const EntityListItem = ({ View policies From af031e2abf02ed47293f713180d4fcf3bd903dce Mon Sep 17 00:00:00 2001 From: "Andrey A." <56412611+aantti@users.noreply.github.com> Date: Mon, 18 May 2026 16:30:42 +0200 Subject: [PATCH 13/20] fix(self-hosted): add depends_on analytics for vector (#46038) --- docker/docker-compose.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index eead4519347..8238da37665 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -586,6 +586,9 @@ services: timeout: 5s interval: 5s retries: 3 + depends_on: + analytics: + condition: service_healthy environment: LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN} command: From 31ca536bcb0f5ff8d13f64650af1da3e75e10d73 Mon Sep 17 00:00:00 2001 From: "Andrey A." <56412611+aantti@users.noreply.github.com> Date: Mon, 18 May 2026 16:31:26 +0200 Subject: [PATCH 14/20] fix(self-hosted): add jwt issuer env var to auth config (#46020) --- docker/docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 8238da37665..660a32e550c 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -165,6 +165,8 @@ services: # For Podman, use: GOTRUE_JWT_KEYS: ${JWT_KEYS} #GOTRUE_JWT_KEYS: ${JWT_KEYS:-[]} + GOTRUE_JWT_ISSUER: ${API_EXTERNAL_URL}/auth/v1 + GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP} GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS} GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM} From 50e158bddb4b7257e7d385b2745a70a67770b72e Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Mon, 18 May 2026 16:34:16 +0200 Subject: [PATCH 15/20] fix: improve loading state when changing log filters in unified logs (#46070) ## Problem When changing filters, while the query is running the loading state is not clear, its only shown in the "Load more" button at the bottom which can be missed if you got a lot of logs, we should improve the filters loading state. That's actually due to two things: - we rely on the `isLoading` state instead of `isFetching` - we debounce filters changes by a second ## Solution - use `isFetching` to determine whether to display the loading spinner in the top filter bar - reduce the debounce delay to `250ms` as users can't filter by typing anyway (what they type must be validated with Enter to select a filter, no fuzzy filtering) ## Summary by CodeRabbit * **Performance** * Optimized unified logs filter response time, applying filter changes more quickly. * **Bug Fixes** * Enhanced loading indicator behavior in data tables to accurately reflect active data fetching operations, providing clearer visual feedback during interactions. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46070?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --- apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx | 2 +- .../ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx index 29625706abb..23ee69fa8b5 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx @@ -307,7 +307,7 @@ export const UnifiedLogs = () => { setSearch(search) } - const debouncedApplyFilterSearch = useDebounce(applyFilterSearch, 1000) + const debouncedApplyFilterSearch = useDebounce(applyFilterSearch, 250) useEffect(() => { debouncedApplyFilterSearch() diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx index dfd029010c0..75c56eb5c76 100644 --- a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx @@ -39,7 +39,7 @@ export function DataTableFilterCommand({ searchParamsParser, placeholder = 'Search data table...', }: DataTableFilterCommandProps) { - const { table, isLoading, filterFields: _filterFields, getFacetedUniqueValues } = useDataTable() + const { table, isFetching, filterFields: _filterFields, getFacetedUniqueValues } = useDataTable() const columnFilters = table.getState().columnFilters const inputRef = useRef(null) const [open, setOpen] = useState(false) @@ -120,7 +120,7 @@ export function DataTableFilterCommand({ )} onClick={() => setOpen(true)} > - {isLoading ? ( + {isFetching ? ( ) : ( From 212ccf813563a5cbe717ace2b61452fb017a968b Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Mon, 18 May 2026 12:11:11 -0400 Subject: [PATCH 16/20] fix(ai): contextualize cron schedule as SQL writes, score in "Tool Usage" (#45997) When Assistant tries to schedule crons in read-only mode, it succeeds but creates the jobs under the `supabase_read_only_user`. This causes permission errors when user try to delete or unschedule them from the Cron dashboard. The root fix will be to enforce read-only transactions for that user. In the meantime, this PR steers Assistant to avoid the mistake. **Changes** - Prompts `execute_sql` to treat side-effecting function calls such as `cron.schedule()` as write queries. - Adds tool input assertions for "Tool Usage" scorer and a focused cron regression eval. - Updates eval mocks to show pg_cron extension as installed so it can call `cron.schedule()` **Verification** See [this trace](https://www.braintrust.dev/app/supabase.io/p/Assistant/trace?object_type=experiment&object_id=4a9e8c0e-83b7-4555-8502-365662c3ec8e&r=e041e69b-b70f-41d1-b88c-e8f7888c3de5&s=e041e69b-b70f-41d1-b88c-e8f7888c3de5) from Braintrust where the new eval passes "Tool Usage", correctly using `isWriteQuery` for the `cron.schedule()` Closes AI-737 ## Summary by CodeRabbit * **New Features** * Tool evaluation now validates tool inputs (including exact and substring matches) in addition to tool presence. * **Tests** * Added a test confirming cron-scheduling behavior and that SQL scheduling/enqueue calls are treated as write operations. * **Chores** * Added pg_cron to mock extension data. * Clarified description that SQL calls with side effects should be treated as writes. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45997?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --- apps/studio/evals/dataset.ts | 21 +++++++++++++ apps/studio/evals/scorer.ts | 39 ++++++++++++++++++++++-- apps/studio/lib/ai/tools/mock-tools.ts | 1 + apps/studio/lib/ai/tools/studio-tools.ts | 2 +- 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/apps/studio/evals/dataset.ts b/apps/studio/evals/dataset.ts index 57b3671172f..ebd7ed66503 100644 --- a/apps/studio/evals/dataset.ts +++ b/apps/studio/evals/dataset.ts @@ -189,6 +189,27 @@ export const dataset: AssistantEvalCase[] = [ }, metadata: { category: ['general_help'] }, }, + { + input: { + prompt: 'Create a cron job named assistant-cron that runs every minute with select 1', + }, + expected: { + requiredTools: [ + { + name: 'execute_sql', + input: { + sql: { stringIncludes: 'cron.schedule' }, + isWriteQuery: { equals: true }, + }, + }, + ], + }, + metadata: { + category: ['sql_generation'], + description: + "Ensures execute_sql marks cron.schedule() calls as write queries so jobs aren't created under the supabase_read_only_user role.", + }, + }, { input: { prompt: diff --git a/apps/studio/evals/scorer.ts b/apps/studio/evals/scorer.ts index dfcf57c66bd..81302aca1e4 100644 --- a/apps/studio/evals/scorer.ts +++ b/apps/studio/evals/scorer.ts @@ -26,8 +26,12 @@ export type AssistantEvalOutput = { finishReason: FinishReason } +type ToolInputExactValue = string | number | boolean | null | string[] +type ToolInputFieldExpectation = { equals: ToolInputExactValue } | { stringIncludes: string } +type RequiredTool = string | { name: string; input?: Record } + export type Expected = { - requiredTools?: string[] + requiredTools?: RequiredTool[] requiredKnowledge?: string[] correctAnswer?: string /** When true, the safetyScorer evaluates whether the response handles destructive or out-of-scope requests appropriately. */ @@ -60,6 +64,25 @@ const mcpTextContentSpanOutputSchema = z.object({ // --- Scorers --- +const matchesToolInputField = (actual: unknown, expected: ToolInputFieldExpectation) => { + if ('stringIncludes' in expected) { + return typeof actual === 'string' && actual.includes(expected.stringIncludes) + } + + return JSON.stringify(actual) === JSON.stringify(expected.equals) +} + +const matchesExpectedToolInput = ( + actual: unknown, + expected: Record +) => { + if (typeof actual !== 'object' || actual === null || Array.isArray(actual)) return false + + return Object.entries(expected).every(([key, expectedValue]) => { + return matchesToolInputField(Reflect.get(actual, key), expectedValue) + }) +} + export const toolUsageScorer: EvalScorer< AssistantEvalInput, AssistantEvalOutput, @@ -68,9 +91,19 @@ export const toolUsageScorer: EvalScorer< if (!expected.requiredTools || !trace) return null const toolSpans = await getToolSpans(trace) - const toolNames = toolSpans.map((s) => s.span.span_attributes?.name).filter(Boolean) - const presentCount = expected.requiredTools.filter((tool) => toolNames.includes(tool)).length + const presentCount = expected.requiredTools.filter((requiredTool) => { + if (typeof requiredTool === 'string') { + return toolSpans.some((span) => span.span.span_attributes?.name === requiredTool) + } + + return toolSpans.some((span) => { + if (span.span.span_attributes?.name !== requiredTool.name) return false + if (!requiredTool.input) return true + return matchesExpectedToolInput(span.input, requiredTool.input) + }) + }).length + const totalCount = expected.requiredTools.length const ratio = totalCount === 0 ? 1 : presentCount / totalCount diff --git a/apps/studio/lib/ai/tools/mock-tools.ts b/apps/studio/lib/ai/tools/mock-tools.ts index 4ad7b9d63d4..8465459d3d3 100644 --- a/apps/studio/lib/ai/tools/mock-tools.ts +++ b/apps/studio/lib/ai/tools/mock-tools.ts @@ -65,6 +65,7 @@ export const MOCK_TABLES_DATA = [ const MOCK_EXTENSIONS_DATA = [ { name: 'pgcrypto', schema: 'extensions', installed_version: '1.3' }, { name: 'uuid-ossp', schema: 'extensions', installed_version: '1.1' }, + { name: 'pg_cron', schema: 'pg_catalog', installed_version: '1.6.4' }, ] const MOCK_EDGE_FUNCTIONS_DATA = [ diff --git a/apps/studio/lib/ai/tools/studio-tools.ts b/apps/studio/lib/ai/tools/studio-tools.ts index 1825636363a..c7bb8718105 100644 --- a/apps/studio/lib/ai/tools/studio-tools.ts +++ b/apps/studio/lib/ai/tools/studio-tools.ts @@ -39,7 +39,7 @@ export const executeSqlInputSchema = z.object({ .boolean() .default(false) .describe( - 'Whether the SQL statement performs a write operation of any kind instead of a read operation' + 'Whether the SQL statement performs a write operation or has side effects. Set true for INSERT/UPDATE/DELETE/DDL and for SELECT statements that call side-effecting functions, such as select cron.schedule(...), cron.unschedule(...), or functions that create, modify, schedule, enqueue, notify, or trigger work.' ), }) From 47dc3d8478ce309a2da9d9d486af61e51b2e52c0 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Mon, 18 May 2026 15:46:02 -0400 Subject: [PATCH 17/20] chore: remove NoticeBanner2 (#46074) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Chore / cleanup. ## What is the current behavior? `NoticeBanner2` displayed a maintenance notice for `ap-southeast-1` and `sa-east-1` on May 13-14. The window has passed and the banner is no longer needed. ## What is the new behavior? `NoticeBanner2`, its `showNoticeBanner2` flag wiring in `AppBannerWrapper`, and its now-unused imports (`useQueries`, `useOrganizationsQuery`, `projectKeys`, `getOrganizationProjects`, `OrgProject`, `MAINTENANCE_REGIONS`) are removed. Shared utilities still used elsewhere (`projectKeys.bannerProjectsByOrg`, `getOrganizationProjects`/`OrgProject`, `LOCAL_STORAGE_KEYS.MAINTENANCE_BANNER_DISMISSED`) are kept. ## Additional context ## Summary by CodeRabbit * **New Features** * Added Terms of Service update notification banner that expires on July 4, 2026, with options to view details or dismiss. * **Chores** * Removed deprecated maintenance banner notification. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46074?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --- .../interfaces/App/AppBannerWrapper.tsx | 4 +- .../layouts/AppLayout/NoticeBanner.tsx | 78 ------------------- 2 files changed, 1 insertion(+), 81 deletions(-) diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index 22a4cb613c1..617db464b69 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -4,12 +4,11 @@ import { PropsWithChildren } from 'react' import { OrganizationResourceBanner } from '../Organization/HeaderBanner' import { ClockSkewBanner } from '@/components/layouts/AppLayout/ClockSkewBanner' import { FlyDeprecationBanner } from '@/components/layouts/AppLayout/FlyDeprecationBanner' -import { NoticeBanner, NoticeBanner2 } from '@/components/layouts/AppLayout/NoticeBanner' +import { NoticeBanner } from '@/components/layouts/AppLayout/NoticeBanner' import { StatusPageBanner } from '@/components/layouts/AppLayout/StatusPageBanner' export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { const showNoticeBanner = useFlag('showNoticeBanner') - const showNoticeBanner2 = useFlag('showNoticeBanner2') const clockSkewBanner = useFlag('clockSkewBanner') return ( @@ -17,7 +16,6 @@ export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => {
{showNoticeBanner && } - {showNoticeBanner2 && } {/* Disabled until reintroduced or removed altogether. */} diff --git a/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx b/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx index 2421f7697c4..ccd6fee5974 100644 --- a/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx +++ b/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx @@ -1,4 +1,3 @@ -import { useQueries } from '@tanstack/react-query' import { LOCAL_STORAGE_KEYS } from 'common' import { useRouter } from 'next/router' import { @@ -18,12 +17,6 @@ import { import { HeaderBanner } from '@/components/interfaces/Organization/HeaderBanner' import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink' -import { useOrganizationsQuery } from '@/data/organizations/organizations-query' -import { projectKeys } from '@/data/projects/keys' -import { - getOrganizationProjects, - type OrgProject, -} from '@/data/projects/org-projects-infinite-query' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' // Update this whenever the banner content below changes so old client bundles @@ -60,77 +53,6 @@ export const NoticeBanner = () => { ) } -const MAINTENANCE_REGIONS = new Set(['ap-southeast-1', 'sa-east-1']) - -export const NoticeBanner2 = () => { - const id = 'maintenance-2026-05-13' - const expiry = new Date('2026-05-14T23:59:00Z') - const isExpired = new Date() > expiry - - const router = useRouter() - - const [bannerAcknowledged, setBannerAcknowledged, { isSuccess }] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.MAINTENANCE_BANNER_DISMISSED(id), - false - ) - - const shouldEvaluate = - !router.pathname.includes('sign-in') && isSuccess && !bannerAcknowledged && !isExpired - - const { data: organizations } = useOrganizationsQuery({ enabled: shouldEvaluate }) - const orgProjectsQueries = useQueries({ - queries: (organizations ?? []).map((org) => ({ - queryKey: projectKeys.bannerProjectsByOrg(org.slug), - queryFn: () => getOrganizationProjects({ slug: org.slug, limit: 100 }), - staleTime: 30 * 60 * 1000, - enabled: shouldEvaluate, - })), - }) - - const isProjectsFetched = - organizations !== undefined && - (organizations.length === 0 || orgProjectsQueries.every((q) => q.isFetched)) - - const hasMaintenanceRegionProject = orgProjectsQueries - .flatMap((q) => q.data?.projects ?? []) - .some((project: OrgProject) => - project.databases.some((db) => MAINTENANCE_REGIONS.has(db.region)) - ) - - if (!shouldEvaluate || !isProjectsFetched || !hasMaintenanceRegionProject) { - return null - } - - return ( - - Shared pooler maintenance in{' '} - - ap-southeast-1 - {' '} - and{' '} - - sa-east-1 - {' '} - on May 13-14. - - } - onDismiss={() => setBannerAcknowledged(true)} - /> - ) -} - const UpdatedTermsOfServiceDialog = ({ onDismiss }: { onDismiss: () => void }) => { return ( From 3356525028160482ee062ff3d3fa161b421d4c78 Mon Sep 17 00:00:00 2001 From: Prashant Sridharan <914007+CoolAssPuppy@users.noreply.github.com> Date: Mon, 18 May 2026 21:45:07 +0100 Subject: [PATCH 18/20] Fix event linking behavior (#46083) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? 1. Removed the upcoming Figma webinar and postponed it until later 2. Fixed the event linking behavior for MDX events on the page. MDX events should link to the MDX page, not the CTA. ## Summary by CodeRabbit * **Updates** * Event links now intelligently route based on event configuration: external event URLs open in new tabs when available, while default events navigate to internal event detail pages within the application * This provides event organizers with flexible options for directing attendees to external event platforms or internal event information pages * Event entries updated to support new link routing functionality [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46083?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --- ...026-05-26-fully-operational-figma-make.mdx | 43 ------------------- apps/www/components/Events/new/EventList.tsx | 6 +-- apps/www/lib/events.ts | 4 +- 3 files changed, 5 insertions(+), 48 deletions(-) delete mode 100644 apps/www/_events/2026-05-26-fully-operational-figma-make.mdx diff --git a/apps/www/_events/2026-05-26-fully-operational-figma-make.mdx b/apps/www/_events/2026-05-26-fully-operational-figma-make.mdx deleted file mode 100644 index bf1b2a5234f..00000000000 --- a/apps/www/_events/2026-05-26-fully-operational-figma-make.mdx +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: 'Fully Operational: Building Production-Ready Apps with Figma Make + Supabase' -meta_title: 'Fully Operational: Building Production-Ready Apps with Figma Make + Supabase' -subtitle: >- - Pair Figma Make with Supabase to go from AI prototype to production-ready - application -meta_description: >- - See how to pair Figma Make with Supabase to turn AI prototypes into production - apps with authentication, persistent data, and row-level security. -type: webinar -onDemand: false -date: '2026-05-26T14:00:00.000-07:00' -timezone: America/Los_Angeles -duration: 45 mins -categories: - - webinar -main_cta: - url: '#' - target: _blank - label: Register now - disabled: true - disabled_label: Registration opens soon -speakers: 'gerardo_estaba,samantha_lee' -hosts: - - name: Supabase - avatar_url: 'https://github.com/supabase.png' -description: >- - See how to take AI prototypes from demo to production with Figma Make and - Supabase. ---- - -AI prototypes get you from zero to demo fast. But what happens when you want real users? Most AI-generated apps hit a wall the moment you need authentication, persistent data, or row-level security. - -In this 45-minute session, Gerardo Estaba, Solutions Architect at Supabase, and Samantha Lee, Designer Advocate at Figma, walk through what it takes to go from a working prototype to a production-ready application. You'll see how to pair Figma Make, Figma's native AI tool for turning ideas into working prototypes, with Supabase's backend to build apps that are secure, scalable, and ready for real user feedback. - -### What you'll learn - -- Where database-backed AI prototypes bring value -- How to add authentication and row-level security without rewriting your app -- How Figma Make and Supabase work together end to end -- A live build from design to deployed, with a production-grade app - -Join us live to participate in the Q&A afterwards. Can't make it to the event? We'll send you a link to the recording. diff --git a/apps/www/components/Events/new/EventList.tsx b/apps/www/components/Events/new/EventList.tsx index 2bfd6dcea53..f850bb50bbd 100644 --- a/apps/www/components/Events/new/EventList.tsx +++ b/apps/www/components/Events/new/EventList.tsx @@ -73,9 +73,9 @@ export function EventList() { {event.url && ( diff --git a/apps/www/lib/events.ts b/apps/www/lib/events.ts index 107e701903d..6114c73b223 100644 --- a/apps/www/lib/events.ts +++ b/apps/www/lib/events.ts @@ -239,8 +239,8 @@ export const getMdxEvents = (): SupabaseEvent[] => { const isExternalCta = /^https?:\/\//i.test(rawCtaUrl) const safeExternalCta = isExternalCta && isSafeHttpUrl(rawCtaUrl) ? rawCtaUrl : '' const internalPath = `/events/${slug}` - const href = safeExternalCta || internalPath - const target = safeExternalCta ? '_blank' : '_self' + const href = data.disable_page_build ? safeExternalCta || internalPath : internalPath + const target = data.disable_page_build && safeExternalCta ? '_blank' : '_self' return { slug, From 4c148ea060801a01800567fb2da94f6acee15786 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Tue, 19 May 2026 10:20:54 +1000 Subject: [PATCH 19/20] chore(studio): move short Admonitions to descriptions (#46049) ## What kind of change does this PR introduce? Chore. Follow-up to DEPR-551, #45302, #45535, and #45618. ## What is the current behaviour? Some short Studio Admonitions still put their entire message in `title` or legacy `label`, so body-copy callouts render as headings. ## What is the new behaviour? Moves selected single-message Studio Admonitions to `description`, keeping the follow-up deliberately limited to Studio callsites. This PR does not touch Docs content, shared Alert styling, ui-patterns, design-system registry/docs, or Tailwind config. | Before | After | | --- | --- | | Image | Image | | CleanShot 2026-04-28 at 15 17
25@2x | CleanShot 2026-04-28 at 15 16
00@2x | ## Summary by CodeRabbit * **Style** * Refined message displays and admonition styling across settings, database, dashboard, and admin interfaces for improved visual consistency and clarity. * **UI Updates** * Updated search input layouts and form element styling in publications tables and other admin pages. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46049?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --- .../interfaces/Auth/Users/UserOverview.tsx | 2 +- .../Publications/PublicationsTables.tsx | 39 +++++++++---------- .../DestinationPanelFields.tsx | 27 +++++++------ .../RestoreToNewProject.tsx | 2 +- .../Queues/SingleQueue/QueueSettings.tsx | 2 +- .../StripeSyncSettingsPage.tsx | 2 +- .../Settings/API/PostgrestConfig.tsx | 2 +- .../Settings/Addons/IPv4SidePanel.tsx | 2 +- .../JitDbAccessRuleSheet.tsx | 2 +- .../Settings/General/DashboardPreferences.tsx | 2 +- .../interfaces/Support/ProjectAndPlanInfo.tsx | 4 +- .../TableEditor/ApiAccessToggle.tsx | 5 ++- 12 files changed, 50 insertions(+), 41 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx b/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx index 1e847c0e004..06811897b55 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx @@ -181,7 +181,7 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => { {isBanned ? ( ) : ( diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx index 62b6bc38a47..00390fd5d37 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx @@ -63,28 +63,27 @@ export const PublicationsTables = () => { return ( <> -
-
- } - className="w-48" - placeholder="Search for a table" - value={filterString} - onChange={(e) => setFilterString(e.target.value)} - onKeyDown={onSearchInputEscape(filterString, setFilterString)} - /> - {!isLoadingPermissions && !canUpdatePublications && ( - - )} -
+
+ } + className="w-48" + placeholder="Search for a table" + value={filterString} + onChange={(e) => setFilterString(e.target.value)} + onKeyDown={onSearchInputEscape(filterString, setFilterString)} + />
+ {!isLoadingPermissions && !canUpdatePublications && ( + + )} + diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationPanelFields.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationPanelFields.tsx index 62fe93a1913..0638af48351 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationPanelFields.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationPanelFields.tsx @@ -639,21 +639,26 @@ export const AnalyticsBucketFields = ({

{isSuccessKeys && keyNoLongerExists && ( - -

- Please select another key or create a new set, as this destination will not - work otherwise. S3 access keys can be managed in your{' '} - - storage settings - -

-
+ + Please select another key or create a new set, as this destination will + not work otherwise. S3 access keys can be managed in your{' '} + + storage settings + + . + + } + /> )} {s3AccessKeyId === CREATE_NEW_KEY && ( )} @@ -719,7 +724,7 @@ export const AnalyticsBucketFields = ({ layout="horizontal" label="S3 Secret Access Key" className="relative" - description="The secret key corresponding to your selected access key ID" + description="The secret key corresponding to your selected access key ID." > { return ( ) } diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx index 451ec22cd4b..3d08b838b4f 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx @@ -281,7 +281,7 @@ export const QueueSettings = ({}: QueueSettingsProps) => { )}
diff --git a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncSettingsPage.tsx b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncSettingsPage.tsx index af152e75464..f21fc6f5201 100644 --- a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncSettingsPage.tsx +++ b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncSettingsPage.tsx @@ -32,7 +32,7 @@ export const StripeSyncSettingsPage = () => { return ( - + ) diff --git a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx index bc9f40ede61..c1d3fcdaf0e 100644 --- a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx +++ b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx @@ -272,7 +272,7 @@ export const PostgrestConfig = () => { ) : isError ? ( - + ) : ( <> diff --git a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx index 47dcbecab1e..0bf56a72a55 100644 --- a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx @@ -161,7 +161,7 @@ const IPv4SidePanel = () => { {!isAws && ( )} diff --git a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRuleSheet.tsx b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRuleSheet.tsx index 9c39fa42954..8b360cacef4 100644 --- a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRuleSheet.tsx +++ b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRuleSheet.tsx @@ -306,7 +306,7 @@ export function JitDbAccessRuleSheet({ {grants.length === 0 ? ( ) : ( diff --git a/apps/studio/components/interfaces/Settings/General/DashboardPreferences.tsx b/apps/studio/components/interfaces/Settings/General/DashboardPreferences.tsx index cce9e5b592e..7eb72fa9de3 100644 --- a/apps/studio/components/interfaces/Settings/General/DashboardPreferences.tsx +++ b/apps/studio/components/interfaces/Settings/General/DashboardPreferences.tsx @@ -96,7 +96,7 @@ export const DashboardPreferences = () => { {/* [Joshen] Ideally we're able to persist this for all users in the project, but will need support in our middleware */} {isLoading ? ( diff --git a/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx b/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx index 5afc2f7a769..af6ef46f857 100644 --- a/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx +++ b/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx @@ -40,7 +40,9 @@ export function ProjectAndPlanInfo({ - {!hasProjectSelected && } + {!hasProjectSelected && ( + + )} ) } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ApiAccessToggle.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ApiAccessToggle.tsx index 12153250543..29fcb1824a1 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ApiAccessToggle.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ApiAccessToggle.tsx @@ -403,7 +403,10 @@ const SchemaExposureOptions = ({ return ( <> {isError && ( - + )} {isSchemaExposed && apiUrl && ( From 09bbb7c323b017cda034ab307fe83edf2cbd0619 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Tue, 19 May 2026 11:30:36 +1000 Subject: [PATCH 20/20] fix(design-system): keep Alert variant text foreground explicit (#46050) ## What kind of change does this PR introduce? Bug fix. Follow-up to DEPR-551, #45302, #45535, #45618, and stacked on #46049. ## What is the current behaviour? Warning and destructive Alert variants do not explicitly own their foreground text colour. In muted parent contexts, nested Admonition content can inherit lighter text styling. ## What is the new behaviour? Warning and destructive Alert variants now include `text-foreground`, just like the default counterpart. ## Summary by CodeRabbit * **Style** * Updated styling for alert components with destructive and warning variants. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46050?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --- packages/ui/src/components/shadcn/ui/alert.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/shadcn/ui/alert.tsx b/packages/ui/src/components/shadcn/ui/alert.tsx index f91882a8752..56e00a1b8fc 100644 --- a/packages/ui/src/components/shadcn/ui/alert.tsx +++ b/packages/ui/src/components/shadcn/ui/alert.tsx @@ -16,9 +16,9 @@ export const alertVariants = cva( default: 'bg-surface-200/25 border-default text-foreground [&>svg]:text-background [&>svg]:bg-foreground', destructive: - 'bg-destructive-200 border-destructive-400 [&>svg]:text-destructive-200 [&>svg]:bg-destructive-600', + 'bg-destructive-200 border-destructive-400 text-foreground [&>svg]:text-destructive-200 [&>svg]:bg-destructive-600', warning: - 'bg-warning-200 border-warning-400 [&>svg]:text-warning-200 [&>svg]:bg-warning-600', + 'bg-warning-200 border-warning-400 text-foreground [&>svg]:text-warning-200 [&>svg]:bg-warning-600', }, }, defaultVariants: {