diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 40167c5036e..f3b6f7dccd5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 \ No newline at end of file diff --git a/.github/workflows/autofix_linters.yml b/.github/workflows/autofix_linters.yml index fa02d5c9f8d..d181e750d80 100644 --- a/.github/workflows/autofix_linters.yml +++ b/.github/workflows/autofix_linters.yml @@ -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 diff --git a/.github/workflows/config-preview.yaml b/.github/workflows/config-preview.yaml deleted file mode 100644 index cca3c2d7de3..00000000000 --- a/.github/workflows/config-preview.yaml +++ /dev/null @@ -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" diff --git a/.github/workflows/config-production.yaml b/.github/workflows/config-production.yaml deleted file mode 100644 index b7f3dd3d63c..00000000000 --- a/.github/workflows/config-production.yaml +++ /dev/null @@ -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 diff --git a/apps/docs/app/api/ai/docs/route.ts b/apps/docs/app/api/ai/docs/route.ts index 3b1df3ee2a7..b88d7837f82 100644 --- a/apps/docs/app/api/ai/docs/route.ts +++ b/apps/docs/app/api/ai/docs/route.ts @@ -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 */ diff --git a/apps/docs/app/guides/(with-sidebar)/ai/python/[slug]/page.tsx b/apps/docs/app/guides/(with-sidebar)/ai/python/[slug]/page.tsx index 578df9c3119..d6cc049c849 100644 --- a/apps/docs/app/guides/(with-sidebar)/ai/python/[slug]/page.tsx +++ b/apps/docs/app/guides/(with-sidebar)/ai/python/[slug]/page.tsx @@ -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}`, diff --git a/apps/docs/content/guides/auth/auth-email-templates.mdx b/apps/docs/content/guides/auth/auth-email-templates.mdx index f185d2a5f90..265ffd32807 100644 --- a/apps/docs/content/guides/auth/auth-email-templates.mdx +++ b/apps/docs/content/guides/auth/auth-email-templates.mdx @@ -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 diff --git a/apps/docs/content/guides/auth/auth-hooks.mdx b/apps/docs/content/guides/auth/auth-hooks.mdx index cee056a29f6..9d35a938a5e 100644 --- a/apps/docs/content/guides/auth/auth-hooks.mdx +++ b/apps/docs/content/guides/auth/auth-hooks.mdx @@ -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_' +SEND_SMS_HOOK_SECRET='v1,whsec_' ``` Start the function locally: diff --git a/apps/docs/content/guides/auth/enterprise-sso/auth-sso-saml.mdx b/apps/docs/content/guides/auth/enterprise-sso/auth-sso-saml.mdx index 59a5c388ae4..7e41fc0adb1 100644 --- a/apps/docs/content/guides/auth/enterprise-sso/auth-sso-saml.mdx +++ b/apps/docs/content/guides/auth/enterprise-sso/auth-sso-saml.mdx @@ -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. + + + + + +```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, '/') +} +``` + + + + diff --git a/apps/docs/content/guides/auth/passwords.mdx b/apps/docs/content/guides/auth/passwords.mdx index 892c127e233..25ffbaea936 100644 --- a/apps/docs/content/guides/auth/passwords.mdx +++ b/apps/docs/content/guides/auth/passwords.mdx @@ -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:

Follow this link to confirm your user:

- Confirm your email

@@ -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', + }, }) } ``` diff --git a/apps/docs/content/guides/auth/quickstarts/react-native.mdx b/apps/docs/content/guides/auth/quickstarts/react-native.mdx index 4a0176049f3..220a8db979a 100644 --- a/apps/docs/content/guides/auth/quickstarts/react-native.mdx +++ b/apps/docs/content/guides/auth/quickstarts/react-native.mdx @@ -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' diff --git a/apps/docs/content/guides/auth/social-login/auth-apple.mdx b/apps/docs/content/guides/auth/social-login/auth-apple.mdx index df5ba7c745b..5463db69e7d 100644 --- a/apps/docs/content/guides/auth/social-login/auth-apple.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-apple.mdx @@ -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_. 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, - 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)! 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_. @@ -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_. diff --git a/apps/docs/content/guides/auth/social-login/auth-google.mdx b/apps/docs/content/guides/auth/social-login/auth-google.mdx index d7500402e8b..11b24c66efd 100644 --- a/apps/docs/content/guides/auth/social-login/auth-google.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-google.mdx @@ -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. @@ -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 @@ -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. @@ -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..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. diff --git a/apps/docs/content/guides/database/custom-postgres-config.mdx b/apps/docs/content/guides/database/custom-postgres-config.mdx index 599f37e9810..59a41642bdd 100644 --- a/apps/docs/content/guides/database/custom-postgres-config.mdx +++ b/apps/docs/content/guides/database/custom-postgres-config.mdx @@ -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) | diff --git a/apps/docs/content/guides/database/extensions/pgsodium.mdx b/apps/docs/content/guides/database/extensions/pgsodium.mdx index dbddf13b51f..5c3a9d4c92b 100644 --- a/apps/docs/content/guides/database/extensions/pgsodium.mdx +++ b/apps/docs/content/guides/database/extensions/pgsodium.mdx @@ -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. + + +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. + + + [`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. diff --git a/apps/docs/content/guides/database/extensions/pgvector.mdx b/apps/docs/content/guides/database/extensions/pgvector.mdx index b320a7e23a5..c505abe6991 100644 --- a/apps/docs/content/guides/database/extensions/pgvector.mdx +++ b/apps/docs/content/guides/database/extensions/pgvector.mdx @@ -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) diff --git a/apps/docs/content/guides/database/extensions/postgis.mdx b/apps/docs/content/guides/database/extensions/postgis.mdx index 9c470366db1..72b85b09aff 100644 --- a/apps/docs/content/guides/database/extensions/postgis.mdx +++ b/apps/docs/content/guides/database/extensions/postgis.mdx @@ -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 ); ``` diff --git a/apps/docs/content/guides/database/webhooks.mdx b/apps/docs/content/guides/database/webhooks.mdx index 28297118008..93a17de9ed5 100644 --- a/apps/docs/content/guides/database/webhooks.mdx +++ b/apps/docs/content/guides/database/webhooks.mdx @@ -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. diff --git a/apps/docs/content/guides/functions/background-tasks.mdx b/apps/docs/content/guides/functions/background-tasks.mdx index 2ad76d7028d..fbc1f2aebf8 100644 --- a/apps/docs/content/guides/functions/background-tasks.mdx +++ b/apps/docs/content/guides/functions/background-tasks.mdx @@ -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 + readonly taskPromise: Promise - constructor(taskPromise) { + constructor(taskPromise: Promise) { 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()) }) diff --git a/apps/docs/content/guides/functions/connect-to-postgres.mdx b/apps/docs/content/guides/functions/connect-to-postgres.mdx index 12478874d5c..22b0ada259b 100644 --- a/apps/docs/content/guides/functions/connect-to-postgres.mdx +++ b/apps/docs/content/guides/functions/connect-to-postgres.mdx @@ -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') ?? '', diff --git a/apps/docs/content/guides/functions/examples/github-actions.mdx b/apps/docs/content/guides/functions/examples/github-actions.mdx index 3298913f089..5b665d8f4c8 100644 --- a/apps/docs/content/guides/functions/examples/github-actions.mdx +++ b/apps/docs/content/guides/functions/examples/github-actions.mdx @@ -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: diff --git a/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx b/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx index 1b11196eaeb..88b705e3f90 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx @@ -9,53 +9,7 @@ hideToc: true - - - 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. - - - - - - ```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; - ``` - - - - - - Make the data in your table publicly readable by adding an RLS policy: - - - - - - ```sql SQL_EDITOR - create policy "public can read notes" - on public.notes - for select to anon - using (true); - ``` - - + @@ -104,9 +58,9 @@ hideToc: true - 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. @@ -114,14 +68,14 @@ hideToc: true - ```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
{JSON.stringify(notes, null, 2)}
+ return
{JSON.stringify(countries, null, 2)}
} ``` @@ -166,7 +120,7 @@ hideToc: true - 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. diff --git a/apps/docs/content/guides/getting-started/quickstarts/sveltekit.mdx b/apps/docs/content/guides/getting-started/quickstarts/sveltekit.mdx index bcb65aca897..c78cc43a9b1 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/sveltekit.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/sveltekit.mdx @@ -24,7 +24,7 @@ hideToc: true ```bash Terminal - npm create svelte@latest myapp + npx sv create my-app ``` @@ -43,7 +43,7 @@ hideToc: true ```bash Terminal - cd myapp && npm install @supabase/supabase-js + cd my-app && npm install @supabase/supabase-js ``` @@ -53,7 +53,7 @@ hideToc: true - 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: @@ -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. @@ -98,7 +98,7 @@ hideToc: true - 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. diff --git a/apps/docs/content/guides/getting-started/tutorials/with-expo-react-native.mdx b/apps/docs/content/guides/getting-started/tutorials/with-expo-react-native.mdx index a86e882cd3c..63a270a0ecb 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-expo-react-native.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-expo-react-native.mdx @@ -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. **Project** | | | | | | | Project Management | Transfer | | | | | @@ -188,7 +188,7 @@ The table below shows the actions each role can take on the resources belonging | | Delete | | | | | | | View | | | | | | | List | | | | | -| | Run | | | | [^5] | +| | Run | | | | [^6] | | **Database** | | | | | | | Scheduled Backups | View | | | | | | | Download | | | | | @@ -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). diff --git a/apps/docs/content/guides/platform/database-size.mdx b/apps/docs/content/guides/platform/database-size.mdx index 3ddc6cb224c..bb9174b5f9f 100644 --- a/apps/docs/content/guides/platform/database-size.mdx +++ b/apps/docs/content/guides/platform/database-size.mdx @@ -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
+### 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/) diff --git a/apps/docs/content/guides/platform/ipv4-address.mdx b/apps/docs/content/guides/platform/ipv4-address.mdx index bae6317423f..7a3988ca1b9 100644 --- a/apps/docs/content/guides/platform/ipv4-address.mdx +++ b/apps/docs/content/guides/platform/ipv4-address.mdx @@ -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: + + +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. + + + - 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 diff --git a/apps/docs/content/guides/platform/upgrading.mdx b/apps/docs/content/guides/platform/upgrading.mdx index aef800a9d67..1bdab44de90 100644 --- a/apps/docs/content/guides/platform/upgrading.mdx +++ b/apps/docs/content/guides/platform/upgrading.mdx @@ -22,6 +22,11 @@ This upgrade method is currently in Beta.
+ + 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 + + 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. diff --git a/apps/docs/spec/api_v1_openapi.json b/apps/docs/spec/api_v1_openapi.json index f7387e4701c..75df6edc0d6 100644 --- a/apps/docs/spec/api_v1_openapi.json +++ b/apps/docs/spec/api_v1_openapi.json @@ -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" }, diff --git a/apps/docs/spec/cli_v1_commands.yaml b/apps/docs/spec/cli_v1_commands.yaml index fdd0d23c502..e07ad7fb852 100644 --- a/apps/docs/spec/cli_v1_commands.yaml +++ b/apps/docs/spec/cli_v1_commands.yaml @@ -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 + description: Project ref of the Supabase project. + default_value: '' - id: supabase-completion title: supabase completion summary: Generate the autocompletion script for the specified shell diff --git a/apps/docs/spec/cli_v1_config.yaml b/apps/docs/spec/cli_v1_config.yaml index c1c5657b914..8780895b8da 100644 --- a/apps/docs/spec/cli_v1_config.yaml +++ b/apps/docs/spec/cli_v1_config.yaml @@ -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..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..enabled' diff --git a/apps/docs/spec/common-cli-sections.json b/apps/docs/spec/common-cli-sections.json index 81a29efb105..6e2aaff44be 100644 --- a/apps/docs/spec/common-cli-sections.json +++ b/apps/docs/spec/common-cli-sections.json @@ -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", diff --git a/apps/docs/spec/examples/examples.yml b/apps/docs/spec/examples/examples.yml index 9a35a086b5e..77249cc8fa8 100644 --- a/apps/docs/spec/examples/examples.yml +++ b/apps/docs/spec/examples/examples.yml @@ -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. diff --git a/apps/docs/spec/supabase_dart_v2.yml b/apps/docs/spec/supabase_dart_v2.yml index 565c92b924d..adbcca2ddec 100644 --- a/apps/docs/spec/supabase_dart_v2.yml +++ b/apps/docs/spec/supabase_dart_v2.yml @@ -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'; diff --git a/apps/docs/spec/transforms/api_v1_openapi_deparsed.json b/apps/docs/spec/transforms/api_v1_openapi_deparsed.json index 3d07a743f9d..692ac2af2b2 100644 --- a/apps/docs/spec/transforms/api_v1_openapi_deparsed.json +++ b/apps/docs/spec/transforms/api_v1_openapi_deparsed.json @@ -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" }, diff --git a/apps/studio/components/grid/SupabaseGrid.tsx b/apps/studio/components/grid/SupabaseGrid.tsx index 2ba8e5d0a03..7c996ca9108 100644 --- a/apps/studio/components/grid/SupabaseGrid.tsx +++ b/apps/studio/components/grid/SupabaseGrid.tsx @@ -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, diff --git a/apps/studio/components/grid/components/editor/SelectEditor.tsx b/apps/studio/components/grid/components/editor/SelectEditor.tsx index 0aa6a08e6c2..3ea7cbe16b1 100644 --- a/apps/studio/components/grid/components/editor/SelectEditor.tsx +++ b/apps/studio/components/grid/components/editor/SelectEditor.tsx @@ -23,7 +23,6 @@ export function SelectEditor({ 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 { diff --git a/apps/studio/components/grid/components/footer/pagination/Pagination.tsx b/apps/studio/components/grid/components/footer/pagination/Pagination.tsx index bb32dd1a3e2..9c4384a8f14 100644 --- a/apps/studio/components/grid/components/footer/pagination/Pagination.tsx +++ b/apps/studio/components/grid/components/footer/pagination/Pagination.tsx @@ -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, diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index 292745a27af..45c69218f99 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -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]" /> { 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, diff --git a/apps/studio/components/grid/components/header/RefreshButton.tsx b/apps/studio/components/grid/components/header/RefreshButton.tsx index 0b43a2aa0b2..9bf63bf1b0c 100644 --- a/apps/studio/components/grid/components/header/RefreshButton.tsx +++ b/apps/studio/components/grid/components/header/RefreshButton.tsx @@ -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) diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx index e474cea8e5e..11fdbf2264f 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx @@ -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 ? ( - - {properties.descriptionOptional} - + ) : null } descriptionText={ properties.description ? ( - - {properties.description} - + ) : null } actions={ @@ -140,6 +140,7 @@ const FormField = ({ } /> ) + case 'multiline-string': return ( - {properties.description} - + ) : null } actions={ @@ -183,6 +182,7 @@ const FormField = ({ } /> ) + case 'number': return ( - {properties.description} - + ) : null } actions={ diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx index 1718070d3a5..dd66b17e3be 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx @@ -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={ - - {provider.misc.helper} - + } /> diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx index f804a333cc3..dcb72820e2a 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx @@ -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: { diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyEditorPanel.utils.ts b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyEditorPanel.utils.ts index e68356d2d98..937eaaa4f1d 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyEditorPanel.utils.ts +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyEditorPanel.utils.ts @@ -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 } diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/LockedQuerySection.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/LockedQuerySection.tsx index 429de771a3e..5f4e045b7d1 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/LockedQuerySection.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/LockedQuerySection.tsx @@ -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

- {/* */}

1

diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx index ba6aa9ab2ae..62dfc833efb 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx @@ -595,8 +595,6 @@ export const AIPolicyEditorPanel = memo(function ({ diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx index d6607237fea..5f75945624d 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx @@ -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 = ({
- - -
) diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx index 9c4edfa0dd2..c75be788248 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx @@ -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 - - - - - - {!canToggleRLS - ? 'You need additional permissions to create RLS policies' - : 'Create with Supabase Assistant'} - - + { + 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', + }, + }} + > + + )} diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx index 3e76ab0df6a..1397050ade5 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx @@ -88,6 +88,7 @@ const PolicyTableRow = ({ {policies?.map((policy) => ( { +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') diff --git a/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx b/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx index 04a923d0082..7543072de08 100644 --- a/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx +++ b/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx @@ -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, }) diff --git a/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx b/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx index 8e0b2467781..63c4b904996 100644 --- a/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx +++ b/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx @@ -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>({ @@ -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" /> @@ -139,15 +116,14 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => { strokeWidth={1.5} /> + /> @@ -182,7 +158,7 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => { size="small" htmlType="submit" loading={isCreatingUser} - disabled={!canCreateUsers || isCreatingUser || isLoading} + disabled={!canCreateUsers || isCreatingUser} > Create user diff --git a/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx b/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx index 55da43b43fe..886792b3204 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx @@ -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) => { )} -
+
{
{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) => { /> )}
-

{provider.name}

+

{providerName}

Signed in with a {providerName} account via{' '} {providerName === 'SAML' ? 'SSO' : 'OAuth'} @@ -380,7 +374,7 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => { button={{ icon: , 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 ( -

-

{property}

- {typeof value === 'boolean' ? ( -
- {value ? ( -
- -
- ) : ( -
- -
- )} -
- ) : ( -
-

{!value ? '-' : value}

- {!!value && ( - } - className="transition opacity-0 group-hover:opacity-100 px-1" - text={value} - /> - )} -
- )} -
+ <> +
+

{property}

+ {typeof value === 'boolean' ? ( +
+ {value ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ ) : ( +
+

{!value ? '-' : value}

+ {!!value && ( + } + className="transition opacity-0 group-hover:opacity-100 px-1" + text={value} + /> + )} +
+ )} +
+ + ) } -const RowAction = ({ +export const RowAction = ({ title, description, button, diff --git a/apps/studio/components/interfaces/Auth/Users/Users.constants.ts b/apps/studio/components/interfaces/Auth/Users/Users.constants.ts index c66df1abdba..97b20ae76c6 100644 --- a/apps/studio/components/interfaces/Auth/Users/Users.constants.ts +++ b/apps/studio/components/interfaces/Auth/Users/Users.constants.ts @@ -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( diff --git a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx index 7ecab736fbf..73d5dabc787 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx @@ -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) => { 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 (
@@ -363,7 +362,7 @@ export const UsersV2 = () => { > Refresh - +
diff --git a/apps/studio/components/interfaces/Billing/NoProjectsOnPaidOrgInfo.tsx b/apps/studio/components/interfaces/Billing/NoProjectsOnPaidOrgInfo.tsx index 34e1d52cb90..870af2ef2d4 100644 --- a/apps/studio/components/interfaces/Billing/NoProjectsOnPaidOrgInfo.tsx +++ b/apps/studio/components/interfaces/Billing/NoProjectsOnPaidOrgInfo.tsx @@ -31,7 +31,7 @@ export const NoProjectsOnPaidOrgInfo = ({ organization }: NoProjectsOnPaidOrgInf } /> diff --git a/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx b/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx index 61a7e427a44..96d2c57b55f 100644 --- a/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx +++ b/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx @@ -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={} 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', diff --git a/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx b/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx index 1c25952c1b1..48a962d8840 100644 --- a/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx +++ b/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx @@ -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() - 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 = () => { 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 }) }} diff --git a/apps/studio/components/interfaces/Database/Backups/DatabaseBackupsNav.tsx b/apps/studio/components/interfaces/Database/Backups/DatabaseBackupsNav.tsx index 657ff8c0cfd..f0fe0b1d5e4 100644 --- a/apps/studio/components/interfaces/Database/Backups/DatabaseBackupsNav.tsx +++ b/apps/studio/components/interfaces/Database/Backups/DatabaseBackupsNav.tsx @@ -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`, }, ] diff --git a/apps/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx b/apps/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx index 75c3dc010c5..f86edb50bc4 100644 --- a/apps/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx +++ b/apps/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx @@ -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()} > -

- Are you sure you want to turn OFF the "{extension.name}" extension? -

+
+

+ Are you sure you want to turn OFF the "{extension.name}" extension? +

+ {EXTENSION_DISABLE_WARNINGS[extension.name] && ( + + {EXTENSION_DISABLE_WARNINGS[extension.name]} + + )} +
) diff --git a/apps/studio/components/interfaces/Database/Extensions/Extensions.constants.ts b/apps/studio/components/interfaces/Database/Extensions/Extensions.constants.ts index 09083125b0f..5d662357a5d 100644 --- a/apps/studio/components/interfaces/Database/Extensions/Extensions.constants.ts +++ b/apps/studio/components/interfaces/Database/Extensions/Extensions.constants.ts @@ -19,3 +19,7 @@ export const HIDDEN_EXTENSIONS = [ export const SEARCH_TERMS: Record = { vector: ['pgvector', 'pg_vector'], } + +export const EXTENSION_DISABLE_WARNINGS: Record = { + pg_cron: 'Disabling this extension will delete all scheduled jobs. This cannot be undone.', +} diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx index b5f5bc8021d..263da4ab55b 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx @@ -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 = ({
- - - - - {!canUpdateRoles && ( - - -
- You need additional permissions to add a new role -
-
- )} -
+ } + onClick={() => setIsCreatingRole(true)} + tooltip={{ + content: { + side: 'bottom', + text: !canUpdateRoles + ? 'You need additional permissions to add a new role' + : undefined, + }, + }} + > + Add role +
- {supabaseRoles.length > 0 && ( -
-
-

Roles managed by Supabase

- Protected -
- - {isLoading - ? Array.from({ length: 5 }).map((_, i) => ) - : supabaseRoles.map((role) => ( - - ))} +
+
+

Roles managed by Supabase

+ Protected
- )} - {otherRoles.length > 0 && ( -
-
-

Other database roles

-
+ {isLoading + ? Array.from({ length: 5 }).map((_, i) => ) + : supabaseRoles.map((role) => ( + + ))} +
- {isLoading - ? Array.from({ length: 3 }).map((_, i) => ) - : otherRoles.map((role) => ( - - ))} +
+
+

Other database roles

- )} + + {isLoading + ? Array.from({ length: 3 }).map((_, i) => ) + : otherRoles.map((role) => ( + + ))} +
{filterString.length > 0 && filteredRoles.length === 0 && ( diff --git a/apps/studio/components/interfaces/Database/Triggers/CreateTrigger.tsx b/apps/studio/components/interfaces/Database/Triggers/CreateTrigger.tsx index cf37b3147bc..eb96c6c1877 100644 --- a/apps/studio/components/interfaces/Database/Triggers/CreateTrigger.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/CreateTrigger.tsx @@ -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) { @@ -342,6 +346,7 @@ const CreateTrigger = ({ trigger, visible, setVisible }: CreateTriggerProps) =>
+ {_localState.formState.condition.value && }
) : ( <> @@ -492,6 +497,34 @@ const SelectEnabledMode = observer(({}) => { ) }) +const DisplayCondition = observer(() => { + const _localState = useContext(CreateTriggerContext) + + return ( +
+
+ +
+
+ +
+ This condition must be met for the trigger to fire. +
+ + To update the condition, you must drop and recreate this trigger. + +
+
+
+ ) +}) + const SelectOrientation = observer(({}) => { const _localState = useContext(CreateTriggerContext) return ( diff --git a/apps/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx b/apps/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx index 6cab2ab1fe7..af42e06dbd5 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx +++ b/apps/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx @@ -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) diff --git a/apps/studio/components/interfaces/Database/Wrappers/Wrappers.tsx b/apps/studio/components/interfaces/Database/Wrappers/Wrappers.tsx index 83b04d5f72f..76c92a2eb58 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/Wrappers.tsx +++ b/apps/studio/components/interfaces/Database/Wrappers/Wrappers.tsx @@ -19,7 +19,7 @@ const Wrappers = ({ isEnabled }: { isEnabled: boolean }) => { const [open, setOpen] = useState('') const [selectedWrapperToDelete, setSelectedWrapperToDelete] = useState() - const wrappers = data?.result ?? [] + const wrappers = data ?? [] return ( <> diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx index 818a3d88e4f..ab1c663830f 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx @@ -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 (
@@ -300,7 +312,6 @@ export function DiskManagementForm() { - {!isFlyArchitecture && ( <> +
+ + {!isReadOnlyMode && usedPercentage >= 90 && isWithinCooldownWindow && ( + + + + )} + {isReadOnlyMode && ( + + + + )} +
- + -
@@ -91,16 +96,21 @@ export default function DiskSpaceBar({ form }: DiskSpaceBarProps) { >
= 90 && remainingDuration > 0 + ? 'bg-destructive' + : 'bg-foreground', + 'relative overflow-hidden transition-all duration-500 ease-in-out' + )} + style={{ width: `${usedPercentage >= 100 ? 100 : usedPercentage}%` }} >
@@ -126,15 +138,15 @@ export default function DiskSpaceBar({ form }: DiskSpaceBarProps) {
= 100 ? 100 : newUsedPercentage}%` }} >
New disk size diff --git a/apps/studio/components/interfaces/DiskManagement/ui/DiskTypeRecommendationSection.tsx b/apps/studio/components/interfaces/DiskManagement/ui/DiskTypeRecommendationSection.tsx index b637daf3489..4e09f85aa73 100644 --- a/apps/studio/components/interfaces/DiskManagement/ui/DiskTypeRecommendationSection.tsx +++ b/apps/studio/components/interfaces/DiskManagement/ui/DiskTypeRecommendationSection.tsx @@ -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, }: { diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx index 963daeab751..483a1fa9a84 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx @@ -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 = () => {

Import maps are{' '} - + {hasImportMap ? 'used' : 'not used'} {' '} for this function diff --git a/apps/studio/components/interfaces/GraphQL/GraphiQL.tsx b/apps/studio/components/interfaces/GraphQL/GraphiQL.tsx index 4bb76e3d1e8..53a527e0d0c 100644 --- a/apps/studio/components/interfaces/GraphQL/GraphiQL.tsx +++ b/apps/studio/components/interfaces/GraphQL/GraphiQL.tsx @@ -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 ( -

+
{
{ >
@@ -367,7 +366,7 @@ const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => { {canReadJWTSecret && (
{
{
{PluginContent ? : null}
-
+
{pluginContext?.plugins.map((plugin, index) => { const isVisible = plugin === pluginContext.visiblePlugin diff --git a/apps/studio/components/interfaces/Home/NewProjectPanel/NewProjectPanel.tsx b/apps/studio/components/interfaces/Home/NewProjectPanel/NewProjectPanel.tsx index e77fbe11591..2a7d974d274 100644 --- a/apps/studio/components/interfaces/Home/NewProjectPanel/NewProjectPanel.tsx +++ b/apps/studio/components/interfaces/Home/NewProjectPanel/NewProjectPanel.tsx @@ -50,7 +50,7 @@ const NewProjectPanel = () => { Table Editor + + ))} + +
+ + + + View syntax chart + + + + + +
- + +
+

+ Schedule {timezone ? `(${timezone})` : ''} +

{scheduleString ? ( - - The cron will be run {/* lowercase the first letter */} - {scheduleString - .split(' ') - .map((s, i) => (i === 0 ? s.toLocaleLowerCase() : s)) - .join(' ')} - . + + {isGeneratingCron ? : presetValue || '* * * * *'} ) : ( - !fieldState.error && + + {isGeneratingCron ? : presetValue || '* * * * *'} + )} - + {!inputValue && !isGeneratingCron && !scheduleString ? ( + + Describe your schedule above + + ) : ( + + {isGeneratingCron ? ( + + ) : 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(' ') + '.'} + + )} + + )} +
) }} @@ -110,3 +243,47 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) => ) } + +const CronSyntaxLoader = () => { + return ( +
+ {['*', '*', '*', '*', '*'].map((char, i) => ( + + {char} + + ))} +
+ ) +} + +const LoadingDots = () => { + return ( + + {[0, 1, 2].map((i) => ( + + . + + ))} + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts index e54f33da4e8..ad1dab0ead7 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts @@ -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) + }) + }) }) diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts index f316468e5cf..af9f02fa528 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts @@ -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}$/ diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsListing.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsListing.tsx index edfe0766c69..2ae618a7a8c 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsListing.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsListing.tsx @@ -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() - 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 - if (!pgCronExtensionInstalled) return if (isLoading) return - if (isError) return return ( <>
- - - - - } - /> {(cronJobs ?? []).length == 0 ? (

No cron jobs created yet

@@ -93,7 +38,7 @@ export const CronJobsListing = () => {
) : (
- {cronJobs.map((job) => ( + {(cronJobs ?? []).map((job) => ( setCreateCronJobSheetShown(job)} @@ -104,6 +49,12 @@ export const CronJobsListing = () => { )}
+ setCronJobForDeletion(undefined)} + cronJob={cronJobForDeletion!} + /> + setIsClosingCreateCronJobSheet(true)} @@ -120,16 +71,6 @@ export const CronJobsListing = () => { /> - setCronJobForDeletion(undefined)} - cronJob={cronJobForDeletion!} - /> - setShowEnableExtensionModal(false)} - /> ) } diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronSyntaxChart.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronSyntaxChart.tsx new file mode 100644 index 00000000000..30d4c97ac8b --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronSyntaxChart.tsx @@ -0,0 +1,17 @@ +export default function CronSyntaxChart() { + return ( +
+
+        {/* prettier-ignore */}
+        {`
+ ┌───────────── minute (0 - 59)
+ │  ┌───────────── hour (0 - 23)
+ │  │  ┌───────────── day of month (1 - 31)
+ │  │  │  ┌───────────── month (1 - 12)
+ │  │  │  │  ┌───────────── day of week (0 - 7)
+ │  │  │  │  │
+ *  *  *  *  * `}
+      
+
+ ) +} diff --git a/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx b/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx new file mode 100644 index 00000000000..41622dc9eea --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx @@ -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 +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({ + 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 = 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 ( + <> +
+ + Create a new queue + + +
+ + + + ( + + + + + + )} + /> + + + + ( + + + + {QUEUE_TYPES.map((definition) => ( + +
+
{definition.icon}
+
+
+

{definition.label}

+
+

+ {definition.description} +

+
+
+ {definition.value === 'partitioned' ? ( +
+ COMING SOON +
+ ) : null} + {/* {!pgPartmanExtensionInstalled && + definition.value === 'partitioned' ? ( +
+ + + pg_partman needs to be installed to use this type + +
+ ) : null} */} +
+ ))} +
+
+
+ )} + /> + {/* {!pgPartmanExtensionInstalled && ( + + Enable pg_partman for partitioned + queues + + } + description={ +
+ + This will allow you to create partitioned queues which can handle a large + amount of messages + + setShowEnableExtensionModal(true)} + tooltip={{ + content: { + side: 'bottom', + text: 'You need additional permissions to enable database extensions', + }, + }} + > + Install pg_partman extension + +
+ } + /> + )} */} +
+ + {queueType === 'partitioned' && ( + <> + + ( + + ms

} + /> +
+ )} + /> + ( + + ms

} + /> +
+ )} + /> +
+ + )} + +
+
+ + + + +
+ setIsClosing(false)} + onConfirm={() => onClose()} + > +

+ There are unsaved changes. Are you sure you want to close the panel? Your changes will be + lost. +

+
+ {pgPartmanExtension && ( + setShowEnableExtensionModal(false)} + /> + )} + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Queues/Queues.constants.tsx b/apps/studio/components/interfaces/Integrations/Queues/Queues.constants.tsx new file mode 100644 index 00000000000..a80a56be71e --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Queues/Queues.constants.tsx @@ -0,0 +1,23 @@ +import { Rows2, Rows3, Rows4 } from 'lucide-react' + +export const QUEUE_TYPES = [ + { + value: 'basic', + icon: , + label: 'Basic queue', + description: 'Create a basic queue.', + }, + { + value: 'unlogged', + icon: , + 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: , + label: 'Partitioned queue', + description: 'Create a partitioned queue which is optimized for large amount of messages', + }, +] as const diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuesDisabledState.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuesDisabledState.tsx new file mode 100644 index 00000000000..5de88c73820 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Queues/QueuesDisabledState.tsx @@ -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 ( + <> +
+ +
+

+ Message queues in PostgreSQL allow you to handle asynchronous tasks, manage workloads, + and enable reliable communication between different parts of your application. +

+

Enable queues in your project.

+
+ +
+ + + + + {!canToggleExtensions && ( + + + +
+ + You need additional permissions to enable pgmq for this project + +
+
+
+ )} +
+ +
+
+
+ + setShowEnableExtensionModal(false)} + /> + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuesListing.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuesListing.tsx new file mode 100644 index 00000000000..eba0a0779b3 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Queues/QueuesListing.tsx @@ -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 + if (!pgmqExtensionInstalled) return + if (isLoading) return + if (isError) return + + return ( + <> +
+ + {queues.length === 0 ? ( +
+

No queues created yet

+ +
+ ) : ( +
+
+ } + value={searchQuery || ''} + className="w-64" + onChange={(e) => setSearchQuery(e.target.value)} + /> + + +
+ + + Name + + Type + + + Created at + + +
Size
+
+ + + } + body={} + /> + + )} + + + setIsClosingCreateQueueSheet(true)}> + + { + setIsClosingCreateQueueSheet(false) + setCreateQueueSheetShown(false) + }} + isClosing={isClosingCreateQueueSheet} + setIsClosing={setIsClosingCreateQueueSheet} + /> + + + {pgmqExtension ? ( + setShowEnableExtensionModal(false)} + /> + ) : null} + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuesRows.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuesRows.tsx new file mode 100644 index 00000000000..629b0347d9f --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Queues/QueuesRows.tsx @@ -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 ( + { + router.push(`/project/${selectedProject?.ref}/integrations/queues/${queue.queue_name}`) + }} + className="hover:" + > + +

{queue.queue_name}

+
+ +

+ {type} +

+
+ +

{dayjs(queue.created_at).format(DATETIME_FORMAT)}

+
+ +
+ {isLoading ? ( + + ) : ( +

+ {metrics?.queue_length} {metrics?.method === 'estimated' ? '(Approximate)' : null} +

+ )} +
+
+ +
+ +
+
+
+ ) +} + +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 ( + + +

No results found

+

+ Your search for "{filterString}" did not return any results +

+
+
+ ) + } + + return ( + <> + {queues.map((q) => ( + + ))} + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue.tsx new file mode 100644 index 00000000000..20c9e6812f8 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue.tsx @@ -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 ( + onClose()} + onConfirm={handleDelete} + title="Delete this queue" + loading={isLoading} + confirmLabel={`Delete queue ${queueName}`} + confirmPlaceholder="Type in name of queue" + confirmString={queueName ?? 'Unknown'} + text={ + <> + This will delete the queue{' '} + {queueName} + + } + alert={{ title: 'You cannot recover this queue and its messages once deleted.' }} + /> + ) +} + +export default DeleteQueue diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/MessageDetailsPanel.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/MessageDetailsPanel.tsx new file mode 100644 index 00000000000..9de86466dec --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/MessageDetailsPanel.tsx @@ -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 ( + setSelectedMessage(null)} + className="bg-studio border-t pointer-events-auto" + > + + + + ), + }} + /> + + + {selectedMessage && ( + <> + + + + )} + + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueFilters.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueFilters.tsx new file mode 100644 index 00000000000..2fd77da9157 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueFilters.tsx @@ -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 ( +
+
+ setSelectedTypes(value as any)} + /> +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal.tsx new file mode 100644 index 00000000000..26fb2c27c1b --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal.tsx @@ -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 + +const FORM_ID = 'QUEUES_SEND_MESSAGE_FORM' + +export const SendMessageModal = ({ visible, onClose }: SendMessageModalProps) => { + const { name: queueName } = useParams() + const { project } = useProjectContext() + const form = useForm({ + 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 = (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 ( + { + const values = form.getValues() + onSubmit(values) + }} + > + + +
+ ( + + + sec

} + /> +
+
+ )} + /> + ( + + + field.onChange(e)} + options={{ wordWrap: 'off', contextmenu: false }} + value={field.value} + /> + + + )} + /> + +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx b/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx index 8a0865d6e8e..8f016a3ff55 100644 --- a/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx +++ b/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx @@ -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 ( - - Your organization has a log retention period of{' '} - - {retentionPeriod} day - {retentionPeriod > 1 ? 's' : ''} - - . You may only view logs from{' '} - {dayjs().subtract(retentionPeriod, 'days').format('DD MMM YYYY')} as the - earliest date. - + +
+ Your organization has a log retention period of{' '} + + {retentionPeriod} day + {retentionPeriod > 1 ? 's' : ''} + + . You may only view logs from{' '} + {dayjs().subtract(retentionPeriod, 'days').format('DD MMM YYYY')} as the + earliest date. +
+
) }} /> @@ -284,37 +290,24 @@ const AuditLogs = () => {

Date

- - -
, , diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx index b22f205965e..19ddf0ca50a 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx @@ -69,17 +69,12 @@ const BillingBreakdown = () => { {invoiceFeatureEnabled && ( <> -

+

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{' '} - - usage page - {' '} - for a more detailed usage breakdown. + usage page for a more detailed usage + breakdown.

diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx index 2bfef53e189..edca29e3ee6 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx @@ -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={ { const InvoiceTooltip = ({ text, linkRef }: { text: string; linkRef?: string }) => { return ( - - + + - - - - -
- -

- {text}{' '} - {linkRef && ( - - Read more - - )} -

-
-
-
-
-
+ + +

+ {text}{' '} + {linkRef && ( + + Read more + + )} +

+
+ ) } diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/EnterpriseCard.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/EnterpriseCard.tsx index a6e88d470dc..5f6e77def49 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/EnterpriseCard.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/EnterpriseCard.tsx @@ -1,8 +1,8 @@ -import clsx from 'clsx' import { Check } from 'lucide-react' + import { PricingInformation } from 'shared-data' import { pickFeatures } from 'shared-data/plans' -import { Button } from 'ui' +import { Button, cn } from 'ui' export interface EnterpriseCardProps { plan: PricingInformation @@ -16,14 +16,14 @@ const EnterpriseCard = ({ plan, isCurrentPlan, billingPartner }: EnterpriseCardP return (
-

{plan.name}

+

{plan.name}

{isCurrentPlan ? (
Current plan diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx index 080ad8e8767..f62c0a34626 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx @@ -54,9 +54,6 @@ const ExitSurveyModal = ({ visible, subscription, projects, onClose }: ExitSurve const hasProjectsWithComputeDowngrade = projectsWithComputeDowngrade.length > 0 - const willPlanDowngradeHappenImmediately = - subscription?.billing_via_partner === false || subscription?.billing_partner !== 'fly' - function reducer(state: any, action: any) { if (includes(state, action.target.value)) { return without(state, action.target.value) @@ -105,11 +102,9 @@ const ExitSurveyModal = ({ visible, subscription, projects, onClose }: ExitSurve // [Joshen] In this case we don't raise any errors if the exit survey fails to send since it shouldn't block the user } finally { toast.success( - willPlanDowngradeHappenImmediately - ? hasProjectsWithComputeDowngrade - ? 'Successfully downgraded organization to the Free Plan. Your projects are currently restarting to update their compute instances.' - : 'Successfully downgraded organization to the Free Plan' - : 'Your organization is scheduled for the downgrade at the end of your current billing cycle', + hasProjectsWithComputeDowngrade + ? 'Successfully downgraded organization to the Free Plan. Your projects are currently restarting to update their compute instances.' + : 'Successfully downgraded organization to the Free Plan', { duration: hasProjectsWithComputeDowngrade ? 8000 : 4000 } ) onClose(true) @@ -199,7 +194,7 @@ const ExitSurveyModal = ({ visible, subscription, projects, onClose }: ExitSurve This is due to changes in compute instances from the downgrade. Affected projects include {projectsWithComputeDowngrade.map((project) => project.name).join(', ')}. @@ -209,11 +204,11 @@ const ExitSurveyModal = ({ visible, subscription, projects, onClose }: ExitSurve
- {willPlanDowngradeHappenImmediately && ( -

- The unused amount for the remaining of your billing cycle will be refunded as credits -

- )} +

+ The unused amount for the remaining time of your billing cycle will be refunded as + credits +

+
+
- -
- - Templates - - - Quickstarts - -
- - + + Alphabetical + + + Created At + + + + + + +
-
- +
+ + Templates + + + Quickstarts +
+ +
- + +
+ +
+
) } diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx index 16d3174d5ac..9562476804d 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx @@ -1,4 +1,4 @@ -import { ChevronRight, Eye, EyeOffIcon, Heart, Unlock } from 'lucide-react' +import { Eye, EyeOffIcon, Heart, Unlock } from 'lucide-react' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import { toast } from 'sonner' @@ -31,18 +31,15 @@ import { useSnippets, useSqlEditorV2StateSnapshot, } from 'state/sql-editor-v2' +import { Separator, Skeleton, TreeView } from 'ui' import { - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, - Alert_Shadcn_, - CollapsibleContent_Shadcn_, - CollapsibleTrigger_Shadcn_, - Collapsible_Shadcn_, - Separator, - TreeView, -} from 'ui' + InnerSideBarEmptyPanel, + InnerSideMenuCollapsible, + InnerSideMenuCollapsibleContent, + InnerSideMenuCollapsibleTrigger, + InnerSideMenuSeparator, +} from 'ui-patterns' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { ROOT_NODE, formatFolderResponseForTreeView } from './SQLEditorNav.utils' import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem' @@ -75,11 +72,6 @@ export const SQLEditorNav = ({ searchText: _searchText }: SQLEditorNavProps) => const [selectedSnippetToDownload, setSelectedSnippetToDownload] = useState() const [selectedFolderToDelete, setSelectedFolderToDelete] = useState() - const COLLAPSIBLE_TRIGGER_CLASS_NAMES = - 'flex items-center gap-x-2 px-4 [&[data-state=open]>svg]:!rotate-90' - const COLLAPSIBLE_ICON_CLASS_NAMES = 'text-foreground-light transition-transform duration-200' - const COLLASIBLE_HEADER_CLASS_NAMES = 'text-foreground-light font-mono text-sm uppercase' - // ======================================================= // [Joshen] Set up favorites, shared, and private snippets // ======================================================= @@ -341,27 +333,24 @@ export const SQLEditorNav = ({ searchText: _searchText }: SQLEditorNavProps) => return ( <> - - + {((numProjectSnippets === 0 && searchText.length === 0) || numProjectSnippets > 0) && ( <> - - - - - Shared{numProjectSnippets > 0 && ` (${numProjectSnippets})`} - - - + + 0 ? ` (${numProjectSnippets})` : ''}`} + /> + {numProjectSnippets === 0 ? ( -
- - No shared queries - - Share queries with your team by right-clicking on the query. - - -
+ ) : ( )} /> )} -
-
- + + + )} {((numFavoriteSnippets === 0 && searchText.length === 0) || numFavoriteSnippets > 0) && ( <> - - - - - Favorites{numFavoriteSnippets > 0 && ` (${numFavoriteSnippets})`} - - - + + 0 ? ` (${numFavoriteSnippets})` : ''}`} + /> + {numFavoriteSnippets === 0 ? ( -
- - No favorite queries - + Save a query to favorites for easy accessbility by clicking the{' '} {' '} icon. - - -
+ + } + /> ) : ( )} /> )} -
-
- + + + )} - - - - - PRIVATE - {numPrivateSnippets > 0 && ` (${numPrivateSnippets})`} - - - + + 0 ? ` (${numPrivateSnippets})` : ''}`} + /> + {!snapV2.loaded[projectRef as string] ? ( -
- -
+ <> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ ) : folders.length === 0 && numPrivateSnippets === 0 ? ( -
- - No queries created yet - - Queries will be automatically saved once you start writing in the editor. - - -
+ ) : ( )} /> )} -
-
+ + diff --git a/apps/studio/components/layouts/SettingsLayout/SettingsMenuItem.tsx b/apps/studio/components/layouts/SettingsLayout/SettingsMenuItem.tsx index c79f5b56f4b..ce68295607d 100644 --- a/apps/studio/components/layouts/SettingsLayout/SettingsMenuItem.tsx +++ b/apps/studio/components/layouts/SettingsLayout/SettingsMenuItem.tsx @@ -1,6 +1,7 @@ -import clsx from 'clsx' import Link from 'next/link' +import { cn } from 'ui' + interface SettingsMenuItemProps { href: string label: string @@ -12,7 +13,7 @@ const SettingsMenuItem = ({ href, label, isActive }: SettingsMenuItemProps) => {
{ const { ref: projectRef } = useParams() @@ -54,18 +62,30 @@ const StorageSettings = () => { }) const isFreeTier = isSuccessSubscription && subscription.plan.id === 'free' - const [initialValues, setInitialValues] = useState({ + const [initialValues, setInitialValues] = useState({ fileSizeLimit: 0, unit: StorageSizeUnits.BYTES, + imageTransformationEnabled: !isFreeTier, }) useEffect(() => { if (isSuccess && config) { - const { fileSizeLimit } = config + const { fileSizeLimit, features } = config const { value, unit } = convertFromBytes(fileSizeLimit ?? 0) - setInitialValues({ fileSizeLimit: value, unit: unit }) + const imageTransformationEnabled = features?.imageTransformation?.enabled ?? !isFreeTier + + setInitialValues({ + fileSizeLimit: value, + unit: unit, + imageTransformationEnabled, + }) + // Reset the form values when the config values load - form.reset({ fileSizeLimit: value, unit: unit }) + form.reset({ + fileSizeLimit: value, + unit: unit, + imageTransformationEnabled, + }) } }, [isSuccess, config]) @@ -77,6 +97,7 @@ const StorageSettings = () => { .object({ fileSizeLimit: z.coerce.number(), unit: z.nativeEnum(StorageSizeUnits), + imageTransformationEnabled: z.boolean(), }) .superRefine((data, ctx) => { const { unit, fileSizeLimit } = data @@ -95,7 +116,7 @@ const StorageSettings = () => { resolver: zodResolver(FormSchema), defaultValues: initialValues, }) - const { fileSizeLimit: limit, unit: storageUnit } = form.watch() + const { fileSizeLimit: limit, unit: storageUnit, imageTransformationEnabled } = form.watch() const { mutate: updateStorageConfig, isLoading: isUpdating } = useProjectStorageConfigUpdateUpdateMutation({ @@ -109,6 +130,11 @@ const StorageSettings = () => { updateStorageConfig({ projectRef, fileSizeLimit: convertToBytes(data.fileSizeLimit, data.unit), + features: { + imageTransformation: { + enabled: data.imageTransformationEnabled, + }, + }, }) } @@ -205,7 +231,31 @@ const StorageSettings = () => {

+ +
+
+

Enable Image Transformation

+
+
+
+ ( + + )} + /> +
+ +
+
+ {isFreeTier && (
(null) const bottomRef = useRef(null) @@ -102,7 +103,6 @@ export const AIAssistant = ({ SupportedAssistantEntities | '' >('') const [selectedSchemas, setSelectedSchemas] = useSchemasForAi(project?.ref!) - const [selectedTables, setSelectedTables] = useState<{ schema: string; name: string }[]>([]) const [contextHistory, setContextHistory] = useState<{ [key: string]: { entity: string; schemas: string[]; tables: string[] } }>({}) @@ -121,6 +121,19 @@ export const AIAssistant = ({ const { data: check } = useCheckOpenAIKeyQuery() const isApiKeySet = IS_PLATFORM || !!check?.hasKey + const { data: policies } = useDatabasePoliciesQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + }, + { enabled: editor === 'rls-policies' } + ) + const existingPolicies = (policies ?? []) + .filter((policy) => + selectedTables.some((x) => policy.schema === x.schema && policy.table === x.name) + ) + .sort((a, b) => a.name.localeCompare(b.name)) + const { data: existingDefinition } = useEntityDefinitionQuery({ id: entity?.id, type: editor, @@ -165,7 +178,12 @@ export const AIAssistant = ({ } = useChat({ id, api: `${BASE_PATH}/api/ai/sql/generate-v2`, - body: { entityDefinitions, context: selectedDatabaseEntity, existingSql: existingDefinition }, + body: { + entityDefinitions, + context: selectedDatabaseEntity, + existingSql: existingDefinition, + existingPolicies, + }, onError: (error) => setAssistantError(JSON.parse(error.message).error), }) @@ -219,12 +237,14 @@ export const AIAssistant = ({ const toggleEntity = ({ schema, name }: { schema: string; name: string }) => { const isExisting = selectedTables.find((x) => x.schema === schema && x.name === name) if (isExisting) { - setSelectedTables(selectedTables.filter((x) => !(x.schema === schema && x.name === name))) + setAiAssistantPanel({ + tables: selectedTables.filter((x) => !(x.schema === schema && x.name === name)), + }) } else { const newselectedTables = [...selectedTables, { schema, name }].sort( (a, b) => a.schema.localeCompare(b.schema) || a.name.localeCompare(b.name) ) - setSelectedTables(newselectedTables) + setAiAssistantPanel({ tables: newselectedTables }) sendTelemetryEvent(TELEMETRY_ACTIONS.TABLE_CONTEXT_ADDED) } } @@ -435,19 +455,28 @@ export const AIAssistant = ({ transition={{ duration: ANIMATION_DURATION }} >

- How can I help you - {!!entityContext ? ( + {entity !== undefined && !!entityContext ? ( <> - {' '} - with{' '} - - {entityContext.id === 'rls-policies' - ? entityContext.label - : `Database ${entityContext.label}`} - + Need help with updating this{' '} + {entityContext.name} ) : ( - ' today' + <> + How can I help you + {!!entityContext ? ( + <> + {' '} + with{' '} + + {entityContext.id === 'rls-policies' + ? entityContext.label + : `Database ${entityContext.label}`} + + + ) : ( + ' today' + )} + )} ?

@@ -582,7 +611,7 @@ export const AIAssistant = ({ value={`${selectedSchemas.slice(0, 2).join(', ')}${selectedSchemas.length > 2 ? ` and ${selectedSchemas.length - 2} other${selectedSchemas.length > 3 ? 's' : ''}` : ''}`} onRemove={() => { setSelectedSchemas([]) - setSelectedTables([]) + setAiAssistantPanel({ tables: [] }) }} tooltip={ selectedSchemas.length > 2 ? ( @@ -610,7 +639,7 @@ export const AIAssistant = ({ .join( ', ' )}${selectedTables.length > 2 ? ` and ${selectedTables.length - 2} other${selectedTables.length > 3 ? 's' : ''}` : ''}`} - onRemove={() => setSelectedTables([])} + onRemove={() => setAiAssistantPanel({ tables: [] })} tooltip={ selectedTables.length > 2 ? ( <> diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts index 10285b026b6..e18d6b42af8 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts @@ -76,7 +76,8 @@ ${existingDefinition ` } case 'rls-policies': - return `${PLACEHOLDER_PREFIX} + if (entity === undefined) { + return `${PLACEHOLDER_PREFIX} CREATE POLICY *name* ON *table_name*\n AS PERMISSIVE -- PERMISSIVE | RESTRICTIVE\n FOR ALL -- ALL | SELECT | INSERT | UPDATE | DELETE\n @@ -84,6 +85,33 @@ TO *role_name* -- Default: public\n USING ( *using_expression* )\n WITH CHECK ( *check_expression* ); ` + } else { + let expression = '' + if (entity.definition !== null && entity.definition !== undefined) { + expression += `USING ( *${entity.definition}* )${ + entity.check === null || entity.check === undefined ? ';' : '' + }\n` + } + if (entity.check !== null && entity.check !== undefined) { + expression += `WITH CHECK ( *${entity.check}* );\n` + } + return `${PLACEHOLDER_PREFIX} +BEGIN;\n + \n +-- To update your policy definition\n +ALTER POLICY "${entity.name}"\n +ON "${entity.schema}"."${entity.table}"\n +TO *${(entity.roles ?? []).join(', ')}*\n +${expression} + \n +-- To rename the policy\n +ALTER POLICY "${entity.name}"\n +ON "${entity.schema}"."${entity.table}"\n +RENAME TO "*New Policy Name*";\n + \n +COMMIT; +` + } default: return undefined } @@ -111,7 +139,7 @@ export const validateQuery = (editor: SupportedAssistantEntities | null, query: formattedQuery.includes('create or replace function') ) case 'rls-policies': - return formattedQuery.includes('create policy') + return formattedQuery.includes('create policy') || formattedQuery.includes('alter policy') default: return true } @@ -126,7 +154,7 @@ export const generatePrompt = ({ type: SupportedAssistantQuickPromptTypes context: SupportedAssistantEntities schemas: string[] - tables: { schema: string; name: string }[] + tables: readonly { schema: string; name: string }[] }) => { if (type === 'examples') { return `What are some common examples of user-defined database ${context}? ${PLACEHOLDER_LIMIT}` diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx index 937e55bdce5..7684c993774 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx @@ -16,8 +16,9 @@ import { suffixWithLimit } from 'components/interfaces/SQLEditor/SQLEditor.utils import Results from 'components/interfaces/SQLEditor/UtilityPanel/Results' import { useSqlDebugMutation } from 'data/ai/sql-debug-mutation' import { databasePoliciesKeys } from 'data/database-policies/keys' +import { useEntityDefinitionQuery } from 'data/database/entity-definition-query' +import { databaseKeys } from 'data/database/keys' import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' -import { sqlKeys } from 'data/sql/keys' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { usePrevious } from 'hooks/deprecated' import { useLocalStorage } from 'hooks/misc/useLocalStorage' @@ -48,7 +49,6 @@ import CodeEditor from '../CodeEditor/CodeEditor' import { AIAssistant } from './AIAssistant' import { generateCTA, generatePlaceholder, generateTitle, validateQuery } from './AIAssistant.utils' import { ASSISTANT_SUPPORT_ENTITIES } from './AiAssistant.constants' -import { useEntityDefinitionQuery } from 'data/database/entity-definition-query' export const AiAssistantPanel = () => { const os = detectOS() @@ -61,6 +61,7 @@ export const AiAssistantPanel = () => { const { open, editor, content, entity } = aiAssistantPanel const previousEditor = usePrevious(editor) + const previousEntity = usePrevious(entity) const [isAcknowledged, setIsAcknowledged] = useLocalStorage( LOCAL_STORAGE_KEYS.SQL_SCRATCH_PAD_BANNER_ACKNOWLEDGED, @@ -108,18 +109,20 @@ export const AiAssistantPanel = () => { onSuccess: async (res) => { // [Joshen] If in a specific editor context mode, assume that intent was to create/update // a database entity - so close it once success. Otherwise it's in Quick SQL mode and we - // show the results. Currently though it assumes we're "creating", thinking need to support "updating" too + // show the results. if (editor !== null) { switch (editor) { case 'functions': - await queryClient.invalidateQueries(sqlKeys.query(ref, ['functions-list'])) + await queryClient.invalidateQueries(databaseKeys.databaseFunctions(ref)) break case 'rls-policies': await queryClient.invalidateQueries(databasePoliciesKeys.list(ref)) break } - toast.success(`Successfully created ${entityContext?.name}!`) + toast.success( + `Successfully ${entity === undefined ? 'created' : 'updated'} ${entityContext?.name}!` + ) setAiAssistantPanel({ open: false }) } else { setShowResults(true) @@ -257,8 +260,8 @@ export const AiAssistantPanel = () => { useEffect(() => { if (open) { - // [Joshen] Only reset the assistant if the editor changed - if (previousEditor !== editor) { + // [Joshen] Only reset the assistant if the editor changed or if the provided entity has changed + if (previousEditor !== editor || previousEntity !== entity) { setChatId(uuidv4()) setError(undefined) setShowWarning(false) diff --git a/apps/studio/components/ui/AIAssistantPanel/EntitiesDropdownMenu.tsx b/apps/studio/components/ui/AIAssistantPanel/EntitiesDropdownMenu.tsx index d301fb5814e..f2120c27ca3 100644 --- a/apps/studio/components/ui/AIAssistantPanel/EntitiesDropdownMenu.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/EntitiesDropdownMenu.tsx @@ -1,23 +1,23 @@ -import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { debounce } from 'lodash' import { Check, Loader2, Search } from 'lucide-react' import { useCallback, useState } from 'react' + +import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { Command_Shadcn_, CommandEmpty_Shadcn_, CommandGroup_Shadcn_, CommandItem_Shadcn_, CommandList_Shadcn_, - ScrollArea, } from 'ui' +import { Admonition } from 'ui-patterns' import { Input } from 'ui-patterns/DataInputs/Input' import { DropdownScrollArea } from './DropdownScrollArea' -import { Admonition } from 'ui-patterns' interface EntitiesDropdownMenuProps { selectedSchemas: string[] - selectedEntities: { schema: string; name: string }[] + selectedEntities: readonly { schema: string; name: string }[] onToggleEntity: (value: { schema: string; name: string }) => void } diff --git a/apps/studio/components/ui/CardButton.tsx b/apps/studio/components/ui/CardButton.tsx index ab15ba26513..ee56413b87d 100644 --- a/apps/studio/components/ui/CardButton.tsx +++ b/apps/studio/components/ui/CardButton.tsx @@ -1,6 +1,7 @@ -import { Loader, ChevronRight } from 'lucide-react' +import { ChevronRight, Loader } from 'lucide-react' import Link from 'next/link' -import React, { PropsWithChildren } from 'react' +import React, { cloneElement, PropsWithChildren } from 'react' + import { cn } from 'ui' interface CardButtonProps { @@ -18,6 +19,7 @@ interface CardButtonProps { fixedHeight?: boolean hideChevron?: boolean titleClass?: string + containerElement?: React.ReactNode } // Define separate interfaces for each type of container @@ -56,6 +58,7 @@ const CardButton = ({ fixedHeight = true, hideChevron = false, titleClass = '', + containerElement, ...props }: PropsWithChildren) => { const isLink = url || linkHref || props.onClick @@ -63,23 +66,28 @@ const CardButton = ({ let Container: React.ElementType let containerProps: ContainerProps = {} + const ContainerComponentOverride = + containerElement && React.isValidElement(containerElement) + ? (props: any) => cloneElement(containerElement, { ...props }) + : undefined + if (props.onClick) { - Container = 'button' + Container = ContainerComponentOverride ?? 'button' containerProps = props } else if (linkHref) { - Container = Link + Container = ContainerComponentOverride ?? Link containerProps = { href: linkHref, ...props, } } else if (url) { - Container = 'a' + Container = ContainerComponentOverride ?? 'a' containerProps = { href: url, ...props, } } else { - Container = 'div' + Container = ContainerComponentOverride ?? 'div' containerProps = props } diff --git a/apps/studio/components/ui/FilterPopover.tsx b/apps/studio/components/ui/FilterPopover.tsx index f47c47d1f8d..f3448d2880d 100644 --- a/apps/studio/components/ui/FilterPopover.tsx +++ b/apps/studio/components/ui/FilterPopover.tsx @@ -10,12 +10,12 @@ import { ScrollArea, } from 'ui' -interface FilterPopoverProps { +interface FilterPopoverProps { title?: string - options: any[] - activeOptions: any[] - valueKey: string - labelKey: string + options: T[] + activeOptions: string[] + valueKey: keyof T + labelKey: keyof T iconKey?: string name: string variant?: 'rectangular' | 'rounded' @@ -27,7 +27,7 @@ interface FilterPopoverProps { onSaveFilters: (options: string[]) => void } -export const FilterPopover = ({ +export const FilterPopover = >({ title, options = [], activeOptions = [], @@ -42,7 +42,7 @@ export const FilterPopover = ({ maxHeightClass = 'h-[205px]', clearButtonText = 'Clear', onSaveFilters, -}: FilterPopoverProps) => { +}: FilterPopoverProps) => { const [open, setOpen] = useState(false) const [selectedOptions, setSelectedOptions] = useState([]) @@ -140,7 +140,7 @@ export const FilterPopover = ({ type="primary" onClick={() => { // Order the selection based on the options provided - const sortingOrder = options.map((option) => option[valueKey]) + const sortingOrder = options.map((option) => option[valueKey]) as string[] const sortedSelection = selectedOptions.sort( (a, b) => sortingOrder.indexOf(a) - sortingOrder.indexOf(b) ) diff --git a/apps/studio/components/ui/PageTelemetry.tsx b/apps/studio/components/ui/PageTelemetry.tsx index dbad4b6cfe6..7bf41bf7844 100644 --- a/apps/studio/components/ui/PageTelemetry.tsx +++ b/apps/studio/components/ui/PageTelemetry.tsx @@ -1,4 +1,3 @@ -import { Sha256 } from '@aws-crypto/sha256-browser' import * as Sentry from '@sentry/nextjs' import { useRouter } from 'next/router' import { PropsWithChildren, useEffect } from 'react' @@ -15,12 +14,13 @@ import { useAppStateSnapshot } from 'state/app-state' import { useConsent } from 'ui-patterns/ConsentToast' const getAnonId = async (id: string) => { - const hash = new Sha256() - hash.update(id) - const u8Array = await hash.digest() - const binString = Array.from(u8Array, (byte) => String.fromCodePoint(byte)).join('') - const b64encoded = btoa(binString) - return b64encoded + const encoder = new TextEncoder() + const data = encoder.encode(id) + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const base64String = btoa(hashArray.map((byte) => String.fromCharCode(byte)).join('')) + + return base64String } const PageTelemetry = ({ children }: PropsWithChildren<{}>) => { diff --git a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx index 9cf249a7ca3..c45e703792a 100644 --- a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx +++ b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx @@ -121,7 +121,7 @@ const DisplayApiSettings = ({ legacy }: { legacy?: boolean }) => { onChange={() => {}} descriptionText={ x.tags === 'service_role' - ? 'This key has the ability to bypass Row Level Security. Never share it publicly. ' + + ? 'This key has the ability to bypass Row Level Security. Never share it publicly. If leaked, generate a new JWT secret immediately. ' + (legacy ? 'Prefer using Publishable API keys instead.' : '') : 'This key is safe to use in a browser if you have enabled Row Level Security for your tables and configured policies. ' + (legacy ? 'Prefer using Secret API keys instead.' : '') diff --git a/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.constants.ts b/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.constants.ts index c4a8540e494..fc10cb76512 100644 --- a/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.constants.ts +++ b/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.constants.ts @@ -43,7 +43,7 @@ export const RESOURCE_WARNING_MESSAGES: Record = }, }, docsUrl: 'https://supabase.com/docs/guides/platform/database-size#disabling-read-only-mode', - buttonText: 'View database settings', + buttonText: 'View Compute and Disk', metric: 'read_only', }, disk_io_exhaustion: { diff --git a/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.tsx b/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.tsx index 01a303471c0..bf79befa704 100644 --- a/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.tsx +++ b/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.tsx @@ -78,8 +78,8 @@ const ResourceExhaustionWarningBanner = () => { const correctionUrlVariants = { undefined: undefined, null: '/project/[ref]/settings/[infra-path]', - disk_space: '/project/[ref]/settings/database', - read_only: '/project/[ref]/settings/database', + disk_space: '/project/[ref]/settings/compute-and-disk', + read_only: '/project/[ref]/settings/compute-and-disk', auth_email_rate_limit: '/project/[ref]/settings/auth', auth_restricted_email_sending: '/project/[ref]/settings/auth', default: (metric: string) => `/project/[ref]/settings/[infra-path]#${metric}`, @@ -114,7 +114,7 @@ const ResourceExhaustionWarningBanner = () => { const onUsageOrInfraAndNotInReadOnlyMode = isUsageOrInfraPage && !activeWarnings.includes('is_readonly_mode_enabled') const onDatabaseSettingsAndInReadOnlyMode = - router.pathname.endsWith('settings/database') && + router.pathname.endsWith('settings/compute-and-disk') && activeWarnings.includes('is_readonly_mode_enabled') // these take precedence over each other, so there's only one active warning to check @@ -149,7 +149,9 @@ const ResourceExhaustionWarningBanner = () => { onUsageOrInfraAndNotInReadOnlyMode || onDatabaseSettingsAndInReadOnlyMode || !isVisible || - bannerAcknowledged + (router.pathname.includes('/auth/') && + !!projectResourceWarnings?.auth_restricted_email_sending && + bannerAcknowledged) ) { return null } diff --git a/apps/studio/components/ui/SparkBar.tsx b/apps/studio/components/ui/SparkBar.tsx index 41352b4b831..a6334e44742 100644 --- a/apps/studio/components/ui/SparkBar.tsx +++ b/apps/studio/components/ui/SparkBar.tsx @@ -1,4 +1,4 @@ -import clsx from 'clsx' +import { cn } from 'ui' interface SparkBarProps { value: number @@ -35,7 +35,7 @@ const SparkBar = ({ {hasLabels && (

0 && 'max-w-[75%]', labelBottomClass @@ -43,7 +43,7 @@ const SparkBar = ({ > {labelBottom}

-

{labelTop}

+

{labelTop}

)}
+ + + + + + + + + + + + + + + + + + @@ -150,15 +168,6 @@ Supabase 是一个[托管平台](https://supabase.com/dashboard)。你可以注 - - - - - - - - - @@ -177,15 +186,6 @@ Supabase 是一个[托管平台](https://supabase.com/dashboard)。你可以注 - - - - - - - - - diff --git a/package-lock.json b/package-lock.json index 2772cec9bae..75f96c23839 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1782,7 +1782,6 @@ "apps/studio": { "version": "0.0.9", "dependencies": { - "@aws-crypto/sha256-browser": "^3.0.0", "@dagrejs/dagre": "^1.0.4", "@graphiql/react": "^0.19.4", "@graphiql/toolkit": "^0.9.1", @@ -1812,7 +1811,6 @@ "ai": "^2.2.31", "ai-commands": "*", "awesome-debounce-promise": "^2.1.0", - "clsx": "^1.2.1", "common": "*", "common-tags": "^1.8.2", "config": "*", @@ -3670,6 +3668,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/ie11-detection/-/ie11-detection-3.0.0.tgz", "integrity": "sha512-341lBBkiY1DfDNKai/wXM3aujNBkXR7tq1URPQDL9wi3AUbI80NR74uF1TXHMm7po1AcnFk8iu2S2IeU/+/A+Q==", + "dev": true, "dependencies": { "tslib": "^1.11.1" } @@ -3677,12 +3676,14 @@ "node_modules/@aws-crypto/ie11-detection/node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true }, "node_modules/@aws-crypto/sha256-browser": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-3.0.0.tgz", "integrity": "sha512-8VLmW2B+gjFbU5uMeqtQM6Nj0/F1bro80xQXCW6CQBWgosFWXTx77aeOF5CAIAmbOK64SdMBJdNr6J41yP5mvQ==", + "dev": true, "dependencies": { "@aws-crypto/ie11-detection": "^3.0.0", "@aws-crypto/sha256-js": "^3.0.0", @@ -3697,12 +3698,14 @@ "node_modules/@aws-crypto/sha256-browser/node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true }, "node_modules/@aws-crypto/sha256-js": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-3.0.0.tgz", "integrity": "sha512-PnNN7os0+yd1XvXAy23CFOmTbMaDxgxXtTKHybrJ39Y8kGzBATgBFibWJKH6BhytLI/Zyszs87xCOBNyBig6vQ==", + "dev": true, "dependencies": { "@aws-crypto/util": "^3.0.0", "@aws-sdk/types": "^3.222.0", @@ -3712,12 +3715,14 @@ "node_modules/@aws-crypto/sha256-js/node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true }, "node_modules/@aws-crypto/supports-web-crypto": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-3.0.0.tgz", "integrity": "sha512-06hBdMwUAb2WFTuGG73LSC0wfPu93xWwo5vL2et9eymgmu3Id5vFAHBbajVWiGhPO37qcsdCap/FqXvJGJWPIg==", + "dev": true, "dependencies": { "tslib": "^1.11.1" } @@ -3725,12 +3730,14 @@ "node_modules/@aws-crypto/supports-web-crypto/node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true }, "node_modules/@aws-crypto/util": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-3.0.0.tgz", "integrity": "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==", + "dev": true, "dependencies": { "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-utf8-browser": "^3.0.0", @@ -3740,7 +3747,8 @@ "node_modules/@aws-crypto/util/node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true }, "node_modules/@aws-sdk/client-secrets-manager": { "version": "3.468.0", @@ -4180,6 +4188,7 @@ "version": "3.468.0", "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.468.0.tgz", "integrity": "sha512-rx/9uHI4inRbp2tw3Y4Ih4PNZkVj32h7WneSg3MVgVjAoVD5Zti9KhS5hkvsBxfgmQmg0AQbE+b1sy5WGAgntA==", + "dev": true, "dependencies": { "@smithy/types": "^2.7.0", "tslib": "^2.5.0" @@ -4206,6 +4215,7 @@ "version": "3.465.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.465.0.tgz", "integrity": "sha512-f+QNcWGswredzC1ExNAB/QzODlxwaTdXkNT5cvke2RLX8SFU5pYk6h4uCtWC0vWPELzOfMfloBrJefBzlarhsw==", + "dev": true, "dependencies": { "tslib": "^2.5.0" }, @@ -4252,6 +4262,7 @@ "version": "3.259.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.259.0.tgz", "integrity": "sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==", + "dev": true, "dependencies": { "tslib": "^2.3.1" } @@ -13151,14 +13162,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@rollup/plugin-commonjs/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/@rollup/pluginutils": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.2.tgz", @@ -14530,6 +14533,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.7.0.tgz", "integrity": "sha512-1OIFyhK+vOkMbu4aN2HZz/MomREkrAC/HqY5mlJMUJfGrPRwijJDTeiN8Rnj9zUaB8ogXAfIOtZrrgqZ4w7Wnw==", + "dev": true, "dependencies": { "tslib": "^2.5.0" }, @@ -20320,8 +20324,9 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "license": "MIT", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -33944,9 +33949,10 @@ } }, "node_modules/npm-run-all/node_modules/cross-spawn": { - "version": "6.0.5", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", "dev": true, - "license": "MIT", "dependencies": { "nice-try": "^1.0.4", "path-key": "^2.0.1", diff --git a/packages/ai-commands/edge.ts b/packages/ai-commands/edge.ts index d73e04b769f..51b414c885b 100644 --- a/packages/ai-commands/edge.ts +++ b/packages/ai-commands/edge.ts @@ -3,3 +3,4 @@ export * from './src/errors' export * from './src/docs' export * from './src/sql/rls' export * from './src/sql/chat' +export * from './src/sql/cron' diff --git a/packages/ai-commands/src/sql/chat.ts b/packages/ai-commands/src/sql/chat.ts index 75e4b02e978..426d9f8f617 100644 --- a/packages/ai-commands/src/sql/chat.ts +++ b/packages/ai-commands/src/sql/chat.ts @@ -43,6 +43,8 @@ export async function chatSql( // is definitely not optimal at all, but just to get an idea started const generateInstructionsBasedOnContext = () => { switch (context) { + // [Joshen] Sorry for the mess, there's duplicate logic here between this and rls.ts - i'm wondering what should be the best practice + // here? Do we (1) Put ALL logic into this file, or (2) we split each context into their own files? Latter might be cleaner? case 'rls-policies': return stripIndent` You're a Supabase Postgres expert in writing row level security policies. Your purpose is to diff --git a/packages/ai-commands/src/sql/cron.ts b/packages/ai-commands/src/sql/cron.ts new file mode 100644 index 00000000000..27df01ec0fd --- /dev/null +++ b/packages/ai-commands/src/sql/cron.ts @@ -0,0 +1,106 @@ +import type OpenAI from 'openai' +import { ContextLengthError } from '../errors' +import { jsonrepair } from 'jsonrepair' +import { codeBlock } from 'common-tags' + +// Responds to a natural language request for cron syntax. +export async function generateCron(openai: OpenAI, prompt: string) { + const initMessages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ + { + role: 'system', + content: codeBlock` + You are a cron syntax expert. Your purpose is to convert natural language time descriptions into valid cron expressions for pg_cron. + + Rules for responses: + - Output cron expressions in the 5-field format supported by pg_cron + - Do not provide any explanation of what the cron expression does + - Format output as markdown with the cron expression in a code block + - Do not ask for clarification if you need it. Just output the cron expression. + + Example input: "Every Monday at 3am" + Example output: + This cron expression runs every Monday at 3:00:00 AM: + \`\`\` + 0 3 * * 1 + \`\`\` + + + This cron expression runs every minute: + \`\`\` + * * * * * + \`\`\` + + Additional examples: + - Every minute: \`* * * * *\` + - Every 5 minutes: \`*/5 * * * *\` + - Every first of the month, at 00:00: \`0 0 1 * *\` + - Every night at midnight: \`0 0 * * *\` + - Every Monday at 2am: \`0 2 * * 1\` + + Field order: + - minute (0-59) + - hour (0-23) + - day (1-31) + - month (1-12) + - weekday (0-6, Sunday=0) + + Here is the user's prompt: + ${prompt} + `, + }, + ] + + const completionFunction = { + name: 'generateCronSyntax', + description: 'Generates a cron expression for a given natural language description.', + parameters: { + type: 'object', + properties: { + cron_expression: { + type: 'string', + description: 'The generated cron expression.', + }, + }, + required: ['cron_expression'], + }, + } + + try { + const response = await openai.chat.completions.create({ + model: 'gpt-4o-mini-2024-07-18', + messages: initMessages, + max_tokens: 1024, + temperature: 0, + stream: false, + tool_choice: { + type: 'function', + function: { + name: completionFunction.name, + }, + }, + tools: [ + { + type: 'function', + function: completionFunction, + }, + ], + }) + + // Parse the JSON string in arguments using jsonrepair + const functionArgs = response?.choices?.[0]?.message?.tool_calls?.[0]?.function?.arguments + if (!functionArgs) { + throw new Error('No cron expression generated') + } + + // Use jsonrepair before parsing + const repairedJsonString = jsonrepair(functionArgs) + const args = JSON.parse(repairedJsonString) + + return args.cron_expression + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'context_length_exceeded') { + throw new ContextLengthError() + } + throw error + } +} diff --git a/packages/ai-commands/src/sql/rls.ts b/packages/ai-commands/src/sql/rls.ts index 905bb276b67..dd9fbd23846 100644 --- a/packages/ai-commands/src/sql/rls.ts +++ b/packages/ai-commands/src/sql/rls.ts @@ -301,6 +301,8 @@ export async function chatRlsPolicy( content: codeBlock` Here is my policy definition for reference: ${definitionBlock} + + I'm requesting to update this policy instead so please opt to use "alter policy" instead of "create policy" where appropriate. `.trim(), }) } diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 6050797151f..e4922dc9677 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -959,6 +959,10 @@ export interface paths { /** Run project lints */ get: operations['ProjectRunLintsController_runProjectLints'] } + '/platform/projects/{ref}/service-versions': { + /** Gets service versions for a specific project */ + get: operations['ProjectServiceVersionsController_getServiceVersions'] + } '/platform/projects/{ref}/settings': { /** Gets project's settings */ get: operations['SettingsController_getProjectApi'] @@ -1205,6 +1209,10 @@ export interface paths { /** Creates a partner organization */ post: operations['AwsPartnerOrganizationsSystemController_createPartnerOrganization'] } + '/system/partner-organizations/{slug}': { + /** Converts an organization into a partner organization */ + put: operations['AwsPartnerOrganizationsSystemController_convertToPartnerOrganization'] + } '/system/projects': { /** Create a project */ post: operations['SystemProjectsController_createProject'] @@ -1225,6 +1233,20 @@ export interface paths { /** Allows a project to obtain temporary credentials. */ post: operations['AwsCredentialsController_getTemporaryCredentials'] } + '/system/projects/{ref}/disk': { + /** Get database disk attributes */ + get: operations['SystemProjectDiskController_getDisk'] + /** Modify database disk */ + post: operations['SystemProjectDiskController_modifyDisk'] + } + '/system/projects/{ref}/disk/util': { + /** Get disk utilization */ + get: operations['SystemProjectDiskController_getDiskUtilization'] + } + '/system/projects/{ref}/disk/volume_info': { + /** Get AWS disk volume information */ + get: operations['SystemProjectDiskController_getAwsDiskInfo'] + } '/system/projects/{ref}/functions': { /** * List all functions @@ -2095,6 +2117,12 @@ export interface paths { /** Updates project's Postgres config */ put: operations['v1-update-postgres-config'] } + '/v1/projects/{ref}/config/storage': { + /** Gets project's storage config */ + get: operations['v1-get-storage-config'] + /** Updates project's storage config */ + patch: operations['v1-update-storage-config'] + } '/v1/projects/{ref}/custom-hostname': { /** [Beta] Gets project's custom hostname config */ get: operations['v1-get-hostname-config'] @@ -2564,9 +2592,8 @@ export interface components { uri_allow_list: string | null } AuthHealthResponse: { - description: string - name: string - version: string + /** @enum {string} */ + name: 'GoTrue' } AutoApiService: { app: { @@ -2681,16 +2708,19 @@ export interface components { } BranchResetResponse: { message: string + workflow_run_id: string } BranchResponse: { created_at: string git_branch?: string id: string is_default: boolean + /** Format: int64 */ latest_check_run_id?: number name: string parent_project_ref: string persistent: boolean + /** Format: int32 */ pr_number?: number project_ref: string reset_on_push: boolean @@ -2728,6 +2758,9 @@ export interface components { /** @enum {string} */ privilege_type: 'ALL' | 'SELECT' | 'INSERT' | 'UPDATE' | 'REFERENCES' } + ConvertIntoAwsPartnerOrganizationBody: { + partner_billing: components['schemas']['AwsPartnerBillingBody'] + } CopyObjectBody: { from: string to: string @@ -3256,7 +3289,7 @@ export interface components { | '8_attached_volume_to_upgraded_instance' | '9_completed_upgrade' | '10_completed_post_physical_backup' - /** @enum {number} */ + /** @enum {integer} */ status: 0 | 1 | 2 target_version: number } @@ -3459,6 +3492,7 @@ export interface components { query: string } FunctionResponse: { + /** Format: int64 */ created_at: number entrypoint_path?: string id: string @@ -3468,11 +3502,13 @@ export interface components { slug: string /** @enum {string} */ status: 'ACTIVE' | 'REMOVED' | 'THROTTLED' + /** Format: int64 */ updated_at: number verify_jwt?: boolean version: number } FunctionSlugResponse: { + /** Format: int64 */ created_at: number entrypoint_path?: string id: string @@ -3482,6 +3518,7 @@ export interface components { slug: string /** @enum {string} */ status: 'ACTIVE' | 'REMOVED' | 'THROTTLED' + /** Format: int64 */ updated_at: number verify_jwt?: boolean version: number @@ -4205,6 +4242,7 @@ export interface components { } OAuthTokenResponse: { access_token: string + /** Format: int64 */ expires_in: number refresh_token: string /** @enum {string} */ @@ -4483,6 +4521,7 @@ export interface components { session_replication_role?: 'origin' | 'replica' | 'local' shared_buffers?: string statement_timeout?: string + track_commit_timestamp?: boolean wal_keep_size?: string wal_sender_timeout?: string work_mem?: string @@ -4796,10 +4835,6 @@ export interface components { | 'PAUSE_FAILED' | 'RESIZING' subscription_id: string - v2MaintenanceWindow: { - end?: string - start?: string - } volumeSizeGb?: number } ProjectInfo: { @@ -4927,10 +4962,9 @@ export interface components { cloud_provider: string db_dns_name: string db_host: string - /** @enum {string|null} */ - db_ip_addr_config: 'legacy' | 'static-ipv4' | 'concurrent-ipv6' | 'ipv6' | null + db_ip_addr_config: string db_name: string - db_port: string + db_port: number db_user: string inserted_at: string jwt_secret?: string @@ -5022,8 +5056,6 @@ export interface components { } RealtimeHealthResponse: { connected_cluster: number - db_connected: boolean - healthy: boolean } Relationship: { constraint_name: string @@ -5358,8 +5390,8 @@ export interface components { service_api_keys: components['schemas']['ServiceApiKeyResponse'][] } ServiceVersions: { - gotrue: string - postgrest: string + gotrue?: string + postgrest?: string 'supabase-postgres': string } SettingsResponse: { @@ -5441,6 +5473,7 @@ export interface components { visibility: 'user' | 'project' | 'org' | 'public' } SnippetProject: { + /** Format: int64 */ id: number name: string } @@ -5460,6 +5493,7 @@ export interface components { visibility: 'user' | 'project' | 'org' | 'public' } SnippetUser: { + /** Format: int64 */ id: number username: string } @@ -5487,8 +5521,15 @@ export interface components { updated_at: string } StorageConfigResponse: { + features: components['schemas']['StorageFeatures'] + /** Format: int64 */ fileSizeLimit: number - isFreeTier: boolean + } + StorageFeatureImageTransformation: { + enabled: boolean + } + StorageFeatures: { + imageTransformation: components['schemas']['StorageFeatureImageTransformation'] } StorageObject: { bucket_id: string @@ -6272,6 +6313,7 @@ export interface components { session_replication_role?: 'origin' | 'replica' | 'local' shared_buffers?: string statement_timeout?: string + track_commit_timestamp?: boolean wal_keep_size?: string wal_sender_timeout?: string work_mem?: string @@ -6356,10 +6398,9 @@ export interface components { public: boolean } UpdateStorageConfigBody: { - fileSizeLimit: number - } - UpdateStorageConfigResponse: { - fileSizeLimit: number + features?: components['schemas']['StorageFeatures'] + /** Format: int64 */ + fileSizeLimit?: number } UpdateSubscriptionBody: { payment_method?: string @@ -6698,7 +6739,9 @@ export interface components { pool_mode?: 'transaction' | 'session' | 'statement' } V1PhysicalBackup: { + /** Format: int64 */ earliest_physical_backup_date_unix?: number + /** Format: int64 */ latest_physical_backup_date_unix?: number } V1PostgrestConfigResponse: { @@ -6709,6 +6752,7 @@ export interface components { max_rows: number } V1ProjectRefResponse: { + /** Format: int64 */ id: number name: string ref: string @@ -6750,6 +6794,7 @@ export interface components { | 'RESIZING' } V1RestorePitrBody: { + /** Format: int64 */ recovery_time_target_unix: number } V1RunQueryBody: { @@ -7548,6 +7593,9 @@ export interface operations { /** Retrieve CLI login session */ CliLoginController_getCliLoginSession: { parameters: { + query?: { + device_code?: string + } path: { session_id: string } @@ -12350,7 +12398,7 @@ export interface operations { responses: { 200: { content: { - 'application/json': components['schemas']['UpdateStorageConfigResponse'] + 'application/json': components['schemas']['StorageConfigResponse'] } } 403: { @@ -12692,7 +12740,46 @@ export interface operations { DailyStatsController_getDailyStats: { parameters: { query: { - attribute: string + attribute: + | 'total_realtime_requests' + | 'total_realtime_egress' + | 'avg_cpu_usage' + | 'total_ingress' + | 'total_egress' + | 'total_requests' + | 'total_get_requests' + | 'total_patch_requests' + | 'total_post_requests' + | 'total_delete_requests' + | 'total_options_requests' + | 'total_supavisor_egress_bytes' + | 'total_rest_ingress' + | 'total_rest_egress' + | 'total_rest_requests' + | 'total_rest_get_requests' + | 'total_rest_post_requests' + | 'total_rest_patch_requests' + | 'total_rest_delete_requests' + | 'total_rest_options_requests' + | 'total_auth_billing_period_mau' + | 'total_auth_billing_period_sso_mau' + | 'total_auth_ingress' + | 'total_auth_egress' + | 'total_auth_requests' + | 'total_auth_get_requests' + | 'total_auth_post_requests' + | 'total_auth_patch_requests' + | 'total_auth_options_requests' + | 'total_auth_delete_requests' + | 'total_storage_ingress' + | 'total_storage_egress' + | 'total_storage_image_render_count' + | 'total_storage_requests' + | 'total_storage_get_requests' + | 'total_storage_post_requests' + | 'total_storage_delete_requests' + | 'total_storage_options_requests' + | 'total_storage_patch_requests' interval: string endDate: string startDate: string @@ -13123,6 +13210,22 @@ export interface operations { } } } + /** Gets service versions for a specific project */ + ProjectServiceVersionsController_getServiceVersions: { + parameters: { + path: { + /** @description Project ref */ + ref: string + } + } + responses: { + 200: { + content: { + 'application/json': components['schemas']['ServiceVersions'] + } + } + } + } /** Gets project's settings */ SettingsController_getProjectApi: { parameters: { @@ -14486,6 +14589,29 @@ export interface operations { } } } + /** Converts an organization into a partner organization */ + AwsPartnerOrganizationsSystemController_convertToPartnerOrganization: { + parameters: { + path: { + /** @description Organization slug */ + slug: string + } + } + requestBody: { + content: { + 'application/json': components['schemas']['ConvertIntoAwsPartnerOrganizationBody'] + } + } + responses: { + 200: { + content: never + } + /** @description Unexpected error while converting organization into a partner organization */ + 500: { + content: never + } + } + } /** Create a project */ SystemProjectsController_createProject: { requestBody: { @@ -14582,6 +14708,92 @@ export interface operations { } } } + /** Get database disk attributes */ + SystemProjectDiskController_getDisk: { + parameters: { + path: { + /** @description Project ref */ + ref: string + } + } + responses: { + 200: { + content: { + 'application/json': components['schemas']['DiskResponse'] + } + } + 403: { + content: never + } + /** @description Failed to get database disk attributes */ + 500: { + content: never + } + } + } + /** Modify database disk */ + SystemProjectDiskController_modifyDisk: { + parameters: { + path: { + /** @description Project ref */ + ref: string + } + } + requestBody: { + content: { + 'application/json': components['schemas']['DiskRequestBody'] + } + } + responses: { + 201: { + content: never + } + 403: { + content: never + } + /** @description Failed to modify database disk */ + 500: { + content: never + } + } + } + /** Get disk utilization */ + SystemProjectDiskController_getDiskUtilization: { + parameters: { + path: { + /** @description Project ref */ + ref: string + } + } + responses: { + 200: { + content: { + 'application/json': components['schemas']['DiskUtilMetricsResponse'] + } + } + 403: { + content: never + } + /** @description Failed to get disk utilization */ + 500: { + content: never + } + } + } + /** Get AWS disk volume information */ + SystemProjectDiskController_getAwsDiskInfo: { + parameters: { + path: { + /** @description Project ref */ + ref: string + } + } + responses: { + 200: { + content: never + } + } + } /** * List all functions * @description Returns all functions you've previously added to the specified project. @@ -15661,6 +15873,55 @@ export interface operations { } } } + /** Gets project's storage config */ + 'v1-get-storage-config': { + parameters: { + path: { + /** @description Project ref */ + ref: string + } + } + responses: { + 200: { + content: { + 'application/json': components['schemas']['StorageConfigResponse'] + } + } + 403: { + content: never + } + /** @description Failed to retrieve project's storage config */ + 500: { + content: never + } + } + } + /** Updates project's storage config */ + 'v1-update-storage-config': { + parameters: { + path: { + /** @description Project ref */ + ref: string + } + } + requestBody: { + content: { + 'application/json': components['schemas']['UpdateStorageConfigBody'] + } + } + responses: { + 200: { + content: never + } + 403: { + content: never + } + /** @description Failed to update project's storage config */ + 500: { + content: never + } + } + } /** [Beta] Gets project's custom hostname config */ 'v1-get-hostname-config': { parameters: { @@ -15939,6 +16200,9 @@ export interface operations { 'application/json': components['schemas']['V1ServiceHealthResponse'][] } } + 403: { + content: never + } /** @description Failed to retrieve project's service health status */ 500: { content: never diff --git a/packages/config/tailwind.config.js b/packages/config/tailwind.config.js index 9254c114124..d0d5df21861 100644 --- a/packages/config/tailwind.config.js +++ b/packages/config/tailwind.config.js @@ -420,6 +420,9 @@ const uiConfig = ui({ require('@tailwindcss/typography'), require('tailwindcss-animate'), plugin(motionSafeTransition), + function ({ addVariant }) { + addVariant('not-disabled', '&:not(:disabled)') + }, ], }) diff --git a/packages/ui-patterns/InnerSideMenu/index.tsx b/packages/ui-patterns/InnerSideMenu/index.tsx index 89de8af534e..d478cc2ef61 100644 --- a/packages/ui-patterns/InnerSideMenu/index.tsx +++ b/packages/ui-patterns/InnerSideMenu/index.tsx @@ -39,7 +39,13 @@ const InnerSideMenuCollapsible = forwardRef< ElementRef, React.ComponentPropsWithoutRef >(({ ...props }, ref) => { - return + return ( + + ) }) const InnerSideMenuCollapsibleTrigger = forwardRef< @@ -73,10 +79,7 @@ const InnerSideMenuCollapsibleContent = forwardRef< ) }) @@ -220,7 +223,7 @@ const InnerSideBarEmptyPanel = forwardRef< HTMLDivElement, React.ComponentPropsWithoutRef<'div'> & { title: string - description?: string + description?: string | React.ReactNode illustration?: React.ReactNode actions?: React.ReactNode } diff --git a/packages/ui/src/components/shadcn/ui/breadcrumb.tsx b/packages/ui/src/components/shadcn/ui/breadcrumb.tsx index 7bddf8fa745..e76be186190 100644 --- a/packages/ui/src/components/shadcn/ui/breadcrumb.tsx +++ b/packages/ui/src/components/shadcn/ui/breadcrumb.tsx @@ -1,6 +1,6 @@ -import * as React from 'react' -import { ChevronRightIcon } from 'lucide-react' import { Slot } from '@radix-ui/react-slot' +import { ChevronRightIcon } from 'lucide-react' +import * as React from 'react' import { cn } from '../../../lib/utils' const Breadcrumb = React.forwardRef< @@ -64,7 +64,7 @@ const BreadcrumbPage = React.forwardRef ) @@ -108,10 +108,10 @@ BreadcrumbEllipsis.displayName = 'BreadcrumbEllipsis' export { Breadcrumb, - BreadcrumbList, + BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink, + BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, - BreadcrumbEllipsis, } diff --git a/packages/ui/src/components/shadcn/ui/select.tsx b/packages/ui/src/components/shadcn/ui/select.tsx index 8bc84d8b5f8..8fd409dbcae 100644 --- a/packages/ui/src/components/shadcn/ui/select.tsx +++ b/packages/ui/src/components/shadcn/ui/select.tsx @@ -47,7 +47,7 @@ const SelectTrigger = React.forwardRef< { test.describe('Table Editor page', () => { test.beforeEach(async ({ page }) => { const tableResponsePromise = page.waitForResponse( - 'http://localhost:8082/api/pg-meta/default/query?key=public-entity-types', + 'http://localhost:8082/api/pg-meta/default/query?key=entity-types-public-0', { timeout: 0 } ) await page.goto('/project/default/editor') @@ -108,7 +108,7 @@ test.describe('Table Editor page', () => { test('should check the auth schema', async ({ page }) => { const tableResponsePromise = page.waitForResponse( - 'http://localhost:8082/api/pg-meta/default/query?key=public-entity-types', + 'http://localhost:8082/api/pg-meta/default/query?key=entity-types-public-0', { timeout: 0 } ) diff --git a/supabase/config.toml b/supabase/config.toml index 961f4351a25..12986999396 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -30,8 +30,6 @@ port = 54323 [inbucket] # Port to use for the email testing server web interface. port = 54324 -smtp_port = 54325 -pop3_port = 54326 [storage] # The maximum file size allowed (e.g. "5MB", "500KB"). @@ -42,7 +40,11 @@ file_size_limit = "50MiB" # in emails. site_url = "http://localhost:3000" # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. -additional_redirect_urls = ["https://localhost:3000"] +additional_redirect_urls = [ + "https://*-supabase.vercel.app/*/*", + "https://supabase.com/*/*", + "http://localhost:3000/*/*", +] # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 seconds (one # week). jwt_expiry = 3600 @@ -61,7 +63,7 @@ enable_confirmations = false # Uncomment the following to use gh oAuth app locally with your own test app # to use env vars locally, run > `source ./supabase/.env && supabase [command...]` [auth.external.github] -enabled = true +enabled = false client_id = "env(GITHUB_CLIENT_ID)" secret = "env(GITHUB_SECRET)" # Overrides the default auth redirectUrl. diff --git a/supabase/remotes/.gitignore b/supabase/remotes/.gitignore deleted file mode 100644 index 9b8a46e692b..00000000000 --- a/supabase/remotes/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -# Local .terraform directories -**/.terraform/* - -# .tfstate files -*.tfstate -*.tfstate.* - -# Crash log files -crash.log -crash.*.log - -# Exclude all .tfvars files, which are likely to contain sensitive data, such as -# password, private keys, and other secrets. These should not be part of version -# control as they are data points which are potentially sensitive and subject -# to change depending on the environment. -*.tfvars -*.tfvars.json - -# Ignore override files as they are usually used to override resources locally and so -# are not checked in -override.tf -override.tf.json -*_override.tf -*_override.tf.json - -# Include override files you do wish to add to version control using negated pattern -# !example_override.tf - -# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan -# example: *tfplan* - -# Ignore CLI configuration files -.terraformrc -terraform.rc diff --git a/supabase/remotes/preview.tf b/supabase/remotes/preview.tf deleted file mode 100644 index 663af43d97e..00000000000 --- a/supabase/remotes/preview.tf +++ /dev/null @@ -1,41 +0,0 @@ -variable "git_branch" { - type = string - default = null -} - -# Fetch all branches of a linked project -data "supabase_branch" "all" { - parent_project_ref = var.linked_project -} - -# Import an existing branch database -import { - for_each = { - for b in data.supabase_branch.all.branches : - b.git_branch => b - if b.git_branch == var.git_branch - } - to = supabase_branch.imported[0] - id = each.value.id -} - -resource "supabase_branch" "imported" { - count = length(var.git_branch[*]) - parent_project_ref = var.linked_project - git_branch = var.git_branch -} - -# Override auth settings for the current branch -resource "supabase_settings" "preview" { - count = length(var.git_branch[*]) - project_ref = supabase_branch.imported[0].database.id - - auth = jsonencode({ - site_url = "http://localhost:3001" - }) -} - -output "branch_database" { - value = one(supabase_branch.imported[*].database) - sensitive = true -} diff --git a/supabase/remotes/production.tf b/supabase/remotes/production.tf deleted file mode 100644 index 92a1b31140b..00000000000 --- a/supabase/remotes/production.tf +++ /dev/null @@ -1,15 +0,0 @@ -# Define a linked project variable as user input -variable "linked_project" { - type = string -} - -# Configure api settings for the linked project -resource "supabase_settings" "production" { - project_ref = var.linked_project - - api = jsonencode({ - db_schema = "public, storage, graphql_public" - db_extra_search_path = "public, extensions" - max_rows = 1000 - }) -} diff --git a/supabase/remotes/provider.tf b/supabase/remotes/provider.tf deleted file mode 100644 index 6fba014c65c..00000000000 --- a/supabase/remotes/provider.tf +++ /dev/null @@ -1,10 +0,0 @@ -terraform { - required_providers { - supabase = { - source = "supabase/supabase" - version = "~> 1.0" - } - } -} - -provider "supabase" {} diff --git a/turbo.json b/turbo.json index 7f58710cd1b..c477be7eec8 100644 --- a/turbo.json +++ b/turbo.json @@ -70,7 +70,9 @@ "LIVE_SUPABASE_COM_SERVICE_ROLE_KEY", "VERCEL_URL", "HUBSPOT_PORTAL_ID", - "HUBSPOT_ENTERPRISE_FORM_GUID" + "HUBSPOT_ENTERPRISE_FORM_GUID", + "EMAIL_REPORT_SLACK_WEBHOOK", + "EMAIL_ABUSE_SERVICE_KEY" ], "outputs": [".next/**", "!.next/cache/**", ".contentlayer/**"] },
realtime-dart storage-dart functions-dart
Swiftsupabase-swiftpostgrest-swiftgotrue-swiftrealtime-swiftstorage-swiftfunctions-swift
Pythonsupabase-pypostgrest-pygotrue-pyrealtime-pystorage-pyfunctions-py
💚 社区 💚storage-kt functions-kt
Pythonsupabase-pypostgrest-pygotrue-pyrealtime-pystorage-pyfunctions-py
Ruby supabase-rb- -
Swiftsupabase-swiftpostgrest-swiftgotrue-swiftrealtime-swiftstorage-swiftfunctions-swift
Godot Engine (GDScript) supabase-gdscript