diff --git a/apps/docs/app/api/search_v2/route.ts b/apps/docs/app/api/search_v2/route.ts index 94030052ef7..9d08c899782 100644 --- a/apps/docs/app/api/search_v2/route.ts +++ b/apps/docs/app/api/search_v2/route.ts @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/nextjs' import { supabaseSearchV2 } from '~/scripts/search_v2/client' import { type NextRequest } from 'next/server' @@ -10,23 +11,36 @@ export async function OPTIONS() { } export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url) - const query = searchParams.get('q')?.trim() - const limit = Number(searchParams.get('limit')) || 10 + try { + const { searchParams } = new URL(request.url) + const query = searchParams.get('q')?.trim() + const limit = Number(searchParams.get('limit')) || 10 - if (!query) { - return Response.json({ error: 'Missing q parameter' }, { status: 400, headers: corsHeaders }) + if (!query) { + return Response.json({ error: 'Missing q parameter' }, { status: 400, headers: corsHeaders }) + } + + const { data, error } = await supabaseSearchV2().rpc('search_docs', { + query_text: query, + match_limit: limit, + }) + + if (error) { + console.error('Error running docs search v2:', error) + Sentry.captureException(new Error(error.message), { + tags: { route: 'search_v2' }, + extra: { query, limit, error }, + }) + return Response.json({ error: error.message }, { status: 500, headers: corsHeaders }) + } + + return Response.json(data, { headers: corsHeaders }) + } catch (error) { + console.error('Error handling docs search v2 request:', error) + Sentry.captureException(error, { tags: { route: 'search_v2' } }) + return Response.json( + { error: 'There was an error processing your request' }, + { status: 500, headers: corsHeaders } + ) } - - const { data, error } = await supabaseSearchV2().rpc('search_docs', { - query_text: query, - match_limit: limit, - }) - - if (error) { - console.error('Error running docs search v2:', error) - return Response.json({ error: error.message }, { status: 500, headers: corsHeaders }) - } - - return Response.json(data, { headers: corsHeaders }) } diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 17022d54b9b..0f7c3bb734a 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1099,6 +1099,10 @@ export const database: NavMenuConstant = { name: 'Managing database functions', url: '/guides/database/functions' as `/${string}`, }, + { + name: 'Debugging database functions', + url: '/guides/database/debugging-functions' as `/${string}`, + }, { name: 'Managing database triggers', url: '/guides/database/postgres/triggers' as `/${string}`, diff --git a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx index 1c06cc8b765..451bb8b77b0 100644 --- a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx @@ -51,7 +51,6 @@ const TopNavBar: FC = () => { {searchVariant === 'search-v2-active' ? ( Search diff --git a/apps/docs/content/guides/database/debugging-functions.mdx b/apps/docs/content/guides/database/debugging-functions.mdx new file mode 100644 index 00000000000..f386ae108f5 --- /dev/null +++ b/apps/docs/content/guides/database/debugging-functions.mdx @@ -0,0 +1,174 @@ +--- +id: 'debugging-functions' +title: 'Debugging database functions' +description: 'Logging and error handling inside Postgres functions.' +--- + +Add logs and error handling to a database function so you can see what it does at runtime. Logs matter most in a complex function. + +For how to write and call a function, see [Database functions](/docs/guides/database/functions). + +Good targets to log include: + +- Values of (non-sensitive) variables +- Returned results from queries + +## General logging + +Use the `raise` keyword to write custom logs to the [Postgres logs](/dashboard/project/_/logs/postgres-logs) in the Dashboard. Three severity levels appear by default: + +- `log` +- `warning` +- `exception` (error level) + +```sql +create function logging_example( + log_message text, + warning_message text, + error_message text +) +returns void +language plpgsql +as $$ +begin + raise log 'logging message: %', log_message; + raise warning 'logging warning: %', warning_message; + + -- immediately ends function and reverts transaction + raise exception 'logging error: %', error_message; +end; +$$; + +select logging_example('LOGGED MESSAGE', 'WARNING MESSAGE', 'ERROR MESSAGE'); +``` + +## Error handling + +You can create custom errors with the `raise exception` keywords. + +A common pattern is to throw an error when a variable doesn't meet a condition: + +```sql +create or replace function error_if_null(some_val text) +returns text +language plpgsql +as $$ +begin + -- error if some_val is null + if some_val is null then + raise exception 'some_val should not be NULL'; + end if; + -- return some_val if it is not null + return some_val; +end; +$$; + +select error_if_null(null); +``` + +Value checking is common, so Postgres provides the `assert` keyword as a shorthand. It takes the following format: + +```text +assert , 'message'; +``` + +For example: + +```sql +-- assumes an attendance_table with an id uuid column and a student text column +create function assert_example(name text) +returns uuid +language plpgsql +as $$ +declare + student_id uuid; +begin + -- save a user's id into the user_id variable + select + id into student_id + from attendance_table + where student = name; + + -- throw an error if the student_id is null + assert student_id is not null, 'assert_example() ERROR: student not found'; + + -- otherwise, return the user's id + return student_id; +end; +$$; + +select assert_example('Harry Potter'); +``` + +You can also capture and modify an error message with the `exception` keyword: + +```sql +create function error_example() +returns void +language plpgsql +as $$ +begin + -- fails: cannot read from nonexistent table + select * from table_that_does_not_exist; + + exception + when others then + raise exception 'An error occurred in function : %', sqlerrm; +end; +$$; +``` + +## Advanced logging + +For a more complex function, or for harder debugging, log the following: + +- Formatted variables +- Individual rows +- Start and end of function calls + +```sql +-- assumes a some_table with a col_1 int column and a col_2 text column +create or replace function advanced_example(num int default 10) +returns text +language plpgsql +as $$ +declare + var1 int := 20; + var2 text; +begin + -- Logging start of function + raise log 'logging start of function call: (%)', (select now()); + + -- Logging a variable from a SELECT query + select + col_1 into var1 + from some_table + limit 1; + raise log 'logging a variable (%)', var1; + + -- It is also possible to avoid using variables, by returning the values of your query to the log + raise log 'logging a query with a single return value(%)', (select col_1 from some_table limit 1); + + -- If necessary, you can even log an entire row as JSON + raise log 'logging an entire row as JSON (%)', (select to_jsonb(some_table.*) from some_table limit 1); + + -- When using INSERT or UPDATE, the new value(s) can be returned + -- into a variable. + -- When using DELETE, the deleted value(s) can be returned. + -- All three operations use "RETURNING value(s) INTO variable(s)" syntax + insert into some_table (col_2) + values ('new val') + returning col_2 into var2; + + raise log 'logging a value from an INSERT (%)', var2; + + return var1 || ',' || var2; +exception + -- Handle exceptions here if needed + when others then + raise exception 'An error occurred in function : %', sqlerrm; +end; +$$; + +select advanced_example(); +``` diff --git a/apps/docs/content/guides/database/extensions/pgaudit.mdx b/apps/docs/content/guides/database/extensions/pgaudit.mdx index 7514e339dea..e67f1f4bc91 100644 --- a/apps/docs/content/guides/database/extensions/pgaudit.mdx +++ b/apps/docs/content/guides/database/extensions/pgaudit.mdx @@ -344,7 +344,7 @@ To prevent PGAudit from monitoring the problematic roles, you'll want to change ### Using PGAudit to debug database functions -Technically yes, but it is not the best approach. It is better to check out our [function debugging guide](/docs/guides/database/functions#general-logging) instead. +Technically yes, but it is not the best approach. It is better to check out our [function debugging guide](/docs/guides/database/debugging-functions#general-logging) instead. ### Downloading database logs @@ -387,6 +387,6 @@ PGAudit's [official documentation](https://www.pgaudit.org) focuses on system an ## Resources - [Official `PGAudit` documentation](https://www.pgaudit.org) -- [Database Function Logging](/docs/guides/database/functions#general-logging) +- [Database Function Logging](/docs/guides/database/debugging-functions#general-logging) - [Supabase Logging](/docs/guides/observability/logs) - [Self-Hosting Logs](/docs/reference/self-hosting-analytics/introduction) diff --git a/apps/docs/content/guides/database/functions.mdx b/apps/docs/content/guides/database/functions.mdx index b077f4c036a..bd1a3b59356 100644 --- a/apps/docs/content/guides/database/functions.mdx +++ b/apps/docs/content/guides/database/functions.mdx @@ -1,6 +1,6 @@ --- id: 'functions' -title: 'Database Functions' +title: 'Database functions' description: 'Creating and using Postgres functions.' video: 'https://www.youtube.com/v/MJZCCpCYEqk' --- @@ -8,11 +8,24 @@ video: 'https://www.youtube.com/v/MJZCCpCYEqk' Postgres has built-in support for [SQL functions](https://www.postgresql.org/docs/current/sql-createfunction.html). These functions live inside your database, and you can call them from your app with [`rpc()`](../../reference/javascript/rpc). +- [Database functions vs Edge Functions](#database-functions-vs-edge-functions) compares the two. Start here if you aren't sure which one fits. +- [Create a database function](#create-a-database-function) has the steps, from a one-line function to one that takes parameters. +- [Secure a database function](#secure-a-database-function) covers which user the function runs as and who can call it. +- [Debugging database functions](/docs/guides/database/debugging-functions) covers logging and error handling. + ## Quick demo -## Getting started +## Database functions vs Edge Functions + +For data-intensive operations, use database functions. They run inside your database, and you can call them remotely with the [REST and GraphQL API](../api). + +For use cases that need low latency, use [Edge Functions](../../guides/functions). They're globally distributed and you write them in TypeScript. + +## Create a database function + +### Getting started Create a database function from the Dashboard, or write the SQL yourself against a [direct connection](../../guides/database/connecting-to-postgres). @@ -24,7 +37,7 @@ To use the Dashboard: 3. Enter the SQL that creates or replaces your database function. 4. Click **Run**. You can also press `cmd+enter` or `ctrl+enter`. -## Basic functions [#simple-functions] +### Basic functions [#simple-functions] Create a basic database function that returns the string "hello world". @@ -47,7 +60,7 @@ At its most basic, a function has the following parts: 2. `returns text`: The type of data the function returns. For a function that returns nothing, write `returns void`. 3. `language sql`: The language used inside the function body. This can also be a procedural language: `plpgsql`, `plpython`, etc. 4. `as $$`: The function wrapper. Anything inside the `$$` symbols is part of the function body. -5. `select 'hello world';`: A basic function body. The function returns the result of the last `select` statement in its body. +5. `select 'hello world';`: A basic function body. The function returns the result of its final query, which can be an `insert`, `update`, or `delete` with a `returning` clause. 6. `$$;`: The closing symbols of the function wrapper. @@ -141,22 +154,22 @@ Reference: [`Rpc()`](../../reference/csharp/rpc) -## Returning data sets +### Returning data sets A database function can also return a data set from a [table](../../guides/database/tables) or a view. -For example, take a database holding some Star Wars data: +For example, create two tables holding some Star Wars data. The **Data** tab shows the rows they end up with. -### Planets +#### Planets ``` | id | name | @@ -166,7 +179,7 @@ For example, take a database holding some Star Wars data: | 3 | Kashyyyk | ``` -### People +#### People ``` | id | name | planet_id | @@ -187,11 +200,11 @@ create table planets ( ); insert into planets - (id, name) + (name) values - (1, 'Tattoine'), - (2, 'Alderaan'), - (3, 'Kashyyyk'); + ('Tatooine'), + ('Alderaan'), + ('Kashyyyk'); create table people ( id serial primary key, @@ -200,12 +213,12 @@ create table people ( ); insert into people - (id, name, planet_id) + (name, planet_id) values - (1, 'Anakin Skywalker', 1), - (2, 'Luke Skywalker', 1), - (3, 'Princess Leia', 2), - (4, 'Chewbacca', 3); + ('Anakin Skywalker', 1), + ('Luke Skywalker', 1), + ('Princess Leia', 2), + ('Chewbacca', 3); ``` @@ -291,7 +304,7 @@ data = supabase.rpc('get_planets').eq('id', 1).execute() -## Passing parameters +### Passing parameters Create a function that inserts a new planet into the `planets` table and returns the new ID. This function uses the `plpgsql` language. @@ -408,20 +421,14 @@ await supabase.Rpc("add_planet", new Dictionary { { "name", "Jak -## Suggestions - -### Database Functions vs Edge Functions - -For data-intensive operations, use database functions. They run inside your database, and you can call them remotely with the [REST and GraphQL API](../api). - -For use cases that need low latency, use [Edge Functions](../../guides/functions). They're globally distributed and you write them in TypeScript. +## Secure a database function ### Security `definer` vs `invoker` Postgres runs a function either as the user _calling_ it (`invoker`) or as its _creator_ (`definer`). For example: ```sql -create function hello_world() +create or replace function hello_world() returns text language plpgsql security definer set search_path = '' @@ -436,6 +443,17 @@ Prefer `security invoker`, which is also the default. When you use `security def With an empty search path, `search_path = ''`, name the schema for every relation in the function body, such as `from public.table`. An empty search path limits the damage when the function can reach a schema you don't want the calling user to reach. + + +A `security definer` function can return rows the caller isn't allowed to read. It runs with its creator's privileges. A function created in the Dashboard or by a migration is owned by `postgres`, which bypasses Row Level Security. Any role can call it by default, including `anon`, the role that serves requests carrying no user session. + +Pinning the `search_path` doesn't change who can call the function. When a `security definer` function reads per-user data: + +- **Check ownership in the function body.** An execute grant controls which roles can call the function. It doesn't limit the rows a permitted role gets back, so granting to `authenticated` still returns any user's row to every signed-in caller. +- **Narrow the execute privilege as well.** Revoke it from `public` and from each role holding a direct grant, such as `anon`. Then grant it to the roles that need it. See the [Function privileges](#function-privileges) section of this page. + + + ### Function privileges By default, any role can run a database function. You can restrict execution in two ways: @@ -463,178 +481,10 @@ By default, any role can run a database function. You can restrict execution in alter default privileges in schema public revoke execute on functions from anon, authenticated; ``` - You can then regrant permissions for a specific function to a specific role: - - ```sql - grant execute on function public.hello_world to authenticated; - ``` - -### Debugging functions - -Add logs to help you debug a function. Logs matter most in a complex function. - -Good targets to log include: - -- Values of (non-sensitive) variables -- Returned results from queries - -#### General logging - -Use the `raise` keyword to write custom logs to the [Postgres logs](/dashboard/project/_/logs/postgres-logs) in the Dashboard. Three severity levels appear by default: - -- `log` -- `warning` -- `exception` (error level) +You can then regrant permissions for a specific function to a specific role: ```sql -create function logging_example( - log_message text, - warning_message text, - error_message text -) -returns void -language plpgsql -as $$ -begin - raise log 'logging message: %', log_message; - raise warning 'logging warning: %', warning_message; - - -- immediately ends function and reverts transaction - raise exception 'logging error: %', error_message; -end; -$$; - -select logging_example('LOGGED MESSAGE', 'WARNING MESSAGE', 'ERROR MESSAGE'); -``` - -#### Error handling - -You can create custom errors with the `raise exception` keywords. - -A common pattern is to throw an error when a variable doesn't meet a condition: - -```sql -create or replace function error_if_null(some_val text) -returns text -language plpgsql -as $$ -begin - -- error if some_val is null - if some_val is null then - raise exception 'some_val should not be NULL'; - end if; - -- return some_val if it is not null - return some_val; -end; -$$; - -select error_if_null(null); -``` - -Value checking is common, so Postgres provides the `assert` keyword as a shorthand. It takes the following format: - -```sql --- throw error when condition is false -assert , 'message'; -``` - -For example: - -```sql -create function assert_example(name text) -returns uuid -language plpgsql -as $$ -declare - student_id uuid; -begin - -- save a user's id into the user_id variable - select - id into student_id - from attendance_table - where student = name; - - -- throw an error if the student_id is null - assert student_id is not null, 'assert_example() ERROR: student not found'; - - -- otherwise, return the user's id - return student_id; -end; -$$; - -select assert_example('Harry Potter'); -``` - -You can also capture and modify an error message with the `exception` keyword: - -```sql -create function error_example() -returns void -language plpgsql -as $$ -begin - -- fails: cannot read from nonexistent table - select * from table_that_does_not_exist; - - exception - when others then - raise exception 'An error occurred in function : %', sqlerrm; -end; -$$; -``` - -#### Advanced logging - -For a more complex function, or for harder debugging, log the following: - -- Formatted variables -- Individual rows -- Start and end of function calls - -```sql -create or replace function advanced_example(num int default 10) -returns text -language plpgsql -as $$ -declare - var1 int := 20; - var2 text; -begin - -- Logging start of function - raise log 'logging start of function call: (%)', (select now()); - - -- Logging a variable from a SELECT query - select - col_1 into var1 - from some_table - limit 1; - raise log 'logging a variable (%)', var1; - - -- It is also possible to avoid using variables, by returning the values of your query to the log - raise log 'logging a query with a single return value(%)', (select col_1 from some_table limit 1); - - -- If necessary, you can even log an entire row as JSON - raise log 'logging an entire row as JSON (%)', (select to_jsonb(some_table.*) from some_table limit 1); - - -- When using INSERT or UPDATE, the new value(s) can be returned - -- into a variable. - -- When using DELETE, the deleted value(s) can be returned. - -- All three operations use "RETURNING value(s) INTO variable(s)" syntax - insert into some_table (col_2) - values ('new val') - returning col_2 into var2; - - raise log 'logging a value from an INSERT (%)', var2; - - return var1 || ',' || var2; -exception - -- Handle exceptions here if needed - when others then - raise exception 'An error occurred in function : %', sqlerrm; -end; -$$; - -select advanced_example(); +grant execute on function public.hello_world to authenticated; ``` ## Resources @@ -642,18 +492,16 @@ select advanced_example(); - Official Client libraries: [JavaScript](../../reference/javascript/rpc) and [Flutter](../../reference/dart/rpc) - Community client libraries: [github.com/supabase-community](https://github.com/supabase-community) - Postgres Official Docs: [Chapter 9. Functions and Operators](https://www.postgresql.org/docs/current/functions.html) -- Postgres Reference: [CREATE FUNCTION](https://www.postgresql.org/docs/9.1/sql-createfunction.html) +- Postgres Reference: [CREATE FUNCTION](https://www.postgresql.org/docs/current/sql-createfunction.html) -## Deep dive - -### Create Database Functions +### Create a database function in the Dashboard -### Call Database Functions using JavaScript +### Call a database function from JavaScript -### Using Database Functions to call an external API +### Call an external API from a database function diff --git a/apps/docs/content/guides/database/multigres.mdx b/apps/docs/content/guides/database/multigres.mdx index 3100f911fbf..8e0cb752c65 100644 --- a/apps/docs/content/guides/database/multigres.mdx +++ b/apps/docs/content/guides/database/multigres.mdx @@ -15,13 +15,14 @@ Multigres gives your project high availability by running Postgres across multip -## Eligibility +## Requesting access + +To request access to the Multigres Private Alpha, [fill out the early access form](https://supabase.com/go/multigres-early-access#form). During the Private Alpha: - Multigres is available to organizations on a paid plan. It isn't available on the Free plan. -- Availability is being rolled out gradually, so the option to enable it may not yet appear for every eligible organization. -- Up to two projects per eligible organization can use Multigres for free during the Alpha. +- Up to two projects per organization can use Multigres for free during the Alpha. ## Enabling Multigres @@ -32,7 +33,7 @@ In the Dashboard, Multigres appears as **High availability** during project crea 3. Finish the remaining fields such as database password, region, and compute size, if shown. 4. Click **Create new project**. -If your organization isn't eligible or hasn't been rolled out yet, **High availability** won't appear. +If you don't see **High availability**, your organization doesn't have access yet. See [Requesting access](#requesting-access). ## What's not included in the alpha diff --git a/apps/docs/content/guides/database/postgres/postgres-log-config.mdx b/apps/docs/content/guides/database/postgres/postgres-log-config.mdx index f820fd05687..f31dc184de4 100644 --- a/apps/docs/content/guides/database/postgres/postgres-log-config.mdx +++ b/apps/docs/content/guides/database/postgres/postgres-log-config.mdx @@ -823,16 +823,16 @@ Determines what _query generated_ logs (not background or networking logs) are r As an example of how `log_min_messages` works, if the setting were changed to `error`, Postgres would stop recording logs with the severity levels `warning`, `notice`, `info`, and `debug1 ... debug5` events. However, it would continue recording all `error`, `log`, `fatal`, and `panic` occurrences. -| Severity | Description | Example log | -| :-------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **debug1 ... debug5** | Successively detailed debugging and server info, predominantly used by Postgres developers and extension maintainers. | `DEBUG1: rehashing catalog cache id 6...` | -| **info** | Information explicitly requested by the user during an operation. | `INFO: analyzing "public.example..."`

Example returned by the [`analyze verbose`](https://www.postgresql.org/docs/current/sql-analyze.html) command | -| **notice** | Helpful, non-essential information about automatic background actions. | `NOTICE: table "old_logs" does not exist, skipping`

Example returned by the [`drop table if exists`](https://www.postgresql.org/docs/current/sql-droptable.html) commands | -| **warning** | A query completed, but skipped requested actions. | `WARNING: no privileges were granted for "some_user"`

Example returned by the [`grant`](https://www.postgresql.org/docs/current/sql-grant.html) command | -| **error** | A specific query failed, but the overall database connection remains alive. | `ERROR: duplicate key value violates unique constraint "example_pkey"` | -| **log** | Operational events. Usually generated by [background activity log settings](/docs/guides/database/postgres/postgres-log-config#background-activity) or by [database functions](/docs/guides/database/functions?queryGroups=language&language=js#debugging-functions) | `LOG: connection received...` | -| **fatal** | An error that causes a database connection to abruptly terminate. | `FATAL: terminating connection due to administrator command` | -| **panic** | A critical, system-wide failure that forces the database to shut down and crash-recover. | `PANIC: could not locate a valid checkpoint record at 0/61013608` | +| Severity | Description | Example log | +| :-------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **debug1 ... debug5** | Successively detailed debugging and server info, predominantly used by Postgres developers and extension maintainers. | `DEBUG1: rehashing catalog cache id 6...` | +| **info** | Information explicitly requested by the user during an operation. | `INFO: analyzing "public.example..."`

Example returned by the [`analyze verbose`](https://www.postgresql.org/docs/current/sql-analyze.html) command | +| **notice** | Helpful, non-essential information about automatic background actions. | `NOTICE: table "old_logs" does not exist, skipping`

Example returned by the [`drop table if exists`](https://www.postgresql.org/docs/current/sql-droptable.html) commands | +| **warning** | A query completed, but skipped requested actions. | `WARNING: no privileges were granted for "some_user"`

Example returned by the [`grant`](https://www.postgresql.org/docs/current/sql-grant.html) command | +| **error** | A specific query failed, but the overall database connection remains alive. | `ERROR: duplicate key value violates unique constraint "example_pkey"` | +| **log** | Operational events. Usually generated by [background activity log settings](/docs/guides/database/postgres/postgres-log-config#background-activity) or by [database functions](/docs/guides/database/debugging-functions) | `LOG: connection received...` | +| **fatal** | An error that causes a database connection to abruptly terminate. | `FATAL: terminating connection due to administrator command` | +| **panic** | A critical, system-wide failure that forces the database to shut down and crash-recover. | `PANIC: could not locate a valid checkpoint record at 0/61013608` | @@ -931,5 +931,5 @@ Review the [pgAudit docs](/docs/guides/database/extensions/pgaudit) for more con ## Resources - [Advanced Log Filtering](/docs/guides/observability/advanced-log-filtering) -- [Database Function Logging](/docs/guides/database/functions#general-logging) +- [Database Function Logging](/docs/guides/database/debugging-functions#general-logging) - [Supabase Logging](/docs/guides/observability/logs) diff --git a/apps/docs/content/guides/functions/auth.mdx b/apps/docs/content/guides/functions/auth.mdx index 511dfe9ce9a..f502c125ad4 100644 --- a/apps/docs/content/guides/functions/auth.mdx +++ b/apps/docs/content/guides/functions/auth.mdx @@ -7,20 +7,32 @@ subtitle: 'Authentication patterns for Edge Functions' Secure an Edge Function by declaring which credentials it accepts. The `withSupabase` wrapper from the [`@supabase/server` package](https://github.com/supabase/server) on GitHub checks each caller against the `auth` mode you set. Your handler receives a preconfigured Supabase client on `ctx`. -For how authorization headers and the `verify_jwt` platform check work, see [Authorization headers](/docs/guides/functions/auth-headers). +- [Choose an auth mode](#choose-an-auth-mode) compares the four modes. Start here if you aren't sure which one your function needs. +- [Secure your function](#secure-your-function) has an example for each mode, plus how to combine modes and shape your own error response. +- [Environment variables](#environment-variables) lists what `@supabase/server` reads from the environment. -The wrapper accepts four `auth` modes: +## Choose an auth mode + +Each mode names the credential a caller must present. `withSupabase` accepts four: | Mode | Accepts | | --------------- | ------------------------------------------ | | `'user'` | A valid user JWT on `Authorization` | -| `'secret'` | A secret key on `apikey` | -| `'publishable'` | A publishable key on `apikey` | +| `'secret'` | The `default` secret key on `apikey` | +| `'publishable'` | The `default` publishable key on `apikey` | | `'none'` | Any caller, no check (for signed webhooks) | -## Authenticated user calls +For how authorization headers and the `verify_jwt` platform check work, see [Authorization headers](/docs/guides/functions/auth-headers). -When a signed-in user calls a function, the request carries the user's session JWT on the `Authorization` header. Your app usually makes that call through `supabase.functions.invoke`. The default is `verify_jwt = true`. The platform validates the JWT before your handler runs. Use `auth: 'user'` to get a `ctx.supabase` scoped to the caller's Row Level Security (RLS) policies. +## Secure your function + +Each of the following sections covers one mode, then how to combine modes and shape your own error response. Every example wraps the handler in `withSupabase` and reads its Supabase client from `ctx`. + +### Authenticated user calls + +When a signed-in user calls a function, the request carries the user's session JWT on the `Authorization` header. Your app usually makes that call through `supabase.functions.invoke`. Use `auth: 'user'` to get a `ctx.supabase` scoped to the caller's Row Level Security (RLS) policies. + +The default is `verify_jwt = true`, so the platform validates the JWT before your handler runs. Leave it on for user-facing functions. Turning it off to clear a 401 during development removes the platform's check, leaving `withSupabase` as the only thing that verifies the caller. ```ts import { withSupabase } from 'npm:@supabase/server@1' @@ -40,9 +52,17 @@ export default { } ``` -## Service-to-service calls +Reach for `ctx.supabase` when the handler serves the signed-in caller. It applies their RLS policies to every query. Use `ctx.supabaseAdmin` only for work that has to cross those policies. -Cron jobs, workers, `pg_net`, and other Edge Functions make calls with a secret key on the `apikey` header rather than a user JWT. Disable `verify_jwt` and use `auth: 'secret'`. The wrapper validates the key against any secret key in your [project's API keys](/dashboard/project/_/settings/api-keys), and gives your handler `ctx.supabaseAdmin` for privileged work. + + +A handler that queries a shared table through `ctx.supabaseAdmin` without filtering by the caller's ID returns every user's rows. `ctx.supabaseAdmin` bypasses Row Level Security, so filter by the caller's ID from `ctx.userClaims`, or use `ctx.supabase` instead. + + + +### Service-to-service calls + +Cron jobs, workers, `pg_net`, and other Edge Functions make calls with a secret key on the `apikey` header rather than a user JWT. Disable `verify_jwt` and use `auth: 'secret'`. The wrapper validates the key against the secret key named `default` in your [project's API keys](/dashboard/project/_/settings/api-keys), and gives your handler `ctx.supabaseAdmin` for privileged work. ```ts import { withSupabase } from 'npm:@supabase/server@1' @@ -57,13 +77,13 @@ export default { -To accept only one specific key, use `auth: 'secret:'`. For example, `auth: 'secret:automations'` accepts only the secret key named `automations`. To name a key, open [**Settings > API keys**](/dashboard/project/_/settings/api-keys) in the Supabase Dashboard. The same syntax works for publishable keys: `auth: 'publishable:'`. +`auth: 'secret'` accepts only the key named `default`. To accept a different key, use `auth: 'secret:'`. For example, `auth: 'secret:automations'` accepts only the secret key named `automations`. To accept any secret key on the project, use `auth: 'secret:*'`. Every project starts with a secret key named `default`, and you can add more. To name a new key, open [**Settings > API keys**](/dashboard/project/_/settings/api-keys) in the Supabase Dashboard. The same syntax works for publishable keys: `auth: 'publishable:'` and `auth: 'publishable:*'`. ![The Secret keys section of the Supabase Dashboard. A table with Name and API key columns lists two keys, "default" and "automations". Each row shows a masked sb_secret_ value with reveal and copy buttons.](/docs/img/guides/functions/secret-keys-automations.png) -## Public functions +### Public functions For a genuinely public function, such as a health check, use `auth: 'none'` with `verify_jwt = false` so anonymous callers can reach the handler. @@ -85,7 +105,7 @@ export default { `auth: 'none'` accepts every caller. For a function that authenticates callers itself, see the [External webhooks](#external-webhooks) section of this page. -## External webhooks +### External webhooks External providers such as Stripe or GitHub don't send Supabase credentials. They sign the request body with their own shared secret. Use `auth: 'none'` to skip the wrapper's credential check, then verify the provider's signature inside the handler. Keep `verify_jwt = false`. @@ -131,7 +151,7 @@ export default { -## Combining modes +### Combining modes Functions that answer both users and internal callers take an array on `auth`. The wrapper tries each mode in order and uses the first one that matches. `ctx.authMode` tells you which mode matched. @@ -151,7 +171,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 instead of rejecting the request for you. 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 81f7b228c27..70b24e7c33d 100644 --- a/apps/docs/content/guides/local-development/cli/getting-started.mdx +++ b/apps/docs/content/guides/local-development/cli/getting-started.mdx @@ -4,25 +4,34 @@ description: 'The Supabase CLI provides tools to develop your project locally, d subtitle: 'Develop locally, deploy to the Supabase Platform, and set up CI/CD workflows' --- -The Supabase CLI enables you to run the entire Supabase stack locally, on your machine or in a CI environment. With two commands, you can set up and start a new local project: +The Supabase CLI runs the entire Supabase stack on your own machine or in a CI environment, so you can build and test locally, then connect your project to a hosted one when you're ready to deploy. -1. `supabase init` to create a new local project -2. `supabase start` to launch the Supabase services +- [Quickstart](#quickstart) is the two-command version, if you already have the CLI and a container runtime. +- [Set up a local project](#set-up-a-local-project) installs the CLI and brings the stack up on your machine. Start here if you haven't run it before. +- [Connect to a hosted project](#connect-to-a-hosted-project) links your local directory to a project on the Supabase Platform. +- [Change your CLI version](#change-your-cli-version) updates the CLI and switches between stable and pre-release builds. +- [Telemetry](#telemetry) covers what the CLI collects and how to opt out. - +## Quickstart -There are two ways to install the CLI, and they change the command you type: +With two commands, you can set up and start a new local project: -- **Project dependency** with `npm`, `pnpm`, or `yarn` installs the CLI into a single project (there is no global `supabase` command with this method). Run it through your package runner instead, for example `npx supabase `. -- **Global install** with Homebrew, Scoop, or Linux packages. Run commands as `supabase `. +1. Run `supabase init` to create a new local project. +2. Run `supabase start` to launch the Supabase services. -Either way, the CLI is **project-scoped**: most commands (including `start`) expect to run inside a directory that has been initialized with `supabase init`, which creates the `supabase/` folder and `config.toml`. Run `init` first, then the other commands from the same directory. + -The rest of this page writes examples as `supabase `; translate them to `npx supabase ` if you installed the CLI as a project dependency. +How you run a CLI command depends on how you install it. Homebrew, Scoop, and the Linux packages give you a global `supabase` command. Installing with npm, pnpm, yarn, or bun adds the CLI to one project instead, so you run it through your package runner as `npx supabase `. This page writes every example as `supabase `. + +The CLI is project-scoped either way. Most commands, including `start`, expect a directory that `supabase init` has already set up with a `supabase` directory and a `config.toml`. Run `init` first, then the rest from the same directory. -## Installing the Supabase CLI +## Set up a local project + +Install the CLI, bring the Supabase stack up on your machine, and stop it when you're done. + +### Install the Supabase CLI [#installing-the-supabase-cli] `; translate them to > -Install the CLI as a project dev dependency. This adds it to a single project rather than installing a global command: +1. Install the CLI as a project dev dependency. This command adds the CLI to a single project rather than installing a global command: -```sh -npm install supabase --save-dev -# or: pnpm add -D supabase / yarn add -D supabase / bun add -D supabase -``` + ```sh + npm install supabase --save-dev + # or: pnpm add -D supabase / yarn add -D supabase / bun add -D supabase + ``` -Pin the version in `package.json` so your whole team uses the same CLI version. Then run every command through your package runner: +2. Pin the version in `package.json` so your whole team uses the same CLI version. -```sh -npx supabase --help -# or: pnpm supabase / yarn supabase / bunx supabase -``` +3. Run the CLI through your package runner: + + ```sh + npx supabase --help + # or: pnpm supabase / yarn supabase / bunx supabase + ``` @@ -86,19 +97,193 @@ brew install supabase/tap/supabase #### Linux packages Linux packages are provided in [Releases](https://github.com/supabase/cli/releases). -To install, download the `.apk`/`.deb`/`.rpm` file depending on your package manager -and run one of the following: -- `sudo apk add --allow-untrusted <...>.apk` -- `sudo dpkg -i <...>.deb` -- `sudo rpm -i <...>.rpm` +1. Download the `.apk`, `.deb`, or `.rpm` file for your package manager. +2. Install the package: + - `sudo apk add --allow-untrusted <...>.apk` + - `sudo dpkg -i <...>.deb` + - `sudo rpm -i <...>.rpm` -## Beta channel +### Run a local Supabase project [#running-a-local-supabase-project] -Pre-release CLI builds ship from the development branch (`X.Y.Z-beta.N` versions). Use the npm `beta` dist-tag, or install `supabase-beta` via Homebrew / Scoop (separate packages from stable). +The most common thing you'll do with the CLI is run the full Supabase stack (Postgres, Auth, Storage, and the rest) on your own machine. That stack runs in Docker containers, so you need a container runtime installed first. On Windows and Linux, follow the official guide to install and configure [Docker Desktop](https://docs.docker.com/desktop). + +On macOS, we recommend [OrbStack](https://orbstack.dev/) instead of Docker Desktop. It's a drop-in replacement that handles extended file attributes (xattrs) on mounted volumes and container networking more reliably than Docker Desktop. It also starts faster and uses less CPU, memory, and disk, which makes a noticeable difference when running the full Supabase stack. + +Alternatively, you can use a different container tool that offers Docker-compatible APIs: + +| Tool | Platforms | +| --------------------------------------------- | --------------------- | +| [Rancher Desktop](https://rancherdesktop.io/) | macOS, Windows, Linux | +| [Podman](https://podman.io/) | macOS, Windows, Linux | +| [colima](https://github.com/abiosoft/colima) | macOS | + +To bring the stack up: + +1. Start your container runtime. + +2. Open a terminal in the directory where you want to create your project. + +3. Initialize the project: + + ```bash + supabase init + ``` + + The command creates a `supabase` directory, which is safe to commit to version control. `init` creates local files only: it doesn't sign you in or connect the directory to a project on the Supabase Platform. To connect one, see [Connect to a hosted project](#connect-to-a-hosted-project). + +4. Start the Supabase services from the same directory: + + ```bash + supabase start + ``` + + + +If you installed the CLI as a project dependency with npm, pnpm, yarn, or bun, run `npx supabase init` and `npx supabase start` instead. See [Install the Supabase CLI](#installing-the-supabase-cli). + + + +The first run takes time while the CLI downloads the Docker images. It pulls the entire Supabase stack, plus a few extra images useful for local development, such as a local SMTP server and a database diff tool. + +### Access your project's services + +After all the Supabase services are running, the CLI prints your local credentials. The output looks like this, with the URLs and keys you use in your local project: + +``` +Started supabase local development setup. + +╭──────────────────────────────────────╮ +│ 🔧 Development Tools │ +├─────────┬────────────────────────────┤ +│ Studio │ http://127.0.0.1:54323 │ +│ Mailpit │ http://127.0.0.1:54324 │ +│ MCP │ http://127.0.0.1:54321/mcp │ +╰─────────┴────────────────────────────╯ + +╭──────────────────────────────────────────────────────╮ +│ 🌐 APIs │ +├────────────────┬─────────────────────────────────────┤ +│ Project URL │ http://127.0.0.1:54321 │ +│ REST │ http://127.0.0.1:54321/rest/v1 │ +│ GraphQL │ http://127.0.0.1:54321/graphql/v1 │ +│ Edge Functions │ http://127.0.0.1:54321/functions/v1 │ +╰────────────────┴─────────────────────────────────────╯ + +╭───────────────────────────────────────────────────────────────╮ +│ ⛁ Database │ +├─────┬─────────────────────────────────────────────────────────┤ +│ URL │ postgresql://postgres:postgres@127.0.0.1:54322/postgres │ +╰─────┴─────────────────────────────────────────────────────────╯ + +╭──────────────────────────────────────────────────────────────╮ +│ 🔑 Authentication Keys │ +├─────────────┬────────────────────────────────────────────────┤ +│ Publishable │ sb_publishable_... │ +│ Secret │ sb_secret_... │ +╰─────────────┴────────────────────────────────────────────────╯ +``` + + + + +```sh +# Default URL: +http://localhost:54323 +``` + +The local development environment includes Supabase Studio, a graphical interface for querying and editing your database. + +![Local Supabase Studio showing the Default Project home page, with a sidebar of section icons, a Client libraries row for JavaScript, Python, and Flutter, and a grid of example project cards.](/docs/img/guides/cli/local-studio.png) + + + + +```sh +# Default URL: +postgresql://postgres:postgres@localhost:54322/postgres +``` + +Access the local Postgres instance with [`psql`](https://www.postgresql.org/docs/current/app-psql.html) or any other Postgres client, such as [pgAdmin](https://www.pgadmin.org/). For example: + +```bash +psql 'postgresql://postgres:postgres@localhost:54322/postgres' +``` + + + +To access the database from an edge function in your local Supabase setup, replace `localhost` with `host.docker.internal`. + + + + + + +```sh +# Default URL: +http://localhost:54321 +``` + +If you are accessing these services without the client libraries, you might need to pass the client keys as an `Authorization` header. Learn more about [JWT headers](/docs/learn/auth-deep-dive/auth-deep-dive-jwts). + +```sh +curl 'http://localhost:54321/rest/v1/' \ + -H "apikey: sb_publishable_..." + +http://localhost:54321/rest/v1/ # REST (PostgREST) +http://localhost:54321/realtime/v1/ # Realtime +http://localhost:54321/storage/v1/ # Storage +http://localhost:54321/auth/v1/ # Auth (GoTrue) +``` + + + + +Local logs rely on the Supabase Analytics Server, which reads the Docker logging driver. On Linux and macOS, mount the `/var/run/docker.sock` domain socket. On Windows, expose the `tcp://localhost:2375` daemon socket. Configure the socket manually after [installing the CLI](#installing-the-supabase-cli). + + + +For advanced logs analysis using the Logs Explorer, use the BigQuery backend instead of the default Postgres backend. See [Using the BigQuery backend](/docs/reference/self-hosting-analytics/introduction#using-the-bigquery-backend). + + + +All logs are stored in the local database under the `_analytics` schema. + + + + +### Stop local services [#stopping-local-services] + +When you finish working, stop the stack. Stopping doesn't reset your local database: + +```bash +supabase stop +``` + +With the default ports, `supabase start` runs one local project per machine, because every project's `config.toml` uses the same ports. To run several local projects or git worktrees at the same time, see [Running multiple local projects](/docs/guides/local-development/running-multiple-local-projects). + +## Connect to a hosted project + +The stack you started in [Run a local Supabase project](#running-a-local-supabase-project) runs only on your machine. Nothing reaches a hosted project until you sign in to the CLI and link your local directory to one. + +For the steps, see [Pushing to a remote project](/docs/guides/local-development/cli-workflows#pushing-to-a-remote-project). To run deployments from CI, see [Configure GitHub Actions](/docs/guides/deployment/managing-environments#configure-github-actions). + +## Change your CLI version + +These sections cover the CLI itself rather than your local project. + +### Use the beta channel [#beta-channel] + +Pre-release CLI builds ship from the development branch and are versioned `X.Y.Z-beta.N`. Use the npm `beta` dist-tag, or install `supabase-beta` with Homebrew or Scoop. `supabase-beta` is a separate package from `supabase`. -## Updating the Supabase CLI +### Update the Supabase CLI [#updating-the-supabase-cli] -When a new [version](https://github.com/supabase/cli/releases) is released, you can update the CLI using the same channels. +Update the CLI with the same package manager you installed it with. See the [CLI releases page](https://github.com/supabase/cli/releases) for available versions. .apk` - `sudo dpkg -i <...>.deb` @@ -231,181 +416,31 @@ brew upgrade supabase-beta -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. +If you have any Supabase containers running locally, stop them and delete their data volumes before upgrading. Deleting the volumes lets Supabase managed services apply new migrations on a clean local database. - + -Remember to save any local schema and data changes before stopping because the `--no-backup` flag will delete them. - -```sh -supabase db diff -f my_schema -supabase db dump --local --data-only > supabase/seed.sql -supabase stop --no-backup -``` +`supabase stop --no-backup` deletes your local schema and data changes. Save them first. -## Running a local Supabase project +1. Save local schema changes as a migration: -The most common thing you'll do with the CLI is run the full Supabase stack (Postgres, Auth, Storage, and the rest) on your own machine. That stack runs in Docker containers, so you need a container runtime installed first. On Windows and Linux, follow the official guide to install and configure [Docker Desktop](https://docs.docker.com/desktop). + ```sh + supabase db diff -f my_schema + ``` -On macOS, we recommend [OrbStack](https://orbstack.dev/) instead of Docker Desktop. It's a drop-in replacement that handles extended file attributes (xattrs) on mounted volumes and container networking more reliably than Docker Desktop. It also starts faster and uses less CPU, memory, and disk, which makes a noticeable difference when running the full Supabase stack. +2. Dump local data to your seed file: -Alternately, you can use a different container tool that offers Docker compatible APIs. + ```sh + supabase db dump --local --data-only > supabase/seed.sql + ``` -- [Rancher Desktop](https://rancherdesktop.io/) (macOS, Windows, Linux) -- [Podman](https://podman.io/) (macOS, Windows, Linux) -- [colima](https://github.com/abiosoft/colima) (macOS) +3. Stop the containers and delete their data volumes: -With a container runtime running, go to the folder where you want to create your project and initialize it: - -```bash -supabase init -``` - -This creates a new `supabase` folder. It's safe to commit this folder to version control. - -Now, from the same folder, start the Supabase stack: - -```bash -supabase start -``` - - - -If you installed the CLI as a project dependency (npm, pnpm, yarn, or bun), run these as `npx supabase init` and `npx supabase start` instead. See the [note above](#installing-the-supabase-cli). - - - -This takes time on your first run because the CLI needs to download the Docker images to your local machine. The CLI includes the entire Supabase stack, and a few additional images useful for local development (like a local SMTP server and a database diff tool). - -## Access your project's services - -Once all the Supabase services are running, you'll see output containing your local Supabase credentials. It should look like the below, with urls and keys that you use in your local project: - -``` -Started supabase local development setup. - -╭──────────────────────────────────────╮ -│ 🔧 Development Tools │ -├─────────┬────────────────────────────┤ -│ Studio │ http://127.0.0.1:54323 │ -│ Mailpit │ http://127.0.0.1:54324 │ -│ MCP │ http://127.0.0.1:54321/mcp │ -╰─────────┴────────────────────────────╯ - -╭──────────────────────────────────────────────────────╮ -│ 🌐 APIs │ -├────────────────┬─────────────────────────────────────┤ -│ Project URL │ http://127.0.0.1:54321 │ -│ REST │ http://127.0.0.1:54321/rest/v1 │ -│ GraphQL │ http://127.0.0.1:54321/graphql/v1 │ -│ Edge Functions │ http://127.0.0.1:54321/functions/v1 │ -╰────────────────┴─────────────────────────────────────╯ - -╭───────────────────────────────────────────────────────────────╮ -│ ⛁ Database │ -├─────┬─────────────────────────────────────────────────────────┤ -│ URL │ postgresql://postgres:postgres@127.0.0.1:54322/postgres │ -╰─────┴─────────────────────────────────────────────────────────╯ - -╭──────────────────────────────────────────────────────────────╮ -│ 🔑 Authentication Keys │ -├─────────────┬────────────────────────────────────────────────┤ -│ Publishable │ sb_publishable_... │ -│ Secret │ sb_secret_... │ -╰─────────────┴────────────────────────────────────────────────╯ -``` - - - - -```sh -# Default URL: -http://localhost:54323 -``` - -The local development environment includes Supabase Studio, a graphical interface for working with your database. - -![Local Studio](/docs/img/guides/cli/local-studio.png) - - - - -```sh -# Default URL: -postgresql://postgres:postgres@localhost:54322/postgres -``` - -The local Postgres instance can be accessed through [`psql`](https://www.postgresql.org/docs/current/app-psql.html) or any other Postgres client, such as [pgAdmin](https://www.pgadmin.org/). For example: - -```bash -psql 'postgresql://postgres:postgres@localhost:54322/postgres' -``` - - - -To access the database from an edge function in your local Supabase setup, replace `localhost` with `host.docker.internal`. - - - - - - -```sh -# Default URL: -http://localhost:54321 -``` - -If you are accessing these services without the client libraries, you may need to pass the client keys as an `Authorization` header. Learn more about [JWT headers](/docs/learn/auth-deep-dive/auth-deep-dive-jwts). - -```sh -curl 'http://localhost:54321/rest/v1/' \ - -H "apikey: sb_publishable_..." - -http://localhost:54321/rest/v1/ # REST (PostgREST) -http://localhost:54321/realtime/v1/ # Realtime -http://localhost:54321/storage/v1/ # Storage -http://localhost:54321/auth/v1/ # Auth (GoTrue) -``` - - - -`sb_publishable_...` is the publishable key output when you run the command `supabase start`. - - - - - - -Local logs rely on the Supabase Analytics Server which accesses the docker logging driver by either volume mounting `/var/run/docker.sock` domain socket on Linux and macOS, or exposing `tcp://localhost:2375` daemon socket on Windows. These settings must be configured manually after [installing](/docs/guides/local-development/cli/getting-started#installing-the-supabase-cli) the Supabase CLI. - - - -For advanced logs analysis using the Logs Explorer, it is advised to use the BigQuery backend instead of the default Postgres backend. Read about the steps [here](/docs/reference/self-hosting-analytics/introduction#using-the-bigquery-backend). - - - -All logs are stored in the local database under the `_analytics` schema. - - - - -## Stopping local services - -When you are finished working on your Supabase project, you can stop the stack (without resetting your local database): - -```bash -supabase stop -``` - -With the default ports, `supabase start` runs one local project per machine, because every project's `config.toml` uses the same ports. To run several local projects or git worktrees at the same time, see [Running multiple local projects](/docs/guides/local-development/running-multiple-local-projects). + ```sh + supabase stop --no-backup + ``` ## Telemetry @@ -413,18 +448,19 @@ The Supabase CLI collects telemetry data about general usage. Participating in t ### How to opt out -You can disable telemetry by running: +1. Disable telemetry: -```bash -supabase telemetry disable -``` + ```bash + supabase telemetry disable + ``` -You can check the current status and re-enable with: +2. Confirm the current setting: -```bash -supabase telemetry status -supabase telemetry enable -``` + ```bash + supabase telemetry status + ``` + +To enable telemetry again, run `supabase telemetry enable`. You can also opt out using the `SUPABASE_TELEMETRY_DISABLED=1` environment variable. The broader `DO_NOT_TRACK=1` convention is also respected. diff --git a/apps/docs/content/guides/local-development/docker-and-native-runtimes.mdx b/apps/docs/content/guides/local-development/docker-and-native-runtimes.mdx index ec1da3c4bc3..113bc12b773 100644 --- a/apps/docs/content/guides/local-development/docker-and-native-runtimes.mdx +++ b/apps/docs/content/guides/local-development/docker-and-native-runtimes.mdx @@ -30,10 +30,12 @@ The following table compares how each runtime runs services, what it needs, and | Flag | `--runtime docker` or `--runtime podman` | `--runtime native` | | How services run | One container per service, from images the CLI pulls | One process per service, from archives the CLI downloads and verifies | | Requires | A running Docker or Podman engine | A supported platform, `tar` on the host, and, on first start, network access to the archive download hosts | -| Database data | A Docker volume when the Docker client and daemon are both version 26 or later, or a host directory otherwise | `~/.supabase/stacks//` in your home directory | +| Database data | Set when the local project is created. A Docker volume if the Docker client and daemon are both version 26 or later, otherwise a host directory | `~/.supabase/stacks//` in your home directory | | Downloaded artifacts | The engine's image cache | `~/.supabase/cache/stack` in your home directory, shared by all local projects | | Isolation between local projects | Separate network namespaces and file systems | Shared host process table and file locks | +A Docker local project keeps the database storage it was created with. A local project created on Docker 25 keeps its host directory after you upgrade to Docker 26. + Both runtimes keep each local project's saved settings under `~/.supabase/stacks//`. The native runtime keeps its database data there too. If you set `SUPABASE_HOME`, the CLI uses that directory instead of `~/.supabase`. In both runtimes, Postgres starts right away and other services start on their first request. By default, services that started this way stop again after 60 seconds idle, and Studio after 5 minutes. Functions doesn't stop on its own. A service also stays up while a running service depends on it, such as pgmeta while Studio runs. Pass `--eager` to start every enabled service before the command returns and turn off idle stops. @@ -69,7 +71,7 @@ The native runtime runs on these platforms: The native runtime doesn't run as `root`, because Postgres `initdb` can't. To run it as `root`, such as in a CI container, set `SUPABASE_NATIVE_POSTGRES_USER` to a non-root account. Create the account first if it doesn't exist. The CLI then runs Postgres as that user. In some supported coding agent sandboxes, the CLI finds a suitable non-root account without the variable. -On first start, the CLI downloads service archives from the [`supabase/slim-services` GitHub releases](https://github.com/supabase/slim-services/releases), with `supabase-cli-artifacts.s3.us-east-1.amazonaws.com` as a fallback. It verifies their checksums and extracts them with the system `tar`, so install `tar` in minimal sandbox images. In a sandbox with an allowlist, allow `github.com` or that S3 host. Allowing only `ghcr.io` isn't enough. +On first start, the CLI downloads service archives from the [`supabase/slim-services` GitHub releases](https://github.com/supabase/slim-services/releases), with `supabase-cli-artifacts.s3.us-east-1.amazonaws.com` as a fallback. It verifies their checksums and extracts them with the system `tar`, so install `tar` in minimal sandbox images. In a sandbox with an allowlist, allow the S3 host. To use GitHub instead, allow both `github.com` and `release-assets.githubusercontent.com`, because GitHub redirects release downloads to that host. Allowing only `ghcr.io` isn't enough. To download the archives ahead of time without starting any services, such as when you build a sandbox image, run: diff --git a/apps/docs/content/guides/local-development/running-multiple-local-projects.mdx b/apps/docs/content/guides/local-development/running-multiple-local-projects.mdx index b1766586047..e718ac1c1b8 100644 --- a/apps/docs/content/guides/local-development/running-multiple-local-projects.mdx +++ b/apps/docs/content/guides/local-development/running-multiple-local-projects.mdx @@ -57,13 +57,15 @@ You need: The `supabase stack` commands exist only when the `[experimental] stack` setting is on. Without it, `supabase stack` commands fail with `Unknown subcommand "stack"`. -1. Add this to `supabase/config.toml` in your app directory: +1. In `supabase/config.toml` in your app directory, add `stack = true` under the existing `[experimental]` table. If your `config.toml` doesn't have an `[experimental]` table, add one: ```toml [experimental] stack = true ``` + Don't add a second `[experimental]` table. A duplicate table makes `config.toml` invalid. The stack commands then stay off. Commands that read the config fail with `failed to load config`. + In a directory without a `config.toml`, set the environment variable instead: ```bash @@ -166,7 +168,7 @@ One app can run several local projects by name. This keeps a destructive test ru A local project started without `--stack` is the app directory's default local project. Named local projects and the default one are independent of each other. -The `db`, `migration`, and other database commands use the default local project for the current branch. They can't target a named local project. See [Limitations](#limitations). +The `db`, `migration`, and other database commands don't accept `--stack`. When they run against a local database, they use the default local project for the current branch. `supabase db pull`, `supabase db push`, and `supabase db dump` target the linked project unless you pass `--local`. To reach a named local project from a command that accepts `--db-url`, pass the `DB_URL` from `supabase status --stack --env`. See [Limitations](#limitations). ## Find a local project's endpoints and keys @@ -253,9 +255,9 @@ supabase stack destroy --stack test --yes `destroy` keeps two kinds of data: - Storage upload files, which live in your app at `supabase/.temp/stack-uploads//`. Delete that directory to remove them. -- Container engine resources, if Docker or Podman isn't running. If no process for the local project is still running, the command removes the local project's registration. It prints cleanup commands to run after the engine starts again. Otherwise it fails and asks you to start the engine and retry. +- Container engine resources, if Docker or Podman isn't running. The command can still remove the local project's registration if no process for it is still running and your user can delete its database files on the host. It then prints cleanup commands to run after the engine starts again. Otherwise it fails, keeps the local project, and asks you to start the engine and retry. Your user might not be able to delete database files that a container wrote to a host directory. Local projects created with Docker version 25 or earlier store data that way. -The printed cleanup command deletes only this local project's database data, from a Docker volume that other local projects share. Don't delete the volume itself. +If the local project stores its database data in a Docker volume, other local projects share that volume. The printed cleanup command deletes only this local project's data from it. Don't delete the volume itself. ## Differences from the default `supabase start` @@ -275,7 +277,7 @@ Keep these differences in mind when you turn the setting on for an app that used - Storage Analytics and Storage vector buckets - A custom `edge_runtime.deno_version` - OrioleDB, and `db.major_version` values other than 15 and 17 -- Database commands can't target a named local project. `supabase db reset`, `supabase db diff`, `supabase db pull`, `supabase db dump`, and the other local database commands use the app directory's default local project for the current branch, and don't accept `--stack`. +- Database commands don't accept `--stack`. `supabase db reset` and `supabase db diff` run against the current branch's default local project. `supabase db pull`, `supabase db push`, and `supabase db dump` target the linked project unless you pass `--local`. To reach a named local project, pass the `DB_URL` from `supabase status --stack --env` to a command that accepts `--db-url`. - Switching branches switches local projects. The git branch is part of the identity. After you check out another branch in the same directory, `supabase start` creates or resumes a different local project with its own data. The first branch's local project keeps running until you stop it. - Moving or renaming an app directory creates a new local project, because the identity includes the project root path. The previous local project's data stays until you destroy it. - `supabase stack logs` streams new log lines only. It has no history. diff --git a/apps/docs/content/guides/platform/access-control.mdx b/apps/docs/content/guides/platform/access-control.mdx index 73bea005159..36717e0e9e8 100644 --- a/apps/docs/content/guides/platform/access-control.mdx +++ b/apps/docs/content/guides/platform/access-control.mdx @@ -11,10 +11,11 @@ For each organization and project, a member can have one of the following roles: - **Administrator**: full access to everything in organization and project resources **except** updating organization settings, transferring projects outside of the organization, and adding new owners. - **Developer**: read-only access to organization resources and content access to project resources but cannot change any project settings. - **Read-Only**: read-only access to organization and project resources. +- **No-access**: no access to any organization or project resources by default. Use this for members who should only see the specific projects they're granted a [project-scoped role](#organization-scoped-roles-vs-project-scoped-roles) on. -Read-Only role is only available on the [Team and Enterprise plans](/pricing). +Read-Only and No-access roles are only available on the [Team and Enterprise plans](/pricing). @@ -67,6 +68,8 @@ Each member in the organization can be assigned a role that is scoped either to This allows for more granular control, ensuring that users only have visibility and access to the projects relevant to their role. +To default a member to zero project access and grant access on a per-project basis, assign them the organization-level **No access** role, then grant project-scoped roles for the specific projects they need. The **No-access** role is omitted from the permission tables below since it has none of the listed permissions by default. + ### Organization permissions across roles The table below shows the actions each role can take on the resources belonging to the organization. diff --git a/apps/docs/content/troubleshooting/how-to-interpret-and-explore-the-postgres-logs-OuCIOj.mdx b/apps/docs/content/troubleshooting/how-to-interpret-and-explore-the-postgres-logs-OuCIOj.mdx index d588c5a938f..c0ab18146cb 100644 --- a/apps/docs/content/troubleshooting/how-to-interpret-and-explore-the-postgres-logs-OuCIOj.mdx +++ b/apps/docs/content/troubleshooting/how-to-interpret-and-explore-the-postgres-logs-OuCIOj.mdx @@ -369,7 +369,7 @@ By default, only failed queries are logged. The [PGAudit extension](/docs/guides #### Logging within database functions -To track or debug functions, logging can be configured by following the [function debugging guide](/docs/guides/database/functions#general-logging) +To track or debug functions, logging can be configured by following the [function debugging guide](/docs/guides/database/debugging-functions#general-logging) ## Frequently Asked Questions @@ -389,7 +389,7 @@ To see the default types of events that are logged, you can check this [guide](h - [Regex for filtering logs](https://github.com/orgs/supabase/discussions/22640) - [Debugging with the DB API logs](https://github.com/orgs/supabase/discussions/22849) -- [Debugging Database Functions](/docs/guides/database/functions#debugging-functions) +- [Debugging Database Functions](/docs/guides/database/debugging-functions) - [pg_audit](/docs/guides/database/extensions/pgaudit) - [Supabase Logging](/docs/guides/observability/logs) - [Self-Hosting Logs](/docs/reference/self-hosting-analytics/introduction) diff --git a/apps/docs/content/troubleshooting/understanding-postgresql-logging-levels-and-how-they-impact-your-project-KXiJRm.mdx b/apps/docs/content/troubleshooting/understanding-postgresql-logging-levels-and-how-they-impact-your-project-KXiJRm.mdx index 83159679774..4f6ca7227c6 100644 --- a/apps/docs/content/troubleshooting/understanding-postgresql-logging-levels-and-how-they-impact-your-project-KXiJRm.mdx +++ b/apps/docs/content/troubleshooting/understanding-postgresql-logging-levels-and-how-they-impact-your-project-KXiJRm.mdx @@ -117,4 +117,4 @@ We support PGAudit extension, which extends Postgres’s built-in logging capabi For detailed configuration instructions and logging options, refer to the complete documentation: [PGAudit Configuration Guide](/docs/guides/database/extensions/pgaudit?queryGroups=database-method&database-method=dashboard#configure-the-extension) **c. Debugging Functions** -For more information on how to debug functions in Supabase, refer to the official guide: [Debugging Functions](/docs/guides/database/functions?queryGroups=language&language=js#debugging-functions). +For more information on how to debug functions in Supabase, refer to the official guide: [Debugging Functions](/docs/guides/database/debugging-functions). diff --git a/apps/docs/data/content-listings/functions.data.ts b/apps/docs/data/content-listings/functions.data.ts index bb69d870704..7400312387c 100644 --- a/apps/docs/data/content-listings/functions.data.ts +++ b/apps/docs/data/content-listings/functions.data.ts @@ -21,9 +21,9 @@ export const functionsExamplesSupabase: ContentListingGroup = { type: 'grid', items: [ { - title: 'With supabase-js', + title: 'Securing Edge Functions', href: '/guides/functions/auth', - description: 'Use the Supabase client inside your Edge Function.', + description: 'Declare which credentials your Edge Function accepts.', }, { title: 'Connect to Postgres', diff --git a/apps/docs/features/SearchV2/SearchV2.utils.test.tsx b/apps/docs/features/SearchV2/SearchV2.utils.test.tsx index d79b031082c..632dafa905b 100644 --- a/apps/docs/features/SearchV2/SearchV2.utils.test.tsx +++ b/apps/docs/features/SearchV2/SearchV2.utils.test.tsx @@ -20,13 +20,13 @@ describe('formatHeadingPath', () => { function renderHighlight(text: string, query: string) { const result = highlightMatches(text, query) - if (typeof result === 'string') return { html: result, strongTexts: [] as string[] } + if (typeof result === 'string') return { html: result, markTexts: [] as string[] } const html = renderToStaticMarkup(<>{result}) const $ = load(html) return { html: $.root().text(), - strongTexts: $('strong') + markTexts: $('mark') .map((_, el) => $(el).text()) .get(), } @@ -37,19 +37,19 @@ describe('highlightMatches', () => { const result = highlightMatches('Bring your own MCP', 'mcp server') expect(typeof result).not.toBe('string') - const { strongTexts, html } = renderHighlight('Bring your own MCP', 'mcp server') - expect(strongTexts).toEqual(['MCP']) + const { markTexts, html } = renderHighlight('Bring your own MCP', 'mcp server') + expect(markTexts).toEqual(['MCP']) expect(html).toBe('Bring your own MCP') }) it('highlights multiple non-overlapping token matches independently', () => { - const { strongTexts } = renderHighlight('MCP servers for your server', 'mcp server') - expect(strongTexts).toEqual(['MCP', 'server', 'server']) + const { markTexts } = renderHighlight('MCP servers for your server', 'mcp server') + expect(markTexts).toEqual(['MCP', 'server', 'server']) }) it('merges overlapping/adjacent matches into a single run', () => { - const { strongTexts } = renderHighlight('server', 'server serv') - expect(strongTexts).toEqual(['server']) + const { markTexts } = renderHighlight('server', 'server serv') + expect(markTexts).toEqual(['server']) }) it('returns the original string unchanged when there is no match', () => { @@ -58,8 +58,8 @@ describe('highlightMatches', () => { }) it('is case-insensitive but preserves the original casing of the matched text', () => { - const { strongTexts } = renderHighlight('Bring your own MCP', 'MCP') - expect(strongTexts).toEqual(['MCP']) + const { markTexts } = renderHighlight('Bring your own MCP', 'MCP') + expect(markTexts).toEqual(['MCP']) }) it('returns the text unchanged for an empty or whitespace-only query', () => { @@ -68,8 +68,8 @@ describe('highlightMatches', () => { }) it('excludes common prepositions/articles/conjunctions from highlighting', () => { - const { strongTexts } = renderHighlight('The best MCP server for you', 'the mcp server') - expect(strongTexts).toEqual(['MCP', 'server']) + const { markTexts } = renderHighlight('The best MCP server for you', 'the mcp server') + expect(markTexts).toEqual(['MCP', 'server']) }) it('returns the text unchanged when the query is made up entirely of ignored words', () => { diff --git a/apps/docs/features/SearchV2/SearchV2.utils.tsx b/apps/docs/features/SearchV2/SearchV2.utils.tsx index d7db0f83e3c..b309ec1f7df 100644 --- a/apps/docs/features/SearchV2/SearchV2.utils.tsx +++ b/apps/docs/features/SearchV2/SearchV2.utils.tsx @@ -1,5 +1,13 @@ +import type { useDocsSearchV2 } from 'common' import type { ReactNode } from 'react' +type DocsSearchV2State = ReturnType['searchState'] + +interface GetIsSearchingParams { + searchState: DocsSearchV2State + query: string +} + /** Common English prepositions/articles/conjunctions, excluded from highlighting so a query like "the mcp server" doesn't bold "the". */ const IGNORED_WORDS = new Set([ 'a', @@ -65,7 +73,7 @@ function getMatchRanges(text: string, query: string): Array<[number, number]> { /** * Highlight every case-insensitive, per-word partial match of `query` inside `text`. * Returns the plain string when there's no match, otherwise a fragment with matches - * wrapped in , preserving the original casing of `text`. + * wrapped in , preserving the original casing of `text`. */ function highlightMatches(text: string, query: string): ReactNode { const ranges = getMatchRanges(text, query) @@ -75,7 +83,11 @@ function highlightMatches(text: string, query: string): ReactNode { let cursor = 0 ranges.forEach(([start, end], i) => { if (start > cursor) nodes.push(text.slice(cursor, start)) - nodes.push({text.slice(start, end)}) + nodes.push( + + {text.slice(start, end)} + + ) cursor = end }) if (cursor < text.length) nodes.push(text.slice(cursor)) @@ -83,4 +95,16 @@ function highlightMatches(text: string, query: string): ReactNode { return <>{nodes} } -export { formatHeadingPath, highlightMatches } +// drives the loading beam: on while a request is in flight or about to be sent +function getIsSearching({ searchState, query }: GetIsSearchingParams): boolean { + if (searchState.status === 'loading') return true + if (searchState.status === 'error') return false + + const trimmedQuery = query.trim() + const settledQuery = 'query' in searchState ? searchState.query : null + + // so a search is on its way and the beam starts on the keystroke, not when the request fires + return trimmedQuery !== '' && trimmedQuery !== settledQuery +} + +export { formatHeadingPath, getIsSearching, highlightMatches } diff --git a/apps/docs/features/SearchV2/SearchV2Dialog.tsx b/apps/docs/features/SearchV2/SearchV2Dialog.tsx index ee12c97675a..e0ffd7f79c7 100644 --- a/apps/docs/features/SearchV2/SearchV2Dialog.tsx +++ b/apps/docs/features/SearchV2/SearchV2Dialog.tsx @@ -1,10 +1,10 @@ 'use client' +import { LoadingBeam } from '~/features/ui/LoadingBeam' import { useDocsSearchV2, type DocsSearchV2Result } from 'common' -import { Loader2 } from 'lucide-react' import { useRouter } from 'next/navigation' import { VisuallyHidden } from 'radix-ui' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState, type KeyboardEvent } from 'react' import { Command, CommandEmpty, @@ -16,34 +16,76 @@ import { DialogContent, DialogDescription, DialogTitle, + KeyboardShortcut, } from 'ui' -import { formatHeadingPath, highlightMatches } from './SearchV2.utils' +import { getIsSearching } from './SearchV2.utils' +import { SearchV2Result } from './SearchV2Result' +import { useSendTelemetryEvent } from '@/lib/telemetry' interface SearchV2DialogProps { open: boolean onOpenChange: (open: boolean) => void } +interface SearchV2PanelProps { + onResultSelect: (path: string) => void + onResultOpen: () => void +} + export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) { const router = useRouter() + + function handleSelect(path: string) { + router.push(path) + onOpenChange(false) + } + + function handleResultOpen() { + onOpenChange(false) + } + + return ( + + + + + + ) +} + +function SearchV2Panel({ onResultSelect, onResultOpen }: SearchV2PanelProps) { + const sendTelemetryEvent = useSendTelemetryEvent() const { searchState, handleDocsSearchDebounced, resetSearch } = useDocsSearchV2() - const [highlightQuery, setHighlightQuery] = useState('') + const [query, setQuery] = useState('') + const [isDeleting, setIsDeleting] = useState(false) + const inputRef = useRef(null) - // Clear stale results once the dialog closes - useEffect(() => { - if (!open) resetSearch() - }, [open, resetSearch]) + // highlight with the query the visible results belong to, not the one being typed + const highlightQuery = + 'query' in searchState + ? searchState.query + : 'staleQuery' in searchState + ? searchState.staleQuery + : '' - // Only update the highlighted query once a new result set actually lands, so highlights - // don't shift on every keystroke while the debounced search is still in flight. useEffect(() => { if (searchState.status === 'results' || searchState.status === 'noResults') { - setHighlightQuery(searchState.query) - } else if (searchState.status === 'initial') { - setHighlightQuery('') + sendTelemetryEvent({ + action: 'docs_search_v2_search_submitted', + properties: { query: searchState.query }, + }) } - }, [searchState]) + }, [searchState, sendTelemetryEvent]) const results: DocsSearchV2Result[] = 'results' in searchState @@ -52,7 +94,25 @@ export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) { ? searchState.staleResults : [] + const isListVisible = + results.length > 0 || searchState.status === 'noResults' || searchState.status === 'error' + const isSearching = getIsSearching({ searchState, query }) + + function handleClear() { + setQuery('') + resetSearch() + inputRef.current?.focus() + } + + // cmdk's root turns enter into "open highlighted result", so keep it on the button. + // other keys must bubble, or tab never reaches the dialog's focus trap + function handleClearKeyDown(event: KeyboardEvent) { + if (event.key === 'Enter') event.stopPropagation() + } + function handleValueChange(value: string) { + setIsDeleting(value.length < query.length) + setQuery(value) if (value) { handleDocsSearchDebounced(value) } else { @@ -61,8 +121,11 @@ export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) { } function handleSelect(path: string) { - router.push(path) - onOpenChange(false) + sendTelemetryEvent({ + action: 'docs_search_v2_result_clicked', + properties: { resultPath: path, query: highlightQuery }, + }) + onResultSelect(path) } // Announced via the aria-live region below — a sighted user sees the spinner/list update, @@ -79,69 +142,89 @@ export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) { } return ( - - {/* hideClose: this is a search box, not a form — closing is Escape/click-outside only, no "X" */} - - - - Search docs - Search the Supabase documentation - - - {/* + + + Search docs + Search the Supabase documentation + +
+ + {query ? ( + + ) : null} + +
+ {/* Screen-reader-only status announcement. Focus stays in the input as results come in (that's what lets people keep typing), so nothing else here gets read aloud on its own — this is what tells a screen reader user a search ran and how many results it found. */} -
- {getStatusMessage()} -
- - {searchState.status === 'initial' && ( - Start typing to search the docs. - )} - {searchState.status === 'loading' && results.length === 0 && ( -
-
- )} - {searchState.status === 'noResults' && No results found.} - {searchState.status === 'error' && ( - Something went wrong. Please try again. - )} - {results.length > 0 && ( - - {results.map((page) => ( - handleSelect(page.path)} - > -
- - {highlightMatches(formatHeadingPath(page.headingPath), highlightQuery)} - - {page.excerpt && ( - - {highlightMatches(page.excerpt, highlightQuery)} - - )} -
-
- ))} -
- )} -
-
-
-
+
+ {getStatusMessage()} +
+ + {searchState.status === 'noResults' && No results found.} + {searchState.status === 'error' && ( + Something went wrong. Please try again. + )} + {results.length > 0 && ( + + {results.map((result) => ( + + ))} + + )} + + {results.length > 0 ? : null} +
+ ) +} + +function SearchV2Footer() { + return ( +
+ + Navigate + + + + + + Open + + +
) } diff --git a/apps/docs/features/SearchV2/SearchV2Result.tsx b/apps/docs/features/SearchV2/SearchV2Result.tsx new file mode 100644 index 00000000000..d19147e9d8c --- /dev/null +++ b/apps/docs/features/SearchV2/SearchV2Result.tsx @@ -0,0 +1,61 @@ +'use client' + +import type { DocsSearchV2Result } from 'common' +import Link from 'next/link' +import { useRef, type MouseEvent } from 'react' +import { CommandItem } from 'ui' + +import { formatHeadingPath, highlightMatches } from './SearchV2.utils' + +interface SearchV2ResultProps { + result: DocsSearchV2Result + highlightQuery: string + onResultSelect: (path: string) => void + onResultOpen: () => void +} + +export function SearchV2Result({ + result, + highlightQuery, + onResultSelect, + onResultOpen, +}: SearchV2ResultProps) { + // clicks navigate via the link and also fire onSelect, so onSelect only navigates for Enter + const isPointerSelectRef = useRef(false) + + function handleLinkClick(event: MouseEvent) { + isPointerSelectRef.current = true + const isNewTab = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey + if (!isNewTab) onResultOpen() + } + + function handleSelect() { + if (isPointerSelectRef.current) { + isPointerSelectRef.current = false + return + } + onResultSelect(result.path) + } + + return ( + + +
+

+ {highlightMatches(formatHeadingPath(result.headingPath), highlightQuery)} +

+ {result.excerpt ? ( +

+ {highlightMatches(result.excerpt, highlightQuery)} +

+ ) : null} +
+ +
+ ) +} diff --git a/apps/docs/features/SearchV2/SearchV2Trigger.tsx b/apps/docs/features/SearchV2/SearchV2Trigger.tsx index ae91f54d1f8..fadb6e383cb 100644 --- a/apps/docs/features/SearchV2/SearchV2Trigger.tsx +++ b/apps/docs/features/SearchV2/SearchV2Trigger.tsx @@ -1,10 +1,11 @@ 'use client' import { Search } from 'lucide-react' -import { useEffect, useState, type ReactNode } from 'react' +import { useCallback, useEffect, useState, type ReactNode } from 'react' import { cn, KeyboardShortcut } from 'ui' import { SearchV2Dialog } from './SearchV2Dialog' +import { useSendTelemetryEvent } from '@/lib/telemetry' interface SearchV2TriggerProps { className?: string @@ -13,6 +14,17 @@ interface SearchV2TriggerProps { export function SearchV2Trigger({ className, placeholder = 'Search...' }: SearchV2TriggerProps) { const [open, setOpen] = useState(false) + const sendTelemetryEvent = useSendTelemetryEvent() + + const handleOpenChange = useCallback( + (next: boolean) => { + setOpen(next) + if (!next) { + sendTelemetryEvent({ action: 'docs_search_v2_closed' }) + } + }, + [sendTelemetryEvent] + ) useEffect(() => { function openOnKeyDown(event: globalThis.KeyboardEvent) { @@ -25,6 +37,10 @@ export function SearchV2Trigger({ className, placeholder = 'Search...' }: Search event.preventDefault() setOpen(true) + sendTelemetryEvent({ + action: 'docs_search_v2_opened', + properties: { triggerType: 'keyboard_shortcut' }, + }) } } @@ -33,7 +49,7 @@ export function SearchV2Trigger({ className, placeholder = 'Search...' }: Search return () => { window.removeEventListener('keydown', openOnKeyDown, { capture: true }) } - }, []) + }, [sendTelemetryEvent]) return ( <> @@ -42,34 +58,32 @@ export function SearchV2Trigger({ className, placeholder = 'Search...' }: Search tabIndex={0} aria-haspopup="dialog" aria-expanded={open} - onClick={() => setOpen(true)} + onClick={() => { + setOpen(true) + sendTelemetryEvent({ + action: 'docs_search_v2_opened', + properties: { triggerType: 'search_input' }, + }) + }} className={cn( - 'group cursor-pointer', + 'cursor-pointer', 'grow md:min-w-44 xl:min-w-56 h-[30px] rounded-md', - 'pl-1.5 md:pl-2 pr-1', + 'pl-2 pr-1', 'flex items-center justify-between', - 'bg-transparent text-foreground-lighter border border-strong', - 'hover:bg-popover hover:border-control-hover', + 'border border-default bg-surface-75 text-foreground-lighter shadow-(--shadow-codeblock)', + 'hover:border-strong hover:text-foreground-light', 'focus-ring', 'transition-colors', className )} > -
- -

{placeholder}

+
+ +

{placeholder}

- + - + ) } diff --git a/apps/docs/features/ui/LoadingBeam.tsx b/apps/docs/features/ui/LoadingBeam.tsx new file mode 100644 index 00000000000..e9dd73faf67 --- /dev/null +++ b/apps/docs/features/ui/LoadingBeam.tsx @@ -0,0 +1,33 @@ +'use client' + +import { useState } from 'react' +import { cn } from 'ui' + +export interface LoadingBeamProps { + isActive: boolean + direction?: 'forward' | 'backward' + className?: string +} + +export const LoadingBeam = ({ isActive, direction = 'forward', className }: LoadingBeamProps) => { + const [run, setRun] = useState(0) + const [runDirection, setRunDirection] = useState(direction) + const [wasActive, setWasActive] = useState(isActive) + if (isActive !== wasActive) { + setWasActive(isActive) + if (isActive) { + setRun((current) => current + 1) + setRunDirection(direction) + } + } + + return ( +
+ ) +} diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 8544ba1ef78..0ce95cc5169 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -38,6 +38,7 @@ Artyom Borissov Aziz Quadri Barco Fourie Barry Roodt +Ben Fritsch Beng Eu Benjamin Coenen Beth Long diff --git a/apps/docs/spec/reference/server/v1/partials/frameworks.mdx b/apps/docs/spec/reference/server/v1/partials/frameworks.mdx index 9c1bd872b76..3e29ea0d870 100644 --- a/apps/docs/spec/reference/server/v1/partials/frameworks.mdx +++ b/apps/docs/spec/reference/server/v1/partials/frameworks.mdx @@ -5,12 +5,14 @@ title: Frameworks `@supabase/server` middleware runs inside Hono, H3, Elysia, NestJS, and TanStack Start through a bridge: one file you copy into your project. The bridge runs a `@supabase/middleware` entry array in the framework's own middleware slot. Your route handlers then read `supabase`, `jwtClaims`, and any other key the entries contribute from the place your framework keeps per-request state. -The framework adapters (`@supabase/server/adapters/*`) predate the middleware engine. We are moving off them, and they will be deprecated soon. The bridges on this page replace them. If you use an adapter today, read "Moving off the adapters" below before you change anything. One step in that migration can open an endpoint up without an error. +The framework adapters (`@supabase/server/adapters/*`) predate the middleware engine. They are deprecated and will be removed on December 1, 2026. The bridges on this page replace them. If you use an adapter today, read "Moving off the adapters" below before you change anything. One step in that migration can open an endpoint up without an error. The bridges need `@supabase/server` 1.6.0 or later and Node 22 or later. `withRequiredClaims` shipped in 1.5.1 and the entry form of `withSupabase` in 1.6.0. Releases before 1.5.1 have no `/middleware/*` exports. +The bridge file imports `@supabase/middleware`, so add it to your project as a direct dependency. `@supabase/server` depends on it, but a strict package manager such as pnpm does not let your code import a transitive dependency. + ### What a bridge gives you @@ -268,7 +270,7 @@ The bridge files live in the server repository under [`examples/frameworks`](htt Each adapter solved the same problem in a framework-specific way: build a `SupabaseContext` and stash it where the handler can reach it. That meant a published entry point, a peer dependency range, and a release cycle per framework, all to wrap one call. The engine replaced the model. A middleware is now an entry, a `(handler) => handler` wrapper over the Web `Request` and `Response` pair, and `withSupabaseClient()`, `withSupabaseAdminClient()`, `withRequiredClaims()`, and the rest are ordinary entries you compose. What remains is the bridge, and it belongs in your project, not in a package that has to track your framework's major versions. -The adapters still work in v1 and will be deprecated soon. They will be removed in a future major. `withSupabase(config, handler)` from `@supabase/server` is not part of this change. +The adapters are deprecated. They still work today and will be removed on December 1, 2026. `withSupabase(config, handler)` from `@supabase/server` is not part of this change. Three changes come with the migration. The first can open an endpoint up. @@ -458,6 +460,7 @@ Do the steps in order. The auth inventory comes before any code change, because ```bash + npm install @supabase/middleware mkdir -p src/lib curl --fail -o src/lib/supabase-middleware.ts \ https://raw.githubusercontent.com/supabase/server/main/examples/frameworks/hono/supabase-middleware.ts @@ -614,7 +617,8 @@ contributes jwtClaims: null and falls through, turning a 401 endpoint into a auth: 'user'. Never compose withClaims() and withRequiredClaims() together; they share the jwtClaims key and that is a compile-time conflict. -STEP 3: FETCH THE BRIDGE. Download +STEP 3: FETCH THE BRIDGE. Add @supabase/middleware as a direct dependency of +the project; the bridge imports it. Then download https://raw.githubusercontent.com/supabase/server/main/examples/frameworks/hono/supabase-middleware.ts and save it as src/lib/supabase-middleware.ts, verbatim. Do not "improve", condense, or drop comments from it. diff --git a/apps/docs/style-guide/WORD_LIST.md b/apps/docs/style-guide/WORD_LIST.md index 61470ca93bc..050a71180fb 100644 --- a/apps/docs/style-guide/WORD_LIST.md +++ b/apps/docs/style-guide/WORD_LIST.md @@ -982,6 +982,10 @@ Don't use these phrases: Don't use _tl;dr_, _ymmv_, _rtfm_, _imo_, or _fwiw_. Write out the meaning or remove the aside. +## Further reference + +- [Microsoft Writing Style Guide](https://learn.microsoft.com/style-guide/welcome/) + ## Attribution Portions of this word list are modifications based on work created and shared by diff --git a/apps/docs/styles/code-block.css b/apps/docs/styles/code-block.css index 762e9e829c3..66bc752d432 100644 --- a/apps/docs/styles/code-block.css +++ b/apps/docs/styles/code-block.css @@ -39,6 +39,7 @@ } [data-theme='light'], .light { + --shadow-codeblock: 0 0 #0000; --code-token-keyword: #5f2fc4; --code-foreground: oklch(from var(--foreground-light) l c h / 1); --code-token-constant: #15593b; diff --git a/apps/docs/styles/globals.css b/apps/docs/styles/globals.css index 2a39bcb51c0..82d4d622f81 100644 --- a/apps/docs/styles/globals.css +++ b/apps/docs/styles/globals.css @@ -1,6 +1,8 @@ @import 'config/tailwind.config.css'; @import './code-block.css'; @import './reference.css'; +@import './loading-beam.css'; +@import './scroll-fade.css'; @source '../app/**/*.{ts,tsx,mdx}'; @source '../components/**/*.tsx'; diff --git a/apps/docs/styles/loading-beam.css b/apps/docs/styles/loading-beam.css new file mode 100644 index 00000000000..c82758df11f --- /dev/null +++ b/apps/docs/styles/loading-beam.css @@ -0,0 +1,80 @@ +@property --loading-beam-x { + syntax: ''; + initial-value: 0.5; + inherits: true; +} + +@keyframes loading-beam-sweep { + 0%, + 100% { + --loading-beam-x: 0; + } + 50% { + --loading-beam-x: 1; + } +} + +@layer components { + .loading-beam { + --loading-beam-color: var(--foreground-default); + --loading-beam-half: 100px; + --loading-beam-center: calc( + var(--loading-beam-half) + var(--loading-beam-x) * (100% - var(--loading-beam-half) * 2) + ); + position: absolute; + inset: auto 0 0; + height: 16px; + overflow: hidden; + pointer-events: none; + opacity: 0; + transition: opacity 150ms ease-out; + animation: loading-beam-sweep 2s ease-in-out -0.25s infinite paused; + } + + :is([data-theme='light'], .light) .loading-beam { + --loading-beam-color: var(--primary); + } + + .loading-beam[data-direction='backward'] { + animation-delay: -1.25s; + } + + .loading-beam[data-active] { + opacity: 1; + transition-duration: 250ms; + animation-play-state: running; + } + + .loading-beam::before, + .loading-beam::after { + content: ''; + position: absolute; + } + + .loading-beam::before { + inset: 0 0 -8px; + filter: blur(4px); + background: radial-gradient( + 120px 12px at var(--loading-beam-center) calc(100% - 8px), + color-mix(in oklch, var(--loading-beam-color) 3.2%, transparent), + transparent + ); + } + + .loading-beam::after { + inset: auto 0 0; + height: 1px; + background: radial-gradient( + var(--loading-beam-half) 4px at var(--loading-beam-center) 100%, + color-mix(in oklch, var(--loading-beam-color) 44.5%, transparent), + color-mix(in oklch, var(--loading-beam-color) 16%, transparent) 45%, + transparent + ); + } + + @media (prefers-reduced-motion: reduce) { + .loading-beam { + animation: none; + } + } +} diff --git a/apps/docs/styles/scroll-fade.css b/apps/docs/styles/scroll-fade.css new file mode 100644 index 00000000000..e7525d8dd83 --- /dev/null +++ b/apps/docs/styles/scroll-fade.css @@ -0,0 +1,32 @@ +@property --scroll-fade-size { + syntax: ''; + initial-value: 0px; + inherits: false; +} + +@keyframes scroll-fade-bottom { + 0%, + 85% { + --scroll-fade-size: 24px; + } + 100% { + --scroll-fade-size: 0px; + } +} + +@layer components { + @supports (animation-timeline: scroll()) { + .scroll-fade-bottom { + mask-image: linear-gradient( + to bottom, + #000 calc(100% - var(--scroll-fade-size)), + rgb(0 0 0 / 0.8) calc(100% - var(--scroll-fade-size) * 0.75), + rgb(0 0 0 / 0.5) calc(100% - var(--scroll-fade-size) * 0.5), + rgb(0 0 0 / 0.2) calc(100% - var(--scroll-fade-size) * 0.25), + transparent 100% + ); + animation: scroll-fade-bottom linear both; + animation-timeline: scroll(self); + } + } +} diff --git a/apps/kb/src/lib/topics.ts b/apps/kb/src/lib/topics.ts index 572a3800b3f..828fd6682e2 100644 --- a/apps/kb/src/lib/topics.ts +++ b/apps/kb/src/lib/topics.ts @@ -21,6 +21,12 @@ export const TOPICS = [ name: 'Comparison', description: 'How Supabase compares to other databases and platforms.', pinned: false, + visible: true, + }, + { + name: 'Rundowns', + description: 'How different technologies compare across a variety of facets.', + pinned: false, visible: false, }, { diff --git a/apps/studio/.github/eslint-rule-baselines.json b/apps/studio/.github/eslint-rule-baselines.json index 745b53c2792..4fdc6d40801 100644 --- a/apps/studio/.github/eslint-rule-baselines.json +++ b/apps/studio/.github/eslint-rule-baselines.json @@ -6,15 +6,15 @@ "@typescript-eslint/no-explicit-any": 799, "no-restricted-exports": 176, "react/no-unstable-nested-components": 34, - "jsx-a11y/control-has-associated-label": 178, + "jsx-a11y/control-has-associated-label": 174, "jsx-a11y/label-has-associated-control": 33, "jsx-a11y/no-redundant-roles": 2, "jsx-a11y/anchor-is-valid": 6, "jsx-a11y/heading-has-content": 2, "valtio/state-snapshot-rule": 1, - "shadcn/no-arbitrary-values": 199, - "shadcn/no-unknown-classes": 151, - "shadcn/no-raw-colors": 64 + "shadcn/no-arbitrary-values": 212, + "shadcn/no-unknown-classes": 150, + "shadcn/no-raw-colors": 63 }, "ruleFiles": { "react-hooks/exhaustive-deps": { @@ -857,9 +857,7 @@ "components/interfaces/Database/Policies/PolicyEditorPanel/PolicyDetailsV2.tsx": 1, "components/interfaces/Database/Privileges/PrivilegesTable.tsx": 1, "components/interfaces/Database/Publications/PublicationSkeleton.tsx": 1, - "components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/Fields.tsx": 2, "components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.tsx": 1, - "components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx": 2, "components/interfaces/Database/Roles/CreateRolePanel.tsx": 2, "components/interfaces/Database/Roles/RoleRow.tsx": 1, "components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx": 1, @@ -1128,7 +1126,6 @@ "components/interfaces/Auth/Users/UsersGridComponents.tsx": 3, "components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx": 1, "components/interfaces/BranchManagement/ConnectToGitHub.tsx": 1, - "components/interfaces/ConnectSheet/content/steps/direct-connection/IPv4StatusPanel.tsx": 1, "components/interfaces/Database/EnumeratedTypes/CreateEnumeratedTypeSidePanel.tsx": 1, "components/interfaces/Database/EnumeratedTypes/EditEnumeratedTypeSidePanel.tsx": 1, "components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx": 1, @@ -1217,7 +1214,6 @@ "components/interfaces/BranchManagement/BranchPanels.tsx": 1, "components/interfaces/BranchManagement/CreateBranchModal.tsx": 6, "components/interfaces/BranchManagement/DeleteBranchModal.tsx": 1, - "components/interfaces/ConnectSheet/content/steps/direct-connection/IPv4StatusPanel.tsx": 1, "components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx": 2, "components/interfaces/Database/Hooks/DeleteHookModal.tsx": 2, "components/interfaces/Database/Migrations/Migrations.tsx": 1, diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.utils.test.ts b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.utils.test.ts index ec481a840bb..ce500e6e2eb 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.utils.test.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.utils.test.ts @@ -1,7 +1,7 @@ import dayjs from 'dayjs' import { describe, expect, test } from 'vitest' -import { TokenFormSchema } from './NewScopedTokenForm.utils' +import { getExpiryDate, TokenFormSchema } from './NewScopedTokenForm.utils' const BASE_VALUES = { tokenName: 'test', @@ -35,3 +35,19 @@ describe('TokenFormSchema custom expiry', () => { ) }) }) + +describe('TokenFormSchema never expiry', () => { + test('accepts the never preset without a date', () => { + const result = TokenFormSchema.safeParse({ ...BASE_VALUES, expiresAt: 'never' }) + expect(result.success).toBe(true) + }) +}) + +describe('getExpiryDate', () => { + test('returns no date for the never preset', () => { + expect(getExpiryDate('never')).toBeUndefined() + }) + test('returns no date for the custom preset', () => { + expect(getExpiryDate('custom')).toBeUndefined() + }) +}) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.utils.ts b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.utils.ts index 1ef60b1b2f5..6aa9589f18a 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.utils.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.utils.ts @@ -4,7 +4,7 @@ import { z } from 'zod' import type { PermissionMode } from '../../AccessToken.permissions' import { getMaxCustomExpiryDate } from '../../AccessToken.utils' -export const EXPIRY_PRESETS = ['24h', '7d', '30d', '90d', 'custom'] as const +export const EXPIRY_PRESETS = ['24h', '7d', '30d', '90d', 'custom', 'never'] as const export type ExpiryPreset = (typeof EXPIRY_PRESETS)[number] export const DEFAULT_EXPIRY: ExpiryPreset = '7d' @@ -21,9 +21,13 @@ export const EXPIRY_OPTIONS: ExpiryOption[] = [ { value: '30d', label: '30 days' }, { value: '90d', label: '90 days' }, { value: 'custom', label: 'Custom' }, + { value: 'never', label: 'Never' }, ] -/** Resolves the final ISO expiry date for a preset, or `undefined` when the caller supplies a custom date. */ +/** + * Resolves the final ISO expiry date for a preset. Returns `undefined` for `custom`, where the + * caller supplies the date, and for `never`, where the token is created without an expiry. + */ export const getExpiryDate = (preset: ExpiryPreset): string | undefined => { switch (preset) { case '24h': @@ -34,6 +38,8 @@ export const getExpiryDate = (preset: ExpiryPreset): string | undefined => { return dayjs().add(30, 'days').toISOString() case '90d': return dayjs().add(90, 'days').toISOString() + case 'never': + return undefined default: return undefined } diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/TokenDetails.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/TokenDetails.tsx index 48d2897c301..ff25c9b8de8 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/TokenDetails.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/TokenDetails.tsx @@ -14,6 +14,7 @@ import { SelectValue, useWatch, } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' import { DatePicker, DatePickerButton, @@ -35,6 +36,7 @@ interface TokenDetailsProps { } export const TokenDetails = ({ control, setValue }: TokenDetailsProps) => { + const expiresAt = useWatch({ control, name: 'expiresAt' }) const customExpiryDate = useWatch({ control, name: 'customExpiryDate' }) const maxExpiryDate = getMaxCustomExpiryDate().toDate() @@ -136,6 +138,15 @@ export const TokenDetails = ({ control, setValue }: TokenDetailsProps) => { )} /> + + {expiresAt === 'never' && ( + + )} ) } diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx index 3f40605cffe..cc15bd2c105 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx @@ -255,6 +255,49 @@ describe('NewScopedTokenSheet', () => { ) }, 10_000) + test('creates a token without an expiry when the Never preset is selected', async () => { + let createBody: Record | undefined + addAPIMock({ + method: 'post', + path: '/platform/profile/scoped-access-tokens', + response: async ({ request }) => { + createBody = (await request.json()) as Record + return HttpResponse.json({ + created_at: '', + expires_at: null, + id: 'plop', + last_used_at: null, + name: 'test', + token: 'a_token_value', + token_alias: '', + permissions: [], + }) + }, + }) + renderSheet() + fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' })) + await screen.findByRole('dialog') + await user.type(await screen.findByLabelText('Name'), 'test') + fireEvent.click(await screen.findByRole('combobox', { name: 'Expires in' })) + fireEvent.click(await screen.findByRole('option', { name: 'Never' })) + fireEvent.click(await screen.findByRole('combobox', { name: 'Organization' })) + fireEvent.click(await screen.findByRole('option', { name: 'Acme Production' })) + fireEvent.click(await screen.findByRole('combobox', { name: 'Projects' })) + fireEvent.click(await screen.findByRole('option', { name: 'Project 1' })) + await expandPermissionCategory('Project') + fireEvent.click(await screen.findByLabelText('Project Settings', { exact: false })) + fireEvent.click(await screen.findByRole('option', { name: 'Read' })) + fireEvent.click(await screen.findByRole('button', { name: 'Review access' })) + await screen.findByText('Never') + fireEvent.click(await screen.findByRole('button', { name: 'Create token' })) + await waitFor(() => expect(createBody).toBeDefined()) + expect(createBody?.expires_at).toBeUndefined() + expect(mockTrack).toHaveBeenCalledWith( + 'access_token_created', + expect.objectContaining({ expiryPreset: 'never' }) + ) + }, 10_000) + test('tracks dismissal with the in-progress resourceAccess and touched state on Cancel', async () => { renderSheet() fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' })) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityLevelToggle.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityLevelToggle.tsx index e2306e9cd04..bb4fb35b43d 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityLevelToggle.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityLevelToggle.tsx @@ -1,4 +1,4 @@ -import { Button, cn } from 'ui' +import { ToggleGroup, ToggleGroupItem } from 'ui' import type { CapabilityLevelFilter } from './TokenCapabilities.utils' @@ -13,31 +13,23 @@ const OPTIONS: { value: CapabilityLevelFilter; label: string }[] = [ { value: 'readwrite', label: 'Read-write' }, ] -/** - * Segmented control matching the "Show all / Error only" pattern from Auth's UserLogs: adjacent - * Buttons with their variant swapped on selection, glued together via rounding/border removal - * and a thin divider, rather than a dedicated segmented-control primitive. - */ export const CapabilityLevelToggle = ({ value, onChange }: CapabilityLevelToggleProps) => ( -
- {OPTIONS.map(({ value: optionValue, label }, index) => ( -
- {index > 0 &&
} - -
+ { + if (newValue) onChange(newValue as CapabilityLevelFilter) + }} + allowDeselect={false} + aria-label="Filter capabilities by permission level" + > + {OPTIONS.map(({ value: optionValue, label }) => ( + + {label} + ))} -
+ ) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx index 22fa3971d2f..bb70fbccc3b 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx @@ -227,11 +227,11 @@ describe('ViewTokenSheet', () => { expect(screen.getByText('Backups')).toBeInTheDocument() // The All/Read/Read-write toggle sits next to the "Capabilities" title, not a text filter. - fireEvent.click(screen.getByRole('button', { name: 'Read-write' })) + fireEvent.click(screen.getByRole('radio', { name: 'Read-write' })) expect(screen.getByText('Database')).toBeInTheDocument() expect(screen.queryByText('Backups')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Read' })) + fireEvent.click(screen.getByRole('radio', { name: 'Read' })) expect(screen.queryByText('Database')).toBeNull() expect(screen.getByText('Backups')).toBeInTheDocument() }) diff --git a/apps/studio/components/interfaces/Account/Preferences/ThemeColorSettings.tsx b/apps/studio/components/interfaces/Account/Preferences/ThemeColorSettings.tsx index c43cdefb926..5905353c086 100644 --- a/apps/studio/components/interfaces/Account/Preferences/ThemeColorSettings.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/ThemeColorSettings.tsx @@ -1,58 +1,120 @@ +import { useFlag } from 'common' import { useCallback, useEffect, useRef, useState } from 'react' -import { Button, CardContent, Slider } from 'ui' +import { Button, CardContent, cn, Slider } from 'ui' +import { sliderTrackStyle } from './ThemeColorSettings.utils' import { useThemeOverrides } from '@/hooks/misc/useThemeOverrides' import { + APPEARANCE_SPOT_COLOR_FLAG, applyThemeOverrides, getThemeOverrideValue, hasThemeOverrides, previewThemeOverride, + resolveThemeOverridesForFlags, sliderValueToThemeOverride, THEME_OVERRIDE_KNOBS, ThemeOverrideKey, + ThemeOverrideKnob, ThemeOverrides, themeOverrideToSliderValue, } from '@/lib/theme-overrides' +const PRIMARY_SWATCHES = [ + { label: 'Solid', variable: '--primary-solid' }, + { label: 'Primary', variable: '--primary' }, + { label: 'Bright', variable: '--primary-bright' }, +] as const + +// `background` shorthand (not transparent + background-image) avoids a dark +// hairline at rounded caps in dark mode. +const SPECTRUM_SLIDER_CLASS = cn( + // eslint-disable-next-line shadcn/no-arbitrary-values -- Dynamic theme preview gradient uses the background shorthand for rounded caps. + '[&_[data-slot=slider-track]]:[background:var(--slider-track-fill)]', + '[&_[data-slot=slider-range]]:invisible' +) + export const ThemeColorSettings = () => { + const isSpotColorEnabled = useFlag(APPEARANCE_SPOT_COLOR_FLAG) const { mode, overrides, setOverride, resetOverrides } = useThemeOverrides() + const visibleOverrides = resolveThemeOverridesForFlags(overrides, { isSpotColorEnabled }) const [draft, setDraft] = useState({}) const draftRef = useRef({}) + const interactionRafRef = useRef(null) + const pendingPreviewRef = useRef<{ knob: ThemeOverrideKnob; raw: number } | null>(null) + const isSpotColorEnabledRef = useRef(isSpotColorEnabled) const modeRef = useRef(mode) - const overridesRef = useRef(overrides) + const overridesRef = useRef(visibleOverrides) + isSpotColorEnabledRef.current = isSpotColorEnabled modeRef.current = mode - overridesRef.current = overrides + overridesRef.current = visibleOverrides - const writeDraft = useCallback((next: ThemeOverrides) => { - draftRef.current = next - setDraft(next) + const knobs = THEME_OVERRIDE_KNOBS.filter( + (knob) => knob.key !== 'primaryHue' || isSpotColorEnabled + ) + + const cancelQueuedInteraction = useCallback(() => { + if (interactionRafRef.current === null) return + cancelAnimationFrame(interactionRafRef.current) + interactionRafRef.current = null }, []) - useEffect(() => writeDraft({}), [mode, writeDraft]) + const flushDraft = useCallback( + (next: ThemeOverrides) => { + cancelQueuedInteraction() + draftRef.current = next + setDraft(next) + }, + [cancelQueuedInteraction] + ) + + /** One React paint + one CSS preview per frame while dragging. */ + const queueInteraction = useCallback((knob: ThemeOverrideKnob, raw: number) => { + draftRef.current = { ...draftRef.current, [knob.key]: raw } + pendingPreviewRef.current = { knob, raw } + if (interactionRafRef.current !== null) return + + interactionRafRef.current = requestAnimationFrame(() => { + interactionRafRef.current = null + setDraft(draftRef.current) + const pending = pendingPreviewRef.current + if (pending === null) return + if (pending.knob.key === 'primaryHue' && !isSpotColorEnabledRef.current) return + previewThemeOverride(pending.knob, modeRef.current, pending.raw) + }) + }, []) + + useEffect(() => { + flushDraft({}) + applyThemeOverrides(document.documentElement, modeRef.current, overridesRef.current) + }, [mode, isSpotColorEnabled, flushDraft]) useEffect( () => () => { + cancelQueuedInteraction() applyThemeOverrides(document.documentElement, modeRef.current, overridesRef.current) }, - [] + [cancelQueuedInteraction] ) const handleReset = useCallback(() => { - writeDraft({}) + flushDraft({}) resetOverrides() - }, [resetOverrides, writeDraft]) + }, [flushDraft, resetOverrides]) const commitDraft = useCallback( (key: ThemeOverrideKey, committed?: number) => { const pending = draftRef.current[key] ?? committed if (pending === undefined) return + const knob = THEME_OVERRIDE_KNOBS.find((candidate) => candidate.key === key) + if (knob !== undefined) previewThemeOverride(knob, modeRef.current, pending) + setOverride(key, pending) const { [key]: _flushed, ...rest } = draftRef.current - writeDraft(rest) + flushDraft(rest) }, - [setOverride, writeDraft] + [flushDraft, setOverride] ) return ( @@ -62,7 +124,7 @@ export const ThemeColorSettings = () => {

Changes are saved separately for light and dark mode.

- {hasThemeOverrides(overrides) && ( + {hasThemeOverrides(visibleOverrides) && ( @@ -70,13 +132,21 @@ export const ThemeColorSettings = () => {
- {THEME_OVERRIDE_KNOBS.map((knob) => { - const rawValue = draft[knob.key] ?? getThemeOverrideValue(knob, mode, overrides) + {knobs.map((knob) => { + const isSpotHue = knob.key === 'primaryHue' + const rawValue = draft[knob.key] ?? getThemeOverrideValue(knob, mode, visibleOverrides) const sliderValue = themeOverrideToSliderValue(knob, mode, rawValue) + const roundedHue = Math.round(rawValue) + const trackStyle = sliderTrackStyle(knob.key, mode) return (
-
+
{ {knob.description}
- - {sliderValue} - + {isSpotHue ? ( + + + {PRIMARY_SWATCHES.map((swatch) => ( + + ))} + + {roundedHue}° + + ) : ( + + {sliderValue} + + )}
{ - const raw = sliderValueToThemeOverride(knob, mode, next) - writeDraft({ ...draftRef.current, [knob.key]: raw }) - previewThemeOverride(knob, mode, raw) + queueInteraction(knob, sliderValueToThemeOverride(knob, mode, next)) }} onValueCommit={([next]) => commitDraft(knob.key, sliderValueToThemeOverride(knob, mode, next)) diff --git a/apps/studio/components/interfaces/Account/Preferences/ThemeColorSettings.utils.ts b/apps/studio/components/interfaces/Account/Preferences/ThemeColorSettings.utils.ts new file mode 100644 index 00000000000..0cf96f49f75 --- /dev/null +++ b/apps/studio/components/interfaces/Account/Preferences/ThemeColorSettings.utils.ts @@ -0,0 +1,50 @@ +import type { CSSProperties } from 'react' + +import type { ThemeOverrideKey, ThemeOverrideMode } from '@/lib/theme-overrides' + +/** Full-hue OKLCH spectrum for the spot color track. */ +const HUE_SPECTRUM_TRACK = + 'linear-gradient(to right, oklch(0.7 0.14 0), oklch(0.7 0.14 60), oklch(0.7 0.14 120), oklch(0.7 0.14 180), oklch(0.7 0.14 240), oklch(0.7 0.14 300), oklch(0.7 0.14 360))' + +type SliderTrackStyle = CSSProperties & { + '--slider-track-fill': string +} + +export function sliderTrackStyle( + key: ThemeOverrideKey, + mode: ThemeOverrideMode +): SliderTrackStyle | undefined { + switch (key) { + case 'primaryHue': + return { '--slider-track-fill': HUE_SPECTRUM_TRACK } + case 'chroma': + // Grey → tinted at the live surface hue (follows spot via CSS offset). + return { + '--slider-track-fill': + mode === 'dark' + ? 'linear-gradient(to right, oklch(0.4 0 var(--surface-hue)), oklch(0.4 0.07 var(--surface-hue)))' + : 'linear-gradient(to right, oklch(0.9 0 var(--surface-hue)), oklch(0.9 0.05 var(--surface-hue)))', + } + case 'contrast': + return { + '--slider-track-fill': + mode === 'dark' + ? 'linear-gradient(to right, oklch(0.35 0 0), oklch(0.9 0 0))' + : 'linear-gradient(to right, oklch(0.82 0 0), oklch(0.2 0 0))', + } + case 'surface': + // Chroma 0 at the ends avoids out-of-gamut flashes at the caps. + return { + '--slider-track-fill': + 'linear-gradient(to right, oklch(0.14 0 0), oklch(0.5 0.015 var(--surface-hue)), oklch(0.97 0 0))', + } + case 'elevationStep': + // Flat → stronger lift between layers (smooth ramp, not hard bands). + return { + '--slider-track-fill': + mode === 'dark' + ? 'linear-gradient(to right, oklch(0.28 0 var(--surface-hue)), oklch(0.55 0 var(--surface-hue)))' + : 'linear-gradient(to right, oklch(0.92 0 var(--surface-hue)), oklch(0.99 0 var(--surface-hue)))', + } + } +} diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index 07705189c50..7ba03996c58 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -4,13 +4,12 @@ import { usePathname } from 'next/navigation' import { PropsWithChildren, useEffect, useRef, useState } from 'react' import { OrganizationResourceBanner } from '../Organization/HeaderBanner' -import { isLogsOrObservabilityPath, isOrganizationLandingPath } from './AppBannerWrapper.utils' +import { isLogsOrObservabilityPath } from './AppBannerWrapper.utils' import { ClockSkewBanner } from '@/components/layouts/AppLayout/ClockSkewBanner' import { NoticeBanner } from '@/components/layouts/AppLayout/NoticeBanner' import { StatusBanner } from '@/components/layouts/AppLayout/StatusBanner' import { StatusPageBanner } from '@/components/layouts/AppLayout/StatusPageBanner' import { BannerLogsAllDeprecation } from '@/components/ui/BannerStack/Banners/BannerLogsAllDeprecation' -import { BannerPrivacyPolicyUpdate } from '@/components/ui/BannerStack/Banners/BannerPrivacyPolicyUpdate' import { BANNER_ID, useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useTrack } from '@/lib/telemetry/track' @@ -31,30 +30,6 @@ export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { const pathname = usePathname() const track = useTrack() - const [privacyPolicyUpdateAcknowledged, , { isSuccess: isPrivacyPolicyDismissalLoaded }] = - useLocalStorageQuery(LOCAL_STORAGE_KEYS.PRIVACY_POLICY_UPDATE, false) - - useEffect(() => { - if (!isPrivacyPolicyDismissalLoaded || pathname == null) return - - if (isOrganizationLandingPath(pathname) && !privacyPolicyUpdateAcknowledged) { - addBanner({ - id: BANNER_ID.PRIVACY_POLICY_UPDATE, - isDismissed: false, - content: , - priority: 0, - }) - } else { - dismissBanner(BANNER_ID.PRIVACY_POLICY_UPDATE) - } - }, [ - pathname, - privacyPolicyUpdateAcknowledged, - isPrivacyPolicyDismissalLoaded, - addBanner, - dismissBanner, - ]) - const [isLogsAllDeprecationDismissed, , { isSuccess: isLogsAllDeprecationLoaded }] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.LOGS_ALL_DEPRECATION_2026_09_23, false) diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.utils.ts b/apps/studio/components/interfaces/App/AppBannerWrapper.utils.ts index 48b20261bbb..2035a8d9bad 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.utils.ts +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.utils.ts @@ -1,16 +1,8 @@ // Anchored to the section root so per-resource log pages (e.g. a single edge // function's logs) don't pull the banner outside Logs/Observability. const LOGS_SECTION_PATH = /^\/project\/[^/]+\/(logs|observability)(\/|$)/ -const ORGANIZATION_HOME_PATH = /^\/org\/[^/]+\/?$/ export function isLogsOrObservabilityPath(pathname: string | null | undefined): boolean { if (!pathname) return false return LOGS_SECTION_PATH.test(pathname) } - -export function isOrganizationLandingPath(pathname: string | null | undefined): boolean { - if (!pathname) return false - return ( - pathname === '/org' || pathname === '/organizations' || ORGANIZATION_HOME_PATH.test(pathname) - ) -} diff --git a/apps/studio/components/interfaces/App/AppearanceSettingsProvider.tsx b/apps/studio/components/interfaces/App/AppearanceSettingsProvider.tsx index 29bf52eaad2..99e9ac4bca1 100644 --- a/apps/studio/components/interfaces/App/AppearanceSettingsProvider.tsx +++ b/apps/studio/components/interfaces/App/AppearanceSettingsProvider.tsx @@ -1,17 +1,23 @@ -import { useIsomorphicLayoutEffect } from 'common' +import { useFlag, useIsomorphicLayoutEffect } from 'common' import { useTheme } from 'next-themes' import { useThemeOverrides } from '@/hooks/misc/useThemeOverrides' -import { applyThemeOverrides } from '@/lib/theme-overrides' +import { + APPEARANCE_SPOT_COLOR_FLAG, + applyThemeOverrides, + resolveThemeOverridesForFlags, +} from '@/lib/theme-overrides' export const AppearanceSettingsProvider = () => { const { resolvedTheme } = useTheme() const { mode, overrides } = useThemeOverrides() + const isSpotColorEnabled = useFlag(APPEARANCE_SPOT_COLOR_FLAG) + const effectiveOverrides = resolveThemeOverridesForFlags(overrides, { isSpotColorEnabled }) useIsomorphicLayoutEffect(() => { if (resolvedTheme === undefined) return - applyThemeOverrides(document.documentElement, mode, overrides) - }, [mode, overrides, resolvedTheme]) + applyThemeOverrides(document.documentElement, mode, effectiveOverrides) + }, [mode, effectiveOverrides, resolvedTheme]) return null } diff --git a/apps/studio/components/interfaces/BranchManagement/Branch.Commands.tsx b/apps/studio/components/interfaces/BranchManagement/Branch.Commands.tsx index 7e061b6de5b..b426fb729e6 100644 --- a/apps/studio/components/interfaces/BranchManagement/Branch.Commands.tsx +++ b/apps/studio/components/interfaces/BranchManagement/Branch.Commands.tsx @@ -1,26 +1,37 @@ -import { Forward, GitBranch } from 'lucide-react' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { Forward, GitBranch, Plus } from 'lucide-react' import { PageType, useRegisterCommands, useRegisterPage, useSetPage } from 'ui-patterns/CommandMenu' import { COMMAND_MENU_SECTIONS } from '../App/CommandMenu/CommandMenu.utils' import { orderCommandSectionsByPriority } from '../App/CommandMenu/ordering' +import { BRANCHING_GITHUB_DISCUSSION_LINK } from './BranchManagement.constants' import { useBranchesQuery } from '@/data/branches/branches-query' +import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { useAppStateSnapshot } from '@/state/app-state' const SWITCH_BRANCH_PAGE_NAME = 'Switch branch' const EMPTY_ARRAY = [] as Array export function useBranchCommands() { const setPage = useSetPage() + const { setShowCreateBranchModal } = useAppStateSnapshot() const { data: selectedProject } = useSelectedProjectQuery() + const ref = selectedProject?.ref || '_' const isBranchingEnabled = selectedProject?.is_branch_enabled === true + const { can: canCreateBranches } = useAsyncCheckPermissions( + PermissionAction.CREATE, + 'preview_branches', + { resource: { is_default: false } } + ) + let { data: branches } = useBranchesQuery( - { - projectRef: selectedProject?.parent_project_ref || selectedProject?.ref, - }, + { projectRef: selectedProject?.parent_project_ref || selectedProject?.ref }, { enabled: isBranchingEnabled } ) + branches ??= EMPTY_ARRAY useRegisterPage( @@ -60,4 +71,48 @@ export function useBranchCommands() { sectionMeta: { priority: 3 }, } ) + + useRegisterCommands( + COMMAND_MENU_SECTIONS.ACTIONS, + [ + { + id: 'create-branch', + name: 'Create branch', + value: 'Create branch, New branch', + action: () => setShowCreateBranchModal(true), + icon: () => , + }, + ], + { + enabled: canCreateBranches, + orderSection: orderCommandSectionsByPriority, + sectionMeta: { priority: 3 }, + } + ) + + useRegisterCommands( + COMMAND_MENU_SECTIONS.NAVIGATE, + [ + { + id: 'nav-branch-management', + name: 'Branch management', + route: `/project/${ref}/branches`, + }, + { + id: 'nav-branch-merge-requests', + name: 'Merge requests', + value: 'Branch', + route: `/project/${ref}/branches/merge-requests`, + }, + ], + { enabled: !!selectedProject } + ) + + useRegisterCommands(COMMAND_MENU_SECTIONS.NAVIGATE, [ + { + id: 'nav-branch-feedback', + name: 'Branching feedback', + route: BRANCHING_GITHUB_DISCUSSION_LINK, + }, + ]) } diff --git a/apps/studio/components/interfaces/BranchManagement/BranchManagement.constants.ts b/apps/studio/components/interfaces/BranchManagement/BranchManagement.constants.ts new file mode 100644 index 00000000000..d52b0fd0dc1 --- /dev/null +++ b/apps/studio/components/interfaces/BranchManagement/BranchManagement.constants.ts @@ -0,0 +1 @@ +export const BRANCHING_GITHUB_DISCUSSION_LINK = 'https://github.com/orgs/supabase/discussions/18937' diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx index d9f7d90b3b1..bc2048e54d6 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx @@ -5,7 +5,7 @@ import { InvoicesSection } from '../InvoicesSettings/InvoicesSection' import BillingBreakdown from './BillingBreakdown/BillingBreakdown' import { BillingCustomerData } from './BillingCustomerData/BillingCustomerData' import BillingEmail from './BillingEmail' -import CostControl from './CostControl/CostControl' +import { CostControl } from './CostControl/CostControl' import CreditBalance from './CreditBalance' import Subscription from './Subscription/Subscription' import { diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx index a46f66a38be..ff59657a02b 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx @@ -9,7 +9,7 @@ import { Admonition } from 'ui-patterns/Admonition' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { ProjectUpdateDisabledTooltip } from '../ProjectUpdateDisabledTooltip' -import SpendCapSidePanel from './SpendCapSidePanel' +import { SpendCapSidePanel } from './SpendCapSidePanel' import { ScaffoldSection, ScaffoldSectionContent, @@ -27,20 +27,19 @@ import { BASE_PATH, DOCS_URL } from '@/lib/constants' import { MANAGED_BY } from '@/lib/constants/infrastructure' import { useOrgSettingsPageStateSnapshot } from '@/state/organization-settings' -export interface CostControlProps {} - -const CostControl = ({}: CostControlProps) => { +export const CostControl = () => { const { slug } = useParams() const { resolvedTheme } = useTheme() + const snap = useOrgSettingsPageStateSnapshot() const { data: selectedOrganization } = useSelectedOrganizationQuery() + const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') + const { isSuccess: isPermissionsLoaded, can: canReadSubscriptions } = useAsyncCheckPermissions( PermissionAction.BILLING_READ, 'stripe.subscriptions' ) - const snap = useOrgSettingsPageStateSnapshot() - const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') const { data: subscription, error, @@ -203,5 +202,3 @@ const CostControl = ({}: CostControlProps) => { ) } - -export default CostControl diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx index e507106268e..c29db6d377f 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx @@ -4,19 +4,49 @@ import { ChevronRight, ExternalLink } from 'lucide-react' import { useTheme } from 'next-themes' import Image from 'next/image' import Link from 'next/link' -import { useEffect, useState } from 'react' +import { Fragment, useEffect, useMemo, useState } from 'react' import { pricing } from 'shared-data/pricing' import { toast } from 'sonner' -import { Button, cn, Collapsible, CollapsibleContent, CollapsibleTrigger, SidePanel } from 'ui' +import { + Button, + Card, + CardContent, + cn, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetSection, + SheetTitle, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from 'ui' import { Admonition } from 'ui-patterns/Admonition' -import Table from '@/components/to-be-cleaned/Table' +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query' import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' import { useOrgSubscriptionUpdateMutation } from '@/data/subscriptions/org-subscription-update-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { BASE_PATH, DOCS_URL, PRICING_TIER_PRODUCT_IDS } from '@/lib/constants' +import { PROJECT_STATUS } from '@/lib/constants/infrastructure' import { useOrgSettingsPageStateSnapshot } from '@/state/organization-settings' +const BILLING_METRIC_CATEGORIES: (keyof typeof pricing)[] = [ + 'database', + 'auth', + 'storage', + 'realtime', + 'edge_functions', +] + const SPEND_CAP_OPTIONS: { name: string value: 'on' | 'off' @@ -37,7 +67,7 @@ const SPEND_CAP_OPTIONS: { }, ] -const SpendCapSidePanel = () => { +export const SpendCapSidePanel = () => { const { slug } = useParams() const { resolvedTheme } = useTheme() @@ -53,6 +83,19 @@ const SpendCapSidePanel = () => { const visible = snap.panelKey === 'costControl' const onClose = () => snap.setPanelKey(undefined) + const { data } = useOrgProjectsInfiniteQuery({ slug }) + const projects = useMemo(() => data?.pages.flatMap((page) => page.projects) || [], [data?.pages]) + + const hasReplicas = useMemo( + () => + projects.some( + (it) => + it.status !== PROJECT_STATUS.INACTIVE && + it.databases.some((db) => db.type === 'READ_REPLICA') + ), + [projects] + ) + const { data: subscription, isPending: isLoading } = useOrgSubscriptionQuery({ orgSlug: slug }) const { mutate: updateOrgSubscription, isPending: isUpdating } = useOrgSubscriptionUpdateMutation( { @@ -70,12 +113,21 @@ const SpendCapSidePanel = () => { const isSpendCapOn = !subscription?.usage_billing_enabled const isTurningOnCap = !isSpendCapOn && selectedOption === 'on' const hasChanges = selectedOption !== (isSpendCapOn ? 'on' : 'off') + const isBlockedByReplicas = isTurningOnCap && hasReplicas - useEffect(() => { - if (visible && subscription !== undefined) { - setSelectedOption(isSpendCapOn ? 'on' : 'off') - } - }, [visible, isLoading, subscription, isSpendCapOn]) + const disabled = + isFreePlan || + isLoading || + !hasChanges || + isUpdating || + !canUpdateSpendCap || + isBlockedByReplicas + + const confirmTooltipText = !canUpdateSpendCap + ? 'You do not have permission to update spend cap' + : isBlockedByReplicas + ? 'Remove your read replicas before enabling the spend cap' + : undefined const onConfirm = async () => { if (!slug) return console.error('Org slug is required') @@ -87,25 +139,17 @@ const SpendCapSidePanel = () => { updateOrgSubscription({ slug, tier }) } - const billingMetricCategories: (keyof typeof pricing)[] = [ - 'database', - 'auth', - 'storage', - 'realtime', - 'edge_functions', - ] + useEffect(() => { + if (visible && subscription !== undefined) { + setSelectedOption(isSpendCapOn ? 'on' : 'off') + } + }, [visible, isLoading, subscription, isSpendCapOn]) return ( - -

Spend cap

+ onClose()}> + + + Spend cap -
- } - tooltip={!canUpdateSpendCap ? 'You do not have permission to update spend cap' : undefined} - > - -
-

- Use the spend cap to manage project usage and costs, and control whether the project can - exceed the included quota allowance of any billed line item in a billing cycle -

+ - - -
- -

- How are each resource charged after exceeding the included quota? -

-
-
- - - -

Item

-
- -

Rate

-
- + +
+

+ Use the spend cap to manage project usage and costs, and control whether the project + can exceed the included quota allowance of any billed line item in a billing cycle +

+ + + +
+ +

+ How is each resource charged after exceeding the included quota? +

+
+
+ + + +
+ + + Item + Rate + + + + {BILLING_METRIC_CATEGORIES.map((categoryId) => { + const category = pricing[categoryId] + const usageItems = category.features.filter((it) => it.usage_based) + + return ( + + + + {category.title} + + + + {usageItems.map((item) => ( + + {item.title} + + {Array.isArray(item.plans['pro']) + ? item.plans['pro']?.join(', ') + : item.plans['pro']} + + + ))} + + ) + })} + +
+ + +
+
+ + {isFreePlan && ( + snap.setPanelKey('subscriptionPlan')}> + View available plans + } - body={billingMetricCategories.map((categoryId) => { - const category = pricing[categoryId] - const usageItems = category.features.filter((it: any) => it.usage_based) + /> + )} + +
+
+ {SPEND_CAP_OPTIONS.map((option) => { + const isSelected = selectedOption === option.value return ( - <> - - -

{category.title}

-
- {null} -
- {usageItems.map((item: any) => { - return ( - - -

{item.title}

-
- -

- {Array.isArray(item.plans['pro']) - ? item.plans['pro']?.join(', ') - : item.plans['pro']} -

-
-
- ) - })} - + ) })} - /> - - - - {isFreePlan && ( - snap.setPanelKey('subscriptionPlan')}> - View available plans - - } - /> - )} - -
-
- {SPEND_CAP_OPTIONS.map((option) => { - const isSelected = selectedOption === option.value - - return ( -
!isFreePlan && setSelectedOption(option.value)} - > - Spend Cap - -

- {option.name} -

-
- ) - })} +
+ + {isBlockedByReplicas ? ( + + ) : selectedOption === 'on' ? ( + + ) : ( + + )} + + {hasChanges && !disabled && ( + <> +

+ {selectedOption === 'on' + ? 'Upon clicking confirm, spend cap will be enabled for your organization and you will no longer be charged any extra for usage.' + : 'Upon clicking confirm, spend cap will be disabled for your organization and you will be charged for any usage beyond the included quota.'} +

+

+ Toggling spend cap triggers an invoice and there might be prorated charges for any + usage beyond the Pro Plans quota during this billing cycle. +

+ + )}
+ - {selectedOption === 'on' ? ( - - ) : ( - - )} - - {hasChanges && ( - <> -

- {selectedOption === 'on' - ? 'Upon clicking confirm, spend cap will be enabled for your organization and you will no longer be charged any extra for usage.' - : 'Upon clicking confirm, spend cap will be disabled for your organization and you will be charged for any usage beyond the included quota.'} -

-

- Toggling spend cap triggers an invoice and there might be prorated charges for any - usage beyond the Pro Plans quota during this billing cycle. -

- - )} -
- - + + + + Confirm + + + + ) } - -export default SpendCapSidePanel diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index 516b9ed8a26..c9976e5bed0 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -358,12 +358,20 @@ export const InviteMemberButton = () => { const canAssignRole = rolesAddable.includes(role.id) const isOwnerRole = role.name === 'Owner' const disabledForStripe = isStripeProjectsOrg && isOwnerRole - const disabled = !canAssignRole || disabledForStripe + + const isNoAccessRole = role.name === 'No-access' + const isApplyingProjectRole = !applyToOrg + const disabledForProjectRole = isApplyingProjectRole && isNoAccessRole + + const disabled = + !canAssignRole || disabledForStripe || disabledForProjectRole const disabledReason = disabledForStripe ? 'Cannot be assigned in Stripe Projects organizations' - : !canAssignRole - ? 'Additional permissions required to assign role' - : undefined + : disabledForProjectRole + ? 'Cannot assign No-access role to project-scoped members' + : !canAssignRole + ? 'Additional permissions required to assign role' + : undefined return ( diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/Roles.constants.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/Roles.constants.tsx index a3759ad9ca9..30b7ec2d957 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/Roles.constants.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/Roles.constants.tsx @@ -17,4 +17,5 @@ export const ROLE_DESCRIPTIONS: Record = { 'Manage project content, including deleting data, users, files, and Edge Functions. Cannot change settings or delete projects.', 'Read-only': 'View resources without modifying or deleting them. SQL Editor access is limited to SELECT queries.', + 'No-access': 'No access to any organization or project resources', } diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx index c4470ea1bac..b57a713e1c7 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx @@ -282,12 +282,19 @@ export const UpdateRolesPanel = ({ visible, member, onClose }: UpdateRolesPanelP const canAssignRole = rolesAddable.includes(role.id) const isOwnerRole = role.name === 'Owner' const disabledForStripe = isStripeProjectsOrg && isOwnerRole - const disabled = !canAssignRole || disabledForStripe + + const isNoAccessRole = role.name === 'No-access' + const disabledForProjectRole = + !isApplyingRoleToAllProjects && isNoAccessRole + const disabled = + !canAssignRole || disabledForStripe || disabledForProjectRole const disabledReason = disabledForStripe ? 'Cannot be assigned in Stripe Projects organizations' - : !canAssignRole - ? 'Additional permissions required to assign role' - : undefined + : disabledForProjectRole + ? 'Cannot assign No-access role to project-scoped members' + : !canAssignRole + ? 'Additional permissions required to assign role' + : undefined return ( relevantProjects.some((it) => it.databases.some((db) => db.type === 'READ_REPLICA')), + [relevantProjects] + ) + const hasProjectsExceedingDiskSize = useMemo(() => { return relevantProjects.some((it) => - it.databases.some( - (db) => db.type === 'READ_REPLICA' || (db.disk_volume_size_gb && db.disk_volume_size_gb > 8) - ) + it.databases.some((db) => db.disk_volume_size_gb && db.disk_volume_size_gb > 8) ) }, [relevantProjects]) @@ -103,6 +106,21 @@ export const DiskUsage = ({ )} + {currentBillingCycleSelected && + subscription?.usage_billing_enabled === false && + !hasProjectsExceedingDiskSize && + hasReplicas && ( + + + + Read replicas add disk usage beyond your plan's included quota + + + You have read replicas in your projects, but do not allow any overages with the + Spend Cap on. Remove the read replicas or disable the spend cap. + + + )}
diff --git a/apps/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx b/apps/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx index 555a8dc749f..1c30f3cec67 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx @@ -184,7 +184,7 @@ const MessagesTable = ({
({ logData: [] as LogData[] })) + +vi.mock('./useRealtimeMessages', () => ({ + useRealtimeMessages: () => ({ logData: realtime.logData, sendMessage: vi.fn() }), +})) +vi.mock('./useRealtimeInspectorShortcuts', () => ({ useRealtimeInspectorShortcuts: vi.fn() })) +vi.mock('@/lib/telemetry/track', () => ({ useTrack: () => vi.fn() })) +vi.mock('./Header', () => ({ + Header: ({ + config, + onChangeConfig, + }: { + config: RealtimeConfig + onChangeConfig: (config: RealtimeConfig) => void + }) => ( + + ), +})) +vi.mock('./MessagesTable', () => ({ default: () =>
Inspector messages
})) +vi.mock('./EmptyRealtime', () => ({ + EmptyRealtime: () => , +})) +vi.mock('./SendMessageModal', () => ({ SendMessageModal: () => null })) + +const PROJECT: ProjectDetail = { + cloud_provider: 'AWS', + connectionString: 'postgresql://postgres@localhost:5432/postgres', + db_host: 'db.default.supabase.co', + high_availability: false, + id: 1, + inserted_at: '2026-01-01T00:00:00.000Z', + integration_source: null, + is_branch_enabled: false, + is_hibernating: false, + is_physical_backups_enabled: false, + name: 'Test project', + organization_id: 1, + ref: 'default', + region: 'us-east-1', + restUrl: 'https://default.supabase.co/rest/v1', + status: 'ACTIVE_HEALTHY', + subscription_id: 'subscription-1', + updated_at: '2026-01-01T00:00:00.000Z', +} + +const publication = ( + tables: PGPublication['tables'], + name = 'supabase_realtime' +): PGPublication => ({ + id: 1, + name, + owner: 'postgres', + publish_insert: true, + publish_update: true, + publish_delete: true, + publish_truncate: true, + tables, +}) + +function mockPublications(publications: PGPublication[]) { + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: () => HttpResponse.json(publications), + }) +} + +function renderInspector() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + customRender(, { queryClient }) + return queryClient +} + +async function waitForPublications(queryClient: QueryClient, status = 'success') { + await waitFor(() => + expect(queryClient.getQueryState(databasePublicationsKeys.list('default'))?.status).toBe(status) + ) +} + +beforeEach(() => { + realtime.logData = [] + addAPIMock({ method: 'get', path: '/platform/projects/:ref', response: PROJECT }) +}) + +describe('Realtime Inspector setup guidance', () => { + test.each([ + ['a published table', [publication([{ id: 1, name: 'books', schema: 'public' }])]], + ['all tables published', [publication(null)]], + ])('does not show setup guidance with %s and no messages or channel', async (_, publications) => { + mockPublications(publications) + await waitForPublications(renderInspector()) + + expect(screen.getByText('Inspector messages')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Set up realtime for me' })).not.toBeInTheDocument() + }) + + test.each([ + ['no publications', []], + ['an empty Realtime publication', [publication([])]], + ['a different publication', [publication(null, 'other_publication')]], + ])('retains setup guidance with %s', async (_, publications) => { + mockPublications(publications) + renderInspector() + + expect( + await screen.findByRole('button', { name: 'Set up realtime for me' }) + ).toBeInTheDocument() + expect(screen.queryByText('Inspector messages')).not.toBeInTheDocument() + }) + + test('does not show setup guidance while publications are loading', async () => { + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: async () => { + await delay('infinite') + return HttpResponse.json([]) + }, + }) + const queryClient = renderInspector() + await waitFor(() => + expect(queryClient.getQueryState(databasePublicationsKeys.list('default'))?.fetchStatus).toBe( + 'fetching' + ) + ) + + expect(screen.getByText('Inspector messages')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Set up realtime for me' })).not.toBeInTheDocument() + }) + + test('does not imply Realtime is unconfigured when publications fail to load', async () => { + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: () => HttpResponse.json({ message: 'Unavailable' }, { status: 500 }), + }) + await waitForPublications(renderInspector(), 'error') + + expect(screen.getByText('Inspector messages')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Set up realtime for me' })).not.toBeInTheDocument() + }) + + test('shows messages immediately after joining a broadcast-only channel', async () => { + mockPublications([publication([])]) + renderInspector() + await screen.findByRole('button', { name: 'Set up realtime for me' }) + + await userEvent.click(screen.getByRole('button', { name: 'Join a channel' })) + + expect(screen.getByText('Inspector messages')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Set up realtime for me' })).not.toBeInTheDocument() + }) + + test('retains received messages without a channel or published tables', async () => { + realtime.logData = [{ id: '1', timestamp: 1, message: 'BROADCAST' }] + mockPublications([]) + await waitForPublications(renderInspector()) + + expect(screen.getByText('Inspector messages')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Set up realtime for me' })).not.toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/Realtime/Inspector/index.tsx b/apps/studio/components/interfaces/Realtime/Inspector/index.tsx index 08af2260f63..ab2b33a43a0 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/index.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/index.tsx @@ -19,7 +19,7 @@ export const RealtimeInspector = () => { const { data: project } = useSelectedProjectQuery() // Check if realtime publications are available - const { data: publications } = useDatabasePublicationsQuery({ + const { data: publications, isSuccess: isPublicationsSuccess } = useDatabasePublicationsQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) @@ -55,10 +55,10 @@ export const RealtimeInspector = () => { const hasChannel = realtimeConfig.channelName.length > 0 const isListening = realtimeConfig.enabled - // Once a channel is set, MessagesTable renders its own empty states (including - // the "Broadcast a message" entry point), so sending doesn't depend on a - // message having arrived first. EmptyRealtime is only the pre-channel onboarding. - const showMessagesTable = hasChannel || (logData ?? []).length > 0 + // Only show setup guidance once publications confirm that Postgres Changes + // is not configured. An empty Inspector session does not imply Realtime is unused. + const showMessagesTable = + !isPublicationsSuccess || isRealtimeAvailable || hasChannel || (logData ?? []).length > 0 const handleJoinChannel = useCallback(() => { if (!hasChannel) { diff --git a/apps/studio/components/layouts/AppLayout/BranchDropdownCommandContent.tsx b/apps/studio/components/layouts/AppLayout/BranchDropdownCommandContent.tsx index ac0d6d4397c..e77fef3876f 100644 --- a/apps/studio/components/layouts/AppLayout/BranchDropdownCommandContent.tsx +++ b/apps/studio/components/layouts/AppLayout/BranchDropdownCommandContent.tsx @@ -14,12 +14,11 @@ import { } from 'ui' import { BranchLink } from './BranchLink' +import { BRANCHING_GITHUB_DISCUSSION_LINK } from '@/components/interfaces/BranchManagement/BranchManagement.constants' import { CommandItemLink } from '@/components/ui/CommandItemLink' import type { Branch } from '@/data/branches/branches-query' import { useTrack } from '@/lib/telemetry/track' -const BRANCHING_GITHUB_DISCUSSION_LINK = 'https://github.com/orgs/supabase/discussions/18937' - export interface BranchDropdownCommandContentProps { embedded: boolean className?: string diff --git a/apps/studio/components/layouts/AppLayout/useStatusBanner.ts b/apps/studio/components/layouts/AppLayout/useStatusBanner.ts index 85797ea0bf0..74fb1d90e37 100644 --- a/apps/studio/components/layouts/AppLayout/useStatusBanner.ts +++ b/apps/studio/components/layouts/AppLayout/useStatusBanner.ts @@ -61,5 +61,5 @@ export function useStatusBanner(): StatusBannerState { ) } - return { type: 'shown', selection, pageUrl: data?.pageUrl ?? DEFAULT_STATUS_PAGE_URL, onDismiss } + return { type: 'shown', selection, pageUrl: DEFAULT_STATUS_PAGE_URL, onDismiss } } diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/ProjectSettings.Commands.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/ProjectSettings.Commands.tsx index 9755bce0b57..aefdcb0b6c8 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/ProjectSettings.Commands.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/ProjectSettings.Commands.tsx @@ -45,7 +45,7 @@ export function useProjectSettingsGotoCommands(options?: CommandOptions) { }, { id: 'nav-project-settings-general', - name: 'General Settings', + name: 'General settings', route: `/project/${ref}/settings/general`, defaultHidden: true, }, @@ -61,13 +61,13 @@ export function useProjectSettingsGotoCommands(options?: CommandOptions) { : []), { id: 'nav-project-settings-database', - name: 'Database Settings', + name: 'Database settings', route: `/project/${ref}/database/settings`, defaultHidden: true, }, { id: 'nav-project-settings-auth', - name: 'Auth Settings', + name: 'Auth settings', route: authenticationSignInProviders ? `/project/${ref}/auth/providers` : `/project/${ref}/database/policies`, @@ -77,7 +77,7 @@ export function useProjectSettingsGotoCommands(options?: CommandOptions) { ? [ { id: 'nav-project-settings-webhooks', - name: 'Project Webhooks', + name: 'Project webhooks', route: `/project/${ref}/settings/webhooks`, defaultHidden: true, } as IRouteCommand, @@ -95,13 +95,19 @@ export function useProjectSettingsGotoCommands(options?: CommandOptions) { : []), { id: 'nav-project-settings-api', - name: 'API Settings', + name: 'API settings', route: `/project/${ref}/integrations/data_api/settings`, defaultHidden: true, }, + { + id: 'nav-project-settings-integration', + name: 'Project Integrations', + value: 'Branch integration, Vercel integration, GitHub integration, AWS PrivateLink', + route: `/project/${ref}/settings/integrations`, + }, { id: 'nav-project-settings-storage', - name: 'Storage Settings', + name: 'Storage settings', route: `/project/${ref}/storage/settings`, defaultHidden: true, }, @@ -109,7 +115,7 @@ export function useProjectSettingsGotoCommands(options?: CommandOptions) { ? [ { id: 'nav-project-settings-custom-domains', - name: 'Custom Domains', + name: 'Custom domains', route: `/project/${ref}/settings/general#custom-domains`, defaultHidden: true, } as IRouteCommand, diff --git a/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx b/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx index 50148decc82..2ecdcd95600 100644 --- a/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx +++ b/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx @@ -5,7 +5,6 @@ export const BANNER_ID = { TABLE_EDITOR_QUEUE_OPERATIONS: 'table-editor-queue-operations-banner', RLS_EVENT_TRIGGER: 'rls-event-trigger-banner', FREE_MICRO_UPGRADE: 'free-micro-upgrade-banner', - PRIVACY_POLICY_UPDATE: 'privacy-policy-update-banner', LOGS_ALL_DEPRECATION: 'logs-all-deprecation-banner', EXPLORER: 'explorer-banner', } as const diff --git a/apps/studio/components/ui/BannerStack/Banners/BannerPrivacyPolicyUpdate.test.tsx b/apps/studio/components/ui/BannerStack/Banners/BannerPrivacyPolicyUpdate.test.tsx deleted file mode 100644 index b0ace47f5d0..00000000000 --- a/apps/studio/components/ui/BannerStack/Banners/BannerPrivacyPolicyUpdate.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { LOCAL_STORAGE_KEYS } from 'common/constants/local-storage' -import { PropsWithChildren } from 'react' -import { beforeEach, describe, expect, it } from 'vitest' - -import { BannerStackProvider } from '../BannerStackProvider' -import { BannerPrivacyPolicyUpdate } from './BannerPrivacyPolicyUpdate' - -const renderBanner = () => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }) - - const Wrapper = ({ children }: PropsWithChildren) => ( - - {children} - - ) - - return render(, { wrapper: Wrapper }) -} - -describe('BannerPrivacyPolicyUpdate', () => { - beforeEach(() => window.localStorage.clear()) - - it('shows the compact notice and the full update details', async () => { - renderBanner() - - expect(screen.getByText("We've updated our Privacy Policy")).toBeVisible() - - fireEvent.click(screen.getByRole('button', { name: 'Learn more' })) - - expect(await screen.findByRole('dialog')).toBeVisible() - expect(screen.getByRole('link', { name: 'Privacy Policy' })).toHaveAttribute( - 'href', - 'https://supabase.com/privacy' - ) - expect(screen.getByRole('link', { name: 'contact us' })).toHaveAttribute( - 'href', - 'mailto:privacy@supabase.com' - ) - }) - - it('persists acknowledgement from the dialog', async () => { - renderBanner() - - fireEvent.click(screen.getByRole('button', { name: 'Learn more' })) - fireEvent.click(await screen.findByRole('button', { name: 'Understood' })) - - await waitFor(() => - expect(window.localStorage.getItem(LOCAL_STORAGE_KEYS.PRIVACY_POLICY_UPDATE)).toBe('true') - ) - }) -}) diff --git a/apps/studio/components/ui/BannerStack/Banners/BannerPrivacyPolicyUpdate.tsx b/apps/studio/components/ui/BannerStack/Banners/BannerPrivacyPolicyUpdate.tsx deleted file mode 100644 index 6dbcd364efb..00000000000 --- a/apps/studio/components/ui/BannerStack/Banners/BannerPrivacyPolicyUpdate.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { LOCAL_STORAGE_KEYS } from 'common' -import { - Badge, - Button, - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogSection, - DialogSectionSeparator, - DialogTitle, - DialogTrigger, -} from 'ui' - -import { InlineLink } from '../../InlineLink' -import { BannerCard } from '../BannerCard' -import { BANNER_ID, useBannerStack } from '../BannerStackProvider' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' - -export const BannerPrivacyPolicyUpdate = () => { - const { dismissBanner } = useBannerStack() - const [, setPrivacyPolicyUpdateAcknowledged] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.PRIVACY_POLICY_UPDATE, - false - ) - - const acknowledgeUpdate = () => { - setPrivacyPolicyUpdateAcknowledged(true) - dismissBanner(BANNER_ID.PRIVACY_POLICY_UPDATE) - } - - return ( - -
- - Notice - - -
-

We've updated our Privacy Policy

-

- The data controller is now Supabase Pte. Ltd. Your rights and protections are unchanged. -

-
- -
-
- ) -} - -const PrivacyPolicyUpdateDialog = ({ onAcknowledge }: { onAcknowledge: () => void }) => { - return ( - - - - - - - Privacy Policy update - - We've updated our Privacy Policy to align with our Data Processing Addendum. - - - - - - -

- The data controller is now listed as Supabase Pte. Ltd. (previously Supabase, Inc.). -

-

- This is an organizational change for clarity. Your rights, our obligations, and your - data protections remain unchanged. -

-

- Read the updated{' '} - Privacy Policy or{' '} - contact us with any - questions. -

-
- - - - - - -
-
- ) -} diff --git a/apps/studio/data/logs/get-unified-logs.ts b/apps/studio/data/logs/get-unified-logs.ts index fbcc0c4dc71..f9030b2b966 100644 --- a/apps/studio/data/logs/get-unified-logs.ts +++ b/apps/studio/data/logs/get-unified-logs.ts @@ -1,9 +1,8 @@ import { useMutation } from '@tanstack/react-query' -import { useFlag } from 'common' import { toast } from 'sonner' import { logsAllEndpointUrl, pickLogsQueryBuilder } from './logs-endpoint' -import { getUnifiedLogsISOStartEnd } from './unified-logs-infinite-query' +import { getUnifiedLogsISOStartEnd, useUnifiedLogsBackend } from './unified-logs-infinite-query' import { mapUnifiedLogRow, parseUnifiedLogsQueryRows } from './unified-logs.utils' import { getUnifiedLogsQuery } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries' import { getUnifiedLogsQuery as getUnifiedLogsQueryBq } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries.bq' @@ -58,7 +57,7 @@ export const useGetUnifiedLogsMutation = ({ UseCustomMutationOptions, 'mutationFn' > = {}) => { - const useOtel = useFlag('otelUnifiedLogs') + const useOtel = useUnifiedLogsBackend() return useMutation({ mutationFn: (vars) => retrieveUnifiedLogs({ ...vars, useOtel: vars.useOtel ?? useOtel }), async onSuccess(data, variables, context) { diff --git a/apps/studio/data/logs/unified-log-inspection-query.ts b/apps/studio/data/logs/unified-log-inspection-query.ts index cc2d0e03662..43894fbe7d1 100644 --- a/apps/studio/data/logs/unified-log-inspection-query.ts +++ b/apps/studio/data/logs/unified-log-inspection-query.ts @@ -1,5 +1,4 @@ import { useQuery } from '@tanstack/react-query' -import { useFlag } from 'common' import { executeAnalyticsSql } from './execute-analytics-sql' import { logsKeys } from './keys' @@ -12,6 +11,7 @@ import { analyticsLiteral as lit, safeSql, type SafeLogSqlFragment } from './saf import { getUnifiedLogsISOStartEnd, UNIFIED_LOGS_QUERY_OPTIONS, + useUnifiedLogsBackend, } from './unified-logs-infinite-query' import { getAuthServiceFlowQuery, @@ -301,7 +301,7 @@ export const useUnifiedLogInspectionQuery = ( ...options }: UseCustomQueryOptions = {} ) => { - const useOtel = useFlag('otelUnifiedLogs') + const useOtel = useUnifiedLogsBackend() return useQuery({ queryKey: [...logsKeys.serviceFlow(projectRef, search, logId), { otel: useOtel }], queryFn: ({ signal }) => diff --git a/apps/studio/data/logs/unified-logs-chart-query.ts b/apps/studio/data/logs/unified-logs-chart-query.ts index 6a9315153d1..2af1561f304 100644 --- a/apps/studio/data/logs/unified-logs-chart-query.ts +++ b/apps/studio/data/logs/unified-logs-chart-query.ts @@ -1,10 +1,13 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query' -import { useFlag } from 'common' import { executeAnalyticsSql } from './execute-analytics-sql' import { logsKeys } from './keys' import { logsAllEndpointUrl, pickLogsQueryBuilder } from './logs-endpoint' -import { UNIFIED_LOGS_QUERY_OPTIONS, UnifiedLogsVariables } from './unified-logs-infinite-query' +import { + UNIFIED_LOGS_QUERY_OPTIONS, + UnifiedLogsVariables, + useUnifiedLogsBackend, +} from './unified-logs-infinite-query' import { parseLogsFilterUrlParams } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.filters' import { getLogsChartQuery } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries' import { getLogsChartQuery as getLogsChartQueryBq } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries.bq' @@ -163,7 +166,7 @@ export const useUnifiedLogsChartQuery = ( ...options }: UseCustomQueryOptions = {} ) => { - const useOtel = useFlag('otelUnifiedLogs') + const useOtel = useUnifiedLogsBackend() return useQuery({ queryKey: [...logsKeys.unifiedLogsChart(projectRef, search), { otel: useOtel }], queryFn: ({ signal }) => getUnifiedLogsChart({ projectRef, search, useOtel }, signal), diff --git a/apps/studio/data/logs/unified-logs-count-query.ts b/apps/studio/data/logs/unified-logs-count-query.ts index 0a49dfbadd6..69fae9f7817 100644 --- a/apps/studio/data/logs/unified-logs-count-query.ts +++ b/apps/studio/data/logs/unified-logs-count-query.ts @@ -1,5 +1,4 @@ import { useQuery } from '@tanstack/react-query' -import { useFlag } from 'common' import { executeAnalyticsSql } from './execute-analytics-sql' import { logsKeys } from './keys' @@ -8,6 +7,7 @@ import { getUnifiedLogsISOStartEnd, UNIFIED_LOGS_QUERY_OPTIONS, UnifiedLogsVariables, + useUnifiedLogsBackend, } from './unified-logs-infinite-query' import { getLogsCountQuery } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries' import { getLogsCountQuery as getLogsCountQueryBq } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries.bq' @@ -90,7 +90,7 @@ export const useUnifiedLogsCountQuery = ( ...options }: UseCustomQueryOptions = {} ) => { - const useOtel = useFlag('otelUnifiedLogs') + const useOtel = useUnifiedLogsBackend() return useQuery({ queryKey: [...logsKeys.unifiedLogsCount(projectRef, search), { otel: useOtel }], queryFn: ({ signal }) => getUnifiedLogsCount({ projectRef, search, useOtel }, signal), diff --git a/apps/studio/data/logs/unified-logs-facet-count-query.ts b/apps/studio/data/logs/unified-logs-facet-count-query.ts index 45b080ec50e..43dde999327 100644 --- a/apps/studio/data/logs/unified-logs-facet-count-query.ts +++ b/apps/studio/data/logs/unified-logs-facet-count-query.ts @@ -1,5 +1,4 @@ import { useQuery } from '@tanstack/react-query' -import { useFlag } from 'common' import { executeAnalyticsSql } from './execute-analytics-sql' import { logsKeys } from './keys' @@ -9,6 +8,7 @@ import { getUnifiedLogsISOStartEnd, UNIFIED_LOGS_QUERY_OPTIONS, UnifiedLogsVariables, + useUnifiedLogsBackend, } from './unified-logs-infinite-query' import { getFacetCountQuery } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries' import { @@ -65,7 +65,7 @@ export const useUnifiedLogsFacetCountQuery = ...options }: UseCustomQueryOptions = {} ) => { - const useOtel = useFlag('otelUnifiedLogs') + const useOtel = useUnifiedLogsBackend() return useQuery({ queryKey: [ ...logsKeys.unifiedLogsFacetCount(projectRef, facet, facetSearch, search), diff --git a/apps/studio/data/logs/unified-logs-infinite-query.ts b/apps/studio/data/logs/unified-logs-infinite-query.ts index cf564c9c81f..86f3c68b8cd 100644 --- a/apps/studio/data/logs/unified-logs-infinite-query.ts +++ b/apps/studio/data/logs/unified-logs-infinite-query.ts @@ -1,5 +1,5 @@ import { InfiniteData, keepPreviousData, useInfiniteQuery } from '@tanstack/react-query' -import { useFlag } from 'common' +import { useFeatureFlags } from 'common' import { executeAnalyticsSql } from './execute-analytics-sql' import { logsKeys } from './keys' @@ -29,6 +29,11 @@ export type UnifiedLogsData = any export type UnifiedLogsError = ResponseError export type UnifiedLogsVariables = { projectRef?: string; search: QuerySearchParamsType } +export const useUnifiedLogsBackend = () => { + const { otelUnifiedLogs = true } = useFeatureFlags().configcat + return typeof otelUnifiedLogs === 'boolean' ? otelUnifiedLogs : true +} + export const getUnifiedLogsISOStartEnd = ( search: QuerySearchParamsType, endHoursFromNow: number = 1 @@ -152,12 +157,11 @@ export const useUnifiedLogsInfiniteQuery = ( PageParam | null > = {} ) => { - const useOtel = useFlag('otelUnifiedLogs') + const useOtel = useUnifiedLogsBackend() return useInfiniteQuery({ queryKey: [...logsKeys.unifiedLogsInfinite(projectRef, search), { otel: useOtel }], - queryFn: ({ signal, pageParam }) => { - return getUnifiedLogs({ projectRef, search, pageParam, useOtel }, signal) - }, + queryFn: ({ signal, pageParam }) => + getUnifiedLogs({ projectRef, search, pageParam, useOtel }, signal), enabled: enabled && typeof projectRef !== 'undefined', placeholderData: keepPreviousData, getPreviousPageParam: (firstPage) => { diff --git a/apps/studio/data/oauth-apps/keys.ts b/apps/studio/data/oauth-apps/keys.ts new file mode 100644 index 00000000000..7fa7cd3de54 --- /dev/null +++ b/apps/studio/data/oauth-apps/keys.ts @@ -0,0 +1,25 @@ +export const oauthAppsKeys = { + authorizeRequest: (id: string | undefined) => ['oauth-apps', 'authorize', id] as const, + authorizeOrganizations: (id: string | undefined) => + ['oauth-apps', 'authorize', id, 'organizations'] as const, + authorizeOrganizationProjects: (id: string | undefined, slug: string | undefined) => + ['oauth-apps', 'authorize', id, 'organizations', slug, 'projects'] as const, + orgAppDetails: (slug: string | undefined, appId: string | undefined) => + ['oauth-apps', 'organizations', slug, 'apps', appId, 'grant'] as const, + preflightValidation: (slug: string | undefined, appId: string | undefined) => + ['oauth-apps', 'organizations', slug, 'apps', appId, 'preflight-validation'] as const, + // cursor omitted (rather than passed as `undefined`) so the cursor-less key is a true prefix + // of every paginated key — invalidating without a cursor clears every cached page. + approvals: (slug: string | undefined, cursor?: string) => + cursor === undefined + ? (['oauth-apps', 'approvals', slug] as const) + : (['oauth-apps', 'approvals', slug, cursor] as const), + appMemberGrants: (slug: string | undefined, appId: string | undefined, cursor?: string) => + cursor === undefined + ? (['oauth-apps', 'authorized', slug, appId, 'member-grants'] as const) + : (['oauth-apps', 'authorized', slug, appId, 'member-grants', cursor] as const), + grants: (cursor?: string) => + cursor === undefined + ? (['oauth-apps', 'grants'] as const) + : (['oauth-apps', 'grants', cursor] as const), +} diff --git a/apps/studio/data/oauth-apps/mocks.ts b/apps/studio/data/oauth-apps/mocks.ts new file mode 100644 index 00000000000..d809a743c30 --- /dev/null +++ b/apps/studio/data/oauth-apps/mocks.ts @@ -0,0 +1,622 @@ +import dayjs from 'dayjs' + +import type { OAuthAppsAuthorizeIdentity } from './oauth-apps-authorize-organizations-query' +import type { + ListOAuthApprovalsResponse, + ListOrgAppGrantsResponse, + ListOwnGrantsResponse, + OAuthAppsAuthorizeApproveResult, + OAuthAppsAuthorizeOrganizationProject, + OAuthAppsAuthorizePreflightResult, + OAuthAppsAuthorizeRequest, + OAuthExistingGrant, + OAuthOrganizationRole, + OAuthOrgAppDetails, + OAuthScope, +} from './types' +import { isPreflightValidationFailure, isWriteScope } from './types' +import type { OrganizationRole } from '@/data/organization-members/organization-roles-query' + +const ENABLE_MOCKS = true +export const USE_MOCKS = ENABLE_MOCKS && process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' + +export const OAUTH_APPS_MOCK_SCENARIOS = { + vercelDeveloper: 'mock-vercel-developer', + vercelReadOnly: 'mock-vercel-readonly', + vercelReconsent: 'mock-vercel-reconsent', + vercelOrgAdmin: 'mock-vercel-org-admin', + vercelManyProjects: 'mock-vercel-many-projects', + vercelRoleValidation: 'mock-vercel-role-validation', + vercelOptionalProjects: 'mock-vercel-optional-projects', + vercelAllProjects: 'mock-vercel-all-projects', + vercelReconsentAllProjects: 'mock-vercel-reconsent-all-projects', + kemalBot: 'mock-kemal-bot', + kemalBotOrgWide: 'mock-kemal-bot-org-wide', + dynamicMcpClient: 'mock-dynamic-mcp-client', +} as const + +export const OWNER_ROLE: OrganizationRole = { + id: 1, + base_role_id: 1, + name: 'Owner', + description: null, + projects: [], +} + +export const ADMINISTRATOR_ROLE: OrganizationRole = { + id: 2, + base_role_id: 2, + name: 'Administrator', + description: null, + projects: [], +} + +export const DEVELOPER_ROLE: OrganizationRole = { + id: 3, + base_role_id: 3, + name: 'Developer', + description: null, + projects: [], +} + +export const READ_ONLY_ROLE: OrganizationRole = { + id: 4, + base_role_id: 4, + name: 'Read-only', + description: null, + projects: [], +} + +const VERCEL_SCOPES: OAuthScope[] = [ + 'database:read', + 'database:write', + 'environment:read', + 'environment:write', + 'secrets:read', + 'secrets:write', + 'projects:read', + 'edge_functions:read', + 'storage:read', +] + +const VERCEL_REQUEST: OAuthAppsAuthorizeRequest = { + app_id: 'vercel', + app_name: 'Vercel', + name: 'Vercel', + website: 'https://vercel.com', + domain: 'vercel.com', + redirect_uri: 'https://vercel.com/api/integrations/supabase/callback', + registration_type: 'manual', + expires_at: dayjs().add(1, 'month').toISOString(), + grant_kind: 'member_bound', + project_scoping_mode: true, + scopes: VERCEL_SCOPES, +} + +const VERCEL_OPTIONAL_PROJECTS_REQUEST: OAuthAppsAuthorizeRequest = { + ...VERCEL_REQUEST, + app_id: 'vercel-optional', + project_scoping_mode: true, +} + +const VERCEL_ALL_PROJECTS_REQUEST: OAuthAppsAuthorizeRequest = { + ...VERCEL_REQUEST, + app_id: 'vercel-org-wide', + project_scoping_mode: false, +} + +const DYNAMIC_MCP_CLIENT_REQUEST: OAuthAppsAuthorizeRequest = { + ...VERCEL_REQUEST, + app_id: 'dynamic-mcp-client', + app_name: 'Northwind MCP', + name: 'Northwind MCP', + website: 'https://mcp.northwind.example', + domain: 'mcp.northwind.example', + redirect_uri: 'https://mcp.northwind.example/callback', + registration_type: 'dynamic', + // DCR (MCP) oauth apps are forced org-bound, unscoped. + grant_kind: 'organization_bound', + project_scoping_mode: false, +} + +const KEMAL_BOT_REQUEST: OAuthAppsAuthorizeRequest = { + app_id: 'kemal-bot', + app_name: 'kemal-bot', + name: 'kemal-bot', + website: 'https://kemal.lol', + domain: 'kemal.lol', + redirect_uri: 'https://kemal.lol/hollerback', + registration_type: 'manual', + expires_at: dayjs().add(1, 'month').toISOString(), + grant_kind: 'organization_bound', + project_scoping_mode: true, + scopes: ['projects:read'], +} + +const KEMAL_BOT_ORG_WIDE_REQUEST: OAuthAppsAuthorizeRequest = { + ...KEMAL_BOT_REQUEST, + app_id: 'kemal-bot-org-wide', + project_scoping_mode: false, +} + +const MOCK_AUTHORIZE_REQUESTS: Record = { + [OAUTH_APPS_MOCK_SCENARIOS.vercelDeveloper]: VERCEL_REQUEST, + [OAUTH_APPS_MOCK_SCENARIOS.vercelReadOnly]: VERCEL_REQUEST, + [OAUTH_APPS_MOCK_SCENARIOS.vercelReconsent]: VERCEL_REQUEST, + [OAUTH_APPS_MOCK_SCENARIOS.vercelOrgAdmin]: VERCEL_REQUEST, + [OAUTH_APPS_MOCK_SCENARIOS.vercelManyProjects]: VERCEL_REQUEST, + [OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation]: VERCEL_REQUEST, + [OAUTH_APPS_MOCK_SCENARIOS.vercelOptionalProjects]: VERCEL_OPTIONAL_PROJECTS_REQUEST, + [OAUTH_APPS_MOCK_SCENARIOS.vercelAllProjects]: VERCEL_ALL_PROJECTS_REQUEST, + [OAUTH_APPS_MOCK_SCENARIOS.vercelReconsentAllProjects]: VERCEL_OPTIONAL_PROJECTS_REQUEST, + [OAUTH_APPS_MOCK_SCENARIOS.kemalBot]: KEMAL_BOT_REQUEST, + [OAUTH_APPS_MOCK_SCENARIOS.kemalBotOrgWide]: KEMAL_BOT_ORG_WIDE_REQUEST, + [OAUTH_APPS_MOCK_SCENARIOS.dynamicMcpClient]: DYNAMIC_MCP_CLIENT_REQUEST, +} + +const NORTHWIND_TRADERS_DEVELOPER: OAuthOrganizationRole = { + slug: 'northwind-traders', + name: 'Northwind Traders', + default_role: DEVELOPER_ROLE, +} + +const NORTHWIND_TRADERS_READ_ONLY: OAuthOrganizationRole = { + ...NORTHWIND_TRADERS_DEVELOPER, + default_role: READ_ONLY_ROLE, +} + +const CONTOSO_LABS: OAuthOrganizationRole = { + slug: 'contoso-labs', + name: 'Contoso Labs', + default_role: OWNER_ROLE, +} + +const TAILSPIN_TOYS_ADMIN: OAuthOrganizationRole = { + slug: 'tailspin-toys', + name: 'Tailspin Toys', + default_role: ADMINISTRATOR_ROLE, +} + +const FABRIKAM_OWNER: OAuthOrganizationRole = { + slug: 'fabrikam-industries', + name: 'Fabrikam Industries', + default_role: OWNER_ROLE, +} + +const WINGTIP_TOYS_DEVELOPER: OAuthOrganizationRole = { + slug: 'wingtip-toys', + name: 'Wingtip Toys', + default_role: DEVELOPER_ROLE, +} + +const LITWARE_DEVELOPER: OAuthOrganizationRole = { + slug: 'litware-inc', + name: 'Litware Inc.', + default_role: DEVELOPER_ROLE, +} + +const MOCK_IDENTITIES: Record = { + [OAUTH_APPS_MOCK_SCENARIOS.vercelDeveloper]: { + email: 'admin@example.com', + organizations: [NORTHWIND_TRADERS_DEVELOPER, CONTOSO_LABS], + }, + [OAUTH_APPS_MOCK_SCENARIOS.vercelReadOnly]: { + email: 'admin@example.com', + organizations: [NORTHWIND_TRADERS_READ_ONLY, CONTOSO_LABS], + }, + [OAUTH_APPS_MOCK_SCENARIOS.vercelReconsent]: { + email: 'admin@example.com', + organizations: [TAILSPIN_TOYS_ADMIN, NORTHWIND_TRADERS_DEVELOPER], + }, + [OAUTH_APPS_MOCK_SCENARIOS.vercelOrgAdmin]: { + email: 'admin@example.com', + organizations: [TAILSPIN_TOYS_ADMIN, FABRIKAM_OWNER, NORTHWIND_TRADERS_DEVELOPER], + }, + [OAUTH_APPS_MOCK_SCENARIOS.vercelManyProjects]: { + email: 'admin@example.com', + organizations: [WINGTIP_TOYS_DEVELOPER, NORTHWIND_TRADERS_DEVELOPER], + }, + [OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation]: { + email: 'admin@example.com', + organizations: [NORTHWIND_TRADERS_DEVELOPER, CONTOSO_LABS], + }, + [OAUTH_APPS_MOCK_SCENARIOS.vercelOptionalProjects]: { + email: 'admin@example.com', + organizations: [NORTHWIND_TRADERS_DEVELOPER, CONTOSO_LABS], + }, + [OAUTH_APPS_MOCK_SCENARIOS.vercelAllProjects]: { + email: 'admin@example.com', + organizations: [NORTHWIND_TRADERS_READ_ONLY, CONTOSO_LABS, FABRIKAM_OWNER], + }, + [OAUTH_APPS_MOCK_SCENARIOS.vercelReconsentAllProjects]: { + email: 'admin@example.com', + organizations: [TAILSPIN_TOYS_ADMIN, NORTHWIND_TRADERS_DEVELOPER], + }, + [OAUTH_APPS_MOCK_SCENARIOS.kemalBot]: { + email: 'admin@example.com', + organizations: [NORTHWIND_TRADERS_DEVELOPER, CONTOSO_LABS], + }, + [OAUTH_APPS_MOCK_SCENARIOS.kemalBotOrgWide]: { + email: 'admin@example.com', + organizations: [NORTHWIND_TRADERS_DEVELOPER, FABRIKAM_OWNER], + }, + [OAUTH_APPS_MOCK_SCENARIOS.dynamicMcpClient]: { + email: 'admin@example.com', + organizations: [NORTHWIND_TRADERS_DEVELOPER, CONTOSO_LABS], + }, +} + +const MOCK_ORGANIZATION_PROJECTS: Record = { + 'northwind-traders': [ + { ref: 'northwindstorefront1', name: 'northwind-storefront', role: ADMINISTRATOR_ROLE }, + { ref: 'northwindcms1', name: 'northwind-cms', role: DEVELOPER_ROLE }, + { ref: 'fabrikamapi1', name: 'fabrikam-api', role: READ_ONLY_ROLE }, + { ref: 'fabrikamjobs1', name: 'fabrikam-jobs', role: READ_ONLY_ROLE }, + ], + 'wingtip-toys': [ + { ref: 'wingtipweb1', name: 'wingtip-web', role: ADMINISTRATOR_ROLE }, + { ref: 'wingtipapi1', name: 'wingtip-api', role: DEVELOPER_ROLE }, + { ref: 'wingtipadmin1', name: 'wingtip-admin', role: DEVELOPER_ROLE }, + { ref: 'wingtipjobs1', name: 'wingtip-jobs', role: DEVELOPER_ROLE }, + { ref: 'wingtipsearch1', name: 'wingtip-search', role: READ_ONLY_ROLE }, + { ref: 'wingtipbilling1', name: 'wingtip-billing', role: ADMINISTRATOR_ROLE }, + { ref: 'wingtipmail1', name: 'wingtip-mail', role: DEVELOPER_ROLE }, + { ref: 'wingtipmedia1', name: 'wingtip-media', role: DEVELOPER_ROLE }, + { ref: 'wingtipmetrics1', name: 'wingtip-metrics', role: READ_ONLY_ROLE }, + { ref: 'wingtipstaging1', name: 'wingtip-staging', role: DEVELOPER_ROLE }, + { ref: 'wingtippreview1', name: 'wingtip-preview', role: DEVELOPER_ROLE }, + { ref: 'wingtipsandbox1', name: 'wingtip-sandbox', role: READ_ONLY_ROLE }, + ], + 'fabrikam-industries': [ + { ref: 'fabrikamledger1', name: 'fabrikam-ledger', role: ADMINISTRATOR_ROLE }, + ], + 'tailspin-toys': [ + { ref: 'tailspinshop1', name: 'tailspin-shop', role: ADMINISTRATOR_ROLE }, + { ref: 'tailspinwarehouse1', name: 'tailspin-warehouse', role: DEVELOPER_ROLE }, + ], + 'litware-inc': [{ ref: 'litwarecrm1', name: 'litware-crm', role: DEVELOPER_ROLE }], + 'contoso-labs': [], +} + +const TAILSPIN_VERCEL_EXISTING_GRANT: OAuthExistingGrant = { + approved_scopes: ['database:read', 'database:write', 'projects:read'], + project_refs: ['tailspinshop1', 'tailspindeleted1'], + approved_at: '2026-08-14T09:12:00.000Z', +} + +const TAILSPIN_VERCEL_OPTIONAL_EXISTING_GRANT: OAuthExistingGrant = { + approved_scopes: ['database:read', 'projects:read'], + project_refs: [], + approved_at: '2026-08-29T16:40:00.000Z', +} + +const DEFAULT_ORG_APP_DETAILS: OAuthOrgAppDetails = { + existing_grant: null, +} + +const MOCK_ORG_APP_DETAILS: Record> = { + 'tailspin-toys': { + vercel: { existing_grant: TAILSPIN_VERCEL_EXISTING_GRANT }, + 'vercel-optional': { existing_grant: TAILSPIN_VERCEL_OPTIONAL_EXISTING_GRANT }, + }, +} + +// Real oauth_apps config, keyed by app_id — preflight-validation is org+app scoped, not tied to +// an in-flight authorization request. +const MOCK_APPS_BY_ID: Record = { + vercel: VERCEL_REQUEST, + 'vercel-optional': VERCEL_OPTIONAL_PROJECTS_REQUEST, + 'vercel-org-wide': VERCEL_ALL_PROJECTS_REQUEST, + 'dynamic-mcp-client': DYNAMIC_MCP_CLIENT_REQUEST, + 'kemal-bot': KEMAL_BOT_REQUEST, + 'kemal-bot-org-wide': KEMAL_BOT_ORG_WIDE_REQUEST, +} + +// The current member's org-level role, keyed by org slug — same reasoning as MOCK_APPS_BY_ID. +const MOCK_ORGANIZATIONS_BY_SLUG: Record = { + 'northwind-traders': NORTHWIND_TRADERS_READ_ONLY, + 'tailspin-toys': TAILSPIN_TOYS_ADMIN, + 'fabrikam-industries': FABRIKAM_OWNER, + 'contoso-labs': CONTOSO_LABS, + 'wingtip-toys': WINGTIP_TOYS_DEVELOPER, + 'litware-inc': LITWARE_DEVELOPER, +} + +const MOCK_APPROVALS: ListOAuthApprovalsResponse = { + data: [ + { + app: { + id: 'vercel', + name: 'Vercel', + icon: null, + created_by: '', + }, + grant_target: 'members', + org_grant: null, + }, + { + app: { + id: 'dynamic-mcp-client', + name: 'Northwind MCP', + icon: null, + created_by: '', + }, + grant_target: 'members', + org_grant: null, + }, + { + app: { + id: 'contoso-analytics', + name: 'Contoso Analytics', + icon: null, + created_by: '', + }, + grant_target: 'organization', + org_grant: { + grant_id: 'grant-contoso-analytics-org', + approved_scopes: ['analytics:read', 'projects:read'], + approved_at: '2025-11-03T14:20:00.000Z', + }, + }, + ], + pagination: { next_cursor: null }, +} + +const MOCK_APP_GRANTS: Record = { + vercel: { + data: [ + { + grant_id: 'grant-vercel-admin', + kind: 'member_bound', + user: { + gotrue_id: 'b1d3e2f4-0000-4000-8000-000000000001', + email: 'admin@example.com', + }, + projects: [ + { ref: 'northwindstorefront1', name: 'northwind-storefront' }, + { ref: 'northwindcms1', name: 'northwind-cms' }, + ], + approved_scopes: ['database:read', 'database:write', 'projects:read'], + approved_at: '2026-08-18T09:12:00.000Z', + }, + { + grant_id: 'grant-vercel-developer', + kind: 'member_bound', + user: { + gotrue_id: 'b1d3e2f4-0000-4000-8000-000000000002', + email: 'developer@example.com', + avatar_url: 'https://avatars.example/developer.png', + }, + projects: [{ ref: 'northwindcms1', name: 'northwind-cms' }], + approved_scopes: ['projects:read'], + approved_at: '2026-08-16T11:30:00.000Z', + }, + ], + pagination: { next_cursor: null }, + }, + 'dynamic-mcp-client': { + data: [ + { + grant_id: 'grant-northwind-mcp-ops', + kind: 'member_bound', + user: { + gotrue_id: 'b1d3e2f4-0000-4000-8000-000000000003', + email: 'ops@example.com', + }, + projects: [{ ref: 'northwindstorefront1', name: 'northwind-storefront' }], + approved_scopes: ['database:read', 'database:write'], + approved_at: '2026-09-01T08:45:00.000Z', + }, + ], + pagination: { next_cursor: null }, + }, + 'contoso-analytics': { + data: [ + { + grant_id: 'grant-contoso-analytics-org', + kind: 'organization_bound', + user: null, + projects: null, + approved_scopes: ['analytics:read', 'projects:read'], + approved_at: '2025-11-03T14:20:00.000Z', + }, + ], + pagination: { next_cursor: null }, + }, +} + +const MOCK_OWN_GRANTS: ListOwnGrantsResponse = { + data: [ + { + grant_id: 'grant-vercel-admin', + app: { id: 'vercel', name: 'Vercel', icon: null }, + organization: { slug: 'northwind-traders', name: 'Northwind Traders' }, + projects: [ + { ref: 'northwindstorefront1', name: 'northwind-storefront' }, + { ref: 'northwindcms1', name: 'northwind-cms' }, + ], + approved_scopes: ['database:read', 'database:write', 'projects:read'], + approved_at: '2026-08-18T09:12:00.000Z', + }, + { + grant_id: 'grant-northwind-mcp-ops', + app: { id: 'dynamic-mcp-client', name: 'Northwind MCP', icon: null }, + organization: { slug: 'northwind-traders', name: 'Northwind Traders' }, + projects: [{ ref: 'northwindstorefront1', name: 'northwind-storefront' }], + approved_scopes: ['database:read', 'database:write'], + approved_at: '2026-09-01T08:45:00.000Z', + }, + { + grant_id: 'kemal-bot-all-projects', + app: { id: 'kemal-bot', name: 'kemal-bot', icon: null }, + organization: { slug: 'northwind-traders', name: 'Northwind Traders' }, + // Targets all projects + projects: null, + approved_scopes: ['database:read', 'database:write'], + approved_at: '2026-09-20T14:30:00.000Z', + }, + ], + pagination: { next_cursor: null }, +} + +// Fixture lists are small and static — cursor is accepted for real-endpoint parity but not +// used to actually paginate the mock data. +export function getMockOAuthApprovals(_cursor?: string): ListOAuthApprovalsResponse { + return MOCK_APPROVALS +} + +export function getMockOAuthAppGrants(appId: string, _cursor?: string): ListOrgAppGrantsResponse { + return MOCK_APP_GRANTS[appId] ?? { data: [], pagination: { next_cursor: null } } +} + +export function getMockOAuthOwnGrants(_cursor?: string): ListOwnGrantsResponse { + return MOCK_OWN_GRANTS +} + +export function getMockOAuthAppsAuthorizeRequest(authId: string): OAuthAppsAuthorizeRequest { + const request = MOCK_AUTHORIZE_REQUESTS[authId] + if (!request) throw new Error(`No mock authorize request for id "${authId}"`) + return request +} + +export function getMockOAuthAppsAuthorizeIdentity(authId: string): OAuthAppsAuthorizeIdentity { + const identity = MOCK_IDENTITIES[authId] + if (!identity) throw new Error(`No mock identity for id "${authId}"`) + return identity +} + +export function getMockOAuthAppsAuthorizeOrganizationProjects( + slug: string +): OAuthAppsAuthorizeOrganizationProject[] { + return MOCK_ORGANIZATION_PROJECTS[slug] ?? [] +} + +export function getMockOAuthOrgAppDetails(slug: string, appId: string): OAuthOrgAppDetails { + return MOCK_ORG_APP_DETAILS[slug]?.[appId] ?? DEFAULT_ORG_APP_DETAILS +} + +function findMockOrganization(authId: string, slug: string): OAuthOrganizationRole | undefined { + return getMockOAuthAppsAuthorizeIdentity(authId).organizations.find( + (candidate) => candidate.slug === slug + ) +} + +const MOCK_OAUTH_STATE = 'mock_state_9f2c1b' + +function buildMockRedirectUrl(redirectUri: string, params: Record) { + const url = new URL(redirectUri) + Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value)) + return url.toString() +} + +export function getMockOAuthAppsAuthorizeRedirect( + authId: string, + { approved }: { approved: boolean } +) { + const request = getMockOAuthAppsAuthorizeRequest(authId) + const url = approved + ? buildMockRedirectUrl(request.redirect_uri, { + code: 'mock_authorization_code', + state: MOCK_OAUTH_STATE, + }) + : buildMockRedirectUrl(request.redirect_uri, { + error: 'access_denied', + error_description: 'The user denied the authorization request', + state: MOCK_OAUTH_STATE, + }) + + return { url } +} + +const ROLE_VALIDATED_SCENARIOS = new Set([ + OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation, + OAUTH_APPS_MOCK_SCENARIOS.vercelAllProjects, + OAUTH_APPS_MOCK_SCENARIOS.kemalBotOrgWide, +]) + +function isReadOnlyRole(role: OrganizationRole) { + return role.id === READ_ONLY_ROLE.id +} + +function isOwnerOrAdmin(role: OrganizationRole) { + return role.id === OWNER_ROLE.id || role.id === ADMINISTRATOR_ROLE.id +} + +// Upfront org-level check shared by preflight-validation and the "all projects" branch of approve. +// Never checks project-scoped roles (that's approve's job once project_refs are known). +function evaluateGrantEligibility( + request: OAuthAppsAuthorizeRequest, + organization: OAuthOrganizationRole +): OAuthAppsAuthorizePreflightResult { + if (request.grant_kind === 'organization_bound') { + if (isOwnerOrAdmin(organization.default_role)) return { ok: true } + return { + error_code: 'role_validation_failed', + message: `Your ${organization.default_role.name} role cannot install this app for the organization.`, + validation: { scope_target: 'organization', role: organization.default_role }, + } + } + + const failedScopes = request.scopes.filter(isWriteScope) + if (failedScopes.length === 0 || !isReadOnlyRole(organization.default_role)) return { ok: true } + + return { + error_code: 'role_validation_failed', + message: `Your ${organization.default_role.name} role cannot satisfy this app's scopes for all projects.`, + validation: { + scope_target: 'all_projects', + role: organization.default_role, + failed_scopes: failedScopes, + }, + } +} + +export function getMockOAuthAppsPreflightValidation( + slug: string, + appId: string +): OAuthAppsAuthorizePreflightResult { + const request = MOCK_APPS_BY_ID[appId] + const organization = MOCK_ORGANIZATIONS_BY_SLUG[slug] + if (!request || !organization) return { ok: true } + + return evaluateGrantEligibility(request, organization) +} + +export function getMockOAuthAppsAuthorizeApproveResult( + authId: string, + { slug, projectRefs }: { slug: string; projectRefs: string[] | undefined } +): OAuthAppsAuthorizeApproveResult { + const approved = getMockOAuthAppsAuthorizeRedirect(authId, { approved: true }) + if (!ROLE_VALIDATED_SCENARIOS.has(authId)) return approved + + const request = getMockOAuthAppsAuthorizeRequest(authId) + const organization = findMockOrganization(authId, slug) + if (!organization) return approved + + if (projectRefs === undefined || request.grant_kind === 'organization_bound') { + const result = evaluateGrantEligibility(request, organization) + return isPreflightValidationFailure(result) ? result : approved + } + + const failedScopes = request.scopes.filter(isWriteScope) + if (failedScopes.length === 0) return approved + + const blocked = getMockOAuthAppsAuthorizeOrganizationProjects(slug).filter( + (project) => projectRefs.includes(project.ref) && isReadOnlyRole(project.role) + ) + if (blocked.length === 0) return approved + + return { + error_code: 'role_validation_failed', + message: `Your role is read-only on ${blocked.length} of the selected projects.`, + validation: { + scope_target: 'projects', + failures: blocked.map(({ name, ref, role }) => ({ + ref, + name, + role, + failed_scopes: failedScopes, + })), + }, + } +} diff --git a/apps/studio/data/oauth-apps/oauth-apps-approvals-query.ts b/apps/studio/data/oauth-apps/oauth-apps-approvals-query.ts new file mode 100644 index 00000000000..f440618196b --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-approvals-query.ts @@ -0,0 +1,40 @@ +import { useQuery } from '@tanstack/react-query' + +import { oauthAppsKeys } from './keys' +import { getMockOAuthApprovals, USE_MOCKS } from './mocks' +import type { ListOAuthApprovalsResponse } from './types' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type OAuthApprovalsVariables = { + slug?: string + cursor?: string +} + +export type { ListOAuthApprovalsResponse, OAuthApprovalItem } from './types' + +export async function getOAuthApprovals({ + slug, + cursor, +}: OAuthApprovalsVariables): Promise { + if (!slug) throw new Error('Organization slug is required') + if (!USE_MOCKS) throw new Error('OAuth app approvals are not yet implemented') + + return getMockOAuthApprovals(cursor) +} + +export type OAuthApprovalsData = Awaited> +export type OAuthApprovalsError = ResponseError + +export const useOAuthApprovalsQuery = ( + { slug, cursor }: OAuthApprovalsVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => + useQuery({ + queryKey: oauthAppsKeys.approvals(slug, cursor), + queryFn: () => getOAuthApprovals({ slug, cursor }), + enabled: enabled && USE_MOCKS && Boolean(slug), + ...options, + }) diff --git a/apps/studio/data/oauth-apps/oauth-apps-authorize-approve-mutation.ts b/apps/studio/data/oauth-apps/oauth-apps-authorize-approve-mutation.ts new file mode 100644 index 00000000000..d3da65fb547 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-authorize-approve-mutation.ts @@ -0,0 +1,56 @@ +import { useMutation } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { getMockOAuthAppsAuthorizeApproveResult, USE_MOCKS } from './mocks' +import type { OAuthAppsAuthorizeApproveResult, OAuthAuthorizeApproveRequest } from './types' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type OAuthAppsAuthorizeApproveVariables = { + slug: string + auth_id: string + body: OAuthAuthorizeApproveRequest +} + +export type OAuthAppsAuthorizeApproveResponse = OAuthAppsAuthorizeApproveResult + +export async function approveOAuthAppsAuthorize({ + slug, + auth_id, + body, +}: OAuthAppsAuthorizeApproveVariables): Promise { + if (!auth_id) throw new Error('Authorization request id is required') + if (!slug) throw new Error('Organization slug is required') + if (!USE_MOCKS) throw new Error('OAuth app authorization approval is not yet implemented') + + return getMockOAuthAppsAuthorizeApproveResult(auth_id, { slug, projectRefs: body.project_refs }) +} + +type OAuthAppsAuthorizeApproveData = Awaited> + +export const useOAuthAppsAuthorizeApproveMutation = ({ + onError, + ...options +}: Omit< + UseCustomMutationOptions< + OAuthAppsAuthorizeApproveData, + ResponseError, + OAuthAppsAuthorizeApproveVariables + >, + 'mutationFn' +> = {}) => { + return useMutation< + OAuthAppsAuthorizeApproveData, + ResponseError, + OAuthAppsAuthorizeApproveVariables + >({ + mutationFn: (vars) => approveOAuthAppsAuthorize(vars), + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to authorize access to OAuth app: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/oauth-apps/oauth-apps-authorize-deny-mutation.ts b/apps/studio/data/oauth-apps/oauth-apps-authorize-deny-mutation.ts new file mode 100644 index 00000000000..e54431cd965 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-authorize-deny-mutation.ts @@ -0,0 +1,47 @@ +import { useMutation } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { getMockOAuthAppsAuthorizeRedirect, USE_MOCKS } from './mocks' +import type { OAuthAppsAuthorizeRedirect } from './types' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type OAuthAppsAuthorizeDenyVariables = { + slug: string + auth_id: string +} + +export type OAuthAppsAuthorizeDenyResponse = OAuthAppsAuthorizeRedirect + +export async function denyOAuthAppsAuthorize({ slug, auth_id }: OAuthAppsAuthorizeDenyVariables) { + if (!auth_id) throw new Error('Authorization request id is required') + if (!slug) throw new Error('Organization slug is required') + if (!USE_MOCKS) throw new Error('OAuth app authorization denial is not yet implemented') + + return getMockOAuthAppsAuthorizeRedirect(auth_id, { approved: false }) +} + +type OAuthAppsAuthorizeDenyData = Awaited> + +export const useOAuthAppsAuthorizeDenyMutation = ({ + onError, + ...options +}: Omit< + UseCustomMutationOptions< + OAuthAppsAuthorizeDenyData, + ResponseError, + OAuthAppsAuthorizeDenyVariables + >, + 'mutationFn' +> = {}) => { + return useMutation({ + mutationFn: (vars) => denyOAuthAppsAuthorize(vars), + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to deny access to OAuth app: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/oauth-apps/oauth-apps-authorize-mutations.test.ts b/apps/studio/data/oauth-apps/oauth-apps-authorize-mutations.test.ts new file mode 100644 index 00000000000..f53884948b3 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-authorize-mutations.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'vitest' + +import { OAUTH_APPS_MOCK_SCENARIOS } from './mocks' +import { approveOAuthAppsAuthorize } from './oauth-apps-authorize-approve-mutation' +import { denyOAuthAppsAuthorize } from './oauth-apps-authorize-deny-mutation' +import { isRoleValidationFailure } from './types' + +const AUTH_ID = OAUTH_APPS_MOCK_SCENARIOS.vercelDeveloper +const SLUG = 'northwind-traders' +const BODY = { project_refs: ['northwindstorefront1'] } + +async function approveExpectingRedirect( + variables: Parameters[0] +) { + const result = await approveOAuthAppsAuthorize(variables) + if (isRoleValidationFailure(result)) throw new Error('expected a redirect, got a role failure') + return result +} + +describe('approveOAuthAppsAuthorize', () => { + test('returns a redirect url carrying both code and state', async () => { + const { url } = await approveExpectingRedirect({ slug: SLUG, auth_id: AUTH_ID, body: BODY }) + + const params = new URL(url).searchParams + expect(params.get('code')).toBeTruthy() + expect(params.get('state')).toBeTruthy() + expect(params.get('error')).toBeNull() + }) + + test('accepts a body without project_refs', async () => { + const { url } = await approveExpectingRedirect({ slug: SLUG, auth_id: AUTH_ID, body: {} }) + + expect(new URL(url).searchParams.get('code')).toBeTruthy() + }) + + test('requires an authorization request id', async () => { + await expect( + approveOAuthAppsAuthorize({ slug: SLUG, auth_id: '', body: BODY }) + ).rejects.toThrow('Authorization request id is required') + }) + + test('requires an organization slug', async () => { + await expect( + approveOAuthAppsAuthorize({ slug: '', auth_id: AUTH_ID, body: BODY }) + ).rejects.toThrow('Organization slug is required') + }) + + test('does not enforce a selection cap', async () => { + const manyRefs = Array.from({ length: 25 }, (_, index) => `ref-${index}`) + + const { url } = await approveExpectingRedirect({ + slug: SLUG, + auth_id: AUTH_ID, + body: { project_refs: manyRefs }, + }) + + expect(url).toBeTruthy() + }) +}) + +describe('denyOAuthAppsAuthorize', () => { + test('returns a redirect url carrying access_denied and state', async () => { + const { url } = await denyOAuthAppsAuthorize({ slug: SLUG, auth_id: AUTH_ID }) + + const params = new URL(url).searchParams + expect(params.get('error')).toBe('access_denied') + expect(params.get('state')).toBeTruthy() + expect(params.get('code')).toBeNull() + }) + + test('requires an authorization request id', async () => { + await expect(denyOAuthAppsAuthorize({ slug: SLUG, auth_id: '' })).rejects.toThrow( + 'Authorization request id is required' + ) + }) + + test('requires an organization slug', async () => { + await expect(denyOAuthAppsAuthorize({ slug: '', auth_id: AUTH_ID })).rejects.toThrow( + 'Organization slug is required' + ) + }) +}) diff --git a/apps/studio/data/oauth-apps/oauth-apps-authorize-organization-projects-query.ts b/apps/studio/data/oauth-apps/oauth-apps-authorize-organization-projects-query.ts new file mode 100644 index 00000000000..8881f59a2b1 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-authorize-organization-projects-query.ts @@ -0,0 +1,53 @@ +import { useQuery } from '@tanstack/react-query' + +import { oauthAppsKeys } from './keys' +import { getMockOAuthAppsAuthorizeOrganizationProjects, USE_MOCKS } from './mocks' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type OAuthAppsAuthorizeOrganizationProjectsVariables = { + id?: string + slug?: string +} + +export type { OAuthAppsAuthorizeOrganizationProject } from './types' + +export async function getOAuthAppsAuthorizeOrganizationProjects({ + id, + slug, +}: OAuthAppsAuthorizeOrganizationProjectsVariables) { + if (!id) throw new Error('Authorization request id is required') + if (!slug) throw new Error('Organization slug is required') + if (!USE_MOCKS) + throw new Error('OAuth app authorization organization projects are not yet implemented') + + return getMockOAuthAppsAuthorizeOrganizationProjects(slug) +} + +export type OAuthAppsAuthorizeOrganizationProjectsData = Awaited< + ReturnType +> +export type OAuthAppsAuthorizeOrganizationProjectsError = ResponseError + +export const useOAuthAppsAuthorizeOrganizationProjectsQuery = < + TData = OAuthAppsAuthorizeOrganizationProjectsData, +>( + { id, slug }: OAuthAppsAuthorizeOrganizationProjectsVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions< + OAuthAppsAuthorizeOrganizationProjectsData, + OAuthAppsAuthorizeOrganizationProjectsError, + TData + > = {} +) => + useQuery< + OAuthAppsAuthorizeOrganizationProjectsData, + OAuthAppsAuthorizeOrganizationProjectsError, + TData + >({ + queryKey: oauthAppsKeys.authorizeOrganizationProjects(id, slug), + queryFn: () => getOAuthAppsAuthorizeOrganizationProjects({ id, slug }), + enabled: enabled && USE_MOCKS && Boolean(id) && Boolean(slug), + ...options, + }) diff --git a/apps/studio/data/oauth-apps/oauth-apps-authorize-organizations-query.ts b/apps/studio/data/oauth-apps/oauth-apps-authorize-organizations-query.ts new file mode 100644 index 00000000000..c41bf4992f3 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-authorize-organizations-query.ts @@ -0,0 +1,49 @@ +import { useQuery } from '@tanstack/react-query' + +import { oauthAppsKeys } from './keys' +import { getMockOAuthAppsAuthorizeIdentity, USE_MOCKS } from './mocks' +import type { OAuthOrganizationRole } from './types' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type OAuthAppsAuthorizeOrganizationsVariables = { + id?: string +} + +export type OAuthAppsAuthorizeIdentity = { + email: string + organizations: OAuthOrganizationRole[] +} + +export async function getOAuthAppsAuthorizeIdentity({ + id, +}: OAuthAppsAuthorizeOrganizationsVariables) { + if (!id) throw new Error('Authorization request id is required') + if (!USE_MOCKS) throw new Error('OAuth app authorization identity is not yet implemented') + + return getMockOAuthAppsAuthorizeIdentity(id) +} + +export type OAuthAppsAuthorizeOrganizationsData = Awaited< + ReturnType +> +export type OAuthAppsAuthorizeOrganizationsError = ResponseError + +export const useOAuthAppsAuthorizeOrganizationsQuery = < + TData = OAuthAppsAuthorizeOrganizationsData, +>( + { id }: OAuthAppsAuthorizeOrganizationsVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions< + OAuthAppsAuthorizeOrganizationsData, + OAuthAppsAuthorizeOrganizationsError, + TData + > = {} +) => + useQuery({ + queryKey: oauthAppsKeys.authorizeOrganizations(id), + queryFn: () => getOAuthAppsAuthorizeIdentity({ id }), + enabled: enabled && USE_MOCKS && Boolean(id), + ...options, + }) diff --git a/apps/studio/data/oauth-apps/oauth-apps-authorize-request-query.ts b/apps/studio/data/oauth-apps/oauth-apps-authorize-request-query.ts new file mode 100644 index 00000000000..a231073e086 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-authorize-request-query.ts @@ -0,0 +1,42 @@ +import { useQuery } from '@tanstack/react-query' + +import { oauthAppsKeys } from './keys' +import { getMockOAuthAppsAuthorizeRequest, USE_MOCKS } from './mocks' +import type { OAuthAppsAuthorizeRequest } from './types' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type OAuthAppsAuthorizeRequestVariables = { + id?: string +} + +export type { OAuthAppsAuthorizeRequest } from './types' + +export async function getOAuthAppsAuthorizeRequest({ + id, +}: OAuthAppsAuthorizeRequestVariables): Promise { + if (!id) throw new Error('Authorization request id is required') + if (!USE_MOCKS) throw new Error('OAuth app authorization request is not yet implemented') + + return getMockOAuthAppsAuthorizeRequest(id) +} + +export type OAuthAppsAuthorizeRequestData = Awaited> +export type OAuthAppsAuthorizeRequestError = ResponseError + +export const useOAuthAppsAuthorizeRequestQuery = ( + { id }: OAuthAppsAuthorizeRequestVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions< + OAuthAppsAuthorizeRequestData, + OAuthAppsAuthorizeRequestError, + TData + > = {} +) => + useQuery({ + queryKey: oauthAppsKeys.authorizeRequest(id), + queryFn: () => getOAuthAppsAuthorizeRequest({ id }), + enabled: enabled && USE_MOCKS && Boolean(id), + ...options, + }) diff --git a/apps/studio/data/oauth-apps/oauth-apps-grants-query.ts b/apps/studio/data/oauth-apps/oauth-apps-grants-query.ts new file mode 100644 index 00000000000..f5f300fc160 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-grants-query.ts @@ -0,0 +1,37 @@ +import { useQuery } from '@tanstack/react-query' + +import { oauthAppsKeys } from './keys' +import { getMockOAuthOwnGrants, USE_MOCKS } from './mocks' +import type { ListOwnGrantsResponse } from './types' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type OAuthGrantsVariables = { + cursor?: string +} + +export type { ListOwnGrantsResponse, MemberOauthGrantItem, OAuthGrantProject } from './types' + +export async function getOAuthGrants({ + cursor, +}: OAuthGrantsVariables = {}): Promise { + if (!USE_MOCKS) throw new Error('OAuth grants are not yet implemented') + + return getMockOAuthOwnGrants(cursor) +} + +export type OAuthGrantsData = Awaited> +export type OAuthGrantsError = ResponseError + +export const useOAuthGrantsQuery = ( + { cursor }: OAuthGrantsVariables = {}, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => + useQuery({ + queryKey: oauthAppsKeys.grants(cursor), + queryFn: () => getOAuthGrants({ cursor }), + enabled: enabled && USE_MOCKS, + ...options, + }) diff --git a/apps/studio/data/oauth-apps/oauth-apps-member-grants-query.ts b/apps/studio/data/oauth-apps/oauth-apps-member-grants-query.ts new file mode 100644 index 00000000000..6cef0997500 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-member-grants-query.ts @@ -0,0 +1,43 @@ +import { useQuery } from '@tanstack/react-query' + +import { oauthAppsKeys } from './keys' +import { getMockOAuthAppGrants, USE_MOCKS } from './mocks' +import type { ListOrgAppGrantsResponse } from './types' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type OAuthAppMemberGrantsVariables = { + slug?: string + appId?: string + cursor?: string +} + +export type { ListOrgAppGrantsResponse, OAuthGrantItem, OAuthGrantProject } from './types' + +export async function getOAuthAppMemberGrants({ + slug, + appId, + cursor, +}: OAuthAppMemberGrantsVariables): Promise { + if (!slug) throw new Error('Organization slug is required') + if (!appId) throw new Error('App id is required') + if (!USE_MOCKS) throw new Error('OAuth app member grants are not yet implemented') + + return getMockOAuthAppGrants(appId, cursor) +} + +export type OAuthAppMemberGrantsData = Awaited> +export type OAuthAppMemberGrantsError = ResponseError + +export const useOAuthAppMemberGrantsQuery = ( + { slug, appId, cursor }: OAuthAppMemberGrantsVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => + useQuery({ + queryKey: oauthAppsKeys.appMemberGrants(slug, appId, cursor), + queryFn: () => getOAuthAppMemberGrants({ slug, appId, cursor }), + enabled: enabled && USE_MOCKS && Boolean(slug) && Boolean(appId), + ...options, + }) diff --git a/apps/studio/data/oauth-apps/oauth-apps-org-app-details-query.ts b/apps/studio/data/oauth-apps/oauth-apps-org-app-details-query.ts new file mode 100644 index 00000000000..169ae55359e --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-org-app-details-query.ts @@ -0,0 +1,41 @@ +import { useQuery } from '@tanstack/react-query' + +import { oauthAppsKeys } from './keys' +import { getMockOAuthOrgAppDetails, USE_MOCKS } from './mocks' +import type { OAuthOrgAppDetails } from './types' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type OAuthOrgAppDetailsVariables = { + slug?: string + appId?: string +} + +export type { OAuthOrgAppDetails } from './types' + +export async function getOAuthOrgAppDetails({ + slug, + appId, +}: OAuthOrgAppDetailsVariables): Promise { + if (!slug) throw new Error('Organization slug is required') + if (!appId) throw new Error('App id is required') + if (!USE_MOCKS) throw new Error('OAuth org app details are not yet implemented') + + return getMockOAuthOrgAppDetails(slug, appId) +} + +export type OAuthOrgAppDetailsData = Awaited> +export type OAuthOrgAppDetailsError = ResponseError + +export const useOAuthOrgAppDetailsQuery = ( + { slug, appId }: OAuthOrgAppDetailsVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => + useQuery({ + queryKey: oauthAppsKeys.orgAppDetails(slug, appId), + queryFn: () => getOAuthOrgAppDetails({ slug, appId }), + enabled: enabled && USE_MOCKS && Boolean(slug) && Boolean(appId), + ...options, + }) diff --git a/apps/studio/data/oauth-apps/oauth-apps-preflight-validation-query.ts b/apps/studio/data/oauth-apps/oauth-apps-preflight-validation-query.ts new file mode 100644 index 00000000000..5bf8f6a2e68 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-preflight-validation-query.ts @@ -0,0 +1,47 @@ +import { useQuery } from '@tanstack/react-query' + +import { oauthAppsKeys } from './keys' +import { getMockOAuthAppsPreflightValidation, USE_MOCKS } from './mocks' +import type { OAuthAppsAuthorizePreflightResult } from './types' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type OAuthAppsPreflightValidationVariables = { + slug?: string + appId?: string +} + +export type { OAuthAppsAuthorizePreflightResult } from './types' + +export async function getOAuthAppsPreflightValidation({ + slug, + appId, +}: OAuthAppsPreflightValidationVariables): Promise { + if (!slug) throw new Error('Organization slug is required') + if (!appId) throw new Error('App id is required') + if (!USE_MOCKS) throw new Error('OAuth app preflight validation is not yet implemented') + + return getMockOAuthAppsPreflightValidation(slug, appId) +} + +export type OAuthAppsPreflightValidationData = Awaited< + ReturnType +> +export type OAuthAppsPreflightValidationError = ResponseError + +export const useOAuthAppsPreflightValidationQuery = ( + { slug, appId }: OAuthAppsPreflightValidationVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions< + OAuthAppsPreflightValidationData, + OAuthAppsPreflightValidationError, + TData + > = {} +) => + useQuery({ + queryKey: oauthAppsKeys.preflightValidation(slug, appId), + queryFn: () => getOAuthAppsPreflightValidation({ slug, appId }), + enabled: enabled && USE_MOCKS && Boolean(slug) && Boolean(appId), + ...options, + }) diff --git a/apps/studio/data/oauth-apps/oauth-apps-revoke-grant-mutation.ts b/apps/studio/data/oauth-apps/oauth-apps-revoke-grant-mutation.ts new file mode 100644 index 00000000000..e7fe1faa1b4 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-revoke-grant-mutation.ts @@ -0,0 +1,48 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { oauthAppsKeys } from './keys' +import { USE_MOCKS } from './mocks' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type OAuthGrantRevokeVariables = { + slug: string + grantId: string +} + +export async function revokeOAuthGrant({ slug, grantId }: OAuthGrantRevokeVariables) { + if (!slug) throw new Error('Organization slug is required') + if (!grantId) throw new Error('Grant id is required') + if (!USE_MOCKS) throw new Error('OAuth grant revocation is not yet implemented') + + // 204 on success — no response body. +} + +type OAuthGrantRevokeData = Awaited> + +export const useOAuthGrantRevokeMutation = ({ + onError, + onSuccess, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => revokeOAuthGrant(vars), + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries({ queryKey: oauthAppsKeys.grants() }) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to revoke OAuth grant: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/oauth-apps/oauth-apps-revoke-mutation.ts b/apps/studio/data/oauth-apps/oauth-apps-revoke-mutation.ts new file mode 100644 index 00000000000..72f80a5b592 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-revoke-mutation.ts @@ -0,0 +1,57 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { oauthAppsKeys } from './keys' +import { USE_MOCKS } from './mocks' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type OAuthAppRevokeVariables = { + slug: string + appId: string +} + +export async function revokeOAuthApp({ slug, appId }: OAuthAppRevokeVariables) { + if (!slug) throw new Error('Organization slug is required') + if (!appId) throw new Error('App id is required') + if (!USE_MOCKS) throw new Error('OAuth app revocation is not yet implemented') + + return { id: appId } +} + +type OAuthAppRevokeData = Awaited> + +export const useOAuthAppRevokeMutation = ({ + onError, + onSuccess, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => revokeOAuthApp(vars), + async onSuccess(data, variables, context) { + // Revoking an app deletes every row for (app_id, organization_id), both kinds — the + // org's approvals overview, this app's grant list, and any member's own-grants view are + // all stale. + await Promise.all([ + queryClient.invalidateQueries({ queryKey: oauthAppsKeys.approvals(variables.slug) }), + queryClient.invalidateQueries({ + queryKey: oauthAppsKeys.appMemberGrants(variables.slug, variables.appId), + }), + queryClient.invalidateQueries({ queryKey: oauthAppsKeys.grants() }), + ]) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to revoke OAuth app: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/oauth-apps/types.test.ts b/apps/studio/data/oauth-apps/types.test.ts new file mode 100644 index 00000000000..0af62eee06a --- /dev/null +++ b/apps/studio/data/oauth-apps/types.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from 'vitest' + +import { DEVELOPER_ROLE, READ_ONLY_ROLE } from './mocks' +import type { OAuthAppsAuthorizeOrganizationProject, OAuthExistingGrant } from './types' +import { getFailedProjects, getPreselectedProjectRefs, isRoleValidationFailure } from './types' + +const project = (ref: string): OAuthAppsAuthorizeOrganizationProject => ({ + ref, + name: ref, + role: DEVELOPER_ROLE, +}) + +const grant = (refs: string[]): OAuthExistingGrant => ({ + approved_scopes: ['projects:read'], + project_refs: refs, + approved_at: '2026-08-14T09:12:00.000Z', +}) + +describe('getPreselectedProjectRefs', () => { + const live = ['alpha', 'bravo', 'charlie', 'delta'].map(project) + + test('preselects the project_ref query param when it resolves against live projects', () => { + expect( + getPreselectedProjectRefs({ existingGrant: null, projectRef: 'alpha', liveProjects: live }) + ).toEqual(['alpha']) + }) + + test('drops a project_ref that does not resolve against live projects', () => { + expect( + getPreselectedProjectRefs({ existingGrant: null, projectRef: 'ghost', liveProjects: live }) + ).toEqual([]) + }) + + test('returns nothing when there is neither a grant nor a project_ref', () => { + expect( + getPreselectedProjectRefs({ existingGrant: null, projectRef: null, liveProjects: live }) + ).toEqual([]) + }) + + test('existing-grant refs win over the project_ref query param', () => { + expect( + getPreselectedProjectRefs({ + existingGrant: grant(['charlie', 'delta']), + projectRef: 'alpha', + liveProjects: live, + }) + ).toEqual(['charlie', 'delta']) + }) + + test('filters stale refs out of the existing grant', () => { + expect( + getPreselectedProjectRefs({ + existingGrant: grant(['alpha', 'ghost']), + projectRef: null, + liveProjects: live, + }) + ).toEqual(['alpha']) + }) + + test('falls back to the project_ref when the existing grant carries no refs', () => { + expect( + getPreselectedProjectRefs({ + existingGrant: grant([]), + projectRef: 'bravo', + liveProjects: live, + }) + ).toEqual(['bravo']) + }) + + test('does not cap the number of preselected refs', () => { + const manyProjects = Array.from({ length: 15 }, (_, index) => project(`ref-${index}`)) + const manyRefs = manyProjects.map((entry) => entry.ref) + + expect( + getPreselectedProjectRefs({ + existingGrant: grant(manyRefs), + projectRef: null, + liveProjects: manyProjects, + }) + ).toEqual(manyRefs) + }) + + test('returns nothing when there are no live projects', () => { + expect( + getPreselectedProjectRefs({ + existingGrant: grant(['alpha']), + projectRef: 'alpha', + liveProjects: [], + }) + ).toEqual([]) + }) +}) + +describe('isRoleValidationFailure', () => { + test('recognises a role validation failure', () => { + expect( + isRoleValidationFailure({ + error_code: 'role_validation_failed', + message: 'nope', + validation: { scope_target: 'organization', role: READ_ONLY_ROLE }, + }) + ).toBe(true) + }) + + test('treats a redirect as a success', () => { + expect(isRoleValidationFailure({ url: 'https://vercel.com/callback?code=abc' })).toBe(false) + }) +}) + +describe('getFailedProjects', () => { + test('returns the failures of a projects-level failure', () => { + const failure = { + ref: 'alpha', + name: 'alpha', + role: READ_ONLY_ROLE, + failed_scopes: ['database:write' as const], + } + + expect( + getFailedProjects({ + error_code: 'role_validation_failed', + message: 'nope', + validation: { scope_target: 'projects', failures: [failure] }, + }) + ).toEqual([failure]) + }) + + test('is empty for an organization-level failure', () => { + expect( + getFailedProjects({ + error_code: 'role_validation_failed', + message: 'nope', + validation: { scope_target: 'organization', role: READ_ONLY_ROLE }, + }) + ).toEqual([]) + }) + + test('is empty when there is no failure', () => { + expect(getFailedProjects(null)).toEqual([]) + expect(getFailedProjects(undefined)).toEqual([]) + }) +}) diff --git a/apps/studio/data/oauth-apps/types.ts b/apps/studio/data/oauth-apps/types.ts new file mode 100644 index 00000000000..4d811a3b37d --- /dev/null +++ b/apps/studio/data/oauth-apps/types.ts @@ -0,0 +1,190 @@ +import type { components } from 'api-types' + +import type { OrganizationRole } from '@/data/organization-members/organization-roles-query' + +type LiveAuthorizeRequest = components['schemas']['GetOAuthAuthorizationResponse_Output'] + +export type OAuthAppsAuthorizeLiveFields = Pick< + LiveAuthorizeRequest, + 'name' | 'website' | 'domain' | 'icon' | 'redirect_uri' | 'registration_type' | 'expires_at' +> + +export type OAuthScope = NonNullable[number] + +export function isWriteScope(scope: OAuthScope): boolean { + return scope.endsWith(':write') +} + +export type OAuthGrantKind = 'organization_bound' | 'member_bound' + +export type OAuthAppsAuthorizeRequest = OAuthAppsAuthorizeLiveFields & { + app_id: string + app_name: string + grant_kind: OAuthGrantKind + project_scoping_mode: boolean + scopes: OAuthScope[] +} + +export type OAuthOrganizationRole = { + slug: string + name: string + default_role: OrganizationRole +} + +export type OAuthAppsAuthorizeOrganizationProject = { + ref: string + name: string + role: OrganizationRole +} + +export type OAuthExistingGrant = { + approved_scopes: OAuthScope[] | null + project_refs: string[] + approved_at: string +} + +export type OAuthOrgAppDetails = { + existing_grant: OAuthExistingGrant | null +} + +export function getPreselectedProjectRefs({ + existingGrant, + projectRef, + liveProjects, +}: { + existingGrant: OAuthExistingGrant | null + projectRef: string | null + liveProjects: OAuthAppsAuthorizeOrganizationProject[] +}): string[] { + const liveRefs = new Set(liveProjects.map((project) => project.ref)) + const candidates = existingGrant?.project_refs.length + ? existingGrant.project_refs + : projectRef + ? [projectRef] + : [] + return candidates.filter((ref) => liveRefs.has(ref)) +} + +export type OAuthAuthorizeApproveRequest = { + // not allowed when project_scoping_mode is false. + // when project_scoping_mode is true, if omitted, this means all projects + project_refs?: string[] +} + +export type OAuthAppsAuthorizeRedirect = + components['schemas']['ApproveAuthorizationResponse_Output'] + +export type OAuthScopeValidationResult = + | { + scope_target: 'organization' + role: OrganizationRole + } + | { + scope_target: 'all_projects' + role: OrganizationRole + failed_scopes: OAuthScope[] + } + | { + scope_target: 'projects' + failures: Array<{ + ref: string + name: string + role: OrganizationRole + failed_scopes: OAuthScope[] + }> + } + +export type OAuthScopeValidationProjectFailure = Extract< + OAuthScopeValidationResult, + { scope_target: 'projects' } +>['failures'][number] + +export type OAuthAppsAuthorizeRoleValidationFailure = { + error_code: 'role_validation_failed' + message: string + validation: OAuthScopeValidationResult +} + +export type OAuthAppsAuthorizeApproveResult = + | OAuthAppsAuthorizeRedirect + | OAuthAppsAuthorizeRoleValidationFailure + +export function isRoleValidationFailure( + result: OAuthAppsAuthorizeApproveResult +): result is OAuthAppsAuthorizeRoleValidationFailure { + return 'error_code' in result && result.error_code === 'role_validation_failed' +} + +export function getFailedProjects( + failure: OAuthAppsAuthorizeRoleValidationFailure | null | undefined +): OAuthScopeValidationProjectFailure[] { + if (!failure) return [] + return failure.validation.scope_target === 'projects' ? failure.validation.failures : [] +} + +export type OAuthAppsAuthorizePreflightSuccess = { ok: true } + +export type OAuthAppsAuthorizePreflightResult = + | OAuthAppsAuthorizePreflightSuccess + | OAuthAppsAuthorizeRoleValidationFailure + +export function isPreflightValidationFailure( + result: OAuthAppsAuthorizePreflightResult +): result is OAuthAppsAuthorizeRoleValidationFailure { + return 'error_code' in result && result.error_code === 'role_validation_failed' +} + +export type OAuthApprovalTarget = 'organization' | 'members' | 'organization_and_members' + +export type OAuthApprovalItem = { + app: { + id: string + name: string + created_by: string + icon: string | null + } + grant_target: OAuthApprovalTarget + org_grant: { + grant_id: string + approved_scopes: string[] + approved_at: string + } | null +} + +export type ListOAuthApprovalsResponse = { + data: OAuthApprovalItem[] + pagination: { next_cursor: string | null } +} + +export type OAuthGrantProject = { + ref: string + name: string +} + +export type OAuthGrantItem = { + grant_id: string + kind: OAuthGrantKind + user: { gotrue_id: string; email: string; avatar_url?: string } | null + projects: OAuthGrantProject[] | null + approved_scopes: string[] + approved_at: string +} + +export type ListOrgAppGrantsResponse = { + data: OAuthGrantItem[] + pagination: { next_cursor: string | null } +} + +export type MemberOauthGrantItem = { + grant_id: string + app: { id: string; name: string; icon: string | null } + organization: { slug: string; name: string } + projects: OAuthGrantProject[] | null + approved_scopes: string[] + approved_at: string +} + +export type ListOwnGrantsResponse = { + data: MemberOauthGrantItem[] + pagination: { next_cursor: string | null } +} diff --git a/apps/studio/data/organization-members/organization-roles-query.ts b/apps/studio/data/organization-members/organization-roles-query.ts index 826f3029eba..669d1a8de5f 100644 --- a/apps/studio/data/organization-members/organization-roles-query.ts +++ b/apps/studio/data/organization-members/organization-roles-query.ts @@ -5,7 +5,7 @@ import { organizationKeys } from './keys' import { get, handleError } from '@/data/fetchers' import type { ResponseError, UseCustomQueryOptions } from '@/types' -export const FIXED_ROLE_ORDER = ['Owner', 'Administrator', 'Developer', 'Read-only'] +export const FIXED_ROLE_ORDER = ['Owner', 'Administrator', 'Developer', 'Read-only', 'None'] export type OrganizationRolesVariables = { slug?: string } export type OrganizationRolesResponse = components['schemas']['OrganizationRoleResponse_Output'] export type OrganizationRole = @@ -50,8 +50,17 @@ export const useOrganizationRolesV2Query = ( .sort((a, b) => { return FIXED_ROLE_ORDER.indexOf(a.name) - FIXED_ROLE_ORDER.indexOf(b.name) }) - .filter((role) => FIXED_ROLE_ORDER.includes(role.name)), + .filter((role) => FIXED_ROLE_ORDER.includes(role.name)) + .map((role) => ({ ...role, name: getOverrideRoleName(role.name) })), } as TData }, ...options, }) + +const OVERRIDE_ROLE_NAMES: Record = { + None: 'No-access', +} + +export function getOverrideRoleName(rolename: string) { + return OVERRIDE_ROLE_NAMES[rolename] ?? rolename +} diff --git a/apps/studio/lib/__tests__/theme-overrides.test.ts b/apps/studio/lib/__tests__/theme-overrides.test.ts index 601ac2967f5..63823bf4af9 100644 --- a/apps/studio/lib/__tests__/theme-overrides.test.ts +++ b/apps/studio/lib/__tests__/theme-overrides.test.ts @@ -9,6 +9,7 @@ import { mergeThemeOverride, parseThemeOverridesByMode, resolveThemeOverrideMode, + resolveThemeOverridesForFlags, sliderValueToThemeOverride, THEME_OVERRIDE_DEFAULTS, THEME_OVERRIDE_KNOBS, @@ -141,11 +142,32 @@ describe('theme override application', () => { expect(root.style.getPropertyValue('--contrast')).toBe('') }) + it('applies a spot hue via --primary-hue and leaves brand and surface hues alone', () => { + const root = document.createElement('html') + applyThemeOverrides(root, 'dark', { primaryHue: 48 }) + + expect(root.style.getPropertyValue('--primary-hue')).toBe('48') + expect(root.style.getPropertyValue('--surface-hue')).toBe('') + expect(root.style.getPropertyValue('--brand-default')).toBe('') + + applyThemeOverrides(root, 'dark', {}) + expect(root.style.getPropertyValue('--primary-hue')).toBe('') + }) + it('reports whether a mode has overrides', () => { expect(hasThemeOverrides({})).toBe(false) expect(hasThemeOverrides({ chroma: 0.02 })).toBe(true) }) + it('strips spot hue when the employee-only flag is off', () => { + expect( + resolveThemeOverridesForFlags({ primaryHue: 48, chroma: 0.02 }, { isSpotColorEnabled: false }) + ).toEqual({ chroma: 0.02 }) + expect( + resolveThemeOverridesForFlags({ primaryHue: 48, chroma: 0.02 }, { isSpotColorEnabled: true }) + ).toEqual({ primaryHue: 48, chroma: 0.02 }) + }) + it('resolves classic dark as dark for legacy stored themes', () => { expect(resolveThemeOverrideMode('dark')).toBe('dark') expect(resolveThemeOverrideMode('light')).toBe('light') diff --git a/apps/studio/lib/ai/tools/incident-tools.test.ts b/apps/studio/lib/ai/tools/incident-tools.test.ts index c2e0ccee842..f62d3c7a0af 100644 --- a/apps/studio/lib/ai/tools/incident-tools.test.ts +++ b/apps/studio/lib/ai/tools/incident-tools.test.ts @@ -170,22 +170,19 @@ describe('ai/tools/incident-tools', () => { expect((result as any).message).toContain('2 active incidents') }) - it('should handle fetch errors', async () => { + it('throws on fetch errors', async () => { const common = await import('common') vi.spyOn(common, 'IS_PLATFORM', 'get').mockReturnValue(true) mockFetch.mockRejectedValue(new Error('Network error')) const tools = getIncidentTools({ baseUrl: 'https://supabase.com/dashboard' }) - const result = await (tools.get_active_incidents.execute as any)({}, executeOptions) - - expect(result).toEqual({ - incidents: [], - error: 'Unable to check incident status at this time.', - }) + await expect( + (tools.get_active_incidents.execute as any)({}, executeOptions) + ).rejects.toThrow('Network error') }) - it('should handle non-ok responses', async () => { + it('throws on non-ok responses with the status code', async () => { const common = await import('common') vi.spyOn(common, 'IS_PLATFORM', 'get').mockReturnValue(true) @@ -195,12 +192,9 @@ describe('ai/tools/incident-tools', () => { }) const tools = getIncidentTools({ baseUrl: 'https://supabase.com/dashboard' }) - const result = await (tools.get_active_incidents.execute as any)({}, executeOptions) - - expect(result).toEqual({ - incidents: [], - error: 'Unable to check incident status at this time.', - }) + await expect( + (tools.get_active_incidents.execute as any)({}, executeOptions) + ).rejects.toThrow('Failed to fetch incident status: 500') }) it('should use timeout signal', async () => { @@ -424,7 +418,7 @@ describe('ai/tools/incident-tools', () => { }) }) - it('returns an error when the response fails to parse', async () => { + it('throws when the response fails to parse', async () => { mockFetch.mockResolvedValue({ ok: true, json: async () => ({ this_is: 'not a valid status page response' }), @@ -434,15 +428,12 @@ describe('ai/tools/incident-tools', () => { baseUrl: 'https://supabase.com/dashboard', useStatusPageWidget: true, }) - const result = await (tools.get_active_incidents.execute as any)({}, executeOptions) - - expect(result).toEqual({ - incidents: [], - error: 'Unable to check incident status at this time.', - }) + await expect((tools.get_active_incidents.execute as any)({}, executeOptions)).rejects.toThrow( + 'Failed to parse status page response' + ) }) - it('returns an error on a non-ok response', async () => { + it('throws on a non-ok response with the status code', async () => { mockFetch.mockResolvedValue({ ok: false, status: 500, @@ -452,12 +443,9 @@ describe('ai/tools/incident-tools', () => { baseUrl: 'https://supabase.com/dashboard', useStatusPageWidget: true, }) - const result = await (tools.get_active_incidents.execute as any)({}, executeOptions) - - expect(result).toEqual({ - incidents: [], - error: 'Unable to check incident status at this time.', - }) + await expect((tools.get_active_incidents.execute as any)({}, executeOptions)).rejects.toThrow( + 'Failed to fetch status page: 500' + ) }) }) }) diff --git a/apps/studio/lib/ai/tools/incident-tools.ts b/apps/studio/lib/ai/tools/incident-tools.ts index ffb8e0e0acb..d9e7a9cffbb 100644 --- a/apps/studio/lib/ai/tools/incident-tools.ts +++ b/apps/studio/lib/ai/tools/incident-tools.ts @@ -8,10 +8,6 @@ import { type AffectedComponent, } from '@/lib/status-page/status-page.schema' -const INCIDENT_STATUS_ERROR = { - incidents: [], - error: 'Unable to check incident status at this time.', -} const NO_ACTIVE_INCIDENTS_MESSAGE = 'No active incidents.' const DEFAULT_STATUS_PAGE_URL = 'https://status.supabase.com' @@ -74,108 +70,95 @@ export const getIncidentTools = ({ } if (useStatusPageWidget) { - try { - const response = await fetch(`${baseUrl}/api/status-page`, { - signal: timeoutSignal(abortSignal), - }) - - if (!response.ok) { - console.warn('Failed to fetch status page:', response.status) - return INCIDENT_STATUS_ERROR - } - - const json = await response.json() - const parseResult = StatusPageResponseSchema.safeParse(json) - - if (!parseResult.success) { - console.warn('Failed to parse status page response:', parseResult.error) - return INCIDENT_STATUS_ERROR - } - - const { data } = parseResult - - const incidents = data.ongoing_incidents - .filter((incident) => incident.visible) - .map((incident) => ({ - kind: 'incident' as const, - name: incident.name, - status: incident.status, - impact: incident.current_worst_impact, - affected_components: formatAffectedComponents(incident.affected_components), - last_update_message: incident.last_update_message ?? null, - url: incident.url, - })) - - const maintenances = data.in_progress_maintenances - .filter((maintenance) => maintenance.visible) - .map((maintenance) => ({ - kind: 'maintenance' as const, - name: maintenance.name, - status: maintenance.status, - affected_components: formatAffectedComponents(maintenance.affected_components), - last_update_message: maintenance.last_update_message ?? null, - url: maintenance.url, - started_at: maintenance.started_at, - })) - - const combined = [...incidents, ...maintenances] - - if (combined.length === 0) { - return { - incidents: [], - message: NO_ACTIVE_INCIDENTS_MESSAGE, - } - } - - const statusPageUrl = data.page_url || DEFAULT_STATUS_PAGE_URL - - return { - incidents: combined, - message: buildActiveIncidentsMessage( - incidents.length, - maintenances.length, - statusPageUrl - ), - } - } catch (error) { - console.warn('Failed to fetch status page:', error) - return INCIDENT_STATUS_ERROR - } - } - - try { - const response = await fetch(`${baseUrl}/api/incident-status`, { + const response = await fetch(`${baseUrl}/api/status-page`, { signal: timeoutSignal(abortSignal), }) if (!response.ok) { - console.warn('Failed to fetch incident status:', response.status) - return INCIDENT_STATUS_ERROR + throw new Error(`Failed to fetch status page: ${response.status}`) } - const incidents: IncidentInfo[] = await response.json() + const json = await response.json() + const parseResult = StatusPageResponseSchema.safeParse(json) - if (incidents.length === 0) { + if (!parseResult.success) { + throw new Error(`Failed to parse status page response: ${parseResult.error.message}`) + } + + const { data } = parseResult + + const incidents = data.ongoing_incidents + .filter((incident) => incident.visible) + .map((incident) => ({ + kind: 'incident' as const, + name: incident.name, + status: incident.status, + impact: incident.current_worst_impact, + affected_components: formatAffectedComponents(incident.affected_components), + last_update_message: incident.last_update_message ?? null, + url: incident.url, + })) + + const maintenances = data.in_progress_maintenances + .filter((maintenance) => maintenance.visible) + .map((maintenance) => ({ + kind: 'maintenance' as const, + name: maintenance.name, + status: maintenance.status, + affected_components: formatAffectedComponents(maintenance.affected_components), + last_update_message: maintenance.last_update_message ?? null, + url: maintenance.url, + started_at: maintenance.started_at, + })) + + const combined = [...incidents, ...maintenances] + + if (combined.length === 0) { return { incidents: [], message: NO_ACTIVE_INCIDENTS_MESSAGE, } } - const incidentSummaries = incidents.map((incident) => ({ - name: incident.name, - status: incident.status, - impact: incident.impact, - active_since: incident.active_since, - })) + const statusPageUrl = data.page_url || DEFAULT_STATUS_PAGE_URL return { - incidents: incidentSummaries, - message: buildActiveIncidentsMessage(incidents.length, 0, DEFAULT_STATUS_PAGE_URL), + incidents: combined, + message: buildActiveIncidentsMessage( + incidents.length, + maintenances.length, + statusPageUrl + ), } - } catch (error) { - console.warn('Failed to fetch incident status:', error) - return INCIDENT_STATUS_ERROR + } + + const response = await fetch(`${baseUrl}/api/incident-status`, { + signal: timeoutSignal(abortSignal), + }) + + if (!response.ok) { + throw new Error(`Failed to fetch incident status: ${response.status}`) + } + + const incidents: IncidentInfo[] = await response.json() + + if (incidents.length === 0) { + return { + incidents: [], + message: NO_ACTIVE_INCIDENTS_MESSAGE, + } + } + + const incidentSummaries = incidents.map((incident) => ({ + name: incident.name, + status: incident.status, + impact: incident.impact, + active_since: incident.active_since, + })) + + return { + incidents: incidentSummaries, + message: buildActiveIncidentsMessage(incidents.length, 0, DEFAULT_STATUS_PAGE_URL), } }, }), diff --git a/apps/studio/lib/helpers.test.ts b/apps/studio/lib/helpers.test.ts index cd867453c81..c800524d7c2 100644 --- a/apps/studio/lib/helpers.test.ts +++ b/apps/studio/lib/helpers.test.ts @@ -10,6 +10,7 @@ import { formatBytes, formatCurrency, formatRestoreWindow, + getBasePathURL, getDatabaseMajorVersion, getDistanceLatLonKM, getSemanticVersion, @@ -107,6 +108,33 @@ describe('getURL', () => { }) }) +describe('getBasePathURL', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('appends the base path to the site URL', () => { + vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'https://supabase.com') + expect(getBasePathURL('/dashboard')).toEqual('https://supabase.com/dashboard') + }) + + it('does not double the base path on the fallback URL', () => { + vi.stubEnv('NEXT_PUBLIC_SITE_URL', '') + vi.stubEnv('NEXT_PUBLIC_VERCEL_BRANCH_URL', '') + expect(getBasePathURL('/dashboard')).toEqual('https://supabase.com/dashboard') + }) + + it('strips a trailing slash before appending the base path', () => { + vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'https://supabase.com/') + expect(getBasePathURL('/dashboard')).toEqual('https://supabase.com/dashboard') + }) + + it('returns the site URL when there is no base path', () => { + vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'http://localhost:8082') + expect(getBasePathURL('')).toEqual('http://localhost:8082') + }) +}) + describe('makeRandomString', () => { it('should return a random string of the given length', () => { const result = makeRandomString(10) diff --git a/apps/studio/lib/helpers.ts b/apps/studio/lib/helpers.ts index 2206be48975..bf6c58c60b7 100644 --- a/apps/studio/lib/helpers.ts +++ b/apps/studio/lib/helpers.ts @@ -2,6 +2,7 @@ import { UIEvent } from 'react' import { v4 as _uuidV4 } from 'uuid' import type { TablesData } from '../data/tables/tables-query' +import { BASE_PATH } from './constants' export const uuidv4 = () => { return _uuidV4() @@ -73,6 +74,16 @@ export const getURL = () => { return url.includes('http') ? url : `https://${url}` } +/** + * Adds the base path to `getURL()` when it's missing. + * `https://supabase.com` → `https://supabase.com/dashboard` + * `https://supabase.com/dashboard` → unchanged + */ +export const getBasePathURL = (basePath = BASE_PATH) => { + const url = getURL().replace(/\/$/, '') + return url.endsWith(basePath) ? url : `${url}${basePath}` +} + /** * Generates a random string using alpha characters */ diff --git a/apps/studio/lib/theme-overrides.ts b/apps/studio/lib/theme-overrides.ts index d363f6fb072..dee967a9f84 100644 --- a/apps/studio/lib/theme-overrides.ts +++ b/apps/studio/lib/theme-overrides.ts @@ -1,8 +1,11 @@ import * as z from 'zod' -export type ThemeOverrideKey = 'chroma' | 'contrast' | 'surface' | 'elevationStep' +export type ThemeOverrideKey = 'primaryHue' | 'chroma' | 'contrast' | 'surface' | 'elevationStep' export type ThemeOverrideMode = 'dark' | 'light' +/** ConfigCat flag: Spot color control (employee-only via Supabase Team Email segment). */ +export const APPEARANCE_SPOT_COLOR_FLAG = 'appearanceSpotColor' + type ThemeOverrideRange = { min: number; max: number } export interface ThemeOverrideKnob { @@ -14,11 +17,18 @@ export interface ThemeOverrideKnob { } export const THEME_OVERRIDE_KNOBS: readonly ThemeOverrideKnob[] = [ + { + key: 'primaryHue', + cssVar: '--primary-hue', + label: 'Spot color', + description: 'Changes the hue of primary controls and the canvas.', + ranges: { dark: { min: 0, max: 360 }, light: { min: 0, max: 360 } }, + }, { key: 'chroma', cssVar: '--chroma', - label: 'Color intensity', - description: 'Controls how vivid interface colors appear.', + label: 'Surface tint', + description: 'Controls how much color tints backgrounds, text, and borders.', ranges: { dark: { min: 0, max: 0.04 }, light: { min: 0, max: 0.03 } }, }, { @@ -48,8 +58,8 @@ export const THEME_OVERRIDE_DEFAULTS: Record< ThemeOverrideMode, Record > = { - dark: { chroma: 0.005, contrast: 0.5, surface: 0.19, elevationStep: 0.025 }, - light: { chroma: 0, contrast: 0.53, surface: 0.995, elevationStep: 0.024 }, + dark: { primaryHue: 157.5, chroma: 0.005, contrast: 0.5, surface: 0.19, elevationStep: 0.025 }, + light: { primaryHue: 157.5, chroma: 0, contrast: 0.53, surface: 0.995, elevationStep: 0.024 }, } export type ThemeOverrides = Partial> @@ -57,6 +67,7 @@ export type ThemeOverridesByMode = Partial overrides[knob.key] !== undefined) } +/** Drops the Spot color override when the employee-only flag is off. */ +export function resolveThemeOverridesForFlags( + overrides: ThemeOverrides, + { isSpotColorEnabled }: { isSpotColorEnabled: boolean } +): ThemeOverrides { + if (isSpotColorEnabled || overrides.primaryHue === undefined) return overrides + const { primaryHue: _removed, ...rest } = overrides + return rest +} + function sanitizeThemeOverrides( mode: ThemeOverrideMode, overrides: ThemeOverrides diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts index f0e4fe3998e..67209870b15 100644 --- a/apps/studio/pages/api/ai/sql/generate-v4.ts +++ b/apps/studio/pages/api/ai/sql/generate-v4.ts @@ -30,7 +30,7 @@ import { getTools } from '@/lib/ai/tools' import { encodeNotebookToolError } from '@/lib/ai/tools/notebook-tools' import { apiWrapper } from '@/lib/api/apiWrapper' import { executeQuery } from '@/lib/api/self-hosted/query' -import { getURL } from '@/lib/helpers' +import { getBasePathURL } from '@/lib/helpers' import { isServerFlagEnabled, trustedUserEmail } from '@/lib/server/configcat' export const maxDuration = 300 @@ -192,7 +192,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw authorization, aiOptInLevel, accessToken, - baseUrl: getURL(), + baseUrl: getBasePathURL(), supportMode, isExplorerEnabled: explorerEnabled, useStatusPageWidget, diff --git a/apps/studio/pages/project/[ref]/branches/index.tsx b/apps/studio/pages/project/[ref]/branches/index.tsx index f16f58b939c..e40721ce812 100644 --- a/apps/studio/pages/project/[ref]/branches/index.tsx +++ b/apps/studio/pages/project/[ref]/branches/index.tsx @@ -6,6 +6,7 @@ import { useRouter } from 'next/router' import { useState, type PropsWithChildren } from 'react' import { Button } from 'ui' +import { BRANCHING_GITHUB_DISCUSSION_LINK } from '@/components/interfaces/BranchManagement/BranchManagement.constants' import { DeleteBranchModal } from '@/components/interfaces/BranchManagement/DeleteBranchModal' import { Overview } from '@/components/interfaces/BranchManagement/Overview' import BranchLayout from '@/components/layouts/BranchLayout/BranchLayout' @@ -189,11 +190,7 @@ export const BranchesPageWrapper = ({ children }: PropsWithChildren) => { variant="text" icon={} > - + Branching feedback diff --git a/apps/studio/pages/project/[ref]/branches/merge-requests.tsx b/apps/studio/pages/project/[ref]/branches/merge-requests.tsx index fa1d83f6f99..e98869a918e 100644 --- a/apps/studio/pages/project/[ref]/branches/merge-requests.tsx +++ b/apps/studio/pages/project/[ref]/branches/merge-requests.tsx @@ -12,9 +12,12 @@ import { DropdownMenuItem, DropdownMenuTrigger, Tooltip, + TooltipContent, + TooltipTrigger, } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +import { BRANCHING_GITHUB_DISCUSSION_LINK } from '@/components/interfaces/BranchManagement/BranchManagement.constants' import { BranchManagementSection, BranchRow, @@ -250,14 +253,22 @@ const MergeRequestsPage: NextPageWithLayout = () => { // whether the branch is linked to a GitHub PR. branch.review_requested_at && ( - - diff --git a/apps/studio/routes/__root.tsx b/apps/studio/routes/__root.tsx index de414f0b2b0..a7abe08003a 100644 --- a/apps/studio/routes/__root.tsx +++ b/apps/studio/routes/__root.tsx @@ -341,7 +341,7 @@ export const Route = createRootRouteWithContext()({ maintenanceMode: IS_MAINTENANCE_MODE, hash: location.hash, }) - if (!match) return + if (!match) return undefined // `to`/`search`/`hash`, never `href`: the router treats `href` as an // opaque (external) target, and preloading a Link whose beforeLoad // throws `redirect({ href })` recurses forever — the preload retry diff --git a/apps/studio/tests/components/interfaces/Account/Preferences/ThemeColorSettings.test.tsx b/apps/studio/tests/components/interfaces/Account/Preferences/ThemeColorSettings.test.tsx index d0bc1d73299..ab3c04ee6a7 100644 --- a/apps/studio/tests/components/interfaces/Account/Preferences/ThemeColorSettings.test.tsx +++ b/apps/studio/tests/components/interfaces/Account/Preferences/ThemeColorSettings.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import type { ComponentProps } from 'react' import type * as UI from 'ui' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -8,11 +8,20 @@ import { customRender } from '@/tests/lib/custom-render' type SliderProps = ComponentProps -const { resetOverrides, setOverride } = vi.hoisted(() => ({ +const { resetOverrides, setOverride, mockUseFlag } = vi.hoisted(() => ({ resetOverrides: vi.fn(), setOverride: vi.fn(), + mockUseFlag: vi.fn(() => true), })) +vi.mock('common', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useFlag: mockUseFlag, + } +}) + vi.mock('@/hooks/misc/useThemeOverrides', () => ({ useThemeOverrides: () => ({ mode: 'dark', @@ -23,7 +32,7 @@ vi.mock('@/hooks/misc/useThemeOverrides', () => ({ })) vi.mock('ui', async (importOriginal) => { - const actual = await importOriginal() + const actual = await importOriginal() return { ...actual, @@ -45,14 +54,17 @@ describe('ThemeColorSettings', () => { beforeEach(() => { resetOverrides.mockReset() setOverride.mockReset() + mockUseFlag.mockReset() + mockUseFlag.mockReturnValue(true) document.documentElement.style.removeProperty('--chroma') + document.documentElement.style.removeProperty('--primary-hue') document.documentElement.dataset.theme = 'dark' }) it('persists a rapid pointer change for the active mode', () => { customRender() - const slider = screen.getByRole('slider', { name: 'Color intensity' }) + const slider = screen.getByRole('slider', { name: 'Surface tint' }) fireEvent.click(slider) fireEvent.lostPointerCapture(slider) @@ -62,13 +74,50 @@ describe('ThemeColorSettings', () => { it('persists an ordinary committed change', () => { customRender() - const slider = screen.getByRole('slider', { name: 'Color intensity' }) + const slider = screen.getByRole('slider', { name: 'Surface tint' }) fireEvent.click(slider) fireEvent.keyUp(slider) expect(setOverride).toHaveBeenCalledWith('chroma', 0.04) }) + it('previews and saves the spot color hue when the flag is on', async () => { + customRender() + + expect(screen.getByText('158°')).toBeInTheDocument() + + const slider = screen.getByRole('slider', { name: 'Spot color' }) + fireEvent.click(slider) + + await waitFor(() => { + expect(document.documentElement.style.getPropertyValue('--primary-hue')).toBe('360') + }) + expect(await screen.findByText('360°')).toBeInTheDocument() + + fireEvent.keyUp(slider) + expect(setOverride).toHaveBeenCalledWith('primaryHue', 360) + }) + + it('hides the spot color control when the employee-only flag is off', () => { + mockUseFlag.mockReturnValue(false) + customRender() + + expect(screen.queryByRole('slider', { name: 'Spot color' })).not.toBeInTheDocument() + expect(screen.getByRole('slider', { name: 'Surface tint' })).toBeInTheDocument() + }) + + it('cancels a queued hue preview when the flag turns off', async () => { + const { rerender } = customRender() + fireEvent.click(screen.getByRole('slider', { name: 'Spot color' })) + + mockUseFlag.mockReturnValue(false) + rerender() + + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + expect(document.documentElement.style.getPropertyValue('--primary-hue')).toBe('') + expect(screen.queryByRole('slider', { name: 'Spot color' })).not.toBeInTheDocument() + }) + it('resets the active mode', () => { customRender() @@ -77,11 +126,13 @@ describe('ThemeColorSettings', () => { expect(resetOverrides).toHaveBeenCalledOnce() }) - it('restores persisted values when an active preview unmounts', () => { + it('restores persisted values when an active preview unmounts', async () => { const { unmount } = customRender() - fireEvent.click(screen.getByRole('slider', { name: 'Color intensity' })) - expect(document.documentElement.style.getPropertyValue('--chroma')).toBe('0.04') + fireEvent.click(screen.getByRole('slider', { name: 'Surface tint' })) + await waitFor(() => { + expect(document.documentElement.style.getPropertyValue('--chroma')).toBe('0.04') + }) unmount() diff --git a/apps/studio/tests/components/interfaces/Account/Preferences/ThemeColorSettings.utils.test.ts b/apps/studio/tests/components/interfaces/Account/Preferences/ThemeColorSettings.utils.test.ts new file mode 100644 index 00000000000..84a6b766cd8 --- /dev/null +++ b/apps/studio/tests/components/interfaces/Account/Preferences/ThemeColorSettings.utils.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' + +import { sliderTrackStyle } from '@/components/interfaces/Account/Preferences/ThemeColorSettings.utils' +import type { ThemeOverrideKey, ThemeOverrideMode } from '@/lib/theme-overrides' + +describe('sliderTrackStyle', () => { + const cases: Array<{ + key: ThemeOverrideKey + mode: ThemeOverrideMode + start: string + end: string + }> = [ + { key: 'primaryHue', mode: 'dark', start: '0.7 0.14 0', end: '0.7 0.14 360' }, + { key: 'primaryHue', mode: 'light', start: '0.7 0.14 0', end: '0.7 0.14 360' }, + { + key: 'chroma', + mode: 'dark', + start: '0.4 0 var(--surface-hue)', + end: '0.4 0.07 var(--surface-hue)', + }, + { + key: 'chroma', + mode: 'light', + start: '0.9 0 var(--surface-hue)', + end: '0.9 0.05 var(--surface-hue)', + }, + { key: 'contrast', mode: 'dark', start: '0.35 0 0', end: '0.9 0 0' }, + { key: 'contrast', mode: 'light', start: '0.82 0 0', end: '0.2 0 0' }, + { key: 'surface', mode: 'dark', start: '0.14 0 0', end: '0.97 0 0' }, + { key: 'surface', mode: 'light', start: '0.14 0 0', end: '0.97 0 0' }, + { + key: 'elevationStep', + mode: 'dark', + start: '0.28 0 var(--surface-hue)', + end: '0.55 0 var(--surface-hue)', + }, + { + key: 'elevationStep', + mode: 'light', + start: '0.92 0 var(--surface-hue)', + end: '0.99 0 var(--surface-hue)', + }, + ] + + it.each(cases)('illustrates the $key range in $mode mode', ({ key, mode, start, end }) => { + const gradient = sliderTrackStyle(key, mode)?.['--slider-track-fill'] + expect(gradient).toMatch(/^linear-gradient\(to right, /) + expect(gradient).toContain(`oklch(${start})`) + expect(gradient).toContain(`oklch(${end})`) + }) +}) diff --git a/apps/studio/tests/components/interfaces/App/AppBannerWrapper.utils.test.ts b/apps/studio/tests/components/interfaces/App/AppBannerWrapper.utils.test.ts index bdb577a3881..12716cda236 100644 --- a/apps/studio/tests/components/interfaces/App/AppBannerWrapper.utils.test.ts +++ b/apps/studio/tests/components/interfaces/App/AppBannerWrapper.utils.test.ts @@ -1,9 +1,6 @@ import { describe, expect, test } from 'vitest' -import { - isLogsOrObservabilityPath, - isOrganizationLandingPath, -} from '@/components/interfaces/App/AppBannerWrapper.utils' +import { isLogsOrObservabilityPath } from '@/components/interfaces/App/AppBannerWrapper.utils' describe('isLogsOrObservabilityPath', () => { test.each([ @@ -29,16 +26,3 @@ describe('isLogsOrObservabilityPath', () => { expect(isLogsOrObservabilityPath(pathname)).toBe(false) }) }) - -describe('isOrganizationLandingPath', () => { - test.each(['/org', '/organizations', '/org/my-org', '/org/my-org/'])('matches %s', (pathname) => { - expect(isOrganizationLandingPath(pathname)).toBe(true) - }) - - test.each([undefined, null, '', '/project/abc', '/org/my-org/general', '/organizations/new'])( - 'does not match %s', - (pathname) => { - expect(isOrganizationLandingPath(pathname)).toBe(false) - } - ) -}) diff --git a/apps/www/_blog/2025-12-17-building-chatgpt-apps-with-supabase.mdx b/apps/www/_blog/2025-12-17-building-chatgpt-apps-with-supabase.mdx index b49c6cc5ab5..a405a1ba2e7 100644 --- a/apps/www/_blog/2025-12-17-building-chatgpt-apps-with-supabase.mdx +++ b/apps/www/_blog/2025-12-17-building-chatgpt-apps-with-supabase.mdx @@ -194,7 +194,7 @@ const SupabaseStatusWidget: React.FC = () => {
-

+

Supabase Status

diff --git a/apps/www/_customers/flick-games.mdx b/apps/www/_customers/flick-games.mdx new file mode 100644 index 00000000000..f0db53c9461 --- /dev/null +++ b/apps/www/_customers/flick-games.mdx @@ -0,0 +1,124 @@ +--- +name: Flick Games +title: How a two-person studio keeps two live games in sync on Supabase +description: Flick Games is an indie UK studio running two live products, Art of Solitaire and Goalman, on Supabase. A single backend covers authentication, cross-device cloud saves, and remote content delivery across iOS, Android, and Steam for a team of two. +author: wendie_cheung +author_title: Product Marketing +logo: /images/customers/logos/on-light/flick-games.png +logo_inverse: /images/customers/logos/on-dark/flick-games.png +tags: + - supabase +date: '2026-10-06' +company_url: https://flick.games +stats: [] +misc: + [ + { + label: 'Use case', + text: 'Cross-platform mobile and Steam games with unified authentication and cross-device cloud saves', + }, + { label: 'Solutions', text: 'Database, Auth' }, + ] +about: 'Flick is an indie games studio based in the UK running two live games, Art of Solitaire and Goalman, built in Solar2D.' +# "healthcare" | "fintech" | "ecommerce" | "education" | "gaming" | "media" | "real-estate" | "saas" | "social" | "analytics" | "ai" | "developer-tools" +industry: ['gaming'] +# "startup" | "enterprise" | "indie_dev" +company_size: 'indie_dev' +# "Asia" | "Europe" | "North America" | "South America" | "Africa" | "Oceania" +region: 'Europe' +# "database" | "auth" | "storage" | "realtime" | "functions" | "vector" +supabase_products: ['database', 'auth'] +--- + + + When you run a game as a service, you worry about what's going to break while you're not at work, + over a weekend or on holiday. Just knowing how battle-tested Supabase is takes a level of stress + off. + + +[Flick](https://flick.games) is an indie games studio based in the UK. Ian Masters, the studio's founder, has been making video games for close to three decades. Alongside engineer Alan Thomson, he now runs two live products on Supabase: Art of Solitaire, a card game on iOS, Android, and Steam Early Access, and Goalman, a very silly penalty shootout browser game that launched in time for the World Cup. + +Art of Solitaire has built an audience that differs from the typical solitaire crowd. Where most classic solitaire games skew 65 and older, Flick's version pulls in players aged 18 to 25, largely on the strength of its art. The studio has commissioned more than 70 artists from around the world to design its card decks, adding a new one every month, including collaborations for Pride, Black History Month, and International Women's Month, and more. + +## The challenge + +Before landing on its current setup, Flick pieced together a different backend service for each platform, and none of them held up. Google Play Game Services and Game Center covered authentication on mobile, but treated Android and iOS as separate identities entirely, with no way to recognize the same player moving between them. + + + Android users could only authenticate on Android. If they also had an iOS device, tough. We don't + know that they're the same person, and that's a pretty big problem. + + +Flick used ChilliConnect for backups and save data until Unity acquired it and shut the service down, forcing a move. The team evaluated Firebase, but it didn't fit how they worked, so they settled on each platform's native option: iCloud on iOS and Google Play on Android. That held up while Flick was mobile-only, but it was never cross-platform, and once Steam arrived as a third platform it stopped making sense. + +## Why they chose Supabase + +Adding a third channel meant Flick didn't want to bolt on a third separate backend service. Supabase covered authentication, database, and storage together, and the team could see it working almost immediately. + + + It was just really easy to use. It had authentication, it had all the database stuff, it had + storage. It ticked loads of boxes very quickly. + + +## How Flick runs on Supabase + +Art of Solitaire's authentication now runs on a passwordless email flow through Supabase Auth, with no passwords stored anywhere. The studio recently migrated from a magic link players clicked in their email to a one-time code players type in directly, a change made to better support platforms like Steam Deck, which doesn't have its own email client. + + + We were actually able to have both services live at the same time. So as people update the game + from one version to another, it never breaks, because both services are working simultaneously. + + +Rather than adopt a separate SDK for each platform, Flick built one lightweight Lua wrapper around Supabase's REST endpoints and reused it across iOS, Android, and Steam, so adding a platform never means maintaining another SDK in parallel. + + + Supabase having REST endpoints is massive. There are some tools where they're SDK only, and that's + problematic when you're cross-platform, because you're suddenly managing two, three, four SDKs, + and they're not always kept in sync. Whereas REST endpoints with our Lua code base means we just + write a very lightweight wrapper and we're done. It's very easy for us to expand to other + platforms. + + +Steam added its own challenge: unlike iOS and Android, it has no native way to check whether a player has already made an in-game purchase. Flick now records every Steam transaction directly in Supabase, and even players who haven't logged in are tracked as anonymous Supabase users so the game still has somewhere to store their activity. + +## Keeping three platforms and one experience in sync + +Players who move between a phone and Steam expect their progress and purchases to already be there, so Flick built merge logic that reconciles saves across devices on login. Progress adds up rather than overwrites: currency collected on two different devices adds together instead of picking one number, and progress made offline catches up correctly once a device reconnects. + + + That requires some sort of record of those things on all the devices that can then be married up + and turned into one single source of truth. We were able to do that through the Supabase database + tables. + + +Remote delivery solved a different problem for Flick: shipping new content without shipping a new build. Art of Solitaire's card decks used to ship embedded inside the game binary, which meant a store submission and approval cycle every time a new deck went out, and the team often had to turn a deck around on a tight deadline when an artist delivered late in the month. The studio now serves deck data remotely through Supabase, so a new deck can go out without a single update to the game itself. + + + We can now roll decks out remotely. We don't have to update the game at all. It was always a big + source of stress that's now gone. + + +## The results + +Flick is two people running two live games, with backend work in place that would normally take a much bigger team to build. + + + If we were to write all of this authentication, cloud saves, and remote config ourselves, the + stuff we use Supabase for, I think it's realistic to say we wouldn't have all of those features + built out for users. We've got limited resources so Supabase has really helped us be way ahead + from when we started. + + +For a team this size, cost matters every bit as much as capability. + + + You're building a game, you hope it will be successful and grow, but the truth is most games + won't. Supabase's pricing is really accessible, and it feels like you're intentionally trying to + support smaller developers with that kind of structure. + + +## What's next + +Flick's next moves build on the same backend. The team is opening up an artist portal on its own website, powered by Supabase. The artists it commissions will be able to log in to test their assets today and, soon, upload them directly. Players will get a similar login to view their card collection outside the game itself. + +Separately, Art of Solitaire is collaborating with another Steam game, Forbidden Solitaire, still with a two-person team behind it. diff --git a/apps/www/_go/events/accenture-reinvention-2026/contest.tsx b/apps/www/_go/events/accenture-reinvention-2026/contest.tsx index a0c3b136a0a..9c2123e2381 100644 --- a/apps/www/_go/events/accenture-reinvention-2026/contest.tsx +++ b/apps/www/_go/events/accenture-reinvention-2026/contest.tsx @@ -71,13 +71,13 @@ const page: GoPageInput = {

No purchase necessary. Void where prohibited.{' '} diff --git a/apps/www/_go/events/ai-engineer-europe-2026/contest-thank-you.tsx b/apps/www/_go/events/ai-engineer-europe-2026/contest-thank-you.tsx index 80b4e3df8be..6a2603d919e 100644 --- a/apps/www/_go/events/ai-engineer-europe-2026/contest-thank-you.tsx +++ b/apps/www/_go/events/ai-engineer-europe-2026/contest-thank-you.tsx @@ -22,7 +22,7 @@ const page: GoPageInput = { children: (

No purchase necessary. Void where prohibited.{' '} diff --git a/apps/www/_go/events/dash-2026/contest.tsx b/apps/www/_go/events/dash-2026/contest.tsx index aded5437805..d95756b3f2f 100644 --- a/apps/www/_go/events/dash-2026/contest.tsx +++ b/apps/www/_go/events/dash-2026/contest.tsx @@ -85,7 +85,7 @@ const page: GoPageInput = {

  • Complete these steps by the deadline in your post-event note
  • No purchase necessary. Void where prohibited.{' '} diff --git a/apps/www/_go/events/mcp-dev-nyc-2026/contest-thank-you.tsx b/apps/www/_go/events/mcp-dev-nyc-2026/contest-thank-you.tsx index 3ae184c25b9..9c72fcc5a7e 100644 --- a/apps/www/_go/events/mcp-dev-nyc-2026/contest-thank-you.tsx +++ b/apps/www/_go/events/mcp-dev-nyc-2026/contest-thank-you.tsx @@ -22,7 +22,7 @@ const page: GoPageInput = { children: (

    No purchase necessary. Void where prohibited.{' '} diff --git a/apps/www/_go/events/pgconf-dev-2026/contest-thank-you.tsx b/apps/www/_go/events/pgconf-dev-2026/contest-thank-you.tsx index f5354497172..0feefeefe44 100644 --- a/apps/www/_go/events/pgconf-dev-2026/contest-thank-you.tsx +++ b/apps/www/_go/events/pgconf-dev-2026/contest-thank-you.tsx @@ -22,7 +22,7 @@ const page: GoPageInput = { children: (

    No purchase necessary. Void where prohibited.{' '} diff --git a/apps/www/_go/events/postgres-summit-2026/contest-thank-you.tsx b/apps/www/_go/events/postgres-summit-2026/contest-thank-you.tsx index c1d81ff7309..50fa7eeef07 100644 --- a/apps/www/_go/events/postgres-summit-2026/contest-thank-you.tsx +++ b/apps/www/_go/events/postgres-summit-2026/contest-thank-you.tsx @@ -22,7 +22,7 @@ const page: GoPageInput = { children: (

    No purchase necessary. Void where prohibited.{' '} diff --git a/apps/www/_go/events/postgresconf-sjc-2026/contest-thank-you.tsx b/apps/www/_go/events/postgresconf-sjc-2026/contest-thank-you.tsx index 632e1b99976..a5f7b172bd2 100644 --- a/apps/www/_go/events/postgresconf-sjc-2026/contest-thank-you.tsx +++ b/apps/www/_go/events/postgresconf-sjc-2026/contest-thank-you.tsx @@ -23,7 +23,7 @@ const page: GoPageInput = { children: (

    No purchase necessary. Void where prohibited.{' '} diff --git a/apps/www/_go/events/startup-grind-2026/contest.tsx b/apps/www/_go/events/startup-grind-2026/contest.tsx index 01c3afabc31..db8761b2eeb 100644 --- a/apps/www/_go/events/startup-grind-2026/contest.tsx +++ b/apps/www/_go/events/startup-grind-2026/contest.tsx @@ -45,7 +45,7 @@ const page: GoPageInput = {

  • Complete these steps by Monday, May 11, 2026 at 12:00 PM PST
  • No purchase necessary. Void where prohibited.{' '} diff --git a/apps/www/_go/events/stripe-sessions-2026/contest.tsx b/apps/www/_go/events/stripe-sessions-2026/contest.tsx index 71cc333b550..66d91148c81 100644 --- a/apps/www/_go/events/stripe-sessions-2026/contest.tsx +++ b/apps/www/_go/events/stripe-sessions-2026/contest.tsx @@ -62,12 +62,12 @@ stripe projects env --sync`} Connect your Stripe account to Supabase and sync your payments, customers, and subscriptions into a live Postgres schema.

    - Install the Stripe Sync Engine → - +

    diff --git a/apps/www/_go/events/stripe-sessions-2026/party.tsx b/apps/www/_go/events/stripe-sessions-2026/party.tsx index 83238bc8992..0cbc564741a 100644 --- a/apps/www/_go/events/stripe-sessions-2026/party.tsx +++ b/apps/www/_go/events/stripe-sessions-2026/party.tsx @@ -34,7 +34,7 @@ const page: GoPageInput = { title: "Tonight's hosts", children: ( <> - @@ -43,7 +43,7 @@ const page: GoPageInput = {

    Open-source Postgres development platform

    - + Complete these steps by Monday, May 11, 2026 at 12:00 PM PST

    No purchase necessary. Void where prohibited.{' '} diff --git a/apps/www/_go/events/sxsw-2026/contest.tsx b/apps/www/_go/events/sxsw-2026/contest.tsx index 4d18e0f0f29..b4fbb998be6 100644 --- a/apps/www/_go/events/sxsw-2026/contest.tsx +++ b/apps/www/_go/events/sxsw-2026/contest.tsx @@ -46,7 +46,7 @@ const page: GoPageInput = {

    No purchase necessary. Void where prohibited.{' '} diff --git a/apps/www/_go/thank-you/example-thank-you.tsx b/apps/www/_go/thank-you/example-thank-you.tsx index 80641077db9..87b12d5dfec 100644 --- a/apps/www/_go/thank-you/example-thank-you.tsx +++ b/apps/www/_go/thank-you/example-thank-you.tsx @@ -1,5 +1,4 @@ import type { GoPageInput } from 'marketing' -import Link from 'next/link' import { Button } from 'ui' const page: GoPageInput = { @@ -22,10 +21,10 @@ const page: GoPageInput = { children: (

    ), diff --git a/apps/www/_go/webinar/agency-webinar-mar2026-thankyou.tsx b/apps/www/_go/webinar/agency-webinar-mar2026-thankyou.tsx index d5c8b91727c..6ce6a357d5c 100644 --- a/apps/www/_go/webinar/agency-webinar-mar2026-thankyou.tsx +++ b/apps/www/_go/webinar/agency-webinar-mar2026-thankyou.tsx @@ -1,5 +1,4 @@ import type { GoPageInput } from 'marketing' -import Link from 'next/link' import { Button } from 'ui' const page: GoPageInput = { @@ -22,10 +21,10 @@ const page: GoPageInput = { children: (
    ), diff --git a/apps/www/_go/webinar/bolt-webinar-thank-you.tsx b/apps/www/_go/webinar/bolt-webinar-thank-you.tsx index 88a4369adbc..065fe977179 100644 --- a/apps/www/_go/webinar/bolt-webinar-thank-you.tsx +++ b/apps/www/_go/webinar/bolt-webinar-thank-you.tsx @@ -1,5 +1,4 @@ import type { GoPageInput } from 'marketing' -import Link from 'next/link' import { Button } from 'ui' const page: GoPageInput = { @@ -21,10 +20,10 @@ const page: GoPageInput = { children: (
    ), diff --git a/apps/www/_go/webinar/figma-webinar-may2026-thankyou.tsx b/apps/www/_go/webinar/figma-webinar-may2026-thankyou.tsx index 310700cf987..d2110e29c7d 100644 --- a/apps/www/_go/webinar/figma-webinar-may2026-thankyou.tsx +++ b/apps/www/_go/webinar/figma-webinar-may2026-thankyou.tsx @@ -1,5 +1,4 @@ import type { GoPageInput } from 'marketing' -import Link from 'next/link' import { Button } from 'ui' const page: GoPageInput = { @@ -22,10 +21,10 @@ const page: GoPageInput = { children: (
    ), diff --git a/apps/www/app/(home)/_components/BuiltWithSupabaseSection.tsx b/apps/www/app/(home)/_components/BuiltWithSupabaseSection.tsx index 17cb65d331d..80a86d4ee06 100644 --- a/apps/www/app/(home)/_components/BuiltWithSupabaseSection.tsx +++ b/apps/www/app/(home)/_components/BuiltWithSupabaseSection.tsx @@ -108,12 +108,12 @@ export function BuiltWithSupabaseSection() { Kickstart your next project
    with production ready templates - View all examples - +
    diff --git a/apps/www/app/(home)/_components/CTASection.tsx b/apps/www/app/(home)/_components/CTASection.tsx index 1423c100f41..f6850df860c 100644 --- a/apps/www/app/(home)/_components/CTASection.tsx +++ b/apps/www/app/(home)/_components/CTASection.tsx @@ -20,7 +20,7 @@ export function CTASection() {
    diff --git a/apps/www/app/(home)/_components/Hero.tsx b/apps/www/app/(home)/_components/Hero.tsx index e10b92618c5..972fb74c727 100644 --- a/apps/www/app/(home)/_components/Hero.tsx +++ b/apps/www/app/(home)/_components/Hero.tsx @@ -27,7 +27,7 @@ export function Hero() {
    diff --git a/apps/www/app/(products)/auth/_components/Hero.tsx b/apps/www/app/(products)/auth/_components/Hero.tsx index ccd37f2cb40..f450bb73fca 100644 --- a/apps/www/app/(products)/auth/_components/Hero.tsx +++ b/apps/www/app/(products)/auth/_components/Hero.tsx @@ -1,5 +1,4 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' -import Link from 'next/link' import { Button } from 'ui' import { StartYourProjectButton } from '@/components/StartYourProjectButton' @@ -21,7 +20,7 @@ export function Hero() {
    diff --git a/apps/www/app/(products)/auth/_components/HighlightsSection.tsx b/apps/www/app/(products)/auth/_components/HighlightsSection.tsx index 74e46741de3..bf7041acbd7 100644 --- a/apps/www/app/(products)/auth/_components/HighlightsSection.tsx +++ b/apps/www/app/(products)/auth/_components/HighlightsSection.tsx @@ -3,6 +3,8 @@ import { Check } from 'lucide-react' import Link from 'next/link' import { Button } from 'ui' +import { isCrossZoneHref } from '@/lib/cross-zone' + const HIGHLIGHTS = [ { title: 'Multi-Factor Authentication', @@ -65,7 +67,11 @@ export function HighlightsSection() {
    diff --git a/apps/www/app/(products)/auth/_components/RLSSectionClient.tsx b/apps/www/app/(products)/auth/_components/RLSSectionClient.tsx index 453dafe44e0..af4d867dce3 100644 --- a/apps/www/app/(products)/auth/_components/RLSSectionClient.tsx +++ b/apps/www/app/(products)/auth/_components/RLSSectionClient.tsx @@ -2,7 +2,6 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' import { AnimatePresence, motion, useInView } from 'framer-motion' -import Link from 'next/link' import { useCallback, useEffect, useRef, useState } from 'react' import { cn } from 'ui' @@ -178,7 +177,7 @@ export function RLSSectionClient({ examples }: { examples: RLSExample[] }) { /> - @@ -200,7 +199,7 @@ export function RLSSectionClient({ examples }: { examples: RLSExample[] }) { strokeLinejoin="round" /> - +
    diff --git a/apps/www/app/(products)/database/_components/ApiSectionClient.tsx b/apps/www/app/(products)/database/_components/ApiSectionClient.tsx index 342014446ba..10ffae261a4 100644 --- a/apps/www/app/(products)/database/_components/ApiSectionClient.tsx +++ b/apps/www/app/(products)/database/_components/ApiSectionClient.tsx @@ -3,7 +3,6 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' import { AnimatePresence, motion, useInView } from 'framer-motion' import { ArrowDownUp, ChevronDown, Filter, Plus, Search } from 'lucide-react' -import Link from 'next/link' import { useCallback, useEffect, useRef, useState } from 'react' import { cn } from 'ui' @@ -188,7 +187,7 @@ export function ApiSectionClient({ examples }: { examples: ApiExample[] }) { /> - @@ -209,7 +208,7 @@ export function ApiSectionClient({ examples }: { examples: ApiExample[] }) { strokeLinejoin="round" /> - +
    diff --git a/apps/www/app/(products)/database/_components/DatabaseContent.tsx b/apps/www/app/(products)/database/_components/DatabaseContent.tsx index ec3c7ce58cd..dfd27b5ce4c 100644 --- a/apps/www/app/(products)/database/_components/DatabaseContent.tsx +++ b/apps/www/app/(products)/database/_components/DatabaseContent.tsx @@ -1,6 +1,7 @@ import { FeaturesSection } from './FeaturesSection' import { Hero } from './Hero' import { HighlightsSection } from './HighlightsSection' +import { MultigresAlphaSection } from './MultigresAlphaSection' import { QuoteSection } from './QuoteSection' import { SqlEditorSection } from './SqlEditorSection' import { TableEditorSection } from './TableEditorSection' @@ -40,6 +41,13 @@ export function DatabaseContent({ apiSlot }: { apiSlot: React.ReactNode }) { > +
    + +
    diff --git a/apps/www/app/(products)/database/_components/FeaturesSection.tsx b/apps/www/app/(products)/database/_components/FeaturesSection.tsx index 43b464f8c93..d85dc3ee53a 100644 --- a/apps/www/app/(products)/database/_components/FeaturesSection.tsx +++ b/apps/www/app/(products)/database/_components/FeaturesSection.tsx @@ -162,7 +162,7 @@ function PostgresSkeleton() { viewBox="0 0 390 430" className="w-80 h-80 relative z-[5]" style={{ - color: hovered ? 'hsl(var(--brand-default))' : 'hsl(var(--foreground-muted))', + color: hovered ? 'hsl(var(--brand-default))' : 'var(--foreground-muted)', opacity: hovered ? 1 : 0.8, transition: 'color 0.5s, opacity 0.5s', }} diff --git a/apps/www/app/(products)/database/_components/Hero.tsx b/apps/www/app/(products)/database/_components/Hero.tsx index 8d6a35bc1e9..e9e8a277e43 100644 --- a/apps/www/app/(products)/database/_components/Hero.tsx +++ b/apps/www/app/(products)/database/_components/Hero.tsx @@ -1,5 +1,4 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' -import Link from 'next/link' import { Button } from 'ui' import { StartYourProjectButton } from '@/components/StartYourProjectButton' @@ -21,7 +20,7 @@ export function Hero() {
    diff --git a/apps/www/app/(products)/database/_components/HighlightsSection.tsx b/apps/www/app/(products)/database/_components/HighlightsSection.tsx index 71f2b94c573..cbe7ef15c8e 100644 --- a/apps/www/app/(products)/database/_components/HighlightsSection.tsx +++ b/apps/www/app/(products)/database/_components/HighlightsSection.tsx @@ -4,6 +4,8 @@ import Image from 'next/image' import Link from 'next/link' import { Badge, Button } from 'ui' +import { isCrossZoneHref } from '@/lib/cross-zone' + type Highlight = { title: string badge?: string @@ -75,7 +77,11 @@ export function HighlightsSection() {
    diff --git a/apps/www/app/(products)/database/_components/MultigresAlphaSection.tsx b/apps/www/app/(products)/database/_components/MultigresAlphaSection.tsx new file mode 100644 index 00000000000..c14ae8326eb --- /dev/null +++ b/apps/www/app/(products)/database/_components/MultigresAlphaSection.tsx @@ -0,0 +1,53 @@ +import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' +import { MarketingForm } from 'marketing/forms' +import { Button } from 'ui' + +import { getGoPageBySlug } from '@/lib/go' + +const GO_PAGE_SLUG = 'multigres-early-access' +const FORM_ID = 'form' + +function getMultigresAlphaPage() { + const page = getGoPageBySlug(GO_PAGE_SLUG) + const form = page?.sections?.find((section) => section.id === FORM_ID) + if (!page || form?.type !== 'form') { + throw new Error(`Go page form "${GO_PAGE_SLUG}#${FORM_ID}" not found`) + } + + return { hero: page.hero, form } +} + +export function MultigresAlphaSection() { + const { hero, form } = getMultigresAlphaPage() + + return ( + +
    +
    +

    + {hero.subtitle} +
    + {hero.title} +

    +

    {hero.description}

    +

    {form.description}

    + +
    + +
    +
    + ) +} diff --git a/apps/www/app/(products)/edge-functions/_components/CTASection.tsx b/apps/www/app/(products)/edge-functions/_components/CTASection.tsx index 658eb674196..a13b23887ec 100644 --- a/apps/www/app/(products)/edge-functions/_components/CTASection.tsx +++ b/apps/www/app/(products)/edge-functions/_components/CTASection.tsx @@ -2,7 +2,6 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' import { AnimatePresence, motion, useInView } from 'framer-motion' -import Link from 'next/link' import { useEffect, useRef, useState } from 'react' import { Button } from 'ui' @@ -88,7 +87,7 @@ export function CTASection() {
    diff --git a/apps/www/app/(products)/edge-functions/_components/ExamplesSection.tsx b/apps/www/app/(products)/edge-functions/_components/ExamplesSection.tsx index c88ad81fbb9..59c58503e42 100644 --- a/apps/www/app/(products)/edge-functions/_components/ExamplesSection.tsx +++ b/apps/www/app/(products)/edge-functions/_components/ExamplesSection.tsx @@ -25,12 +25,12 @@ export function ExamplesSection() { What you can build
    with Edge Functions - View all examples - +
    {/* Cards */} diff --git a/apps/www/app/(products)/edge-functions/_components/GlobalPresenceSection.tsx b/apps/www/app/(products)/edge-functions/_components/GlobalPresenceSection.tsx index 437599640f9..223bd75bf6d 100644 --- a/apps/www/app/(products)/edge-functions/_components/GlobalPresenceSection.tsx +++ b/apps/www/app/(products)/edge-functions/_components/GlobalPresenceSection.tsx @@ -1,5 +1,3 @@ -import Link from 'next/link' - import SectionContainerWithCn from '../../../../components/Layouts/SectionContainerWithCn' const features = [ @@ -8,12 +6,12 @@ const features = [ paragraph: ( <> Edge functions run globally or can be{' '} - pinned to your database's proximity - + ), }, diff --git a/apps/www/app/(products)/edge-functions/_components/Hero.tsx b/apps/www/app/(products)/edge-functions/_components/Hero.tsx index 57665fcfc2e..d1aeadd65ef 100644 --- a/apps/www/app/(products)/edge-functions/_components/Hero.tsx +++ b/apps/www/app/(products)/edge-functions/_components/Hero.tsx @@ -1,5 +1,4 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' -import Link from 'next/link' import { Button } from 'ui' import { Highlights } from './Highlights' @@ -22,7 +21,7 @@ export function Hero() {
    diff --git a/apps/www/app/(products)/edge-functions/_components/IntegratesSectionClient.tsx b/apps/www/app/(products)/edge-functions/_components/IntegratesSectionClient.tsx index 0587801d4a3..56dddfac327 100644 --- a/apps/www/app/(products)/edge-functions/_components/IntegratesSectionClient.tsx +++ b/apps/www/app/(products)/edge-functions/_components/IntegratesSectionClient.tsx @@ -3,7 +3,6 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' import { AnimatePresence, motion, useInView } from 'framer-motion' import { Database, HardDrive, Shield, Webhook, Zap } from 'lucide-react' -import Link from 'next/link' import { useCallback, useEffect, useRef, useState } from 'react' import { cn } from 'ui' @@ -197,7 +196,7 @@ export function IntegratesSectionClient({ useCases }: { useCases: UseCase[] }) {

    {active.paragraph}

    - @@ -219,7 +218,7 @@ export function IntegratesSectionClient({ useCases }: { useCases: UseCase[] }) { strokeLinejoin="round" /> - +
    diff --git a/apps/www/app/(products)/edge-functions/_components/LocalDXSection.tsx b/apps/www/app/(products)/edge-functions/_components/LocalDXSection.tsx index 86588996ef0..5937c75708a 100644 --- a/apps/www/app/(products)/edge-functions/_components/LocalDXSection.tsx +++ b/apps/www/app/(products)/edge-functions/_components/LocalDXSection.tsx @@ -83,14 +83,14 @@ function CICard() {

    Continuous Integration

    Use the{' '} - Supabase CLI with GitHub actions - {' '} + {' '} to preview and deploy your functions along with the rest of your application

    diff --git a/apps/www/app/(products)/realtime/_components/ApiSectionClient.tsx b/apps/www/app/(products)/realtime/_components/ApiSectionClient.tsx index cfcd68ec584..86a16180b91 100644 --- a/apps/www/app/(products)/realtime/_components/ApiSectionClient.tsx +++ b/apps/www/app/(products)/realtime/_components/ApiSectionClient.tsx @@ -3,7 +3,6 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' import { AnimatePresence, motion, useInView } from 'framer-motion' import { Database, Radio, Users } from 'lucide-react' -import Link from 'next/link' import { useCallback, useEffect, useRef, useState } from 'react' import { cn } from 'ui' @@ -167,7 +166,7 @@ export function ApiSectionClient({ examples }: { examples: ApiExample[] }) { /> - @@ -188,7 +187,7 @@ export function ApiSectionClient({ examples }: { examples: ApiExample[] }) { strokeLinejoin="round" /> - + diff --git a/apps/www/app/(products)/realtime/_components/FeaturesSection.tsx b/apps/www/app/(products)/realtime/_components/FeaturesSection.tsx index 7dc72de5698..1f31b8f6938 100644 --- a/apps/www/app/(products)/realtime/_components/FeaturesSection.tsx +++ b/apps/www/app/(products)/realtime/_components/FeaturesSection.tsx @@ -610,7 +610,7 @@ function BroadcastSkeleton() { diff --git a/apps/www/app/(products)/storage/_components/ApiSectionClient.tsx b/apps/www/app/(products)/storage/_components/ApiSectionClient.tsx index a0abc97d11b..3809e5106b8 100644 --- a/apps/www/app/(products)/storage/_components/ApiSectionClient.tsx +++ b/apps/www/app/(products)/storage/_components/ApiSectionClient.tsx @@ -3,7 +3,6 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' import { AnimatePresence, motion, useInView } from 'framer-motion' import { ArrowRightLeft, ChevronDown, Download, List, Upload } from 'lucide-react' -import Link from 'next/link' import { useCallback, useEffect, useRef, useState } from 'react' import { cn } from 'ui' @@ -189,7 +188,7 @@ export function ApiSectionClient({ examples }: { examples: ApiExample[] }) { /> - @@ -210,7 +209,7 @@ export function ApiSectionClient({ examples }: { examples: ApiExample[] }) { strokeLinejoin="round" /> - + diff --git a/apps/www/app/(products)/storage/_components/BucketTypesSection.tsx b/apps/www/app/(products)/storage/_components/BucketTypesSection.tsx index f81c8b39ab3..35dacee0d39 100644 --- a/apps/www/app/(products)/storage/_components/BucketTypesSection.tsx +++ b/apps/www/app/(products)/storage/_components/BucketTypesSection.tsx @@ -3,6 +3,8 @@ import { Check } from 'lucide-react' import Link from 'next/link' import { Button } from 'ui' +import { isCrossZoneHref } from '@/lib/cross-zone' + const BUCKET_TYPES = [ { title: 'Files buckets', @@ -65,7 +67,11 @@ export function BucketTypesSection() {
    diff --git a/apps/www/app/(products)/storage/_components/FeaturesSection.tsx b/apps/www/app/(products)/storage/_components/FeaturesSection.tsx index 24db2ceac62..b8abb4783c0 100644 --- a/apps/www/app/(products)/storage/_components/FeaturesSection.tsx +++ b/apps/www/app/(products)/storage/_components/FeaturesSection.tsx @@ -69,7 +69,7 @@ function InteroperableSkeleton() { ))} -
    +
    -
    +
    @@ -110,7 +110,7 @@ function InteroperableSkeleton() {
    -
    +
    -
    -
    +
    +
    -
    -
    +
    +
    diff --git a/apps/www/app/(products)/storage/_components/PermissionsSectionClient.tsx b/apps/www/app/(products)/storage/_components/PermissionsSectionClient.tsx index d463a0b887b..57af15fd110 100644 --- a/apps/www/app/(products)/storage/_components/PermissionsSectionClient.tsx +++ b/apps/www/app/(products)/storage/_components/PermissionsSectionClient.tsx @@ -2,7 +2,6 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' import { AnimatePresence, motion, useInView } from 'framer-motion' -import Link from 'next/link' import { useCallback, useEffect, useRef, useState } from 'react' import { cn } from 'ui' @@ -167,7 +166,7 @@ export function PermissionsSectionClient({ examples }: { examples: PermissionExa /> - @@ -188,7 +187,7 @@ export function PermissionsSectionClient({ examples }: { examples: PermissionExa strokeLinejoin="round" /> - +
    diff --git a/apps/www/app/partners/PartnersContent.tsx b/apps/www/app/partners/PartnersContent.tsx index 8744b8fe019..49429aae35f 100644 --- a/apps/www/app/partners/PartnersContent.tsx +++ b/apps/www/app/partners/PartnersContent.tsx @@ -14,6 +14,7 @@ import Panel from '@/components/Panel' import BecomeAPartner from '@/components/Partners/BecomeAPartner' import ProductHeaderCentered from '@/components/Sections/ProductHeaderCentered' import pageData, { type PARTNER_TIER } from '@/data/partners' +import { isCrossZoneHref } from '@/lib/cross-zone' type FeaturedPartner = { slug: string; title: string; logo: string } @@ -264,14 +265,8 @@ export default function PartnersContent({ featuredPartners }: Props) {
    - {pageData.integrationOptions.options.map((option) => ( - + {pageData.integrationOptions.options.map((option) => { + const panelContent = (
    - - ))} + ) + return isCrossZoneHref(option.href) ? ( + + {panelContent} + + ) : ( + + {panelContent} + + ) + })}
    diff --git a/apps/www/app/state-of-startups/StateOfStartups2026Content.tsx b/apps/www/app/state-of-startups/StateOfStartups2026Content.tsx index d2ce5b7adbd..892f97c71b3 100644 --- a/apps/www/app/state-of-startups/StateOfStartups2026Content.tsx +++ b/apps/www/app/state-of-startups/StateOfStartups2026Content.tsx @@ -301,7 +301,7 @@ const CTABanner = forwardRef((props, ref) => {
    ) diff --git a/apps/www/app/state-of-startups/components/SurveyCrossTabChart.tsx b/apps/www/app/state-of-startups/components/SurveyCrossTabChart.tsx index 5001b30346a..e1df252b4f8 100644 --- a/apps/www/app/state-of-startups/components/SurveyCrossTabChart.tsx +++ b/apps/www/app/state-of-startups/components/SurveyCrossTabChart.tsx @@ -37,7 +37,7 @@ const PAD_TOP = 24 const PAD_BOTTOM = 72 const ACCENT = 'hsl(var(--brand-default))' -const MUTED = 'hsl(var(--foreground-light))' +const MUTED = 'var(--foreground-light)' export function SurveyCrossTabChart({ title, @@ -86,7 +86,7 @@ export function SurveyCrossTabChart({ x2={W - PAD_RIGHT} y1={y(g)} y2={y(g)} - style={{ stroke: 'hsl(var(--border-muted))' }} + style={{ stroke: 'var(--border-muted)' }} strokeWidth={1} /> {

    diff --git a/apps/www/components/CTABanner/index.tsx b/apps/www/components/CTABanner/index.tsx index 5434aa39f1b..c9e205b8f81 100644 --- a/apps/www/components/CTABanner/index.tsx +++ b/apps/www/components/CTABanner/index.tsx @@ -31,7 +31,7 @@ const CTABanner = ({ darkerBg, className }: Props) => {
    ))}
    diff --git a/apps/www/components/Nav/DevelopersDropdown.tsx b/apps/www/components/Nav/DevelopersDropdown.tsx index 2e252c85cbe..3a012d3c386 100644 --- a/apps/www/components/Nav/DevelopersDropdown.tsx +++ b/apps/www/components/Nav/DevelopersDropdown.tsx @@ -3,6 +3,8 @@ import { data as DevelopersData } from 'data/Developers' import { ChevronRight } from 'lucide-react' import Link from 'next/link' +import { isCrossZoneHref } from '@/lib/cross-zone' + type LinkProps = { text: string description?: string @@ -23,12 +25,9 @@ export const DevelopersDropdown = () => { {column.label}
      - {column.links.map(({ icon: Icon, ...link }: LinkProps) => ( -
    • - + {column.links.map(({ icon: Icon, ...link }: LinkProps) => { + const label = ( + <> {Icon && } {link.text} {link.text === 'Careers' && jobsCount > 0 && ( @@ -40,9 +39,28 @@ export const DevelopersDropdown = () => { strokeWidth={2} className="w-3 -ml-1 transition-all will-change-transform -translate-x-1 opacity-0 group-hover:translate-x-0 group-hover:opacity-100" /> - -
    • - ))} + + ) + return ( +
    • + {isCrossZoneHref(link.url!) ? ( + + {label} + + ) : ( + + {label} + + )} +
    • + ) + })}
    ))} diff --git a/apps/www/components/Nav/MenuItem.tsx b/apps/www/components/Nav/MenuItem.tsx index 0532f7f60d8..a1dacd8acd0 100644 --- a/apps/www/components/Nav/MenuItem.tsx +++ b/apps/www/components/Nav/MenuItem.tsx @@ -3,6 +3,8 @@ import Link from 'next/link' import React from 'react' import { Badge, cn } from 'ui' +import { isCrossZoneHref } from '@/lib/cross-zone' + const MenuItem = React.forwardRef< React.ElementRef<'a'>, React.ComponentPropsWithoutRef<'a'> & { @@ -31,64 +33,65 @@ const MenuItem = React.forwardRef< }, ref ) => { - return ( - + {icon && ( +
    + +
    )} - {...props} - > - {children ?? ( - <> - {icon && ( -
    - -
    - )} -
    -
    -

    - {title} -

    - {badge && {badge.label}} - {hasChevron && ( - - )} -
    - {description && ( -

    - {description} -

    +
    +
    +

    - - )} + > + {title} +

    + {badge && {badge.label}} + {hasChevron && ( + + )} +
    + {description && ( +

    + {description} +

    + )} +
    + + ) + const linkClassName = cn( + 'group/menu-item flex items-center text-foreground-light text-sm hover:text-foreground select-none gap-3 rounded-md p-2 leading-none no-underline focus-ring focus-visible:text-foreground', + description && 'items-center', + className + ) + return isCrossZoneHref(href) ? ( + + {content} + + ) : ( + + {content} ) } diff --git a/apps/www/components/Nav/MobileMenu.tsx b/apps/www/components/Nav/MobileMenu.tsx index 2c3cc44eae4..1898ac58d1b 100644 --- a/apps/www/components/Nav/MobileMenu.tsx +++ b/apps/www/components/Nav/MobileMenu.tsx @@ -15,6 +15,7 @@ import SupabaseWordmark from './SupabaseWordmark' import staticContent from '@/.generated/staticContent/_index.json' import ProductModulesData from '@/data/ProductModules' import { DEFAULT_EASE } from '@/lib/animations' +import { isCrossZoneHref } from '@/lib/cross-zone' import { useSendTelemetryEvent } from '@/lib/telemetry' interface Props { @@ -195,6 +196,14 @@ export const MobileMenu = ({ open, setOpen, menu }: Props) => { {menuItem.title} + ) : isCrossZoneHref(menuItem.url ?? '/') ? ( + setOpen(false)} + > + {menuItem.title} + ) : ( { {!isUserLoading && ( <> {isLoggedIn ? ( - - - + ) : ( <> - - sendTelemetryEvent({ - action: 'sign_in_button_clicked', - properties: { buttonLocation: 'Mobile Nav' }, - }) - } - > - - - - sendTelemetryEvent({ - action: 'start_project_button_clicked', - properties: { buttonLocation: 'Mobile Nav' }, - }) - } - > - - + + )} diff --git a/apps/www/components/Nav/index.tsx b/apps/www/components/Nav/index.tsx index 5c72689f343..ba37f71424c 100644 --- a/apps/www/components/Nav/index.tsx +++ b/apps/www/components/Nav/index.tsx @@ -5,7 +5,6 @@ import ScrollProgress from 'components/ScrollProgress' import { getMenu } from 'data/nav' import { DevToolbarTrigger } from 'dev-tools' import { useSendTelemetryEvent } from 'lib/telemetry' -import Link from 'next/link' import { usePathname } from 'next/navigation' import React, { useState } from 'react' import { useWindowSize } from 'react-use' @@ -13,6 +12,7 @@ import { Button, buttonVariants, cn, + FloatingPlate, NavigationMenu, NavigationMenuContent, NavigationMenuItem, @@ -216,28 +216,54 @@ const Nav = ({ hideNavbar, stickyNavbar = true }: Props) => { {isLoggedIn ? ( <> - + {isStateOfStartupsPage ? ( + + + + ) : ( + + )} ) : ( <> - + {isStateOfStartupsPage ? ( + + + + ) : ( + + )} )} diff --git a/apps/www/components/Partners/BackgroundPattern.tsx b/apps/www/components/Partners/BackgroundPattern.tsx index 7315b8aa421..2ef32c721c9 100644 --- a/apps/www/components/Partners/BackgroundPattern.tsx +++ b/apps/www/components/Partners/BackgroundPattern.tsx @@ -5,14 +5,14 @@ const BackgroundPattern = ({ className }: { className?: string }) => {
    diff --git a/apps/www/components/Pricing/ComputePricingTable.tsx b/apps/www/components/Pricing/ComputePricingTable.tsx index d99d608734d..342ab640be2 100644 --- a/apps/www/components/Pricing/ComputePricingTable.tsx +++ b/apps/www/components/Pricing/ComputePricingTable.tsx @@ -3,6 +3,8 @@ import Link from 'next/link' import { Fragment, useMemo } from 'react' import { cn } from 'ui' +import { isCrossZoneHref } from '@/lib/cross-zone' + const ComputePricingTable = () => { const columnNames = useMemo( () => @@ -49,13 +51,23 @@ const ComputePricingTable = () => { translate={column.key === 'pricing' ? 'no' : undefined} > {column.url ? ( - - {column.value} - + isCrossZoneHref(column.url) ? ( + + {column.value} + + ) : ( + + {column.value} + + ) ) : ( column.value )} diff --git a/apps/www/components/Pricing/PricingAddons.tsx b/apps/www/components/Pricing/PricingAddons.tsx index e95472af019..01e5b2f28bb 100644 --- a/apps/www/components/Pricing/PricingAddons.tsx +++ b/apps/www/components/Pricing/PricingAddons.tsx @@ -8,6 +8,7 @@ import { FC } from 'react' import { Button, cn } from 'ui' import CostControlAnimation from './CostControlAnimation' +import { isCrossZoneHref } from '@/lib/cross-zone' const addons = [ { @@ -97,9 +98,15 @@ const AddonCard = ({ addon }: any) => {
    @@ -152,6 +159,10 @@ const AddonCard = ({ addon }: any) => {
    + ) : isCrossZoneHref(addon.ctaLink) ? ( + + + ) : ( diff --git a/apps/www/components/Pricing/PricingComparisonTable.tsx b/apps/www/components/Pricing/PricingComparisonTable.tsx index b8b42e4d436..ae0005aefef 100644 --- a/apps/www/components/Pricing/PricingComparisonTable.tsx +++ b/apps/www/components/Pricing/PricingComparisonTable.tsx @@ -21,6 +21,7 @@ import { } from 'ui' import UpgradePlan from './UpgradePlan' +import { isCrossZoneHref } from '@/lib/cross-zone' const MobileHeader = ({ description, @@ -46,6 +47,17 @@ const MobileHeader = ({ const selectedPlan = plans.find((p) => p.name === plan)! const isUpgradablePlan = selectedPlan.name === 'Pro' || selectedPlan.name === 'Team' + const trackPlanCtaClick = () => + sendTelemetryEvent({ + action: 'www_pricing_plan_cta_clicked', + properties: { + plan, + showUpgradeText: false, + section: 'comparison_table', + tableMode: 'mobile', + }, + ...(orgSlug && { groups: { organization: orgSlug } }), + }) return (
    @@ -84,23 +96,15 @@ const MobileHeader = ({ /> ) : ( )}
    @@ -407,6 +411,17 @@ const PricingComparisonTable = ({ {plans.map((plan) => { const isUpgradablePlan = plan.name === 'Pro' || plan.name === 'Team' + const trackPlanCtaClick = () => + sendTelemetryEvent({ + action: 'www_pricing_plan_cta_clicked', + properties: { + plan: plan.name, + showUpgradeText: false, + section: 'comparison_table', + tableMode: 'desktop', + }, + ...(orgSlug && { groups: { organization: orgSlug } }), + }) return ( - - sendTelemetryEvent({ - action: 'www_pricing_plan_cta_clicked', - properties: { - plan: plan.name, - showUpgradeText: false, - section: 'comparison_table', - tableMode: 'desktop', - }, - ...(orgSlug && { groups: { organization: orgSlug } }), - }) - } - > - {plan.cta} - + {isCrossZoneHref(plan.href) ? ( + + {plan.cta} + + ) : ( + + {plan.cta} + + )} )} diff --git a/apps/www/components/Pricing/PricingComputeSection.tsx b/apps/www/components/Pricing/PricingComputeSection.tsx index 3dd252eff7e..93532b63bb4 100644 --- a/apps/www/components/Pricing/PricingComputeSection.tsx +++ b/apps/www/components/Pricing/PricingComputeSection.tsx @@ -1,7 +1,6 @@ 'use client' import { ChevronDownIcon } from '@heroicons/react/outline' -import Link from 'next/link' import React, { useEffect, useRef, useState } from 'react' import { useWindowSize } from 'react-use' import { plans as allPlans } from 'shared-data/plans' @@ -129,9 +128,9 @@ const PricingComputeSection = () => {
    {

    Read more on{' '} - usage-based billing for compute - {' '} + {' '} or{' '} - Compute Add-ons - + .

    diff --git a/apps/www/components/Pricing/PricingDiskSection.tsx b/apps/www/components/Pricing/PricingDiskSection.tsx index 5c7873f03a1..2ce34071caa 100644 --- a/apps/www/components/Pricing/PricingDiskSection.tsx +++ b/apps/www/components/Pricing/PricingDiskSection.tsx @@ -1,5 +1,4 @@ import { ArrowUpRight } from 'lucide-react' -import Link from 'next/link' import { Button } from 'ui' import Panel from '../Panel' @@ -8,7 +7,7 @@ const diskTypes = [ { name: 'General Purpose', tagline: 'Balance between price and performance', - maxSize: '16 TB', + maxSize: '64 TB', size: '8 GB included\nthen $0.125 per GB', iops: '3,000 IOPS included\nthen $0.024 per IOPS', throughput: '125 MB/s included\nthen $0.095 per MB/s', @@ -103,9 +102,9 @@ const PricingDiskSection = () => (
    diff --git a/apps/www/components/Pricing/PricingPlans.tsx b/apps/www/components/Pricing/PricingPlans.tsx index a7380bb0742..4b9f0f923f2 100644 --- a/apps/www/components/Pricing/PricingPlans.tsx +++ b/apps/www/components/Pricing/PricingPlans.tsx @@ -8,6 +8,7 @@ import { plans } from 'shared-data/plans' import { Button, cn } from 'ui' import UpgradePlan from './UpgradePlan' +import { isCrossZoneHref } from '@/lib/cross-zone' interface PricingPlansProps { organizations?: Organization[] @@ -86,9 +87,15 @@ const PricingPlans = ({ organizations, hasExistingOrganizations }: PricingPlansP variant={plan.name === 'Enterprise' ? 'default' : 'primary'} asChild > - - {plan.cta} - + {isCrossZoneHref(plan.href) ? ( + + {plan.cta} + + ) : ( + + {plan.cta} + + )} )} diff --git a/apps/www/components/Pricing/PricingTableRow.tsx b/apps/www/components/Pricing/PricingTableRow.tsx index 87f7890ead1..b34abd998dc 100644 --- a/apps/www/components/Pricing/PricingTableRow.tsx +++ b/apps/www/components/Pricing/PricingTableRow.tsx @@ -1,5 +1,4 @@ import ProductIcon from 'components/ProductIcon' -import Link from 'next/link' import { Fragment, ReactNode } from 'react' import { FeatureKey } from 'shared-data/pricing' import { InfoTooltip } from 'ui-patterns/info-tooltip' @@ -104,9 +103,9 @@ export const pricingTooltips: PricingTooltips = { AWS PrivateLink enables private connectivity between your AWS VPC and Supabase, keeping traffic within the AWS network. Read more in our{' '} - + docs - + . ), @@ -117,9 +116,9 @@ export const pricingTooltips: PricingTooltips = { Supabase provides granular access controls to manage permissions across your organizations and projects. Read more in our{' '} - + docs - + . ), @@ -136,9 +135,9 @@ export const pricingTooltips: PricingTooltips = { automatically captured for all authentication events and help you monitor user authentication activities, detect suspicious behavior, and maintain compliance with security requirements. Read more in our{' '} - + docs - + . ), @@ -150,9 +149,9 @@ export const pricingTooltips: PricingTooltips = { Any Platform API/Dashboard actions performed by organization members are logged automatically for auditing and security purposes. Includes actions such as creating a new project, inviting members or changing project settings. Read more in our{' '} - + docs - + . ), @@ -164,9 +163,9 @@ export const pricingTooltips: PricingTooltips = { You are charged for the total volume of log data that Supabase ingests across all your project's services (Postgres, API gateway, Auth, Storage, Realtime, Edge Functions, and others) during the billing cycle. Read more in our{' '} - + docs - + . ), @@ -179,9 +178,9 @@ export const pricingTooltips: PricingTooltips = { allowance that scales with how much log data you ingest. The allowance covers the volume of log data scanned when you read logs through the Studio UI, the Management API, the CLI, or any other interface. Read more in our{' '} - + docs - + . ), diff --git a/apps/www/components/Products/Functions/ParityImage.tsx b/apps/www/components/Products/Functions/ParityImage.tsx index 913dc8ed8aa..6b9417247cc 100644 --- a/apps/www/components/Products/Functions/ParityImage.tsx +++ b/apps/www/components/Products/Functions/ParityImage.tsx @@ -107,7 +107,7 @@ const ParityImage = ({ isHovered = false }: { isHovered?: boolean }) => ( boxShadow: '0 0 12px 0 #3ecf8e40', } : { - border: '1px solid hsl(var(--border-default))', + border: '1px solid var(--border-default)', boxShadow: 'none', } } @@ -127,7 +127,7 @@ const ParityImage = ({ isHovered = false }: { isHovered?: boolean }) => ( boxShadow: '0 0 12px 0 #bda4ff40', } : { - border: '1px solid hsl(var(--border-default))', + border: '1px solid var(--border-default)', boxShadow: 'none', } } diff --git a/apps/www/components/Products/Functions/QueryLogs.tsx b/apps/www/components/Products/Functions/QueryLogs.tsx index 040b5a82268..f00c6bcf7c8 100644 --- a/apps/www/components/Products/Functions/QueryLogs.tsx +++ b/apps/www/components/Products/Functions/QueryLogs.tsx @@ -120,7 +120,7 @@ const QueryLogs = ({ isActive, isInView }: { isActive?: boolean; isInView?: bool )} style={{ background: - 'linear-gradient(to bottom, hsl(var(--background-surface-75)) 0%, transparent 100%)', + 'linear-gradient(to bottom, var(--background-surface-75) 0%, transparent 100%)', }} />
    + const cardClassName = cn( + 'group relative w-full sm:h-[400px] flex flex-col gap-5 lg:flex-row focus-ring rounded-xl', + className + ) + const cardContent = ( + <> {image && } + + ) + + return isCrossZoneHref(url) ? ( + + {cardContent} + + ) : ( + + {cardContent} ) } diff --git a/apps/www/components/Sections/GithubExamples.tsx b/apps/www/components/Sections/GithubExamples.tsx index 58eaed6dabb..2dc11554f17 100644 --- a/apps/www/components/Sections/GithubExamples.tsx +++ b/apps/www/components/Sections/GithubExamples.tsx @@ -27,7 +27,7 @@ function GithubExamples() {

    {props.documentation_url && ( ) })} diff --git a/apps/www/components/StartYourProjectButton.tsx b/apps/www/components/StartYourProjectButton.tsx index 4dc263ffd06..ad24b52f53f 100644 --- a/apps/www/components/StartYourProjectButton.tsx +++ b/apps/www/components/StartYourProjectButton.tsx @@ -26,9 +26,9 @@ export function StartYourProjectButton({ return ( ) } diff --git a/apps/www/components/Wrapped/Pages/ProductAnnouncements.tsx b/apps/www/components/Wrapped/Pages/ProductAnnouncements.tsx index 81ba3b4e0f8..e5f4907dde1 100644 --- a/apps/www/components/Wrapped/Pages/ProductAnnouncements.tsx +++ b/apps/www/components/Wrapped/Pages/ProductAnnouncements.tsx @@ -2,6 +2,7 @@ import Link from 'next/link' import { cn } from 'ui' import { AnimatedGridBackground } from '../AnimatedGridBackground' +import { isCrossZoneHref } from '@/lib/cross-zone' type Announcement = { title: string @@ -356,17 +357,31 @@ function MonthSection({ month }: { month: Month }) {
      {month.announcements.map((announcement) => (
    • - - - → - - {announcement.title} - + {isCrossZoneHref(announcement.url) ? ( + + + → + + {announcement.title} + + ) : ( + + + → + + {announcement.title} + + )}
    • ))}
    diff --git a/apps/www/data/features.tsx b/apps/www/data/features.tsx index 597092badce..49bbfeeb214 100644 --- a/apps/www/data/features.tsx +++ b/apps/www/data/features.tsx @@ -2168,7 +2168,7 @@ With the default ports, a second \`supabase start\` on the same machine fails wi 2. Automatic ports: The CLI assigns ports from a shared range and keeps them across restarts. Remove the fixed ports from \`config.toml\` once, or create the project with the stack commands turned on. 3. Named environments: Start \`--stack dev\` and \`--stack test\` side by side in one app. 4. Docker or native runtime: Run in containers, or as processes on your machine without Docker on Linux and on macOS on Apple silicon. Use Docker when you run several local projects on one machine. -5. Services start on demand: Postgres starts right away. Other services start on their first request and stop when idle, which keeps idle local projects light. +5. Services start on demand: Postgres starts right away. Other services start on their first request. Most of them stop when idle, which keeps idle local projects light. ## Parallel local projects are valuable for: - Developers running coding agents in git worktrees @@ -2192,7 +2192,7 @@ The \`supabase stack\` commands are experimental. Their interface can change bet title: 'Native runtime for local development', subtitle: 'Run a local Supabase project as processes on your machine, without Docker.', description: ` -The Supabase CLI can run a local Supabase project as native processes instead of containers. Coding agent sandboxes and CI runners often have no Docker daemon. There, \`supabase stack start\` downloads verified service binaries and runs Postgres, Auth, Storage, and the other services directly on the host. +The Supabase CLI can run a local Supabase project as native processes instead of containers. Coding agent sandboxes and CI runners often have no Docker daemon. To run there, [turn on the \`[experimental] stack\` setting](https://supabase.com/docs/guides/local-development/running-multiple-local-projects#turn-on-the-stack-commands). Then \`supabase stack start\` downloads verified service binaries and runs Postgres, Auth, Storage, and the other services directly on the host. It is the same local project you get from \`supabase start\`, with the same services and most of the same \`config.toml\` settings. The CLI picks Docker when its daemon responds, then Podman, then native. To require one, pass \`--runtime docker\`, \`--runtime podman\`, or \`--runtime native\`. diff --git a/apps/www/data/legal/customer-resources/supplemental-terms/v1.mdx b/apps/www/data/legal/customer-resources/supplemental-terms/v1.mdx new file mode 100644 index 00000000000..94fbcd084cb --- /dev/null +++ b/apps/www/data/legal/customer-resources/supplemental-terms/v1.mdx @@ -0,0 +1 @@ +Check here for any feature specific terms that we may need to release, or clauses that allow for compliance with geography specific laws that we may introduce. diff --git a/apps/www/data/products/functions/page.tsx b/apps/www/data/products/functions/page.tsx index b01d2e4548b..73de99861c1 100644 --- a/apps/www/data/products/functions/page.tsx +++ b/apps/www/data/products/functions/page.tsx @@ -236,12 +236,12 @@ export default (isMobile?: boolean) => ({ paragraph: ( <> Use the{' '} - Supabase CLI with GitHub actions - {' '} + {' '} to preview and deploy your functions along with the rest of your application ), @@ -264,12 +264,12 @@ export default (isMobile?: boolean) => ({ paragraph: ( <> Edge functions run globally or can be{' '} - pinned to your database's proximity - + ), }, @@ -336,12 +336,12 @@ export default (isMobile?: boolean) => ({ paragraph: ( <> Edge Functions are designed to work seamlessly with{' '} - Supabase Auth - + ), panel: , @@ -351,12 +351,12 @@ export default (isMobile?: boolean) => ({ paragraph: ( <> Edge Functions are designed to work seamlessly with{' '} - Supabase Storage - + ), panel: , diff --git a/apps/www/eslint-rules/no-cross-zone-link.cjs b/apps/www/eslint-rules/no-cross-zone-link.cjs new file mode 100644 index 00000000000..36d8e877c0a --- /dev/null +++ b/apps/www/eslint-rules/no-cross-zone-link.cjs @@ -0,0 +1,83 @@ +const { readFileSync } = require('node:fs') +const { join } = require('node:path') +const vm = require('node:vm') + +const SUPABASE_ORIGIN = 'https://supabase.com' + +// Evaluated as production: the /docs rewrite only exists there, and /docs links must be flagged in every environment. +function loadProductionRewrites() { + const rewritesModule = { exports: [] } + const source = readFileSync(join(__dirname, '../lib/rewrites.js'), 'utf8') + vm.runInNewContext(source, { + module: rewritesModule, + process: { env: { NEXT_PUBLIC_VERCEL_ENV: 'production' } }, + }) + return rewritesModule.exports +} + +const crossZonePrefixes = loadProductionRewrites() + .filter(({ destination }) => !destination.startsWith('/')) + .map(({ source }) => source.replace(/\/(:path\*)?$/, '')) + +function parseHref(href) { + try { + return new URL(href, SUPABASE_ORIGIN) + } catch { + return null + } +} + +function isCrossZonePath(href) { + const url = parseHref(href) + if (url?.origin !== SUPABASE_ORIGIN) return false + + return crossZonePrefixes.some( + (prefix) => url.pathname === prefix || url.pathname.startsWith(`${prefix}/`) + ) +} + +function staticHrefPrefix(value) { + if (!value) return null + if (value.type === 'Literal') return typeof value.value === 'string' ? value.value : null + if (value.type !== 'JSXExpressionContainer') return null + + const { expression } = value + if (expression.type === 'Literal' && typeof expression.value === 'string') return expression.value + if (expression.type === 'TemplateLiteral') return expression.quasis[0].value.cooked + return null +} + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + messages: { + crossZone: + "'{{href}}' is served by another zone. Use a plain : Next.js can't prefetch or client-navigate across zones, so 404s its prefetch.", + }, + schema: [], + }, + create(context) { + const linkNames = new Set() + + return { + ImportDeclaration(node) { + if (node.source.value !== 'next/link') return + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportDefaultSpecifier') linkNames.add(specifier.local.name) + } + }, + JSXOpeningElement(node) { + if (node.name.type !== 'JSXIdentifier' || !linkNames.has(node.name.name)) return + + const hrefAttribute = node.attributes.find( + (attribute) => attribute.type === 'JSXAttribute' && attribute.name.name === 'href' + ) + const href = staticHrefPrefix(hrefAttribute?.value) + if (!href || !isCrossZonePath(href)) return + + context.report({ node: hrefAttribute, messageId: 'crossZone', data: { href } }) + }, + } + }, +} diff --git a/apps/www/eslint-rules/no-cross-zone-link.test.ts b/apps/www/eslint-rules/no-cross-zone-link.test.ts new file mode 100644 index 00000000000..7b6e70c82cb --- /dev/null +++ b/apps/www/eslint-rules/no-cross-zone-link.test.ts @@ -0,0 +1,39 @@ +import { RuleTester } from 'eslint' +import { describe, it } from 'vitest' + +import noCrossZoneLink from './no-cross-zone-link.cjs' + +RuleTester.describe = describe +RuleTester.it = it + +const ruleTester = new RuleTester({ + languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } }, +}) + +ruleTester.run('no-cross-zone-link', noCrossZoneLink, { + valid: [ + 'import Link from \'next/link\'; ', + 'import Link from \'next/link\'; ', + "import Link from 'next/link'; ", + '', + 'import Link from \'somewhere-else\'; ', + ], + invalid: [ + { + code: 'import Link from \'next/link\'; ', + errors: [{ messageId: 'crossZone' }], + }, + { + code: 'import Link from \'next/link\'; ', + errors: [{ messageId: 'crossZone' }], + }, + { + code: "import Link from 'next/link'; ", + errors: [{ messageId: 'crossZone' }], + }, + { + code: 'import NextLink from \'next/link\'; ', + errors: [{ messageId: 'crossZone' }], + }, + ], +}) diff --git a/apps/www/eslint.config.cjs b/apps/www/eslint.config.cjs index 4f6ba19eb29..37ec0adc370 100644 --- a/apps/www/eslint.config.cjs +++ b/apps/www/eslint.config.cjs @@ -1,6 +1,8 @@ const { defineConfig } = require('eslint/config') const supabaseConfig = require('eslint-config-supabase/next') +const noCrossZoneLink = require('./eslint-rules/no-cross-zone-link.cjs') + module.exports = defineConfig([ supabaseConfig, { @@ -19,4 +21,9 @@ module.exports = defineConfig([ 'react/no-children-prop': 'warn', }, }, + { + files: ['**/*.{jsx,tsx}'], + plugins: { www: { rules: { 'no-cross-zone-link': noCrossZoneLink } } }, + rules: { 'www/no-cross-zone-link': 'error' }, + }, ]) diff --git a/apps/www/lib/cross-zone.test.ts b/apps/www/lib/cross-zone.test.ts new file mode 100644 index 00000000000..9dcd13dfa31 --- /dev/null +++ b/apps/www/lib/cross-zone.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +async function loadIsCrossZoneHref(vercelEnv: string) { + vi.stubEnv('NEXT_PUBLIC_VERCEL_ENV', vercelEnv) + vi.resetModules() + const { isCrossZoneHref } = await import('./cross-zone') + return isCrossZoneHref +} + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('isCrossZoneHref in production', () => { + let isCrossZoneHref: (href: string) => boolean + + beforeEach(async () => { + isCrossZoneHref = await loadIsCrossZoneHref('production') + }) + + it.each([ + '/docs/guides/auth#setup', + '/dashboard', + '/dashboard/project/_?sidebar=ai-assistant', + 'https://supabase.com/dashboard/sign-up', + '/humans.txt', + ])('%s is served by another zone', (href) => { + expect(isCrossZoneHref(href)).toBe(true) + }) + + it.each([ + '/pricing', + '/dashboards', + '/.well-known/ai-catalog.json', + 'https://github.com/supabase/supabase', + 'https://supabase.com.example.com/dashboard', + ])('%s stays in the www zone or is external', (href) => { + expect(isCrossZoneHref(href)).toBe(false) + }) + + it('treats an unparseable href as not cross-zone instead of throwing', () => { + expect(isCrossZoneHref('https://')).toBe(false) + }) +}) + +describe('isCrossZoneHref outside production', () => { + it('only treats /docs as another zone where the docs rewrite exists', async () => { + const isCrossZoneHref = await loadIsCrossZoneHref('preview') + + expect(isCrossZoneHref('/docs')).toBe(false) + expect(isCrossZoneHref('/dashboard')).toBe(true) + }) +}) diff --git a/apps/www/lib/cross-zone.ts b/apps/www/lib/cross-zone.ts new file mode 100644 index 00000000000..f419d2357c1 --- /dev/null +++ b/apps/www/lib/cross-zone.ts @@ -0,0 +1,30 @@ +import rewrites from './rewrites' + +const SUPABASE_ORIGIN = 'https://supabase.com' +const PATH_WILDCARD = '/:path*' + +const crossZoneSources: string[] = rewrites + .filter(({ destination }) => !destination.startsWith('/')) + .map(({ source }) => source) + +function matchesSource(pathname: string, source: string) { + if (!source.endsWith(PATH_WILDCARD)) return pathname === source + + const prefix = source.slice(0, -PATH_WILDCARD.length) + return pathname === prefix || pathname.startsWith(`${prefix}/`) +} + +function parseHref(href: string) { + try { + return new URL(href, SUPABASE_ORIGIN) + } catch { + return null + } +} + +export function isCrossZoneHref(href: string): boolean { + const url = parseHref(href) + if (url?.origin !== SUPABASE_ORIGIN) return false + + return crossZoneSources.some((source) => matchesSource(url.pathname, source)) +} diff --git a/apps/www/lib/llms.ts b/apps/www/lib/llms.ts index 83bacb43bce..9ac9de026e0 100644 --- a/apps/www/lib/llms.ts +++ b/apps/www/lib/llms.ts @@ -108,7 +108,7 @@ function buildComputeSection(): string { const DISK_TYPES = [ { name: 'General Purpose', - maxSize: '16 TB', + maxSize: '64 TB', size: '8 GB included, then $0.125 per GB', iops: '3,000 IOPS included, then $0.024 per IOPS', throughput: '125 MB/s included, then $0.095 per MB/s', diff --git a/apps/www/lib/redirects.js b/apps/www/lib/redirects.js index a7460cba810..64e1267a2ec 100644 --- a/apps/www/lib/redirects.js +++ b/apps/www/lib/redirects.js @@ -1478,16 +1478,6 @@ module.exports = [ source: '/docs/guides/realtime/overview', destination: '/docs/guides/realtime', }, - { - permanent: true, - source: '/docs/guides/realtime#broadcast', - destination: '/docs/guides/realtime/broadcast', - }, - { - permanent: true, - source: '/docs/guides/realtime#presence', - destination: '/docs/guides/realtime/presence', - }, { permanent: true, source: '/docs/guides/realtime/postgres-cdc', @@ -1752,16 +1742,6 @@ 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', @@ -1802,75 +1782,6 @@ module.exports = [ source: '/project/:path+', destination: 'https://supabase.com/dashboard/project/:path+', }, - // Reorganizing pooler docs:----------------------------- - - //external libraries - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#integrations', - destination: '/docs/guides/database/connecting-to-postgres#quickstart-connection-guides', - }, - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#connecting-with-drizzle', - destination: '/docs/guides/database/drizzle', - }, - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#connecting-with-prisma', - destination: '/docs/guides/database/prisma', - }, - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#connecting-with-postgresjs', - destination: '/docs/guides/database/postgres-js', - }, - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#connecting-with-pgadmin', - destination: '/docs/guides/database/pgadmin', - }, - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#connecting-with-psql', - destination: '/docs/guides/database/psql', - }, - - // pooling - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#connection-pooler', - destination: '/docs/guides/database/connecting-to-postgres#connection-pooling-in-depth', - }, - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#troubleshooting-supavisor', - destination: '/docs/guides/database/supavisor', - }, - - //IPv4/IPv6 - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#finding-your-database-hostname', - destination: '/docs/guides/platform/ipv4-address#finding-your-databases-ip-address', - }, - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#managing-your-ip-address', - destination: '/docs/guides/platform/ipv4-address#supabase-and-ipv6-compatibility', - }, - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#ipv6-address', - destination: '/docs/guides/platform/ipv4-address#supabase-and-ipv6-compatibility', - }, - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#ipv4-address', - destination: '/docs/guides/platform/ipv4-address', - }, - //-------------------------------------------------------- - // START docs 2.0, moving pages in to structure { permanent: true, @@ -2498,11 +2409,6 @@ module.exports = [ source: '/docs/guides/database/extensions/pgrepack', destination: '/docs/guides/database/extensions/pg_repack', }, - { - permanent: false, - source: '/docs/guides/database/extensions/pg_partman', - destination: '/docs/guides/database/extensions', - }, { permanent: true, source: '/docs/guides/ai/structured-unstructured-embeddings', @@ -3317,11 +3223,6 @@ module.exports = [ source: '/docs/guides/resources/examples', destination: '/docs/guides/getting-started', }, - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres/serverless-drivers', - destination: '/docs/guides/database/connecting-to-postgres', - }, { permanent: true, source: '/docs/guides/monitoring-troubleshooting/troubleshooting', @@ -3372,61 +3273,6 @@ module.exports = [ source: '/docs/guides/platform/org-based-billing', destination: '/docs/guides/platform/billing-on-supabase', }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#how-billing-is-organized', - destination: '/docs/guides/platform/billing-on-supabase#organization-based-billing', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#quotas-and-features', - destination: '/docs/guides/platform/billing-on-supabase#variable-usage-fees-and-quotas', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#usage-items', - destination: '/docs/guides/platform/billing-on-supabase#variable-usage-fees-and-quotas', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#billing-for-compute-compute-hours', - destination: '/docs/guides/platform/manage-your-usage/compute', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#compute-pricing', - destination: '/docs/guides/platform/manage-your-usage/compute#pricing', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#compute-credits', - destination: '/docs/guides/platform/manage-your-usage/compute#compute-credits', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#read-replicas', - destination: '/docs/guides/platform/manage-your-usage/read-replicas', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#project-add-ons', - destination: '/docs/guides/platform/manage-your-usage', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#unified-egress', - destination: '/docs/guides/platform/manage-your-usage/egress', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#included-egress-quota', - destination: '/docs/guides/platform/manage-your-usage/egress#pricing', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#egress-dashboards', - destination: '/docs/guides/platform/manage-your-usage/egress#usage-page', - }, { permanent: true, source: '/docs/guides/platform/manage-your-usage/log-ingest', @@ -3437,31 +3283,6 @@ module.exports = [ source: '/docs/guides/platform/manage-your-usage/log-query', destination: '/docs/guides/platform/manage-your-usage/logs-query', }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#disk-size', - destination: '/docs/guides/platform/manage-your-usage/disk-size', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#free-plan', - destination: '/docs/guides/platform/billing-on-supabase#free-plan', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#billing-examples', - destination: '/docs/guides/platform/billing-on-supabase', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#multiple-projects-in-a-free-plan-organization', - destination: '/docs/guides/platform/billing-on-supabase', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#multiple-projects-in-a-pro-plan-organization', - destination: '/docs/guides/platform/billing-on-supabase', - }, { permanent: false, source: '/docs/guides/platform/spend-cap', diff --git a/apps/www/pages/assistant.tsx b/apps/www/pages/assistant.tsx index 84e2d876921..024e834e94b 100644 --- a/apps/www/pages/assistant.tsx +++ b/apps/www/pages/assistant.tsx @@ -683,9 +683,9 @@ function Assistant() {
    {!isUserLoading && ( )}