mirror of
https://github.com/supabase/supabase.git
synced 2026-10-18 07:39:07 +03:00
Merge branch 'master' into joshenlim/fe-4529-table-definition-needs-to-escape-sql-identifiers
# Conflicts: # apps/studio/data/logs/unified-log-inspection-query.ts # apps/studio/data/logs/unified-logs-facet-count-query.ts
This commit is contained in:
commit
958e642374
208 files changed
+4954
-1832
No files matched your search
@@ -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 })
|
||||
}
|
||||
@@ -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}`,
|
||||
|
||||
@@ -51,7 +51,6 @@ const TopNavBar: FC = () => {
|
||||
<DevToolbarTrigger />
|
||||
{searchVariant === 'search-v2-active' ? (
|
||||
<SearchV2Trigger
|
||||
className="[&>div>p]:text-foreground-lighter"
|
||||
placeholder={
|
||||
<>
|
||||
Search
|
||||
|
||||
@@ -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 <some condition>, '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 <function name>: %', 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 <advanced_example>: %', sqlerrm;
|
||||
end;
|
||||
$$;
|
||||
|
||||
select advanced_example();
|
||||
```
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
<YouTube id="MJZCCpCYEqk" title="Creating and calling Postgres functions" />
|
||||
|
||||
## 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.
|
||||
|
||||
</details>
|
||||
@@ -141,22 +154,22 @@ Reference: [`Rpc()`](../../reference/csharp/rpc)
|
||||
</$Show>
|
||||
</Tabs>
|
||||
|
||||
## 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.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="data"
|
||||
defaultActiveId="sql"
|
||||
queryGroup="example-view"
|
||||
>
|
||||
<TabPanel id="data" label="Data">
|
||||
|
||||
### 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);
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -291,7 +304,7 @@ data = supabase.rpc('get_planets').eq('id', 1).execute()
|
||||
</$Show>
|
||||
</Tabs>
|
||||
|
||||
## 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<string, object> { { "name", "Jak
|
||||
</$Show>
|
||||
</Tabs>
|
||||
|
||||
## 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.
|
||||
|
||||
<Admonition type="danger">
|
||||
|
||||
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.
|
||||
|
||||
</Admonition>
|
||||
|
||||
### 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 <some condition>, '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 <function name>: %', 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 <advanced_example>: %', 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
|
||||
|
||||
<YouTube id="MJZCCpCYEqk" title="Creating a Postgres function in the dashboard" />
|
||||
|
||||
### Call Database Functions using JavaScript
|
||||
### Call a database function from JavaScript
|
||||
|
||||
<YouTube id="I6nnp9AINJk" title="Calling a Postgres function from JavaScript" />
|
||||
|
||||
### Using Database Functions to call an external API
|
||||
### Call an external API from a database function
|
||||
|
||||
<YouTube id="rARgrELRCwY" title="Calling an external API from a Postgres function" />
|
||||
@@ -15,13 +15,14 @@ Multigres gives your project high availability by running Postgres across multip
|
||||
|
||||
<ContentListings id="database-multigres-what-you-get" />
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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..."` <br/><br/> 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` <br/><br/> 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"` <br/><br/> 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..."` <br/><br/> 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` <br/><br/> 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"` <br/><br/> 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` |
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
<Admonition type="danger">
|
||||
|
||||
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.
|
||||
|
||||
</Admonition>
|
||||
|
||||
### 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 {
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
To accept only one specific key, use `auth: 'secret:<name>'`. 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:<name>'`.
|
||||
`auth: 'secret'` accepts only the key named `default`. To accept a different key, use `auth: 'secret:<name>'`. 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:<name>'` and `auth: 'publishable:*'`.
|
||||
|
||||

|
||||
|
||||
</Admonition>
|
||||
|
||||
## 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 {
|
||||
|
||||
</Admonition>
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Admonition type="note" label="Global command vs. project dependency">
|
||||
## 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 <command>`.
|
||||
- **Global install** with Homebrew, Scoop, or Linux packages. Run commands as `supabase <command>`.
|
||||
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.
|
||||
<Admonition type="note" title="Global command or project dependency">
|
||||
|
||||
The rest of this page writes examples as `supabase <command>`; translate them to `npx supabase <command>` 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 <command>`. This page writes every example as `supabase <command>`.
|
||||
|
||||
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.
|
||||
|
||||
</Admonition>
|
||||
|
||||
## 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]
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
@@ -33,19 +42,21 @@ The rest of this page writes examples as `supabase <command>`; translate them to
|
||||
>
|
||||
<TabPanel id="npm" label="npm">
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
@@ -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`
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
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).
|
||||
|
||||
</Admonition>
|
||||
|
||||
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_... │
|
||||
╰─────────────┴────────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="studio"
|
||||
queryGroup="access-method"
|
||||
>
|
||||
<TabPanel id="studio" label="Studio">
|
||||
|
||||
```sh
|
||||
# Default URL:
|
||||
http://localhost:54323
|
||||
```
|
||||
|
||||
The local development environment includes Supabase Studio, a graphical interface for querying and editing your database.
|
||||
|
||||

|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="postgres" label="Postgres">
|
||||
|
||||
```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'
|
||||
```
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
To access the database from an edge function in your local Supabase setup, replace `localhost` with `host.docker.internal`.
|
||||
|
||||
</Admonition>
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="kong" label="API Gateway">
|
||||
|
||||
```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)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="analytics" label="Analytics">
|
||||
|
||||
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).
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
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).
|
||||
|
||||
</Admonition>
|
||||
|
||||
All logs are stored in the local database under the `_analytics` schema.
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
### 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`.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
@@ -149,14 +334,14 @@ brew link --overwrite supabase-beta
|
||||
|
||||
#### Linux packages
|
||||
|
||||
Beta builds are attached to [GitHub pre-releases](https://github.com/supabase/cli/releases). Download the `.apk`, `.deb`, or `.rpm` for your platform and install with the same commands as [Linux packages](#linux-packages) above.
|
||||
Beta builds are attached to [GitHub pre-releases](https://github.com/supabase/cli/releases). Download the `.apk`, `.deb`, or `.rpm` for your platform and install with the same commands as [Linux packages](#linux-packages).
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
## 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.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
@@ -173,7 +358,7 @@ Update the CLI with [npm](https://www.npmjs.com/package/supabase):
|
||||
npm update supabase --save-dev
|
||||
```
|
||||
|
||||
Update to the latest beta release or switch a stable install to the beta channel with:
|
||||
Update to the current beta release, or switch a stable install to the beta channel:
|
||||
|
||||
```sh
|
||||
npm install supabase@beta --save-dev
|
||||
@@ -222,7 +407,7 @@ brew upgrade supabase-beta
|
||||
|
||||
#### Linux packages
|
||||
|
||||
1. Download the latest package from the [Supabase CLI releases page](https://github.com/supabase/cli/releases/latest)
|
||||
1. Download the package from the [Supabase CLI releases page](https://github.com/supabase/cli/releases/latest)
|
||||
2. Install the package using the same commands as the [initial installation](#linux-packages):
|
||||
- `sudo apk add --allow-untrusted <...>.apk`
|
||||
- `sudo dpkg -i <...>.deb`
|
||||
@@ -231,181 +416,31 @@ brew upgrade supabase-beta
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
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.
|
||||
|
||||
<Admonition type="note" title="Backup and stop running containers">
|
||||
<Admonition type="caution" title="Back up before you stop">
|
||||
|
||||
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.
|
||||
|
||||
</Admonition>
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
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).
|
||||
|
||||
</Admonition>
|
||||
|
||||
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_... │
|
||||
╰─────────────┴────────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="studio"
|
||||
queryGroup="access-method"
|
||||
>
|
||||
<TabPanel id="studio" label="Studio">
|
||||
|
||||
```sh
|
||||
# Default URL:
|
||||
http://localhost:54323
|
||||
```
|
||||
|
||||
The local development environment includes Supabase Studio, a graphical interface for working with your database.
|
||||
|
||||

|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="postgres" label="Postgres">
|
||||
|
||||
```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'
|
||||
```
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
To access the database from an edge function in your local Supabase setup, replace `localhost` with `host.docker.internal`.
|
||||
|
||||
</Admonition>
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="kong" label="API Gateway">
|
||||
|
||||
```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)
|
||||
```
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
`sb_publishable_...` is the publishable key output when you run the command `supabase start`.
|
||||
|
||||
</Admonition>
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="analytics" label="Analytics">
|
||||
|
||||
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.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
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).
|
||||
|
||||
</Admonition>
|
||||
|
||||
All logs are stored in the local database under the `_analytics` schema.
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -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/<id>/` 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/<id>/` 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/<id>/`. 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:
|
||||
|
||||
|
||||
@@ -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 <name> --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/<id>/`. 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 <name> --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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
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).
|
||||
|
||||
</Admonition>
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-2
@@ -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)
|
||||
+1
-1
@@ -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).
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { useDocsSearchV2 } from 'common'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type DocsSearchV2State = ReturnType<typeof useDocsSearchV2>['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 <strong>, preserving the original casing of `text`.
|
||||
* wrapped in <mark>, 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(<strong key={i}>{text.slice(start, end)}</strong>)
|
||||
nodes.push(
|
||||
<mark key={i} className="-mx-px rounded-sm bg-primary-bright/15 px-px text-primary">
|
||||
{text.slice(start, end)}
|
||||
</mark>
|
||||
)
|
||||
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 }
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
hideClose
|
||||
centered={false}
|
||||
dialogOverlayProps={{
|
||||
className:
|
||||
'data-closed:animate-out! data-closed:fade-out-0 data-closed:fill-mode-forwards data-closed:duration-150 data-closed:ease-enter pt-20 sm:pt-20',
|
||||
}}
|
||||
size="large"
|
||||
className="overflow-hidden rounded-lg border-0 p-0 inset-ring inset-ring-border shadow-(--shadow-codeblock)!"
|
||||
>
|
||||
<SearchV2Panel onResultSelect={handleSelect} onResultOpen={handleResultOpen} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
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<HTMLInputElement>(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<HTMLButtonElement>) {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{/* hideClose: this is a search box, not a form — closing is Escape/click-outside only, no "X" */}
|
||||
<DialogContent hideClose size="large" className="overflow-hidden p-0 shadow-lg">
|
||||
<Command>
|
||||
<VisuallyHidden.VisuallyHidden>
|
||||
<DialogTitle>Search docs</DialogTitle>
|
||||
<DialogDescription>Search the Supabase documentation</DialogDescription>
|
||||
</VisuallyHidden.VisuallyHidden>
|
||||
<CommandInput
|
||||
placeholder="Search docs..."
|
||||
aria-label="Search the Supabase documentation"
|
||||
onValueChange={handleValueChange}
|
||||
wrapperClassName="[&_svg]:h-5 [&_svg]:w-5 border-0"
|
||||
className="h-14 text-base"
|
||||
/>
|
||||
{/*
|
||||
<Command shouldFilter={false} className="bg-transparent">
|
||||
<VisuallyHidden.VisuallyHidden>
|
||||
<DialogTitle>Search docs</DialogTitle>
|
||||
<DialogDescription>Search the Supabase documentation</DialogDescription>
|
||||
</VisuallyHidden.VisuallyHidden>
|
||||
<div className="relative flex items-center gap-3 pr-4">
|
||||
<CommandInput
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
placeholder="Search docs..."
|
||||
aria-label="Search the Supabase documentation"
|
||||
onValueChange={handleValueChange}
|
||||
wrapperClassName="flex-1 border-0 pl-3 pr-0 text-foreground-lighter [&_svg]:size-4.5 [&_svg]:stroke-2 [&_svg]:opacity-100"
|
||||
className="h-12 pl-2.5 text-base text-foreground placeholder:text-foreground-lighter"
|
||||
/>
|
||||
{query ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
onClick={handleClear}
|
||||
onKeyDown={handleClearKeyDown}
|
||||
className="relative -mx-2 shrink-0 cursor-pointer rounded-md px-2 py-1 text-xs text-foreground-lighter transition-colors duration-150 after:absolute after:inset-x-0 after:-inset-y-2 after:content-[''] hover:bg-overlay-hover hover:text-foreground focus-ring"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
<LoadingBeam
|
||||
isActive={isSearching}
|
||||
direction={isDeleting ? 'backward' : 'forward'}
|
||||
className={isListVisible ? '-bottom-px' : undefined}
|
||||
/>
|
||||
</div>
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<div role="status" aria-live="polite" className="sr-only">
|
||||
{getStatusMessage()}
|
||||
</div>
|
||||
<CommandList label="Search results">
|
||||
{searchState.status === 'initial' && (
|
||||
<CommandEmpty>Start typing to search the docs.</CommandEmpty>
|
||||
)}
|
||||
{searchState.status === 'loading' && results.length === 0 && (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-sm text-foreground-muted">
|
||||
<Loader2 className="animate-spin" size={14} aria-hidden="true" />
|
||||
Searching...
|
||||
</div>
|
||||
)}
|
||||
{searchState.status === 'noResults' && <CommandEmpty>No results found.</CommandEmpty>}
|
||||
{searchState.status === 'error' && (
|
||||
<CommandEmpty>Something went wrong. Please try again.</CommandEmpty>
|
||||
)}
|
||||
{results.length > 0 && (
|
||||
<CommandGroup heading="Results" forceMount>
|
||||
{results.map((page) => (
|
||||
<CommandItem
|
||||
key={page.path}
|
||||
value={page.path}
|
||||
forceMount
|
||||
onSelect={() => handleSelect(page.path)}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm">
|
||||
{highlightMatches(formatHeadingPath(page.headingPath), highlightQuery)}
|
||||
</span>
|
||||
{page.excerpt && (
|
||||
<span className="text-xs text-foreground-muted">
|
||||
{highlightMatches(page.excerpt, highlightQuery)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div role="status" aria-live="polite" className="sr-only">
|
||||
{getStatusMessage()}
|
||||
</div>
|
||||
<CommandList
|
||||
label="Search results"
|
||||
className="h-(--cmdk-list-height) max-h-[min(477px,70dvh)] inset-shadow-2xs inset-shadow-border mx-px transition-all duration-150 ease-enter motion-reduce:transition-none scroll-fade-bottom"
|
||||
>
|
||||
{searchState.status === 'noResults' && <CommandEmpty>No results found.</CommandEmpty>}
|
||||
{searchState.status === 'error' && (
|
||||
<CommandEmpty>Something went wrong. Please try again.</CommandEmpty>
|
||||
)}
|
||||
{results.length > 0 && (
|
||||
<CommandGroup className="pt-1.25">
|
||||
{results.map((result) => (
|
||||
<SearchV2Result
|
||||
key={result.path}
|
||||
result={result}
|
||||
highlightQuery={highlightQuery}
|
||||
onResultSelect={handleSelect}
|
||||
onResultOpen={onResultOpen}
|
||||
/>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
{results.length > 0 ? <SearchV2Footer /> : null}
|
||||
</Command>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchV2Footer() {
|
||||
return (
|
||||
<footer
|
||||
aria-hidden
|
||||
className="flex items-center justify-end gap-3 border-t px-4 py-2.5 text-xs text-foreground-lighter mx-px max-lg:hidden"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
Navigate
|
||||
<KeyboardShortcut keys={['ArrowUp']} />
|
||||
<KeyboardShortcut keys={['ArrowDown']} />
|
||||
</span>
|
||||
<span className="h-3 w-px bg-border" />
|
||||
<span className="flex items-center gap-1.5">
|
||||
Open
|
||||
<KeyboardShortcut keys={['Enter']} />
|
||||
</span>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLAnchorElement>) {
|
||||
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 (
|
||||
<CommandItem
|
||||
asChild
|
||||
value={result.path}
|
||||
onSelect={handleSelect}
|
||||
className="cursor-pointer rounded-md px-2 py-2"
|
||||
>
|
||||
<Link href={result.path} prefetch={false} tabIndex={-1} onClick={handleLinkClick}>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<p className="text-sm leading-5 text-foreground">
|
||||
{highlightMatches(formatHeadingPath(result.headingPath), highlightQuery)}
|
||||
</p>
|
||||
{result.excerpt ? (
|
||||
<p className="line-clamp-2 text-pretty text-sm leading-5 text-foreground-lighter">
|
||||
{highlightMatches(result.excerpt, highlightQuery)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
</CommandItem>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-1.5 text-foreground-lighter">
|
||||
<Search
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
className="group-hover:text-foreground-light transition-colors"
|
||||
/>
|
||||
<p className="flex text-xs pr-2 text-foreground-muted">{placeholder}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Search aria-hidden className="size-4 shrink-0" strokeWidth={2.25} />
|
||||
<p className="flex pr-2 text-sm">{placeholder}</p>
|
||||
</div>
|
||||
<KeyboardShortcut
|
||||
keys={['Meta', 'k']}
|
||||
aria-hidden
|
||||
className="hidden md:inline-flex border border-default bg-surface-300 text-foreground-lighter shadow-xs shadow-background-surface-100"
|
||||
/>
|
||||
<KeyboardShortcut keys={['Meta', 'k']} aria-hidden className="hidden md:inline-flex" />
|
||||
</button>
|
||||
<SearchV2Dialog open={open} onOpenChange={setOpen} />
|
||||
<SearchV2Dialog open={open} onOpenChange={handleOpenChange} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
key={run}
|
||||
aria-hidden
|
||||
data-active={isActive ? '' : undefined}
|
||||
data-direction={runDirection}
|
||||
className={cn('loading-beam', className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -38,6 +38,7 @@ Artyom Borissov
|
||||
Aziz Quadri
|
||||
Barco Fourie
|
||||
Barry Roodt
|
||||
Ben Fritsch
|
||||
Beng Eu
|
||||
Benjamin Coenen
|
||||
Beth Long
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Admonition type="note" title="Versions">
|
||||
|
||||
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.
|
||||
|
||||
</Admonition>
|
||||
|
||||
### 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
|
||||
<RefSubLayout.Examples>
|
||||
|
||||
```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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
@property --loading-beam-x {
|
||||
syntax: '<number>';
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
@property --scroll-fade-size {
|
||||
syntax: '<length>';
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
+4
-8
@@ -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,
|
||||
|
||||
+17
-1
@@ -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()
|
||||
})
|
||||
})
|
||||
+8
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
{expiresAt === 'never' && (
|
||||
<Admonition
|
||||
type="warning"
|
||||
className="mb-0"
|
||||
title="This token never expires"
|
||||
description="Anyone with the token keeps access until you delete it."
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
+43
@@ -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<string, unknown> | undefined
|
||||
addAPIMock({
|
||||
method: 'post',
|
||||
path: '/platform/profile/scoped-access-tokens',
|
||||
response: async ({ request }) => {
|
||||
createBody = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json<CreateTokenResponse>({
|
||||
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' }))
|
||||
|
||||
+18
-26
@@ -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) => (
|
||||
<div className="flex items-center">
|
||||
{OPTIONS.map(({ value: optionValue, label }, index) => (
|
||||
<div key={optionValue} className="flex items-center">
|
||||
{index > 0 && <div className="border-button border border-l-0 py-3" />}
|
||||
<Button
|
||||
type="button"
|
||||
size="tiny"
|
||||
variant={value === optionValue ? 'secondary' : 'default'}
|
||||
aria-pressed={value === optionValue}
|
||||
className={cn(
|
||||
index === 0 && 'rounded-r-none border-r-0',
|
||||
index === OPTIONS.length - 1 && 'rounded-l-none border-l-0',
|
||||
index > 0 && index < OPTIONS.length - 1 && 'rounded-none border-x-0'
|
||||
)}
|
||||
onClick={() => onChange(optionValue)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</div>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
variant="segmented"
|
||||
size="tiny"
|
||||
tone="outline"
|
||||
value={value}
|
||||
onValueChange={(newValue) => {
|
||||
if (newValue) onChange(newValue as CapabilityLevelFilter)
|
||||
}}
|
||||
allowDeselect={false}
|
||||
aria-label="Filter capabilities by permission level"
|
||||
>
|
||||
{OPTIONS.map(({ value: optionValue, label }) => (
|
||||
<ToggleGroupItem key={optionValue} value={optionValue}>
|
||||
{label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</div>
|
||||
</ToggleGroup>
|
||||
)
|
||||
+2
-2
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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<ThemeOverrides>({})
|
||||
const draftRef = useRef<ThemeOverrides>({})
|
||||
const interactionRafRef = useRef<number | null>(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 = () => {
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Changes are saved separately for light and dark mode.
|
||||
</p>
|
||||
{hasThemeOverrides(overrides) && (
|
||||
{hasThemeOverrides(visibleOverrides) && (
|
||||
<Button variant="default" size="tiny" className="self-start" onClick={handleReset}>
|
||||
Reset
|
||||
</Button>
|
||||
@@ -70,13 +132,21 @@ export const ThemeColorSettings = () => {
|
||||
</div>
|
||||
|
||||
<div className="col-span-full md:col-span-8 flex flex-col gap-6 pb-2">
|
||||
{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 (
|
||||
<div key={knob.key} className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_2rem] items-start gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
'grid items-start gap-4',
|
||||
isSpotHue ? 'grid-cols-[minmax(0,1fr)_auto]' : 'grid-cols-[minmax(0,1fr)_2rem]'
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex flex-col gap-1">
|
||||
<span
|
||||
id={`theme-color-${knob.key}-label`}
|
||||
@@ -86,22 +156,39 @@ export const ThemeColorSettings = () => {
|
||||
</span>
|
||||
<span className="text-sm text-foreground-light">{knob.description}</span>
|
||||
</div>
|
||||
<span className="text-right text-sm text-foreground-light tabular-nums">
|
||||
{sliderValue}
|
||||
</span>
|
||||
{isSpotHue ? (
|
||||
<span className="flex items-center justify-end gap-2 text-sm text-foreground-light">
|
||||
<span className="flex items-center gap-1" aria-hidden>
|
||||
{PRIMARY_SWATCHES.map((swatch) => (
|
||||
<span
|
||||
key={swatch.variable}
|
||||
title={swatch.label}
|
||||
className="size-2.5 rounded-full border border-foreground/15"
|
||||
style={{ background: `var(${swatch.variable})` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="tabular-nums">{roundedHue}°</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-right text-sm text-foreground-light tabular-nums">
|
||||
{sliderValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Slider
|
||||
className="[&_[data-slot=slider-track]]:bg-input"
|
||||
className={
|
||||
trackStyle ? SPECTRUM_SLIDER_CLASS : '[&_[data-slot=slider-track]]:bg-input'
|
||||
}
|
||||
style={trackStyle}
|
||||
aria-labelledby={`theme-color-${knob.key}-label`}
|
||||
aria-valuetext={`${sliderValue} out of 100`}
|
||||
aria-valuetext={isSpotHue ? `${roundedHue} degrees` : `${sliderValue} out of 100`}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={[sliderValue]}
|
||||
onValueChange={([next]) => {
|
||||
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))
|
||||
|
||||
@@ -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)))',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: <BannerPrivacyPolicyUpdate />,
|
||||
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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<any>
|
||||
|
||||
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: () => <Plus />,
|
||||
},
|
||||
],
|
||||
{
|
||||
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,
|
||||
},
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const BRANCHING_GITHUB_DISCUSSION_LINK = 'https://github.com/orgs/supabase/discussions/18937'
|
||||
@@ -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 {
|
||||
|
||||
+5
-8
@@ -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
|
||||
+244
-178
@@ -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 (
|
||||
<SidePanel
|
||||
size="large"
|
||||
loading={isLoading || isUpdating}
|
||||
disabled={isFreePlan || isLoading || !hasChanges || isUpdating || !canUpdateSpendCap}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
onConfirm={onConfirm}
|
||||
header={
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h4>Spend cap</h4>
|
||||
<Sheet open={visible} onOpenChange={() => onClose()}>
|
||||
<SheetContent showClose={false} size="lg" className="flex flex-col gap-0">
|
||||
<SheetHeader className="flex items-center justify-between w-full">
|
||||
<SheetTitle>Spend cap</SheetTitle>
|
||||
<Button asChild icon={<ExternalLink strokeWidth={1.5} />}>
|
||||
<Link
|
||||
href={`${DOCS_URL}/guides/platform/cost-control#spend-cap`}
|
||||
@@ -115,166 +159,188 @@ const SpendCapSidePanel = () => {
|
||||
About spend cap
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
tooltip={!canUpdateSpendCap ? 'You do not have permission to update spend cap' : undefined}
|
||||
>
|
||||
<SidePanel.Content>
|
||||
<div className="py-6 space-y-4">
|
||||
<p className="text-sm">
|
||||
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
|
||||
</p>
|
||||
</SheetHeader>
|
||||
|
||||
<Collapsible open={showUsageCosts} onOpenChange={setShowUsageCosts}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center space-x-2 cursor-pointer">
|
||||
<ChevronRight
|
||||
strokeWidth={1.5}
|
||||
size={16}
|
||||
className={showUsageCosts ? 'rotate-90' : ''}
|
||||
/>
|
||||
<p className="text-sm text-foreground-light">
|
||||
How are each resource charged after exceeding the included quota?
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent asChild>
|
||||
<Table
|
||||
className="mt-4"
|
||||
head={
|
||||
<>
|
||||
<Table.th>
|
||||
<p className="text-xs">Item</p>
|
||||
</Table.th>
|
||||
<Table.th>
|
||||
<p className="text-xs">Rate</p>
|
||||
</Table.th>
|
||||
</>
|
||||
<SheetSection className="overflow-auto grow">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm">
|
||||
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
|
||||
</p>
|
||||
|
||||
<Collapsible open={showUsageCosts} onOpenChange={setShowUsageCosts}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center space-x-2 cursor-pointer">
|
||||
<ChevronRight
|
||||
strokeWidth={1.5}
|
||||
size={16}
|
||||
className={showUsageCosts ? 'rotate-90' : ''}
|
||||
/>
|
||||
<p className="text-sm text-foreground-light">
|
||||
How is each resource charged after exceeding the included quota?
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent asChild>
|
||||
<Card className="mt-4">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-9 text-xs">Item</TableHead>
|
||||
<TableHead className="h-9 text-xs">Rate</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{BILLING_METRIC_CATEGORIES.map((categoryId) => {
|
||||
const category = pricing[categoryId]
|
||||
const usageItems = category.features.filter((it) => it.usage_based)
|
||||
|
||||
return (
|
||||
<Fragment key={categoryId}>
|
||||
<TableRow>
|
||||
<TableCell className="py-2 text-xs text-foreground">
|
||||
{category.title}
|
||||
</TableCell>
|
||||
<TableCell className="py-2" />
|
||||
</TableRow>
|
||||
{usageItems.map((item) => (
|
||||
<TableRow
|
||||
key={item.title}
|
||||
className="[&>td]:text-foreground-lighter"
|
||||
>
|
||||
<TableCell className="py-2 text-xs pl-8">{item.title}</TableCell>
|
||||
<TableCell className="py-2 text-xs">
|
||||
{Array.isArray(item.plans['pro'])
|
||||
? item.plans['pro']?.join(', ')
|
||||
: item.plans['pro']}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{isFreePlan && (
|
||||
<Admonition
|
||||
type="note"
|
||||
layout="horizontal"
|
||||
title="Toggling of the spend cap is only available on the Pro Plan"
|
||||
description="Upgrade your plan to disable the spend cap"
|
||||
actions={
|
||||
<Button onClick={() => snap.setPanelKey('subscriptionPlan')}>
|
||||
View available plans
|
||||
</Button>
|
||||
}
|
||||
body={billingMetricCategories.map((categoryId) => {
|
||||
const category = pricing[categoryId]
|
||||
const usageItems = category.features.filter((it: any) => it.usage_based)
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-8! pb-4">
|
||||
<div className="flex gap-3">
|
||||
{SPEND_CAP_OPTIONS.map((option) => {
|
||||
const isSelected = selectedOption === option.value
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table.tr key={categoryId}>
|
||||
<Table.td>
|
||||
<p className="text-xs text-foreground">{category.title}</p>
|
||||
</Table.td>
|
||||
<Table.td>{null}</Table.td>
|
||||
</Table.tr>
|
||||
{usageItems.map((item: any) => {
|
||||
return (
|
||||
<Table.tr key={item.title}>
|
||||
<Table.td>
|
||||
<p className="text-xs pl-4">{item.title}</p>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<p className="text-xs pl-4">
|
||||
{Array.isArray(item.plans['pro'])
|
||||
? item.plans['pro']?.join(', ')
|
||||
: item.plans['pro']}
|
||||
</p>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
disabled={isFreePlan}
|
||||
tabIndex={isFreePlan ? -1 : 0}
|
||||
className={cn(
|
||||
'col-span-4 group space-y-1 flex flex-col items-start text-left bg-transparent border-0 p-0',
|
||||
isFreePlan && 'opacity-75 cursor-not-allowed'
|
||||
)}
|
||||
onClick={() => !isFreePlan && setSelectedOption(option.value)}
|
||||
>
|
||||
<Image
|
||||
alt="Spend Cap"
|
||||
className={cn(
|
||||
'relative rounded-xl transition border bg-no-repeat bg-center bg-cover w-[160px] h-[96px]',
|
||||
isSelected
|
||||
? 'border-foreground'
|
||||
: 'border-foreground-muted opacity-50 group-hover:border-foreground-lighter group-hover:opacity-100',
|
||||
!isFreePlan && 'cursor-pointer',
|
||||
!isFreePlan && !isSelected && 'group-hover:border-foreground-light'
|
||||
)}
|
||||
width={160}
|
||||
height={96}
|
||||
src={
|
||||
resolvedTheme?.includes('dark') ? option.imageUrl : option.imageUrlLight
|
||||
}
|
||||
/>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm transition',
|
||||
!isFreePlan && 'group-hover:text-foreground',
|
||||
isSelected ? 'text-foreground' : 'text-foreground-light'
|
||||
)}
|
||||
>
|
||||
{option.name}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{isFreePlan && (
|
||||
<Admonition
|
||||
type="note"
|
||||
layout="horizontal"
|
||||
title="Toggling of the spend cap is only available on the Pro Plan"
|
||||
description="Upgrade your plan to disable the spend cap"
|
||||
actions={
|
||||
<Button onClick={() => snap.setPanelKey('subscriptionPlan')}>
|
||||
View available plans
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-8! pb-4">
|
||||
<div className="flex gap-3">
|
||||
{SPEND_CAP_OPTIONS.map((option) => {
|
||||
const isSelected = selectedOption === option.value
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
className={cn('col-span-4 group space-y-1', isFreePlan && 'opacity-75')}
|
||||
onClick={() => !isFreePlan && setSelectedOption(option.value)}
|
||||
>
|
||||
<Image
|
||||
alt="Spend Cap"
|
||||
className={cn(
|
||||
'relative rounded-xl transition border bg-no-repeat bg-center bg-cover w-[160px] h-[96px]',
|
||||
isSelected
|
||||
? 'border-foreground'
|
||||
: 'border-foreground-muted opacity-50 group-hover:border-foreground-lighter group-hover:opacity-100',
|
||||
!isFreePlan && 'cursor-pointer',
|
||||
!isFreePlan && !isSelected && 'group-hover:border-foreground-light'
|
||||
)}
|
||||
width={160}
|
||||
height={96}
|
||||
src={resolvedTheme?.includes('dark') ? option.imageUrl : option.imageUrlLight}
|
||||
/>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm transition',
|
||||
!isFreePlan && 'group-hover:text-foreground',
|
||||
isSelected ? 'text-foreground' : 'text-foreground-light'
|
||||
)}
|
||||
>
|
||||
{option.name}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isBlockedByReplicas ? (
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="Remove read replicas before enabling the Spend Cap"
|
||||
description="Read replicas add disk usage beyond your plan's included quota, which isn't allowed once the Spend Cap is on. Remove your read replicas first, or keep the Spend Cap disabled."
|
||||
/>
|
||||
) : selectedOption === 'on' ? (
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="Your projects could become unresponsive or enter read only mode"
|
||||
description="Exceeding the included quota allowance with spend cap enabled can cause your projects to become unresponsive or enter read only mode."
|
||||
/>
|
||||
) : (
|
||||
<Admonition
|
||||
type="note"
|
||||
title="Charges apply for usage beyond included quota allowance"
|
||||
description="Your projects will always remain responsive and active, and charges only apply when exceeding the included quota limit."
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasChanges && !disabled && (
|
||||
<>
|
||||
<p className="text-sm">
|
||||
{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.'}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Toggling spend cap triggers an invoice and there might be prorated charges for any
|
||||
usage beyond the Pro Plans quota during this billing cycle.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
{selectedOption === 'on' ? (
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="Your projects could become unresponsive or enter read only mode"
|
||||
description="Exceeding the included quota allowance with spend cap enabled can cause your projects
|
||||
to become unresponsive or enter read only mode."
|
||||
/>
|
||||
) : (
|
||||
<Admonition
|
||||
type="note"
|
||||
title="Charges apply for usage beyond included quota allowance"
|
||||
description="Your projects will always remain responsive and active, and charges only apply when
|
||||
exceeding the included quota limit."
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasChanges && (
|
||||
<>
|
||||
<p className="text-sm">
|
||||
{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.'}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Toggling spend cap triggers an invoice and there might be prorated charges for any
|
||||
usage beyond the Pro Plans quota during this billing cycle.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SidePanel.Content>
|
||||
</SidePanel>
|
||||
<SheetFooter>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<ButtonTooltip
|
||||
variant="primary"
|
||||
loading={isUpdating}
|
||||
disabled={disabled}
|
||||
onClick={onConfirm}
|
||||
tooltip={{ content: { text: confirmTooltipText } }}
|
||||
>
|
||||
Confirm
|
||||
</ButtonTooltip>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export default SpendCapSidePanel
|
||||
@@ -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 (
|
||||
<FormItem asChild key={role.id}>
|
||||
|
||||
@@ -17,4 +17,5 @@ export const ROLE_DESCRIPTIONS: Record<string, ReactNode> = {
|
||||
'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',
|
||||
}
|
||||
+11
-4
@@ -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 (
|
||||
<SelectItem
|
||||
|
||||
@@ -62,11 +62,14 @@ export const DiskUsage = ({
|
||||
: []
|
||||
}, [isSuccess, projects, projectRef])
|
||||
|
||||
const hasReplicas = useMemo(
|
||||
() => 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 = ({
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{currentBillingCycleSelected &&
|
||||
subscription?.usage_billing_enabled === false &&
|
||||
!hasProjectsExceedingDiskSize &&
|
||||
hasReplicas && (
|
||||
<Alert variant="warning">
|
||||
<CriticalIcon />
|
||||
<AlertTitle>
|
||||
Read replicas add disk usage beyond your plan's included quota
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
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.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -184,7 +184,7 @@ const MessagesTable = ({
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
className="data-grid--simple-logs h-full border-t-0! border-b-0!"
|
||||
className="data-grid--simple-logs h-full! border-t-0! border-b-0!"
|
||||
rowHeight={40}
|
||||
headerRowHeight={0}
|
||||
columns={ColumnRenderer}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { RealtimeInspector } from '.'
|
||||
import type { PGPublication } from '@supabase/pg-meta'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { delay, HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import type { LogData } from './Messages.types'
|
||||
import type { RealtimeConfig } from './useRealtimeMessages'
|
||||
import { databasePublicationsKeys } from '@/data/database-publications/keys'
|
||||
import type { ProjectDetail } from '@/data/projects/project-detail-query'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
|
||||
|
||||
const realtime = vi.hoisted(() => ({ 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
|
||||
}) => (
|
||||
<button
|
||||
tabIndex={0}
|
||||
onClick={() => onChangeConfig({ ...config, channelName: 'test', enabled: true })}
|
||||
>
|
||||
Join a channel
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
vi.mock('./MessagesTable', () => ({ default: () => <div>Inspector messages</div> }))
|
||||
vi.mock('./EmptyRealtime', () => ({
|
||||
EmptyRealtime: () => <button tabIndex={0}>Set up realtime for me</button>,
|
||||
}))
|
||||
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<PGPublication[]>(publications),
|
||||
})
|
||||
}
|
||||
|
||||
function renderInspector() {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
customRender(<RealtimeInspector />, { 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<PGPublication[]>([])
|
||||
},
|
||||
})
|
||||
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<APIErrorBody>({ 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()
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BannerStackProvider>{children}</BannerStackProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
return render(<BannerPrivacyPolicyUpdate />, { 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')
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<BannerCard onDismiss={acknowledgeUpdate}>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Badge variant="default" className="w-min -ml-0.5 uppercase inline-flex items-center mb-2">
|
||||
Notice
|
||||
</Badge>
|
||||
|
||||
<div className="flex flex-col gap-y-1 mb-2">
|
||||
<p className="text-sm font-medium">We've updated our Privacy Policy</p>
|
||||
<p className="text-xs text-foreground-lighter text-balance">
|
||||
The data controller is now Supabase Pte. Ltd. Your rights and protections are unchanged.
|
||||
</p>
|
||||
</div>
|
||||
<PrivacyPolicyUpdateDialog onAcknowledge={acknowledgeUpdate} />
|
||||
</div>
|
||||
</BannerCard>
|
||||
)
|
||||
}
|
||||
|
||||
const PrivacyPolicyUpdateDialog = ({ onAcknowledge }: { onAcknowledge: () => void }) => {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-min">Learn more</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Privacy Policy update</DialogTitle>
|
||||
<DialogDescription>
|
||||
We've updated our Privacy Policy to align with our Data Processing Addendum.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogSectionSeparator />
|
||||
|
||||
<DialogSection className="text-sm flex flex-col gap-y-2">
|
||||
<p>
|
||||
The data controller is now listed as Supabase Pte. Ltd. (previously Supabase, Inc.).
|
||||
</p>
|
||||
<p>
|
||||
This is an organizational change for clarity. Your rights, our obligations, and your
|
||||
data protections remain unchanged.
|
||||
</p>
|
||||
<p>
|
||||
Read the updated{' '}
|
||||
<InlineLink href="https://supabase.com/privacy">Privacy Policy</InlineLink> or{' '}
|
||||
<InlineLink href="mailto:privacy@supabase.com">contact us</InlineLink> with any
|
||||
questions.
|
||||
</p>
|
||||
</DialogSection>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button className="opacity-100" onClick={onAcknowledge}>
|
||||
Understood
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<LogDrainCreateData, ResponseError, getUnifiedLogsVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const useOtel = useFlag('otelUnifiedLogs')
|
||||
const useOtel = useUnifiedLogsBackend()
|
||||
return useMutation<LogDrainCreateData, ResponseError, getUnifiedLogsVariables>({
|
||||
mutationFn: (vars) => retrieveUnifiedLogs({ ...vars, useOtel: vars.useOtel ?? useOtel }),
|
||||
async onSuccess(data, variables, context) {
|
||||
|
||||
@@ -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 = <TData = UnifiedLogInspectionData>(
|
||||
...options
|
||||
}: UseCustomQueryOptions<UnifiedLogInspectionData, UnifiedLogInspectionError, TData> = {}
|
||||
) => {
|
||||
const useOtel = useFlag('otelUnifiedLogs')
|
||||
const useOtel = useUnifiedLogsBackend()
|
||||
return useQuery<UnifiedLogInspectionData, UnifiedLogInspectionError, TData>({
|
||||
queryKey: [...logsKeys.serviceFlow(projectRef, search, logId), { otel: useOtel }],
|
||||
queryFn: ({ signal }) =>
|
||||
|
||||
@@ -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 = <TData = UnifiedLogsChartData>(
|
||||
...options
|
||||
}: UseCustomQueryOptions<UnifiedLogsChartData, UnifiedLogsChartError, TData> = {}
|
||||
) => {
|
||||
const useOtel = useFlag('otelUnifiedLogs')
|
||||
const useOtel = useUnifiedLogsBackend()
|
||||
return useQuery<UnifiedLogsChartData, UnifiedLogsChartError, TData>({
|
||||
queryKey: [...logsKeys.unifiedLogsChart(projectRef, search), { otel: useOtel }],
|
||||
queryFn: ({ signal }) => getUnifiedLogsChart({ projectRef, search, useOtel }, signal),
|
||||
|
||||
@@ -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 = <TData = UnifiedLogsCountData>(
|
||||
...options
|
||||
}: UseCustomQueryOptions<UnifiedLogsCountData, UnifiedLogsCountError, TData> = {}
|
||||
) => {
|
||||
const useOtel = useFlag('otelUnifiedLogs')
|
||||
const useOtel = useUnifiedLogsBackend()
|
||||
return useQuery<UnifiedLogsCountData, UnifiedLogsCountError, TData>({
|
||||
queryKey: [...logsKeys.unifiedLogsCount(projectRef, search), { otel: useOtel }],
|
||||
queryFn: ({ signal }) => getUnifiedLogsCount({ projectRef, search, useOtel }, signal),
|
||||
|
||||
@@ -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 = <TData = UnifiedLogsFacetCountData>
|
||||
...options
|
||||
}: UseCustomQueryOptions<UnifiedLogsFacetCountData, UnifiedLogsFacetCountError, TData> = {}
|
||||
) => {
|
||||
const useOtel = useFlag('otelUnifiedLogs')
|
||||
const useOtel = useUnifiedLogsBackend()
|
||||
return useQuery<UnifiedLogsFacetCountData, UnifiedLogsFacetCountError, TData>({
|
||||
queryKey: [
|
||||
...logsKeys.unifiedLogsFacetCount(projectRef, facet, facetSearch, search),
|
||||
|
||||
@@ -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 = <TData = UnifiedLogsData>(
|
||||
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) => {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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<string, OAuthAppsAuthorizeRequest> = {
|
||||
[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<string, OAuthAppsAuthorizeIdentity> = {
|
||||
[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<string, OAuthAppsAuthorizeOrganizationProject[]> = {
|
||||
'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<string, Record<string, OAuthOrgAppDetails>> = {
|
||||
'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<string, OAuthAppsAuthorizeRequest> = {
|
||||
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<string, OAuthOrganizationRole> = {
|
||||
'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<string, ListOrgAppGrantsResponse> = {
|
||||
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<string, string>) {
|
||||
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<string>([
|
||||
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,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<ListOAuthApprovalsResponse> {
|
||||
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<ReturnType<typeof getOAuthApprovals>>
|
||||
export type OAuthApprovalsError = ResponseError
|
||||
|
||||
export const useOAuthApprovalsQuery = <TData = OAuthApprovalsData>(
|
||||
{ slug, cursor }: OAuthApprovalsVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseCustomQueryOptions<OAuthApprovalsData, OAuthApprovalsError, TData> = {}
|
||||
) =>
|
||||
useQuery<OAuthApprovalsData, OAuthApprovalsError, TData>({
|
||||
queryKey: oauthAppsKeys.approvals(slug, cursor),
|
||||
queryFn: () => getOAuthApprovals({ slug, cursor }),
|
||||
enabled: enabled && USE_MOCKS && Boolean(slug),
|
||||
...options,
|
||||
})
|
||||
@@ -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<OAuthAppsAuthorizeApproveResult> {
|
||||
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<ReturnType<typeof approveOAuthAppsAuthorize>>
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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<ReturnType<typeof denyOAuthAppsAuthorize>>
|
||||
|
||||
export const useOAuthAppsAuthorizeDenyMutation = ({
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseCustomMutationOptions<
|
||||
OAuthAppsAuthorizeDenyData,
|
||||
ResponseError,
|
||||
OAuthAppsAuthorizeDenyVariables
|
||||
>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
return useMutation<OAuthAppsAuthorizeDenyData, ResponseError, OAuthAppsAuthorizeDenyVariables>({
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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<typeof approveOAuthAppsAuthorize>[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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<typeof getOAuthAppsAuthorizeOrganizationProjects>
|
||||
>
|
||||
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,
|
||||
})
|
||||
@@ -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<typeof getOAuthAppsAuthorizeIdentity>
|
||||
>
|
||||
export type OAuthAppsAuthorizeOrganizationsError = ResponseError
|
||||
|
||||
export const useOAuthAppsAuthorizeOrganizationsQuery = <
|
||||
TData = OAuthAppsAuthorizeOrganizationsData,
|
||||
>(
|
||||
{ id }: OAuthAppsAuthorizeOrganizationsVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseCustomQueryOptions<
|
||||
OAuthAppsAuthorizeOrganizationsData,
|
||||
OAuthAppsAuthorizeOrganizationsError,
|
||||
TData
|
||||
> = {}
|
||||
) =>
|
||||
useQuery<OAuthAppsAuthorizeOrganizationsData, OAuthAppsAuthorizeOrganizationsError, TData>({
|
||||
queryKey: oauthAppsKeys.authorizeOrganizations(id),
|
||||
queryFn: () => getOAuthAppsAuthorizeIdentity({ id }),
|
||||
enabled: enabled && USE_MOCKS && Boolean(id),
|
||||
...options,
|
||||
})
|
||||
@@ -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<OAuthAppsAuthorizeRequest> {
|
||||
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<ReturnType<typeof getOAuthAppsAuthorizeRequest>>
|
||||
export type OAuthAppsAuthorizeRequestError = ResponseError
|
||||
|
||||
export const useOAuthAppsAuthorizeRequestQuery = <TData = OAuthAppsAuthorizeRequestData>(
|
||||
{ id }: OAuthAppsAuthorizeRequestVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseCustomQueryOptions<
|
||||
OAuthAppsAuthorizeRequestData,
|
||||
OAuthAppsAuthorizeRequestError,
|
||||
TData
|
||||
> = {}
|
||||
) =>
|
||||
useQuery<OAuthAppsAuthorizeRequestData, OAuthAppsAuthorizeRequestError, TData>({
|
||||
queryKey: oauthAppsKeys.authorizeRequest(id),
|
||||
queryFn: () => getOAuthAppsAuthorizeRequest({ id }),
|
||||
enabled: enabled && USE_MOCKS && Boolean(id),
|
||||
...options,
|
||||
})
|
||||
@@ -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<ListOwnGrantsResponse> {
|
||||
if (!USE_MOCKS) throw new Error('OAuth grants are not yet implemented')
|
||||
|
||||
return getMockOAuthOwnGrants(cursor)
|
||||
}
|
||||
|
||||
export type OAuthGrantsData = Awaited<ReturnType<typeof getOAuthGrants>>
|
||||
export type OAuthGrantsError = ResponseError
|
||||
|
||||
export const useOAuthGrantsQuery = <TData = OAuthGrantsData>(
|
||||
{ cursor }: OAuthGrantsVariables = {},
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseCustomQueryOptions<OAuthGrantsData, OAuthGrantsError, TData> = {}
|
||||
) =>
|
||||
useQuery<OAuthGrantsData, OAuthGrantsError, TData>({
|
||||
queryKey: oauthAppsKeys.grants(cursor),
|
||||
queryFn: () => getOAuthGrants({ cursor }),
|
||||
enabled: enabled && USE_MOCKS,
|
||||
...options,
|
||||
})
|
||||
@@ -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<ListOrgAppGrantsResponse> {
|
||||
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<ReturnType<typeof getOAuthAppMemberGrants>>
|
||||
export type OAuthAppMemberGrantsError = ResponseError
|
||||
|
||||
export const useOAuthAppMemberGrantsQuery = <TData = OAuthAppMemberGrantsData>(
|
||||
{ slug, appId, cursor }: OAuthAppMemberGrantsVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseCustomQueryOptions<OAuthAppMemberGrantsData, OAuthAppMemberGrantsError, TData> = {}
|
||||
) =>
|
||||
useQuery<OAuthAppMemberGrantsData, OAuthAppMemberGrantsError, TData>({
|
||||
queryKey: oauthAppsKeys.appMemberGrants(slug, appId, cursor),
|
||||
queryFn: () => getOAuthAppMemberGrants({ slug, appId, cursor }),
|
||||
enabled: enabled && USE_MOCKS && Boolean(slug) && Boolean(appId),
|
||||
...options,
|
||||
})
|
||||
@@ -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<OAuthOrgAppDetails> {
|
||||
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<ReturnType<typeof getOAuthOrgAppDetails>>
|
||||
export type OAuthOrgAppDetailsError = ResponseError
|
||||
|
||||
export const useOAuthOrgAppDetailsQuery = <TData = OAuthOrgAppDetailsData>(
|
||||
{ slug, appId }: OAuthOrgAppDetailsVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseCustomQueryOptions<OAuthOrgAppDetailsData, OAuthOrgAppDetailsError, TData> = {}
|
||||
) =>
|
||||
useQuery<OAuthOrgAppDetailsData, OAuthOrgAppDetailsError, TData>({
|
||||
queryKey: oauthAppsKeys.orgAppDetails(slug, appId),
|
||||
queryFn: () => getOAuthOrgAppDetails({ slug, appId }),
|
||||
enabled: enabled && USE_MOCKS && Boolean(slug) && Boolean(appId),
|
||||
...options,
|
||||
})
|
||||
@@ -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<OAuthAppsAuthorizePreflightResult> {
|
||||
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<typeof getOAuthAppsPreflightValidation>
|
||||
>
|
||||
export type OAuthAppsPreflightValidationError = ResponseError
|
||||
|
||||
export const useOAuthAppsPreflightValidationQuery = <TData = OAuthAppsPreflightValidationData>(
|
||||
{ slug, appId }: OAuthAppsPreflightValidationVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseCustomQueryOptions<
|
||||
OAuthAppsPreflightValidationData,
|
||||
OAuthAppsPreflightValidationError,
|
||||
TData
|
||||
> = {}
|
||||
) =>
|
||||
useQuery<OAuthAppsPreflightValidationData, OAuthAppsPreflightValidationError, TData>({
|
||||
queryKey: oauthAppsKeys.preflightValidation(slug, appId),
|
||||
queryFn: () => getOAuthAppsPreflightValidation({ slug, appId }),
|
||||
enabled: enabled && USE_MOCKS && Boolean(slug) && Boolean(appId),
|
||||
...options,
|
||||
})
|
||||
@@ -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<ReturnType<typeof revokeOAuthGrant>>
|
||||
|
||||
export const useOAuthGrantRevokeMutation = ({
|
||||
onError,
|
||||
onSuccess,
|
||||
...options
|
||||
}: Omit<
|
||||
UseCustomMutationOptions<OAuthGrantRevokeData, ResponseError, OAuthGrantRevokeVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<OAuthGrantRevokeData, ResponseError, OAuthGrantRevokeVariables>({
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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<ReturnType<typeof revokeOAuthApp>>
|
||||
|
||||
export const useOAuthAppRevokeMutation = ({
|
||||
onError,
|
||||
onSuccess,
|
||||
...options
|
||||
}: Omit<
|
||||
UseCustomMutationOptions<OAuthAppRevokeData, ResponseError, OAuthAppRevokeVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<OAuthAppRevokeData, ResponseError, OAuthAppRevokeVariables>({
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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<LiveAuthorizeRequest['scopes']>[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 }
|
||||
}
|
||||
@@ -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 = <TData = OrganizationRolesData>(
|
||||
.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<string, string> = {
|
||||
None: 'No-access',
|
||||
}
|
||||
|
||||
export function getOverrideRoleName(rolename: string) {
|
||||
return OVERRIDE_ROLE_NAMES[rolename] ?? rolename
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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<ThemeOverrideKey, number>
|
||||
> = {
|
||||
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<Record<ThemeOverrideKey, number>>
|
||||
@@ -57,6 +67,7 @@ export type ThemeOverridesByMode = Partial<Record<ThemeOverrideMode, ThemeOverri
|
||||
|
||||
const themeOverridesSchema = z
|
||||
.object({
|
||||
primaryHue: z.number().finite().optional(),
|
||||
chroma: z.number().finite().optional(),
|
||||
contrast: z.number().finite().optional(),
|
||||
surface: z.number().finite().optional(),
|
||||
@@ -156,6 +167,16 @@ export function hasThemeOverrides(overrides: ThemeOverrides): boolean {
|
||||
return THEME_OVERRIDE_KNOBS.some((knob) => 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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={<MessageCircle className="text-muted" strokeWidth={1} />}
|
||||
>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://github.com/orgs/supabase/discussions/18937"
|
||||
>
|
||||
<a target="_blank" rel="noreferrer" href={BRANCHING_GITHUB_DISCUSSION_LINK}>
|
||||
Branching feedback
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
@@ -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 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
icon={<MoreVertical />}
|
||||
className="px-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Manage merge request"
|
||||
variant="text"
|
||||
className="px-1"
|
||||
icon={<MoreVertical />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Manage merge request
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent className="w-56" side="bottom" align="end">
|
||||
<Tooltip>
|
||||
<DropdownMenuItem
|
||||
@@ -369,11 +380,7 @@ export const MergeRequestsPageWrapper = ({ children }: PropsWithChildren<{}>) =>
|
||||
variant="text"
|
||||
icon={<MessageCircle className="text-muted" strokeWidth={1} />}
|
||||
>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://github.com/orgs/supabase/discussions/18937"
|
||||
>
|
||||
<a target="_blank" rel="noreferrer" href={BRANCHING_GITHUB_DISCUSSION_LINK}>
|
||||
Branching feedback
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
@@ -341,7 +341,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
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
|
||||
|
||||
+59
-8
@@ -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<typeof UI.Slider>
|
||||
|
||||
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<typeof import('common')>()
|
||||
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<typeof import('ui')>()
|
||||
const actual = await importOriginal<typeof UI>()
|
||||
|
||||
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(<ThemeColorSettings />)
|
||||
|
||||
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(<ThemeColorSettings />)
|
||||
|
||||
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(<ThemeColorSettings />)
|
||||
|
||||
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(<ThemeColorSettings />)
|
||||
|
||||
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(<ThemeColorSettings />)
|
||||
fireEvent.click(screen.getByRole('slider', { name: 'Spot color' }))
|
||||
|
||||
mockUseFlag.mockReturnValue(false)
|
||||
rerender(<ThemeColorSettings />)
|
||||
|
||||
await new Promise<void>((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(<ThemeColorSettings />)
|
||||
|
||||
@@ -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(<ThemeColorSettings />)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
+51
@@ -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})`)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -194,7 +194,7 @@ const SupabaseStatusWidget: React.FC = () => {
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-[hsl(var(--foreground))] mb-1">
|
||||
<h2 className="text-3xl font-bold text-foreground mb-1">
|
||||
Supabase Status
|
||||
</h2>
|
||||
<p className="text-sm text-foreground-muted">
|
||||
|
||||
@@ -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']
|
||||
---
|
||||
|
||||
<Quote img="ian-masters-flick.jpg" caption="Ian Masters, Founder and CEO, Flick Games">
|
||||
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.
|
||||
</Quote>
|
||||
|
||||
[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.
|
||||
|
||||
<Quote img="alan-thomson-flick.jpg" caption="Alan Thomson, Senior Engineer, Flick Games">
|
||||
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.
|
||||
</Quote>
|
||||
|
||||
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.
|
||||
|
||||
<Quote img="alan-thomson-flick.jpg" caption="Alan Thomson, Senior Engineer, Flick Games">
|
||||
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.
|
||||
</Quote>
|
||||
|
||||
## 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.
|
||||
|
||||
<Quote img="alan-thomson-flick.jpg" caption="Alan Thomson, Senior Engineer, Flick Games">
|
||||
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.
|
||||
</Quote>
|
||||
|
||||
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.
|
||||
|
||||
<Quote img="alan-thomson-flick.jpg" caption="Alan Thomson, Senior Engineer, Flick Games">
|
||||
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.
|
||||
</Quote>
|
||||
|
||||
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.
|
||||
|
||||
<Quote img="alan-thomson-flick.jpg" caption="Alan Thomson, Senior Engineer, Flick Games">
|
||||
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.
|
||||
</Quote>
|
||||
|
||||
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.
|
||||
|
||||
<Quote img="alan-thomson-flick.jpg" caption="Alan Thomson, Senior Engineer, Flick Games">
|
||||
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.
|
||||
</Quote>
|
||||
|
||||
## 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.
|
||||
|
||||
<Quote img="alan-thomson-flick.jpg" caption="Alan Thomson, Senior Engineer, Flick Games">
|
||||
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.
|
||||
</Quote>
|
||||
|
||||
For a team this size, cost matters every bit as much as capability.
|
||||
|
||||
<Quote img="ian-masters-flick.jpg" caption="Ian Masters, Founder and CEO, Flick Games">
|
||||
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.
|
||||
</Quote>
|
||||
|
||||
## 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.
|
||||
@@ -71,13 +71,13 @@ const page: GoPageInput = {
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<Button asChild variant="primary" size="medium">
|
||||
<Link
|
||||
<a
|
||||
href="https://supabase.com/docs/guides/getting-started/ai-skills"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn about Supabase AI Tools
|
||||
</Link>
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild size="medium">
|
||||
<Link
|
||||
@@ -104,7 +104,7 @@ const page: GoPageInput = {
|
||||
<li>Complete these steps by Monday, May 4, 2026 at 12:00 PM PST</li>
|
||||
</ol>
|
||||
<Button asChild size="medium">
|
||||
<Link href="https://supabase.com/dashboard">Create your account</Link>
|
||||
<a href="https://supabase.com/dashboard">Create your account</a>
|
||||
</Button>
|
||||
<p className="text-xs text-foreground-lighter mt-4">
|
||||
No purchase necessary. Void where prohibited.{' '}
|
||||
|
||||
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user