Merge branch 'master' into feat/move-realtime-publications

This commit is contained in:
Saxon Fletcher committed 2024-11-21 09:26:13 +10:00
commit 8febffd6fe
314 files changed
+7539 -2980

No files matched your search

+7 -1
View File
@@ -10,4 +10,10 @@
/apps/www/public/images/blog @supabase/marketing
/apps/www/lib/redirects.js
/docker/ @supabase/cli
/docker/ @supabase/dev-workflows
/apps/studio/next.config.js @supabase/security
/apps/studio/components/interfaces/Billing/Payment @supabase/security
/apps/studio/components/interfaces/Organization/BillingSettings/ @supabase/security
/apps/studio/components/interfaces/Organization/Documents/ @supabase/security
/apps/studio/pages/new/index.tsx @supabase/security
+2 -2
View File
@@ -18,10 +18,10 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.event_name == 'pull_request' && (github.event.label.name == 'autofix') }}
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: apps
ref: ${{ github.head_ref }}
- uses: actions/setup-node@v4
with:
@@ -39,7 +39,7 @@ jobs:
git config --global user.email 'github-tidy-bot@supabase.com'
if [[ `git status --porcelain` ]]; then
echo "[bot] Changes detected, committing."
echo "[bot] Running in non-squash mode."
git commit -am "ci: Autofix updates from GitHub workflow"
git push
-63
View File
@@ -1,63 +0,0 @@
name: Config (Preview)
on:
pull_request:
types:
- opened
- reopened
- synchronize
branches:
- master
paths:
- "supabase/**"
workflow_dispatch:
jobs:
wait:
runs-on: ubuntu-latest
outputs:
status: ${{ steps.check.outputs.conclusion }}
steps:
- uses: fountainhead/action-wait-for-check@v1.2.0
id: check
with:
checkName: Supabase
ref: ${{ github.event.pull_request.head.sha }}
token: ${{ secrets.GITHUB_TOKEN }}
apply:
needs:
- wait
if: ${{ needs.wait.outputs.status == 'success' }}
runs-on: ubuntu-latest
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
TF_VAR_linked_project: xguihxuzqibwxjnimxev
TF_VAR_git_branch: ${{ github.head_ref }}
TF_CLI_ARGS_apply: -target=supabase_settings.preview
defaults:
run:
working-directory: supabase/remotes
outputs:
db_user: ${{ steps.branch.outputs.user }}
db_pass: ${{ steps.branch.outputs.password }}
db_host: ${{ steps.branch.outputs.host }}
db_port: ${{ steps.branch.outputs.port }}
jwt_secret: ${{ steps.branch.outputs.jwt_secret }}
ref: ${{ steps.branch.outputs.id }}
status: ${{ steps.branch.outputs.status }}
version: ${{ steps.branch.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_wrapper: false
- run: terraform init
- run: terraform apply -auto-approve -no-color
- id: branch
run: |
terraform output -json branch_database \
| jq -r "to_entries|map(\"\(.key)=\(.value|tostring)\")|.[]" \
>> "$GITHUB_OUTPUT"
-28
View File
@@ -1,28 +0,0 @@
name: Config (Production)
on:
push:
branches:
- master
paths:
- "supabase/remotes/**"
workflow_dispatch:
jobs:
apply:
runs-on: ubuntu-latest
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
TF_VAR_linked_project: xguihxuzqibwxjnimxev
defaults:
run:
working-directory: supabase/remotes
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_wrapper: false
- run: terraform init
- run: terraform apply -auto-approve -no-color
+1 -1
View File
@@ -6,7 +6,7 @@ import OpenAI from 'openai'
export const runtime = 'edge'
/* To avoid OpenAI errors, restrict to the Vercel Edge Function regions that
overlap with the OpenAI API regions.
Reference for Vercel regions: https://vercel.com/docs/edge-network/regions#region-list
Reference for OpenAI regions: https://help.openai.com/en/articles/5347006-openai-api-supported-countries-and-territories
*/
@@ -1,9 +1,9 @@
import { type SerializeOptions } from 'next-mdx-remote/dist/types'
import type { SerializeOptions } from 'next-mdx-remote/dist/types'
import { notFound } from 'next/navigation'
import { relative } from 'path'
import rehypeSlug from 'rehype-slug'
import { genGuideMeta } from '~/features/docs/GuidesMdx.utils'
import { genGuideMeta, removeRedundantH1 } from '~/features/docs/GuidesMdx.utils'
import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template'
import { fetchRevalidatePerDay } from '~/features/helpers.fetch'
import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform'
@@ -84,7 +84,8 @@ const getContent = async ({ slug }: Params) => {
`https://raw.githubusercontent.com/${org}/${repo}/${branch}/${docsDir}/${remoteFile}`
)
const content = await response.text()
let content = await response.text()
content = removeRedundantH1(content)
return {
pathname: `/guides/ai/python/${slug}` satisfies `/${string}`,
@@ -86,7 +86,7 @@ const {
## Customization
Supabase Auth makes use of [Go Templates](https://pkg.go.dev/text/template). This means it is possible to conditionally render information based on template properties.You may wish to checkout this [guide by Hugo](https://gohugo.io/templates/introduction/) for a guide on the templating language.
Supabase Auth makes use of [Go Templates](https://pkg.go.dev/text/template). This means it is possible to conditionally render information based on template properties.
### Send different email to early access users
+1 -1
View File
@@ -241,7 +241,7 @@ secrets = "env(SEND_SMS_HOOK_SECRET)"
Fill in the Hook Secret in `supabase/functions/.env`
```bash
SEND_SMS_HOOK_SECRETS='v1,whsec_<base64_standard_secret>'
SEND_SMS_HOOK_SECRET='v1,whsec_<base64_standard_secret>'
```
Start the function locally:
@@ -421,3 +421,47 @@ If you really need access to the key, please [open a support ticket](https://sup
Yes, Supabase supports multi-tenant Single Sign-On (SSO) using SAML 2.0. While the dashboard displays only one SAML field, you can set up multiple SAML connections using the Supabase CLI.
Each connection is assigned a unique `sso_provider_id`, which is included in the user's JWT and can be used in Row Level Security (RLS) policies. You can configure custom attribute mappings for each connection to include tenant-specific information, such as roles.
This setup allows you to implement multi-tenant SSO for multiple clients or organizations within a single application. For example, if you have an app with multiple clients using different Azure Active Directories, you can create separate SAML connections for each and use the `sso_provider_id` to manage access and apply appropriate security policies.
### Is multi-subdomain SSO with SAML supported?
Yes, also referred to as [cross-origin authentication within the same site](https://web.dev/articles/same-site-same-origin). To redirect to a URL other than the [Site URL](https://supabase.com/docs/guides/auth/redirect-urls), following the SAML response from the IdP, the `redirectTo` option can be added to [`signInWithSSO`](https://supabase.com/docs/reference/javascript/auth-signinwithsso).
```ts
const { data, error } = await supabase.auth.signInWithSSO({
domain: 'company.com'
options: {
redirectTo: `https://app.company.com/callback`,
},
})
```
When redirecting to a URL other than the Site URL, a `/callback` endpoint is necessary to process the auth code from the IdP and exchange it for a session. This assumes the [Supabase SSR client](https://supabase.com/docs/guides/auth/server-side/creating-a-client) has already been configured.
<Tabs scrollable size="small" type="underlined" defaultActiveId="sveltekit" queryGroup="framework">
<TabPanel id="sveltekit" label="SvelteKit">
```ts
import { error, redirect } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
export const GET: RequestHandler = async ({ url, locals }) => {
const code = url.searchParams.get('code')
if (!code) {
error(400, 'No authorization code provided')
}
const { error: tokenExchangeError } = await locals.supabase.auth.exchangeCodeForSession(code)
if (tokenExchangeError) {
error(400, 'Failed to exchange authorization code for session')
}
redirect(303, '/')
}
```
</TabPanel>
</Tabs>
+17 -11
View File
@@ -142,7 +142,7 @@ The PKCE flow allows for server-side authentication. Unlike the implicit flow, w
##### Step 1: Update signup confirmation email
Update your signup email template to send the token hash. See [Email Templates](/docs/guides/auth/auth-email-templates) for how to configure your email templates.
Update your signup email template to send the token hash. For detailed instructions on how to configure your email templates, including the use of variables like `{{ .SiteURL }}`, `{{ .TokenHash }}`, and `{{ .RedirectTo }}`, please refer to our [Email Templates](/docs/guides/auth/auth-email-templates) guide.
Your signup email template should contain the following HTML:
@@ -151,7 +151,8 @@ Your signup email template should contain the following HTML:
<p>Follow this link to confirm your user:</p>
<p>
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email"
<a
href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&next={{ .RedirectTo }}"
>Confirm your email</a
>
</p>
@@ -170,18 +171,16 @@ Create a new file at `app/auth/confirm/route.ts` and populate with the following
```ts app/auth/confirm/route.ts
import { type EmailOtpType } from '@supabase/supabase-js'
import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
// The client you created from the Server-Side Auth instructions
import { type NextRequest } from 'next/server'
import { createClient } from '@/utils/supabase/server'
import { redirect } from 'next/navigation'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const token_hash = searchParams.get('token_hash')
const type = searchParams.get('type') as EmailOtpType | null
const next = searchParams.get('next') ?? '/'
const redirectTo = request.nextUrl.clone()
redirectTo.pathname = next
if (token_hash && type) {
const supabase = await createClient()
@@ -191,13 +190,13 @@ export async function GET(request: NextRequest) {
token_hash,
})
if (!error) {
return NextResponse.redirect(redirectTo)
// redirect user to specified redirect URL or root of app
redirect(next)
}
}
// return the user to an error page with some instructions
redirectTo.pathname = '/auth/auth-code-error'
return NextResponse.redirect(redirectTo)
// redirect the user to an error page with some instructions
redirect('/auth/auth-code-error')
}
```
@@ -381,11 +380,18 @@ app.get("/auth/confirm", async function (req, res) {
To sign up the user, call [signUp()](/docs/reference/javascript/auth-signup) with their email address and password:
You can optionally specify a URL to redirect to after the user clicks the confirmation link. This URL must be configured as a [Redirect URL](/docs/guides/auth/redirect-urls), which you can do in the [dashboard](/dashboard/project/_/auth/url-configuration) for hosted projects, or in the [configuration file](/docs/guides/cli/config#auth.additional_redirect_urls) for self-hosted projects.
If you don't specify a redirect URL, the user is automatically redirected to your site URL. This defaults to `localhost:3000`, but you can also configure this.
```js
async function signUpNewUser() {
const { data, error } = await supabase.auth.signUp({
email: 'example@email.com',
password: 'example-password',
options: {
emailRedirectTo: 'https://example.com/welcome',
},
})
}
```
@@ -219,7 +219,6 @@ hideToc: true
import { useState, useEffect } from 'react'
import { supabase } from './lib/supabase'
import Auth from './components/Auth'
import Account from './components/Account'
import { View, Text } from 'react-native'
import { Session } from '@supabase/supabase-js'
@@ -111,7 +111,7 @@ When developing with Expo, you can test Sign in with Apple via the Expo Go app,
1. Have an **App ID** which uniquely identifies the app you are building. You can create a new App ID from the [Identifiers](https://developer.apple.com/account/resources/identifiers/list/bundleId) section in the Apple Developer Console (use the filter menu in the upper right side to see all App IDs). These usually are a reverse domain name string, for example `com.example.app`. Make sure you configure Sign in with Apple for the App ID you created or already have, in the Capabilities list. At this time Supabase Auth does not support Server-to-Server notification endpoints, so you should leave that setting blank. (In the past App IDs were referred to as _bundle IDs._)
2. Obtain a **Services ID** attached to the App ID that uniquely identifies the website. Use this value as the client ID when initializing Sign in with Apple JS. You can create a new Services ID from the [Identifiers](https://developer.apple.com/account/resources/identifiers/list/serviceId) section in the Apple Developer Console (use the filter menu in the upper right side to see all Services IDs). These usually are a reverse domain name string, for example `com.example.app.website`.
3. Configure Website URLs for the newly created **Services ID**. The web domain you should use is the domain your website is hosted on. The redirect URL must also point to a page on your website that will receive the callback from Apple.
4. Register the Services ID you created to your project's [Apple provider configuration in the Supabase dashboard](https://supabase.com/dashboard/project/_/auth/providers) under _Authorized Client IDs_.
4. Register the Services ID you created to your project's [Apple provider configuration in the Supabase dashboard](https://supabase.com/dashboard/project/_/auth/providers) under _Client IDs_.
<Admonition type="note">
If you're using Sign in with Apple JS you do not need to configure the OAuth settings.
@@ -184,12 +184,12 @@ When developing with Expo, you can test Sign in with Apple via the Expo Go app,
<Admonition type="note">
When testing with Expo Go, the Expo App ID `host.exp.Exponent` will be used. Make sure to add this to the "Authorized Client IDs" list in your [Supabase dashboard Apple provider configuration](https://supabase.com/dashboard/project/_/auth/providers)!
When testing with Expo Go, the Expo App ID `host.exp.Exponent` will be used. Make sure to add this to the "Client IDs" list in your [Supabase dashboard Apple provider configuration](https://supabase.com/dashboard/project/_/auth/providers)!
</Admonition>
1. Have an **App ID** which uniquely identifies the app you are building. You can create a new App ID from the [Identifiers](https://developer.apple.com/account/resources/identifiers/list/bundleId) section in the Apple Developer Console (use the filter menu in the upper right side to see all App IDs). These usually are a reverse domain name string, for example `com.example.app`. Make sure you configure Sign in with Apple for the App ID you created or already have, in the Capabilities list. At this time Supabase Auth does not support Server-to-Server notification endpoints, so you should leave that setting blank. (In the past App IDs were referred to as _bundle IDs._)
2. Register all of the App IDs that will be using your Supabase project in the [Apple provider configuration in the Supabase dashboard](https://supabase.com/dashboard/project/_/auth/providers) under _Authorized Client IDs_.
2. Register all of the App IDs that will be using your Supabase project in the [Apple provider configuration in the Supabase dashboard](https://supabase.com/dashboard/project/_/auth/providers) under _Client IDs_.
<Admonition type="note">
@@ -243,7 +243,7 @@ When developing with Expo, you can test Sign in with Apple via the Expo Go app,
### Configuration [#flutter-configuration-native-app]
1. Have an **App ID** which uniquely identifies the app you are building. You can create a new App ID from the [Identifiers](https://developer.apple.com/account/resources/identifiers/list/bundleId) section in the Apple Developer Console (use the filter menu in the upper right side to see all App IDs). These usually are a reverse domain name string, for example `com.example.app`. Make sure you configure Sign in with Apple for the App ID you created or already have, in the Capabilities list. At this time Supabase Auth does not support Server-to-Server notification endpoints, so you should leave that setting blank. (In the past App IDs were referred to as _bundle IDs._)
2. Register all of the App IDs that will be using your Supabase project in the [Apple provider configuration in the Supabase dashboard](https://supabase.com/dashboard/project/_/auth/providers) under _Authorized Client IDs_.
2. Register all of the App IDs that will be using your Supabase project in the [Apple provider configuration in the Supabase dashboard](https://supabase.com/dashboard/project/_/auth/providers) under _Client IDs_.
## Apple sign in on Android, Web, Windows and Linux
@@ -339,7 +339,7 @@ When developing with Expo, you can test Sign in with Apple via the Expo Go app,
### Configuration [#swift-configuration-native-app]
1. Have an **App ID** which uniquely identifies the app you are building. You can create a new App ID from the [Identifiers](https://developer.apple.com/account/resources/identifiers/list/bundleId) section in the Apple Developer Console (use the filter menu in the upper right side to see all App IDs). These usually are a reverse domain name string, for example `com.example.app`. Make sure you configure Sign in with Apple for the App ID you created or already have, in the Capabilities list. At this time Supabase Auth does not support Server-to-Server notification endpoints, so you should leave that setting blank. (In the past App IDs were referred to as _bundle IDs._)
2. Register all of the App IDs that will be using your Supabase project in the [Apple provider configuration in the Supabase dashboard](https://supabase.com/dashboard/project/_/auth/providers) under _Authorized Client IDs_.
2. Register all of the App IDs that will be using your Supabase project in the [Apple provider configuration in the Supabase dashboard](https://supabase.com/dashboard/project/_/auth/providers) under _Client IDs_.
<Admonition type="note">
@@ -69,7 +69,7 @@ To use Google's pre-built signin buttons:
1. Click `Create credentials` and choose `OAuth Client ID`.
1. For application type, choose `Web application`.
1. Under **Authorized JavaScript origins** and **Authorized redirect URLs**, add your site URL. This is the URL of the website where the signin button will appear, _not_ your Supabase project domain. If you're testing in localhost, ensure that you have `http://localhost` set in the **Authorized JavaScript origins** section as well. This is important when integrating with Google One-Tap to ensure you can use it locally.
1. When you finish configuring your credentials, you will be shown your client ID. Add this to the **Authorized Client IDs** field in the [Google Auth Provider section of the Supabase Dashboard](/dashboard/project/_/auth/providers). Leave the OAuth client ID and secret blank. You don't need them when using Google's pre-built approach.
1. When you finish configuring your credentials, you will be shown your client ID. Add this to the **Client IDs** field in the [Google Auth Provider section of the Supabase Dashboard](/dashboard/project/_/auth/providers). Leave the OAuth client ID and secret blank. You don't need them when using Google's pre-built approach.
</TabPanel>
@@ -80,7 +80,7 @@ To use Google's pre-built signin buttons:
- You will have a different set of SHA-1 certificate fingerprint for testing locally and going to production. Make sure to add both to the Google Cloud Console. and add all of the Client IDs to Supabase dashboard.
- For iOS, use the instructions on screen to provide the app Bundle ID, and App Store ID and Team ID if the app is already published on the Apple AppStore.
2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app. In particular, make sure you have set up links to your app's privacy policy and terms of service.
3. Finally, add the client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Authorized Client IDs_.
3. Finally, add the client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Client IDs_.
Note that you do not have to configure the OAuth flow in the Supabase Dashboard in order to use native sign in.
@@ -96,7 +96,7 @@ To use Google's pre-built signin buttons:
- You will have a different set of SHA-1 certificate fingerprint for testing locally and going to production. Make sure to add both to the Google Cloud Console. and add all of the Client IDs to Supabase dashboard.
- For iOS, use the instructions on screen to provide the app Bundle ID, and App Store ID and Team ID if the app is already published on the Apple AppStore.
2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app. In particular, make sure you have set up links to your app's privacy policy and terms of service.
3. Add only the web client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Authorized Client IDs_. If you need iOS support, enable the `Skip nonce check` option.
3. Add only the web client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Client IDs_. If you need iOS support, enable the `Skip nonce check` option.
4. For iOS, add the `CFBundleURLTypes` attributes below into the `/ios/Runner/Info.plist` file.
```xml
<!-- Put me in the [my_project]/ios/Runner/Info.plist file -->
@@ -168,7 +168,7 @@ To use Google's pre-built signin buttons:
1. Follow the integration instructions on the [get started with Google Sign-In](https://developers.google.com/identity/sign-in/ios/start-integrating) for iOS guide.
2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app. In particular, make sure you have set up links to your app's privacy policy and terms of service.
3. Add only the web client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Authorized Client IDs_. Enable the `Skip nonce check` option.
3. Add only the web client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Client IDs_. Enable the `Skip nonce check` option.
</TabPanel>
@@ -178,7 +178,7 @@ To use Google's pre-built signin buttons:
1. Configure OAuth credentials for your Google Cloud project in the [Credentials](https://console.cloud.google.com/apis/credentials) page of the console. You need two client IDs, a web client ID and Android client ID. When creating a new Android OAuth client ID, use the instructions on screen to provide the SHA-1 certificate fingerprint used to sign your Android app.
2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app. In particular, make sure you have set up links to your app's privacy policy and terms of service.
3. Finally, add the web client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Authorized Client IDs_.
3. Finally, add the web client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Client IDs_.
Note that you do not have to configure the OAuth flow in the Supabase Dashboard in order to use native sign in.
@@ -190,7 +190,7 @@ To use Google's pre-built signin buttons:
- For iOS, use the instructions on screen to provide the app Bundle ID, and App Store ID and Team ID if the app is already published on the Apple AppStore.
2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app. In particular, make sure you have set up links to your app's privacy policy and terms of service.
3. Finally, add the client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Authorized Client IDs_.
3. Finally, add the client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Client IDs_.
Note that you do not have to configure the OAuth flow in the Supabase Dashboard in order to use native sign in.
@@ -202,7 +202,7 @@ To use Google's pre-built signin buttons:
1. Configure OAuth credentials for your Google Cloud project in the [Credentials](https://console.cloud.google.com/apis/credentials) page of the console. When creating a new OAuth client ID, choose _Chrome extension_ for the application type. For _Item ID_ provide the unique ID of your Chrome extension. You can get this by calling `chrome.runtime.id` within the extension, or from the Web Store URL of the extension. For example, the [Google Translate extension](https://chrome.google.com/webstore/detail/google-translate/aapbdbdomjkkjkaonfhkkikfgjllcleb) has the Web Store URL `https://chrome.google.com/webstore/detail/google-translate/aapbdbdomjkkjkaonfhkkikfgjllcleb` and the last part `aapbdbdomjkkjkaonfhkkikfgjllcleb` is its unique ID.
2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app.
3. Finally, add the client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Authorized Client IDs_.
3. Finally, add the client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Client IDs_.
Note that you do not have to configure the OAuth flow in the Supabase Dashboard to sign in with Google inside Chrome extensions.
@@ -439,6 +439,8 @@ Unlike the OAuth flow which requires the use of a web browser, the native Sign i
When the user provides consent, Google issues an identity token (commonly abbreviated as ID token) that is then sent to your project's Supabase Auth server. When valid, a new user session is started by issuing an access and refresh token from Supabase Auth.
By default, Supabase Auth implements nonce validation during the authentication flow. This can be disabled in production under `Authentication > Providers > Google > Skip Nonce Check` in the Dashboard, or when developing locally by setting `auth.external.<provider>.skip_nonce_check`. Only disable this if your client libraries cannot properly handle nonce verification.
When working with Expo, you can use the [`react-native-google-signin/google-signin` library](https://github.com/react-native-google-signin/google-signin#expo-installation) library to obtain an ID token that you can pass to supabase-js [`signInWithIdToken` method](/docs/reference/javascript/auth-signinwithidtoken).
Follow the [Expo installation docs](https://react-native-google-signin.github.io/docs/setting-up/expo) for installation and configuration instructions. See the [supabase-js reference](/docs/reference/javascript/initializing?example=react-native-options-async-storage) for instructions on initializing the supabase-js client in React Native.
@@ -53,6 +53,8 @@ Some settings can only be modified by a superuser. Supabase pre-enables the [`su
| `auto_explain.log_min_duration` | Logs query plans taking longer than this duration. |
| `auto_explain.log_nested_statements` | Log nested statements' plans. |
| `log_min_messages` | Minimum severity level of messages to log. |
| `pg_net.ttl` | Sets how long the [pg_net extension](/docs/guides/database/extensions/pg_net) saves responses |
| `pg_net.batch_size` | Sets how many requests the [pg_net extension](/docs/guides/database/extensions/pg_net) can make per second |
| `pgaudit.*` | Configures the [PGAudit extension](https://supabase.com/docs/guides/database/extensions/pgaudit). The `log_parameter` is still restricted to protect secrets |
| `pgrst.*` | [`PostgREST` settings](https://docs.postgrest.org/en/stable/references/configuration.html#db-aggregates-enabled) |
| `plan_filter.*` | Configures the [pg_plan_filter extension](https://supabase.com/docs/guides/database/extensions/pg_plan_filter) |
@@ -8,6 +8,12 @@ Supabase DOES NOT RECOMMEND any new usage of [`pgsodium`](https://github.com/mic
The [`pgsodium`](https://github.com/michelp/pgsodium) extension is expected to go through a deprecation cycle in the near future. We will reach out to owners of impacted projects to assist with migrations away from [`pgsodium`](https://github.com/michelp/pgsodium) once the deprecation process begins.
<Admonition type="note">
The [Vault extension](/docs/guides/database/vault) won’t be impacted. Its internal implementation will shift away from pgsodium, but the interface and API will remain unchanged.
</Admonition>
[`pgsodium`](https://github.com/michelp/pgsodium) is a Postgres extension which provides SQL access to [`libsodium`'s](https://doc.libsodium.org/) high-level cryptographic algorithms.
Supabase previously documented two features derived from pgsodium. Namely [Server Key Management](https://github.com/michelp/pgsodium#server-key-management) and [Transparent Column Encryption](https://github.com/michelp/pgsodium#transparent-column-encryption). At this time, we do not recommend using either on the Supabase platform due to their high level of operational complexity and misconfiguration risk.
@@ -102,6 +102,20 @@ const { data, error } = await supabase.from('posts').insert({
})
```
## Specific usage cases
### Queries with filtering
If you use an IVFFlat or HNSW index and naively filter the results based on the value of another column, you may get fewer rows returned than requested.
For example, the following query may return fewer than 5 rows, even if 5 corresponding rows exist in the database. This is because the embedding index may not return 5 rows matching the filter.
```
SELECT * FROM items WHERE category_id = 123 ORDER BY embedding <-> '[3,1,2]' LIMIT 5;
```
To get the exact number of requested rows, use [iterative search](https://github.com/pgvector/pgvector/?tab=readme-ov-file#iterative-index-scans) to continue scanning the index until enough results are found.
## More pgvector and Supabase resources
- [Supabase Clippy: ChatGPT for Supabase Docs](https://supabase.com/blog/chatgpt-supabase-docs)
@@ -58,7 +58,7 @@ Let’s create our table. Each row represents a restaurant with its location sto
create table if not exists public.restaurants (
id int generated by default as identity primary key,
name text not null,
location geography(POINT) not null
location gis.geography(POINT) not null
);
```
@@ -27,7 +27,7 @@ This video demonstrates how you can create a new customer in Stripe each time a
## Creating a webhook
1. Create a new [Database Webhook](https://supabase.com/dashboard/project/_/database/hooks) in the Dashboard.
1. Create a new [Database Webhook](https://supabase.com/dashboard/project/_/integrations/hooks) in the Dashboard.
1. Give your Webhook a name.
1. Select the table you want to hook into.
1. Select one or more events (table inserts, updates, or deletes) you want to hook into.
@@ -3,27 +3,32 @@ id: 'function-background-tasks'
title: 'Background Tasks'
description: 'How to run background tasks in an Edge Function outside of the request handler'
subtitle: 'How to run background tasks in an Edge Function outside of the request handler'
tocVideo: 'rSKBTJxG9VA'
---
Edge Function instances can process background tasks outside of the request handler. Background tasks are useful for asynchronous operations like uploading a file to Storage, updating a database, or sending events to a logging service. You can respond to the request immediately and leave the task running in the background.
A background task can run until the Edge Function instance hits its [wall-clock limit](/docs/guides/functions/limits).
### How long a background task can run
A background task can run until the Edge Function instance hits its wall-clock limit or reaches CPU/Memory limit. Check [limits](/docs/guides/functions/limits) for current caps.
### Example
Here's an example of defining a background task using a custom event.
```ts
// define a custom event
// Define a custom event type for the background task.
class MyBackgroundTaskEvent extends Event {
readonly taskPromise: Promise<string>
readonly taskPromise: Promise<Response>
constructor(taskPromise) {
constructor(taskPromise: Promise<Response>) {
super('myBackgroundTask')
this.taskPromise = taskPromise
}
}
globalThis.addEventListener('myBackgroundTask', async (event: MyBackgroundTaskEvent) => {
const res = await event.taskPromise
globalThis.addEventListener('myBackgroundTask', async (event) => {
const res = await (event as MyBackgroundTaskEvent).taskPromise
console.log(await res.json())
})
@@ -16,7 +16,7 @@ The `supabase-js` client is a great option for connecting to your Supabase datab
```ts index.ts
import { createClient } from 'jsr:@supabase/supabase-js@2'
Deno.serve(async (_req) => {
Deno.serve(async (req) => {
try {
const supabase = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
@@ -34,7 +34,7 @@ jobs:
PROJECT_ID: YOUR_SUPABASE_PROJECT_ID
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
with:
@@ -9,53 +9,7 @@ hideToc: true
<StepHikeCompact.Step step={1}>
<StepHikeCompact.Details title="Create a Supabase project">
Go to [database.new](https://database.new) and create a new Supabase project.
When your project is up and running, go to the [Table Editor](https://supabase.com/dashboard/project/_/editor), create a new table and insert some data.
Alternatively, you can run the following snippet in your project's [SQL Editor](https://supabase.com/dashboard/project/_/sql/new). This will create a `notes` table with some sample data.
</StepHikeCompact.Details>
<StepHikeCompact.Code>
```sql SQL_EDITOR
-- Create the table
create table notes (
id bigint primary key generated always as identity,
title text not null
);
-- Insert some sample data into the table
insert into notes (title)
values
('Today I created a Supabase project.'),
('I added some data and queried it from Next.js.'),
('It was awesome!');
alter table notes enable row level security;
```
</StepHikeCompact.Code>
<StepHikeCompact.Details>
Make the data in your table publicly readable by adding an RLS policy:
</StepHikeCompact.Details>
<StepHikeCompact.Code>
```sql SQL_EDITOR
create policy "public can read notes"
on public.notes
for select to anon
using (true);
```
</StepHikeCompact.Code>
<QuickstartDbSetup />
</StepHikeCompact.Step>
@@ -104,9 +58,9 @@ hideToc: true
<StepHikeCompact.Step step={4}>
<StepHikeCompact.Details title="Query Supabase data from Next.js">
Create a new file at `app/notes/page.tsx` and populate with the following.
Create a new file at `app/countries/page.tsx` and populate with the following.
This will select all the rows from the `notes` table in Supabase and render them on the page.
This will select all the rows from the `countries` table in Supabase and render them on the page.
</StepHikeCompact.Details>
@@ -114,14 +68,14 @@ hideToc: true
<CH.Code className="min-h-[34rem]">
```ts app/notes/page.tsx
```ts app/countries/page.tsx
import { createClient } from '@/utils/supabase/server';
export default async function Notes() {
export default async function Countries() {
const supabase = await createClient();
const { data: notes } = await supabase.from("notes").select();
const { data: countries } = await supabase.from("countries").select();
return <pre>{JSON.stringify(notes, null, 2)}</pre>
return <pre>{JSON.stringify(countries, null, 2)}</pre>
}
```
@@ -166,7 +120,7 @@ hideToc: true
<StepHikeCompact.Step step={5}>
<StepHikeCompact.Details title="Start the app">
Run the development server, go to http://localhost:3000/notes in a browser and you should see the list of notes.
Run the development server, go to http://localhost:3000/countries in a browser and you should see the list of countries.
</StepHikeCompact.Details>
@@ -24,7 +24,7 @@ hideToc: true
<StepHikeCompact.Code>
```bash Terminal
npm create svelte@latest myapp
npx sv create my-app
```
</StepHikeCompact.Code>
@@ -43,7 +43,7 @@ hideToc: true
<StepHikeCompact.Code>
```bash Terminal
cd myapp && npm install @supabase/supabase-js
cd my-app && npm install @supabase/supabase-js
```
</StepHikeCompact.Code>
@@ -53,7 +53,7 @@ hideToc: true
<StepHikeCompact.Step step={4}>
<StepHikeCompact.Details title="Create the Supabase client">
Create a `/src/lib` directory in your SvelteKit app, create a file called `supabaseClient.js` and add the following code to initialize the Supabase client with your project URL and public API (anon) key:
Create a `src/lib` directory in your SvelteKit app, create a file called `supabaseClient.js` and add the following code to initialize the Supabase client with your project URL and public API (anon) key:
<ProjectConfigVariables variable="url" />
<ProjectConfigVariables variable="anonKey" />
@@ -77,7 +77,7 @@ hideToc: true
Use `load` method to fetch the data server-side and display the query results as a simple list.
Create `+page.server.js` file in the `routes` directory with the following code.
Create `+page.server.js` file in the `src/routes` directory with the following code.
</StepHikeCompact.Details>
<StepHikeCompact.Code>
@@ -98,7 +98,7 @@ hideToc: true
<StepHikeCompact.Details title="">
Replace the existing content in your `+page.svelte` file in the `routes` directory with the following code.
Replace the existing content in your `+page.svelte` file in the `src/routes` directory with the following code.
</StepHikeCompact.Details>
<StepHikeCompact.Code>
@@ -40,7 +40,7 @@ npx expo install @supabase/supabase-js @react-native-async-storage/async-storage
Now let's create a helper file to initialize the Supabase client.
We need the API URL and the `anon` key that you copied [earlier](#get-the-api-keys).
These variables are safe to expose in your Expo app since Supabase has
[Row Level Security](/docs/guides/auth#row-level-security) enabled on your Database.
[Row Level Security](/docs/guides/database/postgres/row-level-security) enabled on your Database.
<Tabs
scrollable
@@ -115,7 +115,7 @@ The table below shows the actions each role can take on the resources belonging
The table below shows the actions each role can take on the resources belonging to the project.
| Resource | Action | Owner | Admin | Developer | Read-Only [^3] |
| Resource | Action | Owner | Admin | Developer | Read-Only[^3][^5] |
| ------------------------------------------------------------------------------------------------------ | ---------------------- | :-------------------------------------: | :-------------------------------------: | :-------------------------------------: | :-------------------------------------------------------------: |
| <a href="#project-permissions" id="project-permissions">**Project**</a> | | | | | |
| Project Management | Transfer | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> | <IconX size={14} /> |
@@ -188,7 +188,7 @@ The table below shows the actions each role can take on the resources belonging
| | Delete | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
| | View | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
| | List | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
| | Run | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck className="inline" size={14} color="#3FCF8E" /> [^5] |
| | Run | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck className="inline" size={14} color="#3FCF8E" /> [^6] |
| <a href="#database-permissions" id="database-permissions">**Database**</a> | | | | | |
| Scheduled Backups | View | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
| | Download | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> |
@@ -261,4 +261,5 @@ The table below shows the actions each role can take on the resources belonging
[^2]: Invites sent from a SSO account can only be accepted by another SSO account coming from the same identity provider. This is a security measure that prevents accidental invites to accounts not managed by your company's enterprise systems.
[^3]: Available on the Enterprise Plan.
[^4]: Read-Only role is able to access secrets.
[^5]: Limited to executing SELECT queries.
[^5]: Listed permissions are for the API and Dashboard.
[^6]: Limited to executing SELECT queries. SQL Query Snippets run by the Read-Only role are run against the database using the **supabase_read_only_user**. This role has the [predefined Postgres role pg_read_all_data](https://www.postgresql.org/docs/current/predefined-roles.html).
@@ -16,7 +16,7 @@ This SQL query will show the size of all databases in your Postgres cluster:
```sql
select
sum(pg_database_size(pg_database.datname)) / (1024 * 1024) as db_size_mb
pg_size_pretty(sum(pg_database_size(pg_database.datname)))
from pg_database;
```
@@ -30,6 +30,16 @@ Depending on your billing plan, your database can go into read-only mode which c
</Admonition>
### Disk space usage
Your database size is part of the disk usage for your Supabase project, there are many components to Postgres that consume additional disk space. One of the primary components, is the [Write Ahead Log (WAL)](https://www.postgresql.org/docs/current/wal-intro.html). Postgres will store database changes in log files that are cleared away after they are applied to the database. These same files are also used by [Read Replicas](/docs/guides/platform/read-replicas) or other replication methods.
If you would like to determine the size of the WAL files stored on disk, Postgres provides `pg_ls_waldir` as a helper function; the following query can be run:
```sql
select pg_size_pretty(sum(size)) as wal_size from pg_ls_waldir();
```
### Vacuum operations
Postgres does not immediately reclaim the physical space used by dead tuples (i.e., deleted rows) in the DB. They are marked as "removed" until a [vacuum operation](https://www.postgresql.org/docs/current/routine-vacuuming.html) is executed. As a result, deleting data from your database may not immediately reduce the reported disk usage. You can use the [Supabase CLI](https://supabase.com/docs/guides/cli/getting-started) `inspect db bloat` command to view all dead tuples in your database. Alternatively, you can run the [query](https://github.com/supabase/cli/blob/c9cce58025fded16b4c332747f819a44f45c3b83/internal/inspect/bloat/bloat.go#L17) found in the CLI's GitHub repo in the [SQL Editor](https://supabase.com/dashboard/project/_/sql/)
@@ -1,5 +1,5 @@
---
title: 'Dedicated IPv4 Address'
title: 'Dedicated IPv4 Address for Ingress'
description: 'Attach an IPv4 address to your database'
subtitle: 'Attach an IPv4 address to your database'
---
@@ -15,6 +15,12 @@ The Internet Protocol (IP) addresses devices on the internet. There are two main
## When you need the IPv4 add-on:
<Admonition type="caution">
IPv4 addresses are guaranteed to be static for ingress traffic. If your database is making outbound connections, the outbound IP address is not static and cannot be guaranteed.
</Admonition>
- When using the direct connection string in an IPv6-incompatible network instead of Supavisor or client libraries.
- When you need a dedicated IP address for your direct connection string
@@ -22,6 +22,11 @@ This upgrade method is currently in Beta.
</Admonition>
<Admonition type="note">
For security purposes, passwords for custom roles are not backed up and, following a restore, they
would need to be reset. See [here](/docs/guides/platform/backups#daily-backups) for more details
</Admonition>
pg_upgrade performs an in-place upgrade on your database. For projects larger than 1GB, pg_upgrade is generally faster than a pause+restore cycle, and the speed advantage grows with the size of the database.
1. Plan for an appropriate downtime window, and ensure you have reviewed the [caveats](#caveats) section of this document before executing the upgrade.
+3
View File
@@ -1540,6 +1540,7 @@
}
}
},
"403": { "description": "" },
"500": { "description": "Failed to retrieve project's service health status" }
},
"tags": ["Projects"],
@@ -3461,6 +3462,7 @@
"max_worker_processes": { "type": "integer", "minimum": 0, "maximum": 262143 },
"shared_buffers": { "type": "string" },
"statement_timeout": { "type": "string" },
"track_commit_timestamp": { "type": "boolean" },
"wal_keep_size": { "type": "string" },
"wal_sender_timeout": { "type": "string" },
"work_mem": { "type": "string" },
@@ -3487,6 +3489,7 @@
"max_worker_processes": { "type": "integer", "minimum": 0, "maximum": 262143 },
"shared_buffers": { "type": "string" },
"statement_timeout": { "type": "string" },
"track_commit_timestamp": { "type": "boolean" },
"wal_keep_size": { "type": "string" },
"wal_sender_timeout": { "type": "string" },
"work_mem": { "type": "string" },
+22 -1
View File
@@ -1,7 +1,7 @@
clispec: '001'
info:
id: cli
version: 1.215.0
version: 1.223.10
title: Supabase CLI
language: sh
source: https://github.com/supabase/cli
@@ -3354,6 +3354,27 @@ commands:
name: --use-pgadmin
description: Use pgAdmin to generate schema diff.
default_value: 'false'
- id: supabase-config
title: supabase config
summary: Manage Supabase project configurations
tags:
- management-api
links: []
subcommands:
- supabase-config-push
flags: []
- id: supabase-config-push
title: supabase config push
summary: Pushes local config.toml to the linked project
tags: []
links: []
usage: supabase config push
subcommands: []
flags:
- id: project-ref
name: --project-ref <string>
description: Project ref of the Supabase project.
default_value: ''
- id: supabase-completion
title: supabase completion
summary: Generate the autocompletion script for the specified shell
+10
View File
@@ -779,6 +779,16 @@ parameters:
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.external.provider.skip_nonce_check'
title: 'auth.external.<provider>.skip_nonce_check'
tags: ['auth']
required: false
description: |
Disables nonce validation during OIDC authentication flow for the specified provider. Enable only when client libraries cannot properly handle nonce verification. Be aware that this reduces security by allowing potential replay attacks with stolen ID tokens.
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'functions.function_name.enabled'
title: 'functions.<function_name>.enabled'
+42 -24
View File
@@ -357,6 +357,30 @@
}
]
},
{
"type": "category",
"title": "Organizations",
"items": [
{
"id": "supabase-orgs",
"title": "Manage organizations",
"slug": "supabase-orgs",
"type": "cli-command"
},
{
"id": "supabase-orgs-create",
"title": "Create an organization",
"slug": "supabase-orgs-create",
"type": "cli-command"
},
{
"id": "supabase-orgs-list",
"title": "List all organizations",
"slug": "supabase-orgs-list",
"type": "cli-command"
}
]
},
{
"type": "category",
"title": "Projects",
@@ -393,6 +417,24 @@
}
]
},
{
"type": "category",
"title": "Config",
"items": [
{
"id": "supabase-config",
"title": "Manage project configurations",
"slug": "supabase-config",
"type": "cli-command"
},
{
"id": "supabase-config-push",
"title": "Push config from a local file",
"slug": "supabase-config-push",
"type": "cli-command"
}
]
},
{
"type": "category",
"title": "Branches",
@@ -441,30 +483,6 @@
}
]
},
{
"type": "category",
"title": "Organizations",
"items": [
{
"id": "supabase-orgs",
"title": "Manage organizations",
"slug": "supabase-orgs",
"type": "cli-command"
},
{
"id": "supabase-orgs-create",
"title": "Create an organization",
"slug": "supabase-orgs-create",
"type": "cli-command"
},
{
"id": "supabase-orgs-list",
"title": "List all organizations",
"slug": "supabase-orgs-list",
"type": "cli-command"
}
]
},
{
"type": "category",
"title": "Edge Functions",
+1
View File
@@ -194,6 +194,7 @@ functions:
- If you're using an email, you can configure whether you want the user to receive a magiclink or a OTP.
- If you're using phone, you can configure whether you want the user to receive a OTP.
- The magic link's destination URL is determined by the [`SITE_URL`](https://supabase.com/docs/guides/auth/redirect-urls). You can modify the `SITE_URL` or add additional redirect URLs in [your project](https://supabase.com/dashboard/project/_/auth/url-configuration).
- If you are looking to send your own OTP emails, you can use the [Auth Admin command](https://supabase.com/docs/reference/javascript/auth-admin-generatelink) or the `Send Email` [Auth Hook](https://supabase.com/docs/guides/auth/auth-hooks)
examples:
- id: sign-in-with-email.
name: Sign in with email.
+1 -1
View File
@@ -594,7 +594,7 @@ functions:
- [Steps to obtain Android client ID](https://developers.google.com/identity/sign-in/android/start-integrating#configure_a_project)
- [Steps to obtain iOS client ID](https://developers.google.com/identity/sign-in/ios/start-integrating#get_an_oauth_client_id)
Add the web client ID to the `Authentication -> Providers -> Google -> Authorized Client IDs` section in your Supabase dashboard
Add the web client ID to the `Authentication -> Providers -> Google -> Client IDs` section in your Supabase dashboard
code: |
```dart
import 'package:google_sign_in/google_sign_in.dart';
@@ -4488,6 +4488,9 @@
}
}
},
"403": {
"description": ""
},
"500": {
"description": "Failed to retrieve project's service health status"
}
@@ -4720,6 +4723,9 @@
"statement_timeout": {
"type": "string"
},
"track_commit_timestamp": {
"type": "boolean"
},
"wal_keep_size": {
"type": "string"
},
@@ -4835,6 +4841,9 @@
"statement_timeout": {
"type": "string"
},
"track_commit_timestamp": {
"type": "boolean"
},
"wal_keep_size": {
"type": "string"
},
@@ -4927,6 +4936,9 @@
"statement_timeout": {
"type": "string"
},
"track_commit_timestamp": {
"type": "boolean"
},
"wal_keep_size": {
"type": "string"
},
@@ -11882,6 +11894,9 @@
"statement_timeout": {
"type": "string"
},
"track_commit_timestamp": {
"type": "boolean"
},
"wal_keep_size": {
"type": "string"
},
@@ -11963,6 +11978,9 @@
"statement_timeout": {
"type": "string"
},
"track_commit_timestamp": {
"type": "boolean"
},
"wal_keep_size": {
"type": "string"
},
+1 -2
View File
@@ -149,10 +149,9 @@ const SupabaseGridLayout = (props: SupabaseGridProps) => {
const { project } = useProjectContext()
const { data, error, isSuccess, isError, isLoading, isRefetching } = useTableRowsQuery(
{
queryKey: [props.table.schema, props.table.name],
projectRef: project?.ref,
connectionString: project?.connectionString,
table: props.table,
tableId: props.table.id,
sorts,
filters,
page: snap.page,
@@ -23,7 +23,6 @@ export function SelectEditor<TRow, TSummaryRow = unknown>({
const value = row[column.key as keyof TRow] as unknown as string
function onChange(event: any) {
debugger
if (!event.target.value || event.target.value == '') {
onRowChange({ ...row, [column.key]: null }, true)
} else {
@@ -44,7 +44,6 @@ const Pagination = () => {
const [{ filter }] = useUrlState({ arrayKeys: ['filter'] })
const filters = formatFilterURLParams(filter as string[])
const page = snap.page
const table = state.table ?? undefined
const roleImpersonationState = useRoleImpersonationStateSnapshot()
const [isConfirmNextModalOpen, setIsConfirmNextModalOpen] = useState(false)
@@ -60,10 +59,9 @@ const Pagination = () => {
const { data, isLoading, isSuccess, isError, isFetching } = useTableRowsCountQuery(
{
queryKey: [table?.schema, table?.name, 'count-estimate'],
projectRef: project?.ref,
connectionString: project?.connectionString,
table,
tableId: id,
filters,
enforceExactCount: snap.enforceExactCount,
impersonatedRole: roleImpersonationState.role,
@@ -1,5 +1,4 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import clsx from 'clsx'
import saveAs from 'file-saver'
import { ArrowUp, ChevronDown, FileText, Trash } from 'lucide-react'
import Papa from 'papaparse'
@@ -196,7 +195,7 @@ const DefaultHeader = ({ table, onAddColumn, onAddRow, onImportData }: DefaultHe
className="-translate-x-[2px]"
/>
<ArrowUp
className={clsx(
className={cn(
'transition duration-200 absolute bottom-0 right-0 translate-y-1 opacity-0 bg-brand-400 rounded-full',
'group-data-[highlighted]:translate-y-0 group-data-[highlighted]:text-brand group-data-[highlighted]:opacity-100'
)}
@@ -240,10 +239,9 @@ const RowHeader = ({ table, sorts, filters }: RowHeaderProps) => {
const [isExporting, setIsExporting] = useState(false)
const { data } = useTableRowsQuery({
queryKey: [table.schema, table.name],
projectRef: project?.ref,
connectionString: project?.connectionString,
table,
tableId: table.id,
sorts,
filters,
page: snap.page,
@@ -253,10 +251,9 @@ const RowHeader = ({ table, sorts, filters }: RowHeaderProps) => {
const { data: countData } = useTableRowsCountQuery(
{
queryKey: [table?.schema, table?.name, 'count-estimate'],
projectRef: project?.ref,
connectionString: project?.connectionString,
table,
tableId: table.id,
filters,
enforceExactCount: snap.enforceExactCount,
impersonatedRole: roleImpersonationState.role,
@@ -2,20 +2,18 @@ import { useQueryClient } from '@tanstack/react-query'
import { RefreshCw } from 'lucide-react'
import { useParams } from 'common'
import type { SupaTable } from 'components/grid/types'
import { sqlKeys } from 'data/sql/keys'
import { Entity } from 'data/table-editor/table-editor-types'
import { tableRowKeys } from 'data/table-rows/keys'
import { Button } from 'ui'
export type RefreshButtonProps = {
table: Entity | SupaTable
table: { id: number }
isRefetching?: boolean
}
const RefreshButton = ({ table, isRefetching }: RefreshButtonProps) => {
const { ref } = useParams()
const queryClient = useQueryClient()
const queryKey = sqlKeys.query(ref, [table.schema, table.name])
const queryKey = tableRowKeys.tableRowsAndCount(ref, table.id)
async function onClick() {
await queryClient.invalidateQueries(queryKey)
@@ -3,6 +3,7 @@ import { Eye, EyeOff } from 'lucide-react'
import { useEffect, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import { Markdown } from 'components/interfaces/Markdown'
import { DatePicker } from 'components/ui/DatePicker'
import { BASE_PATH } from 'lib/constants'
import { Button, Input, InputNumber, Listbox, Toggle } from 'ui'
@@ -109,16 +110,15 @@ const FormField = ({
label={properties.title}
labelOptional={
properties.descriptionOptional ? (
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
{properties.descriptionOptional}
</ReactMarkdown>
<Markdown
content={properties.descriptionOptional}
className="text-foreground-lighter"
/>
) : null
}
descriptionText={
properties.description ? (
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
{properties.description}
</ReactMarkdown>
<Markdown content={properties.description} className="text-foreground-lighter" />
) : null
}
actions={
@@ -140,6 +140,7 @@ const FormField = ({
}
/>
)
case 'multiline-string':
return (
<Input.TextArea
@@ -159,9 +160,7 @@ const FormField = ({
}
descriptionText={
properties.description ? (
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
{properties.description}
</ReactMarkdown>
<Markdown content={properties.description} className="text-foreground-lighter" />
) : null
}
actions={
@@ -183,6 +182,7 @@ const FormField = ({
}
/>
)
case 'number':
return (
<InputNumber
@@ -201,9 +201,7 @@ const FormField = ({
}
descriptionText={
properties.description ? (
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
{properties.description}
</ReactMarkdown>
<Markdown content={properties.description} className="text-foreground-lighter" />
) : null
}
actions={
@@ -28,6 +28,7 @@ import {
import { ProviderCollapsibleClasses } from './AuthProvidersForm.constants'
import type { Provider } from './AuthProvidersForm.types'
import FormField from './FormField'
import { Markdown } from 'components/interfaces/Markdown'
export interface ProviderFormProps {
config: components['schemas']['GoTrueConfigResponse']
@@ -281,9 +282,10 @@ const ProviderForm = ({ config, provider }: ProviderFormProps) => {
: `${apiUrl}/auth/v1/callback`
}
descriptionText={
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
{provider.misc.helper}
</ReactMarkdown>
<Markdown
content={provider.misc.helper}
className="text-foreground-lighter"
/>
}
/>
</>
@@ -399,23 +399,16 @@ const EXTERNAL_PROVIDER_APPLE = {
type: 'boolean',
},
EXTERNAL_APPLE_CLIENT_ID: {
title: 'Service ID (for OAuth)',
description: `Client identifier used in the OAuth flow on the web.
[Learn more](https://developer.apple.com/documentation/sign_in_with_apple/configuring_your_environment_for_sign_in_with_apple)`,
title: 'Client IDs',
description: `Comma separated list of allowed Apple app (Web, OAuth, iOS, macOS, watchOS, or tvOS) bundle IDs for native sign in, or service IDs for Sign in with Apple JS. [Learn more](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js)`,
type: 'string',
},
EXTERNAL_APPLE_SECRET: {
title: 'Secret Key (for OAuth)',
description: `Secret key used in the OAuth flow.
[Learn more](https://supabase.com/docs/guides/auth/social-login/auth-apple#generate-a-client_secret)`,
description: `Secret key used in the OAuth flow. [Learn more](https://supabase.com/docs/guides/auth/social-login/auth-apple#generate-a-client_secret)`,
type: 'string',
isSecret: true,
},
EXTERNAL_APPLE_ADDITIONAL_CLIENT_IDS: {
title: 'Authorized Client IDs (iOS, macOS, watchOS, tvOS bundle IDs or service IDs)',
description: `Comma separated list of allowed Apple app bundle IDs for native sign in, or service IDs for Sign in with Apple JS. [Learn more](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js)`,
type: 'string',
},
},
validationSchema: object().shape({
EXTERNAL_APPLE_ENABLED: boolean().required(),
@@ -426,7 +419,6 @@ const EXTERNAL_PROVIDER_APPLE = {
},
then: (schema) =>
schema
.required('Secret key is required when using the OAuth flow.')
.matches(/^[a-z0-9_-]+([.][a-z0-9_-]+){2}$/i, 'Secret key should be a JWT.')
.test({
message: 'Secret key is not a correctly generated JWT.',
@@ -473,49 +465,27 @@ const EXTERNAL_PROVIDER_APPLE = {
},
}),
})
.when(
[
'EXTERNAL_APPLE_ENABLED',
'EXTERNAL_APPLE_ADDITIONAL_CLIENT_IDS',
'EXTERNAL_APPLE_CLIENT_ID',
],
{
is: (
EXTERNAL_APPLE_ENABLED: boolean,
EXTERNAL_APPLE_ADDITIONAL_CLIENT_IDS: string,
EXTERNAL_APPLE_CLIENT_ID: string
) => {
return (
EXTERNAL_APPLE_ENABLED &&
!!EXTERNAL_APPLE_ADDITIONAL_CLIENT_IDS &&
!EXTERNAL_APPLE_CLIENT_ID
)
},
then: (schema) =>
schema.matches(
/^$/,
'Secret Key should only be set if Service ID for OAuth is provided.'
),
}
),
EXTERNAL_APPLE_CLIENT_ID: string().matches(
/^[a-z0-9.-]+$/i,
'Invalid characters. Apple recommends a reverse-domain name style string (e.g. com.example.app).'
),
EXTERNAL_APPLE_ADDITIONAL_CLIENT_IDS: string()
.matches(
/^([.a-z0-9-]+(,\s*[.a-z0-9-]+)*,*\s*)?$/i,
'Invalid characters. Apple recommends a reverse-domain name style string (e.g. com.example.app). You must only use explicit bundle IDs, asterisks (*) are not allowed.'
)
.when(['EXTERNAL_APPLE_ENABLED', 'EXTERNAL_APPLE_CLIENT_ID'], {
is: (EXTERNAL_APPLE_ENABLED: boolean, EXTERNAL_APPLE_CLIENT_ID: string) => {
return EXTERNAL_APPLE_ENABLED && !EXTERNAL_APPLE_CLIENT_ID
},
then: (schema) =>
schema.required(
'At least one Authorized Client ID is required when not using the OAuth flow.'
schema.matches(
/^$/,
'Secret Key should only be set if Service ID for OAuth is provided.'
),
}),
EXTERNAL_APPLE_CLIENT_ID: string()
.matches(/^\S+$/, 'Client IDs should not contain spaces.')
.matches(
/^([a-z0-9-]+\.[a-z0-9-]+(\.[a-z0-9-]+)*(,[a-z0-9-]+\.[a-z0-9-]+(\.[a-z0-9-]+)*)*)$/i,
'Invalid characters. Each ID should follow a reverse-domain style string (e.g. com.example.app). Use commas to separate multiple IDs.'
)
.when('EXTERNAL_APPLE_ENABLED', {
is: true,
then: (schema) =>
schema.required('At least one Client ID is required when Apple sign-in is enabled.'),
}),
}),
misc: {
iconKey: 'apple-icon',
@@ -833,8 +803,9 @@ const EXTERNAL_PROVIDER_GOOGLE = {
type: 'boolean',
},
EXTERNAL_GOOGLE_CLIENT_ID: {
title: 'Client ID (for OAuth)',
description: 'Client ID to use with the OAuth flow on the web.',
title: 'Client IDs',
description:
'Comma-separated list of client IDs for Web, OAuth, Android apps, One Tap, and Chrome extensions.',
type: 'string',
},
EXTERNAL_GOOGLE_SECRET: {
@@ -843,78 +814,34 @@ const EXTERNAL_PROVIDER_GOOGLE = {
type: 'string',
isSecret: true,
},
EXTERNAL_GOOGLE_ADDITIONAL_CLIENT_IDS: {
title: 'Authorized Client IDs (for Android, One Tap, and Chrome extensions)',
description:
'Comma separated list of client IDs of Android apps, One Tap or Chrome extensions that are allowed to log in to your project.',
type: 'string',
},
EXTERNAL_GOOGLE_SKIP_NONCE_CHECK: {
title: 'Skip nonce checks',
description:
"Allows ID tokens with any nonce to be accepted, which is less secure. Useful in situations where you don't have access to the nonce used to issue the ID token, such with iOS.",
"Allows ID tokens with any nonce to be accepted, which is less secure. Useful in situations where you don't have access to the nonce used to issue the ID token, such as with iOS.",
type: 'boolean',
},
},
validationSchema: object().shape({
EXTERNAL_GOOGLE_ENABLED: boolean().required(),
EXTERNAL_GOOGLE_SECRET: string()
.when(['EXTERNAL_GOOGLE_ENABLED', 'EXTERNAL_GOOGLE_CLIENT_ID'], {
is: (EXTERNAL_GOOGLE_ENABLED: boolean, EXTERNAL_GOOGLE_CLIENT_ID: string) => {
return EXTERNAL_GOOGLE_ENABLED && !!EXTERNAL_GOOGLE_CLIENT_ID
},
then: (schema) =>
schema
.matches(
/^[a-z0-9.\/_-]*$/i,
'Invalid characters. Google OAuth Client Secrets usually contain letters, numbers, dots, dashes and underscores.'
)
.required('Client Secret is required when using the OAuth flow.'),
})
.when(
[
'EXTERNAL_GOOGLE_ENABLED',
'EXTERNAL_GOOGLE_ADDITIONAL_CLIENT_IDS',
'EXTERNAL_GOOGLE_CLIENT_ID',
],
{
is: (
EXTERNAL_GOOGLE_ENABLED: boolean,
EXTERNAL_GOOGLE_ADDITIONAL_CLIENT_IDS: string,
EXTERNAL_GOOGLE_CLIENT_ID: string
) => {
return (
EXTERNAL_GOOGLE_ENABLED &&
!!EXTERNAL_GOOGLE_ADDITIONAL_CLIENT_IDS &&
!EXTERNAL_GOOGLE_CLIENT_ID
)
},
then: (schema) =>
schema.matches(
/^$/,
'Client Secret should only be set when Client ID for OAuth is set.'
),
}
),
EXTERNAL_GOOGLE_CLIENT_ID: string().matches(
/^([a-z0-9-]+([.][a-z0-9-]+)+)?$/i,
'Invalid characters. Google OAuth Client IDs are usually a domain-name (e.g. 01234567890-abcdefghijklmnopqrstuvwxyz012345.apps.googleusercontent.com).'
),
EXTERNAL_GOOGLE_ADDITIONAL_CLIENT_IDS: string()
EXTERNAL_GOOGLE_CLIENT_ID: string()
.matches(/^\S+$/, 'Client IDs should not contain spaces.')
.matches(
/^([a-z0-9-]+([.][a-z0-9-]+)*(,\s*[a-z0-9-]+([.][a-z0-9-]+)*)*,*\s*)?$/i,
'Invalid characters. Google Client IDs are usually a domain-name style string (e.g. com.example.com.app or *.apps.googleusercontent.com).'
/^([a-z0-9-]+\.[a-z0-9-]+(\.[a-z0-9-]+)*(,[a-z0-9-]+\.[a-z0-9-]+(\.[a-z0-9-]+)*)*)$/i,
'Invalid characters. Google Client IDs should be a comma-separated list of domain-like strings.'
)
.when(['EXTERNAL_GOOGLE_ENABLED', 'EXTERNAL_GOOGLE_CLIENT_ID'], {
is: (EXTERNAL_GOOGLE_ENABLED: boolean, EXTERNAL_GOOGLE_CLIENT_ID: string) => {
return EXTERNAL_GOOGLE_ENABLED && !EXTERNAL_GOOGLE_CLIENT_ID
},
.when('EXTERNAL_GOOGLE_ENABLED', {
is: true,
then: (schema) =>
schema.required(
'At least one Authorized Client ID is required when not using the OAuth flow.'
),
schema.required('At least one Client ID is required when Google sign-in is enabled.'),
}),
EXTERNAL_GOOGLE_SECRET: string().when('EXTERNAL_GOOGLE_ENABLED', {
is: true,
then: (schema) =>
schema.matches(
/^[a-z0-9.\/_-]*$/i,
'Invalid characters. Google OAuth Client Secrets usually contain letters, numbers, dots, dashes, and underscores.'
),
}),
EXTERNAL_GOOGLE_SKIP_NONCE_CHECK: boolean().required(),
}),
misc: {
@@ -1,7 +1,8 @@
import type { PostgresPolicy } from '@supabase/postgres-meta'
import { isEqual } from 'lodash'
import type { Message } from 'ai/react'
import { uuidv4 } from 'lib/helpers'
import { isEqual } from 'lodash'
export type MessageWithDebug = Message & { isDebug: boolean }
@@ -1,29 +1,17 @@
import { PostgresPolicy } from '@supabase/postgres-meta'
import { Lock } from 'lucide-react'
import { useRouter } from 'next/router'
import { useParams } from 'common'
import { Button } from 'ui'
import { generateAlterPolicyQuery, generateCreatePolicyQuery } from './AIPolicyEditorPanel.utils'
interface LockedCreateQuerySection {
schema: string
selectedPolicy?: PostgresPolicy
formFields: { name: string; table: string; behavior: string; command: string; roles: string }
editorOneRef: any
editorTwoRef: any
}
export const LockedCreateQuerySection = ({
schema,
selectedPolicy,
formFields,
editorOneRef,
editorTwoRef,
}: LockedCreateQuerySection) => {
const router = useRouter()
const { ref } = useParams()
const isEditing = selectedPolicy !== undefined
const { name, table, behavior, command, roles } = formFields
@@ -38,41 +26,6 @@ export const LockedCreateQuerySection = ({
Use options above to edit
</p>
</div>
{/* <Button
type="default"
onClick={() => {
const query = isEditing
? generateAlterPolicyQuery({
name: '',
newName: name,
schema,
table,
command,
roles: roles.length === 0 ? 'public' : roles,
using: (editorOneRef.current?.getValue() ?? undefined)?.trim(),
check:
command === 'insert'
? (editorOneRef.current?.getValue() ?? undefined)?.trim()
: (editorTwoRef.current?.getValue() ?? undefined)?.trim(),
})
: generateCreatePolicyQuery({
name,
schema,
table,
behavior,
command,
roles: roles.length === 0 ? 'public' : roles,
using: (editorOneRef.current?.getValue() ?? undefined)?.trim(),
check:
command === 'insert'
? (editorOneRef.current?.getValue() ?? undefined)?.trim()
: (editorTwoRef.current?.getValue() ?? undefined)?.trim(),
})
router.push(`/project/${ref}/sql/new?content=${query}`)
}}
>
Open in SQL Editor
</Button> */}
</div>
<div className="flex items-start" style={{ fontSize: '14px' }}>
<p className="px-6 font-mono text-sm text-foreground-light select-none">1</p>
@@ -595,8 +595,6 @@ export const AIPolicyEditorPanel = memo(function ({
<LockedCreateQuerySection
schema={schema}
selectedPolicy={selectedPolicy}
editorOneRef={editorOneRef}
editorTwoRef={editorTwoRef}
formFields={{ name, table, behavior, command, roles }}
/>
@@ -21,23 +21,33 @@ import {
TooltipContent_Shadcn_,
TooltipTrigger_Shadcn_,
} from 'ui'
import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useAppStateSnapshot } from 'state/app-state'
interface PolicyRowProps {
policy: PostgresPolicy
onSelectEditPolicy: (policy: PostgresPolicy) => void
onSelectDeletePolicy: (policy: PostgresPolicy) => void
isLocked: boolean
}
const PolicyRow = ({
policy,
isLocked: isLockedDefault,
onSelectEditPolicy = noop,
onSelectDeletePolicy = noop,
}: PolicyRowProps) => {
const { setAiAssistantPanel } = useAppStateSnapshot()
const enableAssistantV2 = useIsDatabaseFunctionsAssistantEnabled()
const canUpdatePolicies = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'policies')
const { project } = useProjectContext()
const { data: authConfig } = useAuthConfigQuery({ projectRef: project?.ref })
// override islocked for Realtime messages table
const isLocked =
policy.schema === 'realtime' && policy.table === 'messages' ? false : isLockedDefault
// TODO(km): Simple check for roles that allow authenticated access.
// In the future, we'll use splinter to return proper warnings for policies that allow anonymous user access.
const appliesToAnonymousUsers =
@@ -84,32 +94,54 @@ const PolicyRow = ({
</div>
</div>
<div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1.5" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-40">
<DropdownMenuItem className="gap-x-2" onClick={() => onSelectEditPolicy(policy)}>
<Edit size={14} />
<p>Edit policy</p>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={!canUpdatePolicies}
onClick={() => onSelectDeletePolicy(policy)}
tooltip={{
content: {
side: 'left',
text: 'You need additional permissions to delete policies',
},
}}
{!isLocked && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1.5" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
className={cn(enableAssistantV2 ? 'w-52' : 'w-40')}
>
<Trash size={14} />
<p>Delete policy</p>
</DropdownMenuItemTooltip>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenuItem className="gap-x-2" onClick={() => onSelectEditPolicy(policy)}>
<Edit size={14} />
<p>Edit policy</p>
</DropdownMenuItem>
{enableAssistantV2 && (
<DropdownMenuItem
className="space-x-2"
onClick={() => {
setAiAssistantPanel({
open: true,
editor: 'rls-policies',
entity: policy,
tables: [{ schema: policy.schema, name: policy.table }],
})
}}
>
<Edit size={14} />
<p>Edit policy with Assistant</p>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={!canUpdatePolicies}
onClick={() => onSelectDeletePolicy(policy)}
tooltip={{
content: {
side: 'left',
text: 'You need additional permissions to delete policies',
},
}}
>
<Trash size={14} />
<p>Delete policy</p>
</DropdownMenuItemTooltip>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</Panel.Content>
)
@@ -1,20 +1,15 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { noop } from 'lodash'
import { Lock, Unlock } from 'lucide-react'
import { useQueryState } from 'nuqs'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import {
AiIconAnimation,
Badge,
Button,
TooltipContent_Shadcn_,
TooltipTrigger_Shadcn_,
Tooltip_Shadcn_,
} from 'ui'
import { useAppStateSnapshot } from 'state/app-state'
import { AiIconAnimation, Badge } from 'ui'
interface PolicyTableRowHeaderProps {
table: {
@@ -40,7 +35,9 @@ const PolicyTableRowHeader = ({
onSelectCreatePolicy,
}: PolicyTableRowHeaderProps) => {
const { ref } = useParams()
const { setAiAssistantPanel } = useAppStateSnapshot()
const enableAssistantV2 = useIsDatabaseFunctionsAssistantEnabled()
const canToggleRLS = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables')
const isRealtimeSchema = table.schema === 'realtime'
@@ -115,25 +112,33 @@ const PolicyTableRowHeader = ({
Create policy
</ButtonTooltip>
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<Button
type="default"
className="px-1"
onClick={() => {
onSelectCreatePolicy()
setEditView('conversation')
}}
>
<AiIconAnimation className="scale-75 [&>div>div]:border-black dark:[&>div>div]:border-white" />
</Button>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom">
{!canToggleRLS
? 'You need additional permissions to create RLS policies'
: 'Create with Supabase Assistant'}
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
<ButtonTooltip
type="default"
className="px-1"
onClick={() => {
if (enableAssistantV2) {
setAiAssistantPanel({
open: true,
editor: 'rls-policies',
entity: undefined,
tables: [{ schema: table.schema, name: table.name }],
})
} else {
onSelectCreatePolicy()
setEditView('conversation')
}
}}
tooltip={{
content: {
side: 'bottom',
text: !canToggleRLS
? 'You need additional permissions to create RLS policies'
: 'Create with Supabase Assistant',
},
}}
>
<AiIconAnimation className="scale-75 [&>div>div]:border-black dark:[&>div>div]:border-white" />
</ButtonTooltip>
</div>
</div>
)}
@@ -88,6 +88,7 @@ const PolicyTableRow = ({
{policies?.map((policy) => (
<PolicyRow
key={policy.id}
isLocked={isLocked}
policy={policy}
onSelectEditPolicy={onSelectEditPolicy}
onSelectDeletePolicy={onSelectDeletePolicy}
@@ -18,18 +18,8 @@ import {
import CreateUserModal from './CreateUserModal'
import InviteUserModal from './InviteUserModal'
export type AddUserDropdownProps = {
projectKpsVersion?: string
}
const AddUserDropdown = ({ projectKpsVersion }: AddUserDropdownProps) => {
const AddUserDropdown = () => {
const inviteEnabled = IS_PLATFORM
? semver.gte(
// @ts-ignore
semver.coerce(projectKpsVersion ?? 'kps-v2.5.4'),
semver.coerce('kps-v2.5.3')
)
: true
const canInviteUsers = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'invite_user')
const canCreateUsers = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'create_user')
@@ -8,7 +8,6 @@ import * as z from 'zod'
import { useParams } from 'common'
import { useUserUpdateMutation } from 'data/auth/user-update-mutation'
import { User } from 'data/auth/users-infinite-query'
import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import {
Button,
cn,
@@ -34,7 +33,6 @@ interface BanUserModalProps {
export const BanUserModal = ({ visible, user, onClose }: BanUserModalProps) => {
const { ref: projectRef } = useParams()
const { data: settings } = useProjectSettingsV2Query({ projectRef })
const { mutate: updateUser, isLoading: isBanningUser } = useUserUpdateMutation({
onSuccess: (_, vars) => {
const bannedUntil = dayjs()
@@ -62,25 +60,15 @@ export const BanUserModal = ({ visible, user, onClose }: BanUserModalProps) => {
const bannedUntil = dayjs().add(Number(value), unit).format('DD MMM YYYY HH:mm (ZZ)')
const onSubmit = (data: FormType) => {
if (!settings) {
return toast.error(`Failed to ban user: Error loading project config`)
} else if (user.id === undefined) {
if (projectRef === undefined) return console.error('Project ref is required')
if (user.id === undefined) {
return toast.error(`Failed to ban user: User ID not found`)
}
const durationHours = data.unit === 'hours' ? Number(data.value) : Number(data.value) * 24
const protocol = settings?.app_config?.protocol ?? 'https'
const endpoint = settings?.app_config?.endpoint
const { serviceKey } = getAPIKeys(settings)
if (!endpoint) return toast.error(`Failed to ban user: Unable to retrieve API endpoint`)
if (!serviceKey?.api_key) return toast.error(`Failed to ban user: Unable to retrieve API key`)
updateUser({
projectRef,
protocol,
endpoint,
serviceApiKey: serviceKey.api_key,
userId: user.id,
banDuration: durationHours,
})
@@ -7,7 +7,6 @@ import * as z from 'zod'
import { useParams } from 'common'
import { useUserCreateMutation } from 'data/auth/user-create-mutation'
import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import {
Button,
@@ -39,13 +38,6 @@ const CreateUserFormSchema = z.object({
const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => {
const { ref: projectRef } = useParams()
const {
data: settings,
isLoading,
isSuccess,
} = useProjectSettingsV2Query({ projectRef }, { enabled: visible })
const canCreateUsers = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'create_user')
const { mutate: createUser, isLoading: isCreatingUser } = useUserCreateMutation({
@@ -57,24 +49,9 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => {
})
const onCreateUser = async (values: any) => {
if (!isSuccess) {
return toast.error(`Failed to create user: Error loading project config`)
}
const protocol = settings?.app_config?.protocol ?? 'https'
const endpoint = settings?.app_config?.endpoint
const { serviceKey } = getAPIKeys(settings)
if (!projectRef) return console.error('Project ref is required')
if (!endpoint) return toast.error(`Failed to create user: Unable to retrieve API endpoint`)
if (!serviceKey?.api_key)
return toast.error(`Failed to create user: Unable to retrieve API key`)
createUser({
projectRef,
protocol,
endpoint,
serviceApiKey: serviceKey.api_key,
user: values,
})
createUser({ projectRef, user: values })
}
const form = useForm<z.infer<typeof CreateUserFormSchema>>({
@@ -115,7 +92,7 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => {
type="email"
name="email"
placeholder="user@example.com"
disabled={isCreatingUser || isLoading}
disabled={isCreatingUser}
className="pl-8"
/>
</div>
@@ -139,15 +116,14 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => {
strokeWidth={1.5}
/>
<Input_Shadcn_
autoFocus
{...field}
autoComplete="new-password"
type="password"
name="password"
placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;"
disabled={isCreatingUser || isLoading}
disabled={isCreatingUser}
className="pl-8"
></Input_Shadcn_>
/>
</div>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
@@ -182,7 +158,7 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => {
size="small"
htmlType="submit"
loading={isCreatingUser}
disabled={!canCreateUsers || isCreatingUser || isLoading}
disabled={!canCreateUsers || isCreatingUser}
>
Create user
</Button>
@@ -16,7 +16,6 @@ import { useUserSendMagicLinkMutation } from 'data/auth/user-send-magic-link-mut
import { useUserSendOTPMutation } from 'data/auth/user-send-otp-mutation'
import { useUserUpdateMutation } from 'data/auth/user-update-mutation'
import { User } from 'data/auth/users-infinite-query'
import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { BASE_PATH } from 'lib/constants'
import { timeout } from 'lib/helpers'
@@ -58,6 +57,7 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
}
})
const canUpdateUser = useCheckPermissions(PermissionAction.AUTH_EXECUTE, '*')
const canSendMagicLink = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'send_magic_link')
const canSendRecovery = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'send_recovery')
const canSendOtp = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'send_otp')
@@ -76,7 +76,6 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
const [isDeleteFactorsModalOpen, setIsDeleteFactorsModalOpen] = useState(false)
const { data } = useAuthConfigQuery({ projectRef })
const { data: settings } = useProjectSettingsV2Query({ projectRef })
const mailerOtpExpiry = data?.MAILER_OTP_EXP ?? 0
const minutes = Math.floor(mailerOtpExpiry / 60)
@@ -143,24 +142,13 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
}
const handleUnban = () => {
if (!settings) {
return toast.error(`Failed to ban user: Error loading project config`)
} else if (user.id === undefined) {
if (projectRef === undefined) return console.error('Proejct ref is required')
if (user.id === undefined) {
return toast.error(`Failed to ban user: User ID not found`)
}
const protocol = settings?.app_config?.protocol ?? 'https'
const endpoint = settings?.app_config?.endpoint
const { serviceKey } = getAPIKeys(settings)
if (!endpoint) return toast.error(`Failed to unban user: Unable to retrieve API endpoint`)
if (!serviceKey?.api_key) return toast.error(`Failed to unban user: Unable to retrieve API key`)
updateUser({
projectRef,
protocol,
endpoint,
serviceApiKey: serviceKey.api_key,
userId: user.id,
banDuration: 'none',
})
@@ -188,7 +176,7 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
<Separator />
)}
<div className={cn('flex flex-col', PANEL_PADDING)}>
<div className={cn('flex flex-col gap-1.5', PANEL_PADDING)}>
<RowData property="User UID" value={user.id} />
<RowData
property="Created at"
@@ -221,13 +209,19 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
<div className={cn('flex flex-col -space-y-1 !pt-0', PANEL_PADDING)}>
{providers.map((provider) => {
const providerMeta = PROVIDERS_SCHEMAS.find(
(x) => x.title.toLowerCase() === provider.name
(x) =>
x.title.toLowerCase() ===
(provider.name === 'linkedin' ? 'linkedin (oidc)' : provider.name)
)
const enabledProperty = Object.keys(providerMeta?.properties ?? {}).find((x) =>
x.toLowerCase().endsWith('_enabled')
)
const providerName =
provider.name === 'email' ? 'email' : providerMeta?.title ?? provider.name
provider.name === 'email'
? 'email'
: provider.name === 'linkedin'
? 'LinkedIn'
: providerMeta?.title ?? provider.name
const isActive = data?.[enabledProperty as keyof typeof data] ?? false
return (
@@ -241,7 +235,7 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
/>
)}
<div className="flex-grow mt-0.5">
<p className="capitalize">{provider.name}</p>
<p className="capitalize">{providerName}</p>
<p className="text-xs text-foreground-light">
Signed in with a {providerName} account via{' '}
{providerName === 'SAML' ? 'SSO' : 'OAuth'}
@@ -380,7 +374,7 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
button={{
icon: <Ban />,
text: isBanned ? 'Unban user' : 'Ban user',
disabled: !canRemoveMFAFactors,
disabled: !canUpdateUser,
onClick: () => {
if (isBanned) {
setIsUnbanModalOpen(true)
@@ -465,41 +459,44 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
)
}
const RowData = ({ property, value }: { property: string; value?: string | boolean }) => {
export const RowData = ({ property, value }: { property: string; value?: string | boolean }) => {
return (
<div className="flex items-center gap-x-2 group">
<p className="w-36 text-foreground-light text-sm">{property}</p>
{typeof value === 'boolean' ? (
<div className="h-[26px] flex items-center justify-center">
{value ? (
<div className="rounded-full w-4 h-4 dark:bg-white bg-black flex items-center justify-center">
<Check size={10} className="text-contrast" strokeWidth={4} />
</div>
) : (
<div className="rounded-full w-4 h-4 dark:bg-white bg-black flex items-center justify-center">
<X size={10} className="text-contrast" strokeWidth={4} />
</div>
)}
</div>
) : (
<div className="flex items-center gap-x-2 h-[26px]">
<p className="text-sm">{!value ? '-' : value}</p>
{!!value && (
<CopyButton
iconOnly
type="text"
icon={<Copy />}
className="transition opacity-0 group-hover:opacity-100 px-1"
text={value}
/>
)}
</div>
)}
</div>
<>
<div className="flex items-center gap-x-2 group justify-between">
<p className=" text-foreground-lighter text-sm">{property}</p>
{typeof value === 'boolean' ? (
<div className="h-[26px] flex items-center justify-center min-w-[70px]">
{value ? (
<div className="rounded-full w-4 h-4 dark:bg-white bg-black flex items-center justify-center">
<Check size={10} className="text-contrast" strokeWidth={4} />
</div>
) : (
<div className="rounded-full w-4 h-4 dark:bg-white bg-black flex items-center justify-center">
<X size={10} className="text-contrast" strokeWidth={4} />
</div>
)}
</div>
) : (
<div className="flex items-center gap-x-2 h-[26px] font-mono min-w-[40px]">
<p className="text-sm">{!value ? '-' : value}</p>
{!!value && (
<CopyButton
iconOnly
type="text"
icon={<Copy />}
className="transition opacity-0 group-hover:opacity-100 px-1"
text={value}
/>
)}
</div>
)}
</div>
<Separator />
</>
)
}
const RowAction = ({
export const RowAction = ({
title,
description,
button,
@@ -3,7 +3,12 @@ import { PROVIDER_PHONE, PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidatio
export const PROVIDER_FILTER_OPTIONS = PROVIDERS_SCHEMAS.map((provider) => ({
name: provider.title,
value: provider.title.toLowerCase(),
value:
provider.title === 'Slack (OIDC)'
? 'slack_oidc'
: provider.title === 'LinkedIn (OIDC)'
? 'linkedin_oidc'
: provider.title.toLowerCase(),
icon: `${BASE_PATH}/img/icons/${provider.misc.iconKey}.svg`,
iconClass: provider.title === 'GitHub' ? 'dark:invert' : '',
})).concat(
@@ -123,9 +123,8 @@ export const UsersV2 = () => {
providers: selectedProviders,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
const totalUsers = useMemo(() => countData?.result[0].count ?? 0, [countData?.result[0].count])
const users = useMemo(() => data?.pages.flatMap((page) => page.result), [data?.pages])
const totalUsers = countData ?? 0
const users = useMemo(() => data?.pages.flatMap((page) => page.result) ?? [], [data?.pages])
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
if (isLoading || !isAtBottom(event)) return
@@ -188,7 +187,7 @@ export const UsersV2 = () => {
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSuccess, isRefetching, isSuccessStorage, isErrorStorage, errorStorage])
}, [isSuccess, isRefetching, isSuccessStorage, isErrorStorage, errorStorage, users])
return (
<div className="h-full flex flex-col">
@@ -363,7 +362,7 @@ export const UsersV2 = () => {
>
Refresh
</Button>
<AddUserDropdown projectKpsVersion={project?.kpsVersion} />
<AddUserDropdown />
</div>
</div>
<LoadingLine loading={isLoading || isRefetching || isFetchingNextPage} />
@@ -31,7 +31,7 @@ export const NoProjectsOnPaidOrgInfo = ({ organization }: NoProjectsOnPaidOrgInf
<Markdown
className="!max-w-full"
content={`The monthly fees for the paid plan still apply. To cancel your subscription, head over to
your [organization billing settings](/org/${organization?.slug}/billing).`}
your [organization billing settings](/dashboard/org/${organization?.slug}/billing).`}
/>
}
/>
@@ -7,16 +7,17 @@ import { useBackupDownloadMutation } from 'data/database/backup-download-mutatio
import type { DatabaseBackup } from 'data/database/backups-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { Badge } from 'ui'
import { useParams } from 'common'
interface BackupItemProps {
index: number
isHealthy: boolean
projectRef: string
backup: DatabaseBackup
onSelectBackup: () => void
}
const BackupItem = ({ index, isHealthy, backup, projectRef, onSelectBackup }: BackupItemProps) => {
const BackupItem = ({ index, isHealthy, backup, onSelectBackup }: BackupItemProps) => {
const { ref: projectRef } = useParams()
const canTriggerScheduledBackups = useCheckPermissions(
PermissionAction.INFRA_EXECUTE,
'queue_job.restore.prepare'
@@ -62,7 +63,10 @@ const BackupItem = ({ index, isHealthy, backup, projectRef, onSelectBackup }: Ba
icon={<Download />}
loading={isDownloading}
disabled={!canTriggerScheduledBackups || isDownloading}
onClick={() => downloadBackup({ ref: projectRef, backup })}
onClick={() => {
if (!projectRef) return console.error('Project ref is required')
downloadBackup({ ref: projectRef, backup })
}}
tooltip={{
content: {
side: 'bottom',
@@ -1,4 +1,3 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { Clock } from 'lucide-react'
@@ -12,28 +11,23 @@ import UpgradeToPro from 'components/ui/UpgradeToPro'
import { useBackupRestoreMutation } from 'data/database/backup-restore-mutation'
import { DatabaseBackup, useBackupsQuery } from 'data/database/backups-query'
import { setProjectStatus } from 'data/projects/projects-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { PROJECT_STATUS } from 'lib/constants'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import BackupItem from './BackupItem'
import BackupsEmpty from './BackupsEmpty'
import BackupsStorageAlert from './BackupsStorageAlert'
import { useParams } from 'common'
const BackupsList = () => {
const router = useRouter()
const queryClient = useQueryClient()
const { ref: projectRef } = useParams()
const { project: selectedProject } = useProjectContext()
const projectRef = selectedProject?.ref || 'default'
const isHealthy = selectedProject?.status === PROJECT_STATUS.ACTIVE_HEALTHY
const [selectedBackup, setSelectedBackup] = useState<DatabaseBackup>()
const canTriggerScheduledBackups = useCheckPermissions(
PermissionAction.INFRA_EXECUTE,
'queue_job.restore.prepare'
)
const { data: backups } = useBackupsQuery({ projectRef })
const {
mutate: restoreFromBackup,
@@ -41,15 +35,17 @@ const BackupsList = () => {
isSuccess: isSuccessBackup,
} = useBackupRestoreMutation({
onSuccess: () => {
setTimeout(() => {
setProjectStatus(queryClient, projectRef, PROJECT_STATUS.RESTORING)
toast.success(
`Restoring database back to ${dayjs(selectedBackup?.inserted_at).format(
'DD MMM YYYY HH:mm:ss'
)}`
)
router.push(`/project/${projectRef}`)
}, 3000)
if (projectRef) {
setTimeout(() => {
setProjectStatus(queryClient, projectRef, PROJECT_STATUS.RESTORING)
toast.success(
`Restoring database back to ${dayjs(selectedBackup?.inserted_at).format(
'DD MMM YYYY HH:mm:ss'
)}`
)
router.push(`/project/${projectRef}`)
}, 3000)
}
},
})
@@ -86,7 +82,6 @@ const BackupsList = () => {
<BackupItem
key={x.id}
backup={x}
projectRef={projectRef}
index={i}
isHealthy={isHealthy}
onSelectBackup={() => setSelectedBackup(x)}
@@ -106,6 +101,7 @@ const BackupsList = () => {
loading={isRestoring || isSuccessBackup}
onCancel={() => setSelectedBackup(undefined)}
onConfirm={() => {
if (projectRef === undefined) return console.error('Project ref required')
if (selectedBackup === undefined) return console.error('Backup required')
restoreFromBackup({ ref: projectRef, backup: selectedBackup })
}}
@@ -1,23 +1,26 @@
import Link from 'next/link'
import React from 'react'
import { useParams } from 'common'
import { NavMenu, NavMenuItem } from 'ui'
type Props = {
projRef: string
active: 'scheduled' | 'pitr'
}
function DatabaseBackupsNav({ projRef, active }: Props) {
function DatabaseBackupsNav({ active }: Props) {
const { ref } = useParams()
const navMenuItems = [
{
id: 'scheduled',
label: 'Scheduled backups',
href: `/project/${projRef}/database/backups/scheduled`,
href: `/project/${ref}/database/backups/scheduled`,
},
{
id: 'pitr',
label: 'Point in time',
href: `/project/${projRef}/database/backups/pitr`,
href: `/project/${ref}/database/backups/pitr`,
},
]
@@ -10,8 +10,10 @@ import { DatabaseExtension } from 'data/database-extensions/database-extensions-
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { extensions } from 'shared-data'
import { Button, cn, Switch } from 'ui'
import { Admonition } from 'ui-patterns'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import EnableExtensionModal from './EnableExtensionModal'
import { EXTENSION_DISABLE_WARNINGS } from './Extensions.constants'
interface ExtensionCardProps {
extension: DatabaseExtension
@@ -166,9 +168,16 @@ const ExtensionCard = ({ extension }: ExtensionCardProps) => {
onCancel={() => setIsDisableModalOpen(false)}
onConfirm={() => onConfirmDisable()}
>
<p className="text-sm text-foreground-light">
Are you sure you want to turn OFF the "{extension.name}" extension?
</p>
<div className="flex flex-col gap-y-3">
<p className="text-sm text-foreground-light">
Are you sure you want to turn OFF the "{extension.name}" extension?
</p>
{EXTENSION_DISABLE_WARNINGS[extension.name] && (
<Admonition type="warning" className="m-0">
{EXTENSION_DISABLE_WARNINGS[extension.name]}
</Admonition>
)}
</div>
</ConfirmationModal>
</>
)
@@ -19,3 +19,7 @@ export const HIDDEN_EXTENSIONS = [
export const SEARCH_TERMS: Record<string, string[]> = {
vector: ['pgvector', 'pg_vector'],
}
export const EXTENSION_DISABLE_WARNINGS: Record<string, string> = {
pg_cron: 'Disabling this extension will delete all scheduled jobs. This cannot be undone.',
}
@@ -11,12 +11,14 @@ import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useAppStateSnapshot } from 'state/app-state'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'ui'
import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
interface FunctionListProps {
schema: string
@@ -35,6 +37,7 @@ const FunctionList = ({
}: FunctionListProps) => {
const router = useRouter()
const { project: selectedProject } = useProjectContext()
const enableAssistantV2 = useIsDatabaseFunctionsAssistantEnabled()
const { setAiAssistantPanel } = useAppStateSnapshot()
const { data: functions } = useDatabaseFunctionsQuery({
@@ -108,7 +111,10 @@ const FunctionList = ({
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent side="left">
<DropdownMenuContent
side="left"
className={cn(enableAssistantV2 ? 'w-52' : 'w-40')}
>
{isApiDocumentAvailable && (
<DropdownMenuItem
className="space-x-2"
@@ -122,15 +128,17 @@ const FunctionList = ({
<Edit2 size={14} />
<p>Edit function</p>
</DropdownMenuItem>
<DropdownMenuItem
className="space-x-2"
onClick={() => {
setAiAssistantPanel({ open: true, editor: 'functions', entity: x })
}}
>
<Edit size={14} />
<p>Edit function with Assistant</p>
</DropdownMenuItem>
{enableAssistantV2 && (
<DropdownMenuItem
className="space-x-2"
onClick={() => {
setAiAssistantPanel({ open: true, editor: 'functions', entity: x })
}}
>
<Edit size={14} />
<p>Edit function with Assistant</p>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="space-x-2" onClick={() => deleteFunction(x)}>
<Trash size={14} className="text-destructive" />
@@ -1,4 +1,3 @@
import clsx from 'clsx'
import { ChevronDown, Plus, Trash } from 'lucide-react'
import Link from 'next/link'
@@ -10,6 +9,7 @@ import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
import { uuidv4 } from 'lib/helpers'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -157,7 +157,7 @@ const HTTPRequestFields = ({
type="default"
size="tiny"
icon={<Plus />}
className={clsx(type === 'supabase_function' && 'rounded-r-none px-3')}
className={cn(type === 'supabase_function' && 'rounded-r-none px-3')}
onClick={onAddHeader}
>
Add a new header
@@ -1,3 +1,4 @@
import { useQueryClient } from '@tanstack/react-query'
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
@@ -6,7 +7,7 @@ import { toast } from 'sonner'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useIndexesQuery } from 'data/database/indexes-query'
import { databaseKeys } from 'data/database/keys'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useTableColumnsQuery } from 'data/database/table-columns-query'
import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query'
@@ -43,6 +44,7 @@ interface CreateIndexSidePanelProps {
}
const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) => {
const queryClient = useQueryClient()
const { project } = useProjectContext()
const [selectedSchema, setSelectedSchema] = useState('public')
const [selectedEntity, setSelectedEntity] = useState<string | undefined>(undefined)
@@ -51,11 +53,7 @@ const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) =
const [schemaDropdownOpen, setSchemaDropdownOpen] = useState(false)
const [tableDropdownOpen, setTableDropdownOpen] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const { refetch: refetchIndexes } = useIndexesQuery({
schema: selectedSchema,
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { data: schemas } = useSchemasQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
@@ -80,7 +78,7 @@ const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) =
const { mutate: execute, isLoading: isExecuting } = useExecuteSqlMutation({
onSuccess: async () => {
await refetchIndexes()
await queryClient.invalidateQueries(databaseKeys.indexes(project?.ref, selectedSchema))
onClose()
toast.success(`Successfully created index`)
},
@@ -98,7 +96,7 @@ const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) =
setSearchTerm(value)
}
const columns = tableColumns?.result[0]?.columns ?? []
const columns = tableColumns?.[0]?.columns ?? []
const columnOptions: MultiSelectOption[] = columns
.filter((column): column is NonNullable<typeof column> => column !== null)
.map((column) => ({
@@ -53,12 +53,12 @@ const Indexes = () => {
})
const { mutate: execute, isLoading: isExecuting } = useExecuteSqlMutation({
onSuccess() {
refetchIndexes()
onSuccess: async () => {
await refetchIndexes()
setSelectedIndexToDelete(undefined)
toast.success('Successfully deleted index')
},
onError(error) {
onError: (error) => {
toast.error(`Failed to delete index: ${error.message}`)
},
})
@@ -69,7 +69,7 @@ const Indexes = () => {
const schema = schemas?.find((schema) => schema.name === selectedSchema)
const isLocked = protectedSchemas.some((s) => s.id === schema?.id)
const sortedIndexes = sortBy(allIndexes?.result ?? [], (index) => index.name.toLocaleLowerCase())
const sortedIndexes = sortBy(allIndexes ?? [], (index) => index.name.toLocaleLowerCase())
const indexes =
search.length > 0
? sortedIndexes.filter((index) => index.name.includes(search) || index.table.includes(search))
@@ -22,8 +22,8 @@ const Migrations = () => {
})
const migrations =
search.length === 0
? data?.result ?? []
: data?.result.filter(
? data ?? []
: data?.filter(
(migration) => migration.version.includes(search) || migration.name?.includes(search)
) ?? []
@@ -62,9 +62,9 @@ const Migrations = () => {
)}
{isSuccess && (
<div>
{data.result.length <= 0 && <MigrationsEmptyState />}
{data.length <= 0 && <MigrationsEmptyState />}
{data.result.length > 0 && (
{data.length > 0 && (
<>
<div className="w-80 mb-4">
<Input
@@ -1,10 +1,9 @@
import clsx from 'clsx'
import { Maximize2, Minimize2, Terminal } from 'lucide-react'
import { useState } from 'react'
import { Collapsible } from 'ui'
import { useParams } from 'common'
import CommandRender from 'components/interfaces/Functions/CommandRender'
import { Terminal, Minimize2, Maximize2 } from 'lucide-react'
import { cn, Collapsible } from 'ui'
const MigrationsEmptyState = () => {
const { ref } = useParams()
@@ -62,7 +61,7 @@ const MigrationsEmptyState = () => {
<Collapsible.Trigger asChild>
<button
type="button"
className={clsx(
className={cn(
'flex w-full items-center justify-between rounded py-3 px-6 text-foreground border bg-surface-100',
showInstructions && 'rounded-b-none'
)}
@@ -16,6 +16,7 @@ import DeleteRoleModal from './DeleteRoleModal'
import RoleRow from './RoleRow'
import RoleRowSkeleton from './RoleRowSkeleton'
import { SUPABASE_ROLES } from './Roles.constants'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
type SUPABASE_ROLE = (typeof SUPABASE_ROLES)[number]
@@ -151,73 +152,60 @@ const RolesList = () => {
</div>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<Button
type="primary"
disabled={!canUpdateRoles}
icon={<Plus size={12} />}
onClick={() => setIsCreatingRole(true)}
>
Add role
</Button>
</Tooltip.Trigger>
{!canUpdateRoles && (
<Tooltip.Content align="start" side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background text-xs',
].join(' ')}
>
You need additional permissions to add a new role
</div>
</Tooltip.Content>
)}
</Tooltip.Root>
<ButtonTooltip
type="primary"
disabled={!canUpdateRoles}
icon={<Plus size={12} />}
onClick={() => setIsCreatingRole(true)}
tooltip={{
content: {
side: 'bottom',
text: !canUpdateRoles
? 'You need additional permissions to add a new role'
: undefined,
},
}}
>
Add role
</ButtonTooltip>
</div>
</div>
<div className="space-y-4">
{supabaseRoles.length > 0 && (
<div>
<div className="bg-surface-100 border border-default px-6 py-3 rounded-t flex items-center space-x-4">
<p className="text-sm text-foreground-light">Roles managed by Supabase</p>
<Badge variant="brand">Protected</Badge>
</div>
{isLoading
? Array.from({ length: 5 }).map((_, i) => <RoleRowSkeleton key={i} index={i} />)
: supabaseRoles.map((role) => (
<RoleRow
disabled
key={role.id}
role={role}
onSelectDelete={setSelectedRoleToDelete}
/>
))}
<div>
<div className="bg-surface-100 border border-default px-6 py-3 rounded-t flex items-center space-x-4">
<p className="text-sm text-foreground-light">Roles managed by Supabase</p>
<Badge variant="brand">Protected</Badge>
</div>
)}
{otherRoles.length > 0 && (
<div>
<div className="bg-surface-100 border border-default px-6 py-3 rounded-t">
<p className="text-sm text-foreground-light">Other database roles</p>
</div>
{isLoading
? Array.from({ length: 5 }).map((_, i) => <RoleRowSkeleton key={i} index={i} />)
: supabaseRoles.map((role) => (
<RoleRow
disabled
key={role.id}
role={role}
onSelectDelete={setSelectedRoleToDelete}
/>
))}
</div>
{isLoading
? Array.from({ length: 3 }).map((_, i) => <RoleRowSkeleton key={i} index={i} />)
: otherRoles.map((role) => (
<RoleRow
key={role.id}
disabled={!canUpdateRoles}
role={role}
onSelectDelete={setSelectedRoleToDelete}
/>
))}
<div>
<div className="bg-surface-100 border border-default px-6 py-3 rounded-t">
<p className="text-sm text-foreground-light">Other database roles</p>
</div>
)}
{isLoading
? Array.from({ length: 3 }).map((_, i) => <RoleRowSkeleton key={i} index={i} />)
: otherRoles.map((role) => (
<RoleRow
key={role.id}
disabled={!canUpdateRoles}
role={role}
onSelectDelete={setSelectedRoleToDelete}
/>
))}
</div>
</div>
{filterString.length > 0 && filteredRoles.length === 0 && (
@@ -8,6 +8,7 @@ import { Badge, Button, Checkbox, Input, Listbox, SidePanel } from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
import FormEmptyBox from 'components/ui/FormBoxEmpty'
import NoTableState from 'components/ui/States/NoTableState'
import {
@@ -19,9 +20,9 @@ import { useDatabaseTriggerUpdateMutation } from 'data/database-triggers/databas
import { useTablesQuery } from 'data/tables/tables-query'
import { BASE_PATH } from 'lib/constants'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import { PauseCircle, PlayCircle, Terminal } from 'lucide-react'
import type { Dictionary } from 'types'
import ChooseFunctionForm from './ChooseFunctionForm'
import { PauseCircle, PlayCircle, Terminal } from 'lucide-react'
class CreateTriggerFormState {
id: number | undefined
@@ -46,6 +47,8 @@ class CreateTriggerFormState {
table: { value: string }
// @ts-ignore
tableId: { value: number; error?: string }
// @ts-ignore
condition: { value: string }
constructor() {
makeAutoObservable(this)
@@ -80,6 +83,7 @@ class CreateTriggerFormState {
this.schema = { value: trigger?.schema ?? '' }
this.table = { value: trigger?.table ?? '' }
this.tableId = { value: trigger?.table_id ?? '' }
this.condition = { value: trigger?.condition ?? '' }
}
update(state: Dictionary<any>) {
@@ -342,6 +346,7 @@ const CreateTrigger = ({ trigger, visible, setVisible }: CreateTriggerProps) =>
<div className="space-y-6 px-6">
<InputName />
<SelectEnabledMode />
{_localState.formState.condition.value && <DisplayCondition />}
</div>
) : (
<>
@@ -492,6 +497,34 @@ const SelectEnabledMode = observer(({}) => {
)
})
const DisplayCondition = observer(() => {
const _localState = useContext(CreateTriggerContext)
return (
<div className="text-sm leading-4 grid gap-2 md:grid md:grid-cols-12">
<div className="flex flex-col space-y-2 col-span-4">
<label className="block text-foreground-light text-sm leading-4 break-all">Condition</label>
</div>
<div className="col-span-8 h-[100px]">
<CodeEditor
isReadOnly
autofocus={false}
id={`trigger-condition-${_localState!.formState.id}`}
language="pgsql"
defaultValue={_localState!.formState.condition.value}
/>
<div className="mt-2 text-foreground-lighter leading-normal text-sm">
This condition must be met for the trigger to fire.
<br />
<span className="text-foreground">
To update the condition, you must drop and recreate this trigger.
</span>
</div>
</div>
</div>
)
})
const SelectOrientation = observer(({}) => {
const _localState = useContext(CreateTriggerContext)
return (
@@ -49,7 +49,7 @@ const EditWrapper = () => {
connectionString: project?.connectionString,
})
const wrappers = data?.result ?? []
const wrappers = data ?? []
const foundWrapper = wrappers.find((w) => Number(w.id) === Number(id))
// this call to useImmutableValue should be removed if the redirect after update is also removed
const wrapper = useImmutableValue(foundWrapper)
@@ -19,7 +19,7 @@ const Wrappers = ({ isEnabled }: { isEnabled: boolean }) => {
const [open, setOpen] = useState<string>('')
const [selectedWrapperToDelete, setSelectedWrapperToDelete] = useState<FDW>()
const wrappers = data?.result ?? []
const wrappers = data ?? []
return (
<>
@@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { AnimatePresence, motion } from 'framer-motion'
import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
@@ -11,6 +12,7 @@ import { toast } from 'sonner'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { MAX_WIDTH_CLASSES, PADDING_CLASSES, ScaffoldContainer } from 'components/layouts/Scaffold'
import { DocsButton } from 'components/ui/DocsButton'
import {
useDiskAttributesQuery,
useRemainingDurationForDiskAttributeUpdate,
@@ -23,9 +25,11 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que
import { useProjectAddonUpdateMutation } from 'data/subscriptions/project-addon-update-mutation'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { AddonVariantId } from 'data/subscriptions/types'
import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { PROJECT_STATUS } from 'lib/constants'
import { useFlag } from 'hooks/ui/useFlag'
import { GB, PROJECT_STATUS } from 'lib/constants'
import {
Button,
cn,
@@ -35,9 +39,11 @@ import {
Form_Shadcn_,
Separator,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormFooterChangeBadge } from '../DataWarehouse/FormFooterChangeBadge'
import { CreateDiskStorageSchema, DiskStorageSchemaType } from './DiskManagement.schema'
import { mapComputeSizeNameToAddonVariantId, showMicroUpgrade } from './DiskManagement.utils'
import { DiskManagementMessage } from './DiskManagement.types'
import { mapComputeSizeNameToAddonVariantId } from './DiskManagement.utils'
import { DiskMangementRestartRequiredSection } from './DiskManagementRestartRequiredSection'
import { DiskManagementReviewAndSubmitDialog } from './DiskManagementReviewAndSubmitDialog'
import { ComputeSizeField } from './fields/ComputeSizeField'
@@ -53,7 +59,6 @@ import {
} from './ui/DiskManagement.constants'
import { NoticeBar } from './ui/NoticeBar'
import { SpendCapDisabledSection } from './ui/SpendCapDisabledSection'
import { DiskManagementMessage } from './DiskManagement.types'
export function DiskManagementForm() {
const {
@@ -64,6 +69,14 @@ export function DiskManagementForm() {
const org = useSelectedOrganization()
const { ref: projectRef } = useParams()
const queryClient = useQueryClient()
const router = useRouter()
const diskAndComputeForm = useFlag('diskAndComputeForm')
const { data: resourceWarnings } = useResourceWarningsQuery()
const projectResourceWarnings = (resourceWarnings ?? [])?.find(
(warning) => warning.project === project?.ref
)
const isReadOnlyMode = projectResourceWarnings?.is_readonly_mode_enabled
/**
* Permissions
@@ -86,18 +99,8 @@ export function DiskManagementForm() {
/**
* Fetch form data
*/
const {
data: databases,
// isLoading: isReadReplicasLoading,
// error: readReplicasError,
isSuccess: isReadReplicasSuccess,
} = useReadReplicasQuery({ projectRef })
const {
data,
// isLoading: isDiskAttributesLoading,
// error: diskAttributesError,
isSuccess: isDiskAttributesSuccess,
} = useDiskAttributesQuery(
const { data: databases, isSuccess: isReadReplicasSuccess } = useReadReplicasQuery({ projectRef })
const { data, isSuccess: isDiskAttributesSuccess } = useDiskAttributesQuery(
{ projectRef },
{
refetchInterval,
@@ -127,7 +130,7 @@ export function DiskManagementForm() {
useRemainingDurationForDiskAttributeUpdate({
projectRef,
})
const { isSuccess: isDiskUtilizationSuccess } = useDiskUtilizationQuery({
const { data: diskUtil, isSuccess: isDiskUtilizationSuccess } = useDiskUtilizationQuery({
projectRef,
})
const { data: subscription, isSuccess: isSubscriptionSuccess } = useOrgSubscriptionQuery({
@@ -173,6 +176,9 @@ export function DiskManagementForm() {
const isPlanUpgradeRequired = subscription?.plan.id === 'free'
const { formState } = form
const usedSize = Math.round(((diskUtil?.metrics.fs_used_bytes ?? 0) / GB) * 100) / 100
const totalSize = formState.defaultValues?.totalSize || 0
const usedPercentage = (usedSize / totalSize) * 100
const isFlyArchitecture = project?.cloud_provider === 'FLY'
@@ -184,14 +190,6 @@ export function DiskManagementForm() {
isFlyArchitecture
const disableComputeInputs = isPlanUpgradeRequired
useEffect(() => {
// Initialize field values properly when data has been loaded, preserving any user changes
if (isSuccess) {
form.reset(defaultValues, {})
}
}, [isSuccess])
const isDirty = !!Object.keys(form.formState.dirtyFields).length
const isProjectResizing = project?.status === PROJECT_STATUS.RESIZING
const isProjectRequestingDiskChanges = isRequestingChanges && !isProjectResizing
@@ -260,6 +258,20 @@ export function DiskManagementForm() {
}
}
useEffect(() => {
// Initialize field values properly when data has been loaded, preserving any user changes
if (isSuccess) {
form.reset(defaultValues, {})
}
}, [isSuccess])
// Redirect logic incase disk and compute feature is not live yet
useEffect(() => {
if (diskAndComputeForm !== undefined && !diskAndComputeForm && projectRef) {
router.push(`/project/${projectRef}/settings/addons?panel=computeInstance`)
}
}, [diskAndComputeForm, projectRef, router])
return (
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-8">
@@ -300,7 +312,6 @@ export function DiskManagementForm() {
<Separator />
<ComputeSizeField form={form} disabled={disableComputeInputs} />
<Separator />
<DiskCountdownRadial />
<SpendCapDisabledSection />
<NoticeBar
type="default"
@@ -310,6 +321,35 @@ export function DiskManagementForm() {
/>
{!isFlyArchitecture && (
<>
<div className="flex flex-col gap-y-3">
<DiskCountdownRadial />
{!isReadOnlyMode && usedPercentage >= 90 && isWithinCooldownWindow && (
<Admonition
type="destructive"
title="Database size is currently over 90% of disk size"
description="Your project will enter read-only mode once you reach 95% of the disk space to prevent your database from exceeding the disk limitations"
>
<DocsButton
abbrev={false}
className="mt-2"
href="https://supabase.com/docs/guides/platform/database-size#read-only-mode"
/>
</Admonition>
)}
{isReadOnlyMode && (
<Admonition
type="destructive"
title="Project is currently in read-only mode"
description="You will need to manually override read-only mode and reduce the database size to below 95% of the disk size"
>
<DocsButton
abbrev={false}
className="mt-2"
href="https://supabase.com/docs/guides/platform/database-size#disabling-read-only-mode"
/>
</Admonition>
)}
</div>
<DiskSizeField
form={form}
disableInput={disableDiskInputs}
@@ -336,7 +376,12 @@ export function DiskManagementForm() {
strokeWidth={1}
/>
</CollapsibleTrigger_Shadcn_>
<CollapsibleContent_Shadcn_ className="data-[state=open]:border flex flex-col gap-8 px-8 py-8 transition-all data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
<CollapsibleContent_Shadcn_
className={cn(
'flex flex-col gap-8 px-8 py-8 transition-all',
'data-[state=open]:border data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down'
)}
>
<StorageTypeField form={form} disableInput={disableDiskInputs} />
<NoticeBar
type="default"
@@ -15,7 +15,7 @@ import { BillingChangeBadge } from '../ui/BillingChangeBadge'
import { DiskType, PLAN_DETAILS } from '../ui/DiskManagement.constants'
import { DiskManagementDiskSizeReadReplicas } from '../ui/DiskManagementReadReplicas'
import DiskSpaceBar from '../ui/DiskSpaceBar'
import { DiskTypeReccomendationSection } from '../ui/DiskTypeRecommendationSection'
import { DiskTypeRecommendationSection } from '../ui/DiskTypeRecommendationSection'
import FormMessage from '../ui/FormMessage'
import { InputPostTab } from '../ui/InputPostTab'
import { InputResetButton } from '../ui/InputResetButton'
@@ -149,7 +149,7 @@ export function DiskSizeField({
subscription?.plan.id &&
`Your plan includes ${includedDiskGB} GB of disk size for ${watchedStorageType}.`}
</span>
<DiskTypeReccomendationSection
<DiskTypeRecommendationSection
form={form}
actions={
<Button
@@ -1,10 +1,12 @@
import { AnimatePresence, motion } from 'framer-motion'
import { Info } from 'lucide-react'
import MotionNumber from 'motion-number'
import { useTheme } from 'next-themes'
import { UseFormReturn } from 'react-hook-form'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useRemainingDurationForDiskAttributeUpdate } from 'data/config/disk-attributes-query'
import { useDiskUtilizationQuery } from 'data/config/disk-utilization-query'
import { useDatabaseSizeQuery } from 'data/database/database-size-query'
import { GB } from 'lib/constants'
@@ -25,7 +27,9 @@ interface DiskSpaceBarProps {
export default function DiskSpaceBar({ form }: DiskSpaceBarProps) {
const { ref } = useParams()
const { resolvedTheme } = useTheme()
const { formState, watch } = form
const isDarkMode = resolvedTheme?.includes('dark')
const {
data: diskUtil,
@@ -50,8 +54,9 @@ export default function DiskSpaceBar({ form }: DiskSpaceBarProps) {
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { remainingDuration } = useRemainingDurationForDiskAttributeUpdate({ projectRef: ref })
const databaseSizeBytes = data?.result[0].db_size ?? 0
const databaseSizeBytes = data ?? 0
return (
<div className="flex flex-col gap-2">
<div className="flex items-center h-6 gap-3">
@@ -91,16 +96,21 @@ export default function DiskSpaceBar({ form }: DiskSpaceBarProps) {
>
<div className="h-full flex">
<div
className="bg-foreground relative overflow-hidden transition-all duration-500 ease-in-out"
style={{ width: `${usedPercentage}%` }}
className={cn(
usedPercentage >= 90 && remainingDuration > 0
? 'bg-destructive'
: 'bg-foreground',
'relative overflow-hidden transition-all duration-500 ease-in-out'
)}
style={{ width: `${usedPercentage >= 100 ? 100 : usedPercentage}%` }}
>
<div
className="absolute inset-0"
style={{
backgroundImage: `repeating-linear-gradient(
-45deg,
rgba(255,255,255,0.1),
rgba(255,255,255,0.1) 1px,
${isDarkMode ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.1)'},
${isDarkMode ? 'rgba(0,0,0,0.1) 1px' : 'rgba(255,255,255,0.1) 1px'},
transparent 1px,
transparent 4px
)`,
@@ -109,7 +119,9 @@ export default function DiskSpaceBar({ form }: DiskSpaceBarProps) {
</div>
<div
className="bg-transparent border-r transition-all duration-500 ease-in-out"
style={{ width: `${resizePercentage - usedPercentage}%` }}
style={{
width: `${resizePercentage - usedPercentage <= 0 ? 0 : resizePercentage - usedPercentage}%`,
}}
/>
</div>
</motion.div>
@@ -126,15 +138,15 @@ export default function DiskSpaceBar({ form }: DiskSpaceBarProps) {
<div className="h-full flex">
<div
className="bg-foreground relative overflow-hidden transition-all duration-500 ease-in-out"
style={{ width: `${newUsedPercentage}%` }}
style={{ width: `${newUsedPercentage >= 100 ? 100 : newUsedPercentage}%` }}
>
<div
className="absolute inset-0"
style={{
backgroundImage: `repeating-linear-gradient(
-45deg,
rgba(255,255,255,0.1),
rgba(255,255,255,0.1) 1px,
${isDarkMode ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.1)'},
${isDarkMode ? 'rgba(0,0,0,0.1) 1px' : 'rgba(255,255,255,0.1) 1px'},
transparent 1px,
transparent 4px
)`,
@@ -151,10 +163,7 @@ export default function DiskSpaceBar({ form }: DiskSpaceBarProps) {
initial={{ opacity: 0, x: 4 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 4 }}
transition={{
duration: 0.12,
delay: 0.12,
}}
transition={{ duration: 0.12, delay: 0.12 }}
className={cn(badgeVariants({ variant: 'success' }), 'absolute right-1 top-[5px]')}
>
New disk size
@@ -6,7 +6,7 @@ import { Admonition } from 'ui-patterns/admonition'
import { DiskStorageSchemaType } from '../DiskManagement.schema'
import { DISK_LIMITS, DISK_TYPE_OPTIONS, DiskType } from './DiskManagement.constants'
export function DiskTypeReccomendationSection({
export function DiskTypeRecommendationSection({
actions,
form,
}: {
@@ -1,5 +1,4 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import clsx from 'clsx'
import dayjs from 'dayjs'
import { ExternalLink, Maximize2, Minimize2, Terminal } from 'lucide-react'
import Link from 'next/link'
@@ -29,6 +28,7 @@ import {
Input,
Modal,
Toggle,
cn,
} from 'ui'
import CommandRender from '../CommandRender'
import { generateCLICommands } from './EdgeFunctionDetails.utils'
@@ -181,7 +181,7 @@ const EdgeFunctionDetails = () => {
<div className="flex items-center space-x-2">
<p className="text-sm">
Import maps are{' '}
<span className={clsx(hasImportMap ? 'text-brand' : 'text-amber-900')}>
<span className={cn(hasImportMap ? 'text-brand' : 'text-amber-900')}>
{hasImportMap ? 'used' : 'not used'}
</span>{' '}
for this function
@@ -32,14 +32,13 @@ import {
} from '@graphiql/react'
import { Fetcher } from '@graphiql/toolkit'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import clsx from 'clsx'
import { AlertTriangle, XIcon } from 'lucide-react'
import { MouseEventHandler, useCallback, useEffect, useState } from 'react'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useLocalStorage } from 'hooks/misc/useLocalStorage'
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
import { AlertTriangle, XIcon } from 'lucide-react'
import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui'
import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button, cn } from 'ui'
import { RoleImpersonationSelector } from '../RoleImpersonationSelector'
import styles from './graphiql.module.css'
@@ -199,15 +198,15 @@ const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
return (
<Tooltip.Provider>
<div className={clsx('graphiql-container', styles.graphiqlContainer)}>
<div className={cn('graphiql-container', styles.graphiqlContainer)}>
<div className="graphiql-main">
<div
ref={pluginResize.firstRef}
style={{ minWidth: '750px' }}
className={clsx('graphiql-sessions', styles.graphiqlSessions)}
className={cn('graphiql-sessions', styles.graphiqlSessions)}
>
<div
className={clsx(
className={cn(
'graphiql-session-header',
!hasSingleTab && styles.graphiqlSessionHeader
)}
@@ -256,12 +255,12 @@ const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
<div
role="tabpanel"
id="graphiql-session"
className={clsx('graphiql-session', styles.graphiqlSession)}
className={cn('graphiql-session', styles.graphiqlSession)}
aria-labelledby={`graphiql-session-tab-${editorContext.activeTabIndex}`}
>
<div ref={editorResize.firstRef}>
<div
className={clsx(
className={cn(
'graphiql-editors',
styles.graphiqlEditors,
hasSingleTab && 'full-height'
@@ -269,7 +268,7 @@ const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
>
<div ref={editorToolsResize.firstRef}>
<section
className={clsx('graphiql-query-editor text-sm', styles.graphiqlQueryEditor)}
className={cn('graphiql-query-editor text-sm', styles.graphiqlQueryEditor)}
aria-label="Query Editor"
>
<QueryEditor onClickReference={onClickReference} />
@@ -367,7 +366,7 @@ const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
{canReadJWTSecret && (
<div
className={clsx(
className={cn(
'graphiql-editor px-1',
activeSecondaryEditor !== 'role-impersonation' && 'hidden'
)}
@@ -381,13 +380,13 @@ const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
</div>
<div
className={clsx('graphiql-horizontal-drag-bar', styles.graphiqlHorizontalDragBar)}
className={cn('graphiql-horizontal-drag-bar', styles.graphiqlHorizontalDragBar)}
ref={editorResize.dragBarRef}
/>
<div ref={editorResize.secondRef}>
<div
className={clsx(
className={cn(
'graphiql-response text-sm relative',
hasSingleTab
? styles.graphiqlResponseSingleTab
@@ -440,7 +439,7 @@ const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
<div className="graphiql-plugin">{PluginContent ? <PluginContent /> : null}</div>
</div>
</div>
<div className={clsx('graphiql-sidebar', styles.graphiqlSidebar)}>
<div className={cn('graphiql-sidebar', styles.graphiqlSidebar)}>
<div className="graphiql-sidebar-section">
{pluginContext?.plugins.map((plugin, index) => {
const isVisible = plugin === pluginContext.visiblePlugin
@@ -50,7 +50,7 @@ const NewProjectPanel = () => {
<EditorIndexPageLink projectRef={ref}>Table Editor</EditorIndexPageLink>
</Button>
<Button asChild type="default" icon={<SqlEditor strokeWidth={1.5} />}>
<Link href={`/project/${ref}/sql/new`}>SQL editor</Link>
<Link href={`/project/${ref}/sql/new`}>SQL Editor</Link>
</Button>
<Button asChild type="default" icon={<ExternalLink />}>
<Link
@@ -3,6 +3,7 @@ import { GitBranch, Github } from 'lucide-react'
import CardButton from 'components/ui/CardButton'
import { ComputeBadgeWrapper } from 'components/ui/ComputeBadgeWrapper'
import type { IntegrationProjectConnection } from 'data/integrations/integrations.types'
import { ProjectIndexPageLink } from 'data/prefetchers/project.$ref'
import type { ProjectInfo } from 'data/projects/projects-query'
import type { ResourceWarning } from 'data/usage/resource-warnings-query'
import { BASE_PATH } from 'lib/constants'
@@ -73,6 +74,7 @@ const ProjectCard = ({
footer={
<ProjectCardStatus projectStatus={projectStatus} resourceWarnings={resourceWarnings} />
}
containerElement={<ProjectIndexPageLink projectRef={projectRef} />}
/>
</li>
)
@@ -18,6 +18,7 @@ import {
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
FormLabel_Shadcn_,
Input_Shadcn_,
RadioGroupStacked,
RadioGroupStackedItem,
@@ -33,7 +34,13 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { CRONJOB_DEFINITIONS } from './CronJobs.constants'
import { buildCronQuery, buildHttpRequestCommand, parseCronJobCommand } from './CronJobs.utils'
import {
buildCronQuery,
buildHttpRequestCommand,
cronPattern,
secondsPattern,
parseCronJobCommand,
} from './CronJobs.utils'
import { CronJobScheduleSection } from './CronJobScheduleSection'
import { EdgeFunctionSection } from './EdgeFunctionSection'
import { HTTPHeaderFieldsSection } from './HttpHeaderFieldsSection'
@@ -90,13 +97,18 @@ const FormSchema = z.object({
.trim()
.min(1)
.refine((value) => {
try {
CronToString(value)
} catch {
return false
if (cronPattern.test(value)) {
try {
CronToString(value)
return true
} catch {
return false
}
} else if (secondsPattern.test(value)) {
return true
}
return true
}, 'The schedule needs to be in a Cron format.'),
return false
}, 'The schedule needs to be in a valid Cron format or specify seconds like "x seconds".'),
values: z.discriminatedUnion('type', [
edgeFunctionSchema,
httpRequestSchema,
@@ -131,7 +143,7 @@ export const CreateCronJobSheet = ({
resolver: zodResolver(FormSchema),
defaultValues: {
name: selectedCronJob?.jobname || '',
schedule: selectedCronJob?.schedule || '',
schedule: selectedCronJob?.schedule || '*/5 * * * *',
values: cronJobValues,
},
})
@@ -228,10 +240,14 @@ export const CreateCronJobSheet = ({
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout label="Name" layout="vertical" className="gap-1">
<FormItemLayout label="Name" layout="vertical" className="gap-1 relative">
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} />
<Input_Shadcn_ {...field} disabled={isEditing} />
</FormControl_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground-lighter text-xs absolute top-0 right-0 ">
Cron jobs cannot be renamed once created
</FormLabel_Shadcn_>
</FormItemLayout>
)}
/>
@@ -1,108 +1,241 @@
import { toString as CronToString } from 'cronstrue'
import { motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { UseFormReturn } from 'react-hook-form'
import { useDebounce } from 'use-debounce'
import { useCompletion } from 'ai/react'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useCronTimezoneQuery } from 'data/database-cron-jobs/database-cron-timezone-query'
import { BASE_PATH } from 'lib/constants'
import {
Accordion_Shadcn_,
AccordionContent_Shadcn_,
AccordionItem_Shadcn_,
AccordionTrigger_Shadcn_,
Button,
cn,
FormControl_Shadcn_,
FormField_Shadcn_,
FormItem_Shadcn_,
FormLabel_Shadcn_,
FormMessage_Shadcn_,
Input_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
SheetSection,
Switch,
} from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import { CreateCronJobForm } from './CreateCronJobSheet'
import CronSyntaxChart from './CronSyntaxChart'
import { secondsPattern } from './CronJobs.utils'
interface CronJobScheduleSectionProps {
form: UseFormReturn<CreateCronJobForm>
}
const schedules = [
const PRESETS = [
{ name: 'Every minute', expression: '* * * * *' },
{ name: 'Every 5 minutes', expression: '*/5 * * * *' },
{ name: 'Every hour, at 30 minutes', expression: '30 * * * *' },
{ name: 'Every first of the month, at 00:00', expression: '0 0 1 * *' },
{ name: 'Custom', expression: 'custom' },
{ name: 'Every night at midnight', expression: '0 0 * * *' },
{ name: 'Every Monday at 2 AM', expression: '0 2 * * 1' },
{ name: 'Every 30 seconds', expression: '30 seconds' },
] as const
export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) => {
let initialValue = schedules[0].expression as string
const scheduleValue = form.getValues('schedule')
if (scheduleValue && scheduleValue.length > 0) {
initialValue = 'custom'
}
const { project } = useProjectContext()
const initialValue = form.getValues('schedule')
const { schedule } = form.watch()
const [presetValue, setPresetValue] = useState<string>(initialValue)
const [inputValue, setInputValue] = useState(initialValue)
const [debouncedValue] = useDebounce(inputValue, 750)
const [useNaturalLanguage, setUseNaturalLanguage] = useState(false)
const [scheduleString, setScheduleString] = useState('')
const onChangeSelectValue = (v: string) => {
setPresetValue(v)
}
const { complete: generateCronSyntax, isLoading: isGeneratingCron } = useCompletion({
api: `${BASE_PATH}/api/ai/sql/cron`,
onResponse: async (response) => {
if (response.ok) {
// remove quotes from the cron expression
const expression = (await response.text()).trim().replace(/^"|"$/g, '')
form.setValue('schedule', expression)
setPresetValue(expression)
setScheduleString(CronToString(expression))
}
},
onError: (error) => {
console.error('Error generating cron:', error)
},
})
const { data: timezone } = useCronTimezoneQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
useEffect(() => {
if (presetValue !== 'custom') {
form.setValue('schedule', presetValue)
if (!useNaturalLanguage || !debouncedValue) return
generateCronSyntax(debouncedValue)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedValue, useNaturalLanguage])
useEffect(() => {
if (!inputValue || inputValue.length < 5) return // set a min length before showing invalid message
// update the cronstrue string when the input value changes
try {
setScheduleString(CronToString(inputValue))
form.setValue('schedule', inputValue)
} catch (error) {
console.error('Error converting cron expression to string:', error)
}
}, [presetValue, form])
}, [form, inputValue])
useEffect(() => {
if (useNaturalLanguage) return
setPresetValue(schedule)
if (!schedule) {
setScheduleString('')
return
}
try {
setScheduleString(CronToString(schedule))
} catch (error) {
console.error('Error converting cron expression to string:', error)
}
}, [schedule])
return (
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="schedule"
render={({ field, fieldState }) => {
let scheduleString = ''
try {
scheduleString = CronToString(field.value)
} catch {}
render={({ field }) => {
return (
<FormItem_Shadcn_ className="flex flex-col gap-1">
<FormLabel_Shadcn_>Schedule</FormLabel_Shadcn_>
<FormLabel_Shadcn_ className="text-foreground-lighter">
{useNaturalLanguage ? 'Describe your schedule in words' : 'Enter a cron expression'}
</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<div className="flex flex-col gap-2">
<Select_Shadcn_ onValueChange={onChangeSelectValue} value={presetValue}>
<FormControl_Shadcn_>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Select a method for the HTTP request" />
</SelectTrigger_Shadcn_>
</FormControl_Shadcn_>
<SelectContent_Shadcn_>
{schedules.map((schedule) => {
return (
<SelectItem_Shadcn_ key={schedule.name} value={schedule.expression}>
{schedule.name}
</SelectItem_Shadcn_>
)
})}
</SelectContent_Shadcn_>
</Select_Shadcn_>
<Input_Shadcn_
{...field}
disabled={field.disabled || presetValue !== 'custom'}
size="large"
autoComplete="off"
/>
<div className="flex flex-col gap-y-2">
{useNaturalLanguage ? (
<Input
value={inputValue}
placeholder="E.g. every 5 minutes"
className={cn(!useNaturalLanguage && 'hidden')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
}
}}
onChange={(e) => setInputValue(e.target.value)}
/>
) : (
<Input_Shadcn_
{...field}
autoComplete="off"
placeholder="* * * * *"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
}
}}
/>
)}
<div className="flex items-center gap-2 mt-2">
<Switch
checked={useNaturalLanguage}
onCheckedChange={() => {
setUseNaturalLanguage(!useNaturalLanguage)
setInputValue('')
setPresetValue('')
setScheduleString('')
form.setValue('schedule', '')
}}
/>
<p className="text-sm text-foreground-light">Use natural language</p>
</div>
<div className="mt-2">
<ul className="flex gap-2 flex-wrap mt-2">
{PRESETS.map((preset) => (
<li key={preset.name}>
<Button
type="outline"
onClick={() => {
setUseNaturalLanguage(false)
form.setValue('schedule', preset.expression)
setPresetValue(preset.expression)
}}
>
{preset.name}
</Button>
</li>
))}
</ul>
</div>
<Accordion_Shadcn_ type="single" collapsible className="mt-2 pb-2">
<AccordionItem_Shadcn_ value="item-1" className="border-none">
<AccordionTrigger_Shadcn_ className="text-xs text-foreground-light font-normal gap-2 justify-start py-1 ">
View syntax chart
</AccordionTrigger_Shadcn_>
<AccordionContent_Shadcn_ asChild className="!pb-0">
<CronSyntaxChart />
</AccordionContent_Shadcn_>
</AccordionItem_Shadcn_>
</Accordion_Shadcn_>
</div>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ className="mt-1">
<div className="bg-surface-100 p-4 rounded grid gap-y-4 border">
<h4 className="text-sm text-foreground">
Schedule {timezone ? `(${timezone})` : ''}
</h4>
{scheduleString ? (
<span className="text-foreground-light">
The cron will be run {/* lowercase the first letter */}
{scheduleString
.split(' ')
.map((s, i) => (i === 0 ? s.toLocaleLowerCase() : s))
.join(' ')}
.
<span
className={cn(
'text-xl font-mono',
isGeneratingCron ? 'animate-pulse text-foreground-lighter' : 'text-foreground'
)}
>
{isGeneratingCron ? <CronSyntaxLoader /> : presetValue || '* * * * *'}
</span>
) : (
!fieldState.error && <span className="h-5" />
<span className="text-xl font-mono text-foreground-lighter">
{isGeneratingCron ? <CronSyntaxLoader /> : presetValue || '* * * * *'}
</span>
)}
</FormMessage_Shadcn_>
{!inputValue && !isGeneratingCron && !scheduleString ? (
<span className="text-sm text-foreground-light">
Describe your schedule above
</span>
) : (
<span className="text-sm text-foreground-light flex items-center gap-2">
{isGeneratingCron ? (
<LoadingDots />
) : scheduleString === '' ? ( // set a min length before showing invalid message
'Enter a valid cron expression above'
) : scheduleString.includes('Invalid cron expression') ? (
'Invalid cron expression'
) : (
<>
The cron will be run{' '}
{secondsPattern.test(schedule)
? 'every ' + schedule
: scheduleString
.split(' ')
.map((s, i) => (i === 0 ? s.toLocaleLowerCase() : s))
.join(' ') + '.'}
</>
)}
</span>
)}
</div>
</FormItem_Shadcn_>
)
}}
@@ -110,3 +243,47 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
</SheetSection>
)
}
const CronSyntaxLoader = () => {
return (
<div className="flex gap-2">
{['*', '*', '*', '*', '*'].map((char, i) => (
<motion.span
key={i}
initial={{ opacity: 0.3 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.6,
repeat: Infinity,
repeatType: 'reverse',
delay: i * 0.15,
}}
>
{char}
</motion.span>
))}
</div>
)
}
const LoadingDots = () => {
return (
<span className="inline-flex items-center">
{[0, 1, 2].map((i) => (
<motion.span
key={i}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.5,
repeat: Infinity,
repeatType: 'reverse',
delay: i * 0.2,
}}
>
.
</motion.span>
))}
</span>
)
}
@@ -1,5 +1,5 @@
import { it } from 'vitest'
import { parseCronJobCommand } from './CronJobs.utils'
import { parseCronJobCommand, secondsPattern, cronPattern } from './CronJobs.utils'
describe('parseCronJobCommand', () => {
it('should return a default object when the command is null', () => {
@@ -34,4 +34,38 @@ describe('parseCronJobCommand', () => {
type: 'edge_function',
})
})
// Array of test cases for secondsPattern
const secondsPatternTests = [
{ description: '10 seconds', command: '10 seconds' },
{ description: '30 seconds', command: '30 seconds' },
]
// Run tests for secondsPattern
secondsPatternTests.forEach(({ description, command }) => {
it(`should match the regex for a secondsPattern with "${description}"`, () => {
expect(command).toMatch(secondsPattern)
})
})
// Array of test cases for cronPattern
const cronPatternTests = [
{ description: 'every hour', command: '0 * * * *' },
{ description: 'every day at midnight', command: '0 0 * * *' },
{ description: 'every Monday at 9 AM', command: '0 9 * * 1' },
{ description: 'every 15th of the month at noon', command: '0 12 15 * *' },
{ description: 'every January 1st at 3 AM', command: '0 3 1 1 *' },
{ description: 'every 30 minutes', command: '30 * * * *' },
{ description: 'every weekday at 6 PM', command: '0 18 * * 1-5' },
{ description: 'every weekend at 10 AM', command: '0 10 * * 0,6' },
{ description: 'every quarter hour', command: '*/15 * * * *' },
{ description: 'twice daily at 8 AM and 8 PM', command: '0 8,20 * * *' },
]
// Replace the single cronPattern test with forEach
cronPatternTests.forEach(({ description, command }) => {
it(`should match the regex for a cronPattern with "${description}"`, () => {
expect(command).toMatch(cronPattern)
})
})
})
@@ -110,3 +110,8 @@ export const parseCronJobCommand = (originalCommand: string): CronJobType => {
return DEFAULT_CRONJOB_COMMAND
}
// detect seconds like "10 seconds" or normal cron syntax like "*/5 * * * *"
export const secondsPattern = /^\d+\s+seconds$/
export const cronPattern =
/^(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)(\s+(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)){4}$/
@@ -1,24 +1,17 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import AlertError from 'components/ui/AlertError'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { CronJob, useCronJobsQuery } from 'data/database-cron-jobs/database-cron-jobs-query'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { Button, Sheet, SheetContent } from 'ui'
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
import { DocsButton } from 'components/ui/DocsButton'
import { CreateCronJobSheet } from './CreateCronJobSheet'
import { CronJobCard } from './CronJobCard'
import { CronJobsDisabledState } from './CronJobsDisabledState'
import DeleteCronJob from './DeleteCronJob'
import { Sheet, SheetContent } from 'ui'
import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/CreateCronJobSheet'
export const CronJobsListing = () => {
const { project } = useProjectContext()
const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
// used for confirmation prompt in the Create Cron Job Sheet
const [isClosingCreateCronJobSheet, setIsClosingCreateCronJobSheet] = useState(false)
const [createCronJobSheetShown, setCreateCronJobSheetShown] = useState<
@@ -26,64 +19,16 @@ export const CronJobsListing = () => {
>()
const [cronJobForDeletion, setCronJobForDeletion] = useState<CronJob | undefined>()
const {
data: cronJobs,
error,
isLoading,
isError,
refetch,
} = useCronJobsQuery({
const { data: cronJobs, isLoading } = useCronJobsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { data: extensions, isLoading: isLoadingExtensions } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const pgCronExtension = (extensions ?? []).find((ext) => ext.name === 'pg_cron')
const pgCronExtensionInstalled = pgCronExtension?.installed_version
useEffect(() => {
// refetch the cron jobs afte the pgcron extension has been installed
if (pgCronExtensionInstalled && isError) {
refetch()
}
}, [isError, pgCronExtensionInstalled, refetch])
// this avoid showing loading screen when the extension is not installed. Otherwise, we'll have to wait for three
// retries (which are sure to fail because the extension is not installed)
if (isLoadingExtensions) return <GenericSkeletonLoader />
if (!pgCronExtensionInstalled) return <CronJobsDisabledState />
if (isLoading) return <GenericSkeletonLoader />
if (isError) return <AlertError error={error} subject="Failed to retrieve database cron jobs" />
return (
<>
<div className="w-full space-y-4">
<FormHeader
title="Cron jobs"
description="Schedule and automate tasks like running queries or maintenance routines at specified intervals"
actions={
<>
<DocsButton href="https://supabase.com/docs/guides/database/extensions/pg_cron" />
<Button
type="primary"
onClick={() =>
setCreateCronJobSheetShown({
jobname: '',
schedule: '',
command: '',
active: true,
})
}
>
Create a new cron job
</Button>
</>
}
/>
{(cronJobs ?? []).length == 0 ? (
<div className="grid text-center h-full w-full items-center justify-center border border-border rounded-md p-4 border-dashed">
<p className="text-sm text-foreground">No cron jobs created yet</p>
@@ -93,7 +38,7 @@ export const CronJobsListing = () => {
</div>
) : (
<div className="w-full space-y-4">
{cronJobs.map((job) => (
{(cronJobs ?? []).map((job) => (
<CronJobCard
job={job}
onEditCronJob={(job) => setCreateCronJobSheetShown(job)}
@@ -104,6 +49,12 @@ export const CronJobsListing = () => {
)}
</div>
<DeleteCronJob
visible={!!cronJobForDeletion}
onClose={() => setCronJobForDeletion(undefined)}
cronJob={cronJobForDeletion!}
/>
<Sheet
open={!!createCronJobSheetShown}
onOpenChange={() => setIsClosingCreateCronJobSheet(true)}
@@ -120,16 +71,6 @@ export const CronJobsListing = () => {
/>
</SheetContent>
</Sheet>
<DeleteCronJob
visible={!!cronJobForDeletion}
onClose={() => setCronJobForDeletion(undefined)}
cronJob={cronJobForDeletion!}
/>
<EnableExtensionModal
visible={showEnableExtensionModal}
extension={pgCronExtension}
onCancel={() => setShowEnableExtensionModal(false)}
/>
</>
)
}
@@ -0,0 +1,17 @@
export default function CronSyntaxChart() {
return (
<div>
<pre className="text-xs font-mono text-foreground-light">
{/* prettier-ignore */}
{`
┌───────────── minute (0 - 59)
│ ┌───────────── hour (0 - 23)
│ │ ┌───────────── day of month (1 - 31)
│ │ │ ┌───────────── month (1 - 12)
│ │ │ │ ┌───────────── day of week (0 - 7)
│ │ │ │ │
* * * * * `}
</pre>
</div>
)
}
@@ -0,0 +1,341 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useDatabaseQueueCreateMutation } from 'data/database-queues/database-queues-create-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import {
Badge,
Button,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input,
Input_Shadcn_,
RadioGroupStacked,
RadioGroupStackedItem,
Separator,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { QUEUE_TYPES } from './Queues.constants'
export interface CreateQueueSheetProps {
isClosing: boolean
setIsClosing: (v: boolean) => void
onClose: () => void
}
const normalQueueSchema = z.object({
type: z.literal('normal'),
})
const partitionedQueueSchema = z.object({
type: z.literal('partitioned'),
partitionInterval: z.number(),
retentionInterval: z.number(),
})
const unloggedQueueSchema = z.object({
type: z.literal('unlogged'),
})
const FormSchema = z.object({
name: z
.string()
.trim()
.min(1, 'Please provide a name for your queue')
.max(47, "The name can't be longer than 47 characters"),
values: z.discriminatedUnion('type', [
normalQueueSchema,
partitionedQueueSchema,
unloggedQueueSchema,
]),
})
export type CreateQueueForm = z.infer<typeof FormSchema>
export type QueueType = CreateQueueForm['values']
const FORM_ID = 'create-queue-sidepanel'
export const CreateQueueSheet = ({ isClosing, setIsClosing, onClose }: CreateQueueSheetProps) => {
const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
const { mutate: createQueue, isLoading } = useDatabaseQueueCreateMutation()
const canToggleExtensions = useCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'extensions'
)
const form = useForm<CreateQueueForm>({
resolver: zodResolver(FormSchema),
defaultValues: {
name: '',
values: {
type: 'normal',
},
},
})
const { project } = useProjectContext()
const isEdited = form.formState.isDirty
// if the form hasn't been touched and the user clicked esc or the backdrop, close the sheet
if (!isEdited && isClosing) {
onClose()
}
const onClosePanel = () => {
if (isEdited) {
setIsClosing(true)
} else {
onClose()
}
}
const onSubmit: SubmitHandler<CreateQueueForm> = async ({ name, values }) => {
let query = `SELECT pgmq.create('${name}');`
if (values.type === 'partitioned') {
query = `select from pgmq.create_partitioned('${name}', '${values.partitionInterval}', '${values.retentionInterval}');`
}
if (values.type === 'unlogged') {
query = `SELECT pgmq.create_unlogged('${name}');`
}
createQueue(
{
projectRef: project!.ref,
connectionString: project?.connectionString,
query,
},
{
onSuccess: () => {
toast.success(`Successfully created queue ${name}`)
onClose()
},
}
)
}
const { data } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const pgPartmanExtension = (data ?? []).find((ext) => ext.name === 'pg_partman')
const pgPartmanExtensionInstalled = pgPartmanExtension?.installed_version != undefined
const queueType = form.watch('values.type')
return (
<>
<div className="flex flex-col h-full" tabIndex={-1}>
<SheetHeader>
<SheetTitle>Create a new queue</SheetTitle>
</SheetHeader>
<div className="overflow-auto flex-grow">
<Form_Shadcn_ {...form}>
<form
id={FORM_ID}
className="flex-grow overflow-auto"
onSubmit={form.handleSubmit(onSubmit)}
>
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout label="Name" layout="vertical" className="gap-1">
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator />
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="values.type"
render={({ field }) => (
<FormItemLayout label="Type" layout="vertical" className="gap-1">
<FormControl_Shadcn_>
<RadioGroupStacked
id="queue_type"
name="queue_type"
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
>
{QUEUE_TYPES.map((definition) => (
<RadioGroupStackedItem
key={definition.value}
id={definition.value}
value={definition.value}
label=""
disabled={
!pgPartmanExtensionInstalled && definition.value === 'partitioned'
}
showIndicator={false}
>
<div className="flex items-center gap-x-5">
<div className="text-foreground">{definition.icon}</div>
<div className="flex flex-col">
<div className="flex gap-x-2">
<p className="text-foreground">{definition.label}</p>
</div>
<p className="text-foreground-light text-left">
{definition.description}
</p>
</div>
</div>
{definition.value === 'partitioned' ? (
<div className="pt-2 pl-10">
<Badge variant="warning">COMING SOON</Badge>
</div>
) : null}
{/* {!pgPartmanExtensionInstalled &&
definition.value === 'partitioned' ? (
<div className="w-full flex gap-x-2 pl-11 py-2 items-center">
<WarningIcon />
<span className="text-xs">
<code>pg_partman</code> needs to be installed to use this type
</span>
</div>
) : null} */}
</RadioGroupStackedItem>
))}
</RadioGroupStacked>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{/* {!pgPartmanExtensionInstalled && (
<Admonition
type="note"
// @ts-ignore
title={
<span>
Enable <code className="text-xs w-min">pg_partman</code> for partitioned
queues
</span>
}
description={
<div className="flex flex-col gap-y-2">
<span>
This will allow you to create partitioned queues which can handle a large
amount of messages
</span>
<ButtonTooltip
type="default"
className="w-min"
disabled={!canToggleExtensions}
onClick={() => setShowEnableExtensionModal(true)}
tooltip={{
content: {
side: 'bottom',
text: 'You need additional permissions to enable database extensions',
},
}}
>
Install pg_partman extension
</ButtonTooltip>
</div>
}
/>
)} */}
</SheetSection>
<Separator />
{queueType === 'partitioned' && (
<>
<SheetSection className="flex flex-col gap-3">
<FormField_Shadcn_
control={form.control}
name="values.partitionInterval"
render={({ field: { ref, ...rest } }) => (
<FormItemLayout label="Partition interval" className="gap-1">
<Input
{...rest}
type="number"
placeholder="1000"
actions={<p className="text-foreground-light pr-2">ms</p>}
/>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="values.retentionInterval"
render={({ field: { ref, ...rest } }) => (
<FormItemLayout label="Retention interval" className="gap-1">
<Input
{...rest}
type="number"
placeholder="1000"
actions={<p className="text-foreground-light pr-2">ms</p>}
/>
</FormItemLayout>
)}
/>
</SheetSection>
</>
)}
</form>
</Form_Shadcn_>
</div>
<SheetFooter>
<Button
size="tiny"
type="default"
htmlType="button"
onClick={onClosePanel}
disabled={isLoading}
>
Cancel
</Button>
<Button
size="tiny"
type="primary"
form={FORM_ID}
htmlType="submit"
disabled={isLoading}
loading={isLoading}
>
Create queue
</Button>
</SheetFooter>
</div>
<ConfirmationModal
visible={isClosing}
title="Discard changes"
confirmLabel="Discard"
onCancel={() => setIsClosing(false)}
onConfirm={() => onClose()}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
{pgPartmanExtension && (
<EnableExtensionModal
visible={showEnableExtensionModal}
extension={pgPartmanExtension}
onCancel={() => setShowEnableExtensionModal(false)}
/>
)}
</>
)
}
@@ -0,0 +1,23 @@
import { Rows2, Rows3, Rows4 } from 'lucide-react'
export const QUEUE_TYPES = [
{
value: 'basic',
icon: <Rows4 strokeWidth={1} />,
label: 'Basic queue',
description: 'Create a basic queue.',
},
{
value: 'unlogged',
icon: <Rows2 strokeWidth={1} />,
label: 'Unlogged queue',
description:
'Creates an unlogged queue which loses all data on database restart. Can be useful when write throughput is more important than durability.',
},
{
value: 'partitioned',
icon: <Rows3 strokeWidth={1} />,
label: 'Partitioned queue',
description: 'Create a partitioned queue which is optimized for large amount of messages',
},
] as const
@@ -0,0 +1,79 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useState } from 'react'
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
import { DocsButton } from 'components/ui/DocsButton'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { Button } from 'ui'
export const QueuesDisabledState = () => {
const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
const { project } = useProjectContext()
const canToggleExtensions = useCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'extensions'
)
const { data } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const pgmqExtension = (data ?? []).find((ext) => ext.name === 'pgmq')
if (!pgmqExtension) return null
return (
<>
<div className="w-full h-full flex items-center justify-center">
<ProductEmptyState title="Queues">
<div className="text-sm text-foreground-light mb-4 grid gap-2">
<p>
Message queues in PostgreSQL allow you to handle asynchronous tasks, manage workloads,
and enable reliable communication between different parts of your application.
</p>
<p>Enable queues in your project.</p>
</div>
<div className="flex items-center gap-3 mt-4">
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<Button type="primary" onClick={() => setShowEnableExtensionModal(true)}>
Enable Queues
</Button>
</Tooltip.Trigger>
{!canToggleExtensions && (
<Tooltip.Portal>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background',
].join(' ')}
>
<span className="text-xs text-foreground">
You need additional permissions to enable pgmq for this project
</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
)}
</Tooltip.Root>
<DocsButton href="https://supabase.com/docs/guides/database/extensions/pgmq" />
</div>
</ProductEmptyState>
</div>
<EnableExtensionModal
visible={showEnableExtensionModal}
extension={pgmqExtension}
onCancel={() => setShowEnableExtensionModal(false)}
/>
</>
)
}
@@ -0,0 +1,137 @@
import { useEffect, useState } from 'react'
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useQueuesQuery } from 'data/database-queues/database-queues-query'
import { Search } from 'lucide-react'
import { useQueryState } from 'nuqs'
import { Button, Input, Sheet, SheetContent } from 'ui'
import { CreateQueueSheet } from './CreateQueueSheet'
import { QueuesDisabledState } from './QueuesDisabledState'
import { QueuesRows } from './QueuesRows'
export const QueuesListing = () => {
const { project } = useProjectContext()
const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
// used for confirmation prompt in the Create Queue Sheet
const [isClosingCreateQueueSheet, setIsClosingCreateQueueSheet] = useState(false)
const [createQueueSheetShown, setCreateQueueSheetShown] = useState(false)
const {
data: queues,
error,
isLoading,
isError,
refetch,
} = useQueuesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { data: extensions, isLoading: isLoadingExtensions } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const pgmqExtension = (extensions ?? []).find((ext) => ext.name === 'pgmq')
// TODO: Change this to true for local development to work
const pgmqExtensionInstalled = pgmqExtension?.installed_version
useEffect(() => {
// refetch the queues after the pgmq extension has been installed
if (pgmqExtensionInstalled && isError) {
refetch()
}
}, [isError, pgmqExtensionInstalled, refetch])
const [searchQuery, setSearchQuery] = useQueryState('search')
// this avoid showing loading screen when the extension is not installed. Otherwise, we'll have to wait for three
// retries (which are sure to fail because the extension is not installed)
if (isLoadingExtensions) return <GenericSkeletonLoader />
if (!pgmqExtensionInstalled) return <QueuesDisabledState />
if (isLoading) return <GenericSkeletonLoader />
if (isError) return <AlertError error={error} subject="Failed to retrieve database queues" />
return (
<>
<div className="w-full space-y-4">
<FormHeader
title="Queues"
description="Use message queues to handle asynchronous tasks, manage workloads, and enable reliable communication between different parts of your application."
/>
{queues.length === 0 ? (
<div
className={
'border rounded border-default px-20 py-16 flex flex-col items-center justify-center space-y-4 border-dashed'
}
>
<p className="text-sm text-foreground">No queues created yet</p>
<Button onClick={() => setCreateQueueSheetShown(true)}>Add a new queue</Button>
</div>
) : (
<div className="w-full space-y-4">
<div className="flex items-center justify-between flex-wrap">
<Input
placeholder="Search for a queue"
size="small"
icon={<Search size={14} />}
value={searchQuery || ''}
className="w-64"
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Button onClick={() => setCreateQueueSheetShown(true)}>Create a queue</Button>
</div>
<Table
className="table-fixed overflow-x-auto"
head={
<>
<Table.th key="name">Name</Table.th>
<Table.th key="arguments" className="table-cell">
Type
</Table.th>
<Table.th key="created_at" className="table-cell w-60">
Created at
</Table.th>
<Table.th key="queue_size" className="table-cell">
<div className="flex justify-center">Size</div>
</Table.th>
<Table.th key="buttons" className="table-cell"></Table.th>
</>
}
body={<QueuesRows queues={queues} filterString={searchQuery || ''} />}
/>
</div>
)}
</div>
<Sheet open={createQueueSheetShown} onOpenChange={() => setIsClosingCreateQueueSheet(true)}>
<SheetContent size="default" tabIndex={undefined}>
<CreateQueueSheet
onClose={() => {
setIsClosingCreateQueueSheet(false)
setCreateQueueSheetShown(false)
}}
isClosing={isClosingCreateQueueSheet}
setIsClosing={setIsClosingCreateQueueSheet}
/>
</SheetContent>
</Sheet>
{pgmqExtension ? (
<EnableExtensionModal
visible={showEnableExtensionModal}
extension={pgmqExtension}
onCancel={() => setShowEnableExtensionModal(false)}
/>
) : null}
</>
)
}
@@ -0,0 +1,99 @@
import dayjs from 'dayjs'
import { includes, sortBy } from 'lodash'
import { ChevronRight, Loader2 } from 'lucide-react'
import { useRouter } from 'next/router'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import Table from 'components/to-be-cleaned/Table'
import { useQueuesMetricsQuery } from 'data/database-queues/database-queues-metrics-query'
import { PostgresQueue } from 'data/database-queues/database-queues-query'
import { DATETIME_FORMAT } from 'lib/constants'
interface QueuesRowsProps {
queues: PostgresQueue[]
filterString: string
}
const QueueRow = ({ queue }: { queue: PostgresQueue }) => {
const router = useRouter()
const { project: selectedProject } = useProjectContext()
const { data: metrics, isLoading } = useQueuesMetricsQuery(
{
queueName: queue.queue_name,
projectRef: selectedProject?.ref,
connectionString: selectedProject?.connectionString,
},
{
staleTime: 30 * 1000, // 60 seconds, talk with Oli whether this is ok to call every minute
}
)
const type = queue.is_partitioned ? 'Partitioned' : queue.is_unlogged ? 'Unlogged' : 'Basic'
return (
<Table.tr
key={queue.queue_name}
onClick={() => {
router.push(`/project/${selectedProject?.ref}/integrations/queues/${queue.queue_name}`)
}}
className="hover:"
>
<Table.td className="truncate">
<p title={queue.queue_name}>{queue.queue_name}</p>
</Table.td>
<Table.td className="table-cell overflow-auto">
<p title={type.toLocaleLowerCase()} className="truncate">
{type}
</p>
</Table.td>
<Table.td className="table-cell">
<p title={queue.created_at}>{dayjs(queue.created_at).format(DATETIME_FORMAT)}</p>
</Table.td>
<Table.td className="table-cell">
<div className="flex justify-center">
{isLoading ? (
<Loader2 className="animate-spin" size={16} />
) : (
<p>
{metrics?.queue_length} {metrics?.method === 'estimated' ? '(Approximate)' : null}
</p>
)}
</div>
</Table.td>
<Table.td>
<div className="flex items-center justify-end">
<ChevronRight size="18" />
</div>
</Table.td>
</Table.tr>
)
}
export const QueuesRows = ({ queues: fetchedQueues, filterString }: QueuesRowsProps) => {
const filteredQueues = fetchedQueues.filter((x) =>
includes(x.queue_name.toLowerCase(), filterString.toLowerCase())
)
const queues = sortBy(filteredQueues, (func) => func.queue_name.toLocaleLowerCase())
if (queues.length === 0 && filterString.length > 0) {
return (
<Table.tr>
<Table.td colSpan={5}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{filterString}" did not return any results
</p>
</Table.td>
</Table.tr>
)
}
return (
<>
{queues.map((q) => (
<QueueRow key={q.queue_name} queue={q} />
))}
</>
)
}
@@ -0,0 +1,62 @@
import { useRouter } from 'next/router'
import { toast } from 'sonner'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useDatabaseQueueDeleteMutation } from 'data/database-queues/database-queues-delete-mutation'
import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal'
interface DeleteQueueProps {
queueName: string
visible: boolean
onClose: () => void
}
const DeleteQueue = ({ queueName, visible, onClose }: DeleteQueueProps) => {
const { project } = useProjectContext()
const router = useRouter()
const { mutate: deleteDatabaseQueue, isLoading } = useDatabaseQueueDeleteMutation({
onSuccess: () => {
toast.success(`Successfully removed queue ${queueName}`)
router.push(`/project/${project?.ref}/integrations/queues`)
onClose()
},
})
async function handleDelete() {
if (!project) return console.error('Project is required')
deleteDatabaseQueue({
queueName: queueName,
projectRef: project.ref,
connectionString: project.connectionString,
})
}
if (!queueName) {
return null
}
return (
<TextConfirmModal
variant="destructive"
visible={visible}
onCancel={() => onClose()}
onConfirm={handleDelete}
title="Delete this queue"
loading={isLoading}
confirmLabel={`Delete queue ${queueName}`}
confirmPlaceholder="Type in name of queue"
confirmString={queueName ?? 'Unknown'}
text={
<>
<span>This will delete the queue</span>{' '}
<span className="text-bold text-foreground">{queueName}</span>
</>
}
alert={{ title: 'You cannot recover this queue and its messages once deleted.' }}
/>
)
}
export default DeleteQueue
@@ -0,0 +1,224 @@
import { useEscapeKeydown } from '@radix-ui/react-use-escape-keydown'
import { isNil, noop } from 'lodash'
import { Archive, Clock12, Trash2, X } from 'lucide-react'
import { useState } from 'react'
import { useParams } from 'common'
import { MonacoEditor } from 'components/grid/components/common/MonacoEditor'
import { RowAction, RowData } from 'components/interfaces/Auth/Users/UserOverview'
import { useDatabaseQueueMessageArchiveMutation } from 'data/database-queues/database-queue-messages-archive-mutation'
import { useDatabaseQueueMessageDeleteMutation } from 'data/database-queues/database-queue-messages-delete-mutation'
import { PostgresQueueMessage } from 'data/database-queues/database-queue-messages-infinite-query'
import { useDatabaseQueueMessageReadMutation } from 'data/database-queues/database-queue-messages-read-mutation'
import dayjs from 'dayjs'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { prettifyJSON } from 'lib/helpers'
import {
Button,
ResizablePanel,
Separator,
Tabs_Shadcn_,
TabsContent_Shadcn_,
TabsList_Shadcn_,
TabsTrigger_Shadcn_,
} from 'ui'
export const DATE_FORMAT = 'DD MMM, YYYY HH:mm'
interface MessageDetailsPanelProps {
selectedMessage: PostgresQueueMessage
setSelectedMessage: (value: number | null) => void
}
const tryFormatInitialValue = (value: string) => {
try {
const jsonValue = JSON.parse(value)
return JSON.stringify(jsonValue)
} catch (err) {
if (typeof value === 'string') {
return value.replaceAll(`\"`, `"`)
} else {
return JSON.stringify(value)
}
}
}
export const MessageDetailsPanel = ({
selectedMessage,
setSelectedMessage,
}: MessageDetailsPanelProps) => {
const { id: _id } = useParams()
const { name: queueName } = useParams()
const project = useSelectedProject()
useEscapeKeydown(() => setSelectedMessage(null))
const {
mutate: archiveMessage,
isLoading: isLoadingArchive,
isSuccess: isSuccessArchive,
} = useDatabaseQueueMessageArchiveMutation()
const {
mutate: readMessage,
isLoading: isLoadingRead,
isSuccess: isSuccessRead,
} = useDatabaseQueueMessageReadMutation()
const {
mutate: deleteMessage,
isLoading: isLoadingDelete,
isSuccess: isSuccessDelete,
} = useDatabaseQueueMessageDeleteMutation()
const initialValue = JSON.stringify(selectedMessage?.message)
const jsonString = prettifyJSON(!isNil(initialValue) ? tryFormatInitialValue(initialValue) : '')
const [view, setView] = useState<'details' | 'suggestion'>('details')
return (
<ResizablePanel
defaultSize={30}
maxSize={45}
minSize={30}
collapsible
onCollapse={() => setSelectedMessage(null)}
className="bg-studio border-t pointer-events-auto"
>
<Button
type="text"
className="absolute top-3 right-3 px-1"
icon={<X />}
onClick={() => setSelectedMessage(null)}
/>
<Tabs_Shadcn_
value={view}
className="flex flex-col h-full"
onValueChange={(value: any) => {
setView(value)
}}
>
<TabsList_Shadcn_ className="px-5 flex gap-x-4 min-h-[46px]">
<TabsTrigger_Shadcn_
value="details"
className="px-0 pb-0 h-full text-xs data-[state=active]:bg-transparent !shadow-none"
>
Overview
</TabsTrigger_Shadcn_>
</TabsList_Shadcn_>
<TabsContent_Shadcn_ value="details" className="w-full mt-0 overflow-y-auto grow">
<div className="flex flex-col px-4 py-4 text-sm">
<RowData property="Message ID" value={`${selectedMessage.msg_id}`} />
<RowData
property="Added at"
value={dayjs(selectedMessage.enqueued_at).format(DATE_FORMAT)}
/>
<RowData
property="Available at"
value={dayjs(selectedMessage.vt).format(DATE_FORMAT)}
/>
<RowData property="Retries" value={`${selectedMessage.read_ct}`} />
<div>
<h3 className="text-foreground-light py-1">Payload</h3>
<MonacoEditor
key={selectedMessage.msg_id}
onChange={noop}
width="100%"
value={jsonString || 'NULL'}
language="json"
readOnly
/>
</div>
</div>
<Separator />
<div className="flex flex-col px-4 py-4 -space-y-1">
{!selectedMessage.archived_at ? (
<>
<RowAction
title="Postpone message"
description="The message will be postponed and won't show up in reads for 60 seconds."
button={{
icon: <Clock12 />,
text: 'Postpone',
isLoading: isLoadingRead,
onClick: () => {
readMessage({
projectRef: project!.ref,
connectionString: project?.connectionString,
queryName: queueName!,
messageId: selectedMessage.msg_id,
duration: 60,
})
},
}}
success={
isSuccessRead
? {
title: 'Postponed',
description: 'The message was postponed for 60 seconds.',
}
: undefined
}
/>
<RowAction
title="Archive message"
description="The message will be marked as archived and hidden from future reads by consumers. You can still access the message later."
button={{
icon: <Archive />,
text: 'Archive',
isLoading: isLoadingArchive,
type: 'warning',
onClick: () => {
archiveMessage({
projectRef: project!.ref,
connectionString: project?.connectionString,
queryName: queueName!,
messageId: selectedMessage.msg_id,
})
},
}}
success={
isSuccessArchive
? {
title: 'Archived',
description: 'The message was archived successfully.',
}
: undefined
}
/>
<RowAction
title="Delete message"
description="The message cannot be recovered afterwards."
button={{
icon: <Trash2 />,
text: 'Delete',
type: 'danger',
isLoading: isLoadingDelete,
onClick: () => {
deleteMessage({
projectRef: project!.ref,
connectionString: project?.connectionString,
queueName: queueName!,
messageId: selectedMessage.msg_id,
})
},
}}
success={
isSuccessDelete
? {
title: 'Deleted',
description: 'The message was deleted successfully.',
}
: undefined
}
/>
</>
) : null}
</div>
</TabsContent_Shadcn_>
</Tabs_Shadcn_>
</ResizablePanel>
)
}
@@ -0,0 +1,65 @@
import { useRouter } from 'next/router'
import { toast } from 'sonner'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useDatabaseQueuePurgeMutation } from 'data/database-queues/database-queues-purge-mutation'
import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal'
interface PurgeQueueProps {
queueName: string
visible: boolean
onClose: () => void
}
const PurgeQueue = ({ queueName, visible, onClose }: PurgeQueueProps) => {
const { project } = useProjectContext()
const router = useRouter()
const { mutate: purgeDatabaseQueue, isLoading } = useDatabaseQueuePurgeMutation({
onSuccess: () => {
toast.success(`Successfully purged queue ${queueName}`)
router.push(`/project/${project?.ref}/integrations/queues`)
onClose()
},
})
async function handlePurge() {
if (!project) return console.error('Project is required')
purgeDatabaseQueue({
queueName: queueName,
projectRef: project.ref,
connectionString: project.connectionString,
})
}
if (!queueName) {
return null
}
return (
<TextConfirmModal
variant="warning"
visible={visible}
onCancel={() => onClose()}
onConfirm={handlePurge}
title="Purge this queue"
loading={isLoading}
confirmLabel={`Purge queue ${queueName}`}
confirmPlaceholder="Type in name of queue"
confirmString={queueName ?? 'Unknown'}
text={
<>
<span>This will purge the queue</span>{' '}
<span className="text-bold text-foreground">{queueName}</span>
</>
}
alert={{
title:
"This action will delete all messages from the queue. They can't be recovered afterwards.",
}}
/>
)
}
export default PurgeQueue
@@ -0,0 +1,9 @@
import { capitalize } from 'lodash'
export const QUEUE_MESSAGE_TYPES = ['archived', 'available', 'scheduled'] as const
export type QUEUE_MESSAGE_TYPE = (typeof QUEUE_MESSAGE_TYPES)[number]
export const QUEUE_MESSAGE_OPTIONS = QUEUE_MESSAGE_TYPES.map((type) => ({
id: type,
name: capitalize(type),
}))
@@ -0,0 +1,219 @@
import dayjs from 'dayjs'
import { TextSearch } from 'lucide-react'
import { useRouter } from 'next/router'
import { parseAsInteger, useQueryState } from 'nuqs'
import { UIEvent, useMemo, useRef } from 'react'
import DataGrid, { Column, DataGridHandle, Row } from 'react-data-grid'
import { PostgresQueueMessage } from 'data/database-queues/database-queue-messages-infinite-query'
import { Badge, Button, ResizableHandle, ResizablePanel, ResizablePanelGroup, cn } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { DATE_FORMAT, MessageDetailsPanel } from './MessageDetailsPanel'
interface QueueDataGridProps {
isLoading: boolean
messages: PostgresQueueMessage[]
showMessageModal: () => void
fetchNextPage: () => void
}
function isAtBottom({ currentTarget }: UIEvent<HTMLDivElement>): boolean {
return currentTarget.scrollTop + 10 >= currentTarget.scrollHeight - currentTarget.clientHeight
}
const messagesCols = [
{
id: 'id',
name: 'ID',
description: undefined,
minWidth: 50,
width: 70,
value: (row: PostgresQueueMessage) => (
<div className="flex items-center gap-1.5">
<h3 className="text-xs">{row.msg_id}</h3>
</div>
),
},
{
id: 'enqueued_at',
name: 'Added at',
description: undefined,
minWidth: 160,
width: 180,
value: (row: PostgresQueueMessage) => (
<div className="flex items-center gap-1 text-xs">
{dayjs(row.enqueued_at).format(DATE_FORMAT)}
</div>
),
},
{
id: 'status',
name: 'Status',
description: undefined,
minWidth: 280,
width: 300,
value: (row: PostgresQueueMessage) => {
const isAvailable = new Date() > new Date(row.vt)
if (row.archived_at) {
return (
<Badge variant="default">Archived at {dayjs(row.archived_at).format(DATE_FORMAT)}</Badge>
)
}
return (
<Badge variant={isAvailable ? 'brand' : 'warning'}>
{isAvailable ? 'Available ' : `Available at ${dayjs(row.vt).format(DATE_FORMAT)}`}
</Badge>
)
},
},
{
id: 'retries',
name: 'Retries',
description: undefined,
minWidth: 50,
width: 70,
value: (row: PostgresQueueMessage) => <span>{row.read_ct}</span>,
},
{
id: 'payload',
name: 'Payload',
description: undefined,
minWidth: 600,
value: (row: PostgresQueueMessage) => <span>{JSON.stringify(row.message)}</span>,
},
]
const columns = messagesCols.map((col) => {
const result: Column<PostgresQueueMessage> = {
key: col.id,
name: col.name,
resizable: true,
minWidth: col.minWidth ?? 120,
width: col.width,
headerCellClass: 'first:pl-6 cursor-pointer',
renderHeaderCell: () => {
return (
<div className="flex items-center justify-between font-normal text-xs w-full">
<div className="flex items-center gap-x-2">
<p className="!text-foreground">{col.name}</p>
{col.description && <p className="text-foreground-lighter">{col.description}</p>}
</div>
</div>
)
},
renderCell: (props) => {
const value = col.value(props.row)
return (
<div
className={cn(
'w-full flex flex-col justify-center font-mono text-xs',
typeof value === 'number' ? 'text-right' : ''
)}
>
<span>{value}</span>
</div>
)
},
}
return result
})
export const QueueMessagesDataGrid = ({
isLoading,
messages,
showMessageModal,
fetchNextPage,
}: QueueDataGridProps) => {
const gridRef = useRef<DataGridHandle>(null)
const router = useRouter()
const [selectedMessageId, setSelectedMessageId] = useQueryState('id', parseAsInteger)
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
if (isLoading || !isAtBottom(event)) return
fetchNextPage()
}
const selectedMessage = useMemo(
() => messages.find((m) => m.msg_id === selectedMessageId),
[messages, selectedMessageId]
)
return (
<div className="relative h-full">
<DataGrid
ref={gridRef}
className="h-full"
rowHeight={44}
headerRowHeight={36}
columns={columns}
onScroll={handleScroll}
rows={messages}
rowClass={(message) => {
const isSelected = message.msg_id === selectedMessageId
return [
`${isSelected ? 'bg-surface-300 dark:bg-surface-300' : 'bg-200'} cursor-pointer`,
`${isSelected ? '[&>div:first-child]:border-l-4 border-l-secondary [&>div]:border-l-foreground' : ''}`,
'[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none',
'[&>.rdg-cell:first-child>div]:ml-4',
].join(' ')
}}
renderers={{
renderRow(idx, props) {
return (
<Row
key={props.row.msg_id}
{...props}
onClick={() => {
if (typeof idx === 'number' && idx >= 0) {
setSelectedMessageId(props.row.msg_id)
gridRef.current?.scrollToCell({ idx: 0, rowIdx: idx })
const { id, ...rest } = router.query
router.push({ ...router, query: { ...rest, id: props.row.msg_id } })
}
}}
/>
)
},
noRowsFallback: isLoading ? (
<div className="absolute top-14 px-6 w-full">
<GenericSkeletonLoader />
</div>
) : (
<div className="absolute top-28 px-6 flex flex-col items-center justify-center w-full gap-y-2">
<TextSearch className="text-foreground-muted" strokeWidth={1} />
<div className="text-center">
<p className="text-foreground">No messages found</p>
<p className="text-foreground-light">
The selected queue doesn't have any messages.
</p>
<Button className="mt-2" onClick={() => showMessageModal()}>
Add message
</Button>
</div>
</div>
),
}}
/>
<ResizablePanelGroup
direction="horizontal"
className="absolute inset-0 z-10 pointer-events-none"
autoSaveId="queue-messages-layout-v1"
>
<ResizablePanel defaultSize={1} />
{selectedMessage && (
<>
<ResizableHandle withHandle className="pointer-events-auto" />
<MessageDetailsPanel
selectedMessage={selectedMessage}
setSelectedMessage={setSelectedMessageId}
/>
</>
)}
</ResizablePanelGroup>
</div>
)
}
@@ -0,0 +1,29 @@
import { FilterPopover } from 'components/ui/FilterPopover'
import { QUEUE_MESSAGE_OPTIONS, QUEUE_MESSAGE_TYPE } from './Queue.utils'
interface QueueFiltersProps {
selectedTypes: QUEUE_MESSAGE_TYPE[]
setSelectedTypes: (value: QUEUE_MESSAGE_TYPE[]) => void
}
export const QueueFilters = ({ selectedTypes, setSelectedTypes }: QueueFiltersProps) => {
return (
<div className="bg-surface-200 py-3 px-6 flex items-center justify-between border-t">
<div className="flex items-center gap-x-2">
<FilterPopover
name={selectedTypes.length === 0 ? 'All types' : 'Types'}
title="Select types to show"
buttonType={selectedTypes.length === 0 ? 'dashed' : 'default'}
options={QUEUE_MESSAGE_OPTIONS} // Ignore user image column
labelKey="name"
valueKey="id"
labelClass="text-xs"
maxHeightClass="h-[190px]"
clearButtonText="Reset"
activeOptions={selectedTypes}
onSaveFilters={(value) => setSelectedTypes(value as any)}
/>
</div>
</div>
)
}
@@ -0,0 +1,138 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { SubmitHandler, useForm } from 'react-hook-form'
import z from 'zod'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
import { useDatabaseQueueMessageSendMutation } from 'data/database-queues/database-queue-messages-send-mutation'
import { useEffect } from 'react'
import { toast } from 'sonner'
import { Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input, Modal } from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
interface SendMessageModalProps {
visible: boolean
onClose: () => void
}
const FormSchema = z.object({
delay: z.coerce.number().int().gte(0).default(5),
payload: z.string().refine(
(val) => {
try {
JSON.parse(val)
} catch {
return false
}
},
{
message: 'The payload should be a JSON object',
}
),
})
export type SendMessageForm = z.infer<typeof FormSchema>
const FORM_ID = 'QUEUES_SEND_MESSAGE_FORM'
export const SendMessageModal = ({ visible, onClose }: SendMessageModalProps) => {
const { name: queueName } = useParams()
const { project } = useProjectContext()
const form = useForm<SendMessageForm>({
resolver: zodResolver(FormSchema),
defaultValues: {
delay: 1,
payload: '{}',
},
})
const { isLoading, mutate } = useDatabaseQueueMessageSendMutation({
onSuccess: () => {
toast.success(`Successfully added a message to the queue.`)
onClose()
},
})
const onSubmit: SubmitHandler<SendMessageForm> = (values) => {
mutate({
projectRef: project?.ref!,
connectionString: project?.connectionString,
queueName: queueName!,
payload: values.payload,
delay: values.delay,
})
}
useEffect(() => {
if (visible) {
form.reset({ delay: 1, payload: '{}' })
}
}, [visible])
return (
<Modal
size="medium"
alignFooter="right"
header="Add a message to the queue"
visible={visible}
loading={isLoading}
onCancel={onClose}
confirmText="Add"
onConfirm={() => {
const values = form.getValues()
onSubmit(values)
}}
>
<Modal.Content className="flex flex-col gap-y-4">
<Form_Shadcn_ {...form}>
<form
id={FORM_ID}
className="flex-grow overflow-auto gap-2 flex flex-col"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField_Shadcn_
control={form.control}
name="delay"
render={({ field: { ref, ...rest } }) => (
<FormItemLayout
label="Delay"
layout="vertical"
className="gap-1"
description="Time in seconds before the message becomes available for reading."
>
<FormControl_Shadcn_>
<Input
{...rest}
type="number"
placeholder="1"
actions={<p className="text-foreground-light pr-2">sec</p>}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="payload"
render={({ field }) => (
<FormItemLayout label="Message payload" layout="vertical" className="gap-1">
<FormControl_Shadcn_>
<CodeEditor
id="message-payload"
language="json"
className="!mb-0 h-32 overflow-hidden rounded border"
onInputChange={(e: string | undefined) => field.onChange(e)}
options={{ wordWrap: 'off', contextmenu: false }}
value={field.value}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</form>
</Form_Shadcn_>
</Modal.Content>
</Modal>
)
}
@@ -1,4 +1,3 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import dayjs from 'dayjs'
@@ -11,27 +10,28 @@ import { LogDetailsPanel } from 'components/interfaces/AuditLogs'
import { ScaffoldContainerLegacy } from 'components/layouts/Scaffold'
import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { DatePicker } from 'components/ui/DatePicker'
import { FilterPopover } from 'components/ui/FilterPopover'
import NoPermission from 'components/ui/NoPermission'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query'
import {
AuditLog,
useOrganizationAuditLogsQuery,
} from 'data/organizations/organization-audit-logs-query'
import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query'
import { useOrganizationRolesQuery } from 'data/organizations/organization-roles-query'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { useProjectsQuery } from 'data/projects/projects-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import {
Alert,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
WarningIcon,
} from 'ui'
import { Admonition } from 'ui-patterns'
// [Joshen considerations]
// - Maybe fix the height of the table to the remaining height of the viewport, so that the search input is always visible
@@ -58,7 +58,7 @@ const AuditLogs = () => {
const { data: projects } = useProjectsQuery()
const { data: organizations } = useOrganizationsQuery()
const { data: members } = useOrganizationMembersQuery({ slug })
const { data: rolesData } = useOrganizationRolesQuery({ slug })
const { data: rolesData } = useOrganizationRolesV2Query({ slug })
const { data, error, isLoading, isSuccess, isError, isRefetching, refetch } =
useOrganizationAuditLogsQuery(
{
@@ -96,7 +96,7 @@ const AuditLogs = () => {
return () => clearInterval(interval)
}, [dateRange.from, dateRange.to])
const roles = rolesData?.roles ?? []
const roles = [...(rolesData?.org_scoped_roles ?? []), ...(rolesData?.project_scoped_roles ?? [])]
const retentionPeriod = data?.retention_period ?? 0
const logs = data?.result ?? []
@@ -229,16 +229,22 @@ const AuditLogs = () => {
}}
renderFooter={() => {
return (
<Alert title="" variant="info" className="mx-3 pl-2 pr-2 pt-1 pb-2">
Your organization has a log retention period of{' '}
<span className="text-brand">
{retentionPeriod} day
{retentionPeriod > 1 ? 's' : ''}
</span>
. You may only view logs from{' '}
{dayjs().subtract(retentionPeriod, 'days').format('DD MMM YYYY')} as the
earliest date.
</Alert>
<Admonition
showIcon={false}
type="default"
className="w-auto mx-2 px-3 py-2"
>
<div className="text-xs text-foreground-light">
Your organization has a log retention period of{' '}
<span className="text-brand">
{retentionPeriod} day
{retentionPeriod > 1 ? 's' : ''}
</span>
. You may only view logs from{' '}
{dayjs().subtract(retentionPeriod, 'days').format('DD MMM YYYY')} as the
earliest date.
</div>
</Admonition>
)
}}
/>
@@ -284,37 +290,24 @@ const AuditLogs = () => {
<div className="flex items-center space-x-2">
<p>Date</p>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<Button
type="text"
className="px-1"
icon={
dateSortDesc ? (
<ArrowDown strokeWidth={1.5} size={14} />
) : (
<ArrowUp strokeWidth={1.5} size={14} />
)
}
onClick={() => setDateSortDesc(!dateSortDesc)}
/>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content side="right">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background',
].join(' ')}
>
<span className="text-xs text-foreground">
{dateSortDesc ? 'Sort latest first' : 'Sort earliest first'}
</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
<ButtonTooltip
type="text"
className="px-1"
icon={
dateSortDesc ? (
<ArrowDown strokeWidth={1.5} size={14} />
) : (
<ArrowUp strokeWidth={1.5} size={14} />
)
}
onClick={() => setDateSortDesc(!dateSortDesc)}
tooltip={{
content: {
side: 'bottom',
text: dateSortDesc ? 'Sort latest first' : 'Sort earliest first',
},
}}
/>
</div>
</Table.th>,
<Table.th key="actions" className="py-2"></Table.th>,
@@ -69,17 +69,12 @@ const BillingBreakdown = () => {
{invoiceFeatureEnabled && (
<>
<p className="text-sm text-foreground-light">
<p className="prose text-sm">
The table shows your upcoming invoice, excluding credits. This invoice will
continue updating until the end of your billing period on{' '}
{billingCycleEnd.format('MMMM DD')}. See{' '}
<Link
className="text-green-900 transition hover:text-green-1000"
href={`/org/${orgSlug}/usage`}
>
usage page
</Link>{' '}
for a more detailed usage breakdown.
<Link href={`/org/${orgSlug}/usage`}>usage page</Link> for a more detailed usage
breakdown.
</p>
<UpcomingInvoice slug={orgSlug} />
@@ -1,15 +1,20 @@
import clsx from 'clsx'
import { ChevronRight, Info } from 'lucide-react'
import Link from 'next/link'
import { useMemo, useState } from 'react'
import * as Tooltip from '@radix-ui/react-tooltip'
import AlertError from 'components/ui/AlertError'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useOrgUpcomingInvoiceQuery } from 'data/invoices/org-invoice-upcoming-query'
import { formatCurrency } from 'lib/helpers'
import Link from 'next/link'
import { useMemo, useState } from 'react'
import { Button, Collapsible } from 'ui'
import {
Button,
cn,
Collapsible,
Tooltip_Shadcn_,
TooltipContent_Shadcn_,
TooltipTrigger_Shadcn_,
} from 'ui'
import { billingMetricUnit, formatUsage } from '../helpers'
import { ChevronRight, Info } from 'lucide-react'
export interface UpcomingInvoiceProps {
slug?: string
@@ -170,7 +175,7 @@ const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => {
className="!px-1"
icon={
<ChevronRight
className={clsx(
className={cn(
'transition',
usageFeesExpanded.includes(fee.description) && 'rotate-90'
)}
@@ -285,32 +290,29 @@ const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => {
const InvoiceTooltip = ({ text, linkRef }: { text: string; linkRef?: string }) => {
return (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_>
<Info size={12} strokeWidth={2} />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div className="rounded bg-alternative py-1 px-2 leading-none shadow border border-background min-w-[300px] max-w-[450px] max-h-[300px] overflow-y-auto">
<span className="text-xs text-foreground">
<p>
{text}{' '}
{linkRef && (
<Link
href={linkRef}
target="_blank"
className="transition text-brand hover:text-brand-600 underline"
>
Read more
</Link>
)}
</p>
</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_
side="bottom"
className="min-w-[300px] max-w-[450px] max-h-[300px] overflow-y-auto"
>
<p>
{text}{' '}
{linkRef && (
<Link
href={linkRef}
target="_blank"
rel="noopener noreferrer"
className="transition text-brand hover:text-brand-600 underline"
>
Read more
</Link>
)}
</p>
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
)
}
Loaded 100 of 314 files, more files were not shown because too many files have changed in this diff. Show more