mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 03:45:06 +03:00
Merge branch 'master' into joshen/debug-104-reduce-pathname-column-width-in-unified-logs
This commit is contained in:
commit
fff04aadef
132 files changed
+2324
-1088
No files matched your search
@@ -172,7 +172,7 @@ Choose the appropriate `type` for your admonition:
|
||||
- `note` for anything else
|
||||
|
||||
```
|
||||
<Admonition type="note" label="Optional label displays as title">
|
||||
<Admonition type="note" title="Optional title">
|
||||
|
||||
Your content here
|
||||
|
||||
|
||||
@@ -1695,6 +1695,7 @@ export const functions: NavMenuConstant = {
|
||||
url: '/guides/functions/auth',
|
||||
items: [
|
||||
{ name: 'Securing your functions', url: '/guides/functions/auth' },
|
||||
{ name: 'Authorization headers', url: '/guides/functions/auth-headers' },
|
||||
{ name: 'Legacy JWT secret', url: '/guides/functions/auth-legacy-jwt' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ subtitle: 'Create and use anonymous users to authenticate with Supabase'
|
||||
|
||||
[Enable Anonymous Sign-Ins](/dashboard/project/_/auth/providers) to build apps which provide users an authenticated experience without requiring users to enter an email address, password, use an OAuth provider or provide any other PII (Personally Identifiable Information). Later, when ready, the user can link an authentication method to their account.
|
||||
|
||||
<Admonition type="note" label="Anonymous user vs the anon key">
|
||||
<Admonition type="note" title="Anonymous user vs the anon key">
|
||||
|
||||
Calling `signInAnonymously()` creates an anonymous user. It's just like a permanent user, except the user can't access their account if they sign out, clear browsing data, or use another device.
|
||||
|
||||
@@ -30,13 +30,13 @@ See the [Access control section](#access-control) for more details.
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Admonition type="caution" label="Use Dynamic Rendering with Next.js">
|
||||
<Admonition type="caution" title="Use Dynamic Rendering with Next.js">
|
||||
|
||||
The Supabase team has received reports of user metadata being cached across unique anonymous users as a result of Next.js static page rendering. For the best user experience, utilize dynamic page rendering.
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Admonition type="note" label="Self hosting and local development">
|
||||
<Admonition type="note" title="Self hosting and local development">
|
||||
|
||||
For self-hosting, you can update your project configuration using the files and environment variables provided. See the [local development docs](/docs/guides/cli/config) for more details.
|
||||
|
||||
@@ -293,7 +293,7 @@ to authenticated
|
||||
using ( true );
|
||||
```
|
||||
|
||||
<Admonition type="note" label="Use restrictive policies">
|
||||
<Admonition type="note" title="Use restrictive policies">
|
||||
|
||||
RLS policies are permissive by default, which means that they are combined using an "OR" operator when multiple policies are applied. It is important to construct restrictive policies to ensure that the checks for an anonymous user are always enforced when combined with other policies.
|
||||
Be aware that a single 'restrictive' RLS policy alone will fail unless combined with another policy that returns true, ensuring the combined condition is met.
|
||||
|
||||
@@ -238,6 +238,11 @@ export default async function ConsentPage({
|
||||
return <div>Error: {error?.message || 'Invalid authorization request'}</div>
|
||||
}
|
||||
|
||||
// if no authorization_id returned, user has previously consented, redirect them
|
||||
if (!('authorization_id' in authDetails)) {
|
||||
redirect(authDetails['redirect_url'])
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Authorize {authDetails.client.name}</h1>
|
||||
@@ -313,7 +318,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
// Redirect back to the client with authorization code
|
||||
return NextResponse.redirect(data.redirect_to)
|
||||
return NextResponse.redirect(data.redirect_url)
|
||||
} else {
|
||||
const { data, error } = await supabase.auth.oauth.denyAuthorization(authorizationId)
|
||||
|
||||
@@ -322,7 +327,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
// Redirect back to the client with error
|
||||
return NextResponse.redirect(data.redirect_to)
|
||||
return NextResponse.redirect(data.redirect_url)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -388,7 +393,7 @@ export function OAuthConsent() {
|
||||
setError(error.message)
|
||||
} else {
|
||||
// Redirect to client app
|
||||
window.location.href = data.redirect_to
|
||||
window.location.href = data.redirect_url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,7 +406,7 @@ export function OAuthConsent() {
|
||||
setError(error.message)
|
||||
} else {
|
||||
// Redirect to client app with error
|
||||
window.location.href = data.redirect_to
|
||||
window.location.href = data.redirect_url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,8 +460,8 @@ export function OAuthConsent() {
|
||||
6. **Handle decision** - When the user clicks approve/deny:
|
||||
- Call `supabase.auth.oauth.approveAuthorization(authorization_id)` or `denyAuthorization(authorization_id)`
|
||||
- These methods handle all OAuth logic internally (generating authorization codes, etc.)
|
||||
- They return a `redirect_to` URL
|
||||
7. **Redirect back** - Redirect the user to the `redirect_to` URL, which sends them back to the third-party app with either an authorization code (approved) or error (denied)
|
||||
- They return a `redirect_url` URL
|
||||
7. **Redirect back** - Redirect the user to the `redirect_url` URL, which sends them back to the third-party app with either an authorization code (approved) or error (denied)
|
||||
|
||||
## Register an OAuth client
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ Here's a visual representation of the complete authorization code flow:
|
||||
│ (code_verifier, code_challenge) │
|
||||
│ │ │
|
||||
│ 2. Redirect to /oauth/authorize with code_challenge │
|
||||
├────────────────────────────────────────────────────────────────>│
|
||||
├───────────────────────────────────────────────────────────────>│
|
||||
│ │ │
|
||||
│ │ 3. Validate params & redirect │
|
||||
│ │ to authorization_path │
|
||||
@@ -71,7 +71,7 @@ Here's a visual representation of the complete authorization code flow:
|
||||
│ │ │
|
||||
│ │ 6. approveAuthorization() │
|
||||
│ ├────────────────────────────────>│
|
||||
│ │ Return redirect_to with code │
|
||||
│ │ Return redirect_url with code │
|
||||
│ │<────────────────────────────────┤
|
||||
│ │ │
|
||||
│ 7. Redirect to client callback with code │
|
||||
@@ -79,18 +79,18 @@ Here's a visual representation of the complete authorization code flow:
|
||||
│ │ │
|
||||
│ 8. Exchange code for tokens (POST /oauth/token) │
|
||||
│ with code_verifier │
|
||||
├────────────────────────────────────────────────────────────────>│
|
||||
├───────────────────────────────────────────────────────────────>│
|
||||
│ │ │
|
||||
│ 9. Return tokens (access, refresh, ID) │
|
||||
│<────────────────────────────────────────────────────────────────┤
|
||||
│<───────────────────────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ 10. Access resources with access_token │
|
||||
│ │ │
|
||||
│ 11. Refresh tokens (POST /oauth/token with refresh_token) │
|
||||
├────────────────────────────────────────────────────────────────>│
|
||||
├───────────────────────────────────────────────────────────────>│
|
||||
│ │ │
|
||||
│ 12. Return new tokens │
|
||||
│<────────────────────────────────────────────────────────────────┤
|
||||
│<───────────────────────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
```
|
||||
|
||||
@@ -189,7 +189,7 @@ Your frontend application at the authorization path should:
|
||||
5. **Handle user decision** - When the user approves or denies:
|
||||
- Call `supabase.auth.oauth.approveAuthorization(authorization_id)` to approve
|
||||
- Call `supabase.auth.oauth.denyAuthorization(authorization_id)` to deny
|
||||
- Redirect user to the returned `redirect_to` URL
|
||||
- Redirect user to the returned `redirect_url` URL
|
||||
|
||||
This is a **frontend implementation** using `supabase-js`. Supabase Auth handles all the backend OAuth logic (generating authorization codes, validating requests, etc.) after you call the approve/deny methods.
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ Supabase allows you to specify wildcards when adding redirect URLs to the [allow
|
||||
|
||||
The separator characters in a URL are defined as `.` and `/`. Use [this tool](https://www.digitalocean.com/community/tools/glob?comments=true&glob=http%3A%2F%2Flocalhost%3A3000%2F%2A%2A&matches=false&tests=http%3A%2F%2Flocalhost%3A3000&tests=http%3A%2F%2Flocalhost%3A3000%2F&tests=http%3A%2F%2Flocalhost%3A3000%2F%3Ftest%3Dtest&tests=http%3A%2F%2Flocalhost%3A3000%2Ftest-test%3Ftest%3Dtest&tests=http%3A%2F%2Flocalhost%3A3000%2Ftest%2Ftest%3Ftest%3Dtest) to test your patterns.
|
||||
|
||||
<Admonition type="note" label="Recommendation">
|
||||
<Admonition type="note" title="Recommendation">
|
||||
|
||||
While the "globstar" (`**`) is useful for local development and preview URLs, we recommend setting the exact redirect URL path for your site URL in production.
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ In some cases you're able to use the OAuth flow within web-based native apps suc
|
||||
|
||||
When developing with Expo, you can test Sign in with Apple via the Expo Go app, in all other cases you will need to obtain an [Apple Developer](https://developer.apple.com) account to enable the capability.
|
||||
|
||||
<Admonition type="caution" label="Secret Key Rotation Required">
|
||||
<Admonition type="caution" title="Secret Key Rotation Required">
|
||||
|
||||
If you're using the OAuth flow (web, Flutter web, Kotlin non-iOS platforms), Apple requires you to generate a new secret key every 6 months using the signing key (`.p8` file). This is a critical maintenance task that will cause authentication failures if missed.
|
||||
|
||||
@@ -35,7 +35,7 @@ This requirement only applies if you're configuring OAuth settings (Services ID,
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Admonition type="caution" label="Apple Does Not Provide Full Name in Identity Token">
|
||||
<Admonition type="caution" title="Apple Does Not Provide Full Name in Identity Token">
|
||||
|
||||
Apple's identity token does not include the user's full name in its claims. This means the Supabase Auth server cannot automatically populate the user's name metadata when users sign in with Apple.
|
||||
|
||||
@@ -97,7 +97,7 @@ The platform-specific examples below demonstrate how to implement this pattern f
|
||||
|
||||
This call takes the user to Apple's consent screen. Once the flow ends, the user's profile information is exchanged and validated with Supabase Auth before it redirects back to your web application with an access and refresh token representing the user's session.
|
||||
|
||||
<Admonition type="note" label="Full Name Not Available in OAuth Flow">
|
||||
<Admonition type="note" title="Full Name Not Available in OAuth Flow">
|
||||
|
||||
When using the OAuth flow, the user's full name is not accessible from Apple's response. Apple only provides the full name through native authentication methods (Sign in with Apple JS, or native iOS/macOS SDKs) during the first sign-in.
|
||||
|
||||
@@ -371,7 +371,7 @@ curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \
|
||||
}
|
||||
```
|
||||
|
||||
<Admonition type="note" label="Android Implementation Notes">
|
||||
<Admonition type="note" title="Android Implementation Notes">
|
||||
|
||||
- Sign in with Apple is not natively available on Android devices
|
||||
- The OAuth flow opens a browser window for authentication
|
||||
|
||||
@@ -208,7 +208,7 @@ select pg_postmaster_start_time();
|
||||
|
||||
You can also pass the `--no-restart` flag to attempt a reload-only apply. If the parameter cannot be reloaded, the change stays pending until the next restart.
|
||||
|
||||
<Admonition type="note" label="Read Replicas and Custom Config">
|
||||
<Admonition type="note" title="Read Replicas and Custom Config">
|
||||
|
||||
Postgres requires several parameters to be synchronized between the Primary cluster and [Read Replicas](/docs/guides/platform/read-replicas).
|
||||
|
||||
|
||||
@@ -309,7 +309,7 @@ order by "created" desc;
|
||||
|
||||
## Configuration
|
||||
|
||||
<Admonition type="note" label="Must be on pg_net v0.12.0 or above to reconfigure ">
|
||||
<Admonition type="note" title="Must be on pg_net v0.12.0 or above to reconfigure ">
|
||||
|
||||
Supabase supports reconfiguring pg*net starting from v0.12.0+. For the latest release, initiate a Postgres upgrade in the [Infrastructure Settings](/dashboard/project/*/settings/infrastructure).
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ hideToc: true
|
||||
- On your project dashboard, click [Connect](/dashboard/project/_?showConnect=true)
|
||||
- View parameters under "Session pooler"
|
||||
|
||||
<Admonition type="note" label="connection notice">
|
||||
<Admonition type="note" title="connection notice">
|
||||
|
||||
If you're in an [IPv6 environment](/docs/guides/platform/ipv4-address#checking-your-network-ipv6-support) or have the [IPv4 Add-On](/docs/guides/platform/ipv4-address#understanding-ip-addresses), you can use the direct connection string instead of Supavisor in Session mode.
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ EXECUTE FUNCTION rls_auto_enable();
|
||||
|
||||
Note that this applies to tables created after the trigger is installed. Existing tables still need RLS enabled manually.
|
||||
|
||||
<Admonition type="caution" label="`auth.uid()` Returns `null` When Unauthenticated">
|
||||
<Admonition type="caution" title="`auth.uid()` Returns `null` When Unauthenticated">
|
||||
|
||||
When a request is made without an authenticated user (e.g., no access token is provided or the session has expired), `auth.uid()` returns `null`.
|
||||
|
||||
@@ -156,7 +156,7 @@ to authenticated
|
||||
using ( true );
|
||||
```
|
||||
|
||||
<Admonition type="note" label="Anonymous user vs the anon key">
|
||||
<Admonition type="note" title="Anonymous user vs the anon key">
|
||||
|
||||
Using the `anon` Postgres role is different from an [anonymous user](/docs/guides/auth/auth-anonymous) in Supabase Auth. An anonymous user assumes the `authenticated` role to access the database and can be differentiated from a permanent user by checking the `is_anonymous` claim in the JWT.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t
|
||||
<StepHikeCompact.Details title="Create a custom user for Prisma">
|
||||
- In the [SQL Editor](/dashboard/project/_/sql/new), create a Prisma DB user with full privileges on the public schema.
|
||||
- This gives you better control over Prisma's access and makes it easier to monitor using Supabase tools like the [Query Performance Dashboard](/dashboard/project/_/advisors/query-performance) and [Log Explorer](/dashboard/project/_/logs/explorer).
|
||||
<Admonition type="note" label="password manager">
|
||||
<Admonition type="note" title="password manager">
|
||||
|
||||
For security, consider using a [password generator](https://bitwarden.com/password-generator/) for the Prisma role.
|
||||
|
||||
@@ -283,7 +283,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t
|
||||
--to-schema prisma/schema.prisma \
|
||||
--script > prisma/migrations/0_init_supabase/migration.sql
|
||||
```
|
||||
<Admonition type="tip" label="conflict management">
|
||||
<Admonition type="tip" title="conflict management">
|
||||
|
||||
If there are any conflicts, reference [Prisma's official doc](https://www.prisma.io/docs/orm/prisma-migrate/getting-started#work-around-features-not-supported-by-prisma-schema-language) or the [trouble shooting guide](/docs/guides/database/prisma/prisma-troubleshooting) for more details
|
||||
|
||||
@@ -311,7 +311,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t
|
||||
--to-schema prisma/schema.prisma \
|
||||
--script > prisma/migrations/0_init_supabase/migration.sql
|
||||
```
|
||||
<Admonition type="note" label="conflict management">
|
||||
<Admonition type="note" title="conflict management">
|
||||
|
||||
If there are any conflicts, reference [Prisma's official doc](https://www.prisma.io/docs/orm/prisma-migrate/getting-started#work-around-features-not-supported-by-prisma-schema-language) or the [trouble shooting guide](/docs/guides/database/prisma/prisma-troubleshooting) for more details
|
||||
|
||||
@@ -339,7 +339,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t
|
||||
--to-schema prisma/schema.prisma \
|
||||
--script > prisma/migrations/0_init_supabase/migration.sql
|
||||
```
|
||||
<Admonition type="note" label="conflict management">
|
||||
<Admonition type="note" title="conflict management">
|
||||
|
||||
If there are any conflicts, reference [Prisma's official doc](https://www.prisma.io/docs/orm/prisma-migrate/getting-started#work-around-features-not-supported-by-prisma-schema-language) or the [trouble shooting guide](/docs/guides/database/prisma/prisma-troubleshooting) for more details
|
||||
|
||||
@@ -367,7 +367,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t
|
||||
--to-schema prisma/schema.prisma \
|
||||
--script > prisma/migrations/0_init_supabase/migration.sql
|
||||
```
|
||||
<Admonition type="note" label="conflict management">
|
||||
<Admonition type="note" title="conflict management">
|
||||
|
||||
If there are any conflicts, reference [Prisma's official doc](https://www.prisma.io/docs/orm/prisma-migrate/getting-started#work-around-features-not-supported-by-prisma-schema-language) or the [trouble shooting guide](/docs/guides/database/prisma-troubleshooting) for more details
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Read replicas are additional Supabase Postgres databases kept in sync with your
|
||||
|
||||
### External replication
|
||||
|
||||
<Admonition type="caution" label="Private Alpha">
|
||||
<Admonition type="caution" title="Private Alpha">
|
||||
|
||||
External replication is currently in private alpha. Access is limited and features may change.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ subtitle: 'Replicate Supabase Postgres tables to BigQuery.'
|
||||
sidebar_label: 'BigQuery'
|
||||
---
|
||||
|
||||
<Admonition type="caution" label="Private Alpha">
|
||||
<Admonition type="caution" title="Private Alpha">
|
||||
|
||||
External replication is currently in private alpha. Access is limited and features may change.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ subtitle: 'Common questions and answers about external replication.'
|
||||
sidebar_label: 'FAQ'
|
||||
---
|
||||
|
||||
<Admonition type="caution" label="Private Alpha">
|
||||
<Admonition type="caution" title="Private Alpha">
|
||||
|
||||
External replication is currently in private alpha. Access is limited and features may change.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ subtitle: 'Track replication status, view logs, and troubleshoot issues.'
|
||||
sidebar_label: 'Monitoring'
|
||||
---
|
||||
|
||||
<Admonition type="caution" label="Private Alpha">
|
||||
<Admonition type="caution" title="Private Alpha">
|
||||
|
||||
External replication is currently in private alpha. Access is limited and features may change.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ subtitle: 'Configure publications and destinations for external replication.'
|
||||
sidebar_label: 'Setting up'
|
||||
---
|
||||
|
||||
<Admonition type="caution" label="Private Alpha">
|
||||
<Admonition type="caution" title="Private Alpha">
|
||||
|
||||
External replication is currently in private alpha. Access is limited and features may change.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Your publishable key is safe to expose with RLS enabled, because row access perm
|
||||
|
||||
Older projects may also show an `anon` key. Treat it like a publishable key: it can identify your project, but it is not a secret and must be paired with RLS and least-privilege grants.
|
||||
|
||||
<Admonition type="danger" label="Never expose your service role or secret keys on the frontend">
|
||||
<Admonition type="danger" title="Never expose your service role or secret keys on the frontend">
|
||||
|
||||
Unlike your publishable key, your secret and service role keys are **never** safe to expose because they bypass RLS. Only use your secret and service role keys on the backend. Treat them as secrets (for example, import them as sensitive environment variables instead of hardcoding them).
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ user = "env(SMTP_USER)"
|
||||
password = "env(SMTP_PASSWORD)"
|
||||
```
|
||||
|
||||
<Admonition type="note" label="Secrets are branch-specific">
|
||||
<Admonition type="note" title="Secrets are branch-specific">
|
||||
|
||||
Secrets set for one branch are not automatically available in other branches. You'll need to set them separately for each branch that needs them.
|
||||
|
||||
@@ -152,7 +152,7 @@ client_id = "env(SUPABASE_AUTH_EXTERNAL_GITHUB_CLIENT_ID)"
|
||||
secret = "env(SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET)"
|
||||
```
|
||||
|
||||
<Admonition type="note" label="Secret fields">
|
||||
<Admonition type="note" title="Secret fields">
|
||||
|
||||
The `encrypted:` syntax only works for designated "secret" fields in the configuration. Using encrypted values in other fields will not be automatically decrypted and may cause issues. For non-secret fields, use environment variables with the `env()` syntax instead.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ subtitle: 'Create, manage, review, and merge branches directly in the dashboard'
|
||||
|
||||
You can create, manage, review, and merge Supabase branches directly via the dashboard. This is useful for quick testing, prototyping, or when you prefer to work in a no-code way. You can also connect a Supabase branch to a GitHub branch at a later time if needed.
|
||||
|
||||
<Admonition type="note" label="Public Alpha">
|
||||
<Admonition type="note" title="Public Alpha">
|
||||
|
||||
Branch management via the dashboard is currently in public alpha. Features and functionality may change.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ Install the Vercel integration:
|
||||
- From the [Vercel marketplace](https://vercel.com/integrations/supabase) or
|
||||
- By clicking the blue `Deploy` button in a Supabase example app's `README` file
|
||||
|
||||
<Admonition type="note" label="Vercel GitHub integration also required.">
|
||||
<Admonition type="note" title="Vercel GitHub integration also required.">
|
||||
|
||||
For branching to work with Vercel, you also need the [Vercel GitHub integration](https://vercel.com/docs/deployments/git/vercel-for-github).
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ Check and review issues in your database using [Performance Advisor](/dashboard/
|
||||
|
||||
## Rate limiting, resource allocation, & abuse prevention
|
||||
|
||||
<Admonition type="caution" label="Shared Responsibility Model">
|
||||
<Admonition type="caution" title="Shared Responsibility Model">
|
||||
|
||||
Running databases is a shared responsibility between you and Supabase. There are some things that we can take care of for you, and some things that you are responsible for.
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: 'auth-headers'
|
||||
title: 'Authorization headers'
|
||||
description: 'How the Authorization and apikey request headers and the verify_jwt platform check work for Edge Functions.'
|
||||
subtitle: 'How the Authorization and apikey headers and the verify_jwt platform check work'
|
||||
---
|
||||
|
||||
Every request to an Edge Function passes through two layers of auth. First, a platform-level check (`verify_jwt`) runs before your code executes. Then, once the request reaches your handler, you decide what to do with the credentials the caller sent. This page is the reference for both layers. For the practical patterns built on top of them, see [Securing Edge Functions](/guides/functions/auth).
|
||||
|
||||
## Understanding authorization headers
|
||||
|
||||
Edge Functions care about two request headers. Sending the wrong credential in the wrong header is the most common source of 401 errors.
|
||||
|
||||
| Header | Value | Used for |
|
||||
| --------------- | --------------------------------------- | -------------------------------------- |
|
||||
| `Authorization` | `Bearer <user-jwt>` | A user signed in through Supabase Auth |
|
||||
| `apikey` | `sb_publishable_...` or `sb_secret_...` | Calls from clients or services |
|
||||
|
||||
A common mistake is sending a publishable or secret key as a bearer token: `Authorization: Bearer sb_publishable_...`. The new API keys are not JWTs. The platform check can't validate them, and your handler can't verify them as JWTs either. Instead, put API keys in the `apikey` header.
|
||||
|
||||
You can send both headers together. A signed-in user calling your function through `supabase-js`, for example, sends their session JWT in `Authorization` and the project's publishable key in `apikey`.
|
||||
|
||||
## The `verify_jwt` platform check
|
||||
|
||||
When `verify_jwt` is enabled (the default), the platform inspects the `Authorization` header of every request before your function runs. It expects a valid user JWT. If the header is missing, malformed, or signed with a different key, the platform returns a 401 error, and your code never executes.
|
||||
|
||||
The check validates legacy HS256 JWTs and JWTs signed with the new asymmetric [signing keys](/docs/guides/auth/signing-keys).
|
||||
|
||||
The check does not accept an API key. Publishable and secret keys are not JWTs, so callers that send one in the `Authorization` header fail the check before their request reaches your handler.
|
||||
|
||||
Use the `verify_jwt` flag to match how the function is called:
|
||||
|
||||
- **Leave `verify_jwt` on** for functions that are only called with a user JWT, such as functions invoked from the client through `supabase.functions.invoke`. The platform rejects unauthenticated requests before they reach your code, and your handler can trust that a valid JWT is present.
|
||||
- **Turn `verify_jwt` off** for functions that are called without an `Authorization` header, such as webhooks from external providers, or service-to-service calls that authenticate with an API key. These patterns are covered in [Securing Edge Functions](/guides/functions/auth).
|
||||
|
||||
Set the flag per function in `supabase/config.toml`:
|
||||
|
||||
```toml
|
||||
[functions.stripe-webhook]
|
||||
verify_jwt = false
|
||||
```
|
||||
|
||||
For 401 failure modes and how to diagnose them, see [Edge Function 401 error response](/docs/troubleshooting/edge-function-401-error-response).
|
||||
@@ -5,218 +5,87 @@ description: 'Authentication patterns for Supabase Edge Functions.'
|
||||
subtitle: 'Authentication patterns for Edge Functions'
|
||||
---
|
||||
|
||||
<Admonition type="caution">
|
||||
The `withSupabase` wrapper from [`@supabase/server`](https://github.com/supabase/server) verifies the caller's credentials against a declared `auth` mode and hands you a pre-configured Supabase client on `ctx`. The sections below show how to use it for each common auth scenario.
|
||||
|
||||
The patterns in this guide assume your project uses the new [JWT signing keys](/docs/guides/getting-started/api-keys) and the new [API keys](https://github.com/orgs/supabase/discussions/29260). If you're still on legacy JWTs, see the [Legacy JWT Secret guide](/docs/guides/functions/auth-legacy-jwt).
|
||||
For how authorization headers and the `verify_jwt` platform check work under the hood, see [Authorization headers](/docs/guides/functions/auth-headers).
|
||||
|
||||
| Mode | Accepts |
|
||||
| --------------- | ------------------------------------------ |
|
||||
| `'user'` | A valid user JWT on `Authorization` |
|
||||
| `'secret'` | A secret key on `apikey` |
|
||||
| `'publishable'` | A publishable key on `apikey` |
|
||||
| `'none'` | Any caller, no check (for signed webhooks) |
|
||||
|
||||
## Authenticated user calls
|
||||
|
||||
Functions called by signed-in users — typically through `supabase.functions.invoke` from the client — send the user's session JWT on the `Authorization` header. Keep `verify_jwt = true` (the default) so the platform validates the JWT before your handler runs, then use `auth: 'user'` to get `ctx.supabase` already scoped to the caller's RLS policies.
|
||||
|
||||
```ts
|
||||
import { withSupabase } from 'npm:@supabase/server'
|
||||
|
||||
export default {
|
||||
fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => {
|
||||
const { supabase, supabaseAdmin, userClaims, jwtClaims, authMode } = ctx
|
||||
// supabase — RLS-scoped to the authenticated user
|
||||
// supabaseAdmin — bypasses RLS (service role)
|
||||
// userClaims — user identity from JWT (id, email, role)
|
||||
// jwtClaims — full JWT claims
|
||||
// authMode — which auth mode matched
|
||||
|
||||
// your business logic goes here
|
||||
return Response.json({ email: ctx.userClaims?.email })
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
## Service-to-service calls
|
||||
|
||||
Cron jobs, workers, `pg_net`, or another Edge Function make calls with a secret key on the `apikey` header rather than a user JWT. Disable `verify_jwt` and use `auth: 'secret'` to validate the key against any secret key from your [dashboard](/dashboard/project/_/settings/api-keys). You get `ctx.supabaseAdmin` for privileged work.
|
||||
|
||||
```ts
|
||||
import { withSupabase } from 'npm:@supabase/server'
|
||||
|
||||
export default {
|
||||
fetch: withSupabase({ auth: 'secret' }, async (_req, ctx) => {
|
||||
// your business logic. ctx.supabaseAdmin bypasses RLS
|
||||
return Response.json({ ok: true })
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
To accept only one specific key, use `auth: 'secret:<name>'`. For example, `auth: 'secret:automations'` only accepts the secret key you named "automations" in the [**Settings > API keys**](/dashboard/project/_/settings/api-keys) section of the Dashboard. The same syntax works for publishable keys (`auth: 'publishable:<name>'`).
|
||||
|
||||

|
||||
|
||||
</Admonition>
|
||||
|
||||
Every request to an Edge Function passes through two layers of auth. First, a platform-level check (`verify_jwt`) runs before your code executes. Then, once the request reaches your handler, you decide what to do with the credentials the caller sent.
|
||||
## Public functions
|
||||
|
||||
## Understanding authorization headers
|
||||
|
||||
Edge Functions care about two request headers. Sending the wrong credential in the wrong header is the most common source of 401 errors.
|
||||
|
||||
| Header | Value | Used for |
|
||||
| --------------- | --------------------------------------- | -------------------------------------- |
|
||||
| `Authorization` | `Bearer <user-jwt>` | A user signed in through Supabase Auth |
|
||||
| `apikey` | `sb_publishable_...` or `sb_secret_...` | Calls from clients or services |
|
||||
|
||||
A common mistake is sending a publishable or secret key as a bearer token: `Authorization: Bearer sb_publishable_...`. The new API keys are not JWTs. The platform check can't validate them, and your handler can't verify them as JWTs either. Instead, put API keys in the `apikey` header.
|
||||
|
||||
You can send both headers together. A signed-in user calling your function through `supabase-js`, for example, sends their session JWT in `Authorization` and the project's publishable key in `apikey`.
|
||||
|
||||
## The `verify_jwt` platform check
|
||||
|
||||
When `verify_jwt` is enabled (the default), the platform inspects the `Authorization` header of every request before your function runs. It expects a valid user JWT. If the header is missing, malformed, or signed with a different key, the platform returns a 401 error, and your code never executes.
|
||||
|
||||
The check validates legacy HS256 JWTs and JWTs signed with the new asymmetric [signing keys](/docs/guides/auth/signing-keys).
|
||||
|
||||
The check does not accept an API key. Publishable and secret keys are not JWTs, so callers that send one in the `Authorization` header fail the check before their request reaches your handler.
|
||||
|
||||
Use the `verify_jwt` flag to match how the function is called:
|
||||
|
||||
- **Leave `verify_jwt` on** for functions that are only called with a user JWT, such as functions invoked from the client through `supabase.functions.invoke`. The platform rejects unauthenticated requests before they reach your code, and your handler can trust that a valid JWT is present.
|
||||
- **Turn `verify_jwt` off** for functions that are called without an `Authorization` header, such as webhooks from external providers, or service-to-service calls that authenticate with an API key. These patterns are covered later in the guide.
|
||||
|
||||
Set the flag per function in `supabase/config.toml`:
|
||||
|
||||
```toml
|
||||
[functions.stripe-webhook]
|
||||
verify_jwt = false
|
||||
```
|
||||
|
||||
For 401 failure modes and how to diagnose them, see [Edge Function 401 error response](/docs/troubleshooting/edge-function-401-error-response).
|
||||
|
||||
## Common auth patterns
|
||||
|
||||
The sections below show the four patterns you'll reach for most often, written without an SDK so the auth moves are visible. Business logic is left as a placeholder. The next section shows the same four patterns using [`@supabase/server`](https://github.com/supabase/server).
|
||||
|
||||
### Authenticated user calls
|
||||
|
||||
Keep `verify_jwt` enabled. The platform validates the JWT before your handler runs. Forward the `Authorization` header to the Supabase client so queries run under the caller's RLS policies.
|
||||
|
||||
```toml
|
||||
[functions.notes]
|
||||
verify_jwt = true
|
||||
```
|
||||
|
||||
```ts
|
||||
import { createClient } from 'npm:@supabase/supabase-js@2'
|
||||
|
||||
const SUPABASE_PUBLISHABLE_KEYS = JSON.parse(Deno.env.get('SUPABASE_PUBLISHABLE_KEYS')!)
|
||||
|
||||
Deno.serve((req) => {
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
SUPABASE_PUBLISHABLE_KEYS['default'],
|
||||
{ global: { headers: { Authorization: req.headers.get('Authorization')! } } }
|
||||
)
|
||||
|
||||
// your business logic. queries run as the caller
|
||||
return Response.json({ ok: true })
|
||||
})
|
||||
```
|
||||
|
||||
### Service-to-service calls
|
||||
|
||||
Cron jobs, workers, `pg_net`, or another Edge Functions make calls with a secret key on the `apikey` header. These callers don't send a user JWT, so disable `verify_jwt` and validate the key yourself.
|
||||
|
||||
```toml
|
||||
[functions.run-automations]
|
||||
verify_jwt = false
|
||||
```
|
||||
|
||||
```ts
|
||||
import { createClient } from 'npm:@supabase/supabase-js@2'
|
||||
|
||||
const SUPABASE_SECRET_KEYS = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)
|
||||
|
||||
Deno.serve((req) => {
|
||||
if (req.headers.get('apikey') !== Deno.env.get('INTERNAL_AUTOMATIONS_KEY')) {
|
||||
return Response.json({ error: 'forbidden' }, { status: 401 })
|
||||
}
|
||||
|
||||
const supabase = createClient(Deno.env.get('SUPABASE_URL')!, SUPABASE_SECRET_KEYS['default'])
|
||||
|
||||
// your business logic. queries run with the service role
|
||||
return Response.json({ ok: true })
|
||||
})
|
||||
```
|
||||
|
||||
<Admonition type="tip">
|
||||
|
||||
Never expose a secret key to the browser. Store it as a [function secret](/docs/guides/functions/secrets).
|
||||
|
||||
</Admonition>
|
||||
|
||||
### Public functions
|
||||
|
||||
For a genuinely public function, like a health check, no credential is required. Disable `verify_jwt` so anonymous callers can reach the handler.
|
||||
For a genuinely public function, like a health check, use `auth: 'none'` with `verify_jwt = false` so anonymous callers can reach the handler.
|
||||
|
||||
```toml
|
||||
[functions.health]
|
||||
verify_jwt = false
|
||||
```
|
||||
|
||||
```ts
|
||||
Deno.serve(() => {
|
||||
// your business logic
|
||||
return Response.json({ ok: true })
|
||||
})
|
||||
```
|
||||
|
||||
### External webhooks
|
||||
|
||||
External providers like Stripe or GitHub don't send Supabase credentials. They sign the request body with their own shared secret. Disable `verify_jwt` and verify the signature before acting on the payload.
|
||||
|
||||
```toml
|
||||
[functions.stripe-webhook]
|
||||
verify_jwt = false
|
||||
```
|
||||
|
||||
```ts
|
||||
import Stripe from 'npm:stripe'
|
||||
|
||||
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const signature = req.headers.get('stripe-signature') ?? ''
|
||||
const body = await req.text()
|
||||
|
||||
try {
|
||||
stripe.webhooks.constructEvent(body, signature, Deno.env.get('STRIPE_WEBHOOK_SECRET')!)
|
||||
} catch {
|
||||
return new Response('bad signature', { status: 400 })
|
||||
}
|
||||
|
||||
// your business logic. handle the event
|
||||
return Response.json({ received: true })
|
||||
})
|
||||
```
|
||||
|
||||
## Simplifying with `@supabase/server`
|
||||
|
||||
The [`@supabase/server`](https://github.com/supabase/server) package wraps your handler, checks the caller's credentials against a declared `auth` mode, and hands you a pre-configured Supabase client on `ctx`. The same patterns above, written against the SDK, look like this.
|
||||
|
||||
| Mode | Accepts |
|
||||
| ---------------------- | ------------------------------------------ |
|
||||
| `'user'` | A valid user JWT on `Authorization` |
|
||||
| `'secret:<name>'` | A named secret key on `apikey` |
|
||||
| `'publishable:<name>'` | A named publishable key on `apikey` |
|
||||
| `'none'` | Any caller, no check (for signed webhooks) |
|
||||
|
||||
<Admonition type="tip">
|
||||
|
||||
See the [`@supabase/server` docs](https://github.com/supabase/server) for the full list of modes.
|
||||
|
||||
</Admonition>
|
||||
|
||||
### Authenticated user calls [#authenticated-user-calls-with-server-sdk]
|
||||
|
||||
`auth: 'user'` pairs with `verify_jwt = true`. The platform validates the JWT, and the SDK hands you `ctx.supabase` already scoped to the caller.
|
||||
|
||||
```ts
|
||||
import { withSupabase } from 'npm:@supabase/server'
|
||||
|
||||
export default {
|
||||
fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => {
|
||||
// your business logic. ctx.supabase is scoped to the caller
|
||||
return Response.json({ email: ctx.userClaims?.email })
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
### Service-to-service calls [#service-to-service-calls-with-server-sdk]
|
||||
|
||||
`auth: 'secret:<name>'` validates the `apikey` header against the named secret key from your [dashboard](/dashboard/project/_/settings/api-keys) and gives you `ctx.supabaseAdmin` for privileged work. The `<name>` matches the name you gave the key. Keep `verify_jwt = false`.
|
||||
|
||||
```ts
|
||||
import { withSupabase } from 'npm:@supabase/server'
|
||||
|
||||
export default {
|
||||
fetch: withSupabase({ auth: 'secret:automations' }, async (_req, ctx) => {
|
||||
// your business logic. ctx.supabaseAdmin bypasses RLS
|
||||
fetch: withSupabase({ auth: 'none' }, async () => {
|
||||
// your business logic
|
||||
return Response.json({ ok: true })
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
<Admonition type="tip">
|
||||
`auth: 'none'` skips every credential check — see the caution under [External webhooks](#external-webhooks) before using it on anything that reads or writes sensitive data.
|
||||
|
||||
Create a named secret key for each caller in the [**Settings > API keys**](/dashboard/project/_/settings/api-keys) section of the Dashboard. Give it a name like "automations", and share the generated `sb_secret_...` value with the service that calls this function.
|
||||
## External webhooks
|
||||
|
||||

|
||||
|
||||
</Admonition>
|
||||
|
||||
### Public functions [#public-functions-with-server-sdk]
|
||||
|
||||
The SDK adds nothing to a truly public function. Use the raw pattern from the previous section. If you need a Supabase client anyway, `auth: 'none'` with `verify_jwt = false` skips every check and treats every caller as anonymous.
|
||||
|
||||
### External webhooks [#external-webhooks-with-server-sdk]
|
||||
|
||||
Use `auth: 'none'` to skip the SDK's credential check, then verify the provider's signature inside the handler. Keep `verify_jwt = false`.
|
||||
External providers like Stripe or GitHub don't send Supabase credentials. They sign the request body with their own shared secret. Use `auth: 'none'` to skip the SDK's credential check, then verify the provider's signature inside the handler. Keep `verify_jwt = false`.
|
||||
|
||||
```ts
|
||||
import { withSupabase } from 'npm:@supabase/server'
|
||||
@@ -247,7 +116,7 @@ export default {
|
||||
|
||||
</Admonition>
|
||||
|
||||
### Combining modes
|
||||
## Combining modes
|
||||
|
||||
Functions that answer both users and internal callers take an array on `auth`. Modes are tried in order. The first match wins, and `ctx.authMode` tells you which matched.
|
||||
|
||||
@@ -255,7 +124,7 @@ Functions that answer both users and internal callers take an array on `auth`. M
|
||||
import { withSupabase } from 'npm:@supabase/server'
|
||||
|
||||
export default {
|
||||
fetch: withSupabase({ auth: ['user', 'secret:automations'] }, async (req, ctx) => {
|
||||
fetch: withSupabase({ auth: ['user', 'secret'] }, async (req, ctx) => {
|
||||
if (ctx.authMode === 'user') {
|
||||
// your business logic for user calls. ctx.supabase is scoped to them
|
||||
return Response.json({ ok: true })
|
||||
@@ -267,7 +136,7 @@ export default {
|
||||
}
|
||||
```
|
||||
|
||||
### Custom error responses
|
||||
## Custom error responses
|
||||
|
||||
To shape the 401 response yourself, use `createSupabaseContext` instead of `withSupabase`. It returns a `{ data, error }` tuple so you stay in control.
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Get the project ID associated with your function:
|
||||
supabase projects list
|
||||
```
|
||||
|
||||
<Admonition type="tip" label="Need a new project?">
|
||||
<Admonition type="tip" title="Need a new project?">
|
||||
|
||||
If you haven't yet created a Supabase project, you can do so by visiting [database.new](https://database.new).
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Here are a few recommendations when you first start developing Edge Functions.
|
||||
|
||||
Edge Functions support `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, and `OPTIONS`. A Function can be designed to perform different actions based on a request's HTTP method. See the [example on building a RESTful service](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/restful-tasks) to learn how to handle different HTTP methods in your Function.
|
||||
|
||||
<Admonition type="caution" label="HTML not supported">
|
||||
<Admonition type="caution" title="HTML not supported">
|
||||
|
||||
HTML content is not supported. `GET` requests that return `text/html` will be rewritten to `text/plain`.
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@ Supabase allows you to create Supabase Edge Functions directly from the Supabase
|
||||
|
||||
This guide will walk you through creating, testing, and deploying your first Edge Function using the Supabase Dashboard. You'll have a working function running globally in under 10 minutes.
|
||||
|
||||
<Admonition type="tip" label="Prefer using the CLI?">
|
||||
<Admonition type="tip" title="Prefer using the CLI?">
|
||||
|
||||
You can also create and deploy functions using the Supabase CLI. Check out our [CLI Quickstart guide](/docs/guides/functions/quickstart).
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Admonition type="note" label="New to Supabase?">
|
||||
<Admonition type="note" title="New to Supabase?">
|
||||
|
||||
You'll need a Supabase project to get started. If you don't have one yet, create a new project at [database.new](https://database.new/).
|
||||
|
||||
@@ -50,7 +50,7 @@ width={2338}
|
||||
height={926}
|
||||
/>
|
||||
|
||||
<Admonition type="note" label="Pre-built templates">
|
||||
<Admonition type="note" title="Pre-built templates">
|
||||
|
||||
The dashboard offers several pre-built templates for common use cases, such as Stripe Webhooks, OpenAI proxying, uploading files to Supabase Storage, and sending emails.
|
||||
|
||||
@@ -139,7 +139,7 @@ To invoke this Edge Function from within your application, you'll need API keys.
|
||||
|
||||
If you’d like to update the deployed function code, click on the function you want to edit, modify the code as needed, then click Deploy updates. This will overwrite the existing deployment with the newly edited function code.
|
||||
|
||||
<Admonition type="caution" label="No version control">
|
||||
<Admonition type="caution" title="No version control">
|
||||
|
||||
There is currently **no version control** for edits! The Dashboard's Edge Function editor currently does not support version control, versioning, or rollbacks. We recommend using it only for quick testing and prototypes.
|
||||
|
||||
@@ -241,7 +241,7 @@ Now that your function is deployed, you can access it from your local developmen
|
||||
|
||||
### CLI
|
||||
|
||||
<Admonition type="note" label="CLI not installed?">
|
||||
<Admonition type="note" title="CLI not installed?">
|
||||
|
||||
Before getting started, make sure you have the **Supabase CLI installed**. Check out the [CLI installation guide](/docs/guides/cli) for installation methods and troubleshooting.
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ supabase functions new hello-world
|
||||
|
||||
{/* TODO: Link to parameter documentation */}
|
||||
|
||||
<Admonition type="tip" label="Secure your function with Supabase Auth">
|
||||
<Admonition type="tip" title="Secure your function with Supabase Auth">
|
||||
|
||||
When an HTTP request is sent to Edge Functions, you can use Supabase Auth to secure endpoints. By default, the `supabase functions new` command adds handling a valid publishable or secret key to the basic template. However, you can change this behavior with the `--auth` flag when creating a new function.
|
||||
|
||||
@@ -132,7 +132,7 @@ After this step, you should have successfully tested your Edge Function locally
|
||||
|
||||
To deploy your function globally, you need to connect your local project to a Supabase project.
|
||||
|
||||
<Admonition type="tip" label="Need to create a new Supabase project?">
|
||||
<Admonition type="tip" title="Need to create a new Supabase project?">
|
||||
|
||||
Create one at [database.new](https://database.new/).
|
||||
|
||||
@@ -176,7 +176,7 @@ If you want to deploy all functions, run the `deploy` command without specifying
|
||||
supabase functions deploy
|
||||
```
|
||||
|
||||
<Admonition type="note" label="Docker not required">
|
||||
<Admonition type="note" title="Docker not required">
|
||||
|
||||
The CLI automatically falls back to API-based deployment if Docker isn't available. You can also explicitly use API deployment with the `--use-api` flag:
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ You can see the output in the [Edge Function Logs](/docs/guides/functions/loggin
|
||||
|
||||
### 546 Resource Limit (Custom Error Code)
|
||||
|
||||
**Cause:** Your Edge Function execution was stopped due to exceeding resource limits (`WORKER_LIMIT`). Edge Function logs should provide which [resource limit](/docs/guides/functions/limits) was exceeded.
|
||||
**Cause:** Your Edge Function execution was stopped due to exceeding resource limits (`WORKER_RESOURCE_LIMIT`, previously it was `WORKER_LIMIT`). Edge Function logs should provide which [resource limit](/docs/guides/functions/limits) was exceeded.
|
||||
|
||||
**Common causes:**
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ Optionally, update `src/styles.css` to style the app. You can find the full cont
|
||||
|
||||
You need an Angular component to manage logins and sign ups. The component uses [Magic Links](/docs/guides/auth/auth-email-passwordless#with-magic-link), so users can sign in with their email without using passwords.
|
||||
|
||||
<Admonition type="tip" label="Did you know?">
|
||||
<Admonition type="tip" title="Did you know?">
|
||||
|
||||
You can customize other emails sent out to new users, including the email's looks, content, and query parameters from [the **Authentication > Email**](/dashboard/project/_/auth/templates) section of the Dashboard.
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ Before proceeding, change the email template to support a server-side authentica
|
||||
- Select the **Confirm signup** template.
|
||||
- Change `{{ .ConfirmationURL }}` to `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email`.
|
||||
|
||||
<Admonition type="tip" label="Did you know?">
|
||||
<Admonition type="tip" title="Did you know?">
|
||||
|
||||
You can customize other emails sent out to new users, including the email's looks, content, and query parameters from [the **Authentication > Email**](/dashboard/project/_/auth/templates) section of the Dashboard.
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ You can find the full contents of this file [in the example repository](https://
|
||||
|
||||
You need a React component to manage logins and sign-ups. It uses [Magic Links](/docs/guides/auth/auth-email-passwordless#with-magic-link), so users can sign in with their email without using passwords.
|
||||
|
||||
<Admonition type="tip" label="Did you know?">
|
||||
<Admonition type="tip" title="Did you know?">
|
||||
|
||||
You can customize other emails sent out to new users, including the email's looks, content, and query parameters from [the **Authentication > Email**](/dashboard/project/_/auth/templates) section of the Dashboard.
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ Before proceeding, change the email template to support sending a token hash:
|
||||
- Change `{{ .ConfirmationURL }}` to `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email`.
|
||||
- Repeat the previous step for **Magic link** template.
|
||||
|
||||
<Admonition type="tip" label="Did you know?">
|
||||
<Admonition type="tip" title="Did you know?">
|
||||
|
||||
You can also customize emails sent out to new users, including the email's looks, content, and query parameters. Check out the [settings of your project](/dashboard/project/_/auth/templates).
|
||||
|
||||
|
||||
@@ -234,7 +234,7 @@ npm update supabase@beta --save-dev
|
||||
|
||||
If you have any Supabase containers running locally, stop them and delete their data volumes before proceeding with the upgrade. This ensures that Supabase managed services can apply new migrations on a clean state of the local database.
|
||||
|
||||
<Admonition type="tip" label="Backup and stop running containers">
|
||||
<Admonition type="tip" title="Backup and stop running containers">
|
||||
|
||||
Remember to save any local schema and data changes before stopping because the `--no-backup` flag will delete them.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ When you delete a project, we permanently remove all associated data, including
|
||||
|
||||
Database backups can be categorized into two types: **logical** and **physical**. You can learn more about them [in this blog post](/blog/postgresql-physical-logical-backups).
|
||||
|
||||
<Admonition type="note" label="Physical backups are now enabled by default">
|
||||
<Admonition type="note" title="Physical backups are now enabled by default">
|
||||
|
||||
All projects on Postgres `15.8.1.079` and newer use the newer physical backup process.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ title: Restore to a new project
|
||||
subtitle: How to clone your existing Supabase project
|
||||
---
|
||||
|
||||
<Admonition type="note" label="Beta Version">
|
||||
<Admonition type="note" title="Beta Version">
|
||||
|
||||
You can clone your Supabase project by restoring your data from an existing project into a completely new one. This process creates a database-only copy and requires manual reconfiguration to fully replicate your original project.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Every project on the Supabase Platform comes with its own dedicated Postgres ins
|
||||
|
||||
The following table describes the base instances, Nano (free plan) and Micro (paid plans), with additional compute instance sizes available if you need extra performance when scaling up.
|
||||
|
||||
<Admonition type="note" label="Nano instances in paid plan organizations">
|
||||
<Admonition type="note" title="Nano instances in paid plan organizations">
|
||||
|
||||
In paid organizations, Nano Compute are billed at the same price as Micro Compute. It is recommended to upgrade your Project from Nano Compute to Micro Compute when it's convenient for you. Compute sizes are not auto-upgraded because of the downtime incurred. See [Supabase Pricing](/pricing) for more information. You cannot launch Nano instances on paid plans, only Micro and above - but you might have Nano instances after upgrading from Free Plan.
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ Paid plans include <Price price="10" /> in Compute Credits, which cover one proj
|
||||
|
||||
[^1]: Compute resources on the Free Plan are subject to change.
|
||||
|
||||
<Admonition type="note" label="Nano Compute size in paid plan organizations">
|
||||
<Admonition type="note" title="Nano Compute size in paid plan organizations">
|
||||
|
||||
In paid organizations, Nano Compute are billed at the same price as Micro Compute. It is recommended to upgrade your Project from Nano Compute to Micro Compute when it's convenient for you. Compute sizes are not auto-upgraded because of the downtime incurred. See [Supabase Pricing](/pricing) for more information. You cannot launch Nano instances on paid plans, only Micro and above - but you might have Nano instances after upgrading from Free Plan.
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ Migrate existing users to Supabase Auth. This requires two main steps: first, ch
|
||||
})
|
||||
```
|
||||
|
||||
<Admonition type="note" label="Supported password hashing algorithms">
|
||||
<Admonition type="note" title="Supported password hashing algorithms">
|
||||
|
||||
Supabase supports bcrypt and Argon2 password hashes.
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ Once configured, you can update your settings anytime from [the **SSO** section]
|
||||
|
||||

|
||||
|
||||
<Admonition type="tip" label="Testing your SSO configuration">
|
||||
<Admonition type="tip" title="Testing your SSO configuration">
|
||||
|
||||
After configuring your SSO provider, thorough testing is essential. See our [SSO Testing and Best Practices](/docs/guides/platform/sso/testing-best-practices) guide for:
|
||||
|
||||
@@ -100,13 +100,13 @@ When SSO is enabled for an organization:
|
||||
6. Thoroughly test using our [SSO Testing and Best Practices](/docs/guides/platform/sso/testing-best-practices) guide
|
||||
7. Invite users to the organization or let them auto-join on login
|
||||
|
||||
<Admonition type="note" label="Account linking">
|
||||
<Admonition type="note" title="Account linking">
|
||||
|
||||
If a user is already a member of the organization under a non-SSO account, they will need to be removed and invited again with an SSO-required invitation to join under their SSO account. SSO and non-SSO accounts with the same email are treated as separate accounts.
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Admonition type="note" label="No automatic linking">
|
||||
<Admonition type="note" title="No automatic linking">
|
||||
|
||||
Each user account verified using a SSO identity provider will not be automatically linked to existing user accounts in the system. That is, if a user `valid.email@supabase.io` had signed up with a password, and then uses their company SSO login with your project, there will be two `valid.email@supabase.io` user accounts in the system.
|
||||
|
||||
@@ -118,7 +118,7 @@ Users will need to ensure they are logged in with the correct account when accep
|
||||
|
||||
If you disable or delete the SSO provider for an organization, **all SSO users will immediately be unable to sign in**.
|
||||
|
||||
<Admonition type="caution" label="Safety requirement">
|
||||
<Admonition type="caution" title="Safety requirement">
|
||||
|
||||
The system requires at least one non-SSO owner account before allowing SSO provider deletion. This prevents complete organization lockout. When you delete an SSO provider, all SSO members are automatically removed from the organization.
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ Before rolling out SSO to your organization, we strongly recommend thorough test
|
||||
- Security best practices
|
||||
- Pre-launch checklist
|
||||
|
||||
<Admonition type="note" label="Testing in Azure sandbox">
|
||||
<Admonition type="note" title="Testing in Azure sandbox">
|
||||
|
||||
If your organization has an Azure sandbox or test tenant, consider testing your SSO configuration there first before applying to production.
|
||||
|
||||
|
||||
@@ -240,7 +240,7 @@ Do users need to start login at supabase.com?
|
||||
4. Use **non-SSO invitations** for contractors
|
||||
5. Consider disabling auto-join to control membership
|
||||
|
||||
<Admonition type="caution" label="Account linking caution">
|
||||
<Admonition type="caution" title="Account linking caution">
|
||||
|
||||
SSO and non-SSO accounts with the same email are treated as separate accounts. An employee with `alice@company.com` will have two accounts if they:
|
||||
|
||||
@@ -306,7 +306,7 @@ If you're uncertain which configuration to use:
|
||||
4. Enable SP-initiated if users request it
|
||||
5. Monitor usage to see which flow is preferred
|
||||
|
||||
<Admonition type="tip" label="Support available">
|
||||
<Admonition type="tip" title="Support available">
|
||||
|
||||
If you need help choosing the right configuration for your organization, contact Supabase support with details about your use case. We're happy to provide personalized recommendations.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: 'Learn about IdP-initiated and SP-initiated SSO login flows and whe
|
||||
|
||||
When configuring SSO for your organization, you can choose between two different login flows: **identity provider (IdP)-initiated** and **service provider (SP)-initiated**. Understanding the difference helps you provide the best experience for your users.
|
||||
|
||||
<Admonition type="tip" label="Quick decision guide">
|
||||
<Admonition type="tip" title="Quick decision guide">
|
||||
|
||||
Most enterprises use IdP-initiated flow for its simplicity and better user experience. Enable SP-initiated only if you need users to start their login journey at supabase.com.
|
||||
|
||||
@@ -107,7 +107,7 @@ IdP-initiated flow is automatically enabled when you configure SSO. No additiona
|
||||
|
||||
Users can now access Supabase through your IdP's app catalog.
|
||||
|
||||
<Admonition type="note" label="Domain configuration optional">
|
||||
<Admonition type="note" title="Domain configuration optional">
|
||||
|
||||
With IdP-initiated flow, you don't need to configure email domains. Your identity provider handles all authentication routing.
|
||||
|
||||
@@ -131,7 +131,7 @@ To enable SP-initiated flow, you need to configure email domains:
|
||||
- Multiple domains supported (e.g., `company.com`, `subsidiary.com`)
|
||||
- Users with matching email domains will be routed to your IdP
|
||||
|
||||
<Admonition type="caution" label="Domain restrictions apply">
|
||||
<Admonition type="caution" title="Domain restrictions apply">
|
||||
|
||||
Only users with email addresses matching your configured domains can use SP-initiated login. Users with other domains cannot sign in via SSO at supabase.com (but can still use IdP-initiated flow if you configure it in your IdP).
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ The traditional challenge with multiple SAML apps is domain conflicts. With SP-i
|
||||
|
||||
**Solution:** Use identity provider (IdP)-initiated flow, which doesn't require domain configuration. You can create unlimited SAML apps under the same domain.
|
||||
|
||||
<Admonition type="tip" label="Recommended pattern">
|
||||
<Admonition type="tip" title="Recommended pattern">
|
||||
|
||||
Configure each environment as IdP-initiated only (no domains). Users access each environment through different app tiles in your identity provider.
|
||||
|
||||
@@ -126,7 +126,7 @@ Configure both organizations with SP-initiated enabled using the same domain:
|
||||
- System routes based on org membership (first match wins)
|
||||
- Also provide IDP tiles for explicit routing
|
||||
|
||||
<Admonition type="caution" label="SP-initiated routing with multiple providers">
|
||||
<Admonition type="caution" title="SP-initiated routing with multiple providers">
|
||||
|
||||
When multiple organizations use SP-initiated with the same domain, the first provider where the user is a member will be used. This can cause confusion. **IDP-initiated is recommended** for clarity.
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ When you exceed a limit, errors will appear in the backend logs and client-side
|
||||
- **Logs**: check the [Realtime logs](/dashboard/project/_/database/realtime-logs) inside your project Dashboard.
|
||||
- **WebSocket errors**: Use your browser's developer tools to find the WebSocket initiation request and view individual messages.
|
||||
|
||||
<Admonition type="tip" label="Realtime Inspector">
|
||||
<Admonition type="tip" title="Realtime Inspector">
|
||||
|
||||
You can use the [Realtime Inspector](https://realtime.supabase.com/inspector/new) to reproduce an error and share those connection details with Supabase support.
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ Review and change URL configuration variables:
|
||||
- `API_EXTERNAL_URL`: used by the Auth service to configure callback URLs, e.g., `http://example.com:8000`
|
||||
- `SITE_URL`: default [redirect URL](/docs/guides/auth/redirect-urls) for Auth, e.g., `http://example.com:3000`
|
||||
|
||||
<Admonition type="note" label="What your-domain means in the docs">
|
||||
<Admonition type="note" title="What your-domain means in the docs">
|
||||
|
||||
Throughout the self-hosting guides, `<your-domain>` stands for the host where your Supabase instance is reachable: your domain name, your server's IP, or `localhost`, depending on your setup.
|
||||
|
||||
@@ -236,7 +236,7 @@ To stop Supabase, use:
|
||||
docker compose down
|
||||
```
|
||||
|
||||
<Admonition type="caution" label="Windows: CRLF line endings">
|
||||
<Admonition type="caution" title="Windows: CRLF line endings">
|
||||
|
||||
If the API gateway (Kong) fails to start with an entrypoint error, your local files may have been checked out with CRLF line endings instead of LF. Re-clone the repository, or normalize everything in the `docker/` directory to LF, then restart Supabase. Fresh clones should already use LF because of `.gitattributes`.
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ Upgrading an existing deployment uses `pg_upgrade` to migrate data in place. The
|
||||
|
||||
### Create a backup
|
||||
|
||||
<Admonition type="danger" label="Back up your data before upgrading">
|
||||
<Admonition type="danger" title="Back up your data before upgrading">
|
||||
|
||||
You should create your own independent backup in case of disk failure or other issues.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ You need:
|
||||
|
||||
Below are two options for adding a reverse proxy with automatic HTTPS in front of your self-hosted Supabase: **Caddy** (simpler, zero-config TLS) and **Nginx + Let's Encrypt** (more control over proxy settings). Both sit in front of the API gateway and terminate TLS, so internal traffic stays on HTTP.
|
||||
|
||||
<Admonition type="tip" label="Using a different reverse proxy?">
|
||||
<Admonition type="tip" title="Using a different reverse proxy?">
|
||||
|
||||
If you already run [HAProxy](https://www.haproxy.com/), [Traefik](https://traefik.io/), [Nginx Proxy Manager](https://nginxproxymanager.com/), or another reverse proxy for your infrastructure, you can use it instead of Caddy or Nginx above. The key requirements are:
|
||||
|
||||
@@ -31,7 +31,7 @@ If you already run [HAProxy](https://www.haproxy.com/), [Traefik](https://traefi
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Admonition type="note" label="Using Envoy instead of Kong?">
|
||||
<Admonition type="note" title="Using Envoy instead of Kong?">
|
||||
|
||||
Envoy is an optional [API gateway](/docs/guides/self-hosting/self-hosted-envoy), enabled via the `docker-compose.envoy.yml` override. If you already run Envoy instead of Kong, edit `docker-compose.caddy.yml` or `docker-compose.nginx.yml` to comment out the `kong:` block and uncomment the `api-gw:` block (and the matching `depends_on` entry) so the reverse proxy sits in front of Envoy.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ You have two options to authenticate with Supabase Storage S3:
|
||||
|
||||
## S3 access keys
|
||||
|
||||
<Admonition type="danger" label="Keep these credentials secure">
|
||||
<Admonition type="danger" title="Keep these credentials secure">
|
||||
|
||||
S3 access keys provide full access to all S3 operations across all buckets and bypass RLS policies. These are meant to be used only on the server.
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
---
|
||||
title = "546 - WORKER_LIMIT Exceeded"
|
||||
title = "546 - WORKER_RESOURCE_LIMIT Exceeded / WORKER_LIMIT Exceeded"
|
||||
topics = [ "functions" ]
|
||||
keywords = [ "546", "error", "resource", "memory", "cpu", "event loop", "edge function" ]
|
||||
database_id = "4e6ff2e4-2abb-4fba-8233-5883b3d56fb0"
|
||||
|
||||
[[errors]]
|
||||
http_status_code = 546
|
||||
message = "WORKER_RESOURCE_LIMIT"
|
||||
|
||||
[[errors]]
|
||||
http_status_code = 546
|
||||
message = "WORKER_LIMIT"
|
||||
---
|
||||
|
||||
A 546 error indicates that an edge function used more resources (CPU or Memory) than it was allocated.
|
||||
A 546 error indicates that an edge function used more resources (CPU or Memory) than it was allocated. Previously it was `WORKER_LIMIT`.
|
||||
|
||||
## Context for the error
|
||||
|
||||
@@ -37,7 +41,7 @@ When an edge function fails due to internal CPU or memory limits, it will return
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "WORKER_LIMIT",
|
||||
"code": "WORKER_RESOURCE_LIMIT",
|
||||
"message": "Function failed due to not having enough compute resources (please check logs)"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -70,4 +70,4 @@ The timeout limit is set to prevent long-running queries which can cause perform
|
||||
|
||||
#### 546 Edge Functions resource limit
|
||||
|
||||
Applies only to Edge Functions. Function execution was stopped due to a resource limit (`WORKER_LIMIT`). Edge Function logs should provide which [resource limit](/docs/guides/functions/limits) was exceeded.
|
||||
Applies only to Edge Functions. Function execution was stopped due to a resource limit (`WORKER_RESOURCE_LIMIT`). Edge Function logs should provide which [resource limit](/docs/guides/functions/limits) was exceeded.
|
||||
@@ -18,7 +18,7 @@ The Supabase Analytics server is a Logflare self-hostable instance that manages
|
||||
When self-hosting the Analytics server, the full logging experience matching that of the Supabase Platform is available in the Studio instance, allowing for an integrated and enhanced development experience.
|
||||
However, it's important to note that certain [differences](#differences) may arise due to the platform's infrastructure.
|
||||
|
||||
<Admonition type="note" label="Logflare Technical Docs">
|
||||
<Admonition type="note" title="Logflare Technical Docs">
|
||||
|
||||
All Logflare technical documentation is available at https://docs.logflare.app.
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ You can use it to:
|
||||
- Locally test and self-host Supabase's Edge Functions (or any Deno Edge Function)
|
||||
- As a programmable HTTP Proxy: You can intercept / route HTTP requests
|
||||
|
||||
<Admonition type="note" label="Beta Version">
|
||||
<Admonition type="note" title="Beta Version">
|
||||
|
||||
Self hosted Edge functions are in beta. There will be breaking changes to APIs / Configuration Options.
|
||||
|
||||
|
||||
@@ -25,10 +25,9 @@ import { formatForeignKeys } from '@/components/interfaces/TableGridEditor/SideP
|
||||
import { useForeignKeyConstraintsQuery } from '@/data/database/foreign-key-constraints-query'
|
||||
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
|
||||
import { isTableLike } from '@/data/table-editor/table-editor-types'
|
||||
import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useCsvFileDrop } from '@/hooks/ui/useCsvFileDrop'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
import { useTableEditorStateSnapshot } from '@/state/table-editor'
|
||||
import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table'
|
||||
import { ResponseError } from '@/types'
|
||||
@@ -69,7 +68,6 @@ export const Grid = memo(
|
||||
const snap = useTableEditorTableStateSnapshot()
|
||||
const { filters, clearFilters } = useTableFilter()
|
||||
|
||||
const { data: org } = useSelectedOrganizationQuery()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const onRowsChange = useOnRowsChange(rows)
|
||||
@@ -92,20 +90,12 @@ export const Grid = memo(
|
||||
const isForeignTable = tableEntityType === ENTITY_TYPE.FOREIGN_TABLE
|
||||
const isTableEmpty = (rows ?? []).length === 0
|
||||
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
const track = useTrack()
|
||||
|
||||
const { isDraggedOver, onDragOver, onFileDrop } = useCsvFileDrop({
|
||||
enabled: isTableEmpty && !isForeignTable,
|
||||
onFileDropped: (file) => tableEditorSnap.onImportData(valtioRef(file)),
|
||||
onTelemetryEvent: (eventName) => {
|
||||
sendEvent({
|
||||
action: eventName,
|
||||
groups: {
|
||||
project: project?.ref ?? 'Unknown',
|
||||
organization: org?.slug ?? 'Unknown',
|
||||
},
|
||||
})
|
||||
},
|
||||
onTelemetryEvent: (eventName) => track(eventName),
|
||||
})
|
||||
|
||||
const { data } = useForeignKeyConstraintsQuery({
|
||||
@@ -339,14 +329,7 @@ export const Grid = memo(
|
||||
className="pointer-events-auto"
|
||||
onClick={() => {
|
||||
tableEditorSnap.onImportData()
|
||||
sendEvent({
|
||||
action: 'import_data_button_clicked',
|
||||
properties: { tableType: 'Existing Table' },
|
||||
groups: {
|
||||
project: project?.ref ?? 'Unknown',
|
||||
organization: org?.slug ?? 'Unknown',
|
||||
},
|
||||
})
|
||||
track('import_data_button_clicked', { tableType: 'Existing Table' })
|
||||
}}
|
||||
>
|
||||
Import data from CSV
|
||||
|
||||
@@ -4,12 +4,11 @@ import { PropsWithChildren } from 'react'
|
||||
import { OrganizationResourceBanner } from '../Organization/HeaderBanner'
|
||||
import { ClockSkewBanner } from '@/components/layouts/AppLayout/ClockSkewBanner'
|
||||
import { FlyDeprecationBanner } from '@/components/layouts/AppLayout/FlyDeprecationBanner'
|
||||
import { NoticeBanner, NoticeBanner2 } from '@/components/layouts/AppLayout/NoticeBanner'
|
||||
import { NoticeBanner } from '@/components/layouts/AppLayout/NoticeBanner'
|
||||
import { StatusPageBanner } from '@/components/layouts/AppLayout/StatusPageBanner'
|
||||
|
||||
export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => {
|
||||
const showNoticeBanner = useFlag('showNoticeBanner')
|
||||
const showNoticeBanner2 = useFlag('showNoticeBanner2')
|
||||
const clockSkewBanner = useFlag('clockSkewBanner')
|
||||
|
||||
return (
|
||||
@@ -17,7 +16,6 @@ export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => {
|
||||
<div className="shrink-0">
|
||||
<StatusPageBanner />
|
||||
{showNoticeBanner && <NoticeBanner />}
|
||||
{showNoticeBanner2 && <NoticeBanner2 />}
|
||||
<FlyDeprecationBanner />
|
||||
<OrganizationResourceBanner />
|
||||
{/* Disabled until reintroduced or removed altogether. */}
|
||||
|
||||
@@ -35,10 +35,10 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
|
||||
},
|
||||
{
|
||||
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS,
|
||||
name: 'New Logs interface',
|
||||
name: 'Updated Logs interface',
|
||||
discussionsUrl: 'https://github.com/orgs/supabase/discussions/37234',
|
||||
enabled: isUnifiedLogsPreviewAvailable || isEnterpriseOrSupabaseOrg,
|
||||
isNew: false,
|
||||
isNew: true,
|
||||
isPlatformOnly: true,
|
||||
isDefaultOptIn: false,
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@ui/components/shadcn/ui/select'
|
||||
import { LOCAL_STORAGE_KEYS } from 'common'
|
||||
import { LOCAL_STORAGE_KEYS, useFlag } from 'common'
|
||||
import { Code, ExternalLink } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
@@ -35,6 +35,7 @@ import { type ParseQueryResults } from './RLSTester.types'
|
||||
import { RLSTesterEmptyState } from './RLSTesterEmptyState'
|
||||
import { RLSTesterResults } from './RLSTesterResults'
|
||||
import { RoleSelector } from './RoleSelector'
|
||||
import { SandboxManagement } from './SandboxManagement'
|
||||
import { UserSelector } from './UserSelector'
|
||||
import { UserSqlEditor } from './UserSqlEditor'
|
||||
import { useTestQueryRLS } from './useTestQueryRLS'
|
||||
@@ -44,6 +45,7 @@ import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown'
|
||||
import { FeaturePreviewBadge } from '@/components/ui/FeaturePreviewBadge'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
|
||||
import { PostgresSandboxProvider } from '@/state/postgres-sandbox/sandbox'
|
||||
import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state'
|
||||
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
|
||||
|
||||
@@ -51,11 +53,20 @@ interface RLSTesterSheetProps {
|
||||
handleSelectEditPolicy: (policy: Policy) => void
|
||||
}
|
||||
|
||||
export const RLSTesterSheet = ({ handleSelectEditPolicy }: RLSTesterSheetProps) => {
|
||||
export const RLSTesterSheet = (props: RLSTesterSheetProps) => {
|
||||
return (
|
||||
<PostgresSandboxProvider>
|
||||
<RLSTesterSheetContents {...props} />
|
||||
</PostgresSandboxProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const RLSTesterSheetContents = ({ handleSelectEditPolicy }: RLSTesterSheetProps) => {
|
||||
const track = useTrack()
|
||||
const aiSnap = useAiAssistantStateSnapshot()
|
||||
const { openSidebar } = useSidebarManagerSnapshot()
|
||||
const { setRole } = useRoleImpersonationStateSnapshot()
|
||||
const sandboxEnabled = useFlag('rlsTesterSandbox')
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selectedOption, setSelectedOption] = useState<'anon' | 'authenticated'>('anon')
|
||||
@@ -132,12 +143,13 @@ export const RLSTesterSheet = ({ handleSelectEditPolicy }: RLSTesterSheetProps)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setRole({ type: 'postgrest', role: 'anon' })
|
||||
// Flip back to service role
|
||||
return () => {
|
||||
if (open) {
|
||||
setRole({ type: 'postgrest', role: 'anon' })
|
||||
} else {
|
||||
// Flip back to service role
|
||||
setRole(undefined)
|
||||
}
|
||||
}, [setRole])
|
||||
}, [open, setRole])
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
@@ -159,7 +171,9 @@ export const RLSTesterSheet = ({ handleSelectEditPolicy }: RLSTesterSheetProps)
|
||||
</SheetHeader>
|
||||
|
||||
<div className="grow overflow-y-auto flex flex-col">
|
||||
<SheetSection className="px-0 py-0">
|
||||
{sandboxEnabled && <SandboxManagement />}
|
||||
|
||||
<SheetSection className="px-0 py-0 border-t">
|
||||
<div className="flex flex-col p-5 pt-4 gap-y-4">
|
||||
<RoleSelector onSelectRole={setSelectedOption} />
|
||||
{selectedOption === 'authenticated' && <UserSelector />}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Box, Loader2, LogOut, RefreshCw } from 'lucide-react'
|
||||
import { Badge, Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { usePostgresSandbox } from '@/state/postgres-sandbox/sandbox'
|
||||
|
||||
export const SandboxManagement = () => {
|
||||
const { status, error, isSyncing, startSandbox, destroySandbox, syncSandbox } =
|
||||
usePostgresSandbox()
|
||||
|
||||
if (status === 'idle') {
|
||||
return (
|
||||
<Admonition
|
||||
type="default"
|
||||
layout="horizontal"
|
||||
className="min-h-min border-none [&>div>div>div>div>p]:!mb-0"
|
||||
actions={[
|
||||
<Button key="set-up" type="default" onClick={() => startSandbox()}>
|
||||
Set up sandbox
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-foreground !m-0">Set up sandbox for testing</p>
|
||||
<Badge variant="success">Recommended</Badge>
|
||||
</div>
|
||||
<p className="text-foreground-light !m-0">
|
||||
Ensure that queries do not affect your actual database
|
||||
</p>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<Admonition
|
||||
showIcon={false}
|
||||
type="default"
|
||||
className="min-h-min border-none py-2 [&>div>div]:flex [&>div>div]:items-center [&>div>div]:justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<div className="bg w-6 h-6 rounded border border-border flex items-center justify-center">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
</div>
|
||||
<p className="text-xs !mb-0 font-mono uppercase tracking-tight">Setting up sandbox</p>
|
||||
</div>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<Admonition
|
||||
type="warning"
|
||||
layout="horizontal"
|
||||
title="Unable to set up sandbox"
|
||||
description={error ?? 'Please try again'}
|
||||
className="min-h-min border-none"
|
||||
actions={[
|
||||
<Button key="set-up" type="default" onClick={() => startSandbox()}>
|
||||
Retry set up
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Admonition
|
||||
showIcon={false}
|
||||
type="default"
|
||||
layout="horizontal"
|
||||
className="min-h-min border-none py-2 [&>div>div>div>div>p]:!mb-0 [&>div>div]:gap-x-2"
|
||||
actions={[
|
||||
<ButtonTooltip
|
||||
key="destroy"
|
||||
type="default"
|
||||
icon={<LogOut />}
|
||||
className="w-7"
|
||||
disabled={isSyncing}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Exit sandbox' } }}
|
||||
onClick={() => destroySandbox()}
|
||||
/>,
|
||||
<ButtonTooltip
|
||||
key="refresh"
|
||||
type="default"
|
||||
icon={<RefreshCw />}
|
||||
className="w-7"
|
||||
loading={isSyncing}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Refresh schema' } }}
|
||||
onClick={() => syncSandbox()}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<div className="bg-brand-300 w-6 h-6 rounded border border-brand-500 flex items-center justify-center">
|
||||
<Box size={14} className="text-brand" />
|
||||
</div>
|
||||
<p className="text-xs text-foreground font-mono uppercase tracking-tight">Sandbox active</p>
|
||||
<p className="text-xs text-foreground-lighter ">Your database is never modified</p>
|
||||
</div>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { useParseSQLQueryMutation } from '@/data/misc/parse-query-mutation'
|
||||
import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { wrapWithRoleImpersonation } from '@/lib/role-impersonation'
|
||||
import { usePostgresSandbox } from '@/state/postgres-sandbox/sandbox'
|
||||
import {
|
||||
isRoleImpersonationEnabled,
|
||||
useGetImpersonatedRoleState,
|
||||
@@ -35,20 +36,23 @@ export const useTestQueryRLS = () => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { role } = useRoleImpersonationStateSnapshot()
|
||||
|
||||
const { sandbox } = usePostgresSandbox()
|
||||
const getImpersonatedRoleState = useGetImpersonatedRoleState()
|
||||
const impersonatedRoleState = getImpersonatedRoleState()
|
||||
const user = useImpersonatedUser()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [sandboxError, setSandboxError] = useState<Error>()
|
||||
|
||||
const { data: policies = [] } = useDatabasePoliciesQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const { mutateAsync: executeSql, error: executeSqlError } = useExecuteSqlMutation({
|
||||
const { mutateAsync: executeSql, error: executeSqlMutationError } = useExecuteSqlMutation({
|
||||
onError: () => {},
|
||||
})
|
||||
const executeSqlError = sandbox ? sandboxError : executeSqlMutationError
|
||||
|
||||
const {
|
||||
mutateAsync: parseClientCode,
|
||||
@@ -104,13 +108,14 @@ export const useTestQueryRLS = () => {
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setSandboxError(undefined)
|
||||
|
||||
const { appendAutoLimit } = checkIfAppendLimitRequired(value, limit)
|
||||
const formattedSql = suffixWithLimit(value, limit)
|
||||
const data = await parseQuery({ sql: formattedSql })
|
||||
|
||||
if (data.operation !== 'SELECT') {
|
||||
return toast('Only SELECT statements are supported for now')
|
||||
return toast('Only SELECT statements are supported with the RLS Tester at the moment')
|
||||
}
|
||||
|
||||
const formattedTables = data.tables.map((x) => {
|
||||
@@ -146,19 +151,25 @@ export const useTestQueryRLS = () => {
|
||||
})
|
||||
|
||||
const autoLimit = appendAutoLimit ? limit : undefined
|
||||
const { result } = await executeSql({
|
||||
autoLimit,
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
sql: wrapWithRoleImpersonation(formattedSql, impersonatedRoleState),
|
||||
isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRoleState.role),
|
||||
isStatementTimeoutDisabled: true,
|
||||
handleError: (error) => {
|
||||
throw error
|
||||
},
|
||||
queryKey: ['rls-tester'],
|
||||
})
|
||||
const sql = wrapWithRoleImpersonation(formattedSql, impersonatedRoleState)
|
||||
|
||||
const { result } = sandbox
|
||||
? await sandbox.run({ sql }).catch((e) => {
|
||||
setSandboxError(e instanceof Error ? e : new Error(String(e)))
|
||||
throw e
|
||||
})
|
||||
: await executeSql({
|
||||
sql,
|
||||
autoLimit,
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRoleState.role),
|
||||
isStatementTimeoutDisabled: true,
|
||||
handleError: (e) => {
|
||||
throw e
|
||||
},
|
||||
queryKey: ['rls-tester'],
|
||||
})
|
||||
onExecuteSQL({ result, isAutoLimit: !!autoLimit })
|
||||
|
||||
onParseQuery({
|
||||
|
||||
@@ -181,7 +181,7 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
|
||||
{isBanned ? (
|
||||
<Admonition
|
||||
type="warning"
|
||||
label={`User banned until ${dayjs(user.banned_until).format(DATE_FORMAT)}`}
|
||||
description={`User banned until ${dayjs(user.banned_until).format(DATE_FORMAT)}`}
|
||||
className="border-r-0 border-l-0 rounded-none -mt-px [&_svg]:ml-0.5"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { LOCAL_STORAGE_KEYS } from 'common'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { cn, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui'
|
||||
|
||||
import {
|
||||
CreateOrganizationCard,
|
||||
OrganizationCard,
|
||||
} from '@/components/interfaces/Organization/OrganizationCard'
|
||||
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
||||
import type { Organization } from '@/types'
|
||||
|
||||
const VISIBLE_ORGANIZATIONS_LIMIT = 3
|
||||
const CONNECT_DISCLOSURE_TRIGGER_CLASSNAME =
|
||||
'mx-auto flex h-7 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2 text-xs text-foreground-lighter transition-colors hover:bg-surface-200 hover:text-foreground'
|
||||
|
||||
export const OrganizationSelector = ({
|
||||
organizations,
|
||||
selectedSlug,
|
||||
disabled = false,
|
||||
description,
|
||||
createLabel,
|
||||
createHrefParams,
|
||||
onSelect,
|
||||
getOrganizationDescription,
|
||||
}: {
|
||||
organizations: Organization[]
|
||||
selectedSlug?: string | null
|
||||
disabled?: boolean
|
||||
description?: ReactNode
|
||||
createLabel?: string
|
||||
createHrefParams?: { [key: string]: string }
|
||||
onSelect: (slug: string) => void
|
||||
getOrganizationDescription?: (organization: Organization) => ReactNode
|
||||
}) => {
|
||||
const [showMore, setShowMore] = useState(false)
|
||||
const [lastVisitedOrganization] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
|
||||
''
|
||||
)
|
||||
|
||||
const { visibleOrganizations, overflowOrganizations } = useMemo(() => {
|
||||
const lastVisitedOrg = organizations.find(({ slug }) => slug === lastVisitedOrganization)
|
||||
const selectedIndex = organizations.findIndex(({ slug }) => slug === selectedSlug)
|
||||
const selectedInOverflow = selectedIndex >= VISIBLE_ORGANIZATIONS_LIMIT
|
||||
|
||||
if (!!lastVisitedOrg) {
|
||||
const withoutLastVisited = organizations.filter(
|
||||
({ slug }) => slug !== lastVisitedOrganization
|
||||
)
|
||||
return {
|
||||
visibleOrganizations: [
|
||||
lastVisitedOrg,
|
||||
...withoutLastVisited.slice(0, VISIBLE_ORGANIZATIONS_LIMIT - 1),
|
||||
],
|
||||
overflowOrganizations: withoutLastVisited.slice(VISIBLE_ORGANIZATIONS_LIMIT - 1),
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedInOverflow || !selectedSlug) {
|
||||
return {
|
||||
visibleOrganizations: organizations.slice(0, VISIBLE_ORGANIZATIONS_LIMIT),
|
||||
overflowOrganizations: organizations.slice(VISIBLE_ORGANIZATIONS_LIMIT),
|
||||
}
|
||||
}
|
||||
|
||||
const selected = organizations[selectedIndex]
|
||||
const withoutSelected = organizations.filter(({ slug }) => slug !== selectedSlug)
|
||||
|
||||
return {
|
||||
visibleOrganizations: [
|
||||
...withoutSelected.slice(0, VISIBLE_ORGANIZATIONS_LIMIT - 1),
|
||||
selected,
|
||||
],
|
||||
overflowOrganizations: withoutSelected.slice(VISIBLE_ORGANIZATIONS_LIMIT - 1),
|
||||
}
|
||||
}, [lastVisitedOrganization, organizations, selectedSlug])
|
||||
|
||||
const hasOverflow = overflowOrganizations.length > 0
|
||||
|
||||
return (
|
||||
<section className="space-y-2" aria-label="Organizations">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-foreground-light">
|
||||
Organization
|
||||
</p>
|
||||
{description && <p className="text-xs text-foreground-lighter pr-4">{description}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{visibleOrganizations.map((organization) => (
|
||||
<ConnectOrganizationButton
|
||||
key={organization.slug}
|
||||
organization={organization}
|
||||
selected={selectedSlug === organization.slug}
|
||||
disabled={disabled}
|
||||
onClick={() => onSelect(organization.slug)}
|
||||
description={
|
||||
getOrganizationDescription?.(organization) ?? getPlanDescription(organization)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!!createLabel && !!createHrefParams && (
|
||||
<CreateOrganizationCard params={createHrefParams} label={createLabel} />
|
||||
)}
|
||||
|
||||
{hasOverflow && (
|
||||
<Collapsible open={showMore} onOpenChange={setShowMore}>
|
||||
<CollapsibleTrigger className={CONNECT_DISCLOSURE_TRIGGER_CLASSNAME}>
|
||||
<span>{showMore ? 'Show fewer' : `Show ${overflowOrganizations.length} more`}</span>
|
||||
<ChevronDown
|
||||
className={cn('size-3.5 transition-transform', showMore && 'rotate-180')}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="data-closed:animate-collapsible-up data-open:animate-collapsible-down overflow-hidden">
|
||||
<div className="space-y-2 pt-1">
|
||||
{overflowOrganizations.map((organization) => (
|
||||
<ConnectOrganizationButton
|
||||
key={organization.slug}
|
||||
organization={organization}
|
||||
selected={selectedSlug === organization.slug}
|
||||
disabled={disabled}
|
||||
onClick={() => onSelect(organization.slug)}
|
||||
description={
|
||||
getOrganizationDescription?.(organization) ?? getPlanDescription(organization)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const getPlanDescription = (organization: Organization) => `${organization.plan.name} Plan`
|
||||
|
||||
const ConnectOrganizationButton = ({
|
||||
organization,
|
||||
selected,
|
||||
disabled,
|
||||
onClick,
|
||||
description,
|
||||
}: {
|
||||
organization: Organization
|
||||
selected?: boolean
|
||||
disabled?: boolean
|
||||
onClick?: () => void
|
||||
description?: ReactNode
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
'group relative block w-full cursor-pointer text-left disabled:cursor-not-allowed disabled:opacity-50',
|
||||
disabled && 'pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<OrganizationCard
|
||||
isLink={false}
|
||||
organization={organization}
|
||||
description={description}
|
||||
className={cn(
|
||||
'pointer-events-none shadow-none transition-colors',
|
||||
!disabled && !selected && 'group-hover:border-default group-hover:bg-surface-200',
|
||||
selected &&
|
||||
'border-brand bg-brand-200/20 dark:bg-brand-300 pr-10 group-hover:border-brand group-hover:bg-brand-200/20'
|
||||
)}
|
||||
/>
|
||||
{selected && (
|
||||
<span className="pointer-events-none absolute right-3 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-full bg-brand-500 dark:bg-brand-200 text-white dark:text-brand">
|
||||
<Check className="size-3.5" strokeWidth={2} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
@@ -63,28 +63,27 @@ export const PublicationsTables = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Input
|
||||
size="tiny"
|
||||
ref={searchInputRef}
|
||||
icon={<Search />}
|
||||
className="w-48"
|
||||
placeholder="Search for a table"
|
||||
value={filterString}
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
onKeyDown={onSearchInputEscape(filterString, setFilterString)}
|
||||
/>
|
||||
{!isLoadingPermissions && !canUpdatePublications && (
|
||||
<Admonition
|
||||
type="note"
|
||||
className="w-[500px]"
|
||||
title="You need additional permissions to update database replications"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Input
|
||||
size="tiny"
|
||||
ref={searchInputRef}
|
||||
icon={<Search />}
|
||||
className="w-48"
|
||||
placeholder="Search for a table"
|
||||
value={filterString}
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
onKeyDown={onSearchInputEscape(filterString, setFilterString)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isLoadingPermissions && !canUpdatePublications && (
|
||||
<Admonition
|
||||
type="warning"
|
||||
className="mb-4 w-full"
|
||||
description="You need additional permissions to update database replications."
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
||||
+16
-11
@@ -639,21 +639,26 @@ export const AnalyticsBucketFields = ({
|
||||
</p>
|
||||
|
||||
{isSuccessKeys && keyNoLongerExists && (
|
||||
<Admonition type="warning" title="Unable to find access key ID in project">
|
||||
<p className="leading-normal!">
|
||||
Please select another key or create a new set, as this destination will not
|
||||
work otherwise. S3 access keys can be managed in your{' '}
|
||||
<InlineLink href={`/project/${projectRef}/storage/files/settings`}>
|
||||
storage settings
|
||||
</InlineLink>
|
||||
</p>
|
||||
</Admonition>
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="Unable to find access key ID in project"
|
||||
description={
|
||||
<>
|
||||
Please select another key or create a new set, as this destination will
|
||||
not work otherwise. S3 access keys can be managed in your{' '}
|
||||
<InlineLink href={`/project/${projectRef}/storage/files/settings`}>
|
||||
storage settings
|
||||
</InlineLink>
|
||||
.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{s3AccessKeyId === CREATE_NEW_KEY && (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="A new set of S3 access keys will be created"
|
||||
description="A new set of S3 access keys will be created."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -719,7 +724,7 @@ export const AnalyticsBucketFields = ({
|
||||
layout="horizontal"
|
||||
label="S3 Secret Access Key"
|
||||
className="relative"
|
||||
description="The secret key corresponding to your selected access key ID"
|
||||
description="The secret key corresponding to your selected access key ID."
|
||||
>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
||||
+1
-1
@@ -136,7 +136,7 @@ export const RestoreToNewProject = () => {
|
||||
return (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="Restoring to new projects is temporarily not available for AWS (Revamped) projects"
|
||||
description="Restoring to new projects is temporarily not available for AWS (Revamped) projects."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,17 +3,16 @@ import { useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
|
||||
import { parseCronJobCommand } from './CronJobs.utils'
|
||||
import { useCronJobsData } from './CronJobsTab.useCronJobsData'
|
||||
import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
|
||||
import { useDatabaseCronJobDeleteMutation } from '@/data/database-cron-jobs/database-cron-jobs-delete-mutation'
|
||||
import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { cleanPointerEventsNoneOnBody } from '@/lib/helpers'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
|
||||
export const DeleteCronJob = () => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { data: org } = useSelectedOrganizationQuery()
|
||||
|
||||
const [searchQuery] = useQueryState('search', parseAsString.withDefault(''))
|
||||
const [cronJobIdForDeletion, setCronJobForDeletion] = useQueryState('delete', parseAsString)
|
||||
@@ -25,17 +24,17 @@ export const DeleteCronJob = () => {
|
||||
})
|
||||
const cronJob = grid.rows.find((j) => j.jobid.toString() === cronJobIdForDeletion)
|
||||
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
const track = useTrack()
|
||||
const {
|
||||
mutate: deleteDatabaseCronJob,
|
||||
isPending,
|
||||
isSuccess: isSuccessDelete,
|
||||
} = useDatabaseCronJobDeleteMutation({
|
||||
onSuccess: () => {
|
||||
sendEvent({
|
||||
action: 'cron_job_removed',
|
||||
groups: { project: project?.ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
|
||||
})
|
||||
if (cronJob && project) {
|
||||
const { type } = parseCronJobCommand(cronJob.command, project.ref)
|
||||
track('cron_job_removed', { type })
|
||||
}
|
||||
toast.success(`Successfully removed cron job`)
|
||||
setCronJobForDeletion(null)
|
||||
},
|
||||
|
||||
@@ -281,7 +281,7 @@ export const QueueSettings = ({}: QueueSettingsProps) => {
|
||||
<Admonition
|
||||
type="default"
|
||||
className="rounded-none border-x-0 border-t-0"
|
||||
title="Only relevant roles for managing queues via client libraries or PostgREST are shown here"
|
||||
description="Only relevant roles for managing queues via client libraries or PostgREST are shown here."
|
||||
/>
|
||||
)}
|
||||
<Table>
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ export const StripeSyncSettingsPage = () => {
|
||||
return (
|
||||
<PageContainer className="mx-0">
|
||||
<PageSection>
|
||||
<Admonition type="default" title="Stripe Sync Engine is not installed" />
|
||||
<Admonition type="default" description="Stripe Sync Engine is not installed." />
|
||||
</PageSection>
|
||||
</PageContainer>
|
||||
)
|
||||
|
||||
@@ -210,7 +210,7 @@ export function LogDrains({
|
||||
onConfirm={() => {
|
||||
if (selectedLogDrain && ref) {
|
||||
deleteLogDrain({ token: selectedLogDrain.token, projectRef: ref })
|
||||
track('log_drain_confirm_button_submitted', {
|
||||
track('log_drain_removed', {
|
||||
destination: selectedLogDrain.type,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
|
||||
import { useDpaRequestMutation } from '@/data/documents/dpa-request-mutation'
|
||||
import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { useProfile } from '@/lib/profile'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
|
||||
export const DPA = () => {
|
||||
const { profile } = useProfile()
|
||||
@@ -21,7 +21,7 @@ export const DPA = () => {
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
const track = useTrack()
|
||||
const { mutate: requestDpa, isPending: isRequesting } = useDpaRequestMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('DPA request sent successfully')
|
||||
@@ -49,12 +49,7 @@ export const DPA = () => {
|
||||
You can review a static PDF version of our latest DPA document{' '}
|
||||
<InlineLink
|
||||
href="https://supabase.com/downloads/docs/Supabase+DPA+260317.pdf"
|
||||
onClick={() =>
|
||||
sendEvent({
|
||||
action: 'dpa_pdf_opened',
|
||||
properties: { source: 'studio' },
|
||||
})
|
||||
}
|
||||
onClick={() => track('dpa_pdf_opened', { source: 'studio' })}
|
||||
>
|
||||
here
|
||||
</InlineLink>
|
||||
@@ -67,9 +62,7 @@ export const DPA = () => {
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsOpen(true)
|
||||
sendEvent({
|
||||
action: 'dpa_request_button_clicked',
|
||||
})
|
||||
track('dpa_request_button_clicked')
|
||||
}}
|
||||
type="default"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useIsMFAEnabled } from 'common'
|
||||
import { Boxes, Lock } from 'lucide-react'
|
||||
import { Boxes, Lock, Plus } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { Fragment } from 'react'
|
||||
import { Fragment, type ReactNode } from 'react'
|
||||
import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { ActionCard } from '@/components/ui/ActionCard'
|
||||
@@ -15,18 +15,21 @@ export const OrganizationCard = ({
|
||||
isLink = true,
|
||||
className,
|
||||
onClick,
|
||||
description,
|
||||
}: {
|
||||
organization: Organization
|
||||
href?: string
|
||||
isLink?: boolean
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
description?: ReactNode
|
||||
}) => {
|
||||
const isUserMFAEnabled = useIsMFAEnabled()
|
||||
const isPlatformOrg = organization.plan?.id === 'platform'
|
||||
const shouldRenderDefaultDescription = description === undefined
|
||||
const { data } = useOrgProjectsInfiniteQuery(
|
||||
{ slug: organization.slug },
|
||||
{ enabled: !isPlatformOrg }
|
||||
{ enabled: !isPlatformOrg && shouldRenderDefaultDescription }
|
||||
)
|
||||
const numProjects = data?.pages[0].pagination.count ?? 0
|
||||
const isMfaRequired = organization.organization_requires_mfa
|
||||
@@ -42,32 +45,36 @@ export const OrganizationCard = ({
|
||||
title={organization.name}
|
||||
onClick={onClick}
|
||||
description={
|
||||
<div className="flex items-center justify-between text-xs text-foreground-light font-sans">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<span>{organization.plan.name} Plan</span>
|
||||
{numProjects > 0 && (
|
||||
<>
|
||||
<span className="text-foreground-lighter">·</span>
|
||||
<span>
|
||||
{numProjects} project{numProjects > 1 ? 's' : ''}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
shouldRenderDefaultDescription ? (
|
||||
<div className="flex items-center justify-between text-xs text-foreground-light font-sans">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<span>{organization.plan.name} Plan</span>
|
||||
{numProjects > 0 && (
|
||||
<>
|
||||
<span className="text-foreground-lighter">·</span>
|
||||
<span>
|
||||
{numProjects} project{numProjects > 1 ? 's' : ''}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<PartnerIcon organization={organization} />
|
||||
{isMfaRequired && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="cursor-default">
|
||||
<Lock size={12} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={!isUserMFAEnabled ? 'w-80' : ''}>
|
||||
MFA enforced
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<PartnerIcon organization={organization} />
|
||||
{isMfaRequired && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="cursor-default">
|
||||
<Lock size={12} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={!isUserMFAEnabled ? 'w-80' : ''}>
|
||||
MFA enforced
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
description
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
@@ -78,3 +85,27 @@ export const OrganizationCard = ({
|
||||
return <Fragment>{renderContent()}</Fragment>
|
||||
}
|
||||
}
|
||||
|
||||
export const CreateOrganizationCard = ({
|
||||
params = {},
|
||||
label = 'Create new organization',
|
||||
}: {
|
||||
params?: { [key: string]: string }
|
||||
label?: string
|
||||
}) => {
|
||||
const createOrganizationHref = `/new${Object.keys(params).length > 0 ? `?${new URLSearchParams(params).toString()}` : ''}`
|
||||
|
||||
return (
|
||||
<Link href={createOrganizationHref}>
|
||||
<ActionCard
|
||||
bgColor="bg border"
|
||||
className={cn(
|
||||
'flex items-center min-h-[70px] [&>div]:w-full [&>div]:items-center max-h-min',
|
||||
'border-dashed shadow-none transition-colors group-hover:border-default group-hover:bg-surface-200'
|
||||
)}
|
||||
icon={<Plus size={18} strokeWidth={1} className="text-foreground" />}
|
||||
title={label}
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
+2
-2
@@ -28,7 +28,7 @@ export const EnableIndexAdvisorButton = () => {
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setIsDialogOpen(true)
|
||||
track('index_advisor_banner_enable_button_clicked')
|
||||
track('index_advisor_enable_button_clicked', { origin: 'banner' })
|
||||
}}
|
||||
>
|
||||
Enable
|
||||
@@ -112,7 +112,7 @@ export const EnableIndexAdvisorDialog = ({
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
onEnableIndexAdvisor()
|
||||
track('index_advisor_dialog_enable_button_clicked')
|
||||
track('index_advisor_enable_button_clicked', { origin: 'dialog' })
|
||||
}}
|
||||
disabled={isEnablingExtension}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { FeatureFlagContext } from 'common'
|
||||
import { HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { RedeemCreditsScreen } from './RedeemCredits'
|
||||
import type { ProfileContextType } from '@/lib/profile'
|
||||
import { createMockOrganization } from '@/tests/helpers'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
import { addAPIMock } from '@/tests/lib/msw'
|
||||
import { routerMock } from '@/tests/lib/route-mock'
|
||||
|
||||
const { creditRedemptionProps } = vi.hoisted(() => ({
|
||||
creditRedemptionProps: vi.fn(),
|
||||
}))
|
||||
const { creditRedemptionQueryCode } = vi.hoisted(() => ({
|
||||
creditRedemptionQueryCode: { current: undefined as string | undefined },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/interfaces/Organization/BillingSettings/CreditCodeRedemption', () => {
|
||||
return {
|
||||
CreditCodeRedemption: (props: { slug?: string }) => {
|
||||
creditRedemptionProps({ ...props, queryCode: creditRedemptionQueryCode.current })
|
||||
return (
|
||||
<div data-testid="credit-redemption">
|
||||
Credit redemption for {props.slug} with code {creditRedemptionQueryCode.current}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const DEFAULT_PROFILE_CONTEXT: ProfileContextType = {
|
||||
profile: {
|
||||
id: 1,
|
||||
auth0_id: 'auth0|test',
|
||||
gotrue_id: 'gotrue-test',
|
||||
username: 'testuser',
|
||||
primary_email: 'test@example.com',
|
||||
first_name: null,
|
||||
last_name: null,
|
||||
mobile: null,
|
||||
is_alpha_user: false,
|
||||
is_sso_user: false,
|
||||
disabled_features: [],
|
||||
free_project_limit: null,
|
||||
},
|
||||
error: null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
}
|
||||
|
||||
const ORGANIZATION = createMockOrganization({
|
||||
id: 1,
|
||||
name: 'Acme Production',
|
||||
slug: 'acme-production',
|
||||
plan: { id: 'pro', name: 'Pro' },
|
||||
})
|
||||
|
||||
function renderScreen() {
|
||||
return customRender(
|
||||
<FeatureFlagContext.Provider value={{ configcat: {}, posthog: {}, hasLoaded: true }}>
|
||||
<RedeemCreditsScreen />
|
||||
</FeatureFlagContext.Provider>,
|
||||
{ profileContext: DEFAULT_PROFILE_CONTEXT }
|
||||
)
|
||||
}
|
||||
|
||||
describe('RedeemCreditsScreen', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
creditRedemptionQueryCode.current = undefined
|
||||
routerMock.setCurrentUrl('/redeem')
|
||||
})
|
||||
|
||||
test('renders ready state from organizations query and opens redemption for selected organization', async () => {
|
||||
const user = userEvent.setup()
|
||||
routerMock.setCurrentUrl('/redeem?code=SUPA-CREDIT-123')
|
||||
creditRedemptionQueryCode.current = 'SUPA-CREDIT-123'
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/organizations',
|
||||
response: () => HttpResponse.json([ORGANIZATION]),
|
||||
})
|
||||
|
||||
renderScreen()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Acme Production/ }))
|
||||
await user.click(screen.getByRole('button', { name: 'Redeem credits' }))
|
||||
|
||||
expect(await screen.findByTestId('credit-redemption')).toHaveTextContent(
|
||||
'Credit redemption for acme-production with code SUPA-CREDIT-123'
|
||||
)
|
||||
expect(creditRedemptionProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
slug: 'acme-production',
|
||||
queryCode: 'SUPA-CREDIT-123',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
test('routes new organization creation back to the current redeem URL', async () => {
|
||||
routerMock.setCurrentUrl('/redeem?code=SUPA-CREDIT-123')
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/organizations',
|
||||
response: () => HttpResponse.json([ORGANIZATION]),
|
||||
})
|
||||
|
||||
renderScreen()
|
||||
|
||||
const createOrganizationLink = await screen.findByRole('link', {
|
||||
name: /Create new organization/,
|
||||
})
|
||||
|
||||
expect(createOrganizationLink).toHaveAttribute(
|
||||
'href',
|
||||
'/new?returnTo=%2Fredeem%3Fcode%3DSUPA-CREDIT-123&returnToOrgParam=selected_org'
|
||||
)
|
||||
})
|
||||
|
||||
test('preselects an organization returned from new organization creation', async () => {
|
||||
const user = userEvent.setup()
|
||||
routerMock.setCurrentUrl('/redeem?code=SUPA-CREDIT-123&selected_org=acme-production')
|
||||
creditRedemptionQueryCode.current = 'SUPA-CREDIT-123'
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/organizations',
|
||||
response: () => HttpResponse.json([ORGANIZATION]),
|
||||
})
|
||||
|
||||
renderScreen()
|
||||
|
||||
const redeemButton = await screen.findByRole('button', { name: 'Redeem credits' })
|
||||
|
||||
await waitFor(() => expect(redeemButton).toBeEnabled())
|
||||
await user.click(redeemButton)
|
||||
|
||||
expect(await screen.findByTestId('credit-redemption')).toHaveTextContent(
|
||||
'Credit redemption for acme-production with code SUPA-CREDIT-123'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
import { FeatureFlagContext } from 'common'
|
||||
import { useRouter } from 'next/router'
|
||||
import { ReactNode, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { Button, Card, CardContent } from 'ui'
|
||||
import { Admonition, ShimmeringLoader } from 'ui-patterns'
|
||||
|
||||
import { OrganizationSelector } from '../Connect/OrganizationSelector'
|
||||
import { CreditCodeRedemption } from '@/components/interfaces/Organization/BillingSettings/CreditCodeRedemption'
|
||||
import {
|
||||
InterstitialAccountRow,
|
||||
InterstitialLayout,
|
||||
SupabaseLogo,
|
||||
} from '@/components/layouts/InterstitialLayout'
|
||||
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
|
||||
import { useProfile } from '@/lib/profile'
|
||||
import { EMPTY_ARR } from '@/lib/void'
|
||||
|
||||
const RETURN_TO_SELECTED_ORG_PARAM = 'selected_org'
|
||||
|
||||
const RedeemCreditsInterstitial = ({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
children: ReactNode
|
||||
}) => (
|
||||
<InterstitialLayout logo={<SupabaseLogo />} title={title} description={description}>
|
||||
<div className="px-6 pb-6">{children}</div>
|
||||
</InterstitialLayout>
|
||||
)
|
||||
|
||||
export const RedeemCreditsScreen = () => {
|
||||
const router = useRouter()
|
||||
const { profile, isLoading: isLoadingProfile } = useProfile()
|
||||
const { hasLoaded } = useContext(FeatureFlagContext)
|
||||
|
||||
const [selectedOrgSlug, setSelectedOrgSlug] = useState<string | null>(null)
|
||||
const [redemptionModalOrgSlug, setRedemptionModalOrgSlug] = useState<string | null>(null)
|
||||
const appliedReturnSelectedOrgRef = useRef<string | null>(null)
|
||||
|
||||
const {
|
||||
data: organizationOptions = EMPTY_ARR,
|
||||
error: organizationsError,
|
||||
isLoading: isLoadingOrganizations,
|
||||
isError: isOrganizationsError,
|
||||
} = useOrganizationsQuery()
|
||||
|
||||
const returnSelectedOrgSlug =
|
||||
router.isReady && typeof router.query[RETURN_TO_SELECTED_ORG_PARAM] === 'string'
|
||||
? router.query[RETURN_TO_SELECTED_ORG_PARAM]
|
||||
: null
|
||||
|
||||
const displayName = profile?.primary_email ?? profile?.username
|
||||
|
||||
const isLoading = isLoadingProfile || isLoadingOrganizations || !hasLoaded
|
||||
|
||||
useEffect(() => {
|
||||
if (!returnSelectedOrgSlug) return
|
||||
if (appliedReturnSelectedOrgRef.current === returnSelectedOrgSlug) return
|
||||
|
||||
const hasReturnedOrganization = (organizationOptions ?? []).some(
|
||||
(organization) => organization.slug === returnSelectedOrgSlug
|
||||
)
|
||||
|
||||
if (hasReturnedOrganization) {
|
||||
setSelectedOrgSlug(returnSelectedOrgSlug)
|
||||
appliedReturnSelectedOrgRef.current = returnSelectedOrgSlug
|
||||
}
|
||||
}, [organizationOptions, returnSelectedOrgSlug])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<RedeemCreditsInterstitial
|
||||
title={<ShimmeringLoader className="mx-auto h-7 w-32 max-w-full py-0" />}
|
||||
description={<ShimmeringLoader className="mx-auto h-4 w-56 max-w-full py-0" />}
|
||||
>
|
||||
<ConnectLoadingCards />
|
||||
</RedeemCreditsInterstitial>
|
||||
)
|
||||
}
|
||||
|
||||
if (isOrganizationsError) {
|
||||
return (
|
||||
<RedeemCreditsInterstitial
|
||||
title="Unable to load credit redemption"
|
||||
description="Please try again before redeeming this code"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Admonition
|
||||
type="warning"
|
||||
description={
|
||||
<>
|
||||
We could not load your organizations.
|
||||
{organizationsError && (
|
||||
<span className="mt-1 block text-foreground-lighter">
|
||||
Error: {organizationsError.message}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</RedeemCreditsInterstitial>
|
||||
)
|
||||
}
|
||||
|
||||
const createOrganizationParams = {
|
||||
returnTo: router.asPath || '/redeem',
|
||||
returnToOrgParam: RETURN_TO_SELECTED_ORG_PARAM,
|
||||
}
|
||||
|
||||
const openRedemption = () => {
|
||||
if (!selectedOrgSlug) return
|
||||
setRedemptionModalOrgSlug(selectedOrgSlug)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<RedeemCreditsInterstitial
|
||||
title="Redeem credits"
|
||||
description="Choose an organization to redeem this code"
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
<InterstitialAccountRow displayName={displayName} />
|
||||
|
||||
<OrganizationSelector
|
||||
organizations={organizationOptions}
|
||||
selectedSlug={selectedOrgSlug}
|
||||
onSelect={setSelectedOrgSlug}
|
||||
getOrganizationDescription={(organization) => `${organization.plan.name} Plan`}
|
||||
createLabel={
|
||||
organizationOptions.length === 0
|
||||
? 'Create your first organization'
|
||||
: 'Create new organization'
|
||||
}
|
||||
createHrefParams={createOrganizationParams}
|
||||
/>
|
||||
|
||||
{organizationOptions.length === 0 && (
|
||||
<Admonition
|
||||
type="warning"
|
||||
description="Create an organization before redeeming this credit code."
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
disabled={!selectedOrgSlug || organizationOptions.length === 0}
|
||||
onClick={openRedemption}
|
||||
>
|
||||
Redeem credits
|
||||
</Button>
|
||||
<p className="text-center text-xs text-foreground-lighter text-balance">
|
||||
Credits apply to one organization and are used toward future invoices before your
|
||||
payment method is charged.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RedeemCreditsInterstitial>
|
||||
|
||||
{redemptionModalOrgSlug && (
|
||||
<CreditCodeRedemption
|
||||
modalVisible
|
||||
slug={redemptionModalOrgSlug}
|
||||
onClose={() => setRedemptionModalOrgSlug(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ConnectLoadingCards = () => (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Card className="shadow-none">
|
||||
<CardContent className="flex items-center gap-3 border-none px-4 py-3">
|
||||
<ShimmeringLoader className="size-8 flex-shrink-0 rounded-full py-0" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<ShimmeringLoader className="h-3 w-20 py-0" />
|
||||
<ShimmeringLoader className="h-4 w-40 max-w-full py-0" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<section className="space-y-2" aria-label="Organizations">
|
||||
<ShimmeringLoader className="h-3 w-24 py-0" />
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Card key={index} className="shadow-none">
|
||||
<CardContent className="flex items-center gap-3 border-none px-4 py-3">
|
||||
<ShimmeringLoader className="size-9 flex-shrink-0 rounded-lg py-0" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<ShimmeringLoader className="h-4 w-32 py-0" />
|
||||
<ShimmeringLoader className="h-3 w-20 py-0" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
<div className="flex flex-col gap-2">
|
||||
<ShimmeringLoader className="h-10 w-full py-0" />
|
||||
<ShimmeringLoader className="h-10 w-full py-0" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -272,7 +272,7 @@ export const PostgrestConfig = () => {
|
||||
</CardContent>
|
||||
) : isError ? (
|
||||
<CardContent>
|
||||
<Admonition type="destructive" title="Failed to retrieve API settings" />
|
||||
<Admonition type="destructive" description="Failed to retrieve API settings." />
|
||||
</CardContent>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -161,7 +161,7 @@ const IPv4SidePanel = () => {
|
||||
{!isAws && (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="Dedicated IPv4 address is only available for AWS projects"
|
||||
description="Dedicated IPv4 address is only available for AWS projects."
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
+1
-1
@@ -306,7 +306,7 @@ export function JitDbAccessRuleSheet({
|
||||
{grants.length === 0 ? (
|
||||
<Admonition
|
||||
type="note"
|
||||
title="No assignable roles found"
|
||||
description="No assignable roles found."
|
||||
className="bg-background"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -96,7 +96,7 @@ export const DashboardPreferences = () => {
|
||||
{/* [Joshen] Ideally we're able to persist this for all users in the project, but will need support in our middleware */}
|
||||
<Admonition
|
||||
type="note"
|
||||
title="These preferences control only your experience in the dashboard. Other members of this project will not be affected"
|
||||
description="These preferences control only your experience in the dashboard. Other members of this project will not be affected."
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
|
||||
@@ -40,7 +40,9 @@ export function ProjectAndPlanInfo({
|
||||
<ProjectSelector form={form} orgSlug={orgSlug} projectRef={projectRef} />
|
||||
<ProjectRefHighlighted projectRef={projectRef} />
|
||||
|
||||
{!hasProjectSelected && <Admonition type="default" title="No project has been selected" />}
|
||||
{!hasProjectSelected && (
|
||||
<Admonition type="default" description="No project has been selected." />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+4
-1
@@ -403,7 +403,10 @@ const SchemaExposureOptions = ({
|
||||
return (
|
||||
<>
|
||||
{isError && (
|
||||
<Admonition type="warning" title="An error occurred while fetching Data API settings." />
|
||||
<Admonition
|
||||
type="warning"
|
||||
description="An error occurred while fetching Data API settings."
|
||||
/>
|
||||
)}
|
||||
|
||||
{isSchemaExposed && apiUrl && (
|
||||
|
||||
@@ -306,7 +306,7 @@ export const UnifiedLogs = () => {
|
||||
setSearch(search)
|
||||
}
|
||||
|
||||
const debouncedApplyFilterSearch = useDebounce(applyFilterSearch, 1000)
|
||||
const debouncedApplyFilterSearch = useDebounce(applyFilterSearch, 250)
|
||||
|
||||
useEffect(() => {
|
||||
debouncedApplyFilterSearch()
|
||||
|
||||
@@ -102,6 +102,7 @@ export function generateDynamicColumns({ data }: { data: ColumnSchema[] }): {
|
||||
meta: {
|
||||
cellClassName: 'font-mono tracking-tight w-[140px]',
|
||||
headerClassName: 'w-[140px]',
|
||||
dataType: 'date',
|
||||
},
|
||||
},
|
||||
// Log type column - always visible
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import { LOCAL_STORAGE_KEYS } from 'common'
|
||||
import { useRouter } from 'next/router'
|
||||
import {
|
||||
@@ -18,12 +17,6 @@ import {
|
||||
|
||||
import { HeaderBanner } from '@/components/interfaces/Organization/HeaderBanner'
|
||||
import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink'
|
||||
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
|
||||
import { projectKeys } from '@/data/projects/keys'
|
||||
import {
|
||||
getOrganizationProjects,
|
||||
type OrgProject,
|
||||
} from '@/data/projects/org-projects-infinite-query'
|
||||
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
||||
|
||||
// Update this whenever the banner content below changes so old client bundles
|
||||
@@ -60,77 +53,6 @@ export const NoticeBanner = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const MAINTENANCE_REGIONS = new Set(['ap-southeast-1', 'sa-east-1'])
|
||||
|
||||
export const NoticeBanner2 = () => {
|
||||
const id = 'maintenance-2026-05-13'
|
||||
const expiry = new Date('2026-05-14T23:59:00Z')
|
||||
const isExpired = new Date() > expiry
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const [bannerAcknowledged, setBannerAcknowledged, { isSuccess }] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.MAINTENANCE_BANNER_DISMISSED(id),
|
||||
false
|
||||
)
|
||||
|
||||
const shouldEvaluate =
|
||||
!router.pathname.includes('sign-in') && isSuccess && !bannerAcknowledged && !isExpired
|
||||
|
||||
const { data: organizations } = useOrganizationsQuery({ enabled: shouldEvaluate })
|
||||
const orgProjectsQueries = useQueries({
|
||||
queries: (organizations ?? []).map((org) => ({
|
||||
queryKey: projectKeys.bannerProjectsByOrg(org.slug),
|
||||
queryFn: () => getOrganizationProjects({ slug: org.slug, limit: 100 }),
|
||||
staleTime: 30 * 60 * 1000,
|
||||
enabled: shouldEvaluate,
|
||||
})),
|
||||
})
|
||||
|
||||
const isProjectsFetched =
|
||||
organizations !== undefined &&
|
||||
(organizations.length === 0 || orgProjectsQueries.every((q) => q.isFetched))
|
||||
|
||||
const hasMaintenanceRegionProject = orgProjectsQueries
|
||||
.flatMap((q) => q.data?.projects ?? [])
|
||||
.some((project: OrgProject) =>
|
||||
project.databases.some((db) => MAINTENANCE_REGIONS.has(db.region))
|
||||
)
|
||||
|
||||
if (!shouldEvaluate || !isProjectsFetched || !hasMaintenanceRegionProject) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<HeaderBanner
|
||||
variant="note"
|
||||
title="Upcoming maintenance"
|
||||
description={
|
||||
<>
|
||||
Shared pooler maintenance in{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener referrer"
|
||||
href="https://status.supabase.com/incidents/hxf8876zl69x"
|
||||
>
|
||||
ap-southeast-1
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://status.supabase.com/incidents/jqsj3pb3mnx7"
|
||||
>
|
||||
sa-east-1
|
||||
</a>{' '}
|
||||
on May 13-14.
|
||||
</>
|
||||
}
|
||||
onDismiss={() => setBannerAcknowledged(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const UpdatedTermsOfServiceDialog = ({ onDismiss }: { onDismiss: () => void }) => {
|
||||
return (
|
||||
<Dialog>
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useTrackExperimentExposure } from '@/hooks/misc/useTrackExperimentExpos
|
||||
import { usePHFlag } from '@/hooks/ui/useFlag'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
|
||||
const EXPERIMENT_ID = 'headerUpgradeCta'
|
||||
const EXPERIMENT_FLAG_KEY = 'headerUpgradeCta'
|
||||
const EXPERIMENT_EXPOSURE_NAME = 'header_upgrade_cta'
|
||||
type HeaderUpgradeCtaVariant = 'control' | 'test'
|
||||
|
||||
interface HeaderUpgradeButtonProps {
|
||||
@@ -14,15 +15,14 @@ interface HeaderUpgradeButtonProps {
|
||||
export const HeaderUpgradeButton = ({ className }: HeaderUpgradeButtonProps) => {
|
||||
const track = useTrack()
|
||||
const { data: organization } = useSelectedOrganizationQuery()
|
||||
const flagValue = usePHFlag<HeaderUpgradeCtaVariant | false>(EXPERIMENT_ID)
|
||||
const flagValue = usePHFlag<HeaderUpgradeCtaVariant | false>(EXPERIMENT_FLAG_KEY)
|
||||
|
||||
const isFreePlan = organization?.plan?.id === 'free'
|
||||
const isInExperiment = flagValue === 'control' || flagValue === 'test'
|
||||
const showButton = flagValue === 'test'
|
||||
|
||||
// Track experiment exposure for all free-plan users in the experiment (both control and test)
|
||||
const variant = isFreePlan && isInExperiment ? (flagValue as string) : undefined
|
||||
useTrackExperimentExposure(EXPERIMENT_ID, variant)
|
||||
useTrackExperimentExposure(EXPERIMENT_EXPOSURE_NAME, variant)
|
||||
|
||||
if (!isFreePlan) return null
|
||||
if (!showButton) return null
|
||||
@@ -31,5 +31,7 @@ export const HeaderUpgradeButton = ({ className }: HeaderUpgradeButtonProps) =>
|
||||
track('header_upgrade_cta_clicked')
|
||||
}
|
||||
|
||||
return <UpgradePlanButton source={EXPERIMENT_ID} className={className} onClick={handleClick} />
|
||||
return (
|
||||
<UpgradePlanButton source={EXPERIMENT_FLAG_KEY} className={className} onClick={handleClick} />
|
||||
)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useTheme } from 'next-themes'
|
||||
import Head from 'next/head'
|
||||
import Image from 'next/legacy/image'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { Separator } from 'ui'
|
||||
|
||||
import { withAuth } from '../../hooks/misc/withAuth'
|
||||
import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
|
||||
import { BASE_PATH } from '@/lib/constants'
|
||||
|
||||
export interface RedeemCreditsLayoutProps {}
|
||||
|
||||
const RedeemCreditsLayout = ({ children }: PropsWithChildren<RedeemCreditsLayoutProps>) => {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const { appTitle } = useCustomContent(['app:title'])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Redeem Credits | {appTitle || 'Supabase'}</title>
|
||||
</Head>
|
||||
<main className="flex flex-col grow w-full h-full overflow-y-auto">
|
||||
<div>
|
||||
<div className="mx-auto px-4 sm:px-6">
|
||||
<div className="max-w-xl flex justify-between items-center py-4">
|
||||
<div className="flex justify-start lg:w-0 lg:flex-1">
|
||||
<div>
|
||||
<span className="sr-only">Supabase</span>
|
||||
<Image
|
||||
src={
|
||||
resolvedTheme?.includes('dark')
|
||||
? `${BASE_PATH}/img/supabase-dark.svg`
|
||||
: `${BASE_PATH}/img/supabase-light.svg`
|
||||
}
|
||||
alt="Supabase Logo"
|
||||
height={20}
|
||||
width={105}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-col justify-center grow mx-auto w-[90vw] space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuth(RedeemCreditsLayout)
|
||||
@@ -318,7 +318,7 @@ export const EntityListItem = ({
|
||||
<DropdownMenuItem key="view-policies" className="space-x-2" asChild>
|
||||
<Link
|
||||
key="view-policies"
|
||||
href={`/project/${projectRef}/auth/policies?schema=${selectedSchema}&search=${entity.id}`}
|
||||
href={`/project/${projectRef}/auth/policies?schema=${encodeURIComponent(selectedSchema ?? '')}&search=${encodeURIComponent(String(entity.id))}`}
|
||||
>
|
||||
<Lock size={12} />
|
||||
<span>View policies</span>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Badge, Card } from 'ui'
|
||||
import { Card } from 'ui'
|
||||
|
||||
export const ActionCard = (card: {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
description: string
|
||||
bgColor: string
|
||||
isBeta?: boolean
|
||||
onClick?: () => void
|
||||
}) => {
|
||||
return (
|
||||
@@ -15,11 +14,6 @@ export const ActionCard = (card: {
|
||||
onClick={card.onClick}
|
||||
>
|
||||
<div className={`relative flex items-start gap-3`}>
|
||||
{card.isBeta && (
|
||||
<Badge className="absolute -right-5 -top-5 bg-surface-300 text-xs text-foreground">
|
||||
Coming soon
|
||||
</Badge>
|
||||
)}
|
||||
<div
|
||||
className={`rounded-full ${card.bgColor} w-8 h-8 flex items-center justify-center shrink-0`}
|
||||
>
|
||||
|
||||
@@ -13,8 +13,10 @@ import { tableKeys } from '@/data/tables/keys'
|
||||
import { tryParseJson } from '@/lib/helpers'
|
||||
import { ResponseError } from '@/types'
|
||||
|
||||
export type MutationCategory = 'functions' | 'rls-policies'
|
||||
|
||||
// [Joshen] This is just very basic identification, but possible can extend perhaps
|
||||
export const identifyQueryType = (query: string) => {
|
||||
export const identifyQueryType = (query: string): MutationCategory | undefined => {
|
||||
const formattedQuery = query.toLowerCase().replaceAll('\n', ' ')
|
||||
if (
|
||||
formattedQuery.includes('create function') ||
|
||||
@@ -24,6 +26,7 @@ export const identifyQueryType = (query: string) => {
|
||||
} else if (formattedQuery.includes('create policy') || formattedQuery.includes('alter policy')) {
|
||||
return 'rls-policies'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Check for function calls that aren't in the safe list
|
||||
|
||||
@@ -14,11 +14,10 @@ import { entityTypeKeys } from '@/data/entity-types/keys'
|
||||
import { lintKeys } from '@/data/lint/keys'
|
||||
import { usePrimaryDatabase } from '@/data/read-replicas/replicas-query'
|
||||
import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
|
||||
import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
|
||||
import { useChangedSync } from '@/hooks/misc/useChanged'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { useProfile } from '@/lib/profile'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
|
||||
interface DisplayBlockRendererProps {
|
||||
messageId: string
|
||||
@@ -77,9 +76,8 @@ export const DisplayBlockRenderer = ({
|
||||
const router = useRouter()
|
||||
const { ref } = useParams()
|
||||
const { profile } = useProfile()
|
||||
const { data: org } = useSelectedOrganizationQuery()
|
||||
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
const track = useTrack()
|
||||
const { can: canCreateSQLSnippet } = useAsyncCheckPermissions(
|
||||
PermissionAction.CREATE,
|
||||
'user_content',
|
||||
@@ -141,16 +139,11 @@ export const DisplayBlockRenderer = ({
|
||||
|
||||
onQueryRun?.(queryType)
|
||||
|
||||
sendEvent({
|
||||
action: 'assistant_suggestion_run_query_clicked',
|
||||
properties: {
|
||||
queryType,
|
||||
...(queryType === 'mutation' ? { category: identifyQueryType(sqlQuery) ?? 'unknown' } : {}),
|
||||
},
|
||||
groups: {
|
||||
project: ref ?? 'Unknown',
|
||||
organization: org?.slug ?? 'Unknown',
|
||||
},
|
||||
track('assistant_suggestion_run_query_clicked', {
|
||||
queryType,
|
||||
...(queryType === 'mutation'
|
||||
? { mutationType: identifyQueryType(sqlQuery) ?? 'unknown' }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Badge, Card, cn } from 'ui'
|
||||
import { Card, cn } from 'ui'
|
||||
|
||||
export const ActionCard = (card: {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
bgColor?: string
|
||||
description?: ReactNode
|
||||
isBeta?: boolean
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
}) => {
|
||||
@@ -18,21 +17,18 @@ export const ActionCard = (card: {
|
||||
)}
|
||||
onClick={card.onClick}
|
||||
>
|
||||
<div className={`relative flex items-start gap-3`}>
|
||||
{card.isBeta && (
|
||||
<Badge className="absolute -right-5 -top-5 bg-surface-300 text-xs text-foreground">
|
||||
Coming soon
|
||||
</Badge>
|
||||
)}
|
||||
<div className="relative flex items-start gap-3">
|
||||
<div
|
||||
className={`rounded-full ${card.bgColor} w-8 h-8 flex items-center justify-center shrink-0`}
|
||||
>
|
||||
{card.icon}
|
||||
</div>
|
||||
<div className="grow flex flex-col gap-0 min-w-0">
|
||||
<h3 title={card.title} className="text-sm text-foreground mb-0 truncate max-w-full">
|
||||
{card.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<h3 title={card.title} className="text-sm text-foreground mb-0 truncate max-w-full">
|
||||
{card.title}
|
||||
</h3>
|
||||
</div>
|
||||
{typeof card.description === 'string' ? (
|
||||
<pre className="text-xs text-foreground-light font-sans">{card.description}</pre>
|
||||
) : (
|
||||
|
||||
@@ -14,7 +14,7 @@ import { formatPercentage, numberFormatter } from './Charts.utils'
|
||||
import { useChartHoverState } from './useChartHoverState'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { formatDateTime, useFormatDateTime } from '@/lib/datetime'
|
||||
import { formatBytes } from '@/lib/helpers'
|
||||
import { formatBytes, formatBytesMinMB } from '@/lib/helpers'
|
||||
|
||||
export interface ChartHeaderProps {
|
||||
title?: string
|
||||
@@ -41,6 +41,7 @@ export interface ChartHeaderProps {
|
||||
valuePrecision?: number
|
||||
shouldFormatBytes?: boolean
|
||||
isNetworkChart?: boolean
|
||||
isMemoryChart?: boolean
|
||||
attributes?: any[]
|
||||
sql?: string
|
||||
titleTooltip?: string
|
||||
@@ -76,6 +77,7 @@ export const ChartHeader = ({
|
||||
sql,
|
||||
titleTooltip,
|
||||
showNewBadge,
|
||||
isMemoryChart,
|
||||
}: ChartHeaderProps) => {
|
||||
const { ref } = useParams()
|
||||
const { hoveredIndex, isHovered } = useChartHoverState(syncId || 'default')
|
||||
@@ -97,7 +99,9 @@ export const ChartHeader = ({
|
||||
|
||||
if (shouldFormatBytes) {
|
||||
const bytesValue = isNetworkChart ? Math.abs(value) : value
|
||||
return formatBytes(bytesValue, valuePrecision)
|
||||
return isMemoryChart
|
||||
? formatBytesMinMB(bytesValue, valuePrecision)
|
||||
: formatBytes(bytesValue, valuePrecision)
|
||||
}
|
||||
|
||||
if (format === '%') {
|
||||
|
||||
@@ -44,7 +44,7 @@ import NoDataPlaceholder from './NoDataPlaceholder'
|
||||
import { ChartHighlight } from './useChartHighlight'
|
||||
import { useChartHoverState } from './useChartHoverState'
|
||||
import { formatDateTime, useFormatDateTime } from '@/lib/datetime'
|
||||
import { formatBytes } from '@/lib/helpers'
|
||||
import { formatBytes, formatBytesMinMB } from '@/lib/helpers'
|
||||
|
||||
export interface ComposedChartProps<D = Datum> extends CommonChartProps<D> {
|
||||
chartId?: string
|
||||
@@ -221,7 +221,9 @@ export function ComposedChart({
|
||||
|
||||
if (shouldFormatBytes) {
|
||||
const bytesValue = isNetworkChart ? Math.abs(value) : value
|
||||
const formatted = formatBytes(bytesValue, valuePrecision)
|
||||
const formatted = isMemoryChart
|
||||
? formatBytesMinMB(bytesValue, valuePrecision)
|
||||
: formatBytes(bytesValue, valuePrecision)
|
||||
return format === 'bytes-per-second' ? `${formatted}/s` : formatted
|
||||
}
|
||||
|
||||
@@ -373,6 +375,8 @@ export function ComposedChart({
|
||||
const isRamChart =
|
||||
!chartData?.some((att: any) => att.name.toLowerCase() === 'ram_usage') &&
|
||||
chartData?.some((att: any) => att.name.toLowerCase().includes('ram_'))
|
||||
const isSwapChart = chartData?.some((att: any) => att.name.toLowerCase().includes('swap_'))
|
||||
const isMemoryChart = isRamChart || isSwapChart
|
||||
const isDiskSpaceChart = chartData?.some((att: any) =>
|
||||
att.name.toLowerCase().includes('disk_space_')
|
||||
)
|
||||
@@ -382,7 +386,7 @@ export function ComposedChart({
|
||||
const isNetworkChart = chartData?.some((att: any) => att.name.toLowerCase().includes('network_'))
|
||||
const isBytesFormat = format === 'bytes' || format === 'bytes-per-second'
|
||||
const shouldFormatBytes =
|
||||
isBytesFormat || isRamChart || isDiskSpaceChart || isDBSizeChart || isNetworkChart
|
||||
isBytesFormat || isMemoryChart || isDiskSpaceChart || isDBSizeChart || isNetworkChart
|
||||
const yMaxFromVisible = Math.max(
|
||||
0,
|
||||
...visibleAttributes.map((att) => (typeof att.value === 'number' ? att.value : 0))
|
||||
@@ -454,6 +458,7 @@ export function ComposedChart({
|
||||
valuePrecision={valuePrecision}
|
||||
shouldFormatBytes={shouldFormatBytes}
|
||||
isNetworkChart={isNetworkChart}
|
||||
isMemoryChart={isMemoryChart}
|
||||
attributes={attributes}
|
||||
sql={sql}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cn, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui
|
||||
import { CHART_COLORS, DateTimeFormats } from './Charts.constants'
|
||||
import { formatPercentage, numberFormatter } from './Charts.utils'
|
||||
import { useFormatDateTime, useTimezone } from '@/lib/datetime'
|
||||
import { formatBytes } from '@/lib/helpers'
|
||||
import { formatBytes, formatBytesMinMB } from '@/lib/helpers'
|
||||
|
||||
export interface ReportAttributes {
|
||||
id?: string
|
||||
@@ -174,12 +174,14 @@ export const CustomTooltip = ({
|
||||
const isRamChart =
|
||||
!payload?.some((p: any) => p.dataKey.toLowerCase() === 'ram_usage') &&
|
||||
payload?.some((p: any) => p.dataKey.toLowerCase().includes('ram_'))
|
||||
const isSwapChart = payload?.some((p: any) => p.dataKey.toLowerCase().includes('swap_'))
|
||||
const isMemoryChart = isRamChart || isSwapChart
|
||||
const isDBSizeChart =
|
||||
payload?.some((p: any) => p.dataKey.toLowerCase().includes('disk_fs_')) ||
|
||||
payload?.some((p: any) => p.dataKey.toLowerCase().includes('pg_database_size'))
|
||||
const isNetworkChart = payload?.some((p: any) => p.dataKey.toLowerCase().includes('network_'))
|
||||
const isBytesFormat = format === 'bytes' || format === 'bytes-per-second'
|
||||
const shouldFormatBytes = isBytesFormat || isRamChart || isDBSizeChart || isNetworkChart
|
||||
const shouldFormatBytes = isBytesFormat || isMemoryChart || isDBSizeChart || isNetworkChart
|
||||
const byteUnitSuffix = format === 'bytes-per-second' ? '/s' : ''
|
||||
|
||||
const attributesToIgnore =
|
||||
@@ -213,7 +215,9 @@ export const CustomTooltip = ({
|
||||
const formatNumeric = (value: number) => {
|
||||
if (!shouldFormatBytes && valuePrecision === 0 && value > 0 && value < 1) return '<1'
|
||||
if (shouldFormatBytes) {
|
||||
return formatBytes(isNetworkChart ? Math.abs(value) : value, valuePrecision)
|
||||
const val = isNetworkChart ? Math.abs(value) : value
|
||||
if (isMemoryChart) return formatBytesMinMB(val, valuePrecision)
|
||||
return formatBytes(val, valuePrecision)
|
||||
}
|
||||
const formatted = numberFormatter(value, valuePrecision)
|
||||
if (
|
||||
|
||||
@@ -357,7 +357,7 @@ const useAttributeQueries = (
|
||||
)
|
||||
|
||||
const referenceLineQueries = referenceLines.map((line) => {
|
||||
let value = line.value || 0
|
||||
let value = line.value ?? line.customValue ?? 0
|
||||
|
||||
return {
|
||||
data: {
|
||||
|
||||
@@ -39,7 +39,7 @@ export function DataTableFilterCommand({
|
||||
searchParamsParser,
|
||||
placeholder = 'Search data table...',
|
||||
}: DataTableFilterCommandProps) {
|
||||
const { table, isLoading, filterFields: _filterFields, getFacetedUniqueValues } = useDataTable()
|
||||
const { table, isFetching, filterFields: _filterFields, getFacetedUniqueValues } = useDataTable()
|
||||
const columnFilters = table.getState().columnFilters
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [open, setOpen] = useState<boolean>(false)
|
||||
@@ -120,7 +120,7 @@ export function DataTableFilterCommand({
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{isLoading ? (
|
||||
{isFetching ? (
|
||||
<LoaderCircle className="mr-2 h-4 w-4 shrink-0 animate-spin text-muted-foreground opacity-50 group-hover:text-popover-foreground" />
|
||||
) : (
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 text-muted-foreground opacity-50 group-hover:text-popover-foreground" />
|
||||
|
||||
@@ -29,7 +29,7 @@ export function DataTableFilterResetButton<TData>({ value: _value }: DataTableFi
|
||||
}
|
||||
}}
|
||||
>
|
||||
{filters.length}
|
||||
{(column?.columnDef.meta as Record<string, string>)?.dataType === 'date' ? 1 : filters.length}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -192,7 +192,28 @@ export function getCSP() {
|
||||
|
||||
const workerSrcDirective = [`worker-src 'self'`, `blob:`, `data:`].join(' ')
|
||||
|
||||
const connectSrcDirective = [
|
||||
`connect-src 'self'`,
|
||||
`data:`,
|
||||
`blob:`,
|
||||
...DEFAULT_SRC_URLS,
|
||||
...(isDevOrStaging
|
||||
? [
|
||||
SUPABASE_STAGING_PROJECTS_URL,
|
||||
SUPABASE_STAGING_PROJECTS_URL_WS,
|
||||
NIMBUS_STAGING_PROJECTS_URL,
|
||||
NIMBUS_STAGING_PROJECTS_URL_WS,
|
||||
VERCEL_LIVE_URL,
|
||||
SUPABASE_DOCS_PROJECT_URL,
|
||||
SUPABASE_CONTENT_API_URL,
|
||||
]
|
||||
: []),
|
||||
PUSHER_URL_WS,
|
||||
SENTRY_URL,
|
||||
].join(' ')
|
||||
|
||||
const cspDirectives = [
|
||||
connectSrcDirective,
|
||||
defaultSrcDirective,
|
||||
imgSrcDirective,
|
||||
scriptSrcDirective,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DiskAttributesData } from '@/data/config/disk-attributes-query'
|
||||
import { MaxConnectionsData } from '@/data/database/max-connections-query'
|
||||
import { Project } from '@/data/projects/project-detail-query'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
import { formatBytes } from '@/lib/helpers'
|
||||
import { formatBytes, formatBytesMinMB } from '@/lib/helpers'
|
||||
|
||||
// Compute variants below 4XL run on EBS instances that draw on a burst
|
||||
// credit pool for disk IO, so the burst balance chart only makes sense for
|
||||
@@ -68,7 +68,7 @@ export const getReportAttributesV2: (
|
||||
valuePrecision: 2,
|
||||
YAxisProps: {
|
||||
width: 75,
|
||||
tickFormatter: (value: number) => formatBytes(value, 2),
|
||||
tickFormatter: (value: number) => formatBytesMinMB(value, 2),
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
@@ -92,6 +92,14 @@ export const getReportAttributesV2: (
|
||||
tooltip:
|
||||
'Unallocated memory available for use. A small portion is always reserved by the operating system',
|
||||
},
|
||||
{
|
||||
attribute: 'ram_usage_total',
|
||||
provider: 'infra-monitoring',
|
||||
label: 'Total RAM',
|
||||
isMaxValue: true,
|
||||
omitFromTotal: true,
|
||||
tooltip: 'Total RAM available on this instance',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -109,7 +117,9 @@ export const getReportAttributesV2: (
|
||||
valuePrecision: 2,
|
||||
YAxisProps: {
|
||||
width: 75,
|
||||
tickFormatter: (value: number) => formatBytes(value, 2),
|
||||
tickFormatter: (value: number) => formatBytesMinMB(value, 2),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
domain: [0, (dataMax: number) => Math.max(dataMax, 1024 * 1024 * 1024)] as any,
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import pgMeta, {
|
||||
getEntityDefinitionsSql,
|
||||
joinSqlFragments,
|
||||
literal,
|
||||
safeSql,
|
||||
type PGPolicy,
|
||||
} from '@supabase/pg-meta'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { executeSql } from '@/data/sql/execute-sql-query'
|
||||
import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas'
|
||||
|
||||
export interface RlsTableStatus {
|
||||
schema: string
|
||||
table: string
|
||||
rls_enabled: boolean
|
||||
rls_forced: boolean
|
||||
}
|
||||
|
||||
export interface CustomRole {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface DatabaseSchemaDDL {
|
||||
schemas: string[]
|
||||
typeDefinitions: string[]
|
||||
entityDefinitions: string[]
|
||||
functionDefinitions: string[]
|
||||
policies: PGPolicy[]
|
||||
rlsStatuses: RlsTableStatus[]
|
||||
customRoles: CustomRole[]
|
||||
}
|
||||
|
||||
const pgMetaRolesList = pgMeta.roles.list()
|
||||
const pgMetaFunctionsZod = pgMeta.functions.list().zod
|
||||
const pgMetaPoliciesZod = pgMeta.policies.list().zod
|
||||
const pgMetaTablesZod = pgMeta.tables.list().zod
|
||||
|
||||
// Extension-owned / platform-specific schemas whose DDL depends on C extensions,
|
||||
// custom operators, and platform functions that PGlite cannot replicate.
|
||||
// We skip entity/function/type DDL for these but still fetch their policies —
|
||||
// those may reference user tables we do load.
|
||||
const SUPABASE_INTERNAL_SCHEMAS = new Set([...INTERNAL_SCHEMAS, '_realtime'])
|
||||
|
||||
const SYSTEM_ROLES = new Set([
|
||||
'postgres',
|
||||
'anon',
|
||||
'authenticated',
|
||||
'service_role',
|
||||
'supabase_admin',
|
||||
'supabase_auth_admin',
|
||||
'supabase_storage_admin',
|
||||
'supabase_replication_admin',
|
||||
'supabase_read_only_user',
|
||||
'pg_monitor',
|
||||
'pg_read_all_settings',
|
||||
'pg_read_all_stats',
|
||||
'pg_stat_scan_tables',
|
||||
'pg_read_server_files',
|
||||
'pg_write_server_files',
|
||||
'pg_execute_server_program',
|
||||
'pg_signal_backend',
|
||||
'dashboard_user',
|
||||
'pgbouncer',
|
||||
])
|
||||
|
||||
function getTypeDefinitionsSql(schemas: string[]) {
|
||||
return safeSql`
|
||||
SELECT
|
||||
CASE t.typtype
|
||||
WHEN 'e' THEN
|
||||
'CREATE TYPE ' || quote_ident(n.nspname) || '.' || quote_ident(t.typname) ||
|
||||
' AS ENUM (' ||
|
||||
(SELECT string_agg(quote_literal(e.enumlabel), ', ' ORDER BY e.enumsortorder)
|
||||
FROM pg_enum e WHERE e.enumtypid = t.oid) ||
|
||||
')'
|
||||
WHEN 'c' THEN
|
||||
'CREATE TYPE ' || quote_ident(n.nspname) || '.' || quote_ident(t.typname) ||
|
||||
' AS (' ||
|
||||
(SELECT string_agg(quote_ident(a.attname) || ' ' || pg_catalog.format_type(a.atttypid, a.atttypmod), ', ' ORDER BY a.attnum)
|
||||
FROM pg_attribute a WHERE a.attrelid = t.typrelid AND a.attnum > 0 AND NOT a.attisdropped) ||
|
||||
')'
|
||||
WHEN 'd' THEN
|
||||
'CREATE DOMAIN ' || quote_ident(n.nspname) || '.' || quote_ident(t.typname) ||
|
||||
' AS ' || pg_catalog.format_type(t.typbasetype, t.typtypmod)
|
||||
END AS definition
|
||||
FROM pg_type t
|
||||
JOIN pg_namespace n ON n.oid = t.typnamespace
|
||||
LEFT JOIN pg_class c ON c.oid = t.typrelid
|
||||
LEFT JOIN pg_depend d ON d.objid = t.oid AND d.deptype = 'e'
|
||||
WHERE n.nspname IN (${joinSqlFragments(schemas.map(literal), ', ')})
|
||||
AND t.typtype IN ('e', 'c', 'd')
|
||||
AND d.objid IS NULL
|
||||
AND (t.typtype != 'c' OR c.relkind = 'c')
|
||||
ORDER BY t.typtype, n.nspname, t.typname
|
||||
`
|
||||
}
|
||||
|
||||
type Variables = {
|
||||
projectRef?: string
|
||||
connectionString?: string | null
|
||||
schemas: string[]
|
||||
}
|
||||
|
||||
export async function getDatabaseSchemaDDL(
|
||||
{ projectRef, connectionString, schemas }: Variables,
|
||||
signal?: AbortSignal
|
||||
): Promise<DatabaseSchemaDDL> {
|
||||
const userSchemas = schemas.filter((s) => !SUPABASE_INTERNAL_SCHEMAS.has(s))
|
||||
|
||||
const entitySql = getEntityDefinitionsSql({ schemas: userSchemas })
|
||||
const functionsSql = pgMeta.functions.list({ includedSchemas: userSchemas }).sql
|
||||
const policiesSql = pgMeta.policies.list({ includedSchemas: schemas }).sql
|
||||
const tablesSql = pgMeta.tables.list({ includedSchemas: userSchemas }).sql
|
||||
|
||||
const [entityResult, policiesResult, rlsResult, rolesResult, functionsResult, typesResult] =
|
||||
await Promise.all([
|
||||
executeSql(
|
||||
{ projectRef, connectionString, sql: entitySql, queryKey: ['rls-sandbox-ddl'] },
|
||||
signal
|
||||
),
|
||||
executeSql(
|
||||
{ projectRef, connectionString, sql: policiesSql, queryKey: ['rls-sandbox-policies'] },
|
||||
signal
|
||||
),
|
||||
executeSql(
|
||||
{ projectRef, connectionString, sql: tablesSql, queryKey: ['rls-sandbox-rls'] },
|
||||
signal
|
||||
),
|
||||
executeSql(
|
||||
{ projectRef, connectionString, sql: pgMetaRolesList.sql, queryKey: ['rls-sandbox-roles'] },
|
||||
signal
|
||||
),
|
||||
executeSql(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: functionsSql,
|
||||
queryKey: ['rls-sandbox-functions'],
|
||||
},
|
||||
signal
|
||||
),
|
||||
executeSql(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: getTypeDefinitionsSql(userSchemas),
|
||||
queryKey: ['rls-sandbox-types'],
|
||||
},
|
||||
signal
|
||||
),
|
||||
])
|
||||
|
||||
const roles = (rolesResult.result as z.infer<typeof pgMetaRolesList.zod>).filter(
|
||||
(r) => !SYSTEM_ROLES.has(r.name) && !r.name.startsWith('pg_') && !r.name.startsWith('supabase_')
|
||||
)
|
||||
|
||||
const functions = (functionsResult.result as z.infer<typeof pgMetaFunctionsZod>).filter(
|
||||
(f) => (f.language === 'sql' || f.language === 'plpgsql') && f.return_type !== 'trigger'
|
||||
)
|
||||
|
||||
return {
|
||||
schemas: userSchemas,
|
||||
typeDefinitions: (typesResult.result as { definition: string }[]).map((r) => r.definition),
|
||||
entityDefinitions: (entityResult.result[0]?.data?.definitions ?? []).map(
|
||||
(d: { sql: string }) => d.sql
|
||||
),
|
||||
functionDefinitions: functions.map((f) => f.complete_statement),
|
||||
policies: policiesResult.result as z.infer<typeof pgMetaPoliciesZod> as PGPolicy[],
|
||||
rlsStatuses: (rlsResult.result as z.infer<typeof pgMetaTablesZod>).map((t) => ({
|
||||
schema: t.schema,
|
||||
table: t.name,
|
||||
rls_enabled: t.rls_enabled,
|
||||
rls_forced: t.rls_forced,
|
||||
})),
|
||||
customRoles: roles,
|
||||
}
|
||||
}
|
||||
|
||||
export type DatabaseSchemaDDLData = Awaited<ReturnType<typeof getDatabaseSchemaDDL>>
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ident, joinSqlFragments, literal, safeSql } from '@supabase/pg-meta'
|
||||
|
||||
import { RlsTableStatus } from './get-schema-ddl'
|
||||
import { executeSql } from '@/data/sql/execute-sql-query'
|
||||
|
||||
export interface TableSeedData {
|
||||
schema: string
|
||||
table: string
|
||||
rows: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
// Each entry can optionally restrict which columns are fetched. Used by the
|
||||
// sandbox to avoid pulling secrets (e.g. auth.users encrypted_password / tokens)
|
||||
// into the browser-side PGlite instance.
|
||||
export type SeedTableEntry = RlsTableStatus & { columns?: readonly string[] }
|
||||
|
||||
type Variables = {
|
||||
projectRef?: string
|
||||
connectionString?: string | null
|
||||
tables: SeedTableEntry[]
|
||||
rowLimit: number
|
||||
}
|
||||
|
||||
async function fetchTableSeed(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
schema,
|
||||
table,
|
||||
columns,
|
||||
rowLimit,
|
||||
}: Omit<Variables, 'tables'> & {
|
||||
schema: string
|
||||
table: string
|
||||
columns?: readonly string[]
|
||||
},
|
||||
signal?: AbortSignal
|
||||
): Promise<TableSeedData> {
|
||||
try {
|
||||
const projection =
|
||||
columns && columns.length > 0 ? joinSqlFragments(columns.map(ident), ', ') : safeSql`*`
|
||||
const { result } = await executeSql(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: safeSql`SELECT ${projection} FROM ${ident(schema)}.${ident(table)} LIMIT ${literal(Number(rowLimit))}`,
|
||||
queryKey: ['rls-sandbox-seed', schema, table],
|
||||
},
|
||||
signal
|
||||
)
|
||||
return { schema, table, rows: (result ?? []) as Record<string, unknown>[] }
|
||||
} catch {
|
||||
return { schema, table, rows: [] }
|
||||
}
|
||||
}
|
||||
|
||||
const SEED_CONCURRENCY = 8
|
||||
|
||||
export async function getProjectSeedData(
|
||||
{ projectRef, connectionString, tables, rowLimit }: Variables,
|
||||
signal?: AbortSignal
|
||||
): Promise<TableSeedData[]> {
|
||||
const results: TableSeedData[] = []
|
||||
const queue = tables.slice()
|
||||
const workers = Array.from({ length: Math.min(SEED_CONCURRENCY, queue.length) }, async () => {
|
||||
while (queue.length > 0) {
|
||||
const entry = queue.shift()
|
||||
if (!entry) break
|
||||
results.push(
|
||||
await fetchTableSeed(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
schema: entry.schema,
|
||||
table: entry.table,
|
||||
columns: entry.columns,
|
||||
rowLimit,
|
||||
},
|
||||
signal
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
await Promise.all(workers)
|
||||
return results.filter((t) => t.rows.length > 0)
|
||||
}
|
||||
|
||||
export type ProjectSeedDataData = TableSeedData[]
|
||||
@@ -189,6 +189,27 @@ export const dataset: AssistantEvalCase[] = [
|
||||
},
|
||||
metadata: { category: ['general_help'] },
|
||||
},
|
||||
{
|
||||
input: {
|
||||
prompt: 'Create a cron job named assistant-cron that runs every minute with select 1',
|
||||
},
|
||||
expected: {
|
||||
requiredTools: [
|
||||
{
|
||||
name: 'execute_sql',
|
||||
input: {
|
||||
sql: { stringIncludes: 'cron.schedule' },
|
||||
isWriteQuery: { equals: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
metadata: {
|
||||
category: ['sql_generation'],
|
||||
description:
|
||||
"Ensures execute_sql marks cron.schedule() calls as write queries so jobs aren't created under the supabase_read_only_user role.",
|
||||
},
|
||||
},
|
||||
{
|
||||
input: {
|
||||
prompt:
|
||||
|
||||
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user