Merge branch 'master' into feat/inline-editor-v2

This commit is contained in:
Saxon Fletcher committed 2025-02-12 08:26:39 +10:00
commit f4e467a43b
280 files changed
+8676 -4075

No files matched your search

-1
View File
@@ -36,7 +36,6 @@ jobs:
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 9.10.0
run_install: false
- name: Use Node.js
uses: actions/setup-node@v4
-1
View File
@@ -26,7 +26,6 @@ jobs:
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 9.10.0
run_install: false
- uses: actions/setup-node@v4
-1
View File
@@ -29,7 +29,6 @@ jobs:
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 9.10.0
run_install: false
- name: Setup node
@@ -20,7 +20,6 @@ jobs:
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 9.10.0
run_install: false
- name: Use Node.js
-1
View File
@@ -25,7 +25,6 @@ jobs:
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 9.10.0
run_install: false
- name: Use Node.js
-1
View File
@@ -26,7 +26,6 @@ jobs:
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 9.10.0
run_install: false
- name: Use Node.js
-1
View File
@@ -28,7 +28,6 @@ jobs:
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 9.10.0
run_install: false
- name: Use Node.js
uses: actions/setup-node@v4
-3
View File
@@ -21,7 +21,6 @@ jobs:
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 9.10.0
run_install: false
- name: Setup node
uses: actions/setup-node@v4
@@ -47,7 +46,6 @@ jobs:
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 9.10.0
run_install: false
- name: Setup node
uses: actions/setup-node@v4
@@ -73,7 +71,6 @@ jobs:
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 9.10.0
run_install: false
- name: Setup node
uses: actions/setup-node@v4
-1
View File
@@ -41,7 +41,6 @@ jobs:
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 9.10.0
run_install: false
- name: Setup node
-1
View File
@@ -34,7 +34,6 @@ jobs:
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 9.10.0
run_install: false
- name: Use Node.js
uses: actions/setup-node@v4
-1
View File
@@ -22,7 +22,6 @@ jobs:
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 9.10.0
run_install: false
- name: Use Node.js
-1
View File
@@ -24,7 +24,6 @@ jobs:
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 9.10.0
run_install: false
- name: Use Node.js
-1
View File
@@ -24,7 +24,6 @@ jobs:
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 9.10.0
run_install: false
- name: Use Node.js
+2 -1
View File
@@ -1 +1,2 @@
engine-strict=true
engine-strict=true
update-notifier=false
+11
View File
@@ -1919,6 +1919,17 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"multi-select-combobox-creatable": {
name: "multi-select-combobox-creatable",
type: "components:example",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/example/multi-select-combobox-creatable")),
source: "",
files: ["registry/default/example/multi-select-combobox-creatable.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"multi-select-deletable-badge": {
name: "multi-select-deletable-badge",
type: "components:example",
+5
View File
@@ -349,6 +349,11 @@ export const docsConfig: DocsConfig = {
href: '/docs/components/slider',
items: [],
},
{
title: 'Sidebar',
href: '/docs/components/sidebar',
items: [],
},
{
title: 'Sonner',
href: '/docs/components/sonner',
File diff suppressed because it is too large. Load diff
@@ -47,6 +47,13 @@ Use `MultiSelectorInput`to add a search input.
<ComponentPreview name="multi-select-combobox" />
### Badge Combo Box with creatable selector
creatable: `boolean`
`creatable` prop on the `MultiSelectorList` component can be used to add a new option if no items match
<ComponentPreview name="multi-select-combobox-creatable" />
### Badge Limit
badgeLimit: `number` | `"wrap"`.
@@ -0,0 +1,34 @@
import { useState } from 'react'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorInput,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
export default function MultiSelectDemo() {
const [selectedValues, setSelectedValues] = useState<string[]>([])
return (
<MultiSelector values={selectedValues} onValuesChange={setSelectedValues}>
<MultiSelectorTrigger className="w-72" label="Select fruits" badgeLimit="wrap" />
<MultiSelectorContent>
<MultiSelectorInput placeholder="Search fruits" showResetIcon />
<MultiSelectorList creatable>
<MultiSelectorItem value="Mango">Mango</MultiSelectorItem>
<MultiSelectorItem value="Date">Date</MultiSelectorItem>
<MultiSelectorItem value="Apple">Apple</MultiSelectorItem>
<MultiSelectorItem value="Elderberrie">Elderberrie</MultiSelectorItem>
<MultiSelectorItem value="Fig">Fig</MultiSelectorItem>
<MultiSelectorItem value="Grape">Grape</MultiSelectorItem>
<MultiSelectorItem value="Banana">Banana</MultiSelectorItem>
<MultiSelectorItem value="Kiwi">Kiwi</MultiSelectorItem>
<MultiSelectorItem value="Strawberry">Strawberry</MultiSelectorItem>
<MultiSelectorItem value="Cherry">Cherry</MultiSelectorItem>
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
)
}
+5
View File
@@ -1091,6 +1091,11 @@ export const examples: Registry = [
type: 'components:example',
files: ['example/multi-select-combobox.tsx'],
},
{
name: 'multi-select-combobox-creatable',
type: 'components:example',
files: ['example/multi-select-combobox-creatable.tsx'],
},
{
name: 'multi-select-deletable-badge',
type: 'components:example',
+2 -1
View File
@@ -8,7 +8,8 @@ module.exports = config({
// purge styles from grid library
//
'./../../packages/ui/src/**/*.{tsx,ts,js}',
'./../../packages/ui-patterns/!(node_modules)/**/*.{tsx,ts,js}',
'./../../packages/ui-patterns/**/*.{tsx,ts,js}',
'!./../../packages/ui-patterns/node_modules/**/*', // ignore node_modules rule
],
theme: {
extend: {
@@ -1747,6 +1747,10 @@ export const ai = {
url: '/guides/ai/vector-indexes',
items: vectorIndexItems,
},
{
name: 'Automatic embeddings',
url: '/guides/ai/automatic-embeddings',
},
{
name: 'Engineering for scale',
url: '/guides/ai/engineering-for-scale',
@@ -1920,6 +1924,22 @@ export const local_development: NavMenuConstant = {
},
],
},
{
name: 'Testing',
url: undefined,
items: [
{ name: 'Getting started', url: '/guides/local-development/testing/overview' },
{
name: 'pgTAP advanced guide',
url: '/guides/local-development/testing/pgtap-extended',
},
{ name: 'Database testing', url: '/guides/database/testing' },
{
name: 'RLS policies testing',
url: '/guides/database/extensions/pgtap#testing-rls-policies',
},
],
},
],
}
@@ -0,0 +1,817 @@
---
id: 'ai-automatic-embeddings'
title: 'Automatic embeddings'
description: 'Automate embedding generation and updates in Postgres'
sidebar_label: 'Automatic embeddings'
---
Vector embeddings enable powerful [semantic search](/docs/guides/ai/semantic-search) capabilities in Postgres, but managing them alongside your content has traditionally been complex. This guide demonstrates how to automate embedding generation and updates using Supabase [Edge Functions](/docs/guides/functions), [pgmq](/docs/guides/database/extensions/pgmq), [pg_net](/docs/guides/database/extensions/pg_net), and [pg_cron](/docs/guides/cron).
## Understanding the challenge
When implementing semantic search with pgvector, developers typically need to:
1. Generate embeddings via an external API (like OpenAI)
2. Store these embeddings alongside the content
3. Keep embeddings in sync when content changes
4. Handle failures and retries in the embedding generation process
While Postgres [full-text search](/docs/guides/database/full-text-search) can handle this internally through synchronous calls to `to_tsvector` and [triggers](https://www.postgresql.org/docs/current/textsearch-features.html#TEXTSEARCH-UPDATE-TRIGGERS), semantic search requires asynchronous API calls to a provider like OpenAI to generate vector embeddings. This guide demonstrates how to use triggers, queues, and Supabase Edge Functions to bridge this gap.
## Understanding the architecture
We'll leverage the following Postgres and Supabase features to create the automated embedding system:
1. [pgvector](/docs/guides/database/extensions/pgvector): Stores and queries vector embeddings
1. [pgmq](/docs/guides/queues): Queues embedding generation requests for processing and retries
1. [pg_net](/docs/guides/database/extensions/pg_net): Handles asynchronous HTTP requests to Edge Functions directly from Postgres
1. [pg_cron](/docs/guides/cron): Automatically processes and retries embedding generations
1. [Triggers](/docs/guides/database/postgres/triggers): Detects content changes and enqueues embedding generation requests
1. [Edge Functions](/docs/guides/functions): Generates embeddings via an API like OpenAI (customizable)
We'll design the system to:
1. Be generic, so that it can be used with any table and content. This allows you to configure embeddings in multiple places, each with the ability to customize the input used for embedding generation. These will all use the same queue infrastructure and Edge Function to generate the embeddings.
1. Handle failures gracefully, by retrying failed jobs and providing detailed information about the status of each job.
## Implementation
We'll start by setting up the infrastructure needed to queue and process embedding generation requests. Then we'll create an example table with triggers to enqueue these embedding requests whenever content is inserted or updated.
### Step 1: Enable extensions
First, let's enable the required extensions:
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="sql"
queryGroup="database-method"
>
<TabPanel id="sql" label="SQL">
```sql
-- For vector operations
create extension if not exists vector
with
schema extensions;
-- For queueing and processing jobs
-- (pgmq will create its own schema)
create extension if not exists pgmq;
-- For async HTTP requests
create extension if not exists pg_net
with
schema extensions;
-- For scheduled processing and retries
-- (pg_cron will create its own schema)
create extension if not exists pg_cron;
-- For clearing embeddings during updates
create extension if not exists hstore
with
schema extensions;
```
Even though the SQL code is `create extension`, this is the equivalent of "enabling the extension".
To disable an extension, call `drop extension`.
</TabPanel>
<TabPanel id="dashboard" label="Dashboard">
1. Go to the [Extensions](/dashboard/project/_/database/extensions) page in the Dashboard.
2. Search for and enable the following extensions:
- `vector`
- `pgmq`
- `pg_net`
- `pg_cron`
- `hstore`
</TabPanel>
</Tabs>
### Step 2: Create utility functions
Before we set up our embedding logic, we need to create some utility functions:
```sql
-- Schema for utility functions
create schema util;
-- Utility function to get the Supabase project URL (required for Edge Functions)
create function util.project_url()
returns text
language plpgsql
security definer
as $$
declare
secret_value text;
begin
-- Retrieve the project URL from Vault
select decrypted_secret into secret_value from vault.decrypted_secrets where name = 'project_url';
return secret_value;
end;
$$;
-- Generic function to invoke any Edge Function
create or replace function util.invoke_edge_function(
name text,
body jsonb,
timeout_milliseconds int = 5 * 60 * 1000 -- default 5 minute timeout
)
returns void
language plpgsql
as $$
declare
headers_raw text;
auth_header text;
begin
-- If we're in a PostgREST session, reuse the request headers for authorization
headers_raw := current_setting('request.headers', true);
-- Only try to parse if headers are present
auth_header := case
when headers_raw is not null then
(headers_raw::json->>'authorization')
else
null
end;
-- Perform async HTTP request to the edge function
perform net.http_post(
url => util.project_url() || '/functions/v1/' || name,
headers => jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', auth_header
),
body => body,
timeout_milliseconds => timeout_milliseconds
);
end;
$$;
-- Generic trigger function to clear a column on update
create or replace function util.clear_column()
returns trigger
language plpgsql as $$
declare
clear_column text := TG_ARGV[0];
begin
NEW := NEW #= hstore(clear_column, NULL);
return NEW;
end;
$$;
```
Here we create:
- A schema `util` to store utility functions.
- A function to retrieve the Supabase project URL from [Vault](/docs/guides/database/vault). We'll add this secret next.
- A generic function to invoke any Edge Function with a given name and request body.
- A generic trigger function to clear a column on update. This function accepts the column name as an argument and sets it to `NULL` in the `NEW` record. We'll explain how to use this function later.
Every project has a unique API URL that is required to invoke Edge Functions. Let's go ahead and add the project URL secret to Vault depending on your environment.
When working with a local Supabase stack, add the following to your `supabase/seed.sql` file:
```sql
select
vault.create_secret('http://api.supabase.internal:8000', 'project_url');
```
When deploying to the cloud platform, open the [SQL editor](/dashboard/project/_/sql/new) and run the following, replacing `<project-url>` with your [project's API URL](/dashboard/project/_/settings/api):
```sql
select
vault.create_secret('<project-url>', 'project_url');
```
### Step 3: Create queue and triggers
Our goal is to automatically generate embeddings whenever content is inserted or updated within a table. We can use triggers and queues to achieve this. Our approach is to automatically queue embedding jobs whenever records are inserted or updated in a table, then process them asynchronously using a cron job. If a job fails, it will remain in the queue and be retried in the next scheduled task.
First we create a `pgmq` queue for processing embedding requests:
```sql
-- Queue for processing embedding jobs
select pgmq.create('embedding_jobs');
```
Next we create a trigger function to queue embedding jobs. We'll use this function to handle both insert and update events:
```sql
-- Generic trigger function to queue embedding jobs
create or replace function util.queue_embeddings()
returns trigger
language plpgsql
as $$
declare
content_function text = TG_ARGV[0];
embedding_column text = TG_ARGV[1];
begin
perform pgmq.send(
queue_name => 'embedding_jobs',
msg => jsonb_build_object(
'id', NEW.id,
'schema', TG_TABLE_SCHEMA,
'table', TG_TABLE_NAME,
'contentFunction', content_function,
'embeddingColumn', embedding_column
)
);
return NEW;
end;
$$;
```
Our `util.queue_embeddings` trigger function is generic and can be used with any table and content function. It accepts two arguments:
1. `content_function`: The name of a function that returns the text content to be embedded. The function should accept a single row as input and return text (see the `embedding_input` example).
This allows you to customize the text input passed to the embedding model - for example, you could concatenate multiple columns together like `title` and `content` and use the result as input.
2. `embedding_column`: The name of the destination column where the embedding will be stored.
Note that the `util.queue_embeddings` trigger function requires a `for each row` clause to work correctly. See [Usage](#usage) for an example of how to use this trigger function with your table.
Next we'll create a function to process the embedding jobs. This function will read jobs from the queue, group them into batches, and invoke the Edge Function to generate embeddings. We'll use `pg_cron` to schedule this function to run every 10 seconds.
```sql
-- Function to process embedding jobs from the queue
create or replace function util.process_embeddings(
batch_size int = 10,
max_requests int = 10,
timeout_milliseconds int = 5 * 60 * 1000 -- default 5 minute timeout
)
returns void
language plpgsql
as $$
declare
job_batches jsonb[];
batch jsonb;
begin
with
-- First get jobs and assign batch numbers
numbered_jobs as (
select
message || jsonb_build_object('jobId', msg_id) as job_info,
(row_number() over (order by 1) - 1) / batch_size as batch_num
from pgmq.read(
queue_name => 'embedding_jobs',
vt => timeout_milliseconds / 1000,
qty => max_requests * batch_size
)
),
-- Then group jobs into batches
batched_jobs as (
select
jsonb_agg(job_info) as batch_array,
batch_num
from numbered_jobs
group by batch_num
)
-- Finally aggregate all batches into array
select array_agg(batch_array)
from batched_jobs
into job_batches;
-- Invoke the embed edge function for each batch
foreach batch in array job_batches loop
perform util.invoke_edge_function(
name => 'embed',
body => batch,
timeout_milliseconds => timeout_milliseconds
);
end loop;
end;
$$;
-- Schedule the embedding processing
select
cron.schedule(
'process-embeddings',
'10 seconds',
$$
select util.process_embeddings();
$$
);
```
Let's discuss some common questions about this approach:
#### Why not generate all embeddings in a single Edge Function request?
While this is possible, it can lead to long processing times and potential timeouts. Batching allows us to process multiple embeddings concurrently and handle failures more effectively.
#### Why not one request per row?
This approach can lead to API rate limiting and performance issues. Batching provides a balance between efficiency and reliability.
#### Why queue requests instead of processing them immediately?
Queuing allows us to handle failures gracefully, retry requests, and manage concurrency more effectively. Specifically we are using `pgmq`'s visibility timeouts to ensure that failed requests are retried.
#### How do visibility timeouts work?
Every time we read a message from the queue, we set a visibility timeout which tells `pgmq` to hide the message from other readers for a certain period. If the Edge Function fails to process the message within this period, the message becomes visible again and will be retried by the next scheduled task.
#### How do we handle retries?
We use `pg_cron` to schedule a task that reads messages from the queue and processes them. If the Edge Function fails to process a message, it becomes visible again after a timeout and can be retried by the next scheduled task.
#### Is 10 seconds a good interval for processing?
This interval is a good starting point, but you may need to adjust it based on your workload and the time it takes to generate embeddings. You can adjust the `batch_size`, `max_requests`, and `timeout_milliseconds` parameters to optimize performance.
### Step 4: Create the Edge Function
Finally we'll create the Edge Function to generate embeddings. We'll use OpenAI's API in this example, but you can replace it with any other embedding generation service.
Use the Supabase CLI to create a new Edge Function:
```bash
supabase functions new embed
```
This will create a new directory `supabase/functions/embed` with an `index.ts` file. Replace the contents of this file with the following:
_supabase/functions/embed/index.ts_:
```typescript
// Setup type definitions for built-in Supabase Runtime APIs
import 'jsr:@supabase/functions-js/edge-runtime.d.ts'
// We'll use the OpenAI API to generate embeddings
import OpenAI from 'jsr:@openai/openai'
import { z } from 'npm:zod'
// We'll make a direct Postgres connection to update the document
import postgres from 'https://deno.land/x/postgresjs@v3.4.5/mod.js'
// Initialize OpenAI client
const openai = new OpenAI({
// We'll need to manually set the `OPENAI_API_KEY` environment variable
apiKey: Deno.env.get('OPENAI_API_KEY'),
})
// Initialize Postgres client
const sql = postgres(
// `SUPABASE_DB_URL` is a built-in environment variable
Deno.env.get('SUPABASE_DB_URL')!
)
const jobSchema = z.object({
jobId: z.number(),
id: z.number(),
schema: z.string(),
table: z.string(),
contentFunction: z.string(),
embeddingColumn: z.string(),
})
const failedJobSchema = jobSchema.extend({
error: z.string(),
})
type Job = z.infer<typeof jobSchema>
type FailedJob = z.infer<typeof failedJobSchema>
type Row = {
id: string
content: unknown
}
const QUEUE_NAME = 'embedding_jobs'
// Listen for HTTP requests
Deno.serve(async (req) => {
if (req.method !== 'POST') {
return new Response('expected POST request', { status: 405 })
}
if (req.headers.get('content-type') !== 'application/json') {
return new Response('expected json body', { status: 400 })
}
// Use Zod to parse and validate the request body
const parseResult = z.array(jobSchema).safeParse(await req.json())
if (parseResult.error) {
return new Response(`invalid request body: ${parseResult.error.message}`, {
status: 400,
})
}
const pendingJobs = parseResult.data
// Track jobs that completed successfully
const completedJobs: Job[] = []
// Track jobs that failed due to an error
const failedJobs: FailedJob[] = []
async function processJobs() {
let currentJob: Job | undefined
while ((currentJob = pendingJobs.shift()) !== undefined) {
try {
await processJob(currentJob)
completedJobs.push(currentJob)
} catch (error) {
failedJobs.push({
...currentJob,
error: error instanceof Error ? error.message : JSON.stringify(error),
})
}
}
}
try {
// Process jobs while listening for worker termination
await Promise.race([processJobs(), catchUnload()])
} catch (error) {
// If the worker is terminating (e.g. wall clock limit reached),
// add pending jobs to fail list with termination reason
failedJobs.push(
...pendingJobs.map((job) => ({
...job,
error: error instanceof Error ? error.message : JSON.stringify(error),
}))
)
}
// Log completed and failed jobs for traceability
console.log('finished processing jobs:', {
completedJobs: completedJobs.length,
failedJobs: failedJobs.length,
})
return new Response(
JSON.stringify({
completedJobs,
failedJobs,
}),
{
// 200 OK response
status: 200,
// Custom headers to report job status
headers: {
'Content-Type': 'application/json',
'X-Completed-Jobs': completedJobs.length.toString(),
'X-Failed-Jobs': failedJobs.length.toString(),
},
}
)
})
/**
* Generates an embedding for the given text.
*/
async function generateEmbedding(text: string) {
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: text,
})
const [data] = response.data
if (!data) {
throw new Error('failed to generate embedding')
}
return data.embedding
}
/**
* Processes an embedding job.
*/
async function processJob(job: Job) {
const { jobId, id, schema, table, contentFunction, embeddingColumn } = job
// Fetch content for the schema/table/row combination
const [row]: [Row] = await sql`
select
id,
${sql(contentFunction)}(t) as content
from
${sql(schema)}.${sql(table)} t
where
id = ${id}
`
if (!row) {
throw new Error(`row not found: ${schema}.${table}/${id}`)
}
if (typeof row.content !== 'string') {
throw new Error(`invalid content - expected string: ${schema}.${table}/${id}`)
}
const embedding = await generateEmbedding(row.content)
await sql`
update
${sql(schema)}.${sql(table)}
set
${sql(embeddingColumn)} = ${JSON.stringify(embedding)}
where
id = ${id}
`
await sql`
select pgmq.delete(${QUEUE_NAME}, ${jobId}::bigint)
`
}
/**
* Returns a promise that rejects if the worker is terminating.
*/
function catchUnload() {
return new Promise((reject) => {
addEventListener('beforeunload', (ev: any) => {
reject(new Error(ev.detail?.reason))
})
})
}
```
The Edge Function listens for incoming HTTP requests from `pg_net` and processes each embedding job. It is a generic worker that can handle embedding jobs for any table and column. It uses OpenAI's API to generate embeddings and updates the corresponding row in the database. It also deletes the job from the queue once it has been processed.
The function is designed to process multiple jobs independently. If one job fails, it will not affect the processing of other jobs. The function returns a `200 OK` response with a list of completed and failed jobs. We can use this information to diagnose failed jobs. See [Troubleshooting](#troubleshooting) for more details.
You will need to set the `OPENAI_API_KEY` environment variable to authenticate with OpenAI. When running the Edge Function locally, you can add it to a `.env` file:
_.env_:
```
OPENAI_API_KEY=your-api-key
```
When you're ready to deploy the Edge Function, set can set the environment variable using the Supabase CLI:
```shell
supabase secrets set --env-file .env
```
or
```shell
supabase secrets set OPENAI_API_KEY=<your-api-key>
```
Alternatively, you can replace the `generateEmbedding` function with your own embedding generation logic.
See [Deploy to Production](/docs/guides/functions/deploy) for more information on how to deploy the Edge Function.
## Usage
Now that the infrastructure is in place, let's go through an example of how to use this system to automatically generate embeddings for a table of documents. You can use this approach with multiple tables and customize the input for each embedding generation as needed.
### 1. Create table to store documents with embeddings
We'll set up a new `documents` table that will store our content and embeddings:
```sql
-- Table to store documents with embeddings
create table documents (
id integer primary key generated always as identity,
title text not null,
content text not null,
embedding halfvec,
created_at timestamp with time zone default now()
);
-- Index for vector search over document embeddings
create index on documents using hnsw (embedding halfvec_cosine_ops);
```
Our `documents` table stores the title and content of each document along with its vector embedding. We use a `halfvec` column to store the embeddings, which is a `pgvector` data type that stores float values in half precision (16 bits) to save space.
We use an [HNSW index](/docs/guides/ai/vector-indexes/hnsw-indexes) on the vector column. Note that we are choosing `halfvec_cosine_ops` as the index method, which means our future queries will need to use cosine distance (`<=>`) to find similar embeddings. Also note that HNSW indexes support a maximum of 4000 dimensions for `halfvec` vectors, so keep this in mind when choosing an embedding model. If your model generates embeddings with more than 4000 dimensions, you will need to reduce the dimensionality before indexing them. See [Matryoshka embeddings](/blog/matryoshka-embeddings) for a potential solution to shortening dimensions.
Also note that the table must have a primary key column named `id` for our triggers to work correctly with the `util.queue_embeddings` function and for our Edge Function to update the correct row.
### 2. Create triggers to enqueue embedding jobs
Now we'll set up the triggers to enqueue embedding jobs whenever content is inserted or updated:
```sql
-- Customize the input for embedding generation
-- e.g. Concatenate title and content with a markdown header
create or replace function embedding_input(doc documents)
returns text
language plpgsql
immutable
as $$
begin
return '# ' || doc.title || E'\n\n' || doc.content;
end;
$$;
-- Trigger for insert events
create trigger embed_documents_on_insert
after insert
on documents
for each row
execute function util.queue_embeddings('embedding_input', 'embedding');
-- Trigger for update events
create trigger embed_documents_on_update
after update of title, content -- must match the columns in embedding_input()
on documents
for each row
execute function util.queue_embeddings('embedding_input', 'embedding');
```
We create 2 triggers:
1. `embed_documents_on_insert`: Enqueues embedding jobs whenever new rows are inserted into the `documents` table.
2. `embed_documents_on_update`: Enqueues embedding jobs whenever the `title` or `content` columns are updated in the `documents` table.
Both of these triggers use the same `util.queue_embeddings` function that will queue the embedding jobs for processing. They accept 2 arguments:
1. `embedding_input`: The name of the function that generates the input for embedding generation. This function allows you to customize the text input passed to the embedding model (e.g. concatenating the title and content). The function should accept a single row as input and return text.
2. `embedding`: The name of the destination column where the embedding will be stored.
Note that the update trigger only fires when the `title` or `content` columns are updated. This is to avoid unnecessary updates to the embedding column when other columns are updated. Make sure that these columns match the columns used in the `embedding_input` function.
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
#### (Optional) Clearing embeddings on update
Note that our trigger will enqueue new embedding jobs when content is updated, but it will not clear any existing embeddings. This means that an embedding can be temporarily out of sync with the content until the new embedding is generated and updated.
If it is more important to have _accurate_ embeddings than _any_ embedding, you can add another trigger to clear the existing embedding until the new one is generated:
```sql
-- Trigger to clear the embedding column on update
create trigger clear_document_embedding_on_update
before update of title, content -- must match the columns in embedding_input()
on documents
for each row
execute function util.clear_column('embedding');
```
`util.clear_column` is a generic trigger function we created earlier that can be used to clear any column in a table.
- It accepts the column name as an argument. This column must be nullable.
- It requires a `before` trigger with a `for each row` clause.
- It requires the `hstore` extension we created earlier.
This example will clear the `embedding` column whenever the `title` or `content` columns are updated (note the `of title, content` clause). This ensures that the embedding is always in sync with the title and content, but it will result in temporary gaps in search results until the new embedding is generated.
We intentionally use a `before` trigger because it allows us to modify the record before it's written to disk, avoiding an extra `update` statement that would be needed with an `after` trigger.
### 3. Insert and update documents
Let's insert a new document and update its content to see the embedding generation in action:
```sql
-- Insert a new document
insert into documents (title, content)
values
('Understanding Vector Databases', 'Vector databases are specialized...');
-- Immediately check the embedding column
select id, embedding
from documents
where title = 'Understanding Vector Databases';
```
You should observe that the `embedding` column is initially `null` after inserting the document. This is because the embedding generation is asynchronous and will be processed by the Edge Function in the next scheduled task.
Wait up to 10 seconds for the next task to run, then check the `embedding` column again:
```sql
select id, embedding
from documents
where title = 'Understanding Vector Databases';
```
You should see the generated embedding for the document.
Next let's update the content of the document:
```sql
-- Update the content of the document
update documents
set content = 'Vector databases allow you to query...'
where title = 'Understanding Vector Databases';
-- Immediately check the embedding column
select id, embedding
from documents
where title = 'Understanding Vector Databases';
```
You should observe that the `embedding` column is reset to `null` after updating the content. This is because of the trigger we added to clear existing embeddings whenever the content is updated. The embedding will be regenerated by the Edge Function in the next scheduled task.
Wait up to 10 seconds for the next task to run, then check the `embedding` column again:
```sql
select id, embedding
from documents
where title = 'Understanding Vector Databases';
```
You should see the updated embedding for the document.
Finally we'll update the title of the document:
```sql
-- Update the title of the document
update documents
set title = 'Understanding Vector Databases with Supabase'
where title = 'Understanding Vector Databases';
```
You should observe that the `embedding` column is once again reset to `null` after updating the title. This is because the trigger we added to clear existing embeddings fires when either the `content` or `title` columns are updated. The embedding will be regenerated by the Edge Function in the next scheduled task.
Wait up to 10 seconds for the next task to run, then check the `embedding` column again:
```sql
select id, embedding
from documents
where title = 'Understanding Vector Databases with Supabase';
```
You should see the updated embedding for the document.
## Troubleshooting
The `embed` Edge Function processes a batch of embedding jobs and returns a `200 OK` response with a list of completed and failed jobs in the body. For example:
```json
{
"completedJobs": [
{
"jobId": "1",
"id": "1",
"schema": "public",
"table": "documents",
"contentFunction": "embedding_input",
"embeddingColumn": "embedding"
}
],
"failedJobs": [
{
"jobId": "2",
"id": "2",
"schema": "public",
"table": "documents",
"contentFunction": "embedding_input",
"embeddingColumn": "embedding",
"error": "error connecting to openai api"
}
]
}
```
It also returns the number of completed and failed jobs in the response headers. For example:
```
X-Completed-Jobs: 1
X-Failed-Jobs: 1
```
You can also use the `X-Deno-Execution-Id` header to trace the execution of the Edge Function within the [dashboard](/dashboard/project/_/functions) logs.
Each failed job includes an `error` field with a description of the failure. Reasons for a job failing could include:
- An error generating the embedding via external API
- An error connecting to the database
- The edge function being terminated (e.g. due to a wall clock limit)
- Any other error thrown during processing
`pg_net` stores HTTP responses in the `net._http_response` table, which can be queried to diagnose issues with the embedding generation process.
```sql
select
*
from
net._http_response
where
(headers->>'X-Failed-Jobs')::int > 0;
```
## Conclusion
Automating embedding generation and updates in Postgres allow you to build powerful semantic search capabilities without the complexity of managing embeddings manually.
By combining Postgres features like triggers, queues, and other extensions with Supabase Edge Functions, we can create a robust system that handles embedding generation asynchronously and retries failed jobs automatically.
This system can be customized to work with any content and embedding generation service, providing a flexible and scalable solution for semantic search in Postgres.
## See also
- [What are embeddings?](/docs/guides/ai/concepts)
- [Semantic search](/docs/guides/ai/semantic-search)
- [Vector indexes](/docs/guides/ai/vector-indexes)
- [Supabase Edge Functions](/docs/guides/functions)
@@ -0,0 +1,118 @@
---
id: automatic-retries-in-supabase-js
title: 'How to do automatic retries with `supabase-js`'
subtitle: 'Learn how to add automatic retries to your Supabase API requests using `fetch-retry`.'
---
<Admonition type="danger" title="Important">
You should only enable retries if your requests fail with network errors (e.g. 520 status from Cloudflare). A high number of retries have the potential to exhaust the Data API connection pool, which could result in lower throughput and failed requests.
</Admonition>
The `fetch-retry` package allows you to add retry logic to `fetch` requests, making it a useful tool for enhancing the resilience of API calls in your Supabase applications. Here's a step-by-step guide on how to integrate `fetch-retry` with the `supabase-js` library.
## 1. Install dependencies
To get started, ensure you have both `supabase-js` and `fetch-retry` installed in your project:
```bash
npm install @supabase/supabase-js fetch-retry
```
## 2. Wrap the fetch function
The `fetch-retry` package works by wrapping the native `fetch` function. You can create a custom fetch instance with retry logic and pass it to the `supabase-js` client.
```javascript
import { createClient } from '@supabase/supabase-js'
import fetchRetry from 'fetch-retry'
// Wrap the global fetch with fetch-retry
const fetchWithRetry = fetchRetry(fetch)
// Create a Supabase client instance with the custom fetch
const supabase = createClient('https://your-supabase-url.supabase.co', 'your-anon-key', {
global: {
fetch: fetchWithRetry,
},
})
```
## 3. Configure retry options
You can configure `fetch-retry` options to control retry behavior, such as the number of retries, retry delay, and which errors should trigger a retry.
Here is an example with custom retry options:
```javascript
const fetchWithRetry = fetchRetry(fetch, {
retries: 3, // Number of retry attempts
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000), // Exponential backoff
retryOn: [520], // Retry only on Cloudflare errors
})
```
In this example, the `retryDelay` function implements an exponential backoff strategy, and retries are triggered only for specific HTTP status codes.
## 4. Using the Supabase client
With `fetch-retry` integrated, you can use the Supabase client as usual. The retry logic will automatically apply to all network requests made by `supabase-js`.
```javascript
async function fetchData() {
const { data, error } = await supabase.from('your_table').select('*')
if (error) {
console.error('Error fetching data:', error)
} else {
console.log('Fetched data:', data)
}
}
fetchData()
```
## 5. Fine-Tuning retries for specific requests
If you need different retry logic for certain requests, you can use the `retryOn` with a custom function to inspect the URL or response and decide whether to retry the request.
```javascript
const fetchWithRetry = fetchRetry(fetch, {
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
retryOn: (attempt, error, response) => {
const shouldRetry
= (attempt: number, error: Error | null, response: Response | null) =>
attempt < 3
&& response
&& response.status == 520 // Cloudflare errors
&& response.url.includes('rpc/your_stored_procedure')
if (shouldRetry(attempt, error, response)) {
console.log(`Retrying request... Attempt #${attempt}`, response)
return true
}
return false
}
})
async function yourStoredProcedure() {
const { data, error } = await supabase
.rpc('your_stored_procedure', { param1: 'value1' });
if (error) {
console.log('Error executing RPC:', error);
} else {
console.log('Response:', data);
}
}
yourStoredProcedure();
```
By using `retryOn` with a custom function, you can define specific conditions for retrying requests. In this example, the retry logic is applied only to requests targeting a specific stored procedure.
## Conclusion
Integrating `fetch-retry` with `supabase-js` is a straightforward way to add robustness to your Supabase API requests. By handling transient errors and implementing retry strategies, you can improve the reliability of your application while maintaining a seamless user experience.
@@ -72,7 +72,7 @@ hideToc: true
Start the development server, go to http://localhost:3000 in a browser, and you should see the contents of `app/page.tsx`.
To sign up a new user, navigate to http://localhost:3000/login, and click `Sign Up Now`.
To sign up a new user, navigate to http://localhost:3000/sign-up, and click `Sign up`. *NOTE: .env.example must be renamed to .env.local before this route becomes available*
</StepHikeCompact.Details>
@@ -10,7 +10,8 @@ How you connect to your database depends on where you're connecting from:
- For frontend applications, use the [Data API](#data-apis-and-client-libraries)
- For Postgres clients, use a connection string
- For persistent clients (for example, long-running servers or database GUIs) use the [direct connection string](#direct-connection) if your environment supports IPv6, and [Supavisor session mode](#supavisor-session-mode) if not
- For single sessions (for example, database GUIs) or Postgres native commands (for example, using client applications like [pg_dump](https://www.postgresql.org/docs/current/app-pgdump.html) or specifying connections for [replication](/docs/guides/database/postgres/setup-replication-external)) use the [direct connection string](#direct-connection) if your environment supports IPv6
- For persistent clients, and support for both IPv4 and IPv6, use [Supavisor session mode](#supavisor-session-mode)
- For temporary clients (for example, serverless or edge functions) use [Supavisor transaction mode](#supavisor-transaction-mode)
## Quickstarts
+9 -9
View File
@@ -98,11 +98,11 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t
<TabPanel id="yarn_initiate" label="yarn">
```bash
yarn init -y
yarn install prisma typescript ts-node @types/node --save-dev
yarn add prisma typescript ts-node @types/node --save-dev
yarn dlx tsc --init
npx tsc --init
yarn dlx prisma init
npx prisma init
```
</TabPanel>
<TabPanel id="bun_initiate" label="bun">
@@ -241,7 +241,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t
</TabPanel>
<TabPanel id="yarn_migrate" label="yarn">
```bash
yarn dlx prisma migrate dev --name first_prisma_migration
npx prisma migrate dev --name first_prisma_migration
```
</TabPanel>
@@ -318,7 +318,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t
<TabPanel id="yarn_sync" label="yarn">
```bash
yarn dlx prisma db pull
npx prisma db pull
```
Create a migration file
@@ -328,7 +328,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t
Synchronize the migrations
```bash
yarn dlx prisma migrate diff \
npx prisma migrate diff \
--from-empty \
--to-schema-datamodel prisma/schema.prisma \
--script > prisma/migrations/0_init_supabase/migration.sql
@@ -338,7 +338,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t
</Admonition>
```bash
yarn dlx prisma migrate resolve --applied 0_init_supabase
npx prisma migrate resolve --applied 0_init_supabase
```
</TabPanel>
@@ -403,8 +403,8 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t
</TabPanel>
<TabPanel id="yarn_client" label="yarn">
```sh
yarn install @prisma/client
yarn dlx prisma generate
yarn add @prisma/client
npx prisma generate
```
</TabPanel>
<TabPanel id="bun_client" label="bun">
@@ -0,0 +1,328 @@
---
id: 'local-development-testing-overview'
title: 'Testing Overview'
description: 'Learn how to develop and test database schemas, tables, functions, and Row Level Security (RLS) policies.'
---
Testing is a critical part of database development, especially when working with features like Row Level Security (RLS) policies. This guide provides a comprehensive approach to testing your Supabase database.
## Testing approaches
### Database unit testing with pgTAP
[pgTAP](https://pgtap.org) is a unit testing framework for PostgreSQL that allows testing:
- Database structure: tables, columns, constraints
- Row Level Security (RLS) policies
- Functions and procedures
- Data integrity
This example demonstrates setting up and testing RLS policies for a simple todo application:
1. Create a test table with RLS enabled:
```sql
-- Create a simple todos table
create table todos (
id uuid primary key default gen_random_uuid(),
task text not null,
user_id uuid references auth.users not null,
completed boolean default false
);
-- Enable RLS
alter table todos enable row level security;
-- Create a policy
create policy "Users can only access their own todos"
on todos for all -- this policy applies to all operations
to authenticated
using ((select auth.uid()) = user_id);
```
2. Set up your testing environment:
```bash
# Create a new test for our policies using supabase cli
supabase test new todos_rls.test
```
3. Write your RLS tests:
```sql
begin;
-- install tests utilities
-- install pgtap extension for testing
create extension if not exists pgtap with schema extensions;
-- Start declare we'll have 6 test cases in our test suite
select plan(4);
-- Setup our testing data
-- Set up auth.users entries
insert into auth.users (id, email) values
('123e4567-e89b-12d3-a456-426614174000', 'user1@test.com'),
('987fcdeb-51a2-43d7-9012-345678901234', 'user2@test.com');
-- Create test todos
insert into public.todos (task, user_id) values
('User 1 Task 1', '123e4567-e89b-12d3-a456-426614174000'),
('User 1 Task 2', '123e4567-e89b-12d3-a456-426614174000'),
('User 2 Task 1', '987fcdeb-51a2-43d7-9012-345678901234');
-- as User 1
set local role authenticated;
set local request.jwt.claim.sub = '123e4567-e89b-12d3-a456-426614174000';
-- Test 1: User 1 should only see their own todos
select results_eq(
'select count(*) from todos',
ARRAY[2::bigint],
'User 1 should only see their 2 todos'
);
-- Test 2: User 1 can create their own todo
select lives_ok(
$$insert into todos (task, user_id) values ('New Task', '123e4567-e89b-12d3-a456-426614174000'::uuid)$$,
'User 1 can create their own todo'
);
-- as User 2
set local request.jwt.claim.sub = '987fcdeb-51a2-43d7-9012-345678901234';
-- Test 3: User 2 should only see their own todos
select results_eq(
'select count(*) from todos',
ARRAY[1::bigint],
'User 2 should only see their 1 todo'
);
-- Test 4: User 2 cannot modify User 1's todo
SELECT results_ne(
$$ update todos set task = 'Hacked!' where user_id = '123e4567-e89b-12d3-a456-426614174000'::uuid returning 1 $$,
$$ values(1) $$,
'User 2 cannot modify User 1 todos'
);
select * from finish();
rollback;
```
4. Run the tests:
```bash
supabase test db
psql:todos_rls.test.sql:4: NOTICE: extension "pgtap" already exists, skipping
./todos_rls.test.sql .. ok
All tests successful.
Files=1, Tests=6, 0 wallclock secs ( 0.01 usr + 0.00 sys = 0.01 CPU)
Result: PASS
```
### Application-Level testing
Testing through application code provides end-to-end verification. Unlike database-level testing with pgTAP, application-level tests cannot use transactions for isolation.
<Admonition type="caution">
Application-level tests should not rely on a clean database state, as resetting the database before each test can be slow and makes tests difficult to parallelize.
Instead, design your tests to be independent by using unique user IDs for each test case.
</Admonition>
Here's an example using TypeScript that mirrors the pgTAP tests above:
```typescript
import { createClient } from '@supabase/supabase-js'
import { beforeAll, describe, expect, it } from 'vitest'
import crypto from 'crypto'
describe('Todos RLS', () => {
// Generate unique IDs for this test suite to avoid conflicts with other tests
const USER_1_ID = crypto.randomUUID()
const USER_2_ID = crypto.randomUUID()
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!)
beforeAll(async () => {
// Setup test data specific to this test suite
const adminSupabase = createClient(process.env.SUPABASE_URL!, process.env.SERVICE_ROLE_KEY!)
// Create test users with unique IDs
await adminSupabase.auth.admin.createUser({
id: USER_1_ID,
email: `user1-${USER_1_ID}@test.com`,
password: 'password123',
// We want the user to be usable right away without email confirmation
email_confirm: true,
})
await adminSupabase.auth.admin.createUser({
id: USER_2_ID,
email: `user2-${USER_2_ID}@test.com`,
password: 'password123',
email_confirm: true,
})
// Create initial todos
await adminSupabase.from('todos').insert([
{ task: 'User 1 Task 1', user_id: USER_1_ID },
{ task: 'User 1 Task 2', user_id: USER_1_ID },
{ task: 'User 2 Task 1', user_id: USER_2_ID },
])
})
it('should allow User 1 to only see their own todos', async () => {
// Sign in as User 1
await supabase.auth.signInWithPassword({
email: `user1-${USER_1_ID}@test.com`,
password: 'password123',
})
const { data: todos } = await supabase.from('todos').select('*')
expect(todos).toHaveLength(2)
todos?.forEach((todo) => {
expect(todo.user_id).toBe(USER_1_ID)
})
})
it('should allow User 1 to create their own todo', async () => {
await supabase.auth.signInWithPassword({
email: `user1-${USER_1_ID}@test.com`,
password: 'password123',
})
const { error } = await supabase.from('todos').insert({ task: 'New Task', user_id: USER_1_ID })
expect(error).toBeNull()
})
it('should allow User 2 to only see their own todos', async () => {
// Sign in as User 2
await supabase.auth.signInWithPassword({
email: `user2-${USER_2_ID}@test.com`,
password: 'password123',
})
const { data: todos } = await supabase.from('todos').select('*')
expect(todos).toHaveLength(1)
todos?.forEach((todo) => {
expect(todo.user_id).toBe(USER_2_ID)
})
})
it('should prevent User 2 from modifying User 1 todos', async () => {
await supabase.auth.signInWithPassword({
email: `user2-${USER_2_ID}@test.com`,
password: 'password123',
})
// Attempt to update the todos we shouldn't have access to
// result will be a no-op
await supabase.from('todos').update({ task: 'Hacked!' }).eq('user_id', USER_1_ID)
// Log back in as User 1 to verify their todos weren't changed
await supabase.auth.signInWithPassword({
email: `user1-${USER_1_ID}@test.com`,
password: 'password123',
})
// Fetch User 1's todos
const { data: todos } = await supabase.from('todos').select('*')
// Verify that none of the todos were changed to "Hacked!"
expect(todos).toBeDefined()
todos?.forEach((todo) => {
expect(todo.task).not.toBe('Hacked!')
})
})
})
```
#### Test isolation strategies
For application-level testing, consider these approaches for test isolation:
1. **Unique Identifiers**: Generate unique IDs for each test suite to prevent data conflicts
2. **Cleanup After Tests**: If necessary, clean up created data in an `afterAll` or `afterEach` hook
3. **Isolated Data Sets**: Use prefixes or namespaces in data to separate test cases
### Continuous integration testing
Set up automated database testing in your CI pipeline:
1. Create a GitHub Actions workflow `.github/workflows/db-tests.yml`:
```yaml
name: Database Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Supabase CLI
uses: supabase/setup-cli@v1
- name: Start Supabase
run: supabase start
- name: Run Tests
run: supabase test db
```
## Best practices
1. **Test Data Setup**
- Use begin and rollback to ensure test isolation
- Create realistic test data that covers edge cases
- Use different user roles and permissions in tests
2. **RLS Policy Testing**
- Test Create, Read, Update, Delete operations
- Test with different user roles: anonymous and authenticated
- Test edge cases and potential security bypasses
- Always test negative cases: what users should not be able to do
3. **CI/CD Integration**
- Run tests automatically on every pull request
- Include database tests in deployment pipeline
- Keep test runs fast using transactions
## Real-World examples
For more complex, real-world examples of database testing, check out:
- [Database Tests Example Repository](https://github.com/usebasejump/basejump/tree/main/supabase/tests/database) - A production-grade example of testing RLS policies
- [RLS Guide and Best Practices](https://github.com/orgs/supabase/discussions/14576)
## Troubleshooting
Common issues and solutions:
1. **Test Failures Due to RLS**
- Ensure you've set the correct role `set local role authenticated;`
- Verify JWT claims are set `set local "request.jwt.claims"`
- Check policy definitions match your test assumptions
2. **CI Pipeline Issues**
- Verify Supabase CLI is properly installed
- Ensure database migrations are run before tests
- Check for proper test isolation using transactions
## Additional resources
- [pgTAP Documentation](https://pgtap.org)
- [Supabase CLI Reference](https://supabase.com/docs/reference/cli/supabase-test)
- [pgTAP Supabase reference](https://supabase.com/docs/guides/database/extensions/pgtap?queryGroups=database-method&database-method=sql#testing-rls-policies)
- [Database testing reference](https://supabase.com/docs/guides/database/testing)
@@ -0,0 +1,665 @@
---
id: 'local-development-testing-pgtap-extended'
title: 'Advanced pgTAP Testing'
description: 'Learn how to leverage dbdev and test helpers for advanced database testing.'
---
While basic pgTAP provides excellent testing capabilities, you can enhance the testing workflow using database development tools and helper packages. This guide covers advanced testing techniques using database.dev and community-maintained test helpers.
## Using database.dev
[Database.dev](https://database.dev) is a package manager for PostgreSQL that allows installation and use of community-maintained packages, including testing utilities.
### Setting up dbdev
To use database development tools and packages, install some prerequisites:
```sql
create extension if not exists http with schema extensions;
create extension if not exists pg_tle;
drop extension if exists "supabase-dbdev";
select pgtle.uninstall_extension_if_exists('supabase-dbdev');
select
pgtle.install_extension(
'supabase-dbdev',
resp.contents ->> 'version',
'PostgreSQL package manager',
resp.contents ->> 'sql'
)
from http(
(
'GET',
'https://api.database.dev/rest/v1/'
|| 'package_versions?select=sql,version'
|| '&package_name=eq.supabase-dbdev'
|| '&order=version.desc'
|| '&limit=1',
array[
('apiKey', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhtdXB0cHBsZnZpaWZyYndtbXR2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2ODAxMDczNzIsImV4cCI6MTk5NTY4MzM3Mn0.z2CN0mvO2No8wSi46Gw59DFGCTJrzM0AQKsu_5k134s')::http_header
],
null,
null
)
) x,
lateral (
select
((row_to_json(x) -> 'content') #>> '{}')::json -> 0
) resp(contents);
create extension "supabase-dbdev";
select dbdev.install('supabase-dbdev');
-- Drop and recreate the extension to ensure a clean installation
drop extension if exists "supabase-dbdev";
create extension "supabase-dbdev";
```
### Installing test helpers
The Test Helpers package provides utilities that simplify testing Supabase-specific features:
```sql
select dbdev.install('basejump-supabase_test_helpers');
create extension if not exists "basejump-supabase_test_helpers" version '0.0.6';
```
## Test helper benefits
The test helpers package provides several advantages over writing raw pgTAP tests:
1. **Simplified User Management**
- Create test users with `tests.create_supabase_user()`
- Switch contexts with `tests.authenticate_as()`
- Retrieve user IDs using `tests.get_supabase_uid()`
2. **Row Level Security (RLS) Testing Utilities**
- Verify RLS status with `tests.rls_enabled()`
- Test policy enforcement
- Simulate different user contexts
3. **Reduced Boilerplate**
- No need to manually insert auth.users
- Simplified JWT claim management
- Clean test setup and cleanup
## Schema-wide Row Level Security testing
When working with Row Level Security, it's crucial to ensure that RLS is enabled on all tables that need it. Create a simple test to verify RLS is enabled across an entire schema:
```sql
begin;
select plan(1);
-- Verify RLS is enabled on all tables in the public schema
select tests.rls_enabled('public');
select * from finish();
rollback;
```
## Test file organization
When working with multiple test files that share common setup requirements, it's beneficial to create a single "pre-test" file that handles the global environment setup. This approach reduces duplication and ensures consistent test environments.
### Creating a pre-test hook
Since pgTAP test files are executed in alphabetical order, create a setup file that runs first by using a naming convention like `000-setup-tests-hooks.sql`:
```bash
supabase test new 000-setup-tests-hooks
```
This setup file should contain:
1. All shared extensions and dependencies
2. Common test utilities
3. A simple always green test to verify the setup
Here's an example setup file:
```sql
-- install tests utilities
-- install pgtap extension for testing
create extension if not exists pgtap with schema extensions;
/*
---------------------
---- install dbdev ----
----------------------
Requires:
- pg_tle: https://github.com/aws/pg_tle
- pgsql-http: https://github.com/pramsey/pgsql-http
*/
create extension if not exists http with schema extensions;
create extension if not exists pg_tle;
drop extension if exists "supabase-dbdev";
select pgtle.uninstall_extension_if_exists('supabase-dbdev');
select
pgtle.install_extension(
'supabase-dbdev',
resp.contents ->> 'version',
'PostgreSQL package manager',
resp.contents ->> 'sql'
)
from http(
(
'GET',
'https://api.database.dev/rest/v1/'
|| 'package_versions?select=sql,version'
|| '&package_name=eq.supabase-dbdev'
|| '&order=version.desc'
|| '&limit=1',
array[
('apiKey', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhtdXB0cHBsZnZpaWZyYndtbXR2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2ODAxMDczNzIsImV4cCI6MTk5NTY4MzM3Mn0.z2CN0mvO2No8wSi46Gw59DFGCTJrzM0AQKsu_5k134s')::http_header
],
null,
null
)
) x,
lateral (
select
((row_to_json(x) -> 'content') #>> '{}')::json -> 0
) resp(contents);
create extension "supabase-dbdev";
select dbdev.install('supabase-dbdev');
drop extension if exists "supabase-dbdev";
create extension "supabase-dbdev";
-- Install test helpers
select dbdev.install('basejump-supabase_test_helpers');
create extension if not exists "basejump-supabase_test_helpers" version '0.0.6';
-- Verify setup with a no-op test
begin;
select plan(1);
select ok(true, 'Pre-test hook completed successfully');
select * from finish();
rollback;
```
### Benefits
This approach provides several advantages:
- Reduces code duplication across test files
- Ensures consistent test environment setup
- Makes it easier to maintain and update shared dependencies
- Provides immediate feedback if the setup process fails
Your subsequent test files (`001-auth-tests.sql`, `002-rls-tests.sql`) can focus solely on their specific test cases, knowing that the environment is properly configured.
## Example: Advanced RLS testing
Here's a complete example using test helpers to verify RLS policies putting it all together:
```sql
begin;
-- Assuming 000-setup-tests-hooks.sql file is present to use tests helpers
select plan(4);
-- Set up test data
-- Create test supabase users
select tests.create_supabase_user('user1@test.com');
select tests.create_supabase_user('user2@test.com');
-- Create test data
insert into public.todos (task, user_id) values
('User 1 Task 1', tests.get_supabase_uid('user1@test.com')),
('User 1 Task 2', tests.get_supabase_uid('user1@test.com')),
('User 2 Task 1', tests.get_supabase_uid('user2@test.com'));
-- Test as User 1
select tests.authenticate_as('user1@test.com');
-- Test 1: User 1 should only see their own todos
select results_eq(
'select count(*) from todos',
ARRAY[2::bigint],
'User 1 should only see their 2 todos'
);
-- Test 2: User 1 can create their own todo
select lives_ok(
$$insert into todos (task, user_id) values ('New Task', tests.get_supabase_uid('user1@test.com'))$$,
'User 1 can create their own todo'
);
-- Test as User 2
select tests.authenticate_as('user2@test.com');
-- Test 3: User 2 should only see their own todos
select results_eq(
'select count(*) from todos',
ARRAY[1::bigint],
'User 2 should only see their 1 todo'
);
-- Test 4: User 2 cannot modify User 1's todo
SELECT results_ne(
$$ update todos set task = 'Hacked!' where user_id = tests.get_supabase_uid('user1@test.com') returning 1 $$,
$$ values(1) $$,
'User 2 cannot modify User 1 todos'
);
select * from finish();
rollback;
```
## Not another todo app: Testing complex organizations
Todo apps are great for learning, but this section explores testing a more realistic scenario: a multi-tenant content publishing platform. This example demonstrates testing complex permissions, plan restrictions, and content management.
### System overview
This demo app implements:
- Organizations with tiered plans (free/pro/enterprise)
- Role-based access (owner/admin/editor/viewer)
- Content management (posts/comments)
- Premium content restrictions
- Plan-based limitations
### What makes this complex?
1. **Layered Permissions**
- Role hierarchies affect access rights
- Plan types influence user capabilities
- Content state (draft/published) affects permissions
2. **Business Rules**
- Free plan post limits
- Premium content visibility
- Cross-organization security
### Testing focus areas
When writing tests, verify:
- Organization member access control
- Content visibility across roles
- Plan limitation enforcement
- Cross-organization data isolation
#### 1. App schema definitions
The app schema tables are defined like this:
```sql
create table public.profiles (
id uuid references auth.users(id) primary key,
username text unique not null,
full_name text,
bio text,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
create table public.organizations (
id bigint primary key generated always as identity,
name text not null,
slug text unique not null,
plan_type text not null check (plan_type in ('free', 'pro', 'enterprise')),
max_posts int not null default 5,
created_at timestamptz default now()
);
create table public.org_members (
org_id bigint references public.organizations(id) on delete cascade,
user_id uuid references auth.users(id) on delete cascade,
role text not null check (role in ('owner', 'admin', 'editor', 'viewer')),
created_at timestamptz default now(),
primary key (org_id, user_id)
);
create table public.posts (
id bigint primary key generated always as identity,
title text not null,
content text not null,
author_id uuid references public.profiles(id) not null,
org_id bigint references public.organizations(id),
status text not null check (status in ('draft', 'published', 'archived')),
is_premium boolean default false,
scheduled_for timestamptz,
category text,
view_count int default 0,
published_at timestamptz,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
create table public.comments (
id bigint primary key generated always as identity,
post_id bigint references public.posts(id) on delete cascade,
author_id uuid references public.profiles(id),
content text not null,
is_deleted boolean default false,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
```
#### 2. RLS policies declaration
Now to setup the RLS policies for each tables:
```sql
-- Create a private schema to store all security definer functions utils
-- As such functions should never be in a API exposed schema
create schema if not exists private;
-- Helper function for role checks
create or replace function private.get_user_org_role(org_id bigint, user_id uuid)
returns text
set search_path = ''
as $$
select role from public.org_members
where org_id = $1 and user_id = $2;
-- Note the use of security definer to avoid RLS checking recursion issue
-- see: https://supabase.com/docs/guides/database/postgres/row-level-security#use-security-definer-functions
$$ language sql security definer;
-- Helper utils to check if an org is below the max post limit
create or replace function private.can_add_post(org_id bigint)
returns boolean
set search_path = ''
as $$
select (select count(*)
from public.posts p
where p.org_id = $1) < o.max_posts
from public.organizations o
where o.id = $1
$$ language sql security definer;
-- Enable RLS for all tables
alter table public.profiles enable row level security;
alter table public.organizations enable row level security;
alter table public.org_members enable row level security;
alter table public.posts enable row level security;
alter table public.comments enable row level security;
-- Profiles policies
create policy "Public profiles are viewable by everyone"
on public.profiles for select using (true);
create policy "Users can insert their own profile"
on public.profiles for insert with check ((select auth.uid()) = id);
create policy "Users can update their own profile"
on public.profiles for update using ((select auth.uid()) = id)
with check ((select auth.uid()) = id);
-- Organizations policies
create policy "Public org info visible to all"
on public.organizations for select using (true);
create policy "Org management restricted to owners"
on public.organizations for all using (
private.get_user_org_role(id, (select auth.uid())) = 'owner'
);
-- Org Members policies
create policy "Members visible to org members"
on public.org_members for select using (
private.get_user_org_role(org_id, (select auth.uid())) is not null
);
create policy "Member management restricted to admins and owners"
on public.org_members for all using (
private.get_user_org_role(org_id, (select auth.uid())) in ('owner', 'admin')
);
-- Posts policies
create policy "Complex post visibility"
on public.posts for select using (
-- Published non-premium posts are visible to all
(status = 'published' and not is_premium)
or
-- Premium posts visible to org members only
(status = 'published' and is_premium and
private.get_user_org_role(org_id, (select auth.uid())) is not null)
or
-- All posts visible to editors and above
private.get_user_org_role(org_id, (select auth.uid())) in ('owner', 'admin', 'editor')
);
create policy "Post creation rules"
on public.posts for insert with check (
-- Must be org member with appropriate role
private.get_user_org_role(org_id, (select auth.uid())) in ('owner', 'admin', 'editor')
and
-- Check org post limits for free plans
(
(select o.plan_type != 'free'
from organizations o
where o.id = org_id)
or
(select private.can_add_post(org_id))
)
);
create policy "Post update rules"
on public.posts for update using (
exists (
select 1
where
-- Editors can update non-published posts
(private.get_user_org_role(org_id, (select auth.uid())) = 'editor' and status != 'published')
or
-- Admins and owners can update any post
private.get_user_org_role(org_id, (select auth.uid())) in ('owner', 'admin')
)
);
-- Comments policies
create policy "Comments on published posts are viewable by everyone"
on public.comments for select using (
exists (
select 1 from public.posts
where id = post_id
and status = 'published'
)
and not is_deleted
);
create policy "Authenticated users can create comments"
on public.comments for insert with check ((select auth.uid()) = author_id);
create policy "Users can update their own comments"
on public.comments for update using (author_id = (select auth.uid()));
```
#### 3. Test cases:
Now everything is setup, let's write RLS test cases, note that each section could be in its own test:
```sql
-- Assuming we already have: 000-setup-tests-hooks.sql file we can use tests helpers
begin;
-- Declare total number of tests
select plan(10);
-- Create test users
select tests.create_supabase_user('org_owner', 'owner@test.com');
select tests.create_supabase_user('org_admin', 'admin@test.com');
select tests.create_supabase_user('org_editor', 'editor@test.com');
select tests.create_supabase_user('premium_user', 'premium@test.com');
select tests.create_supabase_user('free_user', 'free@test.com');
select tests.create_supabase_user('scheduler', 'scheduler@test.com');
select tests.create_supabase_user('free_author', 'free_author@test.com');
-- Create profiles for test users
insert into profiles (id, username, full_name)
values
(tests.get_supabase_uid('org_owner'), 'org_owner', 'Organization Owner'),
(tests.get_supabase_uid('org_admin'), 'org_admin', 'Organization Admin'),
(tests.get_supabase_uid('org_editor'), 'org_editor', 'Organization Editor'),
(tests.get_supabase_uid('premium_user'), 'premium_user', 'Premium User'),
(tests.get_supabase_uid('free_user'), 'free_user', 'Free User'),
(tests.get_supabase_uid('scheduler'), 'scheduler', 'Scheduler User'),
(tests.get_supabase_uid('free_author'), 'free_author', 'Free Author');
-- First authenticate as service role to bypass RLS for initial setup
select tests.authenticate_as_service_role();
-- Create test organizations and setup data
with new_org as (
insert into organizations (name, slug, plan_type, max_posts)
values
('Test Org', 'test-org', 'pro', 100),
('Premium Org', 'premium-org', 'enterprise', 1000),
('Schedule Org', 'schedule-org', 'pro', 100),
('Free Org', 'free-org', 'free', 2)
returning id, slug
),
-- Setup members and posts
member_setup as (
insert into org_members (org_id, user_id, role)
select
org.id,
user_id,
role
from new_org org cross join (
values
(tests.get_supabase_uid('org_owner'), 'owner'),
(tests.get_supabase_uid('org_admin'), 'admin'),
(tests.get_supabase_uid('org_editor'), 'editor'),
(tests.get_supabase_uid('premium_user'), 'viewer'),
(tests.get_supabase_uid('scheduler'), 'editor'),
(tests.get_supabase_uid('free_author'), 'editor')
) as members(user_id, role)
where org.slug = 'test-org'
or (org.slug = 'premium-org' and role = 'viewer')
or (org.slug = 'schedule-org' and role = 'editor')
or (org.slug = 'free-org' and role = 'editor')
)
-- Setup initial posts
insert into posts (title, content, org_id, author_id, status, is_premium, scheduled_for)
select
title,
content,
org.id,
author_id,
status,
is_premium,
scheduled_for
from new_org org cross join (
values
('Premium Post', 'Premium content', tests.get_supabase_uid('premium_user'), 'published', true, null),
('Free Post', 'Free content', tests.get_supabase_uid('premium_user'), 'published', false, null),
('Future Post', 'Future content', tests.get_supabase_uid('scheduler'), 'published', false, '2024-01-02 12:00:00+00'::timestamptz)
) as posts(title, content, author_id, status, is_premium, scheduled_for)
where org.slug in ('premium-org', 'schedule-org');
-- Test owner privileges
select tests.authenticate_as('org_owner');
select lives_ok(
$$
update organizations
set name = 'Updated Org'
where id = (select id from organizations limit 1)
$$,
'Owner can update organization'
);
-- Test admin privileges
select tests.authenticate_as('org_admin');
select results_eq(
$$select count(*) from org_members$$,
ARRAY[6::bigint],
'Admin can view all members'
);
-- Test editor restrictions
select tests.authenticate_as('org_editor');
select throws_ok(
$$
insert into org_members (org_id, user_id, role)
values (
(select id from organizations limit 1),
(select tests.get_supabase_uid('org_editor')),
'viewer'
)
$$,
'42501',
'new row violates row-level security policy for table "org_members"',
'Editor cannot manage members'
);
-- Premium Content Access Tests
select tests.authenticate_as('premium_user');
select results_eq(
$$select count(*) from posts where org_id = (select id from organizations where slug = 'premium-org')$$,
ARRAY[3::bigint],
'Premium user can see all posts'
);
select tests.clear_authentication();
select results_eq(
$$select count(*) from posts where org_id = (select id from organizations where slug = 'premium-org')$$,
ARRAY[2::bigint],
'Anonymous users can only see free posts'
);
-- Time-Based Publishing Tests
select tests.authenticate_as('scheduler');
select tests.freeze_time('2024-01-01 12:00:00+00'::timestamptz);
select results_eq(
$$select count(*) from posts where scheduled_for > now() and org_id = (select id from organizations where slug = 'schedule-org')$$,
ARRAY[1::bigint],
'Can see scheduled posts'
);
select tests.freeze_time('2024-01-02 13:00:00+00'::timestamptz);
select results_eq(
$$select count(*) from posts where scheduled_for < now() and org_id = (select id from organizations where slug = 'schedule-org')$$,
ARRAY[1::bigint],
'Can see posts after schedule time'
);
select tests.unfreeze_time();
-- Plan Limit Tests
select tests.authenticate_as('free_author');
select lives_ok(
$$
insert into posts (title, content, org_id, author_id, status)
select 'Post 1', 'Content 1', id, auth.uid(), 'draft'
from organizations where slug = 'free-org' limit 1
$$,
'First post creates successfully'
);
select lives_ok(
$$
insert into posts (title, content, org_id, author_id, status)
select 'Post 2', 'Content 2', id, auth.uid(), 'draft'
from organizations where slug = 'free-org' limit 1
$$,
'Second post creates successfully'
);
select throws_ok(
$$
insert into posts (title, content, org_id, author_id, status)
select 'Post 3', 'Content 3', id, auth.uid(), 'draft'
from organizations where slug = 'free-org' limit 1
$$,
'42501',
'new row violates row-level security policy for table "posts"',
'Cannot exceed free plan post limit'
);
select * from finish();
rollback;
```
## Additional resources
- [Test Helpers Documentation](https://database.dev/basejump/supabase_test_helpers)
- [Test Helpers Reference](https://github.com/usebasejump/supabase-test-helpers)
- [Row Level Security Writing Guide](https://usebasejump.com/blog/testing-on-supabase-with-pgtap)
- [Database.dev Package Registry](https://database.dev)
- [Row Level Security Performance and Best Practices](https://github.com/orgs/supabase/discussions/14576)
@@ -6,19 +6,16 @@ description: 'Deploy Supabase projects on the Fly.io platform'
Fly Postgres databases are deployed on the Fly.io edge network. Fly Postgres is supported in [every region](https://fly.io/docs/reference/regions/) where Fly.io operates.
<Admonition type="note">
<Admonition type="deprecation">
Fly Postgres is currently in private alpha. To request early access, [submit the request form](https://forms.supabase.com/fly-postgres). Do not use it for Production workloads or to process Protected Health Information (PHI).
Fly Postgres is being deprecated on March 14, 2025. Head over to the [announcement](https://github.com/orgs/supabase/discussions/33413) for more information.
</Admonition>
## Quickstart
1. Install the latest version of the [Fly CLI](https://fly.io/docs/hands-on/install-flyctl/)
2. Authenticate via the CLI: `flyctl auth login`
3. Set up a new Fly Postgres application with `flyctl extensions supabase create`. When prompted, enter your Fly organization, app name, and the region to launch the database in.
4. Once the provisioning is complete, the database connection string is displayed. Add this as an environment variable to connect to the database from your Fly applications.
5. Access the Supabase dashboard by running `flyctl extensions supabase dashboard <app-name>`
1. Authenticate via the CLI: `flyctl auth login`
2. Access the Supabase dashboard by running `flyctl extensions supabase dashboard <app-name>`
The full list of CLI commands for Fly Postgres are documented in the [Fly docs](https://fly.io/docs/flyctl/extensions-supabase/).
@@ -40,7 +37,6 @@ In the dashboard, you gain access to several powerful tools, including:
- Table editor: Create, edit and delete tables and columns.
- Log explorer: Inspect real-time logs for your database.
- Postgres upgrades: Easily upgrade your Fly Postgres instance to the latest version.
- Compute upgrades: Scale the compute resources allocated to your database.
## Permissions
@@ -50,14 +46,6 @@ User accounts on Supabase are also created on demand. Every Fly user gets a uniq
Upon launching a Fly Postgres database, the initiating user is granted the owner role within the new Supabase organization. All subsequent users are added with the `developer` role. Roles can be adjusted in the Supabase dashboard as required.
## Pricing
Each Fly user is entitled to one free Fly Postgres database. To create additional databases, upgrade your Supabase organization to a paid plan.
Fly Postgres databases are priced exactly the same as projects launched via Supabase. For detailed pricing information, visit the [Supabase pricing page](https://supabase.com/pricing).
The [usage page](https://supabase.com/dashboard/org/_/usage) details the usage of your Fly Postgres databases. The usage information is sent to Fly and the billing is added to your Fly invoice. Supabase adheres to the Fly billing cycles, which start on the 1st of every month. Any upgrades or downgrades in between are prorated and billed on the 1st of the next month. This can lead to delayed charges. For example, if you upgrade to the Pro Plan in the middle of a month, you will be charged the pro-rated amount on the first of the next month.
## Limitations
When using Fly Postgres, be aware of the current restrictions:
@@ -65,5 +53,4 @@ When using Fly Postgres, be aware of the current restrictions:
- Direct database connections are only supported via IPv6. Read more in the [connecting to your database](#connecting-to-your-database) section.
- [Network restrictions](/docs/guides/platform/network-restrictions) are not supported
- Currently, only the database, Supavisor, and PostgREST are supported. Support for additional Supabase products such as Realtime, Storage, and Auth is planned.
- New projects can only be launched using the Fly CLI and not via the Supabase dashboard.
- The [pg_cron](/docs/guides/database/extensions/pg_cron) extension is not fully supported for Fly projects. Fly projects shut down after 15 minutes of inactivity, but transparently start up when an external request is received. However, this does not apply to pg_cron jobs, which aren’t triggered by external requests. ` pg_cron` jobs don’t run when the database is shut down due to inactivity. We are working on removing this behavior for paid projects.
- The [pg_cron](/docs/guides/database/extensions/pg_cron) extension is not fully supported for Fly projects. Fly projects shut down after 15 minutes of inactivity, but transparently start up when an external request is received. However, this does not apply to pg_cron jobs, which aren’t triggered by external requests. ` pg_cron` jobs don’t run when the database is shut down due to inactivity.
@@ -9,8 +9,6 @@ Our goal at Supabase is to provide a _predictable_ billing system that grows wit
<Admonition type="note">
If you’re using Fly Postgres, see the [Fly Postgres Pricing](/docs/guides/platform/fly-postgres#pricing) section.
If you’re using Vercel Marketplace, see the [Vercel Marketplace Pricing](/docs/guides/platform/vercel-marketplace#pricing) section.
</Admonition>
+20
View File
@@ -138,6 +138,26 @@ for await insertion in insertions {
```
</TabPanel>
<TabPanel id="python" label="Python">
```py
# Initialize the Python client
from supabase import create_client, Client
supabase: Client = create_client(SUPABASE_URL, SUPABASE_ANON_KEY)
# Create a function to handle inserts
def handle_inserts(payload):
print("Change received", payload)
# Listen to inserts
await supabase.channel("todos").on_postgres_changes(
"INSERT", schema="public", table="todos", callback=handle_inserts
).subscribe()
```
</TabPanel>
</Tabs>
Use [subscribe()](/docs/reference/javascript/subscribe) to listen to database changes.
@@ -1,6 +1,7 @@
import { ExternalLink } from 'lucide-react'
import { type SerializeOptions } from 'next-mdx-remote/dist/types'
import { type ReactNode } from 'react'
import ReactMarkdown from 'react-markdown'
import { cn } from 'ui'
@@ -78,9 +79,13 @@ const GuideTemplate = ({ meta, content, children, editLink, mdxOptions }: GuideT
id="sb-docs-guide-main-article"
className="prose max-w-none"
>
<h1 className="mb-0">{meta?.title || 'Supabase Docs'}</h1>
<h1 className="mb-0">
<ReactMarkdown>{meta?.title || 'Supabase Docs'}</ReactMarkdown>
</h1>
{meta?.subtitle && (
<h2 className="mt-3 text-xl text-foreground-light">{meta.subtitle}</h2>
<h2 className="mt-3 text-xl text-foreground-light">
<ReactMarkdown>{meta.subtitle}</ReactMarkdown>
</h2>
)}
<hr className="not-prose border-t-0 border-b my-8" />
<MDXProviderGuides>
+1 -1
View File
@@ -89,7 +89,7 @@ const getGuidesMarkdownInternal = async (slug: string[]) => {
const getGuidesMarkdown = cache_fullProcess_withDevCacheBust(
getGuidesMarkdownInternal,
GUIDES_DIRECTORY,
(filename: string) => JSON.stringify([{ slug: filename.replace(/\.mdx$/, '').split(sep) }])
(filename: string) => JSON.stringify([filename.replace(/\.mdx$/, '').split(sep)])
)
const genGuidesStaticParams = (directory?: string) => async () => {
+2 -1
View File
@@ -1,7 +1,7 @@
import { ArrowDown, Check, X } from 'lucide-react'
import Link from 'next/link'
import { Button, Image } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { Admonition, type AdmonitionProps } from 'ui-patterns/admonition'
import { GlassPanel } from 'ui-patterns/GlassPanel'
import { IconPanel } from 'ui-patterns/IconPanel'
import SqlToRest from 'ui-patterns/SqlToRest'
@@ -46,6 +46,7 @@ const components = {
Accordion,
AccordionItem,
Admonition,
// Admonition: (props: AdmonitionProps) => <Admonition className="[&_a]:underline" {...props} />,
AuthErrorCodesTable,
AuthRateLimits,
AuthSmsProviderConfig,
+4
View File
@@ -115,6 +115,10 @@ const nextConfig = {
},
],
},
{
source: '/favicon/:slug*',
headers: [{ key: 'cache-control', value: 'public, max-age=86400' }],
},
]
},
+1 -1
View File
@@ -4501,7 +4501,7 @@
"type": "string",
"enum": ["internal", "alpha", "beta", "ga", "withdrawn", "preview"]
},
"postgres_engine": { "type": "string", "enum": ["13", "14", "15", "17-oriole"] }
"postgres_engine": { "type": "string", "enum": ["13", "14", "15", "17", "17-oriole"] }
},
"required": ["version", "release_channel", "postgres_engine"]
},
+2 -2
View File
@@ -1,7 +1,7 @@
clispec: '001'
info:
id: cli
version: 2.9.6
version: 2.12.0
title: Supabase CLI
language: sh
source: https://github.com/supabase/cli
@@ -3006,7 +3006,7 @@ commands:
Linked project is up to date.
- id: self-hosted
name: Self hosted
code: supabase db push --db-url "postgres://user:pass@127.0.0.1:5432/postgres"
code: supabase db push --db-url "postgres://user.tenant:pass@127.0.0.1:5432"
response: |
Pushing migration 20230410135622_create_employees_table.sql...
Finished supabase db push.
+1 -1
View File
@@ -6055,7 +6055,7 @@ functions:
```ts
const { data, error } = await supabase
.from('characters')
.select('id', 'name')
.select('id, name')
.order('id', { ascending: false })
```
data:
+1 -1
View File
@@ -2883,7 +2883,7 @@ functions:
isSpotlight: true
code: |
```kotlin
val supabase = createSupabaseClient(supabaseURL = "https://xyzcompany.supabase.co'", supabaseKey = "public-anon-key") { ... }
val supabase = createSupabaseClient(supabaseUrl = "https://xyzcompany.supabase.co'", supabaseKey = "public-anon-key") { ... }
val auth = supabase.auth
```
- id: sign-up
@@ -8234,7 +8234,7 @@
},
"postgres_engine": {
"type": "string",
"enum": ["13", "14", "15", "17-oriole"]
"enum": ["13", "14", "15", "17", "17-oriole"]
}
},
"required": ["version", "release_channel", "postgres_engine"]
@@ -14521,7 +14521,7 @@
},
"postgres_engine": {
"type": "string",
"enum": ["13", "14", "15", "17-oriole"]
"enum": ["13", "14", "15", "17", "17-oriole"]
}
},
"required": ["version", "release_channel", "postgres_engine"]
@@ -14543,7 +14543,7 @@
},
"postgres_engine": {
"type": "string",
"enum": ["13", "14", "15", "17-oriole"]
"enum": ["13", "14", "15", "17", "17-oriole"]
}
},
"required": ["version", "release_channel", "postgres_engine"]
+2 -1
View File
@@ -10,7 +10,8 @@ module.exports = config({
'./layouts/**/*.tsx',
'./pages/**/*.{tsx,mdx}',
'./../../packages/ui/src/**/*.{tsx,ts,js}',
'./../../packages/ui-patterns/!(node_modules)/**/*.{tsx,ts,js}',
'./../../packages/ui-patterns/**/*.{tsx,ts,js}',
'!./../../packages/ui-patterns/node_modules/**/*', // ignore node_modules rule
],
plugins: [
function ({ addUtilities, addVariant }) {
+2 -1
View File
@@ -11,7 +11,6 @@
FROM node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
# Fixes issues with Sentry CLI and SSL certificates during build
# TODO: Git is added because it's needed to build libpg, remove it once they publish a binary on the S3 bucket
@@ -24,6 +23,8 @@ RUN apt-get update -qq && \
rm -rf /var/lib/apt/lists/* && \
update-ca-certificates
RUN npm install -g pnpm@9.15.5
WORKDIR /app
# Prune unneeded dependencies with turbo (from apps/ for example)
@@ -1,36 +1,22 @@
import { useMonaco } from '@monaco-editor/react'
import { useTheme } from 'next-themes'
import { PropsWithChildren, useMemo } from 'react'
import { PropsWithChildren } from 'react'
import { ClockSkewBanner } from 'components/layouts/AppLayout/ClockSkewBanner'
import IncidentBanner from 'components/layouts/AppLayout/IncidentBanner'
import { NoticeBanner } from 'components/layouts/AppLayout/NoticeBanner'
import { RestrictionBanner } from 'components/layouts/AppLayout/RestrictionBanner'
import { getTheme } from 'components/ui/CodeEditor/CodeEditor.utils'
import { useFlag } from 'hooks/ui/useFlag'
import { useProfile } from 'lib/profile'
const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => {
const monaco = useMonaco()
const { profile } = useProfile()
const { resolvedTheme } = useTheme()
const ongoingIncident = useFlag('ongoingIncident')
const showNoticeBanner = useFlag('showNoticeBanner')
const clockSkewBanner = useFlag('clockSkewBanner')
// Define the supabase theme for Monaco before anything is rendered. Using useEffect would sometime load the theme
// after the editor was loaded, so it looked off. useMemo will always be run before rendering
useMemo(() => {
if (monaco && resolvedTheme) {
const mode: any = getTheme(resolvedTheme)
monaco.editor.defineTheme('supabase', mode)
}
}, [resolvedTheme, monaco])
return (
<div className="min-h-full flex flex-col">
<div className="flex-none">
<div className="flex flex-col">
<div className="flex-shrink-0">
{ongoingIncident && <IncidentBanner />}
{showNoticeBanner && <NoticeBanner />}
{profile !== undefined && <RestrictionBanner />}
@@ -0,0 +1,44 @@
import { useMonaco } from '@monaco-editor/react'
import { useTheme } from 'next-themes'
import { useMemo } from 'react'
const getTheme = (theme: string) => {
const isDarkMode = theme.includes('dark')
// [TODO] Probably need better theming for light mode
return {
base: isDarkMode ? ('vs-dark' as const) : ('vs' as const), // can also be vs-dark or hc-black
inherit: true, // can also be false to completely replace the builtin rules
rules: [
{ token: '', background: isDarkMode ? '1f1f1f' : 'f0f0f0' },
{
token: '',
background: isDarkMode ? '1f1f1f' : 'f0f0f0',
foreground: isDarkMode ? 'd4d4d4' : '444444',
},
{ token: 'string.sql', foreground: '24b47e' },
{ token: 'comment', foreground: '666666' },
{ token: 'predefined.sql', foreground: isDarkMode ? 'D4D4D4' : '444444' },
],
colors: { 'editor.background': isDarkMode ? '#1f1f1f' : '#f0f0f0' },
}
}
/**
* This component is used to set the theme for the Monaco editor. This would be a hook but it needs to be placed between
* ThemeProvider and the layout page so a component is the most convenient way to do this.
*/
export const MonacoThemeProvider = () => {
const monaco = useMonaco()
const { resolvedTheme } = useTheme()
// Define the supabase theme for Monaco before anything is rendered. Using useEffect would sometime load the theme
// after the editor was loaded, so it looked off. useMemo will always be run before rendering
useMemo(() => {
if (monaco && resolvedTheme) {
const mode = getTheme(resolvedTheme)
monaco.editor.defineTheme('supabase', mode)
}
}, [resolvedTheme, monaco])
return null
}
@@ -1,7 +1,6 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useEffect } from 'react'
import { toast } from 'sonner'
import { boolean, number, object, string } from 'yup'
import { number, object } from 'yup'
import { useParams } from 'common'
import { FormActions } from 'components/ui/Forms/FormActions'
@@ -16,7 +15,6 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { IS_PLATFORM } from 'lib/constants'
import FormField from '../AuthProvidersForm/FormField'
import {
AlertDescription_Shadcn_,
@@ -24,62 +22,15 @@ import {
Alert_Shadcn_,
Form,
InputNumber,
Toggle,
WarningIcon,
} from 'ui'
const schema = object({
JWT_EXP: number()
.max(604800, 'Must be less than 604800')
.required('Must have a JWT expiry value'),
REFRESH_TOKEN_ROTATION_ENABLED: boolean().required(),
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: number()
.min(0, 'Must be a value more than 0')
.required('Must have a Reuse Interval value'),
MFA_PHONE_OTP_LENGTH: number()
.min(6, 'Must be a value 6 or larger')
.max(30, 'must be a value no greater than 30'),
MFA_PHONE_TEMPLATE: string().required('SMS template is required.'),
MFA_MAX_ENROLLED_FACTORS: number()
.min(0, 'Must be be a value more than 0')
.max(30, 'Must be a value no greater than 30'),
DB_MAX_POOL_SIZE: number()
.min(1, 'Must be 1 or larger')
.max(200, 'Must be a value no greater than 200'),
API_MAX_REQUEST_DURATION: number()
.min(5, 'Must be 5 or larger')
.max(30, 'Must be a value no greater than 30'),
MFA_TOTP: string().required(),
MFA_PHONE: string().required(),
})
function determineMFAStatus(verifyEnabled: boolean, enrollEnabled: boolean) {
return verifyEnabled ? (enrollEnabled ? 'Enabled' : 'Verify Enabled') : 'Disabled'
}
const MFAFactorSelectionOptions = [
{
label: 'Enabled',
value: 'Enabled',
},
{
label: 'Verify Enabled',
value: 'Verify Enabled',
},
{
label: 'Disabled',
value: 'Disabled',
},
]
const MfaStatusToState = (status: (typeof MFAFactorSelectionOptions)[number]['value']) => {
return status === 'Enabled'
? { verifyEnabled: true, enrollEnabled: true }
: status === 'Verify Enabled'
? { verifyEnabled: true, enrollEnabled: false }
: { verifyEnabled: false, enrollEnabled: false }
}
const AdvancedAuthSettingsForm = () => {
const { ref: projectRef } = useParams()
const {
@@ -102,72 +53,16 @@ const AdvancedAuthSettingsForm = () => {
const isTeamsEnterprisePlan =
isSuccessSubscription && subscription.plan.id !== 'free' && subscription.plan.id !== 'pro'
const isProPlanAndUp = isSuccessSubscription && subscription?.plan?.id !== 'free'
const promptProPlanUpgrade = IS_PLATFORM && !isProPlanAndUp
const projectAddons = subscription?.project_addons.find((addon) => addon.ref === projectRef)
const hasPurchasedAuthMFAAddOn = projectAddons?.addons.some(
(addon) => addon.type === 'auth_mfa_phone'
)
const promptTeamsEnterpriseUpgrade = IS_PLATFORM && !isTeamsEnterprisePlan
const INITIAL_VALUES = {
SITE_URL: authConfig?.SITE_URL,
JWT_EXP: authConfig?.JWT_EXP,
REFRESH_TOKEN_ROTATION_ENABLED: authConfig?.REFRESH_TOKEN_ROTATION_ENABLED || false,
MFA_PHONE_OTP_LENGTH: authConfig?.MFA_PHONE_OTP_LENGTH || 6,
MFA_PHONE_TEMPLATE: authConfig?.MFA_PHONE_TEMPLATE || 'Your code is {{ .Code }}',
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: authConfig?.SECURITY_REFRESH_TOKEN_REUSE_INTERVAL,
MFA_MAX_ENROLLED_FACTORS: authConfig?.MFA_MAX_ENROLLED_FACTORS || 10,
DB_MAX_POOL_SIZE: authConfig?.DB_MAX_POOL_SIZE || 10,
API_MAX_REQUEST_DURATION: authConfig?.API_MAX_REQUEST_DURATION || 10,
// TOTP is enabled by default. Auth environment variables are distinct from UI state - we use MFA_TOTP and MFA_PHONE to hold the derivedUI state.
// MFA_TOTP_VERIFY_ENABLED and MFA_TOTP_ENROLL_ENABLED -> Enabled
// MFA_TOTP_VERIFY_ENABLED and !MFA_TOTP_ENROLL_ENABLED -> Verify Enabled
// !MFA_TOTP_VERIFY_ENABLED and !MFA_TOTP_ENROLL_ENABLED -> Disabled
MFA_TOTP:
determineMFAStatus(
authConfig?.MFA_TOTP_VERIFY_ENABLED ?? true,
authConfig?.MFA_TOTP_ENROLL_ENABLED ?? true
) || 'Enabled',
MFA_PHONE:
determineMFAStatus(
authConfig?.MFA_PHONE_VERIFY_ENABLED || false,
authConfig?.MFA_PHONE_ENROLL_ENABLED || false
) || 'Disabled',
}
// For now, we support Twilio and Vonage. Twilio Verify is not supported and the remaining providers are community maintained.
const sendSMSHookIsEnabled =
authConfig?.HOOK_SEND_SMS_URI !== null && authConfig?.HOOK_SEND_SMS_ENABLED === true
const hasValidMFAPhoneProvider = authConfig?.EXTERNAL_PHONE_ENABLED === true
const hasValidMFAProvider = hasValidMFAPhoneProvider || sendSMSHookIsEnabled
const onSubmit = (values: any, { resetForm }: any) => {
let payload = { ...values }
const { verifyEnabled: MFA_TOTP_VERIFY_ENABLED, enrollEnabled: MFA_TOTP_ENROLL_ENABLED } =
MfaStatusToState(values.MFA_TOTP)
// MFA (Phone) is only available on Pro Plans and up. We translate the UI state, MFA_PHONE and MFA_TOTP into the underlying
// Auth config state - MFA_PHONE_*_ENABLED and MFA_TOTP_*_ENABLED.
if (isProPlanAndUp) {
const { verifyEnabled: MFA_PHONE_VERIFY_ENABLED, enrollEnabled: MFA_PHONE_ENROLL_ENABLED } =
MfaStatusToState(values.MFA_PHONE)
payload = {
...payload,
MFA_PHONE_ENROLL_ENABLED,
MFA_PHONE_VERIFY_ENABLED,
}
}
payload = {
...payload,
MFA_TOTP_ENROLL_ENABLED,
MFA_TOTP_VERIFY_ENABLED,
}
delete payload.MFA_TOTP
delete payload.MFA_PHONE
const payload = { ...values }
if (!isTeamsEnterprisePlan) {
delete payload.DB_MAX_POOL_SIZE
delete payload.API_MAX_REQUEST_DURATION
}
@@ -201,27 +96,11 @@ const AdvancedAuthSettingsForm = () => {
return (
<Form id={formId} initialValues={INITIAL_VALUES} onSubmit={onSubmit} validationSchema={schema}>
{({ handleReset, resetForm, values, initialValues, setFieldValue }: any) => {
{({ handleReset, values, initialValues }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
// Form is reset once remote data is loaded in store
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (isSuccess) resetForm({ values: INITIAL_VALUES, initialValues: INITIAL_VALUES })
}, [isSuccess])
const hasUpgradedPhoneMFA =
INITIAL_VALUES.MFA_PHONE === 'Disabled' && values.MFA_PHONE !== INITIAL_VALUES.MFA_PHONE
const phoneMFAIsEnabled =
values.MFA_PHONE === 'Enabled' || values.MFA_PHONE === 'Verify Enabled'
return (
<>
<FormHeader
title="Advanced Settings"
description="These settings rarely need to be changed."
/>
<FormPanel
disabled={true}
footer={
@@ -241,151 +120,6 @@ const AdvancedAuthSettingsForm = () => {
</div>
}
>
<FormSection header={<FormSectionLabel>Access Tokens (JWT)</FormSectionLabel>}>
<FormSectionContent loading={isLoading}>
<InputNumber
id="JWT_EXP"
size="small"
label="Access token (JWT) expiry time"
descriptionText="How long access tokens are valid for before a refresh token has to be used. Recommendation: 3600 (1 hour)."
actions={<span className="mr-3 text-foreground-lighter">seconds</span>}
disabled={!canUpdateConfig}
/>
</FormSectionContent>
</FormSection>
<FormSection header={<FormSectionLabel>Refresh Tokens</FormSectionLabel>}>
<FormSectionContent loading={isLoading}>
<Toggle
id="REFRESH_TOKEN_ROTATION_ENABLED"
size="small"
label="Detect and revoke potentially compromised refresh tokens"
layout="flex"
descriptionText="Prevent replay attacks from potentially compromised refresh tokens. Recommendation: ON."
disabled={!canUpdateConfig}
/>
{values.REFRESH_TOKEN_ROTATION_ENABLED && (
<InputNumber
id="SECURITY_REFRESH_TOKEN_REUSE_INTERVAL"
size="small"
min={0}
label="Refresh token reuse interval"
descriptionText="Time interval where the same refresh token can be used multiple times to request for an access token. Recommendation: 10 seconds."
actions={<span className="mr-3 text-foreground-lighter">seconds</span>}
disabled={!canUpdateConfig}
/>
)}
</FormSectionContent>
</FormSection>
<FormSection
header={<FormSectionLabel>Multi-Factor Authentication (MFA)</FormSectionLabel>}
>
<FormSectionContent loading={isLoading}>
<FormField
name="MFA_TOTP"
properties={{
type: 'select',
title: 'TOTP (App Authenticator)',
description: 'Control use of TOTP (App Authenticator) factors',
enum: MFAFactorSelectionOptions,
}}
formValues={values}
disabled={!canUpdateConfig}
setFieldValue={setFieldValue}
/>
<InputNumber
id="MFA_MAX_ENROLLED_FACTORS"
size="small"
label="Maximum number of per-user MFA factors"
descriptionText="How many MFA factors can be enrolled at once per user."
actions={<span className="mr-3 text-foreground-lighter">factors</span>}
disabled={!canUpdateConfig}
/>
</FormSectionContent>
</FormSection>
<FormSection header={<FormSectionLabel>Advanced MFA</FormSectionLabel>}>
<FormSectionContent loading={isLoading}>
{promptProPlanUpgrade && (
<UpgradeToPro
primaryText="Upgrade to Pro"
secondaryText="Advanced MFA requires the Pro Plan"
/>
)}
<FormField
name="MFA_PHONE"
properties={{
type: 'select',
title: 'Phone',
description: 'Control use of phone factors',
enum: MFAFactorSelectionOptions,
}}
formValues={values}
disabled={!canUpdateConfig || !isProPlanAndUp}
setFieldValue={setFieldValue}
/>
{!hasValidMFAProvider && phoneMFAIsEnabled && (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>
To use MFA with Phone you should set up a Phone provider or Send SMS Hook.
</AlertTitle_Shadcn_>
</Alert_Shadcn_>
)}
<InputNumber
id="MFA_PHONE_OTP_LENGTH"
size="small"
label="Phone OTP Length"
descriptionText="Number of digits in OTP"
disabled={!canUpdateConfig || !isProPlanAndUp}
/>
<FormField
name="MFA_PHONE_TEMPLATE"
properties={{
title: 'Phone verification message',
type: 'multiline-string',
description: 'To format the OTP code use `{{ .Code }}`',
}}
formValues={values}
disabled={!canUpdateConfig || !isProPlanAndUp}
setFieldValue={setFieldValue}
/>
{hasUpgradedPhoneMFA && (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>
Enabling advanced MFA with phone will result in an additional charge of $75
per month for the first project in the organization and an additional $10
per month for additional projects.
</AlertTitle_Shadcn_>
</Alert_Shadcn_>
)}
</FormSectionContent>
</FormSection>
<FormSection
header={<FormSectionLabel>Max Direct Database Connections</FormSectionLabel>}
>
<FormSectionContent loading={isLoading}>
{promptTeamsEnterpriseUpgrade && (
<UpgradeToPro
primaryText="Upgrade to Team or Enterprise"
secondaryText="Max Direct Database Connections settings are only available on the Team Plan and up."
buttonText="Upgrade to Team"
/>
)}
<InputNumber
id="DB_MAX_POOL_SIZE"
size="small"
label="Max direct database connections used by Auth"
descriptionText="Auth will take up no more than this number of connections from the total number of available connections to serve requests. These connections are not reserved, so when unused they are released."
actions={<span className="mr-3 text-foreground-lighter">connections</span>}
disabled={!canUpdateConfig || !isTeamsEnterprisePlan}
/>
</FormSectionContent>
</FormSection>
<FormSection header={<FormSectionLabel>Max Request Duration</FormSectionLabel>}>
<FormSectionContent loading={isLoading}>
{promptTeamsEnterpriseUpgrade && (
@@ -41,3 +41,6 @@ export function urlRegex(
'i'
)
}
// Use a const string to represent no chars option. Represented as empty string on the backend side.
export const NO_REQUIRED_CHARACTERS = 'NO_REQUIRED_CHARS'
@@ -6,6 +6,7 @@ import ReactMarkdown from 'react-markdown'
import { toast } from 'sonner'
import { useParams } from 'common'
import { Markdown } from 'components/interfaces/Markdown'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { DocsButton } from 'components/ui/DocsButton'
import type { components } from 'data/api'
@@ -24,11 +25,11 @@ import {
Input,
WarningIcon,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { NO_REQUIRED_CHARACTERS } from '../Auth.constants'
import { ProviderCollapsibleClasses } from './AuthProvidersForm.constants'
import type { Provider } from './AuthProvidersForm.types'
import FormField from './FormField'
import { Markdown } from 'components/interfaces/Markdown'
import { Admonition } from 'ui-patterns'
export interface ProviderFormProps {
config: components['schemas']['GoTrueConfigResponse']
@@ -175,6 +176,11 @@ const ProviderForm = ({ config, provider }: ProviderFormProps) => {
if (payload[x] === '') payload[x] = null
})
// The backend uses empty string to represent no required characters in the password
if (payload.PASSWORD_REQUIRED_CHARACTERS === NO_REQUIRED_CHARACTERS) {
payload.PASSWORD_REQUIRED_CHARACTERS = ''
}
updateAuthConfig(
{ projectRef: projectRef!, config: payload },
{
@@ -1,6 +1,6 @@
import { boolean, number, object, string } from 'yup'
import { urlRegex } from 'components/interfaces/Auth/Auth.constants'
import { NO_REQUIRED_CHARACTERS, urlRegex } from 'components/interfaces/Auth/Auth.constants'
import { ProjectAuthConfigData } from 'data/auth/auth-config-query'
import { boolean, number, object, string } from 'yup'
const parseBase64URL = (b64url: string) => {
return atob(b64url.replace(/[-]/g, '+').replace(/[_]/g, '/'))
@@ -36,6 +36,44 @@ const PROVIDER_EMAIL = {
If disabled, a user can change their password at any time.`,
type: 'boolean',
},
PASSWORD_HIBP_ENABLED: {
title: 'Prevent use of leaked passwords',
description:
'Rejects the use of known or easy to guess passwords on sign up or password change. Powered by the HaveIBeenPwned.org Pwned Passwords API.',
type: 'boolean',
},
PASSWORD_MIN_LENGTH: {
title: 'Minimum password length',
type: 'number',
description:
'Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.',
units: 'characters',
},
PASSWORD_REQUIRED_CHARACTERS: {
type: 'select',
title: 'Password Requirements',
description: 'Passwords that do not have at least one of each will be rejected as weak.',
enum: [
{
label: 'No required characters (default)',
value: NO_REQUIRED_CHARACTERS,
},
{
label: 'Letters and digits',
value: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789',
},
{
label: 'Lowercase, uppercase letters and digits',
value: 'abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789',
},
{
label: 'Lowercase, uppercase letters, digits and symbols (recommended)',
value:
'abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};\'\\\\:"|<>?,./`~',
},
],
},
MAILER_OTP_EXP: {
title: 'Email OTP Expiration',
type: 'number',
@@ -55,6 +93,9 @@ const PROVIDER_EMAIL = {
.max(86400, 'Must be no more than 86400')
.required('This is required'),
MAILER_OTP_LENGTH: number().min(6, 'Must be at least 6').max(10, 'Must be no more than 10'),
PASSWORD_MIN_LENGTH: number()
.min(6, 'Must be greater or equal to 6.')
.required('This is required'),
}),
misc: {
iconKey: 'email-icon2',
@@ -1,77 +1,38 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { ExternalLink, Eye, EyeOff } from 'lucide-react'
import { ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { toast } from 'sonner'
import { boolean, number, object, string } from 'yup'
import { boolean, object, string } from 'yup'
import { useParams } from 'common'
import { Markdown } from 'components/interfaces/Markdown'
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import NoPermission from 'components/ui/NoPermission'
import UpgradeToPro from 'components/ui/UpgradeToPro'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { IS_PLATFORM } from 'lib/constants'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
Form,
Input,
InputNumber,
Toggle,
WarningIcon,
} from 'ui'
import FormField from '../AuthProvidersForm/FormField'
// Use a const string to represent no chars option. Represented as empty string on the backend side.
const NO_REQUIRED_CHARACTERS = 'NO_REQUIRED_CHARS'
const LETTERS_AND_DIGITS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789'
const LOWER_UPPER_DIGITS = 'abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789'
const LOWER_UPPER_DIGITS_SYMBOLS = LOWER_UPPER_DIGITS + ':!@#$%^&*()_+-=[]{};\'\\\\:"|<>?,./`~'
import { NO_REQUIRED_CHARACTERS } from '../Auth.constants'
const schema = object({
DISABLE_SIGNUP: boolean().required(),
EXTERNAL_ANONYMOUS_USERS_ENABLED: boolean().required(),
SECURITY_MANUAL_LINKING_ENABLED: boolean().required(),
SITE_URL: string().required('Must have a Site URL'),
SECURITY_CAPTCHA_ENABLED: boolean().required(),
SECURITY_CAPTCHA_SECRET: string().when('SECURITY_CAPTCHA_ENABLED', {
is: true,
then: (schema) => schema.required('Must have a Captcha secret'),
}),
SECURITY_CAPTCHA_PROVIDER: string().when('SECURITY_CAPTCHA_ENABLED', {
is: true,
then: (schema) =>
schema
.oneOf(['hcaptcha', 'turnstile'])
.required('Captcha provider must be either hcaptcha or turnstile'),
}),
SESSIONS_TIMEBOX: number().min(0, 'Must be a positive number'),
SESSIONS_INACTIVITY_TIMEOUT: number().min(0, 'Must be a positive number'),
SESSIONS_SINGLE_PER_USER: boolean(),
PASSWORD_MIN_LENGTH: number().min(6, 'Must be greater or equal to 6.'),
PASSWORD_REQUIRED_CHARACTERS: string(),
PASSWORD_HIBP_ENABLED: boolean(),
})
function HoursOrNeverText({ value }: { value: number }) {
if (value === 0) {
return 'never'
} else if (value === 1) {
return 'hour'
} else {
return 'hours'
}
}
const formId = 'auth-config-basic-settings'
const BasicAuthSettingsForm = () => {
@@ -85,36 +46,14 @@ const BasicAuthSettingsForm = () => {
} = useAuthConfigQuery({ projectRef })
const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
const [hidden, setHidden] = useState(true)
const canReadConfig = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
const organization = useSelectedOrganization()
const { data: subscription, isSuccess: isSuccessSubscription } = useOrgSubscriptionQuery(
{
orgSlug: organization?.slug,
},
{ enabled: IS_PLATFORM }
)
const isProPlanAndUp = isSuccessSubscription && subscription?.plan?.id !== 'free'
const promptProPlanUpgrade = IS_PLATFORM && !isProPlanAndUp
const INITIAL_VALUES = {
DISABLE_SIGNUP: !authConfig?.DISABLE_SIGNUP,
EXTERNAL_ANONYMOUS_USERS_ENABLED: authConfig?.EXTERNAL_ANONYMOUS_USERS_ENABLED,
SECURITY_MANUAL_LINKING_ENABLED: authConfig?.SECURITY_MANUAL_LINKING_ENABLED || false,
SITE_URL: authConfig?.SITE_URL,
SECURITY_CAPTCHA_ENABLED: authConfig?.SECURITY_CAPTCHA_ENABLED || false,
SECURITY_CAPTCHA_SECRET: authConfig?.SECURITY_CAPTCHA_SECRET || '',
SECURITY_CAPTCHA_PROVIDER: authConfig?.SECURITY_CAPTCHA_PROVIDER || 'hcaptcha',
SESSIONS_TIMEBOX: authConfig?.SESSIONS_TIMEBOX || 0,
SESSIONS_INACTIVITY_TIMEOUT: authConfig?.SESSIONS_INACTIVITY_TIMEOUT || 0,
SESSIONS_SINGLE_PER_USER: authConfig?.SESSIONS_SINGLE_PER_USER || false,
PASSWORD_MIN_LENGTH: authConfig?.PASSWORD_MIN_LENGTH || 6,
PASSWORD_REQUIRED_CHARACTERS:
authConfig?.PASSWORD_REQUIRED_CHARACTERS || NO_REQUIRED_CHARACTERS,
PASSWORD_HIBP_ENABLED: authConfig?.PASSWORD_HIBP_ENABLED || false,
}
const onSubmit = (values: any, { resetForm }: any) => {
@@ -154,114 +93,125 @@ const BasicAuthSettingsForm = () => {
}
return (
<Form id={formId} initialValues={INITIAL_VALUES} onSubmit={onSubmit} validationSchema={schema}>
{({ handleReset, resetForm, values, initialValues, setFieldValue }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
// Form is reset once remote data is loaded in store
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (isSuccess) resetForm({ values: INITIAL_VALUES, initialValues: INITIAL_VALUES })
}, [isSuccess])
<div>
<FormHeader title="General" />
return (
<>
<FormPanel
disabled={true}
footer={
<div className="flex py-4 px-8">
<FormActions
form={formId}
isSubmitting={isUpdatingConfig}
hasChanges={hasChanges}
handleReset={handleReset}
disabled={!canUpdateConfig}
helper={
!canUpdateConfig
? 'You need additional permissions to update authentication settings'
: undefined
}
/>
</div>
}
>
<FormSection header={<FormSectionLabel>User Signups</FormSectionLabel>}>
<FormSectionContent loading={isLoading}>
<Toggle
id="DISABLE_SIGNUP"
size="small"
label="Allow new users to sign up"
layout="flex"
descriptionText="If this is disabled, new users will not be able to sign up to your application."
disabled={!canUpdateConfig}
/>
<Toggle
id="SECURITY_MANUAL_LINKING_ENABLED"
size="small"
label="Allow manual linking"
layout="flex"
descriptionText={
<Markdown
extLinks
className="[&>p>a]:text-foreground-light [&>p>a]:transition-all [&>p>a]:hover:text-foreground [&>p>a]:hover:decoration-brand"
content="Enable [manual linking APIs](https://supabase.com/docs/guides/auth/auth-identity-linking#manual-linking-beta) for your project."
/>
}
disabled={!canUpdateConfig}
/>
<Toggle
id="EXTERNAL_ANONYMOUS_USERS_ENABLED"
size="small"
label="Allow anonymous sign-ins"
layout="flex"
descriptionText={
<Markdown
extLinks
className="[&>p>a]:text-foreground-light [&>p>a]:transition-all [&>p>a]:hover:text-foreground [&>p>a]:hover:decoration-brand"
content="Enable [anonymous sign-ins](https://supabase.com/docs/guides/auth/auth-anonymous) for your project."
/>
}
disabled={!canUpdateConfig}
/>
{values.EXTERNAL_ANONYMOUS_USERS_ENABLED && (
<div className="flex flex-col gap-y-2">
<Alert_Shadcn_
className="flex w-full items-center justify-between"
variant="warning"
>
<WarningIcon />
<div>
<AlertTitle_Shadcn_>
Anonymous users will use the{' '}
<code className="text-xs">authenticated</code> role when signing in
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="flex flex-col gap-y-3">
<p>
As a result, anonymous users will be subjected to RLS policies that
apply to the <code className="text-xs">public</code> and{' '}
<code className="text-xs">authenticated</code> roles. We strongly
advise{' '}
<Link
href={`/project/${projectRef}/auth/policies`}
className="text-foreground underline"
<Form
id={formId}
initialValues={INITIAL_VALUES}
onSubmit={onSubmit}
validationSchema={schema}
>
{({ handleReset, resetForm, values, initialValues, setFieldValue }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
// Form is reset once remote data is loaded in store
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (isSuccess) resetForm({ values: INITIAL_VALUES, initialValues: INITIAL_VALUES })
}, [isSuccess])
return (
<>
<FormPanel
disabled={true}
footer={
<div className="flex py-4 px-8">
<FormActions
form={formId}
isSubmitting={isUpdatingConfig}
hasChanges={hasChanges}
handleReset={handleReset}
disabled={!canUpdateConfig}
helper={
!canUpdateConfig
? 'You need additional permissions to update authentication settings'
: undefined
}
/>
</div>
}
>
<FormSection header={<FormSectionLabel>User Signups</FormSectionLabel>}>
<FormSectionContent loading={isLoading}>
<Toggle
id="DISABLE_SIGNUP"
size="small"
label="Allow new users to sign up"
layout="flex"
descriptionText="If this is disabled, new users will not be able to sign up to your application."
disabled={!canUpdateConfig}
/>
<Toggle
id="SECURITY_MANUAL_LINKING_ENABLED"
size="small"
label="Allow manual linking"
layout="flex"
descriptionText={
<Markdown
extLinks
className="[&>p>a]:text-foreground-light [&>p>a]:transition-all [&>p>a]:hover:text-foreground [&>p>a]:hover:decoration-brand"
content="Enable [manual linking APIs](https://supabase.com/docs/guides/auth/auth-identity-linking#manual-linking-beta) for your project."
/>
}
disabled={!canUpdateConfig}
/>
<Toggle
id="EXTERNAL_ANONYMOUS_USERS_ENABLED"
size="small"
label="Allow anonymous sign-ins"
layout="flex"
descriptionText={
<Markdown
extLinks
className="[&>p>a]:text-foreground-light [&>p>a]:transition-all [&>p>a]:hover:text-foreground [&>p>a]:hover:decoration-brand"
content="Enable [anonymous sign-ins](https://supabase.com/docs/guides/auth/auth-anonymous) for your project."
/>
}
disabled={!canUpdateConfig}
/>
{values.EXTERNAL_ANONYMOUS_USERS_ENABLED && (
<div className="flex flex-col gap-y-2">
<Alert_Shadcn_
className="flex w-full items-center justify-between"
variant="warning"
>
<WarningIcon />
<div>
<AlertTitle_Shadcn_>
Anonymous users will use the{' '}
<code className="text-xs">authenticated</code> role when signing in
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="flex flex-col gap-y-3">
<p>
As a result, anonymous users will be subjected to RLS policies that
apply to the <code className="text-xs">public</code> and{' '}
<code className="text-xs">authenticated</code> roles. We strongly
advise{' '}
<Link
href={`/project/${projectRef}/auth/policies`}
className="text-foreground underline"
>
reviewing your RLS policies
</Link>{' '}
to ensure that access to your data is restricted where required.
</p>
<Button
asChild
type="default"
className="w-min"
icon={<ExternalLink />}
>
reviewing your RLS policies
</Link>{' '}
to ensure that access to your data is restricted where required.
</p>
<Button
asChild
type="default"
className="w-min"
icon={<ExternalLink />}
>
<Link href="https://supabase.com/docs/guides/auth/auth-anonymous#access-control">
View access control docs
</Link>
</Button>
</AlertDescription_Shadcn_>
</div>
</Alert_Shadcn_>
{!values.SECURITY_CAPTCHA_ENABLED && (
<Link href="https://supabase.com/docs/guides/auth/auth-anonymous#access-control">
View access control docs
</Link>
</Button>
</AlertDescription_Shadcn_>
</div>
</Alert_Shadcn_>
</div>
)}
{!authConfig?.SECURITY_CAPTCHA_ENABLED &&
values.EXTERNAL_ANONYMOUS_USERS_ENABLED && (
<Alert_Shadcn_>
<WarningIcon />
<AlertTitle_Shadcn_>
@@ -284,173 +234,14 @@ const BasicAuthSettingsForm = () => {
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
</div>
)}
</FormSectionContent>
</FormSection>
<FormSection header={<FormSectionLabel>Passwords</FormSectionLabel>}>
<FormSectionContent loading={isLoading}>
<InputNumber
id="PASSWORD_MIN_LENGTH"
size="small"
label="Minimum password length"
descriptionText="Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more."
actions={<span className="mr-3 text-foreground-lighter">characters</span>}
disabled={!canUpdateConfig}
/>
<FormField
name="PASSWORD_REQUIRED_CHARACTERS"
properties={{
type: 'select',
title: 'Password Requirements',
description:
'Passwords that do not have at least one of each will be rejected as weak.',
enum: [
{
label: 'No required characters (default)',
value: NO_REQUIRED_CHARACTERS,
},
{
label: 'Letters and digits',
value: LETTERS_AND_DIGITS,
},
{
label: 'Lowercase, uppercase letters and digits',
value: LOWER_UPPER_DIGITS,
},
{
label: 'Lowercase, uppercase letters, digits and symbols (recommended)',
value: LOWER_UPPER_DIGITS_SYMBOLS,
},
],
}}
formValues={values}
setFieldValue={setFieldValue}
/>
{!promptProPlanUpgrade ? (
<></>
) : (
<UpgradeToPro
primaryText="Upgrade to Pro"
secondaryText="Leaked password protection available on Pro plans and up."
/>
)}
<Toggle
id="PASSWORD_HIBP_ENABLED"
size="small"
label="Prevent use of leaked passwords"
afterLabel=" (recommended)"
layout="flex"
descriptionText="Rejects the use of known or easy to guess passwords on sign up or password change. Powered by the HaveIBeenPwned.org Pwned Passwords API."
disabled={!canUpdateConfig || !isProPlanAndUp}
/>
</FormSectionContent>
</FormSection>
<FormSection header={<FormSectionLabel>User Sessions</FormSectionLabel>}>
<FormSectionContent loading={isLoading}>
{!promptProPlanUpgrade ? (
<></>
) : (
<UpgradeToPro
primaryText="Upgrade to Pro"
secondaryText="Configuring user sessions requires the Pro Plan."
/>
)}
<Toggle
id="SESSIONS_SINGLE_PER_USER"
size="small"
label="Enforce single session per user"
layout="flex"
descriptionText="If enabled, all but a user's most recently active session will be terminated."
disabled={!canUpdateConfig || !isProPlanAndUp}
/>
<InputNumber
id="SESSIONS_TIMEBOX"
size="small"
label="Time-box user sessions"
descriptionText="The amount of time before a user is forced to sign in again. Use 0 for never"
actions={
<span className="mr-3 text-foreground-lighter">
<HoursOrNeverText value={values.SESSIONS_TIMEBOX} />
</span>
}
disabled={!canUpdateConfig || !isProPlanAndUp}
/>
<InputNumber
id="SESSIONS_INACTIVITY_TIMEOUT"
size="small"
label="Inactivity timeout"
descriptionText="The amount of time a user needs to be inactive to be forced to sign in again. Use 0 for never."
actions={
<span className="mr-3 text-foreground-lighter">
<HoursOrNeverText value={values.SESSIONS_INACTIVITY_TIMEOUT} />
</span>
}
disabled={!canUpdateConfig || !isProPlanAndUp}
/>
</FormSectionContent>
</FormSection>
<FormSection
id="enable-captcha"
header={<FormSectionLabel>Bot and Abuse Protection</FormSectionLabel>}
>
<FormSectionContent loading={isLoading}>
<Toggle
id="SECURITY_CAPTCHA_ENABLED"
size="small"
label="Enable Captcha protection"
layout="flex"
descriptionText="Protect authentication endpoints from bots and abuse."
disabled={!canUpdateConfig}
/>
{values.SECURITY_CAPTCHA_ENABLED && (
<>
<FormField
name="SECURITY_CAPTCHA_PROVIDER"
properties={{
type: 'select',
title: 'Choose Captcha Provider',
description: '',
enum: [
{
label: 'hCaptcha',
value: 'hcaptcha',
icon: 'hcaptcha-icon.png',
},
{
label: 'Turnstile by Cloudflare',
value: 'turnstile',
icon: 'cloudflare-icon.png',
},
],
}}
formValues={values}
setFieldValue={setFieldValue}
/>
<Input
id="SECURITY_CAPTCHA_SECRET"
type={hidden ? 'password' : 'text'}
size="small"
label="Captcha secret"
descriptionText="Obtain this secret from the provider."
disabled={!canUpdateConfig}
actions={
<Button
icon={hidden ? <Eye /> : <EyeOff />}
type="default"
onClick={() => setHidden(!hidden)}
/>
}
/>
</>
)}
</FormSectionContent>
</FormSection>
</FormPanel>
</>
)
}}
</Form>
</FormSectionContent>
</FormSection>
</FormPanel>
</>
)
}}
</Form>
</div>
)
}
@@ -1,6 +1,7 @@
import Link from 'next/link'
import { useParams } from 'common'
import { InlineLink } from 'components/ui/InlineLink'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
@@ -19,20 +20,14 @@ export function EmailRateLimitsAlert() {
<AlertDescription_Shadcn_>
You're using the built-in email service. The service has rate limits and it's not meant to
be used for production apps. Check the{' '}
{/* [Refactor] Swap for InlineLink component once https://github.com/supabase/supabase/pull/30494 is in */}
<a
href="https://supabase.com/docs/guides/platform/going-into-prod#auth-rate-limits"
className="underline"
target="_blank"
rel="noreferrer noopener"
>
<InlineLink href="https://supabase.com/docs/guides/platform/going-into-prod#auth-rate-limits">
documentation
</a>{' '}
</InlineLink>{' '}
for an up-to-date information on the current rate limits.
<Button asChild type="default" className="mt-2">
<Link target="_blank" href={`/project/${ref}/settings/auth#auth-config-smtp-form`}>
Set up custom SMTP server
</Link>
</AlertDescription_Shadcn_>
<AlertDescription_Shadcn_ className="mt-2">
<Button asChild type="default">
<Link href={`/project/${ref}/auth/smtp`}>Set up custom SMTP server</Link>
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
@@ -33,13 +33,6 @@ const EmailTemplates = () => {
return (
<div className="w-full">
<div className="w-full flex justify-between items-center">
<FormHeader
title="Email Templates"
description="Customize the emails that will be sent out to your users."
docsUrl="https://supabase.com/docs/guides/auth/auth-email-templates"
/>
</div>
{isError && (
<AlertError error={authConfigError} subject="Failed to retrieve auth configuration" />
)}
@@ -49,32 +42,35 @@ const EmailTemplates = () => {
</div>
)}
{isSuccess && (
<FormPanel>
<Tabs_Shadcn_ defaultValue={TEMPLATES_SCHEMAS[0].title.trim().replace(/\s+/g, '-')}>
<TabsList_Shadcn_ className="px-4 md:px-8 pt-2 gap-5 overflow-x-scroll no-scrollbar">
<>
{builtInSMTP ? (
<div className="mb-4">
<EmailRateLimitsAlert />
</div>
) : null}
<FormPanel>
<Tabs_Shadcn_ defaultValue={TEMPLATES_SCHEMAS[0].title.trim().replace(/\s+/g, '-')}>
<TabsList_Shadcn_ className="px-4 md:px-8 pt-2 gap-5 overflow-x-scroll no-scrollbar">
{TEMPLATES_SCHEMAS.map((template) => {
return (
<TabsTrigger_Shadcn_ value={template.title.trim().replace(/\s+/g, '-')}>
{template.title}
</TabsTrigger_Shadcn_>
)
})}
</TabsList_Shadcn_>
{TEMPLATES_SCHEMAS.map((template) => {
const panelId = template.title.trim().replace(/\s+/g, '-')
return (
<TabsTrigger_Shadcn_ value={template.title.trim().replace(/\s+/g, '-')}>
{template.title}
</TabsTrigger_Shadcn_>
<TabsContent_Shadcn_ value={panelId} key={panelId}>
<TemplateEditor key={template.title} template={template} />
</TabsContent_Shadcn_>
)
})}
</TabsList_Shadcn_>
{TEMPLATES_SCHEMAS.map((template) => {
const panelId = template.title.trim().replace(/\s+/g, '-')
return (
<TabsContent_Shadcn_ value={panelId} key={panelId}>
{builtInSMTP ? (
<div className="px-4 md:px-8">
<EmailRateLimitsAlert />
</div>
) : null}
<TemplateEditor key={template.title} template={template} />
</TabsContent_Shadcn_>
)
})}
</Tabs_Shadcn_>
</FormPanel>
</Tabs_Shadcn_>
</FormPanel>
</>
)}
</div>
)
@@ -166,7 +166,7 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => {
return (
<>
<FormSection className="!border-t-0 pb-8 pt-4">
<FormSection className="!border-t-0 pb-8 !pt-4">
<FormSectionContent loading={false}>
{Object.keys(properties).map((x: string) => {
const property = properties[x]
@@ -69,11 +69,7 @@ export const HooksListing = () => {
return (
<div className="pb-4">
<FormHeader
title="Auth Hooks"
description="Use Postgres functions or HTTP endpoints to customize the behavior of Supabase Auth to meet your needs."
actions={<AddHookDropdown onSelectHook={setSelectedHook} />}
/>
<FormHeader title="All hooks" actions={<AddHookDropdown onSelectHook={setSelectedHook} />} />
{hooks.filter((h) => isValidHook(h)).length === 0 && (
<div
@@ -0,0 +1,301 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useEffect } from 'react'
import { toast } from 'sonner'
import { number, object, string } from 'yup'
import { useParams } from 'common'
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import NoPermission from 'components/ui/NoPermission'
import UpgradeToPro from 'components/ui/UpgradeToPro'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { IS_PLATFORM } from 'lib/constants'
import FormField from '../AuthProvidersForm/FormField'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Form,
InputNumber,
WarningIcon,
} from 'ui'
const schema = object({
MFA_PHONE_OTP_LENGTH: number()
.min(6, 'Must be a value 6 or larger')
.max(30, 'must be a value no greater than 30'),
MFA_PHONE_TEMPLATE: string().required('SMS template is required.'),
MFA_MAX_ENROLLED_FACTORS: number()
.min(0, 'Must be be a value more than 0')
.max(30, 'Must be a value no greater than 30'),
MFA_TOTP: string().required(),
MFA_PHONE: string().required(),
})
function determineMFAStatus(verifyEnabled: boolean, enrollEnabled: boolean) {
return verifyEnabled ? (enrollEnabled ? 'Enabled' : 'Verify Enabled') : 'Disabled'
}
const MFAFactorSelectionOptions = [
{
label: 'Enabled',
value: 'Enabled',
},
{
label: 'Verify Enabled',
value: 'Verify Enabled',
},
{
label: 'Disabled',
value: 'Disabled',
},
]
const MfaStatusToState = (status: (typeof MFAFactorSelectionOptions)[number]['value']) => {
return status === 'Enabled'
? { verifyEnabled: true, enrollEnabled: true }
: status === 'Verify Enabled'
? { verifyEnabled: true, enrollEnabled: false }
: { verifyEnabled: false, enrollEnabled: false }
}
const MfaAuthSettingsForm = () => {
const { ref: projectRef } = useParams()
const {
data: authConfig,
error: authConfigError,
isLoading,
isError,
isSuccess,
} = useAuthConfigQuery({ projectRef })
const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
const formId = 'auth-config-advanced-form'
const canReadConfig = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
const organization = useSelectedOrganization()
const { data: subscription, isSuccess: isSuccessSubscription } = useOrgSubscriptionQuery({
orgSlug: organization?.slug,
})
const isProPlanAndUp = isSuccessSubscription && subscription?.plan?.id !== 'free'
const promptProPlanUpgrade = IS_PLATFORM && !isProPlanAndUp
const projectAddons = subscription?.project_addons.find((addon) => addon.ref === projectRef)
const hasPurchasedAuthMFAAddOn = projectAddons?.addons.some(
(addon) => addon.type === 'auth_mfa_phone'
)
const INITIAL_VALUES = {
MFA_PHONE_OTP_LENGTH: authConfig?.MFA_PHONE_OTP_LENGTH || 6,
MFA_PHONE_TEMPLATE: authConfig?.MFA_PHONE_TEMPLATE || 'Your code is {{ .Code }}',
MFA_MAX_ENROLLED_FACTORS: authConfig?.MFA_MAX_ENROLLED_FACTORS || 10,
MFA_TOTP:
determineMFAStatus(
authConfig?.MFA_TOTP_VERIFY_ENABLED ?? true,
authConfig?.MFA_TOTP_ENROLL_ENABLED ?? true
) || 'Enabled',
MFA_PHONE:
determineMFAStatus(
authConfig?.MFA_PHONE_VERIFY_ENABLED || false,
authConfig?.MFA_PHONE_ENROLL_ENABLED || false
) || 'Disabled',
}
// For now, we support Twilio and Vonage. Twilio Verify is not supported and the remaining providers are community maintained.
const sendSMSHookIsEnabled =
authConfig?.HOOK_SEND_SMS_URI !== null && authConfig?.HOOK_SEND_SMS_ENABLED === true
const hasValidMFAPhoneProvider = authConfig?.EXTERNAL_PHONE_ENABLED === true
const hasValidMFAProvider = hasValidMFAPhoneProvider || sendSMSHookIsEnabled
const onSubmit = (values: any, { resetForm }: any) => {
let payload = { ...values }
const { verifyEnabled: MFA_TOTP_VERIFY_ENABLED, enrollEnabled: MFA_TOTP_ENROLL_ENABLED } =
MfaStatusToState(values.MFA_TOTP)
// MFA (Phone) is only available on Pro Plans and up. We translate the UI state, MFA_PHONE and MFA_TOTP into the underlying
// Auth config state - MFA_PHONE_*_ENABLED and MFA_TOTP_*_ENABLED.
if (isProPlanAndUp) {
const { verifyEnabled: MFA_PHONE_VERIFY_ENABLED, enrollEnabled: MFA_PHONE_ENROLL_ENABLED } =
MfaStatusToState(values.MFA_PHONE)
payload = {
...payload,
MFA_PHONE_ENROLL_ENABLED,
MFA_PHONE_VERIFY_ENABLED,
}
}
payload = {
...payload,
MFA_TOTP_ENROLL_ENABLED,
MFA_TOTP_VERIFY_ENABLED,
}
delete payload.MFA_TOTP
delete payload.MFA_PHONE
updateAuthConfig(
{ projectRef: projectRef!, config: payload },
{
onError: (error) => {
toast.error(`Failed to update settings: ${error?.message}`)
},
onSuccess: () => {
toast.success('Successfully updated settings')
resetForm({ values: values, initialValues: values })
},
}
)
}
if (isError) {
return (
<Alert_Shadcn_ variant="destructive">
<WarningIcon />
<AlertTitle_Shadcn_>Failed to retrieve auth configuration</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>{authConfigError.message}</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
if (!canReadConfig) {
return <NoPermission resourceText="view auth configuration settings" />
}
return (
<Form id={formId} initialValues={INITIAL_VALUES} onSubmit={onSubmit} validationSchema={schema}>
{({ handleReset, resetForm, values, initialValues, setFieldValue }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
// Form is reset once remote data is loaded in store
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (isSuccess) resetForm({ values: INITIAL_VALUES, initialValues: INITIAL_VALUES })
}, [isSuccess])
const hasUpgradedPhoneMFA =
INITIAL_VALUES.MFA_PHONE === 'Disabled' && values.MFA_PHONE !== INITIAL_VALUES.MFA_PHONE
const phoneMFAIsEnabled =
values.MFA_PHONE === 'Enabled' || values.MFA_PHONE === 'Verify Enabled'
return (
<>
<FormPanel
disabled={true}
footer={
<div className="flex py-4 px-8">
<FormActions
form={formId}
isSubmitting={isUpdatingConfig}
hasChanges={hasChanges}
handleReset={handleReset}
disabled={!canUpdateConfig}
helper={
!canUpdateConfig
? 'You need additional permissions to update authentication settings'
: undefined
}
/>
</div>
}
>
<FormSection
header={<FormSectionLabel>Multi-Factor Authentication (MFA)</FormSectionLabel>}
>
<FormSectionContent loading={isLoading}>
<FormField
name="MFA_TOTP"
properties={{
type: 'select',
title: 'TOTP (App Authenticator)',
description: 'Control use of TOTP (App Authenticator) factors',
enum: MFAFactorSelectionOptions,
}}
formValues={values}
disabled={!canUpdateConfig}
setFieldValue={setFieldValue}
/>
<InputNumber
id="MFA_MAX_ENROLLED_FACTORS"
size="small"
label="Maximum number of per-user MFA factors"
descriptionText="How many MFA factors can be enrolled at once per user."
actions={<span className="mr-3 text-foreground-lighter">factors</span>}
disabled={!canUpdateConfig}
/>
</FormSectionContent>
</FormSection>
<FormSection header={<FormSectionLabel>SMS MFA</FormSectionLabel>}>
<FormSectionContent loading={isLoading}>
{promptProPlanUpgrade && (
<UpgradeToPro
primaryText="Upgrade to Pro"
secondaryText="Advanced MFA requires the Pro Plan"
/>
)}
<FormField
name="MFA_PHONE"
properties={{
type: 'select',
title: 'Phone',
description: 'Control use of phone factors',
enum: MFAFactorSelectionOptions,
}}
formValues={values}
disabled={!canUpdateConfig || !isProPlanAndUp}
setFieldValue={setFieldValue}
/>
{!hasValidMFAProvider && phoneMFAIsEnabled && (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>
To use MFA with Phone you should set up a Phone provider or Send SMS Hook.
</AlertTitle_Shadcn_>
</Alert_Shadcn_>
)}
<InputNumber
id="MFA_PHONE_OTP_LENGTH"
size="small"
label="Phone OTP Length"
descriptionText="Number of digits in OTP"
disabled={!canUpdateConfig || !isProPlanAndUp}
/>
<FormField
name="MFA_PHONE_TEMPLATE"
properties={{
title: 'Phone verification message',
type: 'multiline-string',
description: 'To format the OTP code use `{{ .Code }}`',
}}
formValues={values}
disabled={!canUpdateConfig || !isProPlanAndUp}
setFieldValue={setFieldValue}
/>
{hasUpgradedPhoneMFA && (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>
Enabling advanced MFA with phone will result in an additional charge of $75
per month for the first project in the organization and an additional $10
per month for additional projects.
</AlertTitle_Shadcn_>
</Alert_Shadcn_>
)}
</FormSectionContent>
</FormSection>
</FormPanel>
</>
)
}}
</Form>
)
}
export default MfaAuthSettingsForm
@@ -0,0 +1,228 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Eye, EyeOff } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { boolean, number, object, string } from 'yup'
import { useParams } from 'common'
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import NoPermission from 'components/ui/NoPermission'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
Form,
Input,
Toggle,
WarningIcon,
} from 'ui'
import { NO_REQUIRED_CHARACTERS } from '../Auth.constants'
import FormField from '../AuthProvidersForm/FormField'
const schema = object({
DISABLE_SIGNUP: boolean().required(),
EXTERNAL_ANONYMOUS_USERS_ENABLED: boolean().required(),
SECURITY_MANUAL_LINKING_ENABLED: boolean().required(),
SITE_URL: string().required('Must have a Site URL'),
SECURITY_CAPTCHA_ENABLED: boolean().required(),
SECURITY_CAPTCHA_SECRET: string().when('SECURITY_CAPTCHA_ENABLED', {
is: true,
then: (schema) => schema.required('Must have a Captcha secret'),
}),
SECURITY_CAPTCHA_PROVIDER: string().when('SECURITY_CAPTCHA_ENABLED', {
is: true,
then: (schema) =>
schema
.oneOf(['hcaptcha', 'turnstile'])
.required('Captcha provider must be either hcaptcha or turnstile'),
}),
SESSIONS_TIMEBOX: number().min(0, 'Must be a positive number'),
SESSIONS_INACTIVITY_TIMEOUT: number().min(0, 'Must be a positive number'),
SESSIONS_SINGLE_PER_USER: boolean(),
PASSWORD_MIN_LENGTH: number().min(6, 'Must be greater or equal to 6.'),
PASSWORD_REQUIRED_CHARACTERS: string(),
PASSWORD_HIBP_ENABLED: boolean(),
})
const formId = 'auth-config-protection-settings'
const ProtectionAuthSettingsForm = () => {
const { ref: projectRef } = useParams()
const {
data: authConfig,
error: authConfigError,
isLoading,
isError,
isSuccess,
} = useAuthConfigQuery({ projectRef })
const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
const [hidden, setHidden] = useState(true)
const canReadConfig = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
const INITIAL_VALUES = {
DISABLE_SIGNUP: !authConfig?.DISABLE_SIGNUP,
EXTERNAL_ANONYMOUS_USERS_ENABLED: authConfig?.EXTERNAL_ANONYMOUS_USERS_ENABLED,
SECURITY_MANUAL_LINKING_ENABLED: authConfig?.SECURITY_MANUAL_LINKING_ENABLED || false,
SITE_URL: authConfig?.SITE_URL,
SECURITY_CAPTCHA_ENABLED: authConfig?.SECURITY_CAPTCHA_ENABLED || false,
SECURITY_CAPTCHA_SECRET: authConfig?.SECURITY_CAPTCHA_SECRET || '',
SECURITY_CAPTCHA_PROVIDER: authConfig?.SECURITY_CAPTCHA_PROVIDER || 'hcaptcha',
SESSIONS_TIMEBOX: authConfig?.SESSIONS_TIMEBOX || 0,
SESSIONS_INACTIVITY_TIMEOUT: authConfig?.SESSIONS_INACTIVITY_TIMEOUT || 0,
SESSIONS_SINGLE_PER_USER: authConfig?.SESSIONS_SINGLE_PER_USER || false,
PASSWORD_MIN_LENGTH: authConfig?.PASSWORD_MIN_LENGTH || 6,
PASSWORD_REQUIRED_CHARACTERS:
authConfig?.PASSWORD_REQUIRED_CHARACTERS || NO_REQUIRED_CHARACTERS,
PASSWORD_HIBP_ENABLED: authConfig?.PASSWORD_HIBP_ENABLED || false,
}
const onSubmit = (values: any, { resetForm }: any) => {
const payload = { ...values }
payload.DISABLE_SIGNUP = !values.DISABLE_SIGNUP
// The backend uses empty string to represent no required characters in the password
if (payload.PASSWORD_REQUIRED_CHARACTERS === NO_REQUIRED_CHARACTERS) {
payload.PASSWORD_REQUIRED_CHARACTERS = ''
}
updateAuthConfig(
{ projectRef: projectRef!, config: payload },
{
onError: (error) => {
toast.error(`Failed to update settings: ${error?.message}`)
},
onSuccess: () => {
toast.success(`Successfully updated settings`)
resetForm({ values: values, initialValues: values })
},
}
)
}
if (isError) {
return (
<Alert_Shadcn_ variant="destructive">
<WarningIcon />
<AlertTitle_Shadcn_>Failed to retrieve auth configuration</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>{authConfigError.message}</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
if (!canReadConfig) {
return <NoPermission resourceText="view auth configuration settings" />
}
return (
<div>
<FormHeader
title="Bot and Abuse Protection"
description="Configure protection settings for your application."
/>
<Form
id={formId}
initialValues={INITIAL_VALUES}
onSubmit={onSubmit}
validationSchema={schema}
>
{({ handleReset, resetForm, values, initialValues, setFieldValue }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
// Form is reset once remote data is loaded in store
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (isSuccess) resetForm({ values: INITIAL_VALUES, initialValues: INITIAL_VALUES })
}, [isSuccess])
return (
<>
<FormPanel
disabled={true}
footer={
<div className="flex py-4 px-8">
<FormActions
form={formId}
isSubmitting={isUpdatingConfig}
hasChanges={hasChanges}
handleReset={handleReset}
disabled={!canUpdateConfig}
helper={
!canUpdateConfig
? 'You need additional permissions to update authentication settings'
: undefined
}
/>
</div>
}
>
<FormSection header={<FormSectionLabel>Bot and Abuse Protection</FormSectionLabel>}>
<FormSectionContent loading={isLoading}>
<Toggle
id="SECURITY_CAPTCHA_ENABLED"
size="small"
label="Enable Captcha protection"
layout="flex"
descriptionText="Protect authentication endpoints from bots and abuse."
disabled={!canUpdateConfig}
/>
{values.SECURITY_CAPTCHA_ENABLED && (
<>
<FormField
name="SECURITY_CAPTCHA_PROVIDER"
properties={{
type: 'select',
title: 'Choose Captcha Provider',
description: '',
enum: [
{
label: 'hCaptcha',
value: 'hcaptcha',
icon: 'hcaptcha-icon.png',
},
{
label: 'Turnstile by Cloudflare',
value: 'turnstile',
icon: 'cloudflare-icon.png',
},
],
}}
formValues={values}
setFieldValue={setFieldValue}
/>
<Input
id="SECURITY_CAPTCHA_SECRET"
type={hidden ? 'password' : 'text'}
size="small"
label="Captcha secret"
descriptionText="Obtain this secret from the provider."
disabled={!canUpdateConfig}
actions={
<Button
icon={hidden ? <Eye /> : <EyeOff />}
type="default"
onClick={() => setHidden(!hidden)}
/>
}
/>
</>
)}
</FormSectionContent>
</FormSection>
</FormPanel>
</>
)
}}
</Form>
</div>
)
}
export default ProtectionAuthSettingsForm
@@ -129,12 +129,6 @@ const RateLimits = () => {
return (
<div>
<FormHeader
title="Rate Limits"
description="Safeguard against bursts of incoming traffic to prevent abuse and maximize stability"
docsUrl="https://supabase.com/docs/guides/platform/going-into-prod#rate-limiting-resource-allocation--abuse-prevention"
/>
{isError && <AlertError subject="Failed to retrieve auth config rate limits" error={error} />}
{isLoading && <GenericSkeletonLoader />}
@@ -0,0 +1,218 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useEffect } from 'react'
import { toast } from 'sonner'
import { boolean, number, object } from 'yup'
import { useParams } from 'common'
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import NoPermission from 'components/ui/NoPermission'
import UpgradeToPro from 'components/ui/UpgradeToPro'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { IS_PLATFORM } from 'lib/constants'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Form,
InputNumber,
Toggle,
WarningIcon,
} from 'ui'
function HoursOrNeverText({ value }: { value: number }) {
if (value === 0) {
return 'never'
} else if (value === 1) {
return 'hour'
} else {
return 'hours'
}
}
const schema = object({
REFRESH_TOKEN_ROTATION_ENABLED: boolean().required(),
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: number()
.min(0, 'Must be a value more than 0')
.required('Must have a Reuse Interval value'),
SESSIONS_TIMEBOX: number().min(0, 'Must be a positive number'),
SESSIONS_INACTIVITY_TIMEOUT: number().min(0, 'Must be a positive number'),
SESSIONS_SINGLE_PER_USER: boolean(),
})
const SessionsAuthSettingsForm = () => {
const { ref: projectRef } = useParams()
const {
data: authConfig,
error: authConfigError,
isLoading,
isError,
isSuccess,
} = useAuthConfigQuery({ projectRef })
const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
const formId = 'auth-config-sessions-form'
const canReadConfig = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
const organization = useSelectedOrganization()
const { data: subscription, isSuccess: isSuccessSubscription } = useOrgSubscriptionQuery({
orgSlug: organization?.slug,
})
const isProPlanAndUp = isSuccessSubscription && subscription?.plan?.id !== 'free'
const promptProPlanUpgrade = IS_PLATFORM && !isProPlanAndUp
const INITIAL_VALUES = {
REFRESH_TOKEN_ROTATION_ENABLED: authConfig?.REFRESH_TOKEN_ROTATION_ENABLED || false,
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: authConfig?.SECURITY_REFRESH_TOKEN_REUSE_INTERVAL,
SESSIONS_TIMEBOX: authConfig?.SESSIONS_TIMEBOX || 0,
SESSIONS_INACTIVITY_TIMEOUT: authConfig?.SESSIONS_INACTIVITY_TIMEOUT || 0,
SESSIONS_SINGLE_PER_USER: authConfig?.SESSIONS_SINGLE_PER_USER || false,
}
const onSubmit = (values: any, { resetForm }: any) => {
const payload = { ...values }
updateAuthConfig(
{ projectRef: projectRef!, config: payload },
{
onError: (error) => {
toast.error(`Failed to update settings: ${error?.message}`)
},
onSuccess: () => {
toast.success('Successfully updated settings')
resetForm({ values: values, initialValues: values })
},
}
)
}
if (isError) {
return (
<Alert_Shadcn_ variant="destructive">
<WarningIcon />
<AlertTitle_Shadcn_>Failed to retrieve auth configuration</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>{authConfigError.message}</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
if (!canReadConfig) {
return <NoPermission resourceText="view auth configuration settings" />
}
return (
<Form id={formId} initialValues={INITIAL_VALUES} onSubmit={onSubmit} validationSchema={schema}>
{({ handleReset, resetForm, values, initialValues }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
// Form is reset once remote data is loaded in store
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (isSuccess) resetForm({ values: INITIAL_VALUES, initialValues: INITIAL_VALUES })
}, [isSuccess])
return (
<>
<FormPanel
disabled={true}
footer={
<div className="flex py-4 px-8">
<FormActions
form={formId}
isSubmitting={isUpdatingConfig}
hasChanges={hasChanges}
handleReset={handleReset}
disabled={!canUpdateConfig}
helper={
!canUpdateConfig
? 'You need additional permissions to update authentication settings'
: undefined
}
/>
</div>
}
>
<FormSection header={<FormSectionLabel>Refresh Tokens</FormSectionLabel>}>
<FormSectionContent loading={isLoading}>
<Toggle
id="REFRESH_TOKEN_ROTATION_ENABLED"
size="small"
label="Detect and revoke potentially compromised refresh tokens"
layout="flex"
descriptionText="Prevent replay attacks from potentially compromised refresh tokens."
disabled={!canUpdateConfig}
/>
{values.REFRESH_TOKEN_ROTATION_ENABLED && (
<InputNumber
id="SECURITY_REFRESH_TOKEN_REUSE_INTERVAL"
size="small"
min={0}
label="Refresh token reuse interval"
descriptionText="Time interval where the same refresh token can be used multiple times to request for an access token. Recommendation: 10 seconds."
actions={<span className="mr-3 text-foreground-lighter">seconds</span>}
disabled={!canUpdateConfig}
/>
)}
</FormSectionContent>
</FormSection>
<FormSection header={<FormSectionLabel>User Sessions</FormSectionLabel>}>
<FormSectionContent loading={isLoading}>
{promptProPlanUpgrade && (
<UpgradeToPro
primaryText="Upgrade to Pro"
secondaryText="Configuring user sessions requires the Pro Plan."
/>
)}
<Toggle
id="SESSIONS_SINGLE_PER_USER"
size="small"
label="Enforce single session per user"
layout="flex"
descriptionText="If enabled, all but a user's most recently active session will be terminated."
disabled={!canUpdateConfig || !isProPlanAndUp}
/>
<InputNumber
id="SESSIONS_TIMEBOX"
size="small"
label="Time-box user sessions"
descriptionText="The amount of time before a user is forced to sign in again. Use 0 for never"
actions={
<span className="mr-3 text-foreground-lighter">
<HoursOrNeverText value={values.SESSIONS_TIMEBOX} />
</span>
}
disabled={!canUpdateConfig || !isProPlanAndUp}
/>
<InputNumber
id="SESSIONS_INACTIVITY_TIMEOUT"
size="small"
label="Inactivity timeout"
descriptionText="The amount of time a user needs to be inactive to be forced to sign in again. Use 0 for never."
actions={
<span className="mr-3 text-foreground-lighter">
<HoursOrNeverText value={values.SESSIONS_INACTIVITY_TIMEOUT} />
</span>
}
disabled={!canUpdateConfig || !isProPlanAndUp}
/>
</FormSectionContent>
</FormSection>
</FormPanel>
</>
)
}}
</Form>
)
}
export default SessionsAuthSettingsForm
@@ -185,10 +185,6 @@ const SmtpForm = () => {
return (
<>
<FormHeader
title="SMTP Settings"
description="You can use your own SMTP server instead of the built-in email service."
/>
<FormPanel
footer={
<div className="flex py-4 px-8">
@@ -232,22 +228,16 @@ const SmtpForm = () => {
</p>
}
/>
{enableSmtp ? (
!isValidSmtpConfig && (
<div className="">
<Alert_Shadcn_ variant="warning">
<AlertTriangle strokeWidth={2} />
<AlertTitle_Shadcn_>All fields below must be filled</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
The following fields must be filled before custom SMTP can be properly
enabled
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</div>
)
) : (
{enableSmtp && !isValidSmtpConfig && !isValidSmtpConfig && (
<div className="">
<EmailRateLimitsAlert />
<Alert_Shadcn_ variant="warning">
<AlertTriangle strokeWidth={2} />
<AlertTitle_Shadcn_>All fields below must be filled</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
The following fields must be filled before custom SMTP can be properly
enabled
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</div>
)}
</FormSectionContent>
@@ -2,5 +2,8 @@ export { default as AdvancedAuthSettingsForm } from './AdvancedAuthSettingsForm/
export { default as AuthProvidersForm } from './AuthProvidersForm/AuthProvidersForm'
export { default as BasicAuthSettingsForm } from './BasicAuthSettingsForm/BasicAuthSettingsForm'
export { default as EmailTemplates } from './EmailTemplates/EmailTemplates'
export { default as MfaAuthSettingsForm } from './MfaAuthSettingsForm/MfaAuthSettingsForm'
export { default as SessionsAuthSettingsForm } from './SessionsAuthSettingsForm/SessionsAuthSettingsForm'
export { default as ProtectionAuthSettingsForm } from './ProtectionAuthSettingsForm/ProtectionAuthSettingsForm'
export { default as SmtpForm } from './SmtpForm/SmtpForm'
export { ThirdPartyAuthForm } from './ThirdPartyAuthForm'
@@ -22,7 +22,9 @@ const CPUWarnings = ({ isFreePlan, upgradeUrl, severity }: CPUWarningsProps) =>
</AlertDescription_Shadcn_>
<div className="mt-3 flex items-center space-x-2">
<Button asChild type="default">
<Link href="https://supabase.com/docs/guides/platform/exhaust-cpu">Learn more</Link>
<Link href="https://supabase.com/docs/guides/troubleshooting/high-cpu-usage">
Learn more
</Link>
</Button>
<Button asChild type="warning">
<Link href={upgradeUrl}>
@@ -46,7 +48,9 @@ const CPUWarnings = ({ isFreePlan, upgradeUrl, severity }: CPUWarningsProps) =>
</AlertDescription_Shadcn_>
<div className="mt-3 flex items-center space-x-2">
<Button asChild type="default">
<Link href="https://supabase.com/docs/guides/platform/exhaust-cpu">Learn more</Link>
<Link href="https://supabase.com/docs/guides/troubleshooting/high-cpu-usage">
Learn more
</Link>
</Button>
<Button asChild type="danger">
<Link href={upgradeUrl}>
@@ -22,7 +22,9 @@ const RAMWarnings = ({ isFreePlan, upgradeUrl, severity }: RAMWarningsProps) =>
</AlertDescription_Shadcn_>
<div className="mt-3 flex items-center space-x-2">
<Button asChild type="default">
<Link href="https://supabase.com/docs/guides/platform/exhaust-ram">Learn more</Link>
<Link href="https://supabase.com/docs/guides/troubleshooting/exhaust-ram">
Learn more
</Link>
</Button>
<Button asChild type="warning">
<Link href={upgradeUrl}>
@@ -46,7 +48,9 @@ const RAMWarnings = ({ isFreePlan, upgradeUrl, severity }: RAMWarningsProps) =>
</AlertDescription_Shadcn_>
<div className="mt-3 flex items-center space-x-2">
<Button asChild type="default">
<Link href="https://supabase.com/docs/guides/platform/exhaust-cpu">Learn more</Link>
<Link href="https://supabase.com/docs/guides/troubleshooting/high-cpu-usage">
Learn more
</Link>
</Button>
<Button asChild type="danger">
<Link href={upgradeUrl}>
@@ -1,6 +1,10 @@
import { ChevronRight, FileCode, X } from 'lucide-react'
import Link from 'next/link'
import { useParams } from 'common'
import Panel from 'components/ui/Panel'
import { usePoolingConfigurationQuery } from 'data/database/pooling-configuration-query'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import {
Button,
cn,
@@ -11,6 +15,7 @@ import {
CollapsibleTrigger_Shadcn_,
WarningIcon,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { ConnectionParameters } from './ConnectionParameters'
import { DirectConnectionIcon, TransactionIcon } from './PoolerIcons'
@@ -105,6 +110,13 @@ export const ConnectionPanel = ({
fileTitle,
onCopyCallback,
}: ConnectionPanelProps) => {
const { ref: projectRef } = useParams()
const state = useDatabaseSelectorStateSnapshot()
const { data: poolingInfo } = usePoolingConfigurationQuery({ projectRef })
const poolingConfiguration = poolingInfo?.find((x) => x.identifier === state.selectedDatabaseId)
const isSessionMode = poolingConfiguration?.pool_mode === 'session'
return (
<div className="flex flex-col gap-5 lg:grid lg:grid-cols-2 lg:gap-20 w-full">
<div className="flex flex-col">
@@ -112,27 +124,59 @@ export const ConnectionPanel = ({
<p className="text-sm text-foreground-light mb-4">{description}</p>
<div className="flex flex-col -space-y-px">
{fileTitle && <CodeBlockFileHeader title={fileTitle} />}
<CodeBlock
wrapperClassName={cn(
'[&_pre]:rounded-b-none [&_pre]:px-4 [&_pre]:py-3',
fileTitle && '[&_pre]:rounded-t-none'
)}
language={lang}
value={connectionString}
className="[&_code]:text-[12px] [&_code]:text-foreground"
hideLineNumbers
onCopyCallback={onCopyCallback}
/>
{notice && (
<div className="border px-4 py-1 w-full justify-start rounded-t-none !last:rounded-b group-data-[state=open]:rounded-b-none border-light">
{notice?.map((text: string) => (
<p key={text} className="text-xs text-foreground-lighter">
{text}
</p>
))}
</div>
{type === 'transaction' && isSessionMode ? (
<Admonition
showIcon={false}
type="default"
className="[&>h5]:text-xs [&>div]:text-xs"
title="Transaction pooler is unavailable as pool mode is set to Session"
description="If you'd like to use transaction mode, update your pool mode to Transaction for the connection pooler in your project's Database Settings."
>
<Button asChild type="default" className="mt-2">
<Link
href={`/project/${projectRef}/settings/database#connection-pooler`}
className="text-xs text-light hover:text-foreground"
>
Database Settings
</Link>
</Button>
</Admonition>
) : (
<>
{/* [Joshen] Can probably remove this after Feb 28 */}
{type === 'session' && isSessionMode && (
<Panel.Notice
layout="vertical"
className="border rounded mb-2"
title="Deprecating Session Mode on Port 6543"
description="Please use port 5432 for Session Mode as Supavisor will be deprecating Session Mode on port 6543 on February 28, 2025."
href="https://github.com/orgs/supabase/discussions/32755"
buttonText="Read the announcement"
/>
)}
<CodeBlock
wrapperClassName={cn(
'[&_pre]:rounded-b-none [&_pre]:px-4 [&_pre]:py-3',
fileTitle && '[&_pre]:rounded-t-none'
)}
language={lang}
value={connectionString}
className="[&_code]:text-[12px] [&_code]:text-foreground"
hideLineNumbers
onCopyCallback={onCopyCallback}
/>
{notice && (
<div className="border px-4 py-1 w-full justify-start rounded-t-none !last:rounded-b group-data-[state=open]:rounded-b-none border-light">
{notice?.map((text: string) => (
<p key={text} className="text-xs text-foreground-lighter">
{text}
</p>
))}
</div>
)}
{parameters.length > 0 && <ConnectionParameters parameters={parameters} />}
</>
)}
{parameters.length > 0 && <ConnectionParameters parameters={parameters} />}
</div>
</div>
<div className="flex flex-col items-end">
@@ -11,6 +11,7 @@ import { usePoolingConfigurationQuery } from 'data/database/pooling-configuratio
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { pluckObjectFields } from 'lib/helpers'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import {
@@ -36,7 +37,6 @@ import {
import { CodeBlockFileHeader, ConnectionPanel } from './ConnectionPanel'
import { getConnectionStrings } from './DatabaseSettings.utils'
import examples, { Example } from './DirectConnectionExamples'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
const StepLabel = ({
number,
@@ -127,7 +127,9 @@ export const CollectionForm = ({
className="mt-2"
type="primary"
onClick={() =>
router.push(`/org/${currentOrg?.slug}/billing?panel=subscriptionPlan`)
router.push(
`/org/${currentOrg?.slug}/billing?panel=subscriptionPlan&source=dataWarehouseIncreaseRetention`
)
}
>
Upgrade plan
@@ -62,6 +62,7 @@ const BackupsList = () => {
icon={<Clock size={20} />}
primaryText="Free Plan does not include project backups."
secondaryText="Upgrade to the Pro Plan for up to 7 days of scheduled backups."
source="backups"
/>
)
}
@@ -1,18 +1,20 @@
import type { PostgresSchema } from '@supabase/postgres-meta'
import { Loader2 } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo } from 'react'
import ReactFlow, { Background, BackgroundVariant, MiniMap, useReactFlow } from 'reactflow'
import 'reactflow/dist/style.css'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
import AlertError from 'components/ui/AlertError'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import SchemaSelector from 'components/ui/SchemaSelector'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useTablesQuery } from 'data/tables/tables-query'
import { useLocalStorage } from 'hooks/misc/useLocalStorage'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
import { SchemaGraphLegend } from './SchemaGraphLegend'
import { getGraphDataFromTables, getLayoutedElementsViaDagre } from './Schemas.utils'
@@ -24,7 +26,7 @@ export const SchemaGraph = () => {
const { ref } = useParams()
const { resolvedTheme } = useTheme()
const { project } = useProjectContext()
const [selectedSchema, setSelectedSchema] = useState<string>('public')
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
const miniMapNodeColor = '#111318'
const miniMapMaskColor = resolvedTheme?.includes('dark')
@@ -147,42 +149,58 @@ export const SchemaGraph = () => {
</div>
)}
{isSuccessTables && (
<div className="w-full h-full">
<ReactFlow
defaultNodes={[]}
defaultEdges={[]}
defaultEdgeOptions={{
type: 'smoothstep',
animated: true,
deletable: false,
style: {
stroke: 'hsl(var(--border-stronger))',
strokeWidth: 0.5,
},
}}
nodeTypes={nodeTypes}
fitView
minZoom={0.8}
maxZoom={1.8}
proOptions={{ hideAttribution: true }}
onNodeDragStop={() => saveNodePositions()}
>
<Background
gap={16}
className="[&>*]:stroke-foreground-muted opacity-[25%]"
variant={BackgroundVariant.Dots}
color={'inherit'}
/>
<MiniMap
pannable
zoomable
nodeColor={miniMapNodeColor}
maskColor={miniMapMaskColor}
className="border rounded-md shadow-sm"
/>
<SchemaGraphLegend />
</ReactFlow>
</div>
<>
{tables.length === 0 ? (
<div className="h-full flex items-center justify-center">
<ProductEmptyState
title="No tables created yet"
ctaButtonLabel="Create a new table"
ctaUrl={`/project/${ref}/editor?create=table`}
>
<p className="text-sm text-foreground-light">
There are no tables found in the schema "{selectedSchema}"
</p>
</ProductEmptyState>
</div>
) : (
<div className="w-full h-full">
<ReactFlow
defaultNodes={[]}
defaultEdges={[]}
defaultEdgeOptions={{
type: 'smoothstep',
animated: true,
deletable: false,
style: {
stroke: 'hsl(var(--border-stronger))',
strokeWidth: 1,
},
}}
nodeTypes={nodeTypes}
fitView
minZoom={0.8}
maxZoom={1.8}
proOptions={{ hideAttribution: true }}
onNodeDragStop={() => saveNodePositions()}
>
<Background
gap={16}
className="[&>*]:stroke-foreground-muted opacity-[25%]"
variant={BackgroundVariant.Dots}
color={'inherit'}
/>
<MiniMap
pannable
zoomable
nodeColor={miniMapNodeColor}
maskColor={miniMapMaskColor}
className="border rounded-md shadow-sm"
/>
<SchemaGraphLegend />
</ReactFlow>
</div>
)}
</>
)}
</>
)
@@ -314,7 +314,11 @@ export function DiskManagementForm() {
title="Compute and Disk configuration is not available on the Free Plan"
actions={
<Button type="default" asChild>
<Link href={`/org/${org?.slug}/billing?panel=subscriptionPlan`}>Upgrade plan</Link>
<Link
href={`/org/${org?.slug}/billing?panel=subscriptionPlan&source=diskManagementConfigure`}
>
Upgrade plan
</Link>
</Button>
}
description="You will need to upgrade to at least the Pro Plan to configure compute and disk"
@@ -316,7 +316,7 @@ export function DiskManagementPanelForm() {
<Link
target="_blank"
rel="noreferrer"
href={`/org/${org?.slug}/billing?panel=subscriptionPlan`}
href={`/org/${org?.slug}/billing?panel=subscriptionPlan&source=diskManagementPanelDiskSize`}
>
Upgrade plan
</Link>
@@ -15,7 +15,7 @@ export default function Introduction({ selectedLang }: Props) {
const { ref: projectRef } = useParams()
const { data: settings } = useProjectSettingsV2Query({ projectRef })
const { data: config } = useProjectPostgrestConfigQuery({ projectRef })
const { data: config, isSuccess } = useProjectPostgrestConfigQuery({ projectRef })
const protocol = settings?.app_config?.protocol ?? 'https'
const hostEndpoint = settings?.app_config?.endpoint
@@ -42,9 +42,9 @@ export default function Introduction({ selectedLang }: Props) {
the easiest way to interact with everything we offer within the Supabase ecosystem.
</p>
</article>
<article className="code">
<article className="code flex flex-col gap-y-2">
<CodeSnippet selectedLang={selectedLang} snippet={Snippets.init(endpoint)} />
{!isPublicSchemaEnabled && <PublicSchemaNotEnabledAlert />}
{isSuccess && !isPublicSchemaEnabled && <PublicSchemaNotEnabledAlert />}
</article>
</div>
</>
@@ -13,7 +13,7 @@ interface IntroductionProps {
const Introduction = ({ selectedLang }: IntroductionProps) => {
const { ref: projectRef } = useParams()
const { data: config } = useProjectPostgrestConfigQuery({ projectRef })
const { data: config, isSuccess } = useProjectPostgrestConfigQuery({ projectRef })
const isPublicSchemaEnabled = config?.db_schema
.split(',')
@@ -24,13 +24,14 @@ const Introduction = ({ selectedLang }: IntroductionProps) => {
<>
<h2 className="doc-heading">Introduction</h2>
<div className="doc-section">
<article className="code-column text-foreground">
<article className="code-column text-foreground flex flex-col gap-y-2">
<p>
All views and tables in the <code>public</code> schema and accessible by the active
database role for a request are available for querying.
</p>
{!isPublicSchemaEnabled && <PublicSchemaNotEnabledAlert />}
</article>
<article className="code">
{isSuccess && !isPublicSchemaEnabled && <PublicSchemaNotEnabledAlert />}
</article>
</div>
@@ -1,36 +1,28 @@
import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui'
import { Info } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import Link from 'next/link'
import { useParams } from 'common'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns'
const PublicSchemaNotEnabledAlert = () => {
const { ref: projectRef } = useParams()
return (
<Alert_Shadcn_ variant="default">
<Info className="h-4 w-4" />
<AlertTitle_Shadcn_ className="!-mt-4">
<ReactMarkdown>The `public` schema for this project is not exposed</ReactMarkdown>
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="grid gap-3 !-mt-7">
<ReactMarkdown>
You will not be able to query tables and views in the `public` schema via supabase-js or
HTTP clients.
</ReactMarkdown>
<div className="!-mt-4 inline-block">
<Button asChild type="default">
<Link
href={`/project/${projectRef}/settings/api#postgrest-config`}
className="!no-underline !hover:bg-surface-100 !text-foreground"
>
View schema settings
</Link>
</Button>
</div>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
<Admonition type="default">
<p className="!mt-0 !mb-1.5">The public schema for this project is not exposed</p>
<p className="!mt-0 !mb-1.5 text-foreground-light">
You will not be able to query tables and views in the public schema via supabase-js or HTTP
clients. Configure this behavior in your project's Data API settings.
</p>
<Button asChild type="default" className="mt-1">
<Link
href={`/project/${projectRef}/settings/api#postgrest-config`}
className="!no-underline"
>
View API settings
</Link>
</Button>
</Admonition>
)
}
@@ -0,0 +1,8 @@
export function isVercelUrl(url: string): boolean {
try {
return new URL(url).hostname === 'vercel.com'
} catch {
// If the URL is invalid, return false
return false
}
}
@@ -96,6 +96,7 @@ const AuditLogs = () => {
return () => clearInterval(interval)
}, [dateRange.from, dateRange.to])
const activeMembers = (members ?? []).filter((x) => !x.invited_at)
const roles = [...(rolesData?.org_scoped_roles ?? []), ...(rolesData?.project_scoped_roles ?? [])]
const retentionPeriod = data?.retention_period ?? 0
@@ -165,7 +166,7 @@ const AuditLogs = () => {
<div className="flex items-center">
<Button type="primary" asChild>
<Link href={`/org/${slug}/billing?panel=subscriptionPlan`}>
<Link href={`/org/${slug}/billing?panel=subscriptionPlan&source=auditLogs`}>
Upgrade subscription
</Link>
</Button>
@@ -184,7 +185,7 @@ const AuditLogs = () => {
<p className="text-xs prose">Filter by</p>
<FilterPopover
name="Users"
options={members ?? []}
options={activeMembers}
labelKey="username"
valueKey="gotrue_id"
activeOptions={filters.users}
@@ -143,7 +143,11 @@ const BillingMetric = ({
) : (
<div>
<Button type="default" asChild>
<Link href={`/org/${slug}/billing?panel=subscriptionPlan`}>Upgrade</Link>
<Link
href={`/org/${slug}/billing?panel=subscriptionPlan&source=billingBreakdownUsage${metric.anchor}`}
>
Upgrade
</Link>
</Button>
</div>
)}
@@ -1,8 +1,11 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
import { Form, FormControl, FormField } from '@ui/components/shadcn/ui/form'
import { useParams } from 'common'
import {
ScaffoldSection,
@@ -12,29 +15,56 @@ import {
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { FormSection, FormSectionContent } from 'components/ui/Forms/FormSection'
import NoPermission from 'components/ui/NoPermission'
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation'
import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { Form, Input } from 'ui'
import { FormMessage_Shadcn_, Input_Shadcn_ } from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
const FORM_ID = 'org-billing-email'
const formSchema = z.object({
billingEmail: z.string().email('Please provide a valid email address').optional(),
additionalBillingEmails: z.string().email({ message: 'invalid_email' }).array().default([]),
})
const BillingEmail = () => {
const { slug } = useParams()
const queryClient = useQueryClient()
const selectedOrganization = useSelectedOrganization()
const { name, billing_email } = selectedOrganization ?? {}
const formId = 'org-billing-email'
const initialValues = { billing_email: billing_email ?? '' }
const { name, billing_email } = selectedOrganization ?? {}
const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations')
const canReadBillingEmail = useCheckPermissions(
PermissionAction.BILLING_READ,
'stripe.subscriptions'
)
const { data: billingCustomer, isLoading: loadingBillingCustomer } =
useOrganizationCustomerProfileQuery({ slug }, { enabled: canReadBillingEmail })
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
billingEmail: billing_email ?? '',
additionalBillingEmails: billingCustomer?.additional_emails ?? [],
},
})
const { additionalBillingEmails } = form.watch()
const { errors } = form.formState
const additionalEmailsError = errors.additionalBillingEmails ?? []
const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation()
const onUpdateOrganizationEmail = async (values: any, { resetForm }: any) => {
const onUpdateOrganizationEmail = async (values: z.infer<typeof formSchema>) => {
if (!canUpdateOrganization) {
return toast.error('You do not have the required permissions to update this organization')
}
@@ -45,19 +75,26 @@ const BillingEmail = () => {
{
slug,
name,
billing_email: values.billing_email,
billing_email: values.billingEmail,
additional_billing_emails: values.additionalBillingEmails,
},
{
onSuccess: ({ billing_email }) => {
resetForm({ values: { billing_email }, initialValues: { billing_email } })
invalidateOrganizationsQuery(queryClient)
onSuccess: () => {
toast.success('Successfully saved settings')
form.reset(values)
},
}
)
}
if (!canReadBillingEmail) return null
useEffect(() => {
if (billingCustomer) {
form.reset({
billingEmail: billing_email,
additionalBillingEmails: billingCustomer.additional_emails ?? [],
})
}
}, [billingCustomer])
return (
<ScaffoldSection>
@@ -70,31 +107,23 @@ const BillingEmail = () => {
</div>
</ScaffoldSectionDetail>
<ScaffoldSectionContent>
<Form id={formId} initialValues={initialValues} onSubmit={onUpdateOrganizationEmail}>
{({ handleReset, values, initialValues, resetForm }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
// [Alaister] although this "technically" is breaking the rules of React hooks
// it won't error because the hooks are always rendered in the same order
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
const values = { billing_email: billing_email ?? '' }
resetForm({ values, initialValues: values })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [slug])
return (
{!canReadBillingEmail ? (
<NoPermission resourceText="view this organization's email recipients" />
) : (
<Form {...form}>
<form id={FORM_ID} onSubmit={form.handleSubmit(onUpdateOrganizationEmail)}>
<FormPanel
footer={
<div className="flex py-4 px-8">
<FormActions
form={formId}
form={FORM_ID}
isSubmitting={isUpdating}
hasChanges={hasChanges}
handleReset={handleReset}
hasChanges={form.formState.isDirty}
handleReset={form.reset}
disabled={!canUpdateOrganization}
helper={
!canUpdateOrganization
? "You need additional permissions to manage this organization's settings"
? 'You need additional permissions to update billing emails'
: undefined
}
/>
@@ -102,20 +131,79 @@ const BillingEmail = () => {
}
>
<FormSection>
<FormSectionContent fullWidth loading={false}>
<Input
id="billing_email"
size="small"
label="Email address"
type="text"
disabled={!canUpdateOrganization}
<FormSectionContent fullWidth loading={loadingBillingCustomer}>
<FormField
control={form.control}
name="billingEmail"
render={({ field }) => (
<FormItemLayout label="Email address">
<FormControl>
<Input_Shadcn_
type="email"
{...field}
placeholder="Email"
disabled={!canUpdateOrganization}
/>
</FormControl>
<FormMessage_Shadcn_ />
</FormItemLayout>
)}
/>
<FormField
control={form.control}
name="additionalBillingEmails"
render={({ field }) => (
<FormItemLayout
hideMessage
label={
<div className="flex items-center gap-x-1">
<span>Additional emails</span>
<InfoTooltip side="bottom">
These email addresses will be CC'd in automated invoice or payment
failure emails. Payment receipts will still only go to the primary
billing address.
</InfoTooltip>
</div>
}
>
<FormControl>
<MultiSelector values={field.value} onValuesChange={field.onChange}>
<MultiSelectorTrigger
deletableBadge
showIcon={false}
mode="inline-combobox"
label="Add additional recipients"
badgeLimit="wrap"
/>
<MultiSelectorContent>
<MultiSelectorList creatable />
</MultiSelectorContent>
</MultiSelector>
</FormControl>
{/* [Joshen] Manually construct the message here as MultiSelector doesn't handle array errors from RHF atm */}
{Array.isArray(additionalEmailsError) &&
additionalEmailsError.length > 0 && (
<div className="flex flex-col gap-y-1 mt-2">
{additionalEmailsError.map((x, idx) => (
<p
key={`email-error-${idx}`}
className="text-sm text-destructive"
>
"{additionalBillingEmails[idx]}" is not a valid email address
</p>
))}
</div>
)}
</FormItemLayout>
)}
/>
</FormSectionContent>
</FormSection>
</FormPanel>
)
}}
</Form>
</form>
</Form>
)}
</ScaffoldSectionContent>
</ScaffoldSection>
)
@@ -97,7 +97,11 @@ export const Restriction = () => {
</p>
<div className="flex items-center gap-x-2 mt-3">
<Button asChild key="upgrade-button" type="default">
<Link href={`/org/${org?.slug}/billing?panel=subscriptionPlan`}>Upgrade plan</Link>
<Link
href={`/org/${org?.slug}/billing?panel=subscriptionPlan&source=fairUseGracePeriodStarted`}
>
Upgrade plan
</Link>
</Button>
<Button asChild type="default" icon={<ExternalLink />}>
<a href="https://supabase.com/docs/guides/platform/billing-faq#fair-use-policy">
@@ -124,7 +128,11 @@ export const Restriction = () => {
</p>
<div className="flex items-center gap-x-2 mt-3">
<Button key="upgrade-button" asChild type="default">
<Link href={`/org/${org?.slug}/billing?panel=subscriptionPlan`}>Upgrade plan</Link>
<Link
href={`/org/${org?.slug}/billing?panel=subscriptionPlan&source=fairUseGracePeriodOver`}
>
Upgrade plan
</Link>
</Button>
<Button asChild type="default" icon={<ExternalLink />}>
<a href="https://supabase.com/docs/guides/platform/billing-faq#fair-use-policy">
@@ -151,7 +159,11 @@ export const Restriction = () => {
</p>
<div className="flex items-center gap-x-2 mt-3">
<Button key="upgrade-button" asChild type="default">
<Link href={`/org/${org?.slug}/billing?panel=subscriptionPlan`}>Upgrade plan</Link>
<Link
href={`/org/${org?.slug}/billing?panel=subscriptionPlan&source=fairUseRestricted`}
>
Upgrade plan
</Link>
</Button>
<Button asChild type="default" icon={<ExternalLink />}>
<a href="https://supabase.com/docs/guides/platform/billing-faq#fair-use-policy">
@@ -139,7 +139,7 @@ const PaymentMethodSelection = ({
<AddNewPaymentMethodModal
visible={showAddNewPaymentMethodModal}
returnUrl={`${getURL()}/org/${selectedOrganization?.slug}/billing?panel=subscriptionPlan`}
returnUrl={`${getURL()}/org/${selectedOrganization?.slug}/billing?panel=subscriptionPlan&source=paymentMethod`}
onCancel={() => setShowAddNewPaymentMethodModal(false)}
autoMarkAsDefaultPaymentMethod={true}
onConfirm={async () => {
@@ -7,7 +7,7 @@ import { useRouter } from 'next/router'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { TelemetryActions } from 'common/telemetry-constants'
import { StudioPricingSidePanelOpenedEvent, TelemetryActions } from 'common/telemetry-constants'
import { billingPartnerLabel } from 'components/interfaces/Billing/Subscription/Subscription.utils'
import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
@@ -126,11 +126,18 @@ const PlanUpdateSidePanel = () => {
useEffect(() => {
if (visible) {
setSelectedTier(undefined)
const source = Array.isArray(router.query.source)
? router.query.source[0]
: router.query.source
const properties: StudioPricingSidePanelOpenedEvent['properties'] = {
currentPlan: subscription?.plan?.name,
}
if (source) {
properties.origin = source
}
sendEvent({
action: TelemetryActions.STUDIO_PRICING_SIDE_PANEL_OPENED,
properties: {
currentPlan: subscription?.plan?.name,
},
properties,
groups: { organization: slug ?? 'Unknown' },
})
}
@@ -75,7 +75,7 @@ const SOC2 = () => {
{isSuccess && (
<div className="flex items-center justify-center h-full">
{currentPlan?.id === 'free' || currentPlan?.id === 'pro' ? (
<Link href={`/org/${slug}/billing?panel=subscriptionPlan`}>
<Link href={`/org/${slug}/billing?panel=subscriptionPlan&source=soc2`}>
<Button type="default">Upgrade to Team</Button>
</Link>
) : (
@@ -74,7 +74,9 @@ const SecurityQuestionnaire = () => {
{isSuccess && (
<div className="flex items-center justify-center h-full">
{currentPlan?.id === 'free' || currentPlan?.id === 'pro' ? (
<Link href={`/org/${slug}/billing?panel=subscriptionPlan`}>
<Link
href={`/org/${slug}/billing?panel=subscriptionPlan&source=securityQuestionnaire`}
>
<Button type="default">Upgrade to Team</Button>
</Link>
) : (
@@ -305,7 +305,10 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
<p>
The plan applies only to this new organization. To upgrade an existing
organization,{' '}
<Link className="underline" href="/org/_/billing?panel=subscriptionPlan">
<Link
className="underline"
href="/org/_/billing?panel=subscriptionPlan&source=newOrgUpgradeExisting"
>
click here
</Link>
.
@@ -38,7 +38,11 @@ const FreeProjectLimitWarning = ({
<div>
<Button asChild type="default">
<Link href={`/org/${orgSlug}/billing?panel=subscriptionPlan`}>Upgrade plan</Link>
<Link
href={`/org/${orgSlug}/billing?panel=subscriptionPlan&source=freeProjectLimitWarning`}
>
Upgrade plan
</Link>
</Button>
</div>
</div>
@@ -408,7 +408,9 @@ export const InviteMemberButton = () => {
{isSuccessSubscription &&
(currentPlan?.id === 'free' || currentPlan?.id === 'pro') && (
<Button asChild type="default">
<Link href={`/org/${slug}/billing?panel=subscriptionPlan`}>
<Link
href={`/org/${slug}/billing?panel=subscriptionPlan&source=inviteMemberSSO`}
>
Upgrade to Team
</Link>
</Button>
@@ -1,13 +1,13 @@
import { ArrowRight, Check, Minus, User, X } from 'lucide-react'
import Image from 'next/legacy/image'
import { useState } from 'react'
import Table from 'components/to-be-cleaned/Table'
import PartnerIcon from 'components/ui/PartnerIcon'
import { ProfileImage } from 'components/ui/ProfileImage'
import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query'
import { OrganizationMember } from 'data/organizations/organization-members-query'
import { useProjectsQuery } from 'data/projects/projects-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { getGitHubProfileImgUrl } from 'lib/github'
import { useProfile } from 'lib/profile'
import Link from 'next/link'
import {
@@ -33,7 +33,6 @@ const MEMBER_ORIGIN_TO_MANAGED_BY = {
export const MemberRow = ({ member }: MemberRowProps) => {
const { profile } = useProfile()
const selectedOrganization = useSelectedOrganization()
const [hasInvalidImg, setHasInvalidImg] = useState(false)
const { data: projects } = useProjectsQuery()
const { data: roles, isLoading: isLoadingRoles } = useOrganizationRolesV2Query({
@@ -47,6 +46,9 @@ export const MemberRow = ({ member }: MemberRowProps) => {
const isEmailUser = member.username === member.primary_email
const isFlyUser = Boolean(member.primary_email?.endsWith('customer.fly.io'))
const profileImageUrl =
isInvitedUser || isEmailUser || isFlyUser ? undefined : getGitHubProfileImgUrl(member.username)
// [Joshen] From project role POV, mask any roles for other projects
const isObfuscated =
member.role_ids.filter((id) => {
@@ -69,25 +71,22 @@ export const MemberRow = ({ member }: MemberRowProps) => {
return (
<Table.tr>
<Table.td>
<div className="flex items-center space-x-4">
<div className="w-[32px] h-[32px] md:w-[40px] md:h-[40px]">
{isInvitedUser || isEmailUser || isFlyUser || hasInvalidImg ? (
<div className="w-[32px] h-[32px] md:w-[40px] md:h-[40px] bg-surface-100 border border-overlay rounded-full text-foreground-lighter flex items-center justify-center">
<div className="flex items-center gap-x-4">
<ProfileImage
alt={member.username}
src={profileImageUrl}
className="border rounded-full w-[32px] h-[32px] md:w-[40px] md:h-[40px]"
placeholder={
<div
className={cn(
'w-[32px] h-[32px] md:w-[40px] md:h-[40px]',
'bg-surface-100 border border-overlay rounded-full text-foreground-lighter flex items-center justify-center'
)}
>
<User size={20} strokeWidth={1.5} />
</div>
) : (
<Image
alt={member.username}
src={`https://github.com/${member.username}.png?size=80`}
width="40"
height="40"
className="border rounded-full w-[32px] h-[32px] md:w-[40px] md:h-[40px]"
onError={() => {
setHasInvalidImg(true)
}}
/>
)}
</div>
}
/>
<div className="flex item-center gap-x-2">
{isInvitedUser === undefined ? (
<p className="text-foreground-light truncate">{member.primary_email}</p>
@@ -13,14 +13,14 @@ export const generateUsageData = (attribute: string, days: number): DataPoint[]
})
}
export const getUpgradeUrl = (slug: string, subscription?: OrgSubscription) => {
export const getUpgradeUrl = (slug: string, subscription?: OrgSubscription, source?: string) => {
if (!subscription) {
return `/org/${slug}/billing`
}
return subscription?.plan?.id === 'pro' && subscription?.usage_billing_enabled === false
? `/org/${slug}/billing#cost-control`
: `/org/${slug}/billing?panel=subscriptionPlan`
: `/org/${slug}/billing?panel=subscriptionPlan&source=usage${source}`
}
const compactNumberFormatter = new Intl.NumberFormat('en-US', {
@@ -53,7 +53,7 @@ const AttributeUsage = ({
isSuccess,
currentBillingCycleSelected,
}: AttributeUsageProps) => {
const upgradeUrl = getUpgradeUrl(slug ?? '', subscription)
const upgradeUrl = getUpgradeUrl(slug ?? '', subscription, attribute.key)
const usageRatio = (usageMeta?.usage ?? 0) / (usageMeta?.pricing_free_units ?? 0)
const usageExcess = (usageMeta?.usage ?? 0) - (usageMeta?.pricing_free_units ?? 0)
const usageBasedBilling = subscription?.usage_billing_enabled
@@ -6,10 +6,12 @@ import { toast } from 'sonner'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
import { formatDatabaseID } from 'data/read-replicas/replicas.utils'
import { executeSql } from 'data/sql/execute-sql-query'
import { DbQueryHook } from 'hooks/analytics/useDbQuery'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import {
Button,
@@ -22,7 +24,7 @@ import {
TooltipTrigger,
cn,
} from 'ui'
import ConfirmModal from 'ui-patterns/Dialogs/ConfirmDialog'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { Markdown } from '../Markdown'
import { useQueryPerformanceQuery } from '../Reports/Reports.queries'
@@ -46,6 +48,8 @@ export const QueryPerformance = ({
const state = useDatabaseSelectorStateSnapshot()
const { isLoading, isRefetching } = queryPerformanceQuery
const isPrimaryDatabase = state.selectedDatabaseId === ref
const formattedDatabaseId = formatDatabaseID(state.selectedDatabaseId ?? '')
const [page, setPage] = useState<QUERY_PERFORMANCE_REPORT_TYPES>(
(preset as QUERY_PERFORMANCE_REPORT_TYPES) ?? QUERY_PERFORMANCE_REPORT_TYPES.MOST_TIME_CONSUMING
@@ -62,6 +66,8 @@ export const QueryPerformance = ({
queryHitRate.runQuery()
}
const { data: databases } = useReadReplicasQuery({ projectRef: ref })
const { data: mostTimeConsumingQueries, isLoading: isLoadingMTC } = useQueryPerformanceQuery({
preset: 'mostTimeConsuming',
})
@@ -135,7 +141,7 @@ export const QueryPerformance = ({
tab.id !== QUERY_PERFORMANCE_REPORT_TYPES.MOST_FREQUENT
? tabMax > 1000
? (tabMax / 1000).toFixed(2)
: tabMax.toLocaleString()
: tabMax.toFixed(0)
: tabMax.toLocaleString()
return (
@@ -174,7 +180,6 @@ export const QueryPerformance = ({
</span>
) : (
<span className="text-xs text-foreground-muted group-hover:text-foreground-lighter group-data-[state=active]:text-foreground-lighter transition">
{/* {Number(tab.max).toLocaleString()} */}
{maxValue}
{tab.id !== QUERY_PERFORMANCE_REPORT_TYPES.MOST_FREQUENT
? tabMax > 1000
@@ -246,21 +251,27 @@ export const QueryPerformance = ({
</div>
</div>
<ConfirmModal
danger
<ConfirmationModal
visible={showResetgPgStatStatements}
size="medium"
variant="destructive"
title="Reset query performance analysis"
description={
'This will reset the `extensions.pg_stat_statements` table that is used to calculate query performance. This data will repopulate immediately after.'
}
buttonLabel="Clear table"
buttonLoadingLabel="Deleting"
onSelectCancel={() => setShowResetgPgStatStatements(false)}
onSelectConfirm={async () => {
confirmLabel="Reset report"
confirmLabelLoading="Resetting report"
onCancel={() => setShowResetgPgStatStatements(false)}
onConfirm={async () => {
const connectionString = databases?.find(
(db) => db.identifier === state.selectedDatabaseId
)?.connectionString
if (IS_PLATFORM && !connectionString) {
return toast.error('Unable to run query: Connection string is missing')
}
try {
await executeSql({
projectRef: project?.ref,
connectionString: project?.connectionString,
connectionString,
sql: `SELECT pg_stat_statements_reset();`,
})
handleRefresh()
@@ -269,7 +280,16 @@ export const QueryPerformance = ({
toast.error(`Failed to reset analysis: ${error.message}`)
}
}}
/>
>
<p className="text-foreground-light text-sm">
This will reset the pg_stat_statements table in the extensions schema on your{' '}
<span className="text-foreground">
{isPrimaryDatabase ? 'primary database' : `read replica (ID: ${formattedDatabaseId})`}
</span>
, which is used to calculate query performance. This data will repopulate immediately
after.
</p>
</ConfirmationModal>
</>
)
}
@@ -71,9 +71,7 @@ export const QueryPerformanceGrid = ({ queryPerformanceQuery }: QueryPerformance
renderCell: (props) => {
const value = props.row?.[col.id]
const isTime = col.name.includes('time')
const formattedValue = isTime
? `${Number(value.toFixed(2)).toLocaleString()}ms`
: value.toLocaleString()
const formattedValue = isTime ? `${value.toFixed(0)}ms` : value.toLocaleString()
return (
<div
className={cn(
@@ -164,7 +164,7 @@ export const MoveQueryModal = ({ visible, snippets = [], onClose }: MoveQueryMod
snippets.forEach((snippet) => {
snapV2.updateSnippet({
id: snippet.id,
snippet: { ...snippet, folder_id: selectedId === 'root' ? null : selectedId },
snippet: { ...snippet, folder_id: selectedId === 'root' ? null : folderId },
skipSave: true,
})
})
@@ -30,10 +30,6 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { useAppStateSnapshot } from 'state/app-state'
import { useParams } from 'common'
interface OngoingQueriesPanel {
onClose: () => void
}
export const OngoingQueriesPanel = () => {
const [_, setParams] = useUrlState({ replace: true })
const { viewOngoingQueries } = useParams()
@@ -16,6 +16,7 @@ import {
} from 'lucide-react'
import { Dispatch, SetStateAction, useState } from 'react'
import { toast } from 'sonner'
import { number, object } from 'yup'
import { useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
@@ -23,6 +24,8 @@ import Panel from 'components/ui/Panel'
import { useJwtSecretUpdateMutation } from 'data/config/jwt-secret-update-mutation'
import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query'
import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { uuidv4 } from 'lib/helpers'
import {
@@ -36,8 +39,10 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
Input,
InputNumber,
Modal,
WarningIcon,
Form,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import {
@@ -45,6 +50,15 @@ import {
JWT_SECRET_UPDATE_PROGRESS_MESSAGES,
} from './API.constants'
import { Admonition } from 'ui-patterns'
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
const schema = object({
JWT_EXP: number()
.max(604800, 'Must be less than 604800')
.required('Must have a JWT expiry value'),
})
const JWTSettings = () => {
const { ref: projectRef } = useParams()
@@ -59,12 +73,22 @@ const JWTSettings = () => {
PermissionAction.INFRA_EXECUTE,
'queue_job.projects.update_jwt'
)
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
const { data } = useJwtSecretUpdatingStatusQuery({ projectRef })
const { data: config, isError } = useProjectPostgrestConfigQuery({ projectRef })
const { mutateAsync: updateJwt, isLoading: isSubmittingJwtSecretUpdateRequest } =
useJwtSecretUpdateMutation()
const {
data: authConfig,
error: authConfigError,
isLoading: isLoadingAuthConfig,
isSuccess: isSuccessAuthConfig,
} = useAuthConfigQuery({ projectRef })
const { mutate: updateAuthConfig, isLoading: isUpdatingAuthConfig } =
useAuthConfigUpdateMutation()
const { Failed, Updated, Updating } = JwtSecretUpdateStatus
const isJwtSecretUpdateFailed = data?.jwtSecretUpdateStatus === Failed
@@ -76,6 +100,27 @@ const JWTSettings = () => {
const jwtSecretUpdateProgressMessage =
JWT_SECRET_UPDATE_PROGRESS_MESSAGES[data?.jwtSecretUpdateProgress as JwtSecretUpdateProgress]
const INITIAL_VALUES = {
JWT_EXP: authConfig?.JWT_EXP ?? 3600,
}
const onUpdateJwtExp = async (values: any, { resetForm }: any) => {
if (!projectRef) return console.error('Project ref is required')
updateAuthConfig(
{ projectRef, config: values },
{
onError: (error) => {
toast.error(`Failed to update JWT expiry: ${error?.message}`)
},
onSuccess: () => {
toast.success('Successfully updated JWT expiry')
resetForm({ values, initialValues: values })
},
}
)
}
async function handleJwtSecretUpdate(
jwt_secret: string,
setModalVisibility: Dispatch<SetStateAction<boolean>>
@@ -93,117 +138,161 @@ const JWTSettings = () => {
return (
<>
<Panel title={<h5 className="mb-0">JWT Settings</h5>}>
<Panel.Content className="space-y-6 border-t border-panel-border-interior-light [[data-theme*=dark]_&]:border-panel-border-interior-dark">
{isError ? (
<div className="flex items-center justify-center py-8 space-x-2">
<AlertCircle size={16} strokeWidth={1.5} />
<p className="text-sm text-foreground-light">Failed to retrieve JWT settings</p>
</div>
) : (
<>
<Input
label="JWT Secret"
readOnly
copy={canReadJWTSecret && isNotUpdatingJwtSecret}
reveal={canReadJWTSecret && isNotUpdatingJwtSecret}
disabled
value={
!canReadJWTSecret
? 'You need additional permissions to view the JWT secret'
: isJwtSecretUpdateFailed
? 'JWT secret update failed'
: isUpdatingJwtSecret
? 'Updating JWT secret...'
: config?.jwt_secret || ''
}
className="input-mono"
descriptionText={
'Used to decode your JWTs. You can also use this to mint your own JWTs.'
}
layout="horizontal"
/>
<div className="space-y-3">
<div className="p-3 px-6 border rounded-md shadow-sm bg-studio">
{isUpdatingJwtSecret ? (
<div className="flex items-center space-x-2">
<Loader2 className="animate-spin" size={14} />
<p className="text-sm">
Updating JWT secret: {jwtSecretUpdateProgressMessage}
</p>
</div>
) : (
<div className="w-full space-y-2">
<div className="flex items-center justify-between w-full">
<div className="flex flex-col space-y-1">
<p className="text-sm">Generate a new JWT secret</p>
<p className="text-sm opacity-50">
A random secret will be created, or you can create your own.
</p>
</div>
<div className="flex flex-col items-end">
{isUpdatingJwtSecret ? (
<Button loading type="secondary">
Updating JWT secret...
</Button>
) : !canGenerateNewJWTSecret ? (
<ButtonTooltip
disabled
type="default"
iconRight={<ChevronDown size={14} />}
tooltip={{
content: {
side: 'bottom',
text: 'You need additional permissions to generate a new JWT secret',
},
}}
>
Generate a new secret
</ButtonTooltip>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown size={14} />}>
<span>Generate a new secret</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuItem
className="space-x-2"
onClick={() => setIsGeneratingKey(true)}
>
<RefreshCw size={16} />
<p>Generate a random secret</p>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="space-x-2"
onClick={() => setIsCreatingKey(true)}
>
<PenTool size={16} />
<p>Create my own secret</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</div>
)}
<Form
id="jwt-exp-form"
initialValues={INITIAL_VALUES}
onSubmit={onUpdateJwtExp}
validationSchema={schema}
key={authConfig?.JWT_EXP}
>
{({ handleReset, values, initialValues }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
return (
<Panel
title={<h5 className="mb-0">JWT Settings</h5>}
footer={
<div className="flex py-4 w-full">
<FormActions
form="jwt-exp-form"
isSubmitting={isUpdatingAuthConfig}
hasChanges={hasChanges}
handleReset={handleReset}
disabled={!canUpdateConfig}
helper={
!canUpdateConfig
? 'You need additional permissions to update JWT settings'
: undefined
}
/>
</div>
{isJwtSecretUpdateFailed ? (
<Admonition type="warning" title="Failed to update JWT secret">
Please try again. If the failures persist, please contact Supabase support with
the following details: <br />
Change tracking ID: {data?.changeTrackingId} <br />
Error message: {jwtSecretUpdateErrorMessage}
</Admonition>
) : null}
</div>
</>
)}
</Panel.Content>
</Panel>
}
>
<Panel.Content className="space-y-6 border-t border-panel-border-interior-light [[data-theme*=dark]_&]:border-panel-border-interior-dark">
{isError ? (
<div className="flex items-center justify-center py-8 space-x-2">
<AlertCircle size={16} strokeWidth={1.5} />
<p className="text-sm text-foreground-light">Failed to retrieve JWT settings</p>
</div>
) : (
<>
<Input
label="JWT Secret"
readOnly
copy={canReadJWTSecret && isNotUpdatingJwtSecret}
reveal={canReadJWTSecret && isNotUpdatingJwtSecret}
disabled
value={
!canReadJWTSecret
? 'You need additional permissions to view the JWT secret'
: isJwtSecretUpdateFailed
? 'JWT secret update failed'
: isUpdatingJwtSecret
? 'Updating JWT secret...'
: config?.jwt_secret || ''
}
className="input-mono"
descriptionText={
'Used to decode your JWTs. You can also use this to mint your own JWTs.'
}
layout="horizontal"
/>
<InputNumber
id="JWT_EXP"
name="JWT_EXP"
size="small"
label="Access token expiry time"
descriptionText="How long access tokens are valid for before a refresh token has to be used. Recommendation: 3600 (1 hour)."
layout="horizontal"
actions={<span className="mr-3 text-foreground-lighter">seconds</span>}
disabled={!canUpdateConfig || isLoadingAuthConfig}
/>
<div className="space-y-3">
<div className="p-3 px-6 border rounded-md shadow-sm bg-studio">
{isUpdatingJwtSecret ? (
<div className="flex items-center space-x-2">
<Loader2 className="animate-spin" size={14} />
<p className="text-sm">
Updating JWT secret: {jwtSecretUpdateProgressMessage}
</p>
</div>
) : (
<div className="w-full space-y-2">
<div className="flex items-center justify-between w-full">
<div className="flex flex-col space-y-1">
<p className="text-sm">Generate a new JWT secret</p>
<p className="text-sm opacity-50">
A random secret will be created, or you can create your own.
</p>
</div>
<div className="flex flex-col items-end">
{isUpdatingJwtSecret ? (
<Button loading type="secondary">
Updating JWT secret...
</Button>
) : !canGenerateNewJWTSecret ? (
<ButtonTooltip
disabled
type="default"
iconRight={<ChevronDown size={14} />}
tooltip={{
content: {
side: 'bottom',
text: 'You need additional permissions to generate a new JWT secret',
},
}}
>
Generate a new secret
</ButtonTooltip>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown size={14} />}>
<span>Generate a new secret</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuItem
className="space-x-2"
onClick={() => setIsGeneratingKey(true)}
>
<RefreshCw size={16} />
<p>Generate a random secret</p>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="space-x-2"
onClick={() => setIsCreatingKey(true)}
>
<PenTool size={16} />
<p>Create my own secret</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</div>
)}
</div>
{isJwtSecretUpdateFailed ? (
<Admonition type="warning" title="Failed to update JWT secret">
Please try again. If the failures persist, please contact Supabase support
with the following details: <br />
Change tracking ID: {data?.changeTrackingId} <br />
Error message: {jwtSecretUpdateErrorMessage}
</Admonition>
) : null}
</div>
</>
)}
</Panel.Content>
</Panel>
)
}}
</Form>
<ConfirmationModal
variant={'destructive'}
@@ -147,7 +147,7 @@ const ServiceList = () => {
<DisplayApiSettings key="DisplayAPISettings" />
</section>
<section>
<section id="jwt-settings">
<JWTSettings />
</section>
@@ -282,7 +282,9 @@ const ComputeInstanceSidePanel = () => {
title="Changing your compute size is only available on the Pro Plan"
actions={
<Button asChild type="default">
<Link href={`/org/${organization?.slug}/billing?panel=subscriptionPlan`}>
<Link
href={`/org/${organization?.slug}/billing?panel=subscriptionPlan&source=computeInstanceSidePanel`}
>
View available plans
</Link>
</Button>
@@ -229,7 +229,9 @@ const CustomDomainSidePanel = () => {
title="Custom domains are unavailable on the Free Plan"
actions={
<Button asChild type="default">
<Link href={`/org/${organization?.slug}/billing?panel=subscriptionPlan`}>
<Link
href={`/org/${organization?.slug}/billing?panel=subscriptionPlan&source=customDomainSidePanel`}
>
View available plans
</Link>
</Button>
@@ -222,7 +222,9 @@ const IPv4SidePanel = () => {
<Admonition type="note" title="IPv4 add-on is unavailable on the Free Plan">
<p>Upgrade your plan to enable a IPv4 address for your project</p>
<Button asChild type="default">
<Link href={`/org/${organization?.slug}/billing?panel=subscriptionPlan`}>
<Link
href={`/org/${organization?.slug}/billing?panel=subscriptionPlan&source=ipv4SidePanel`}
>
View available plans
</Link>
</Button>
@@ -279,7 +279,9 @@ const PITRSidePanel = () => {
title="Changing your Point-In-Time-Recovery is only available on the Pro Plan"
actions={
<Button asChild type="default">
<Link href={`/org/${organization?.slug}/billing?panel=subscriptionPlan`}>
<Link
href={`/org/${organization?.slug}/billing?panel=subscriptionPlan&source=pitrSidePanel`}
>
View available plans
</Link>
</Button>
@@ -13,6 +13,7 @@ import { DocsButton } from 'components/ui/DocsButton'
import { FormActions } from 'components/ui/Forms/FormActions'
import Panel from 'components/ui/Panel'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import UpgradeToPro from 'components/ui/UpgradeToPro'
import { useMaxConnectionsQuery } from 'data/database/max-connections-query'
import { usePoolingConfigurationQuery } from 'data/database/pooling-configuration-query'
import { usePoolingConfigurationUpdateMutation } from 'data/database/pooling-configuration-update-mutation'
@@ -35,8 +36,14 @@ import {
Listbox,
Separator,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { SESSION_MODE_DESCRIPTION, TRANSACTION_MODE_DESCRIPTION } from '../Database.constants'
import { POOLING_OPTIMIZATIONS } from './ConnectionPooling.constants'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { IS_PLATFORM } from 'lib/constants'
const formId = 'connection-pooling-form'
@@ -58,12 +65,21 @@ const FormSchema = z.object({
default_pool_size: StringToPositiveNumber,
pool_mode: z.union([z.literal('transaction'), z.literal('session')]),
max_client_conn: StringToPositiveNumber,
DB_MAX_POOL_SIZE: StringToPositiveNumber,
})
export const ConnectionPooling = () => {
const { ref: projectRef } = useParams()
const { project } = useProjectContext()
const snap = useDatabaseSettingsStateSnapshot()
const organization = useSelectedOrganization()
const { data: subscription, isSuccess: isSuccessSubscription } = useOrgSubscriptionQuery({
orgSlug: organization?.slug,
})
const isTeamsEnterprisePlan =
isSuccessSubscription && subscription.plan.id !== 'free' && subscription.plan.id !== 'pro'
const promptTeamsEnterpriseUpgrade = IS_PLATFORM && !isTeamsEnterprisePlan
const { data: addons } = useProjectAddonsQuery({ projectRef })
const computeInstance = addons?.selected_addons.find((addon) => addon.type === 'compute_instance')
@@ -83,7 +99,9 @@ export const ConnectionPooling = () => {
isLoading,
isError,
isSuccess,
} = usePoolingConfigurationQuery({ projectRef: projectRef })
} = usePoolingConfigurationQuery({ projectRef })
const { data: authConfig } = useAuthConfigQuery({ projectRef })
const { data: maxConnData } = useMaxConnectionsQuery({
projectRef: project?.ref,
@@ -103,12 +121,15 @@ export const ConnectionPooling = () => {
}
)
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
pool_mode: poolingConfiguration?.pool_mode as 'transaction' | 'session',
default_pool_size: poolingConfiguration?.default_pool_size as number | undefined,
max_client_conn: poolingConfiguration?.max_client_conn as number | undefined,
DB_MAX_POOL_SIZE: authConfig?.DB_MAX_POOL_SIZE ?? null,
},
})
@@ -120,12 +141,15 @@ export const ConnectionPooling = () => {
pool_mode: data.pool_mode,
default_pool_size: data.default_pool_size,
max_client_conn: poolingConfiguration?.max_client_conn,
DB_MAX_POOL_SIZE: authConfig?.DB_MAX_POOL_SIZE ?? null,
})
}
toast.success('Successfully saved settings')
},
})
const { mutate: updateAuthConfig } = useAuthConfigUpdateMutation()
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (data) => {
if (!projectRef) return console.error('Project ref is required')
if (!poolingInfo) return console.error('Pooling info required')
@@ -135,6 +159,20 @@ export const ConnectionPooling = () => {
default_pool_size: data.default_pool_size as number | undefined,
pool_mode: data.pool_mode,
})
if (data.DB_MAX_POOL_SIZE !== authConfig?.DB_MAX_POOL_SIZE) {
updateAuthConfig(
{ projectRef, config: { DB_MAX_POOL_SIZE: data.DB_MAX_POOL_SIZE ?? undefined } },
{
onError: (error: Error) => {
toast.error(`Failed to update auth max direct connections: ${error?.message}`)
},
onSuccess: () => {
toast.success('Successfully updated auth max direct connections')
},
}
)
}
}
useEffect(() => {
@@ -143,9 +181,10 @@ export const ConnectionPooling = () => {
pool_mode: poolingConfiguration?.pool_mode as 'transaction' | 'session',
default_pool_size: poolingConfiguration?.default_pool_size as number | undefined,
max_client_conn: poolingConfiguration?.max_client_conn as number | undefined,
DB_MAX_POOL_SIZE: authConfig?.DB_MAX_POOL_SIZE ?? null,
})
}
}, [isSuccess])
}, [isSuccess, authConfig])
return (
<section id="connection-pooler">
@@ -259,29 +298,32 @@ export const ConnectionPooling = () => {
<>
{field.value === 'transaction' ? (
<FormDescription_Shadcn_ className="col-start-5 col-span-8 flex flex-col gap-y-2">
<Alert_Shadcn_>
<AlertTitle_Shadcn_ className="text-foreground">
Pool mode will be set to transaction permanently on port 6543
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
This will take into effect once saved. You can use session mode by
pointing the pooler connection to use port 5432.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
<Admonition
type="warning"
title="Pool mode will be set to transaction permanently on port 6543"
description="This will take into effect once saved. If you are using Session mode with port 6543 in your applications, please update to use port 5432 instead before saving."
/>
</FormDescription_Shadcn_>
) : (
<FormDescription_Shadcn_ className="col-start-5 col-span-8 flex flex-col gap-y-2">
<Alert_Shadcn_>
<AlertTitle_Shadcn_ className="text-foreground">
Set to transaction mode to use both pooling modes concurrently
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Session mode can be used concurrently with transaction mode by
{/* [Joshen] Can probably remove this after Feb 28 */}
<Panel.Notice
layout="vertical"
className="border rounded-lg"
title="Deprecating Session Mode on Port 6543"
description="On February 28, 2025, Supavisor is deprecating Session Mode on port 6543. Please update your application/database clients to use port 5432 for Session Mode."
href="https://github.com/orgs/supabase/discussions/32755"
buttonText="Read the announcement"
/>
<Admonition
showIcon={false}
type="default"
title="Set to transaction mode to use both pooling modes concurrently"
description="Session mode can be used concurrently with transaction mode by
using 5432 for session and 6543 for transaction. However, by
configuring the pooler mode to session here, you will not be able
to use transaction mode at the same time.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
to use transaction mode at the same time."
/>
</FormDescription_Shadcn_>
)}
</>
@@ -397,6 +439,45 @@ export const ConnectionPooling = () => {
</FormItem_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name="DB_MAX_POOL_SIZE"
render={({ field }) => (
<FormItem_Shadcn_ className="grid gap-2 md:grid md:grid-cols-12 space-y-0">
<FormLabel_Shadcn_ className="col-span-4 text-sm text-foreground-light">
Max Direct Auth Connections
</FormLabel_Shadcn_>
<div className="col-span-8 space-y-2">
{promptTeamsEnterpriseUpgrade && (
<div className="mb-4">
<UpgradeToPro
primaryText="Upgrade to Team or Enterprise"
secondaryText="Max Direct Auth Connections settings are only available on the Team Plan and up."
buttonText="Upgrade to Team"
/>
</div>
)}
<FormControl_Shadcn_ className="col-span-8">
<Input_Shadcn_
{...field}
type="number"
className="w-full"
value={field.value || undefined}
placeholder={field.value === null ? '10' : ''}
disabled={!canUpdateConfig || isTeamsEnterprisePlan}
/>
</FormControl_Shadcn_>
<FormDescription_Shadcn_>
Auth will take up no more than this number of connections from the total
number of available connections to serve requests. These connections are
not reserved, so when unused they are released. Defaults to 10
connections.
</FormDescription_Shadcn_>
<FormMessage_Shadcn_ />
</div>
</FormItem_Shadcn_>
)}
/>
</form>
</Form_Shadcn_>
<div className="border-muted border-t"></div>
@@ -39,14 +39,18 @@ export const DatabaseReadOnlyAlert = () => {
</li>
{subscription?.plan.id === 'free' ? (
<li>
<Link href={`/org/${organization?.slug}/billing?panel=subscriptionPlan`}>
<Link
href={`/org/${organization?.slug}/billing?panel=subscriptionPlan&source=databaseReadOnlyAlertUpgradePlan`}
>
<a className="text underline">Upgrade to the Pro Plan</a>
</Link>{' '}
to increase your database size limit to 8GB.
</li>
) : subscription?.plan.id === 'pro' && subscription?.usage_billing_enabled ? (
<li>
<Link href={`/org/${organization?.slug}/billing?panel=subscriptionPlan`}>
<Link
href={`/org/${organization?.slug}/billing?panel=subscriptionPlan&source=databaseReadOnlyAlertSpendCap`}
>
<a className="text-foreground underline">Disable your Spend Cap</a>
</Link>{' '}
to allow your project to auto-scale and expand beyond the 8GB database size limit
@@ -90,6 +90,7 @@ const CustomDomainConfig = () => {
: 'To configure a custom domain for your project, please enable the add-on'
}
addon="customDomain"
source="customDomain"
disabled={customDomainsDisabledDueToQuota}
/>
) : (
Loaded 100 of 280 files, more files were not shown because too many files have changed in this diff. Show more