diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index 0a4d1b1fd57..47563fba1ef 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -19,12 +19,16 @@ jobs: permissions: contents: read packages: write + id-token: write steps: + - name: configure aws credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + role-to-assume: ${{ secrets.PROD_AWS_ROLE }} + aws-region: us-east-1 - uses: docker/login-action@v2 with: registry: public.ecr.aws - username: ${{ secrets.PROD_ACCESS_KEY_ID }} - password: ${{ secrets.PROD_SECRET_ACCESS_KEY }} - uses: docker/login-action@v2 with: registry: ghcr.io diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 7abe1bea978..3cf62dee058 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -421,14 +421,30 @@ export const functions = { label: 'Edge Functions', url: '/guides/functions', items: [ + { name: 'Quickstart', url: '/guides/functions/quickstart', items: [] }, { name: 'Auth', url: '/guides/functions/auth', items: [] }, { name: 'CI/CD Workflow', url: '/guides/functions/cicd-workflow', items: [] }, - { name: 'Best Practices', url: '/guides/functions/best-practices', items: [] }, - { name: 'Quickstart', url: '/guides/functions/quickstart', items: [] }, + { name: 'CORS', url: '/guides/functions/cors', items: [] }, + { name: 'Debugging', url: '/guides/functions/debugging', items: [] }, + { name: 'Import Maps', url: '/guides/functions/import-maps', items: [] }, + { name: 'Local Development', url: '/guides/functions/local-development', items: [] }, + { name: 'Managing Secrets', url: '/guides/functions/secrets', items: [] }, + { name: 'Schedule Functions', url: '/guides/functions/schedule-functions', items: [] }, { name: 'Examples', url: '/guides/functions/examples', - items: [{ name: 'OG Image', url: '/guides/functions/examples/og-image', items: [] }], + items: [ + { + name: 'Cloudflare Turnstile', + url: '/guides/functions/examples/cloudflare-turnstile', + items: [], + }, + { name: 'GitHub Actions', url: '/guides/functions/examples/github-actions', items: [] }, + { name: 'OG Image', url: '/guides/functions/examples/og-image', items: [] }, + { name: 'Storage Caching', url: '/guides/functions/examples/storage-caching', items: [] }, + { name: 'Stripe Webhooks', url: '/guides/functions/examples/stripe-webhooks', items: [] }, + { name: 'Telegram Bot', url: '/guides/functions/examples/telegram-bot', items: [] }, + ], }, ], } @@ -748,13 +764,13 @@ export const reference_javascript_v2 = { export const reference_dart_v0 = { icon: 'reference-dart', - title: 'dart', + title: 'Flutter', url: '/guides/reference/dart', parent: '/reference', } export const reference_dart_v1 = { icon: 'reference-dart', - title: 'dart', + title: 'Flutter', url: '/guides/reference/dart', parent: '/reference', } diff --git a/apps/docs/components/reference/RefFunctionSection.tsx b/apps/docs/components/reference/RefFunctionSection.tsx index 14f74a45c44..59e0c0a6abd 100644 --- a/apps/docs/components/reference/RefFunctionSection.tsx +++ b/apps/docs/components/reference/RefFunctionSection.tsx @@ -143,18 +143,24 @@ const RefFunctionSection: React.FC = (props) => { label={example.name} className="flex flex-col gap-3" > - {example.description && ( -
- - {example.description} - -
- )} + + {exampleString + + (example.code && + example.code + .replace(/```/g, '') + .replace('js', '') + .replace('ts', '') + .replace('dart', ''))} + {((tables && tables.length > 0) || sql) && ( <> @@ -189,28 +195,14 @@ const RefFunctionSection: React.FC = (props) => { )} - - {exampleString + - (example.code && - example.code - .replace(/```/g, '') - .replace('js', '') - .replace('ts', '') - .replace('dart', ''))} - - {response && ( @@ -218,6 +210,20 @@ const RefFunctionSection: React.FC = (props) => { )} + + {example.description && ( + +
+ + {example.description} + +
+
+ )} ) })} diff --git a/apps/docs/data/nonGeneratedReferencePages.ts b/apps/docs/data/nonGeneratedReferencePages.ts index fd5ba84d1f5..01b060055c7 100644 --- a/apps/docs/data/nonGeneratedReferencePages.ts +++ b/apps/docs/data/nonGeneratedReferencePages.ts @@ -2,7 +2,7 @@ const nonGeneratedReferencePages = [ 'docs/reference/javascript', 'docs/reference/javascript/installing', 'docs/reference/javascript/release-notes', - 'docs/reference/javascript/upgrade-guide', + 'docs/reference/javascript/v1/upgrade-guide', 'docs/reference/javascript/typescript-support', 'docs/reference/javascript/v1', 'docs/reference/javascript/v1/installing', @@ -11,7 +11,7 @@ const nonGeneratedReferencePages = [ 'docs/reference/dart', 'docs/reference/dart/installing', 'docs/reference/dart/initializing', - 'docs/reference/dart/upgrade-guide', + 'docs/reference/dart/v0/upgrade-guide', 'docs/reference/dart/v0', 'docs/reference/api', 'docs/reference/api/release-notes', diff --git a/apps/docs/docs/ref/dart/introduction.mdx b/apps/docs/docs/ref/dart/introduction.mdx index 8dabccf22b2..e183e37889e 100644 --- a/apps/docs/docs/ref/dart/introduction.mdx +++ b/apps/docs/docs/ref/dart/introduction.mdx @@ -13,11 +13,12 @@ hideTitle: true
- This reference documents every object and method available in Supabase's Flutter - library, [supabase-flutter](https://pub.dev/packages/supabase_flutter). You can - use supabase-flutter to interact with your Postgres database, listen to database changes, invoke - Deno Edge Functions, build login and user management functionality, and manage large files. -
-
- We also provide [supabase](https://pub.dev/packages/supabase) package for non-Flutter projects. + +This reference documents every object and method available in Supabase's Flutter +library, [supabase-flutter](https://pub.dev/packages/supabase_flutter). You can +use supabase-flutter to interact with your Postgres database, listen to database changes, invoke +Deno Edge Functions, build login and user management functionality, and manage large files. + +We also provide a [supabase](https://pub.dev/packages/supabase) package for non-Flutter projects. +
diff --git a/apps/docs/docs/ref/dart/v0/introduction.mdx b/apps/docs/docs/ref/dart/v0/introduction.mdx new file mode 100644 index 00000000000..fdbb9d766a5 --- /dev/null +++ b/apps/docs/docs/ref/dart/v0/introduction.mdx @@ -0,0 +1,28 @@ +--- +id: introduction +title: Introduction +hideTitle: true +--- + +
+ +
+

Flutter Client Library

+

supabase-flutter

+
+
+ +
+ + + You're viewing the docs for an older version of the `supabase-flutter` library. Learn how to + [upgrade to the latest version](/docs/reference/dart/v0/upgrade-guide). + + +This reference documents every object and method available in Supabase's Flutter library, [supabase-flutter](https://pub.dev/packages/supabase_flutter). +You can use supabase-flutter to interact with your Postgres database, listen to database changes, invoke +Deno Edge Functions, build login and user management functionality, and manage large files. + +We also provide a [supabase](https://pub.dev/packages/supabase) package for non-Flutter projects. + +
diff --git a/apps/docs/docs/reference/dart/upgrade-guide.mdx b/apps/docs/docs/ref/dart/v0/upgrade-guide.mdx similarity index 68% rename from apps/docs/docs/reference/dart/upgrade-guide.mdx rename to apps/docs/docs/ref/dart/v0/upgrade-guide.mdx index 707cdd06129..6da7b0c0fe5 100644 --- a/apps/docs/docs/reference/dart/upgrade-guide.mdx +++ b/apps/docs/docs/ref/dart/v0/upgrade-guide.mdx @@ -4,23 +4,35 @@ title: Upgrade to supabase-flutter v1 description: 'Learn how to upgrade to supabase-flutter v1.' --- -import Tabs from '@theme/Tabs' -import TabItem from '@theme/TabItem' - -supabase-flutter focuses on improving the developer experience and making it easier to use. This guide will help you upgrade from supabase-flutter v0 to v1. +supabase-flutter focuses on improving the developer experience and making it easier to use. This guide demonstrates how to upgrade from supabase-flutter v0 to v1. ## Upgrade the client library + + + Update the package in your pubspec.yaml file. + + + ```yaml supabase_flutter: ^1.0.0 ``` + + + ## Error handling + + + The way supabase-flutter throws error has changed in v1. In v0, errors were returned as a response. In v1, errors are thrown as exceptions. This makes it more intuitive as a Flutter developer to handle errors. + + + -## Auth classes / methods + + + +## Auth classes and methods ### Usage of `SupabaseAuthState` and `SupabaseAuthRequiredState` classes -In v0, `SupabaseAuthState` and `SupabaseAuthRequiredState` were required to handle automatic token refresh and to listen to auth state change. In v1, `SupabaseAuthState` and `SupabaseAuthRequiredState` are deprecated, and token refresh will happen automatically just by initializing Supabase. [`onAuthStateChange`](/docs/reference/dart/upgrade-guide#listening-to-auth-state-change) can be used to action on auth state change. + + + +In v0, `SupabaseAuthState` and `SupabaseAuthRequiredState` were required to handle automatic token refresh and to listen to auth state change. In v1, `SupabaseAuthState` and `SupabaseAuthRequiredState` are deprecated, and token refresh will happen automatically just by initializing Supabase. [`onAuthStateChange`](#listening-to-auth-state-change) can be used to action on auth state change. + + + + + + ### Listening to auth state change + + + `onAuthStateChange` now returns a `Stream`. + + + + + + ### Sign in with email and password -The signIn() method has been deprecated in favor of more explicit method signatures to help with type hinting. Previously it was difficult for developers to know what they were missing (e.g., a lot of developers didn't realize they could use passwordless magic links). + + + +`signIn()` has been deprecated in favor of more explicit method signatures to help with type hinting. Previously it was difficult for developers to know what they were missing (e.g., a lot of developers didn't realize they could use passwordless magic links). + + + + + + + + + ### Sign in with magic link + + + + + + + + + ### Sign in with a third-party OAuth provider + + + + + + + + + ### Sign in with phone + + + + + + + + + ### Sign in with phone using OTP + + + + + + + + + ### Reset password for email + + + + + + + + + ### Get the user's current session + + + + + + + + + ### Get the logged-in user + + + + + + + + + ### Update user data for a logged-in user + + + + + + ## Data methods -`.insert()` / `.upsert()` / `.update()` / `.delete()` don't return rows by default. +`.insert()` / `.upsert()` / `.update()` / `.delete()` no longer return rows by default. Previously, these methods return inserted/updated/deleted rows by default (which caused [some confusion](https://github.com/supabase/supabase/discussions/1548)), and you can opt to not return it by specifying `returning: 'minimal'`. Now the default behavior is to not return rows. To return inserted/updated/deleted rows, add a `.select()` call at the end. -Previously, these methods return inserted/updated/deleted rows by default (which caused [some confusion](https://github.com/supabase/supabase/discussions/1548)), and you can opt to not return it by specifying `returning: 'minimal'`. Now the default behavior is to not return rows. To return inserted/updated/deleted rows, add a `.select()` call at the end. +Also, calling `.execute()` at the end of the query was a requirement in v0, but deprecated in v1. -Also, calling `.execute()` at the end of the query was a requirement in v0, but in v1 `.execute` is deperecated. + + ### Insert without returning inserted data + + + + + + + + + ### Insert with returning inserted data + + + + + + ## Realtime methods + + + ### Stream `.stream()` no longer needs the `.execute()` at the end. Also, filtering by `eq` is a lot easier now. `primaryKey` is now a named parameter to make it more obvious what to pass. + + + + + + + + + ### Subscribe + + + + + + + + + ### Unsubscribe + + + + + + diff --git a/apps/docs/docs/ref/javascript/v1/introduction.mdx b/apps/docs/docs/ref/javascript/v1/introduction.mdx index 3e1af1ff9dd..a62134bdd69 100644 --- a/apps/docs/docs/ref/javascript/v1/introduction.mdx +++ b/apps/docs/docs/ref/javascript/v1/introduction.mdx @@ -13,8 +13,15 @@ hideTitle: true
- This reference documents every object and method available in Supabase's isomorphic JavaScript - library, supabase-js. You can use supabase-js to interact with your Postgres database, listen to - database changes, invoke Deno Edge Functions, build login and user management functionality, and - manage large files. + + + You're viewing the docs for an older version of the `supabase-js` library. Learn how to [upgrade + to the latest version](/docs/reference/javascript/v1/upgrade-guide). + + +This reference documents every object and method available in Supabase's isomorphic JavaScript +library, supabase-js. You can use supabase-js to interact with your Postgres database, listen to +database changes, invoke Deno Edge Functions, build login and user management functionality, and +manage large files. +
diff --git a/apps/docs/docs/ref/javascript/upgrade-guide.mdx b/apps/docs/docs/ref/javascript/v1/upgrade-guide.mdx similarity index 100% rename from apps/docs/docs/ref/javascript/upgrade-guide.mdx rename to apps/docs/docs/ref/javascript/v1/upgrade-guide.mdx diff --git a/apps/docs/docs/reference/javascript/upgrade-guide.mdx b/apps/docs/docs/reference/javascript/upgrade-guide.mdx deleted file mode 100644 index 282d66d4966..00000000000 --- a/apps/docs/docs/reference/javascript/upgrade-guide.mdx +++ /dev/null @@ -1,439 +0,0 @@ ---- -id: upgrade-guide -title: Upgrade to supabase-js v2 -description: 'Learn how to upgrade to supabase-js v2.' ---- - -import Tabs from '@theme/Tabs' -import TabItem from '@theme/TabItem' - -supabase-js v2 focuses on "quality-of-life" improvements for developers and addresses some of the largest pain points in v1. v2 includes type support, a rebuilt Auth library with async methods, improved errors, and more. - -No new features will be added to supabase-js v1 , but we'll continuing merging security fixes to v1, with maintenance patches for the next 3 months. - -## Upgrade the client library - -```sh -npm install @supabase/supabase-js@2 -``` - -_Optionally_ if you are using custom configuration with `createClient` then follow below: - - - - -```ts title=src/supabaseClient.ts -const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { - schema: 'custom', - persistSession: false, -}) -``` - - - - -```ts title=src/supabaseClient.ts -const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { - db: { - schema: 'custom', - }, - auth: { - persistSession: true, - }, -}) -``` - - - - -Read more about the [constructor options](/docs/reference/javascript/release-notes#explicit-constructor-options). - -### Auth methods - -The signIn() method has been deprecated in favor of more explicit method signatures to help with type hinting. Previously it was difficult for developers to know what they were missing (e.g., a lot of developers didn't realize they could use passwordless magic links). - -#### Sign in with email and password - - - - -```ts -const { user, error } = await supabase.auth.signIn({ email, password }) -``` - - - - -```ts -const { - data: { user }, - error, -} = await supabase.auth.signInWithPassword({ email, password }) -``` - - - - -#### Sign in with magic link - - - - -```ts -const { error } = await supabase.auth.signIn({ email }) -``` - - - - -```ts -const { error } = await supabase.auth.signInWithOtp({ email }) -``` - - - - -#### Sign in with a third-party provider - - - - -```ts -const { error } = await supabase.auth.signIn({ provider }) -``` - - - - -```ts -const { error } = await supabase.auth.signInWithOAuth({ provider }) -``` - - - - -#### Sign in with phone - - - - -```ts -const { error } = await supabase.auth.signIn({ phone, password }) -``` - - - - -```ts -const { error } = await supabase.auth.signInWithPassword({ phone, password }) -``` - - - - -#### Sign in with phone using OTP - - - - -```ts -const { error } = await supabase.auth.api.sendMobileOTP(phone) -``` - - - - -```ts -const { data, error } = await supabase.auth.signInWithOtp({ phone }) - -// After receiving a SMS with a OTP. -const { data, error } = await supabase.auth.verifyOtp({ phone, token }) -``` - - - - -#### Reset password for email - - - - -```ts -const { data, error } = await supabase.auth.api.resetPasswordForEmail(email) -``` - - - - -```ts -const { data, error } = await supabase.auth.resetPasswordForEmail(email) -``` - - - - -#### Get the user's current session - - - - -```ts -const session = supabase.auth.session() -``` - - - - -```ts -const { - data: { session }, -} = await supabase.auth.getSession() -``` - - - - -#### Get the logged-in user - - - - -```ts -const user = supabase.auth.user() -``` - - - - -```ts -const { - data: { session }, -} = await supabase.auth.getSession() -const { user } = session -``` - - - - -#### Update user data for a logged-in user - - - - -```ts -const { user, error } = await supabase.auth.update({ attributes }) -``` - - - - -```ts -const { - data: { user }, - error, -} = await supabase.auth.updateUser({ attributes }) -``` - - - - -#### Use a custom `access_token` JWT with Supabase - - - - -```ts -const { user, error } = supabase.auth.setAuth(access_token) -``` - - - - -```ts -const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { - global: { - headers: { - Authorization: `Bearer ${access_token}`, - }, - }, -}) -``` - - - - -#### Cookie methods - -The cookie-related methods like `setAuthCookie` and `getUserByCookie` have been removed. - -For Next.js you can use the [Auth Helpers](https://supabase.com/docs/guides/auth/auth-helpers/nextjs) to help you manage cookies. -If you can't use the Auth Helpers, you can use [server-side rendering](https://supabase.com/docs/guides/auth/server-side-rendering). - -Some the [PR](https://github.com/supabase/gotrue-js/pull/340) for additional background information. - -### Data methods - -`.insert()` / `.upsert()` / `.update()` / `.delete()` don't return rows by default: [PR](https://github.com/supabase/postgrest-js/pull/276). - -Previously, these methods return inserted/updated/deleted rows by default (which caused [some confusion](https://github.com/supabase/supabase/discussions/1548)), and you can opt to not return it by specifying `returning: 'minimal'`. Now the default behavior is to not return rows. To return inserted/updated/deleted rows, add a `.select()` call at the end. - -#### Insert and return data - - - - -```ts -const { data, error } = await supabase.auth.insert({ new_data }) -``` - - - - -```ts -const { data, error } = await supabase.auth.insert({ new_data }).select() -``` - - - - -#### Update and return data - - - - -```ts -const { data, error } = await supabase.auth.update({ new_data }).eq('id', id) -``` - - - - -```ts -const { data, error } = await supabase.auth.update({ new_data }).eq('id', id).select() -``` - - - - -### Realtime methods - -#### Subscribe - - - - -```ts -const userListener = supabase - .from('users') - .on('*', (payload) => handleAllEventsPayload(payload.new)) - .subscribe() -``` - - - - -```ts -const userListener = supabase - .channel('public:user') - .on('postgres_changes', { event: '*', schema: 'public', table: 'user' }, (payload) => - handleAllEventsPayload() - ) - .subscribe() -``` - - - - -#### Unsubscribe - - - - -```ts -userListener.unsubscribe() -``` - - - - -```ts -supabase.removeChannel(userListener) -``` - - - diff --git a/apps/docs/pages/guides/auth/auth-helpers/nextjs.mdx b/apps/docs/pages/guides/auth/auth-helpers/nextjs.mdx index 76e169c2271..9985e50fb92 100644 --- a/apps/docs/pages/guides/auth/auth-helpers/nextjs.mdx +++ b/apps/docs/pages/guides/auth/auth-helpers/nextjs.mdx @@ -584,7 +584,7 @@ export async function middleware(req: NextRequest) { } export const config = { - matcher: '/middleware-protected', + matcher: '/middleware-protected/:path*', } ``` @@ -782,7 +782,7 @@ export const config = { ### Migrating to v0.4.X and supabase-js v2 -With the update to `supabase-js` v2 the `auth` API routes are no longer required, therefore you can go ahead and delete your `auth` directory under the `/pages/api/` directory. Please refer to the [v2 migration guide](/docs/reference/javascript/upgrade-guide) for the full set of changes within supabase-js. +With the update to `supabase-js` v2 the `auth` API routes are no longer required, therefore you can go ahead and delete your `auth` directory under the `/pages/api/` directory. Please refer to the [v2 migration guide](/docs/reference/javascript/v1/upgrade-guide) for the full set of changes within supabase-js. The `/api/auth/logout` API route has been removed, please use the `signout` method instead: diff --git a/apps/docs/pages/guides/auth/social-login/auth-azure.mdx b/apps/docs/pages/guides/auth/social-login/auth-azure.mdx index d75a365b1d5..57f398456b0 100644 --- a/apps/docs/pages/guides/auth/social-login/auth-azure.mdx +++ b/apps/docs/pages/guides/auth/social-login/auth-azure.mdx @@ -74,14 +74,12 @@ When your user signs in, call [signInWithOAuth()](/docs/reference/javascript/aut ```js async function signInWithAzure() { - const { data, error } = await supabase.auth.signInWithOAuth( - { - provider: 'azure', - }, - { + const { data, error } = await supabase.auth.signInWithOAuth({ + provider: 'azure', + options: { scopes: 'email', - } - ) + }, + }) } ``` diff --git a/apps/docs/pages/guides/database/extensions/pgcron.mdx b/apps/docs/pages/guides/database/extensions/pgcron.mdx index 57a704e444a..1062db0fd32 100644 --- a/apps/docs/pages/guides/database/extensions/pgcron.mdx +++ b/apps/docs/pages/guides/database/extensions/pgcron.mdx @@ -63,6 +63,8 @@ The schedule uses the standard cron syntax, in which \* means "run every time pe * * * * * ``` +You can use [crontab.guru](https://crontab.guru/) to help validate your cron schedules. + ## Examples ### Delete data every week @@ -71,8 +73,8 @@ Delete old data on Saturday at 3:30am (GMT): ```sql select cron.schedule ( - 'webhook-every-minute', -- name of the cron job - '* * * * *', -- every minute + 'saturday-cleanup', -- name of the cron job + '30 3 * * 6', -- Saturday at 3:30am (GMT) $$ delete from events where event_time < now() - interval '1 week' $$ ); ``` @@ -85,6 +87,26 @@ Vacuum every day at 3:00am (GMT) SELECT cron.schedule('nightly-vacuum', '0 3 * * *', 'VACUUM'); ``` +### Invoke Supabase Edge Function every minute + +Make a POST request to a Supabase Edge Function every minute. Note: this requires the [`pg_net` extension](/docs/guides/database/extensions/pgnet) to be enabled. + +```sql +select + cron.schedule( + 'invoke-function-every-minute', + '* * * * *', -- every minute + $$ + select + net.http_post( + url:='https://project-ref.functions.supabase.co/function-name', + headers:='{"Content-Type": "application/json", "Authorization": "Bearer YOUR_ANON_KEY"}'::jsonb, + body:=concat('{"time": "', now(), '"}')::jsonb + ) as request_id; + $$ + ); +``` + ### Unschedule a job Unschedules a job called `'nightly-vacuum'` diff --git a/apps/docs/pages/guides/database/extensions/pgnet.mdx b/apps/docs/pages/guides/database/extensions/pgnet.mdx index 29283ab4430..18c51a8dd2a 100644 --- a/apps/docs/pages/guides/database/extensions/pgnet.mdx +++ b/apps/docs/pages/guides/database/extensions/pgnet.mdx @@ -53,11 +53,11 @@ Procedural languages are automatically installed within `pg_catalog`, so you don -## `http_get` [#http_get] +## `http_get` Creates an HTTP GET request returning the request's ID. HTTP requests are not started until the transaction is committed. -### Signature +### Signature [#get-signature] @@ -86,7 +86,7 @@ net.http_get( language plpgsql ``` -### Usage +### Usage [#get-usage] ```sql select net.http_get('https://news.ycombinator.com') as request_id; @@ -98,13 +98,13 @@ request_id After triggering `http_get`, use [`http_get_result`](#http_get_result) to get the result of the request. -## `http_post` [#http_post] +## `http_post` Creates an HTTP POST request with a JSON body, returning the request's ID. HTTP requests are not started until the transaction is committed. The body's character set encoding matches the database's `server_encoding` setting. -### Signature +### Signature [#post-signature] @@ -134,7 +134,7 @@ net.http_post( language plpgsql ``` -### Usage +### Usage [#post-usage] ```sql select @@ -150,6 +150,21 @@ request_id After triggering `http_post`, use [`http_get_result`](#http_get_result) to get the result of the request. +## Examples + +### Invoke a Supabase Edge Function + +Make a POST request to a Supabase Edge Function with auth header and JSON body payload: + +```sql +select + net.http_post( + url:='https://project-ref.functions.supabase.co/function-name', + headers:='{"Content-Type": "application/json", "Authorization": "Bearer YOUR_ANON_KEY"}'::jsonb, + body:='{"name": "pg_net"}'::jsonb + ) as request_id; +``` + ## Resources - Source code: [github.com/supabase/pg_net](https://github.com/supabase/pg_net/) diff --git a/apps/docs/pages/guides/functions/best-practices.mdx b/apps/docs/pages/guides/functions/best-practices.mdx deleted file mode 100644 index b670cd73a05..00000000000 --- a/apps/docs/pages/guides/functions/best-practices.mdx +++ /dev/null @@ -1,64 +0,0 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'functions-best-practices', - title: 'Edge Functions Best Practices', - description: 'Globally distributed TypeScript functions.', -} - -## Database Functions vs Edge Functions - -For data-intensive operations we recommend using [Database Functions](/docs/guides/database/functions), which are executed within your database -and can be called remotely using the [REST and GraphQL API](/docs/guides/api). - -For use-cases which require low-latency we recommend [Edge Functions](/docs/guides/functions), which are globally-distributed and can be written in TypeScript. - -## Organizing your Edge Functions - -We recommend developing “fat functions”. This means that you should develop few large functions, rather than many small functions. One common pattern when developing Functions is that you need to share code between two or more Functions. To do this, you can store any shared code in a folder prefixed with an underscore (`_`). We recommend this folder structure: - -```bash -└── supabase - ├── functions - │ ├── _shared - │ | ├── supabaseAdmin.ts # Supabase client with SERVICE_ROLE key - │ │ └── supabaseClient.ts # Supabase client with ANON key - │ ├── function-one # use hyphens to name functions - │ │ └── index.ts - │ └── function-two - │ └── index.ts - ├── migrations - └── config.toml -``` - -## Naming Edge Functions - -We recommend using hyphens to name functions because hyphens are the most URL-friendly of all the naming conventions (snake_case, camelCase, PascalCase). - -## CORS (Cross-Origin Resource Sharing) - -We recommend adding a check to handle [CORS Preflight](https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request) requests in your edge function to be able to invoke the function from browsers. - -See the [example on GitHub](https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/browser-with-cors/index.ts). - -```ts -export const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey', -} - -serve(async (req) => { - if (req.method === 'OPTIONS') { - return new Response('ok', { headers: corsHeaders }) - } - ... -}) -``` - -## Using HTTP Methods - -Edge Functions supports `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, and `OPTIONS`. A function can be designed to perform different actions based on a request's HTTP method. See the [example on building a RESTful service](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/restful-tasks) to learn how to handle different HTTP methods in your function. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/functions/cicd-workflow.mdx b/apps/docs/pages/guides/functions/cicd-workflow.mdx index e902f140370..fd7826ddf0e 100644 --- a/apps/docs/pages/guides/functions/cicd-workflow.mdx +++ b/apps/docs/pages/guides/functions/cicd-workflow.mdx @@ -35,6 +35,15 @@ jobs: - run: supabase functions deploy your-function-name --project-ref $PROJECT_ID ``` +
+ +
+ See the [example on GitHub](https://github.com/supabase/supabase/blob/master/examples/edge-functions/.github/workflows/deploy.yaml). export const Page = ({ children }) => diff --git a/apps/docs/pages/guides/functions/cors.mdx b/apps/docs/pages/guides/functions/cors.mdx new file mode 100644 index 00000000000..14f1a1208d9 --- /dev/null +++ b/apps/docs/pages/guides/functions/cors.mdx @@ -0,0 +1,59 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'functions-cors', + title: 'CORS (Cross-Origin Resource Sharing)', + description: 'Add CORS headers to invoke functions from the browser.', +} + +To invoke the functions from the browser, you need to handle [CORS Preflight](https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request) requests. + +See the [example on GitHub](https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/browser-with-cors/index.ts). + +### Recommended setup + +We recommend adding a `corst.ts` file within a [`_shared` folder](/docs/guides/functions/quickstart#organizing-your-edge-functions) which makes it easy to reuse the CORS headers across functions: + +```ts cors.ts +export const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +} +``` + +You can then import and use the CORS headers within your functions: + +```ts index.ts +import { serve } from 'https://deno.land/std@0.131.0/http/server.ts' +import { corsHeaders } from '../_shared/cors.ts' + +console.log(`Function "browser-with-cors" up and running!`) + +serve(async (req) => { + // This is needed if you're planning to invoke your function from a browser. + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }) + } + + try { + const { name } = await req.json() + const data = { + message: `Hello ${name}!`, + } + + return new Response(JSON.stringify(data), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + }) + } catch (error) { + return new Response(JSON.stringify({ error: error.message }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 400, + }) + } +}) +``` + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/functions/debugging.mdx b/apps/docs/pages/guides/functions/debugging.mdx new file mode 100644 index 00000000000..62c93035b2a --- /dev/null +++ b/apps/docs/pages/guides/functions/debugging.mdx @@ -0,0 +1,20 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'functions-debugging', + title: 'Debugging Functions', + description: 'Debug functions in production.', +} + +You can debug your deployed Edge Functions using the ["Functions" section](https://app.supabase.com/project/_/functions) of the Dashboard. There are two debugging tools available: + +- Invocations: shows the Request and Response for each execution. +- Logs: shows any platform events, including deployments and errors. + +![Function invocations.](/docs/img/guides/functions/function-logs.png) + +When [developing locally](/docs/guides/functions/local-development) you will see error messages and console log statements printed to your local terminal window. + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/functions/examples/cloudflare-turnstile.mdx b/apps/docs/pages/guides/functions/examples/cloudflare-turnstile.mdx new file mode 100644 index 00000000000..560b3d9e454 --- /dev/null +++ b/apps/docs/pages/guides/functions/examples/cloudflare-turnstile.mdx @@ -0,0 +1,96 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'examples-cloudflare-turnstile', + title: 'Cloudflare Turnstile', + description: 'Protecting Forms with Cloudflare Turnstile.', +} + +
+ +
+ +[Clouflare Turnstile](https://www.cloudflare.com/products/turnstile/) is a friendly, free CAPTCHA replacement, and it works seamlessly with Supabase Edge Functions to protect your forms. [View on GitHub](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/cloudflare-turnstile). + +## Setup + +- Follow these steps to set up a new site: https://developers.cloudflare.com/turnstile/get-started/ +- Add the Cloudflare Turnstile widget to your site: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/ + +## Code + +Create a new function in your project: + +```bash +supabase functions new cloudflare-turnstile +``` + +And add the code to the `index.ts` file: + +```ts index.ts +import { serve } from 'https://deno.land/std@0.131.0/http/server.ts' +import { corsHeaders } from '../_shared/cors.ts' + +console.log('Hello from Cloudflare Trunstile!') + +function ips(req: Request) { + return req.headers.get('x-forwarded-for')?.split(/\s*,\s*/) +} + +serve(async (req) => { + // This is needed if you're planning to invoke your function from a browser. + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }) + } + + const { token } = await req.json() + const clientIps = ips(req) || [''] + const ip = clientIps[0] + + // Validate the token by calling the + // "/siteverify" API endpoint. + let formData = new FormData() + formData.append('secret', Deno.env.get('CLOUDFLARE_SECRET_KEY') ?? '') + formData.append('response', token) + formData.append('remoteip', ip) + + const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify' + const result = await fetch(url, { + body: formData, + method: 'POST', + }) + + const outcome = await result.json() + console.log(outcome) + if (outcome.success) { + return new Response('success', { headers: corsHeaders }) + } + return new Response('failure', { headers: corsHeaders }) +}) +``` + +## Deploy the server-side validation Edge Functions + +- https://developers.cloudflare.com/turnstile/get-started/server-side-validation/ + +```bash +supabase functions deploy cloudflare-turnstile +supabase secrets set CLOUDFLARE_TURNSTILE_SECRET_KEY=your_secret_key +``` + +## Invoke the function from your site + +```js +const { data, error } = await supabase.functions.invoke('cloudflare-turnstile', { + body: { token }, +}) +``` + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/functions/examples/github-actions.mdx b/apps/docs/pages/guides/functions/examples/github-actions.mdx new file mode 100644 index 00000000000..97456f33767 --- /dev/null +++ b/apps/docs/pages/guides/functions/examples/github-actions.mdx @@ -0,0 +1,49 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'examples-github-actions', + title: 'GitHub Actions', + description: 'Deploying Edge Functions with GitHub Actions.', +} + +
+ +
+ +Use the Supabase CLI together with GitHub Actions to automatically deploy our Supabase Edge Functions. [View on GitHub](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/github-action-deploy). + +```yaml deploy.yaml +name: Deploy Function + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + + env: + SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} + PROJECT_ID: zdtdtxajzydjqzuktnqx + + steps: + - uses: actions/checkout@v3 + + - uses: supabase/setup-cli@v1 + with: + version: 1.0.0 + + - run: supabase functions deploy github-action-deploy --project-ref $PROJECT_ID +``` + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/functions/examples/og-image.mdx b/apps/docs/pages/guides/functions/examples/og-image.mdx index 2974d5ef853..e4be6c85796 100644 --- a/apps/docs/pages/guides/functions/examples/og-image.mdx +++ b/apps/docs/pages/guides/functions/examples/og-image.mdx @@ -17,6 +17,10 @@ export const meta = { Generate Open Graph images with Deno and Supabase Edge Functions. [View on GitHub](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/opengraph). +## Code + +Create a `handler.tsx` file to construct the OG image in React: + ```tsx handler.tsx import React from 'https://esm.sh/react@18.2.0' import { ImageResponse } from 'https://deno.land/x/og_edge@0.0.4/mod.ts' @@ -42,6 +46,8 @@ export default function handler(req: Request) { } ``` +Create an `index.ts` file to execute the handler on incoming requests: + ```ts index.ts import { serve } from 'https://deno.land/std@0.131.0/http/server.ts' import handler from './handler.tsx' diff --git a/apps/docs/pages/guides/functions/examples/storage-caching.mdx b/apps/docs/pages/guides/functions/examples/storage-caching.mdx new file mode 100644 index 00000000000..6e4b6ff604e --- /dev/null +++ b/apps/docs/pages/guides/functions/examples/storage-caching.mdx @@ -0,0 +1,22 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'examples-storage-caching', + title: 'Caching Images with Supabase Storage CDN', + description: 'Integrate Edge Functions with Supabase Storage to cache images on the Edge (CDN).', +} + +
+ +
+ +Integrate Edge Functions with Supabase Storage to cache images on the Edge (CDN). [View on GitHub](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/og-image-with-storage-cdn). + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/functions/examples/stripe-webhooks.mdx b/apps/docs/pages/guides/functions/examples/stripe-webhooks.mdx new file mode 100644 index 00000000000..5b75bb48878 --- /dev/null +++ b/apps/docs/pages/guides/functions/examples/stripe-webhooks.mdx @@ -0,0 +1,22 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'examples-stripe-webhooks', + title: 'Handling Stripe Webhooks', + description: 'Handling signed Stripe Webhooks with Edge Functions.', +} + +
+ +
+ +Handling signed Stripe Webhooks with Edge Functions. [View on GitHub](https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/stripe-webhooks/index.ts). + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/functions/examples/telegram-bot.mdx b/apps/docs/pages/guides/functions/examples/telegram-bot.mdx new file mode 100644 index 00000000000..bac6a45eba2 --- /dev/null +++ b/apps/docs/pages/guides/functions/examples/telegram-bot.mdx @@ -0,0 +1,22 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'examples-telegram-bot', + title: 'Telegram Bot', + description: 'Building a Telegram Bot with Edge Functions.', +} + +
+ +
+ +Handle Telegram Bot Webhooks with the [grammY framework](https://grammy.dev/). grammY is an open source Telegram Bot Framework which makes it easy to handle and respond to incoming messages. [View on GitHub](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/telegram-bot). + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/functions/import-maps.mdx b/apps/docs/pages/guides/functions/import-maps.mdx new file mode 100644 index 00000000000..1509a9cd8a9 --- /dev/null +++ b/apps/docs/pages/guides/functions/import-maps.mdx @@ -0,0 +1,53 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'functions-import-maps', + title: 'Import Maps', + description: 'Using Import Maps.', +} + +Since [Supabase CLI](https://supabase.com/docs/guides/resources/supabase-cli) version `1.33.0` [import maps](https://deno.land/manual@v1.29.2/node/import_maps) can be used with Supabase Edge Functions. + +[Import maps](https://github.com/WICG/import-maps#the-import-map) is a web-platform standard that allows you to use bare specifiers with Deno without having to install the Node.js package locally. + +So if we want to do the following in our code: + +```ts, ignore +import lodash from "lodash"; +``` + +We can accomplish this using an import map, and we don't even have to install the `lodash` package locally. We would want to create a JSON file (for example **import_map.json**) with the following: + +```json +{ + "imports": { + "lodash": "https://cdn.skypack.dev/lodash" + } +} +``` + +## Import Map Placement + +We recommend creating one `import_map.json` within the `/supabase/functions` folder (see [Organizing your Edge Functions](/docs/guides/functions/quickstart#organizing-your-edge-functions)), similar to a `package.json` file, to define imports that can be used across all of your project's functions. + +Alternatively, you can create one `import_map.json` file in each function folder, which will take priority over a top-level file. + +Lastly, you can override this default behaviour by providing the `--import-map ` flag to the `serve` and `deploy` commands. + +## Visual Studio Code Configuration + +In order for vscode to understand the imports correctly, you need to specify the `deno.importMap` flag in your `.vscode/settings.json` file: + +```json settings.json +{ + "deno.enable": true, + "deno.unstable": true, + "deno.importMap": "./supabase/functions/import_map.json" +} +``` + +For a full guide on developing with Deno in Visual Studio Code, see [this guide](https://deno.land/manual@v1.25.4/vscode_deno). + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/functions/local-development.mdx b/apps/docs/pages/guides/functions/local-development.mdx new file mode 100644 index 00000000000..fffd6ac7072 --- /dev/null +++ b/apps/docs/pages/guides/functions/local-development.mdx @@ -0,0 +1,60 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'functions-local-development', + title: 'Local Development', + description: 'Run your functions locally.', +} + +You can run your Edge Function locally using [`supabase functions serve`](/docs/reference/cli/usage#supabase-functions-serve): + +```bash +supabase start # start the supabase stack +supabase functions serve hello-world # start the Function watcher +``` + +The `functions serve` command has hot-reloading capabilities. It will watch for any changes to your files and restart the Deno server. + +### Invoking functions locally + +While serving your local Function, you can invoke it using curl: + +```bash +curl --request POST 'http://localhost:54321/functions/v1/hello-world' \ + --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs' \ + --header 'Content-Type: application/json' \ + --data '{ "name":"Functions" }' +``` + +or using one of the [client libraries](/docs#reference-documentation), e.g. using [supabase-js](/docs/reference/javascript/functions-invoke): + +```js +import { createClient } from '@supabase/supabase-js' + +// Use the credentials outputted in your terminal when running `supabase start` +const supabase = createClient( + 'http://localhost:54321', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0' +) + +const { data, error } = await supabase.functions.invoke('hello-world', { + body: { name: 'Functions' }, +}) +``` + +You should see the response `{ "message":"Hello Functions!" }`. + +If you execute Function with a different payload the response will change.
+Modify the `--data '{"name":"Functions"}'` line to `--data '{"name":"World"}'` and try invoking the command again! + + + +- Edge Functions don't serve HTML content (`GET` requests that return `text/html` are rewritten to `text/plain`). +- The `Authorization` header is required. You can use either the `ANON` key, the `SERVICE_ROLE` key, or a logged-in user's JWT. +- The Function is proxied through the local API (`http://localhost:54321`) + + + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/functions/quickstart.mdx b/apps/docs/pages/guides/functions/quickstart.mdx index 55b9ed617c6..e4e17a3a708 100644 --- a/apps/docs/pages/guides/functions/quickstart.mdx +++ b/apps/docs/pages/guides/functions/quickstart.mdx @@ -3,7 +3,7 @@ import Layout from '~/layouts/DefaultGuideLayout' export const meta = { id: 'functions-quickstart', title: 'Edge Functions Quickstart', - description: 'Globally distributed TypeScript functions.', + description: 'Globally distributed TypeScript Functions.', sidebar_label: 'Quickstart', } @@ -55,9 +55,9 @@ If you want to use Edge Functions to handle webhooks (e.g. [Stripe payment webho
-## Execute remote functions +## Invoking remote functions -You can execute Edge Functions using curl. Copy the curl command from the Dashboard. It should look like this: +You can invoke Edge Functions using curl: ```bash curl --request POST 'https://.functions.supabase.co/hello-world' \ @@ -66,135 +66,61 @@ curl --request POST 'https://.functions.supabase.co/hello-world' \ --data '{ "name":"Functions" }' ``` + + If you receive an error `Invalid JWT`, find the `ANON_KEY` of your project in the Dashboard under `Settings > API`. + + +or using one of the [client libraries](/docs#reference-documentation), e.g. using [supabase-js](/docs/reference/javascript/functions-invoke): + +```js +import { createClient } from '@supabase/supabase-js' + +// Create a single supabase client for interacting with your database +const supabase = createClient('https://xyzcompany.supabase.co', 'public-anon-key') + +const { data, error } = await supabase.functions.invoke('hello-world', { + body: { name: 'Functions' }, +}) +``` + After invoking your Edge Function you should see the response `{ "message":"Hello Functions!" }`. -## Debug functions +## Database Functions vs Edge Functions -You can debug your deployed Edge Functions using the "Functions" section of the Dashboard. There are two types debugging tools available: +For data-intensive operations we recommend using [Database Functions](/docs/guides/database/functions), which are executed within your database +and can be called remotely using the [REST and GraphQL API](/docs/guides/api). -- Invocations: shows the Request and Response for each execution. -- Logs: shows any platform events, including deployments and errors. +For use-cases which require low-latency we recommend [Edge Functions](/docs/guides/functions), which are globally-distributed and can be written in TypeScript. -![Function invocations.](/docs/img/guides/functions/function-logs.png) +## Organizing your Edge Functions -## Develop locally - -You can run your Edge Function locally using [`supabase functions serve`](/docs/reference/cli/usage#supabase-functions-serve): +We recommend developing “fat functions”. This means that you should develop few large functions, rather than many small functions. One common pattern when developing Functions is that you need to share code between two or more Functions. To do this, you can store any shared code in a folder prefixed with an underscore (`_`). We recommend this folder structure: ```bash -supabase start # start the supabase stack -supabase functions serve hello-world # start the Function watcher +└── supabase + ├── functions + │ ├── import_map.json # A top-level import map to use across functions. + │ ├── _shared + │ │ ├── supabaseAdmin.ts # Supabase client with SERVICE_ROLE key. + │ │ └── supabaseClient.ts # Supabase client with ANON key. + │ │ └── cors.ts # Reusable CORS headers. + │ ├── function-one # Use hyphens to name functions. + │ │ └── index.ts + │ └── function-two + │ └── index.ts + ├── migrations + └── config.toml ``` -The `functions serve` command has hot-reloading capabilities. It will watch for any changes to your files and restart the Deno server. +## Naming Edge Functions -### Invoke functions locally +We recommend using hyphens to name functions because hyphens are the most URL-friendly of all the naming conventions (snake_case, camelCase, PascalCase). -While serving your local Function, you can execute it using curl: +## Using HTTP Methods -```bash -curl --request POST 'http://localhost:54321/functions/v1/hello-world' \ - --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs' \ - --header 'Content-Type: application/json' \ - --data '{ "name":"Functions" }' -``` - -You should see the response `{ "message":"Hello Functions!" }`. - -
-Implementation details - -- Edge Functions don't serve HTML content (`GET` requests that return `text/html` are rewritten to `text/plain`). -- The `Authorization` header is required. You can use either the `ANON` key, the `SERVICE_ROLE` key, or a logged-in user's JWT. -- The Function is proxied through the local API (`http://localhost:54321`) - -
-
- -If you execute Function with a different payload the response will change.
-Modify the `--data '{"name":"Functions"}'` line to `--data '{"name":"World"}'` and try invoking the command again! - -## Secrets and Environment Variables - -It's common that you will need to use sensitive information or environment-specific variables inside your Edge Functions. You can access these using Deno's built-in handler - -```js -Deno.env.get(MY_SECRET_NAME) -``` - -### Default secrets - -By default, Edge Functions have access to these secrets: - -- `SUPABASE_URL`: The API gateway for your Supabase project. -- `SUPABASE_ANON_KEY`: The `anon` key for your Supabase API. This is safe to use in a browser when you have [Row Level Security](/docs/guides/auth/row-level-security) enabled. -- `SUPABASE_SERVICE_ROLE_KEY`: The `service_role` key for your Supabase API. This is safe to use in Edge Functions, but it should NEVER be used in a browser. This key will bypass [Row Level Security](/docs/guides/auth/row-level-security). -- `SUPABASE_DB_URL`: The URL for your [PostgreSQL database](/docs/guides/database). You can use this to connect directly to your database. - -### Local secrets - -Let's create a local file for storing our secrets, and inside it we can store a secret `MY_NAME`: - -```jsx -echo "MY_NAME=Yoda" >> ./supabase/.env.local -``` - -This creates a new file `./supabase/.env.local` for storing your local development secrets. - - - -Never check your .env files into Git! - - - -Now let's access this environment variable `MY_NAME` inside our Function. Anywhere in your function, add this line: - -```jsx -console.log(Deno.env.get('MY_NAME')) -``` - -Now we can invoke our function locally, by serving it with our new `.env.local` file: - -```bash -supabase functions serve hello-world --env-file ./supabase/.env.local -``` - -When the function starts you should see the name “Yoda” output to the terminal. - -### Production secrets - -Let's create a `.env` for production. In this case we'll just use the same as our local secrets: - -```bash -cp ./supabase/.env.local ./supabase/.env -``` - -This creates a new file `./supabase/.env` for storing your production secrets. - - - -Never check your `.env` files into Git! - - - -Let's push all the secrets from the `.env` file to our remote project using [`supabase secrets set`](/docs/reference/cli/usage#supabase-secrets-set): - -```bash -supabase secrets set --env-file ./supabase/.env - -# You can also set secrets individually using: -supabase secrets set MY_NAME=Chewbacca -``` - -You don't need to re-deploy after setting your secrets. - -To see all the secrets which you have set remotely, use [`supabase secrets list`](/docs/reference/cli/usage#supabase-secrets-list): - -```bash -supabase secrets list -``` +Edge Functions supports `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, and `OPTIONS`. A function can be designed to perform different actions based on a request's HTTP method. See the [example on building a RESTful service](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/restful-tasks) to learn how to handle different HTTP methods in your function. ## Limitations diff --git a/apps/docs/pages/guides/functions/schedule-functions.mdx b/apps/docs/pages/guides/functions/schedule-functions.mdx new file mode 100644 index 00000000000..c5108b55523 --- /dev/null +++ b/apps/docs/pages/guides/functions/schedule-functions.mdx @@ -0,0 +1,42 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'schedule-functions', + title: 'Schedule Functions', + description: 'Schedule Functions with pg_cron.', +} + +The hosted Supabase Platform supports the [`pg_cron` extension](/docs/guides/database/extensions/pgcron), a simple cron-based job scheduler for PostgreSQL that runs inside the database. + +In combination with the [`pg_net` extension](/docs/guides/database/extensions/pgnet), this allows us to invoke functions periodically on a set schedule. + +## Examples + +### Invoke Function every minute + +Make a POST request to a Supabase Edge Function every minute: + +```sql +select + cron.schedule( + 'invoke-function-every-minute', + '* * * * *', -- every minute + $$ + select + net.http_post( + url:='https://project-ref.functions.supabase.co/function-name', + headers:='{"Content-Type": "application/json", "Authorization": "Bearer YOUR_ANON_KEY"}'::jsonb, + body:=concat('{"time": "', now(), '"}')::jsonb + ) as request_id; + $$ + ); +``` + +## Resources + +- [`pg_net` extension](/docs/guides/database/extensions/pgnet) +- [`pg_cron` extension](/docs/guides/database/extensions/pgcron) + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/functions/secrets.mdx b/apps/docs/pages/guides/functions/secrets.mdx new file mode 100644 index 00000000000..dd51094d374 --- /dev/null +++ b/apps/docs/pages/guides/functions/secrets.mdx @@ -0,0 +1,89 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'functions-secrets', + title: 'Secrets and Environment Variables', + description: 'Managing secrets and environment variables.', +} + +It's common that you will need to use sensitive information or environment-specific variables inside your Edge Functions. You can access these using Deno's built-in handler + +```js +Deno.env.get(MY_SECRET_NAME) +``` + +### Default secrets + +By default, Edge Functions have access to these secrets: + +- `SUPABASE_URL`: The API gateway for your Supabase project. +- `SUPABASE_ANON_KEY`: The `anon` key for your Supabase API. This is safe to use in a browser when you have [Row Level Security](/docs/guides/auth/row-level-security) enabled. +- `SUPABASE_SERVICE_ROLE_KEY`: The `service_role` key for your Supabase API. This is safe to use in Edge Functions, but it should NEVER be used in a browser. This key will bypass [Row Level Security](/docs/guides/auth/row-level-security). +- `SUPABASE_DB_URL`: The URL for your [PostgreSQL database](/docs/guides/database). You can use this to connect directly to your database. + +### Local secrets + +Let's create a local file for storing our secrets, and inside it we can store a secret `MY_NAME`: + +```jsx +echo "MY_NAME=Yoda" >> ./supabase/.env.local +``` + +This creates a new file `./supabase/.env.local` for storing your local development secrets. + + + +Never check your .env files into Git! + + + +Now let's access this environment variable `MY_NAME` inside our Function. Anywhere in your function, add this line: + +```jsx +console.log(Deno.env.get('MY_NAME')) +``` + +Now we can invoke our function locally, by serving it with our new `.env.local` file: + +```bash +supabase functions serve hello-world --env-file ./supabase/.env.local +``` + +When the function starts you should see the name “Yoda” output to the terminal. + +### Production secrets + +Let's create a `.env` for production. In this case we'll just use the same as our local secrets: + +```bash +cp ./supabase/.env.local ./supabase/.env +``` + +This creates a new file `./supabase/.env` for storing your production secrets. + + + +Never check your `.env` files into Git! + + + +Let's push all the secrets from the `.env` file to our remote project using [`supabase secrets set`](/docs/reference/cli/usage#supabase-secrets-set): + +```bash +supabase secrets set --env-file ./supabase/.env + +# You can also set secrets individually using: +supabase secrets set MY_NAME=Chewbacca +``` + +You don't need to re-deploy after setting your secrets. + +To see all the secrets which you have set remotely, use [`supabase secrets list`](/docs/reference/cli/usage#supabase-secrets-list): + +```bash +supabase secrets list +``` + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/integrations/prisma.mdx b/apps/docs/pages/guides/integrations/prisma.mdx index 6a80e95fee6..840028a00c7 100644 --- a/apps/docs/pages/guides/integrations/prisma.mdx +++ b/apps/docs/pages/guides/integrations/prisma.mdx @@ -68,7 +68,7 @@ By default, Prisma migrations will try to drop the `postgres` database, which ca Create a shadow database in your PostgreSQL server within the same Supabase project using the `psql` CLI and the `DATABASE_URL` from the previous steps (or use the local database). ```bash -psql postgresql://postgres:[YOUR-PASSWORD]@db.vdbnhqozmlzdsaejdxwr.supabase.red:5432 +psql postgresql://postgres:[YOUR-PASSWORD]@db.[YOUR-PROJECT-REF].supabase.co:5432 ``` After you connect to your project's PostgreSQL instance, create another database (e.g., `postgres_shadow`): @@ -83,8 +83,8 @@ In the `.env` file, update `DATABASE_URL` and `SHADOW_DATABASE_URL` to the conne ```env # .env -DATABASE_URL="postgres://postgres:[YOUR-PASSWORD]@db.vdbnhqozmlzdsaejdxwr.supabase.co:5432/postgres" -SHADOW_DATABASE_URL="postgres://postgres:[YOUR-PASSWORD]@db.vdbnhqozmlzdsaejdxwr.supabase.co:5432/postgres_shadow" +DATABASE_URL="postgres://postgres:[YOUR-PASSWORD]@db.[YOUR-PROJECT-REF].supabase.co:5432/postgres" +SHADOW_DATABASE_URL="postgres://postgres:[YOUR-PASSWORD]@db.[YOUR-PROJECT-REF].supabase.co:5432/postgres_shadow" ``` In the `schema.prisma` file, change the `provider` from "sqlite" to `"postgresql"` and add the `shadowDatabaseUrl` property. @@ -145,8 +145,8 @@ When migrating, you need to use the non-pooled connection URL (like the one used ```env # .env -DATABASE_URL="postgres://postgres:[YOUR-PASSWORD]@db.vdbnhqozmlzdsaejdxwr.supabase.co:6543/postgres?pgbouncer=true&connection_limit=1" -SHADOW_DATABASE_URL="postgres://postgres:[YOUR-PASSWORD]@db.vdbnhqozmlzdsaejdxwr.supabase.co:5432/postgres_shadow" +DATABASE_URL="postgres://postgres:[YOUR-PASSWORD]@db.[YOUR-PROJECT-REF].supabase.co:6543/postgres?pgbouncer=true&connection_limit=1" +SHADOW_DATABASE_URL="postgres://postgres:[YOUR-PASSWORD]@db.[YOUR-PROJECT-REF].supabase.co:5432/postgres_shadow" ``` Prisma Migrate uses database transactions to check out the current state of the database and the migrations table. However, the Migration Engine is designed to use a single connection to the database, and does not support connection pooling with PgBouncer. If you attempt to run Prisma Migrate commands in any environment that uses PgBouncer for connection pooling, you might see the following error: diff --git a/apps/www/_blog/2022-08-16-supabase-js-v2.mdx b/apps/www/_blog/2022-08-16-supabase-js-v2.mdx index d7986434a97..07cf3fd54dc 100644 --- a/apps/www/_blog/2022-08-16-supabase-js-v2.mdx +++ b/apps/www/_blog/2022-08-16-supabase-js-v2.mdx @@ -13,7 +13,7 @@ toc_depth: 3

⚠️ UPDATED 20/10: supabase-js v2 is fully released 🥳
-Check the updated docs and migration guide. +Check the updated docs and migration guide.
@@ -309,7 +309,7 @@ Update today by running: npm i @supabase/supabase-js@2 ``` -[Migration guide](https://supabase.com/docs/reference/javascript/upgrade-guide) +[Migration guide](https://supabase.com/docs/reference/javascript/v1/upgrade-guide) We'll continuing merging security fixes to v1, with maintenance patches for the next three months. @@ -329,6 +329,6 @@ We'll continuing merging security fixes to v1, with maintenance patches for the ## supabase-js v2 resources - [v2 Documentation](https://supabase.com/docs/reference/javascript) -- [Migration guide](https://supabase.com/docs/reference/javascript/upgrade-guide) +- [Migration guide](https://supabase.com/docs/reference/javascript/v1/upgrade-guide) - [Next.js quickstart guide](/docs/guides/with-nextjs) - [Examples](https://github.com/supabase/supabase/tree/master/examples) diff --git a/apps/www/_blog/2022-10-20-supabase-js-v2-released.mdx b/apps/www/_blog/2022-10-20-supabase-js-v2-released.mdx index 6e17afd4d14..6c2fca19cd8 100644 --- a/apps/www/_blog/2022-10-20-supabase-js-v2-released.mdx +++ b/apps/www/_blog/2022-10-20-supabase-js-v2-released.mdx @@ -2,7 +2,7 @@ title: 'supabase-js v2 Released' description: We've released supabase-js v2. Updated examples, quickstarts, and an improved experience. author: thor_schaeff -image: supabase-js-v2-release/supabase-js.jpg +image: supabase-js-v2-release/supabase-js.jpg thumb: supabase-js-v2-release/supabase-js.jpg tags: - launch-week @@ -10,17 +10,17 @@ date: '2022-10-20' toc_depth: 3 --- -During our [last Launch Week](/launch-week) we presented the [release candidate for supabase-js v2](/blog/supabase-js-v2). Since then we've been busy incorporating your feedback, [updating the docs](/docs/reference/javascript/), [examples](https://github.com/supabase/supabase/tree/master/examples), [quickstart guides](/docs/guides/with-nextjs), and putting a [migration guide](/docs/reference/javascript/upgrade-guide) together. +During our [last Launch Week](/launch-week) we presented the [release candidate for supabase-js v2](/blog/supabase-js-v2). Since then we've been busy incorporating your feedback, [updating the docs](/docs/reference/javascript/), [examples](https://github.com/supabase/supabase/tree/master/examples), [quickstart guides](/docs/guides/with-nextjs), and putting a [migration guide](/docs/reference/javascript/v1/upgrade-guide) together. ## What is new in v2? -Enhanced TypeScript support built right in was the big one! Now you can use the CLI to generate types, directly from your database. +Enhanced TypeScript support built right in was the big one! Now you can use the CLI to generate types, directly from your database. -Plus v2 comes with lots of improvements that solved some of the largest pain-points highlighted by our users. Your feedback help us improve: you speak, we listen. +Plus v2 comes with lots of improvements that solved some of the largest pain-points highlighted by our users. Your feedback help us improve: you speak, we listen. Read all the updates and the differences with v1 on the [announcement blog post](/blog/supabase-js-v2) -## Acknowledgements +## Acknowledgements It truly takes a village, well, in this case an entire community, and we want to thank you all for your feedback, and contributions! @@ -40,7 +40,7 @@ Special shout outs to: [@vejja](https://github.com/vejja), [@pixtron](https://gi ## Links - [Documentation](/docs/reference/javascript) -- [Migration Guide](/docs/reference/javascript/upgrade-guide) +- [Migration Guide](/docs/reference/javascript/v1/upgrade-guide) - [Quickstart Guides](/docs/guides/with-nextjs) - [Examples (GitHub)](https://github.com/supabase/supabase/tree/master/examples) - [Release Notes](/docs/reference/javascript/release-notes) diff --git a/apps/www/_blog/2022-10-21-supabase-flutter-sdk-v1-released.mdx b/apps/www/_blog/2022-10-21-supabase-flutter-sdk-v1-released.mdx index e7fdabdc869..92aed5cbb3b 100644 --- a/apps/www/_blog/2022-10-21-supabase-flutter-sdk-v1-released.mdx +++ b/apps/www/_blog/2022-10-21-supabase-flutter-sdk-v1-released.mdx @@ -2,7 +2,7 @@ title: 'supabase-flutter v1 Released' description: We've released supabase-flutter v1. More intuitive way of accessing Supabase from your Flutter application. author: tyler_shukert -image: flutter-v1-release/flutter_v1_official_release.jpeg +image: flutter-v1-release/flutter_v1_official_release.jpeg thumb: flutter-v1-release/flutter_v1_official_release.jpeg tags: - flutter @@ -11,7 +11,7 @@ date: '2022-10-21' toc_depth: 3 --- -A few months ago, we announced a [developer preview version of supabase-flutter SDK](https://supabase.com/blog/supabase-flutter-sdk-1-developer-preview). Since then, we have heard a lot of amazing feedback from the community, and have been improving it. Today, we are happy to announce the stable v1 of [supabase-flutter](https://pub.dev/packages/supabase_flutter). You can also find the updated [quick start guide](https://supabase.com/docs/guides/with-flutter), [documentation](https://supabase.com/docs/reference/dart) and a [migration guide from v0](https://supabase.com/docs/reference/dart/upgrade-guide). +A few months ago, we announced a [developer preview version of supabase-flutter SDK](https://supabase.com/blog/supabase-flutter-sdk-1-developer-preview). Since then, we have heard a lot of amazing feedback from the community, and have been improving it. Today, we are happy to announce the stable v1 of [supabase-flutter](https://pub.dev/packages/supabase_flutter). You can also find the updated [quick start guide](https://supabase.com/docs/guides/with-flutter), [documentation](https://supabase.com/docs/reference/dart) and a [migration guide from v0](https://supabase.com/docs/reference/dart/v0/upgrade-guide). ## What is new in v1? @@ -97,7 +97,7 @@ It required massive support from the community to bring the supabase-flutter to - [Install supabase-flutter v1.0](https://pub.dev/packages/supabase_flutter) - [supabase-flutter documentation](https://supabase.com/docs/reference/dart/) -- [v0 to v1 migration guide](https://supabase.com/docs/reference/dart/upgrade-guide) +- [v0 to v1 migration guide](https://supabase.com/docs/reference/dart/v0/upgrade-guide) - [Flutter Tutorial: building a Flutter chat app](https://supabase.com/blog/flutter-tutorial-building-a-chat-app) - [Flutter Tutorial - Part 2: Authentication and Authorization with RLS](https://supabase.com/blog/flutter-authentication-and-authorization-with-rls) - [Build a Flutter app with Very Good CLI and Supabase](https://verygood.ventures/blog/flutter-app-very-good-cli-supabase) diff --git a/apps/www/data/Community.tsx b/apps/www/data/Community.tsx index 4a48cca53dd..ea9573cdcc4 100644 --- a/apps/www/data/Community.tsx +++ b/apps/www/data/Community.tsx @@ -30,7 +30,7 @@ const data: CommunityItem[] = [ }, { title: 'GitHub', - stat: '42,000+', + stat: '44,000+', statLabel: 'GitHub stars', img: 'github.png', invertImgDarkMode: true, @@ -42,7 +42,7 @@ const data: CommunityItem[] = [ }, { title: 'Twitter', - stat: '50,000+', + stat: '54,000+', statLabel: 'Followers', img: 'twitter.png', detail: () => ( @@ -53,7 +53,7 @@ const data: CommunityItem[] = [ }, { title: 'Discord', - stat: '10,000+', + stat: '12,000+', statLabel: 'SupaTroopers', img: 'discord.png', detail: () => ( diff --git a/apps/www/lib/redirects.js b/apps/www/lib/redirects.js index 1e992b67d9e..420340205b9 100644 --- a/apps/www/lib/redirects.js +++ b/apps/www/lib/redirects.js @@ -1554,6 +1554,11 @@ module.exports = [ source: '/docs/guides/functions/examples', destination: '/docs/guides/functions', }, + { + permanent: true, + source: '/docs/guides/functions/best-practices', + destination: '/docs/guides/functions/quickstart', + }, { permanent: true, source: '/projects', @@ -1886,4 +1891,14 @@ module.exports = [ source: '/legal/soc2', destination: 'https://forms.supabase.com/soc2', }, + { + permanent: true, + source: '/docs/reference/javascript/upgrade-guide', + destination: '/docs/reference/javascript/v1/upgrade-guide', + }, + { + permanent: true, + source: '/docs/reference/dart/upgrade-guide', + destination: '/docs/reference/dart/v0/upgrade-guide', + }, ] diff --git a/apps/www/pages/realtime/Realtime.tsx b/apps/www/pages/realtime/Realtime.tsx index cc062d0bde4..633b7bc7bae 100644 --- a/apps/www/pages/realtime/Realtime.tsx +++ b/apps/www/pages/realtime/Realtime.tsx @@ -238,7 +238,7 @@ function RealtimePage() {

, ]} // [TODO] Point to the correct docs URL - documentation_link={'/docs/guides//docs/guides/realtime/broadcast'} + documentation_link={'/docs/guides/realtime#broadcast'} /> diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index db91090408e..74bd0f1e828 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -114,7 +114,7 @@ services: PORT: 4000 DB_HOST: ${POSTGRES_HOST} DB_PORT: ${POSTGRES_PORT} - DB_USER: ${POSTGRES_USER} + DB_USER: supabase_admin DB_PASSWORD: ${POSTGRES_PASSWORD} DB_NAME: ${POSTGRES_DB} DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' diff --git a/spec/common-client-libs-sections.json b/spec/common-client-libs-sections.json index c290fb91859..8f4dc1f62d0 100644 --- a/spec/common-client-libs-sections.json +++ b/spec/common-client-libs-sections.json @@ -650,7 +650,7 @@ { "id": "invoke", "title": "Invoke a function", - "slug": "auth-admin-invoke", + "slug": "functions-invoke", "product": "functions", "type": "function" } diff --git a/spec/enrichments/tsdoc_v2/combined.json b/spec/enrichments/tsdoc_v2/combined.json index 0c72523e032..57242f4b5bf 100644 --- a/spec/enrichments/tsdoc_v2/combined.json +++ b/spec/enrichments/tsdoc_v2/combined.json @@ -79596,7 +79596,7 @@ "kindString": "Call signature", "flags": {}, "comment": { - "shortText": "Retrieves the URL for an asset in a public bucket.\nThis function does not verify if the bucket is public. If a public URL is created for a bucket which is not public, you will not be able to download the asset." + "shortText": "A simple convenience function to get the URL for an asset in a public bucket. If you do not want to use this function, you can construct the public URL by concatenating the bucket URL with the path to the asset.\nThis function does not verify if the bucket is public. If a public URL is created for a bucket which is not public, you will not be able to download the asset." }, "parameters": [ { diff --git a/spec/enrichments/tsdoc_v2/storage.json b/spec/enrichments/tsdoc_v2/storage.json index 475ba699fc3..fb8baa1c895 100644 --- a/spec/enrichments/tsdoc_v2/storage.json +++ b/spec/enrichments/tsdoc_v2/storage.json @@ -5471,7 +5471,7 @@ "kindString": "Call signature", "flags": {}, "comment": { - "shortText": "Retrieves the URL for an asset in a public bucket.\nThis function does not verify if the bucket is public. If a public URL is created for a bucket which is not public, you will not be able to download the asset." + "shortText": "A simple convenience function to get the URL for an asset in a public bucket. If you do not want to use this function, you can construct the public URL by concatenating the bucket URL with the path to the asset.\nThis function does not verify if the bucket is public. If a public URL is created for a bucket which is not public, you will not be able to download the asset." }, "parameters": [ { diff --git a/spec/enrichments/tsdoc_v2/storage_dereferenced.json b/spec/enrichments/tsdoc_v2/storage_dereferenced.json index 634dd23b469..53596ea9650 100644 --- a/spec/enrichments/tsdoc_v2/storage_dereferenced.json +++ b/spec/enrichments/tsdoc_v2/storage_dereferenced.json @@ -5875,7 +5875,7 @@ "kindString": "Call signature", "flags": {}, "comment": { - "shortText": "Retrieves the URL for an asset in a public bucket.\nThis function does not verify if the bucket is public. If a public URL is created for a bucket which is not public, you will not be able to download the asset." + "shortText": "A simple convenience function to get the URL for an asset in a public bucket. If you do not want to use this function, you can construct the public URL by concatenating the bucket URL with the path to the asset.\nThis function does not verify if the bucket is public. If a public URL is created for a bucket which is not public, you will not be able to download the asset." }, "parameters": [ { diff --git a/spec/supabase_js_v2.yml b/spec/supabase_js_v2.yml index 7e220e2dc23..0cd16c93dbd 100644 --- a/spec/supabase_js_v2.yml +++ b/spec/supabase_js_v2.yml @@ -3429,8 +3429,8 @@ functions: code: | ```ts const { data, error } = await supabase - .from('cities') - .select('name', 'country_id') + .from('countries') + .select('id', 'name') .order('id', { ascending: false }) ``` data: diff --git a/studio/components/grid/components/header/Header.tsx b/studio/components/grid/components/header/Header.tsx index 1b3e8ae89bc..4ad75e4eaef 100644 --- a/studio/components/grid/components/header/Header.tsx +++ b/studio/components/grid/components/header/Header.tsx @@ -40,10 +40,7 @@ const Header: FC = ({ sorts, filters, onAddColumn, onAddRow, header onAddRow={onAddRow} /> )} -
- - {headerActions} -
+
{headerActions}
) } @@ -77,6 +74,7 @@ const DefaultHeader: FC = ({ sorts, filters, onAddColumn, on return (
+
diff --git a/studio/components/interfaces/Auth/Policies/Policies.tsx b/studio/components/interfaces/Auth/Policies/Policies.tsx new file mode 100644 index 00000000000..550bd59f740 --- /dev/null +++ b/studio/components/interfaces/Auth/Policies/Policies.tsx @@ -0,0 +1,212 @@ +import { useState, FC } from 'react' +import { isEmpty } from 'lodash' +import { IconHelpCircle } from 'ui' +import { useRouter } from 'next/router' +import { observer } from 'mobx-react-lite' +import { useParams, useStore } from 'hooks' +import { PolicyEditorModal, PolicyTableRow } from 'components/interfaces/Auth/Policies' +import { PostgresRole, PostgresTable } from '@supabase/postgres-meta' + +import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog' +import NoSearchResults from 'components/to-be-cleaned/NoSearchResults' +import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' +import InformationBox from 'components/ui/InformationBox' + +interface Props { + tables: PostgresTable[] + hasTables: boolean + isLocked: boolean +} + +const Policies: FC = ({ tables, hasTables, isLocked }) => { + const router = useRouter() + const { ref } = useParams() + + const { ui, meta } = useStore() + const roles = meta.roles.list((role: PostgresRole) => !meta.roles.systemRoles.includes(role.name)) + + const [selectedSchemaAndTable, setSelectedSchemaAndTable] = useState({}) + const [selectedTableToToggleRLS, setSelectedTableToToggleRLS] = useState({}) + const [selectedPolicyToEdit, setSelectedPolicyToEdit] = useState({}) + const [selectedPolicyToDelete, setSelectedPolicyToDelete] = useState({}) + + const closePolicyEditorModal = () => { + setSelectedPolicyToEdit({}) + setSelectedSchemaAndTable({}) + } + + const closeConfirmModal = () => { + setSelectedPolicyToDelete({}) + setSelectedTableToToggleRLS({}) + } + + const onSelectToggleRLS = (table: any) => { + setSelectedTableToToggleRLS(table) + } + + const onSelectCreatePolicy = (table: any) => { + setSelectedSchemaAndTable({ schema: table.schema, table: table.name }) + } + + const onSelectEditPolicy = (policy: any) => { + setSelectedPolicyToEdit(policy) + setSelectedSchemaAndTable({ schema: policy.schema, table: policy.table }) + } + + const onSelectDeletePolicy = (policy: any) => { + setSelectedPolicyToDelete(policy) + } + + const onSavePolicySuccess = async () => { + ui.setNotification({ category: 'success', message: 'Policy successfully saved!' }) + closePolicyEditorModal() + } + + // Methods that involve some API + const onToggleRLS = async () => { + const payload = { + id: selectedTableToToggleRLS.id, + rls_enabled: !selectedTableToToggleRLS.rls_enabled, + } + + const res: any = await meta.tables.update(payload.id, payload) + if (res.error) { + ui.setNotification({ + category: 'error', + message: `Failed to toggle RLS: ${res.error.message}`, + }) + } + closeConfirmModal() + } + + const onCreatePolicy = async (payload: any) => { + const res = await meta.policies.create(payload) + if (res.error) { + ui.setNotification({ + category: 'error', + message: `Error adding policy: ${res.error.message}`, + }) + return true + } + return false + } + + const onUpdatePolicy = async (payload: any) => { + const res = await meta.policies.update(payload.id, payload) + if (res.error) { + ui.setNotification({ + category: 'error', + message: `Error updating policy: ${res.error.message}`, + }) + return true + } + return false + } + + const onDeletePolicy = async () => { + const res = await meta.policies.del(selectedPolicyToDelete.id) + if (typeof res !== 'boolean' && res.error) { + ui.setNotification({ + category: 'error', + message: `Error deleting policy: ${res.error.message}`, + }) + } else { + ui.setNotification({ category: 'success', message: 'Successfully deleted policy!' }) + } + closeConfirmModal() + } + + return ( + <> + {tables.length > 0 ? ( + tables.map((table: any) => ( +
+ +
+ )) + ) : hasTables ? ( + + ) : ( +
+ router.push(`/project/${ref}/editor`)} + > +
+ } + description={ +
+

+ Policies restrict, on a per-user basis, which rows can be returned by normal + queries, or inserted, updated, or deleted by data modification commands. +

+

+ This is also known as Row-Level Security (RLS). Each policy is attached to a + table, and the policy is executed each time its accessed. +

+
+ } + /> +

+ Create a table in this schema first before creating a policy. +

+
+
+
+ )} + + + + + + + + ) +} + +export default observer(Policies) diff --git a/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx b/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx index e985b6af5db..1e2d975e7a4 100644 --- a/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx +++ b/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx @@ -9,12 +9,14 @@ import { checkPermissions } from 'hooks' interface Props { table: PostgresTable + isLocked: boolean onSelectToggleRLS: (table: PostgresTable) => void onSelectCreatePolicy: (table: PostgresTable) => void } const PolicyTableRowHeader: FC = ({ table, + isLocked, onSelectToggleRLS = () => {}, onSelectCreatePolicy = () => {}, }) => { @@ -35,63 +37,65 @@ const PolicyTableRowHeader: FC = ({ {table.rls_enabled ? 'RLS enabled' : 'RLS disabled'}
-
-
- - - - - {!canCreatePolicies && ( - - -
+
+ + +
- - )} - - - - - - {!canToggleRLS && ( - - -
+ + {!canCreatePolicies && ( + + +
+ + You need additional permissions to create RLS policies + +
+
+ )} + + + +
-
- )} -
+ {table.rls_enabled ? 'Disable RLS' : 'Enable RLS'} + + + {!canToggleRLS && ( + + +
+ + You need additional permissions to toggle RLS + +
+
+ )} + +
-
+ )} ) } diff --git a/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx b/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx index d181c0d6463..b9eafb7e119 100644 --- a/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx +++ b/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx @@ -9,50 +9,61 @@ import Panel from 'components/ui/Panel' interface Props { table: PostgresTable + isLocked: boolean onSelectToggleRLS: (table: PostgresTable) => void onSelectCreatePolicy: (table: PostgresTable) => void onSelectEditPolicy: (policy: PostgresPolicy) => void onSelectDeletePolicy: (policy: PostgresPolicy) => void } -const PolicyTableRow: FC = observer( - ({ - table, - onSelectToggleRLS = () => {}, - onSelectCreatePolicy = () => {}, - onSelectEditPolicy = () => {}, - onSelectDeletePolicy = () => {}, - }) => { - const { meta } = useStore() - const policies = meta.policies.list((x: PostgresPolicy) => x.table === table.name) - return ( - - } - > - {policies.length === 0 && ( -
-

No policies created yet

-
- )} +const PolicyTableRow: FC = ({ + table, + isLocked, + onSelectToggleRLS = () => {}, + onSelectCreatePolicy = () => {}, + onSelectEditPolicy = () => {}, + onSelectDeletePolicy = () => {}, +}) => { + const { meta } = useStore() + const policies = meta.policies.list((x: PostgresPolicy) => x.table === table.name) - {/* @ts-ignore */} - {policies.map((policy) => ( - - ))} -
- ) - } -) + return ( + + } + > + {policies.length === 0 && ( +
+

No policies created yet

+ {table.rls_enabled ? ( +

+ RLS is enabled - create a policy to allow access to this table. +

+ ) : ( +

+ Warning: RLS is disabled - anonymous access is allowed to this table +

+ )} +
+ )} -export default PolicyTableRow + {/* @ts-ignore */} + {policies.map((policy) => ( + + ))} +
+ ) +} + +export default observer(PolicyTableRow) diff --git a/studio/components/interfaces/Auth/Policies/index.tsx b/studio/components/interfaces/Auth/Policies/index.tsx index f1cd35fac60..129075d7344 100644 --- a/studio/components/interfaces/Auth/Policies/index.tsx +++ b/studio/components/interfaces/Auth/Policies/index.tsx @@ -1,7 +1,8 @@ +import Policies from './Policies' import PolicyEditorModal from './PolicyEditorModal' import PolicyTableRow from './PolicyTableRow' import PolicySelection from './PolicySelection' import PolicyTemplates from './PolicyTemplates' export * from './Policies.constants' -export { PolicyEditorModal, PolicyTableRow, PolicySelection, PolicyTemplates } +export { Policies, PolicyEditorModal, PolicyTableRow, PolicySelection, PolicyTemplates } diff --git a/studio/components/interfaces/Billing/EnterpriseUpdate.tsx b/studio/components/interfaces/Billing/EnterpriseUpdate.tsx index 969b5d58993..dd0481565d3 100644 --- a/studio/components/interfaces/Billing/EnterpriseUpdate.tsx +++ b/studio/components/interfaces/Billing/EnterpriseUpdate.tsx @@ -158,18 +158,6 @@ const EnterpriseUpdate: FC = ({ // Last todo to support enterprise billing on dashboard + E2E test const onConfirmPayment = async () => { setIsSubmitting(true) - let token = captchaToken - - try { - if (!token) { - const captchaResponse = await captchaRef.current?.execute({ async: true }) - token = captchaResponse?.response ?? null - } - } catch (error) { - setIsSubmitting(false) - return - } - const payload = { ...formSubscriptionUpdatePayload( currentSubscription, @@ -228,12 +216,12 @@ const EnterpriseUpdate: FC = ({
-
+

Change your project's subscription

diff --git a/studio/components/interfaces/Billing/PlanSelection/PlanSelection.tsx b/studio/components/interfaces/Billing/PlanSelection/PlanSelection.tsx index c2c52459e0e..3c54921b215 100644 --- a/studio/components/interfaces/Billing/PlanSelection/PlanSelection.tsx +++ b/studio/components/interfaces/Billing/PlanSelection/PlanSelection.tsx @@ -2,8 +2,9 @@ import Link from 'next/link' import { FC } from 'react' import { Transition } from '@headlessui/react' -import Plans from './Plans/Plans' +import PlanCard from './Plans/PlanCard' import { formatTierOptions } from './PlanSelection.utils' +import { Button, IconExternalLink } from 'ui' interface Props { visible: boolean @@ -16,7 +17,10 @@ const PlanSelection: FC = ({ visible, tiers, currentPlan, onSelectPlan }) const formattedTiers = formatTierOptions(tiers) // [Kevin] TODO Remove after team plan is generally available - const gridCols = formattedTiers.length === 3 ? 'gap-8 xl:grid-cols-3' : 'gap-4 xl:grid-cols-4' + const gridCols = + formattedTiers.length === 3 + ? 'gap-8 lg:grid-cols-3' + : 'gap-4 lg:grid-cols-3 xl:grid-cols-billingWithTeam' return ( = ({ visible, tiers, currentPlan, onSelectPlan })

Change your project's subscription

- + {formattedTiers.map((plan) => { + return ( + onSelectPlan(plan)} + /> + ) + })}
diff --git a/studio/components/interfaces/Billing/PlanSelection/Plans/PlanCTAButton.tsx b/studio/components/interfaces/Billing/PlanSelection/Plans/PlanCTAButton.tsx index 05dbf005a53..ca1595eaf6c 100644 --- a/studio/components/interfaces/Billing/PlanSelection/Plans/PlanCTAButton.tsx +++ b/studio/components/interfaces/Billing/PlanSelection/Plans/PlanCTAButton.tsx @@ -17,8 +17,8 @@ const PlanCTAButton: FC = ({ plan, currentPlan, onSelectPlan }) => { const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') const getButtonType = (plan: any, currentPlan: any) => { - if (['Enterprise', 'Free tier'].includes(plan.name)) { - // Enterprise/Free are always default + if (['Free tier'].includes(plan.name)) { + // Free is always default return 'default' } else if (currentPlan.prod_id === STRIPE_PRODUCT_IDS.FREE) { // If the current plan is free, other plans are primary @@ -90,7 +90,7 @@ const PlanCTAButton: FC = ({ plan, currentPlan, onSelectPlan }) => { return ( - @@ -100,20 +100,33 @@ const PlanCTAButton: FC = ({ plan, currentPlan, onSelectPlan }) => { return (
- - {!disabled && projectUpdateDisabled && ( -

- Subscription changes are currently disabled, our engineers are working on a fix -

- )} + + + + + {!disabled && projectUpdateDisabled && ( + + +
+ + Subscription changes are currently disabled, our engineers are working on a fix + +
+
+ )} +
) } diff --git a/studio/components/interfaces/Billing/PlanSelection/Plans/PlanCard.tsx b/studio/components/interfaces/Billing/PlanSelection/Plans/PlanCard.tsx new file mode 100644 index 00000000000..e9ebd88adec --- /dev/null +++ b/studio/components/interfaces/Billing/PlanSelection/Plans/PlanCard.tsx @@ -0,0 +1,131 @@ +import { STRIPE_PRODUCT_IDS } from 'lib/constants' +import { FC } from 'react' +import { IconCheck } from 'ui' + +import { useFlag } from 'hooks' +import PlanCTAButton from './PlanCTAButton' +import { PRICING_META } from './Plans.Constants' + +interface Props { + plan: any + currentPlan: any + onSelectPlan: () => void +} + +const PlanCard: FC = ({ plan, currentPlan, onSelectPlan }) => { + const teamTierEnabled = useFlag('teamTier') + + const planMeta = PRICING_META[plan.id] + const isEnterprise = plan.id === 'Enterprise' + const isCurrentPlan = + plan.id === currentPlan?.prod_id || + (plan.id === STRIPE_PRODUCT_IDS.PRO && currentPlan?.prod_id === STRIPE_PRODUCT_IDS.PRO) || + (plan.id === STRIPE_PRODUCT_IDS.PRO && currentPlan?.prod_id === STRIPE_PRODUCT_IDS.PAYG) + + return ( +
+
+
+
+

+ {planMeta.name} +

+ {isCurrentPlan ? ( +
+ Current plan +
+ ) : planMeta.new ? ( +
New
+ ) : ( + <> + )} +
+

{planMeta.description}

+
+ {planMeta.priceMonthly !== undefined ? ( + <> +

Starting from

+
+

${planMeta.priceMonthly}

+

{planMeta.priceUnit}

+
+
+ {planMeta.warning} +
+ + ) : ( + + )} +
+
+
+

{planMeta.preface}

+
    + {planMeta.features.map((feature) => ( +
  • +
    +
    +

    {feature}

    +
  • + ))} +
+
+
+ {!isEnterprise && ( +
+
+

{planMeta.additional}

+

{planMeta.scale}

+
+ + +
+ )} +
+ ) +} + +export default PlanCard diff --git a/studio/components/interfaces/Billing/PlanSelection/Plans/Plans.Constants.ts b/studio/components/interfaces/Billing/PlanSelection/Plans/Plans.Constants.ts index 1e8b6f8e918..025e54ea8a7 100644 --- a/studio/components/interfaces/Billing/PlanSelection/Plans/Plans.Constants.ts +++ b/studio/components/interfaces/Billing/PlanSelection/Plans/Plans.Constants.ts @@ -9,10 +9,11 @@ import { STRIPE_PRODUCT_IDS } from 'lib/constants' export const PRICING_META = { [STRIPE_PRODUCT_IDS.FREE]: { name: 'Free', - href: '#', - priceMonthly: 0, - warning: 'Limit of 2 free projects', description: 'Perfect for passion projects & simple websites.', + priceMonthly: 0, + priceUnit: 'per month per project', + warning: 'Limit of 2 free projects', + preface: 'Get started with:', features: [ 'Up to 500MB database & 1GB file storage', 'Up to 2GB bandwidth', @@ -25,17 +26,15 @@ export const PRICING_META = { '2 million Realtime messages', 'Community support', ], - additional: 'Free projects are paused after 1 week of inactivity.', - - cta: 'Get Started', + scale: 'Free projects are paused after 1 week of inactivity.', }, [STRIPE_PRODUCT_IDS.PRO]: { name: 'Pro', - href: '#', - from: true, - priceMonthly: 25, - warning: '+ any additional usage', description: 'For production applications with the option to scale.', + priceMonthly: 25, + priceUnit: 'per month per project', + warning: '+ any additional usage', + preface: 'Everything in the Free plan, plus:', features: [ '8GB database & 100GB file storage', '50GB bandwidth', @@ -50,19 +49,17 @@ export const PRICING_META = { 'No project pausing', 'Email support', ], - scale: 'Additional fees apply for usage and storage beyond the limits above.', - shutdown: '', - preface: 'Everything in the base plan, plus', additional: 'Need more? Turn off your spend cap to Pay As You Grow ', - cta: 'Get Started', + scale: 'Additional fees apply for usage and storage beyond the limits above.', }, [STRIPE_PRODUCT_IDS.TEAM]: { + new: true, name: 'Team', - href: '#', - from: true, + description: 'Collaborate with different permissions and access patterns.', priceMonthly: 599, + priceUnit: 'per month per organization', warning: '+ usage', - description: 'For collaborative teams with different permissions and access.', + preface: 'The following features will apply to all projects within the organization:', features: [ 'Usage-based pricing', '100,000 monthly active users included', @@ -75,10 +72,6 @@ export const PRICING_META = { '14 day backups', '28 day log retention', ], - scale: '', - shutdown: '', - additional: '', - cta: 'Get Started', }, Enterprise: { name: 'Enterprise', @@ -94,8 +87,5 @@ export const PRICING_META = { `On-premise support`, `24×7×365 premium enterprise support`, ], - scale: '', - shutdown: '', - cta: 'Contact Us', }, } diff --git a/studio/components/interfaces/Billing/PlanSelection/Plans/Plans.tsx b/studio/components/interfaces/Billing/PlanSelection/Plans/Plans.tsx deleted file mode 100644 index a08e0b5b14c..00000000000 --- a/studio/components/interfaces/Billing/PlanSelection/Plans/Plans.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { FC } from 'react' -import { IconCheck } from 'ui' - -import { STRIPE_PRODUCT_IDS } from 'lib/constants' -import { StripeProduct } from 'components/interfaces/Billing' -import PlanCTAButton from './PlanCTAButton' -import { PRICING_META } from './Plans.Constants' - -const AnimatedGradientBackground = () => ( -
-) - -const CurrentSubscriptionBanner = () => ( -
-

Current plan

-
-) - -interface Props { - plans: any[] - currentPlan?: StripeProduct - onSelectPlan: (plan: any) => void -} - -/** - * JSX below for the mapped plans is also very similar to www/pages/pricing - * TO DO: move to use shared components for these - * - * https://github.com/supabase/supabase/blob/master/www/pages/pricing/index.tsx - */ - -const Plans: FC = ({ plans, currentPlan, onSelectPlan }) => { - return ( - <> - {/* Existing available plans */} - {plans.map((plan) => { - const isCurrentPlan = - plan.id === currentPlan?.prod_id || - (plan.id === STRIPE_PRODUCT_IDS.PRO && currentPlan?.prod_id === STRIPE_PRODUCT_IDS.PRO) || - (plan.id === STRIPE_PRODUCT_IDS.PRO && currentPlan?.prod_id === STRIPE_PRODUCT_IDS.PAYG) - - return ( -
- {plan.id === STRIPE_PRODUCT_IDS.PRO && ( -
- -
- )} -
- {isCurrentPlan && } -
- - {PRICING_META[plan.id].name} - -
-
- {PRICING_META[plan.id].priceMonthly !== undefined ? ( - <> -
- {PRICING_META[plan.id].from && ( - From - )} - ${PRICING_META[plan.id].priceMonthly} - /mo -
- - ) : ( - Contact Us - )} -
- {PRICING_META[plan.id].warning && ( -
- {PRICING_META[plan.id].warning} -
- )} -
-
-
-
- -
-

{PRICING_META[plan.id].description}

-
-
-
- {PRICING_META[plan.id].preface && ( -

{PRICING_META[plan.id].preface}

- )} -
    - {PRICING_META[plan.id].features.map((feature) => ( -
  • -
    -
    -

    - {feature} -

    -
  • - ))} -
-
-
-
- {PRICING_META[plan.id].additional && ( -

{PRICING_META[plan.id].additional}

- )} - {PRICING_META[plan.id].scale && ( -

{PRICING_META[plan.id].scale}

- )} - {PRICING_META[plan.id].shutdown && ( -

{PRICING_META[plan.id].shutdown}

- )} -
-
-
-
-
- ) - })} - - ) -} - -export default Plans diff --git a/studio/components/interfaces/Billing/ProUpgrade.tsx b/studio/components/interfaces/Billing/ProUpgrade.tsx index b4cf2635051..6014f79999f 100644 --- a/studio/components/interfaces/Billing/ProUpgrade.tsx +++ b/studio/components/interfaces/Billing/ProUpgrade.tsx @@ -177,6 +177,7 @@ const ProUpgrade: FC = ({ } const onConfirmPayment = async () => { + setIsSubmitting(true) const payload = formSubscriptionUpdatePayload( currentSubscription, selectedTier, @@ -231,13 +232,13 @@ const ProUpgrade: FC = ({ >
-
+
onSelectBack()} />

Change your project's subscription

diff --git a/studio/components/interfaces/Billing/TeamUpgrade.tsx b/studio/components/interfaces/Billing/TeamUpgrade.tsx index 7cc18e7847a..03e264fe5a1 100644 --- a/studio/components/interfaces/Billing/TeamUpgrade.tsx +++ b/studio/components/interfaces/Billing/TeamUpgrade.tsx @@ -169,6 +169,7 @@ const TeamUpgrade: FC = ({ message: 'Team Plan is not enabled yet.', }) } + setIsSubmitting(true) const payload = formSubscriptionUpdatePayload( currentSubscription, selectedTier, @@ -223,13 +224,13 @@ const TeamUpgrade: FC = ({ >
-
+
onSelectBack()} />

Change your project's subscription

diff --git a/studio/components/interfaces/Database/Tables/TableList.tsx b/studio/components/interfaces/Database/Tables/TableList.tsx index 2c4b60c1acb..002c788aa95 100644 --- a/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/studio/components/interfaces/Database/Tables/TableList.tsx @@ -67,7 +67,7 @@ const TableList: FC = ({ size="small" value={selectedSchema} onChange={onSelectSchema} - icon={isLocked && } + icon={isLocked && } >

Schemas

diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 7e410f7d2ab..1c4f3fa1a55 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -144,8 +144,8 @@ const SidePanelEditor: FC = ({ closePanel() } - if (configuration.isEncrypted) { - await meta.schemas.loadViews(selectedTable?.schema ?? '') + if (configuration.isEncrypted && selectedTable?.schema) { + await meta.views.loadBySchema(selectedTable.schema) } resolve() diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx index 09928532ab9..b9a68c8aa27 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx @@ -17,6 +17,7 @@ import { generateTableFieldFromPostgresTable, formatImportedContentToColumnFields, } from './TableEditor.utils' +import InformationBox from 'components/ui/InformationBox' interface Props { table?: PostgresTable @@ -215,22 +216,19 @@ const TableEditor: FC = ({

Restrict access to your table by enabling RLS and writing Postgres policies.

-

- If RLS is not enabled, anyone with the anon key can modify and delete your data. + RLS is secure by default - all normal access to this table must be allowed by a + policy.

{!tableFields.isRLSEnabled && ( - Turning off RLS means that you are allowing anonymous access to your table - - } - /> + title="Turning off RLS means that you are allowing anonymous access to your table" + > + As such, anyone with the anonymous key can modify or delete your data. + )} } diff --git a/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index 58e2adba72d..36798d285ea 100644 --- a/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -5,11 +5,11 @@ import { find, isUndefined } from 'lodash' import { PostgresColumn, PostgresTable } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { SchemaView } from 'types' import { checkPermissions, useFlag, useStore } from 'hooks' import GridHeaderActions from './GridHeaderActions' import NotFoundState from './NotFoundState' import SidePanelEditor from './SidePanelEditor' -import { SchemaView } from 'components/layouts/TableEditorLayout/TableEditorLayout.types' import { Dictionary, parseSupaTable, SupabaseGrid, SupabaseGridRef } from 'components/grid' interface Props { @@ -81,7 +81,7 @@ const TableGridEditor: FC = ({ // @ts-ignore const schema = meta.schemas.list().find((schema) => schema.name === selectedSchema) - const isViewSelected = Object.keys(selectedTable).length === 2 + const isViewSelected = !Object.keys(selectedTable).includes('rls_enabled') const isForeignTableSelected = meta.foreignTables.byId(selectedTable.id) !== undefined const isLocked = meta.excludedSchemas.includes(schema?.name ?? '') const canUpdateTables = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') diff --git a/studio/components/layouts/AuthLayout/AuthLayout.tsx b/studio/components/layouts/AuthLayout/AuthLayout.tsx index 8fa69472d88..f747c9adf22 100644 --- a/studio/components/layouts/AuthLayout/AuthLayout.tsx +++ b/studio/components/layouts/AuthLayout/AuthLayout.tsx @@ -28,6 +28,7 @@ const AuthLayout: FC = ({ title, children }) => { meta.policies.load() meta.tables.load() meta.roles.load() + meta.schemas.load() } }, [ui.selectedProject?.ref]) diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx index 0f4944909fc..f083736a6cb 100644 --- a/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx +++ b/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx @@ -20,7 +20,10 @@ const FeedbackDropdown = () => { return ( setIsOpen(e)} + onOpenChange={(e) => { + setIsOpen(e) + if (!e) setScreenshot(undefined) + }} size="content" side="bottom" align="end" diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackWidget.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackWidget.tsx index a8defc57682..a8b8ec910f7 100644 --- a/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackWidget.tsx +++ b/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackWidget.tsx @@ -1,14 +1,14 @@ import Link from 'next/link' -import { useState, useEffect, useRef, FC } from 'react' +import { useState, useEffect, useRef, FC, ChangeEvent } from 'react' import { useRouter } from 'next/router' -import * as Tooltip from '@radix-ui/react-tooltip' import { toPng } from 'html-to-image' -import { Button, Input, Popover, IconCamera, IconX } from 'ui' +import { Button, Input, Popover, IconCamera, IconX, IconImage, Dropdown, IconUpload } from 'ui' import { useStore } from 'hooks' import { post } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { convertB64toBlob, uploadAttachment } from './FeedbackDropdown.utils' +import { timeout } from 'lib/helpers' interface Props { onClose: () => void @@ -30,6 +30,7 @@ const FeedbackWidget: FC = ({ const { ui } = useStore() const inputRef = useRef(null) + const uploadButtonRef = useRef() const [isSending, setSending] = useState(false) const [isSavingScreenshot, setIsSavingScreenshot] = useState(false) @@ -42,7 +43,7 @@ const FeedbackWidget: FC = ({ setFeedback(e.target.value) } - const captureScreenshot = () => { + const captureScreenshot = async () => { setIsSavingScreenshot(true) function filter(node: HTMLElement) { @@ -52,6 +53,8 @@ const FeedbackWidget: FC = ({ return true } + // Give time for dropdown to close + await timeout(100) toPng(document.body, { filter }) .then((dataUrl: any) => setScreenshot(dataUrl)) .catch((error: any) => { @@ -67,6 +70,18 @@ const FeedbackWidget: FC = ({ }) } + const onFilesUpload = async (event: ChangeEvent) => { + event.persist() + const [file] = event.target.files || (event as any).dataTransfer.items + + const reader = new FileReader() + reader.onload = function (event) { + setScreenshot(event.target?.result as string) + } + reader.readAsDataURL(file) + event.target.value = '' + } + const sendFeedback = async () => { if (feedback.length === 0 && screenshot !== undefined) { return ui.setNotification({ @@ -97,7 +112,7 @@ const FeedbackWidget: FC = ({ } return ( -
+
= ({
) : ( - - - diff --git a/studio/components/layouts/StorageLayout/BucketRow.tsx b/studio/components/layouts/StorageLayout/BucketRow.tsx new file mode 100644 index 00000000000..f96a5d96b4c --- /dev/null +++ b/studio/components/layouts/StorageLayout/BucketRow.tsx @@ -0,0 +1,73 @@ +import { FC } from 'react' +import { Badge, Dropdown, IconLoader, IconMoreVertical, IconTrash } from 'ui' + +import ProductMenuItem from 'components/ui/ProductMenu/ProductMenuItem' +import { STORAGE_ROW_STATUS } from 'components/to-be-cleaned/Storage/Storage.constants' + +interface Props { + bucket: any + projectRef?: string + isSelected: boolean + onSelectDeleteBucket: (bucket: any) => void + onSelectToggleBucketPublic: (bucket: any) => void +} + +const BucketRow: FC = ({ + bucket = {}, + projectRef = '', + isSelected = false, + onSelectDeleteBucket = () => {}, + onSelectToggleBucketPublic = () => {}, +}) => { + return ( + +

+ {bucket.name} +

+ {bucket.public && Public} +
+ } + url={`/project/${projectRef}/storage/buckets/${bucket.id}`} + isActive={isSelected} + action={ + bucket.status === STORAGE_ROW_STATUS.LOADING ? ( + + ) : bucket.status === STORAGE_ROW_STATUS.READY ? ( + onSelectToggleBucketPublic(bucket)} + > + {bucket.public ? 'Make private' : 'Make public'} + , + , + } + key="delete-bucket" + onClick={() => onSelectDeleteBucket(bucket)} + > + Delete bucket + , + ]} + > + + + ) : ( +
+ ) + } + /> + ) +} + +export default BucketRow diff --git a/studio/components/layouts/StorageLayout/StorageMenu.tsx b/studio/components/layouts/StorageLayout/StorageMenu.tsx index 2889292f42f..441e07c40a9 100644 --- a/studio/components/layouts/StorageLayout/StorageMenu.tsx +++ b/studio/components/layouts/StorageLayout/StorageMenu.tsx @@ -2,28 +2,18 @@ import { FC } from 'react' import Link from 'next/link' import { useRouter } from 'next/router' import { observer } from 'mobx-react-lite' -import { useStore } from 'hooks' -import { - Badge, - Button, - Dropdown, - Menu, - IconLoader, - IconMoreVertical, - Alert, - IconEdit, - IconTrash, -} from 'ui' +import { useParams } from 'hooks' +import { Button, Menu, IconLoader, Alert, IconEdit } from 'ui' -import ProductMenuItem from 'components/ui/ProductMenu/ProductMenuItem' -import { STORAGE_ROW_STATUS } from 'components/to-be-cleaned/Storage/Storage.constants' +import BucketRow from './BucketRow' import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore' interface Props {} const StorageMenu: FC = () => { const router = useRouter() - const { ref, bucketId } = router.query + const { ref, bucketId } = useParams() + const page = router.pathname.split('/')[4] as | undefined | 'policies' @@ -31,9 +21,6 @@ const StorageMenu: FC = () => { | 'usage' | 'logs' - const { ui } = useStore() - const projectRef = ui.selectedProject?.ref - const storageExplorerStore = useStorageStore() const { loaded, @@ -98,7 +85,7 @@ const StorageMenu: FC = () => {
- +

Policies

@@ -110,61 +97,3 @@ const StorageMenu: FC = () => { } export default observer(StorageMenu) - -const BucketRow = ({ - bucket = {}, - projectRef = '', - isSelected = false, - onSelectDeleteBucket = () => {}, - onSelectToggleBucketPublic = () => {}, -}: any) => { - return ( - -

- {bucket.name} -

- {bucket.public && Public} -
- } - url={`/project/${projectRef}/storage/buckets/${bucket.id}`} - isActive={isSelected} - action={ - bucket.status === STORAGE_ROW_STATUS.LOADING ? ( - - ) : bucket.status === STORAGE_ROW_STATUS.READY ? ( - onSelectToggleBucketPublic(bucket)} - > - {bucket.public ? 'Make private' : 'Make public'} - , - , - } - key="delete-bucket" - onClick={() => onSelectDeleteBucket(bucket)} - > - Delete bucket - , - ]} - > - - - ) : ( -
- ) - } - /> - ) -} diff --git a/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx b/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx index af9ac5edeec..19178db155f 100644 --- a/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx +++ b/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx @@ -4,7 +4,7 @@ import { observer } from 'mobx-react-lite' import { PostgresTable } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions, useStore } from 'hooks' +import { checkPermissions, useParams, useStore } from 'hooks' import Error from 'components/ui/Error' import ProjectLayout from '../ProjectLayout/ProjectLayout' import TableEditorMenu from './TableEditorMenu' @@ -30,31 +30,54 @@ const TableEditorLayout: FC = ({ children, }) => { const { vault, meta, ui } = useStore() + const { id, type } = useParams() const { isInitialized, isLoading, error } = meta.tables const [loaded, setLoaded] = useState(isInitialized) const canReadTables = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'tables') const vaultExtension = meta.extensions.byId('supabase_vault') - const isEnabled = vaultExtension !== undefined && vaultExtension?.installed_version !== null + const isVaultEnabled = vaultExtension !== undefined && vaultExtension?.installed_version !== null useEffect(() => { if (ui.selectedProject?.ref) { meta.schemas.load() - meta.tables.load() meta.types.load() meta.policies.load() meta.publications.load() meta.extensions.load() + + // [Joshen] pg-meta doesn't support loading views nor foreign tables by a specific ID yet + // Separately, Alaister and I chatted that perhaps we leverage on pg-catalog's pg-class + // table directly, so we can fetch the schema of tables/views/foreign-tables in one call + // rather than trying to discern if the ID is a view, or foreign table (refer to below) + meta.views.load() meta.foreignTables.load() } }, [ui.selectedProject?.ref]) useEffect(() => { - if (isEnabled) { + if (selectedSchema && ui.selectedProject?.ref) { + meta.tables.loadBySchema(selectedSchema) + meta.views.loadBySchema(selectedSchema) + } + }, [ui.selectedProject?.ref, selectedSchema]) + + useEffect(() => { + if (ui.selectedProject?.ref && id) { + // [Joshen] This is a little silly, but because fetching tables/views/foreign-tables + // are all through different endpoints, we need to discern them + if (type !== 'view' && type !== 'foreign') { + meta.tables.loadById(Number(id)) + } + } + }, [ui.selectedProject?.ref, id]) + + useEffect(() => { + if (isVaultEnabled) { vault.load() } - }, [ui.selectedProject?.ref, isEnabled]) + }, [ui.selectedProject?.ref, isVaultEnabled]) useEffect(() => { let cancel = false diff --git a/studio/components/layouts/TableEditorLayout/TableEditorLayout.types.ts b/studio/components/layouts/TableEditorLayout/TableEditorLayout.types.ts deleted file mode 100644 index 636a06056a8..00000000000 --- a/studio/components/layouts/TableEditorLayout/TableEditorLayout.types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface SchemaView { - schema: string - name: string -} diff --git a/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index 1bc51c408ae..fb81c3fa803 100644 --- a/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -1,5 +1,4 @@ import { FC, useEffect, useState } from 'react' -import { useRouter } from 'next/router' import Link from 'next/link' import { partition } from 'lodash' import { observer } from 'mobx-react-lite' @@ -10,6 +9,7 @@ import { IconCopy, IconEdit, IconLoader, + IconLock, IconRefreshCw, IconSearch, IconTrash, @@ -22,9 +22,8 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' import { PostgresSchema, PostgresTable } from '@supabase/postgres-meta' -import Base64 from 'lib/base64' -import { checkPermissions, useStore } from 'hooks' -import { SchemaView } from './TableEditorLayout.types' +import { SchemaView } from 'types' +import { checkPermissions, useStore, useParams } from 'hooks' import ProductMenuItem from 'components/ui/ProductMenu/ProductMenuItem' interface Props { @@ -45,44 +44,34 @@ const TableEditorMenu: FC = ({ onDuplicateTable = () => {}, }) => { const { meta, ui } = useStore() - const router = useRouter() - const { id } = router.query + const { id, ref } = useParams() - const projectRef = ui.selectedProject?.ref const schemas: PostgresSchema[] = meta.schemas.list() const tables: PostgresTable[] = meta.tables.list( (table: PostgresTable) => table.schema === selectedSchema ) + const views: SchemaView[] = meta.views.list((view: SchemaView) => view.schema === selectedSchema) const foreignTables: Partial[] = meta.foreignTables.list( - (table: PostgresTable) => table.schema === selectedSchema + (table: Partial) => table.schema === selectedSchema ) - // @ts-ignore + const isFetchingTables = + // @ts-ignore + tables.filter((t) => t.schema === selectedSchema).length === 0 && meta.tables.isLoading + const schema = schemas.find((schema) => schema.name === selectedSchema) const canCreateTables = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') const [searchText, setSearchText] = useState('') const [isRefreshing, setIsRefreshing] = useState(false) - // We may need to shift this to the schema store and do something like meta.schema.loadViews() - // I don't need we need a separate store for views - useEffect(() => { - let cancel = false - const fetchViews = async (selectedSchema: string) => { - await meta.schemas.loadViews(selectedSchema) - } - if (selectedSchema) fetchViews(selectedSchema) - - return () => { - cancel = true - } - }, [selectedSchema]) - const refreshTables = async () => { - setIsRefreshing(true) - await meta.tables.load() - if (selectedSchema) await meta.schemas.loadViews(selectedSchema) - setIsRefreshing(false) + if (selectedSchema) { + setIsRefreshing(true) + await meta.tables.loadBySchema(selectedSchema) + await meta.views.loadBySchema(selectedSchema) + setIsRefreshing(false) + } } const filteredTables = @@ -93,9 +82,9 @@ const TableEditorMenu: FC = ({ const filteredViews = searchText.length === 0 - ? meta.schemas.views || [] - : (meta.schemas.views || []).filter((view) => - view.name.toLowerCase().includes(searchText.toLowerCase()) + ? views || [] + : (views || []).filter((view: Partial) => + (view?.name ?? '').toLowerCase().includes(searchText.toLowerCase()) ) const filteredForeignTables = @@ -229,147 +218,165 @@ const TableEditorMenu: FC = ({
-
- {/* List of tables belonging to selected schema */} - {filteredTables.length > 0 && ( - - -
-
-

Tables

-

- ({filteredTables.length}) -

+ {isFetchingTables ? ( +
+ +

Loading tables...

+
+ ) : searchText.length === 0 && filteredTables.length === 0 && filteredViews.length === 0 ? ( +
+

No tables available

+

This schema has no tables available yet

+
+ ) : ( +
+ {/* List of tables belonging to selected schema */} + {filteredTables.length > 0 && ( + + +
+
+

Tables

+

+ ({filteredTables.length}) +

+
+
- -
- - } - /> + + } + /> -
- {filteredTables.map((table) => { - const isActive = Number(id) === table.id +
+ {filteredTables.map((table) => { + const isActive = Number(id) === table.id + return ( + } + onClick={() => onEditTable(table)} + > + Edit Table + , + } + onClick={() => onDuplicateTable(table)} + > + Duplicate Table + , + + + }> + View Policies + + + , + , + } + onClick={() => onDeleteTable(table)} + > + Delete Table + , + ]} + > +
+ +
+ + ) + } + /> + ) + })} +
+
+ )} + + {/* List of views belonging to selected schema */} + {filteredViews.length > 0 && ( + + +

Views

+

({filteredViews.length})

+
+ } + /> + + {filteredViews.map((view: SchemaView) => { + const isActive = Number(id) === view.id return ( - } - onClick={() => onEditTable(table)} - > - Edit Table - , - } - onClick={() => onDuplicateTable(table)} - > - Duplicate Table - , - , - } - onClick={() => onDeleteTable(table)} - > - Delete Table - , - ]} - > -
- -
- - ) - } - /> + + + +
+

{view.name}

+
+
+
+ ) })} -
- - )} + + )} - {/* List of views belonging to selected schema */} - {filteredViews.length > 0 && ( - - -

Views

-

({filteredViews.length})

-
- } - /> + {/* List of foreign tables belonging to selected schema */} + {filteredForeignTables.length > 0 && ( + + +

Foreign Tables

+

+ ({filteredForeignTables.length}) +

+
+ } + /> - {filteredViews.map((view: SchemaView) => { - const viewId = Base64.encode(JSON.stringify(view)) - const isActive = id === viewId - return ( - - - -
-

{view.name}

-
-
-
- - ) - })} - - )} - - {/* List of foreign tables belonging to selected schema */} - {filteredForeignTables.length > 0 && ( - - -

Foreign Tables

-

- ({filteredForeignTables.length}) -

-
- } - /> - - {filteredForeignTables.map((table: Partial) => { - const isActive = Number(id) === table.id - return ( - - - -
-

{table.name}

-
-
-
- - ) - })} - - )} -
+ {filteredForeignTables.map((table: Partial) => { + const isActive = Number(id) === table.id + return ( + + + +
+

{table.name}

+
+
+
+ + ) + })} + + )} +
+ )} {searchText.length > 0 && filteredTables.length === 0 && filteredViews.length === 0 && (
@@ -379,13 +386,6 @@ const TableEditorMenu: FC = ({

)} - - {searchText.length === 0 && filteredTables.length === 0 && filteredViews.length === 0 && ( -
-

No tables available

-

This schema has no tables available yet

-
- )}
) } diff --git a/studio/components/to-be-cleaned/SqlEditor/TabSqlQuery.js b/studio/components/to-be-cleaned/SqlEditor/TabSqlQuery.js index 28888b61c59..c377465d834 100644 --- a/studio/components/to-be-cleaned/SqlEditor/TabSqlQuery.js +++ b/studio/components/to-be-cleaned/SqlEditor/TabSqlQuery.js @@ -246,6 +246,16 @@ const ResultsDropdown = observer(() => { } } + const formatDataForCSV = (data = []) => { + return data.map((row) => { + const formattedRow = { ...row } + Object.keys(row).forEach((key) => { + if (typeof row[key] === 'object') formattedRow[key] = JSON.stringify(row[key]) + }) + return formattedRow + }) + } + return ( { diff --git a/studio/components/to-be-cleaned/Storage/Storage.constants.ts b/studio/components/to-be-cleaned/Storage/Storage.constants.ts index 2620cf9fb2d..94fecf605f3 100644 --- a/studio/components/to-be-cleaned/Storage/Storage.constants.ts +++ b/studio/components/to-be-cleaned/Storage/Storage.constants.ts @@ -1,3 +1,9 @@ +export const URL_EXPIRY_DURATION = { + WEEK: 60 * 60 * 24 * 7, + MONTH: 60 * 60 * 24 * 30, + YEAR: 60 * 60 * 24 * 365, +} + export const STORAGE_VIEWS = { COLUMNS: 'COLUMNS', LIST: 'LIST', diff --git a/studio/components/to-be-cleaned/Storage/StorageExplorer/CustomExpiryModal.tsx b/studio/components/to-be-cleaned/Storage/StorageExplorer/CustomExpiryModal.tsx new file mode 100644 index 00000000000..f702ba38bfa --- /dev/null +++ b/studio/components/to-be-cleaned/Storage/StorageExplorer/CustomExpiryModal.tsx @@ -0,0 +1,78 @@ +import { FC } from 'react' +import { Form, Modal, Input, Button } from 'ui' +import { observer } from 'mobx-react-lite' +import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore' + +interface Props {} + +const CustomExpiryModal: FC = () => { + const storageExplorerStore = useStorageStore() + const { selectedFileCustomExpiry, setSelectedFileCustomExpiry, copyFileURLToClipboard } = + storageExplorerStore + + const visible = selectedFileCustomExpiry !== undefined + const onClose = () => setSelectedFileCustomExpiry(undefined) + + return ( + onClose()} + > +
{ + setSubmitting(true) + await copyFileURLToClipboard(selectedFileCustomExpiry, values.expiresIn) + setSubmitting(false) + onClose() + }} + validate={(values: any) => { + const errors: any = {} + if (values.expiresIn !== '' && values.expiresIn <= 0) + errors.expiresIn = 'Expiry duration cannot be less than 0' + return errors + }} + > + {({ values, isSubmitting }: { values: any; isSubmitting: boolean }) => ( + <> +
+ + seconds

} + /> +
+
+ + +
+ + +
+
+ + )} + +
+ ) +} + +export default observer(CustomExpiryModal) diff --git a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorer.js b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorer.js index c76f2d2a2e4..4e0f9af240b 100644 --- a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorer.js +++ b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorer.js @@ -1,10 +1,12 @@ import React, { useEffect, useRef } from 'react' -import { STORAGE_VIEWS, CONTEXT_MENU_KEYS } from '../Storage.constants' +import { observer } from 'mobx-react-lite' +import { STORAGE_VIEWS, CONTEXT_MENU_KEYS } from '../Storage.constants' import ItemContextMenu from './ItemContextMenu' import FolderContextMenu from './FolderContextMenu' import ColumnContextMenu from './ColumnContextMenu' import FileExplorerColumn from './FileExplorerColumn' +import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore' const FileExplorer = ({ view = STORAGE_VIEWS.COLUMNS, @@ -12,18 +14,7 @@ const FileExplorer = ({ openedFolders = [], selectedItems = [], selectedFilePreview = {}, - onCheckItem = () => {}, - onSelectItemDelete = () => {}, - onSelectItemRename = () => {}, - onSelectItemMove = () => {}, - onSelectFile = () => {}, - onRenameFile = () => {}, onFilesUpload = () => {}, - onCopyFileURL = () => {}, - onDownloadFile = () => {}, - onSelectFolder = () => {}, - onRenameFolder = () => {}, - onCreateFolder = () => {}, onSelectAllItemsInColumn = () => {}, onSelectColumnEmptySpace = () => {}, onSelectCreateFolder = () => {}, @@ -33,6 +24,9 @@ const FileExplorer = ({ onColumnLoadMore = () => {}, }) => { const fileExplorerRef = useRef(null) + const storageExplorerStore = useStorageStore() + + const { setSelectedItemToRename } = storageExplorerStore useEffect(() => { if (fileExplorerRef) { @@ -56,18 +50,11 @@ const FileExplorer = ({ onSelectSort={onChangeSortBy} onSelectSortByOrder={onChangeSortByOrder} /> - + setSelectedItemToRename(folder)} + onDeleteFolder={(folder) => setSelectedItemsToDelete([folder])} /> {view === STORAGE_VIEWS.COLUMNS ? (
@@ -80,18 +67,7 @@ const FileExplorer = ({ openedFolders={openedFolders} selectedItems={selectedItems} selectedFilePreview={selectedFilePreview} - onCheckItem={onCheckItem} - onSelectFile={onSelectFile} - onRenameFile={onRenameFile} - onCopyFileURL={onCopyFileURL} onFilesUpload={onFilesUpload} - onDownloadFile={onDownloadFile} - onSelectFolder={onSelectFolder} - onRenameFolder={onRenameFolder} - onCreateFolder={onCreateFolder} - onSelectItemDelete={onSelectItemDelete} - onSelectItemRename={onSelectItemRename} - onSelectItemMove={onSelectItemMove} onSelectAllItemsInColumn={onSelectAllItemsInColumn} onSelectColumnEmptySpace={onSelectColumnEmptySpace} onColumnLoadMore={onColumnLoadMore} @@ -108,18 +84,7 @@ const FileExplorer = ({ column={columns[columns.length - 1]} selectedItems={selectedItems} selectedFilePreview={selectedFilePreview} - onCheckItem={onCheckItem} - onSelectFile={onSelectFile} - onRenameFile={onRenameFile} - onCopyFileURL={onCopyFileURL} onFilesUpload={onFilesUpload} - onDownloadFile={onDownloadFile} - onSelectFolder={onSelectFolder} - onRenameFolder={onRenameFolder} - onCreateFolder={onCreateFolder} - onSelectItemDelete={onSelectItemDelete} - onSelectItemRename={onSelectItemRename} - onSelectItemMove={onSelectItemMove} onSelectAllItemsInColumn={onSelectAllItemsInColumn} onSelectColumnEmptySpace={onSelectColumnEmptySpace} onColumnLoadMore={onColumnLoadMore} @@ -133,4 +98,4 @@ const FileExplorer = ({ ) } -export default FileExplorer +export default observer(FileExplorer) diff --git a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerColumn.js b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerColumn.js index bc418c2f92a..db2a5886e1b 100644 --- a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerColumn.js +++ b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerColumn.js @@ -57,18 +57,7 @@ const FileExplorerColumn = ({ openedFolders = [], selectedItems = [], selectedFilePreview = {}, - onCheckItem = () => {}, - onSelectItemDelete = () => {}, - onSelectItemRename = () => {}, - onSelectItemMove = () => {}, - onSelectFile = () => {}, - onRenameFile = () => {}, - onCopyFileURL = () => {}, onFilesUpload = () => {}, - onDownloadFile = () => {}, - onSelectFolder = () => {}, - onRenameFolder = () => {}, - onCreateFolder = () => {}, onSelectAllItemsInColumn = () => {}, onSelectColumnEmptySpace = () => {}, onColumnLoadMore = () => {}, @@ -212,17 +201,15 @@ const FileExplorerColumn = ({ selectedItems, openedFolders, selectedFilePreview, - onCheckItem, - onSelectFile, - onRenameFile, - onCopyFileURL, - onDownloadFile, - onSelectFolder, - onRenameFolder, - onCreateFolder, - onSelectItemDelete, - onSelectItemRename, - onSelectItemMove, + // onCheckItem, + // onRenameFile, + // onCopyFileURL, + // onDownloadFile, + // onRenameFolder, + // onCreateFolder, + // onSelectItemDelete, + // onSelectItemRename, + // onSelectItemMove, }} ItemComponent={FileExplorerRow} getItemSize={(index) => (index !== 0 && index === columnItems.length ? 85 : 37)} diff --git a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.js b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx similarity index 66% rename from studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.js rename to studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx index 6712cc90e09..b5bd5982c23 100644 --- a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.js +++ b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react' +import { FC, useState, useRef, useEffect } from 'react' import { find, has, isEmpty, isEqual } from 'lodash' import { Checkbox, @@ -11,10 +11,11 @@ import { IconFile, IconAlertCircle, IconDownload, - IconTrash, - IconCopy, IconEdit, IconMove, + IconClipboard, + IconTrash2, + IconChevronRight, } from 'ui' import SVG from 'react-inlinesvg' import * as Tooltip from '@radix-ui/react-tooltip' @@ -24,11 +25,12 @@ import { STORAGE_ROW_TYPES, STORAGE_ROW_STATUS, CONTEXT_MENU_KEYS, -} from '../Storage.constants.ts' + URL_EXPIRY_DURATION, +} from '../Storage.constants' import { formatBytes } from 'lib/helpers' import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore' -const RowIcon = ({ view, status, fileType, mimeType }) => { +const RowIcon = ({ view, status, fileType, mimeType }: any) => { if (view === STORAGE_VIEWS.LIST && status === STORAGE_ROW_STATUS.LOADING) { return } @@ -43,7 +45,6 @@ const RowIcon = ({ view, status, fileType, mimeType }) => { return ( code.replace(/svg/, 'svg class="w-4 h-4 text-color-inherit opacity-75"') } @@ -66,28 +67,44 @@ const RowIcon = ({ view, status, fileType, mimeType }) => { return } -const FileExplorerRow = ({ +interface Props { + item: any + view: string + columnIndex: number + selectedItems: any[] + openedFolders: any[] + selectedFilePreview: any +} + +const FileExplorerRow: FC = ({ item = {}, view = STORAGE_VIEWS.COLUMNS, columnIndex = 0, selectedItems = [], openedFolders = [], selectedFilePreview = {}, - - onCheckItem = () => {}, - onSelectFile = () => {}, - onRenameFile = () => {}, - onCopyFileURL = () => {}, - onDownloadFile = () => {}, - onSelectFolder = () => {}, - onRenameFolder = () => {}, - onCreateFolder = () => {}, - onSelectItemDelete = () => {}, - onSelectItemRename = () => {}, - onSelectItemMove = () => {}, }) => { const storageExplorerStore = useStorageStore() - const { downloadFolder } = storageExplorerStore + const { + popColumnAtIndex, + pushOpenedFolderAtIndex, + popOpenedFoldersAtIndex, + setFilePreview, + closeFilePreview, + clearSelectedItems, + addNewFolder, + renameFolder, + renameFile, + setSelectedItems, + setSelectedItemsToDelete, + setSelectedItemToRename, + setSelectedItemsToMove, + setSelectedFileCustomExpiry, + fetchFolderContents, + downloadFile, + downloadFolder, + copyFileURLToClipboard, + } = storageExplorerStore const itemWithColumnIndex = { ...item, columnIndex } const isSelected = find(selectedItems, item) !== undefined @@ -95,25 +112,47 @@ const FileExplorerRow = ({ openedFolders.length > columnIndex ? isEqual(openedFolders[columnIndex], item) : false const isPreviewed = !isEmpty(selectedFilePreview) && isEqual(selectedFilePreview.id, item.id) + const onSelectFile = async (columnIndex: number, file: any) => { + popColumnAtIndex(columnIndex) + popOpenedFoldersAtIndex(columnIndex - 1) + setFilePreview(file) + clearSelectedItems() + } + + const onSelectFolder = async (columnIndex: number, folder: any) => { + closeFilePreview() + clearSelectedItems(columnIndex + 1) + popOpenedFoldersAtIndex(columnIndex - 1) + pushOpenedFolderAtIndex(folder, columnIndex) + await fetchFolderContents(folder.id, folder.name, columnIndex) + } + + const onCheckItem = (item: any) => { + if (find(selectedItems, item) === undefined) { + setSelectedItems(selectedItems.concat([item])) + } else { + setSelectedItems(selectedItems.filter((selectedItem: any) => item.id !== selectedItem.id)) + } + closeFilePreview() + } + if (item.status === STORAGE_ROW_STATUS.EDITING) { - const inputRef = useRef(null) + const inputRef = useRef(null) const [itemName, setItemName] = useState(item.name) useEffect(() => { - if (inputRef.current) { - inputRef.current.select() - } + if (inputRef.current) inputRef.current.select() }, []) - const onSetItemName = (event) => { + const onSetItemName = async (event: any) => { event.preventDefault() event.stopPropagation() if (item.type === STORAGE_ROW_TYPES.FILE) { - onRenameFile(item, itemName, columnIndex) + await renameFile(item, itemName, columnIndex) } else if (has(item, 'id')) { - onRenameFolder(itemWithColumnIndex, itemName, columnIndex) + renameFolder(itemWithColumnIndex, itemName, columnIndex) } else { - onCreateFolder(itemName, columnIndex) + addNewFolder(itemName, columnIndex) } } @@ -146,57 +185,78 @@ const FileExplorerRow = ({ } const rowOptions = - item.type === STORAGE_ROW_TYPES.BUCKET - ? [{ name: 'Delete', onClick: () => onSelectItemDelete(itemWithColumnIndex) }] - : item.type === STORAGE_ROW_TYPES.FOLDER + item.type === STORAGE_ROW_TYPES.FOLDER ? [ { name: 'Rename', icon: , - onClick: () => onSelectItemRename(itemWithColumnIndex), + onClick: () => setSelectedItemToRename(itemWithColumnIndex), }, { name: 'Download', icon: , onClick: () => downloadFolder(itemWithColumnIndex), }, - { name: 'Separator' }, + { name: 'Separator', icon: undefined, onClick: undefined }, { name: 'Delete', - icon: , - onClick: () => onSelectItemDelete(itemWithColumnIndex), + icon: , + onClick: () => setSelectedItemsToDelete([itemWithColumnIndex]), }, ] : [ ...(!item.isCorrupted ? [ { - name: 'Copy URL', - icon: , - onClick: () => onCopyFileURL(itemWithColumnIndex), + name: 'Get URL', + icon: , + children: [ + { + name: 'Expire in 1 week', + onClick: async () => + await copyFileURLToClipboard(itemWithColumnIndex, URL_EXPIRY_DURATION.WEEK), + }, + { + name: 'Expire in 1 month', + onClick: async () => + await copyFileURLToClipboard( + itemWithColumnIndex, + URL_EXPIRY_DURATION.MONTH + ), + }, + { + name: 'Expire in 1 year', + onClick: async () => + await copyFileURLToClipboard(itemWithColumnIndex, URL_EXPIRY_DURATION.YEAR), + }, + { + name: 'Custom expiry', + onClick: async () => setSelectedFileCustomExpiry(itemWithColumnIndex), + }, + ], }, { name: 'Rename', icon: , - onClick: () => onSelectItemRename(itemWithColumnIndex), + onClick: () => setSelectedItemToRename(itemWithColumnIndex), }, { name: 'Move', icon: , - onClick: () => onSelectItemMove(itemWithColumnIndex), + onClick: () => setSelectedItemsToMove([itemWithColumnIndex]), }, { name: 'Download', icon: , - onClick: () => onDownloadFile(itemWithColumnIndex), + onClick: async () => await downloadFile(itemWithColumnIndex), }, - { name: 'Separator' }, + { name: 'Separator', icon: undefined, onClick: undefined }, ] : []), { name: 'Delete', - icon: , - onClick: () => onSelectItemDelete(itemWithColumnIndex), + icon: , + onClick: () => setSelectedItemsToDelete([itemWithColumnIndex]), }, ] @@ -206,7 +266,7 @@ const FileExplorerRow = ({ const updatedAt = item.updated_at ? new Date(item.updated_at).toLocaleString() : '-' const { show } = useContextMenu() - const displayMenu = (event, rowType) => { + const displayMenu = (event: any, rowType: any) => { show(event, { id: rowType === STORAGE_ROW_TYPES.FILE @@ -342,7 +402,36 @@ const FileExplorerRow = ({ align="end" overlay={[ rowOptions.map((option) => { - if (option.name === 'Separator') { + if ((option?.children ?? []).length > 0) { + return ( + { + return ( + + {child.name} + + ) + })} + > +
+
+ {option.icon} +

{option.name}

+
+ +
+ + ) + } else if (option.name === 'Separator') { return } else { return ( diff --git a/studio/components/to-be-cleaned/Storage/StorageExplorer/ItemContextMenu.js b/studio/components/to-be-cleaned/Storage/StorageExplorer/ItemContextMenu.js index 993fd661b14..eb48047e271 100644 --- a/studio/components/to-be-cleaned/Storage/StorageExplorer/ItemContextMenu.js +++ b/studio/components/to-be-cleaned/Storage/StorageExplorer/ItemContextMenu.js @@ -1,43 +1,97 @@ -import { Menu, Item } from 'react-contexify' +import { observer } from 'mobx-react-lite' +import { Menu, Item, Separator, Submenu } from 'react-contexify' import 'react-contexify/dist/ReactContexify.css' +import { IconClipboard, IconEdit, IconMove, IconDownload, IconTrash2, IconChevronRight } from 'ui' + +import { URL_EXPIRY_DURATION } from '../Storage.constants' +import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore' + +const ItemContextMenu = ({ id = '' }) => { + const storageExplorerStore = useStorageStore() + const { + downloadFile, + selectedBucket, + setSelectedItemsToDelete, + setSelectedItemToRename, + setSelectedItemsToMove, + setSelectedFileCustomExpiry, + copyFileURLToClipboard, + } = storageExplorerStore + const isPublic = selectedBucket.public + + const onHandleClick = async (event, item, expiresIn) => { + if (item.isCorrupted) return + switch (event) { + case 'copy': + if (expiresIn < 0) return setSelectedFileCustomExpiry(item) + else return await copyFileURLToClipboard(item, expiresIn) + case 'rename': + return setSelectedItemToRename(item) + case 'move': + return setSelectedItemsToMove([item]) + case 'download': + return await downloadFile(item) + default: + break + } + } -const ItemContextMenu = ({ - id = '', - onCopyFileURL = () => {}, - onSelectItemRename = () => {}, - onSelectItemMove = () => {}, - onDownloadFile = () => {}, - onSelectItemDelete = () => {}, -}) => { return ( - (!props.item.isCorrupted ? onCopyFileURL(props.item) : () => {})} - > - Copy URL + {isPublic ? ( + onHandleClick('copy', props.item)}> + + Get URL + + ) : ( + + + Get URL +
+ } + arrow={} + > + onHandleClick('copy', props.item, URL_EXPIRY_DURATION.WEEK)} + > + Expire in 1 week + + onHandleClick('copy', props.item, URL_EXPIRY_DURATION.MONTH)} + > + Expire in 1 month + + onHandleClick('copy', props.item, URL_EXPIRY_DURATION.YEAR)} + > + Expire in 1 year + + onHandleClick('copy', props.item, -1)}> + Custom expiry + + + )} + onHandleClick('rename', props.item)}> + + Rename - - !props.item.isCorrupted ? onSelectItemRename(props.item) : () => {} - } - > - Rename + onHandleClick('move', props.item)}> + + Move - (!props.item.isCorrupted ? onSelectItemMove(props.item) : () => {})} - > - Move + onHandleClick('download', props.item)}> + + Download - (!props.item.isCorrupted ? onDownloadFile(props.item) : () => {})} - > - Download - - onSelectItemDelete(props.item)}> - Delete + + setSelectedItemsToDelete([props.item])}> + + Delete ) } -export default ItemContextMenu +export default observer(ItemContextMenu) diff --git a/studio/components/to-be-cleaned/Storage/StorageExplorer/PreviewPane.tsx b/studio/components/to-be-cleaned/Storage/StorageExplorer/PreviewPane.tsx index 49066467eae..205e6fbbba6 100644 --- a/studio/components/to-be-cleaned/Storage/StorageExplorer/PreviewPane.tsx +++ b/studio/components/to-be-cleaned/Storage/StorageExplorer/PreviewPane.tsx @@ -1,17 +1,20 @@ +import Link from 'next/link' import { isEmpty } from 'lodash' import { Button, + Dropdown, IconX, IconLoader, IconClipboard, IconDownload, IconTrash2, IconAlertCircle, + IconChevronDown, } from 'ui' import SVG from 'react-inlinesvg' import { formatBytes } from 'lib/helpers' import { Transition } from '@headlessui/react' -import Link from 'next/link' +import { URL_EXPIRY_DURATION } from '../Storage.constants' import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore' const PreviewFile = ({ mimeType, previewUrl }: { mimeType: string; previewUrl: string }) => { @@ -90,10 +93,12 @@ const PreviewFile = ({ mimeType, previewUrl }: { mimeType: string; previewUrl: s const PreviewPane = () => { const storageExplorerStore = useStorageStore() const { + selectedBucket, selectedFilePreview: file, copyFileURLToClipboard, closeFilePreview, setSelectedItemsToDelete, + setSelectedFileCustomExpiry, } = storageExplorerStore const width = 450 @@ -104,104 +109,148 @@ const PreviewPane = () => { const updatedAt = file.updated_at ? new Date(file.updated_at).toLocaleString() : 'Unknown' return ( - -
+
- {/* Preview Header */} -
- closeFilePreview()} - /> -
- - {/* Preview Thumbnail*/} -
-
- + style={{ width }} + > + {/* Preview Header */} +
+ closeFilePreview()} + />
-
-
- {/* Preview Information */} -
-
{file.name}
- {file.isCorrupted && ( -
- + {/* Preview Thumbnail*/} +
+
+ +
+
+ +
+ {/* Preview Information */} +
+
{file.name}
+ {file.isCorrupted && ( +
+ +

+ File is corrupted, please delete and reupload this file again +

+
+ )} + {mimeType && (

- File is corrupted, please delete and reupload this file again + {mimeType} + {size && - {size}}

+ )} +
+ + {/* Preview Metadata */} +
+
+ +

{createdAt}

+
+
+ +

{updatedAt}

- )} - {mimeType && ( -

- {mimeType} - {size && - {size}} -

- )} -
- - {/* Preview Metadata */} -
-
- -

{createdAt}

-
- -

{updatedAt}

-
-
- {/* Actions */} -
- - + {/* Actions */} +
+ + + + + + {selectedBucket.public ? ( - - + ) : ( + + await copyFileURLToClipboard(file, URL_EXPIRY_DURATION.WEEK) + } + > + Expire in 1 week + , + + await copyFileURLToClipboard(file, URL_EXPIRY_DURATION.MONTH) + } + > + Expire in 1 month + , + + await copyFileURLToClipboard(file, URL_EXPIRY_DURATION.YEAR) + } + > + Expire in 1 year + , + setSelectedFileCustomExpiry(file)}> + Custom expiry + , + ]} + > + + + )} +
-
-
- + + ) } diff --git a/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.js b/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.js index 23da2ae932e..b19374ff0ba 100644 --- a/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.js +++ b/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.js @@ -1,6 +1,6 @@ import { useEffect, useState, useRef } from 'react' import { observer } from 'mobx-react-lite' -import { compact, find, isEmpty, uniqBy, get } from 'lodash' +import { compact, isEmpty, uniqBy, get } from 'lodash' import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore' import { STORAGE_ROW_TYPES } from '../Storage.constants' @@ -11,47 +11,38 @@ import FileExplorerHeaderSelection from './FileExplorerHeaderSelection' import ConfirmDeleteModal from './ConfirmDeleteModal' import MoveItemsModal from './MoveItemsModal' import PreviewPane from './PreviewPane' +import CustomExpiryModal from './CustomExpiryModal' const StorageExplorer = observer(({ bucket }) => { const storageExplorerStore = useStorageStore() const { columns, selectedFilePreview, - setFilePreview, closeFilePreview, selectedItems, setSelectedItems, clearSelectedItems, selectedItemsToDelete, - setSelectedItemsToDelete, clearSelectedItemsToDelete, openedFolders, - pushOpenedFolderAtIndex, popColumnAtIndex, popOpenedFoldersAtIndex, - setSelectedItemToRename, selectedItemsToMove, - setSelectedItemsToMove, clearSelectedItemsToMove, view, setView, setSortBy, setSortByOrder, currentBucketName, - copyFileURLToClipboard, openBucket, loadExplorerPreferences, addNewFolderPlaceholder, - addNewFolder, fetchFolderContents, fetchMoreFolderContents, - renameFolder, deleteFolder, uploadFiles, deleteFiles, - downloadFile, - renameFile, moveFiles, } = storageExplorerStore @@ -100,35 +91,9 @@ const StorageExplorer = observer(({ bucket }) => { openBucket(bucket) }, [bucket]) - /** Navigation methods */ - - const onSelectFile = async (columnIndex, file) => { - popColumnAtIndex(columnIndex) - popOpenedFoldersAtIndex(columnIndex - 1) - setFilePreview(file) - clearSelectedItems() - } - - const onSelectFolder = async (columnIndex, folder) => { - closeFilePreview() - clearSelectedItems(columnIndex + 1) - popOpenedFoldersAtIndex(columnIndex - 1) - pushOpenedFolderAtIndex(folder, columnIndex) - await fetchFolderContents(folder.id, folder.name, columnIndex) - } - /** Checkbox selection methods */ /** [Joshen] We'll only support checkbox selection for files ONLY */ - const onCheckItem = (item) => { - if (find(selectedItems, item) === undefined) { - setSelectedItems(selectedItems.concat([item])) - } else { - setSelectedItems(selectedItems.filter((selectedItem) => item.id !== selectedItem.id)) - } - closeFilePreview() - } - const onSelectAllItemsInColumn = (columnIndex) => { const columnFiles = columns[columnIndex].items .filter((item) => item.type === STORAGE_ROW_TYPES.FILE) @@ -155,38 +120,6 @@ const StorageExplorer = observer(({ bucket }) => { addNewFolderPlaceholder(columnIndex) } - const onCreateFolder = (folderName, columnIndex) => { - addNewFolder(folderName, columnIndex) - } - - const onRenameFolder = (folder, newName, columnIndex) => { - renameFolder(folder, newName, columnIndex) - } - - const onSelectItemDelete = (file) => { - setSelectedItemsToDelete([file]) - } - - const onSelectItemRename = (file) => { - setSelectedItemToRename(file) - } - - const onSelectItemMove = (file) => { - setSelectedItemsToMove([file]) - } - - const onCopyFileURL = async (file) => { - await copyFileURLToClipboard(file) - } - - const onDownloadFile = async (file) => { - await downloadFile(file) - } - - const onRenameFile = async (file, newName, columnIndex) => { - await renameFile(file, newName, columnIndex) - } - const onFilesUpload = async (event, columnIndex = -1) => { event.persist() const items = event.target.files || event.dataTransfer.items @@ -255,18 +188,7 @@ const StorageExplorer = observer(({ bucket }) => { openedFolders={openedFolders} selectedItems={selectedItems} selectedFilePreview={selectedFilePreview} - onCheckItem={onCheckItem} - onSelectFile={onSelectFile} - onRenameFile={onRenameFile} onFilesUpload={onFilesUpload} - onCopyFileURL={onCopyFileURL} - onDownloadFile={onDownloadFile} - onSelectFolder={onSelectFolder} - onRenameFolder={onRenameFolder} - onCreateFolder={onCreateFolder} - onSelectItemDelete={onSelectItemDelete} - onSelectItemRename={onSelectItemRename} - onSelectItemMove={onSelectItemMove} onSelectAllItemsInColumn={onSelectAllItemsInColumn} onSelectColumnEmptySpace={onSelectColumnEmptySpace} onSelectCreateFolder={onSelectCreateFolder} @@ -292,6 +214,7 @@ const StorageExplorer = observer(({ bucket }) => { onSelectCancel={clearSelectedItemsToMove} onSelectMove={onMoveSelectedFiles} /> +
) }) diff --git a/studio/components/ui/BackButton.tsx b/studio/components/ui/BackButton.tsx index 0ae4923d8e7..5460c16a203 100644 --- a/studio/components/ui/BackButton.tsx +++ b/studio/components/ui/BackButton.tsx @@ -2,7 +2,7 @@ import { Button, IconArrowLeft } from 'ui' export function BackButton({ onClick }: { onClick: () => void }) { return ( -
+
diff --git a/studio/localStores/storageExplorer/StorageExplorerStore.js b/studio/localStores/storageExplorer/StorageExplorerStore.js index 5e11d3c234c..b9a1742668b 100644 --- a/studio/localStores/storageExplorer/StorageExplorerStore.js +++ b/studio/localStores/storageExplorer/StorageExplorerStore.js @@ -37,7 +37,7 @@ export const useStorageExplorerStore = () => { const CORRUPTED_THRESHOLD_MS = 15 * 60 * 1000 // 15 minutes const LIMIT = 200 const OFFSET = 0 -const DEFAULT_EXPIRY = 10 * 365 * 24 * 60 * 60 // in seconds, default to 1 year +const DEFAULT_EXPIRY = 10 * 365 * 24 * 60 * 60 // in seconds, default to 10 years const PREVIEW_SIZE_LIMIT = 10000000 // 10MB const BATCH_SIZE = 2 const EMPTY_FOLDER_PLACEHOLDER_FILE_NAME = '.emptyFolderPlaceholder' @@ -58,6 +58,7 @@ class StorageExplorerStore { selectedItemsToDelete = [] selectedItemsToMove = [] selectedFilePreview = {} + selectedFileCustomExpiry = undefined DEFAULT_OPTIONS = { limit: LIMIT, @@ -300,6 +301,10 @@ class StorageExplorerStore { this.selectedItemsToMove = [] } + setSelectedFileCustomExpiry = (item) => { + this.selectedFileCustomExpiry = item + } + addNewFolderPlaceholder = (columnIndex) => { const isPrepend = true const folderName = 'Untitled folder' @@ -397,9 +402,9 @@ class StorageExplorerStore { this.selectedFilePreview = {} } - copyFileURLToClipboard = async (file) => { + copyFileURLToClipboard = async (file, expiresIn = 0) => { const filePreview = find(this.filePreviewCache, { id: file.id }) - if (filePreview) { + if (filePreview && expiresIn === 0) { // Already generated signed URL copyToClipboard(filePreview.url, () => { this.ui.setNotification({ @@ -410,7 +415,7 @@ class StorageExplorerStore { }) } else { // Need to generate signed URL, and might as well save it to cache as well - const signedUrl = await this.fetchFilePreview(file.name) + const signedUrl = await this.fetchFilePreview(file.name, expiresIn) try { let formattedUrl = new URL(signedUrl) @@ -840,7 +845,7 @@ class StorageExplorerStore { this.clearSelectedItemsToMove() } - fetchFilePreview = async (fileName) => { + fetchFilePreview = async (fileName, expiresIn = 0) => { const includeBucket = false const pathToFile = this.getPathAlongOpenedFolders(includeBucket) const formattedPathToFile = pathToFile.length > 0 ? `${pathToFile}/${fileName}` : fileName @@ -857,7 +862,7 @@ class StorageExplorerStore { const { data, error } = await this.supabaseClient.storage .from(this.selectedBucket.name) - .createSignedUrl(formattedPathToFile, DEFAULT_EXPIRY) + .createSignedUrl(formattedPathToFile, expiresIn || DEFAULT_EXPIRY) if (!error) { return data.signedUrl diff --git a/studio/pages/project/[ref]/auth/policies.tsx b/studio/pages/project/[ref]/auth/policies.tsx index 14fc0d2a805..42562e4bc55 100644 --- a/studio/pages/project/[ref]/auth/policies.tsx +++ b/studio/pages/project/[ref]/auth/policies.tsx @@ -1,22 +1,15 @@ -import React, { useState, FC } from 'react' -import { isEmpty } from 'lodash' -import { Button, IconSearch, Input, IconHelpCircle, IconExternalLink } from 'ui' -import { useRouter } from 'next/router' +import { useState, useEffect } from 'react' +import { partition } from 'lodash' +import { Button, Listbox, IconSearch, Input, IconExternalLink, IconLock } from 'ui' import { observer } from 'mobx-react-lite' -import { checkPermissions, useStore } from 'hooks' -import { AuthLayout } from 'components/layouts' -import { NextPageWithLayout } from 'types' -import { PolicyEditorModal, PolicyTableRow } from 'components/interfaces/Auth/Policies' -import { PostgresRole } from '@supabase/postgres-meta' import { PostgresTable, PostgresPolicy } from '@supabase/postgres-meta' - -import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog' -import NoTableState from 'components/ui/States/NoTableState' -import NoSearchResults from 'components/to-be-cleaned/NoSearchResults' import { PermissionAction } from '@supabase/shared-types/out/constants' + +import { NextPageWithLayout } from 'types' +import { checkPermissions, useParams, useStore } from 'hooks' +import { AuthLayout } from 'components/layouts' +import { Policies } from 'components/interfaces/Auth/Policies' import NoPermission from 'components/ui/NoPermission' -import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' -import InformationBox from 'components/ui/InformationBox' /** * Filter tables by table name and policy name @@ -39,13 +32,15 @@ const onFilterTables = ( const findSearchString = (s: string) => s.toLowerCase().includes(filter) // @ts-ignore Type instantiation is excessively deep and possibly infinite const filteredPolicies = policies.filter((p: PostgresPolicy) => findSearchString(p.name)) + return tables .slice() .filter((x: PostgresTable) => { - const searchTableName = findSearchString(x.name) - if (searchTableName) return true - const searchPolicyName = filteredPolicies.some((p: PostgresPolicy) => p.table === x.name) - return searchPolicyName + return ( + x.name.toLowerCase().includes(filter) || + x.id.toString() === filter || + filteredPolicies.some((p: PostgresPolicy) => p.table === x.name) + ) }) .sort((a: PostgresTable, b: PostgresTable) => a.name.localeCompare(b.name)) } @@ -53,11 +48,26 @@ const onFilterTables = ( const AuthPoliciesPage: NextPageWithLayout = () => { const { meta } = useStore() + const { search } = useParams() + const [selectedSchema, setSelectedSchema] = useState('public') + const [searchString, setSearchString] = useState('') + + useEffect(() => { + if (search) setSearchString(search) + }, [search]) + + const schemas = meta.schemas.list() + const [protectedSchemas, openSchemas] = partition(schemas, (schema) => + meta.excludedSchemas.includes(schema?.name ?? '') + ) + // @ts-ignore + const schema = schemas.find((schema) => schema.name === selectedSchema) + const isLocked = protectedSchemas.some((s) => s.id === schema?.id) - const [policiesFilter, setPoliciesFilter] = useState(undefined) - const publicTables = meta.tables.list((table: { schema: string }) => table.schema === 'public') const policies = meta.policies.list() - const filteredTables = onFilterTables(publicTables, policies, policiesFilter) + + const tables = meta.tables.list((table: { schema: string }) => table.schema === selectedSchema) + const filteredTables = onFilterTables(tables, policies, searchString) const canReadPolicies = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'policies') @@ -67,29 +77,78 @@ const AuthPoliciesPage: NextPageWithLayout = () => { return (
- {(publicTables || []).length > 0 && ( -
-
+
+
+
+
+ { + setSelectedSchema(schema) + setSearchString('') + }} + icon={isLocked && } + > + +

Schemas

+
+ {/* @ts-ignore */} + {openSchemas.map((schema) => ( + schema} + > + {schema.name} + + ))} + +

Protected schemas

+
+ {protectedSchemas.map((schema) => ( + schema} + > + {schema.name} + + ))} +
+
setPoliciesFilter(e.target.value)} + value={searchString} + onChange={(e) => setSearchString(e.target.value)} icon={} /> -
+
- )} - 0} tables={filteredTables} /> +
+ 0} isLocked={isLocked} />
) } @@ -101,198 +160,3 @@ AuthPoliciesPage.getLayout = (page) => ( ) export default observer(AuthPoliciesPage) - -interface AuthPoliciesTablesProps { - hasPublicTables: boolean - tables: any[] -} -const AuthPoliciesTables: FC = observer(({ tables, hasPublicTables }) => { - const router = useRouter() - const { ref } = router.query - - const { ui, meta } = useStore() - const roles = meta.roles.list((role: PostgresRole) => !meta.roles.systemRoles.includes(role.name)) - - const [selectedSchemaAndTable, setSelectedSchemaAndTable] = useState({}) - const [selectedTableToToggleRLS, setSelectedTableToToggleRLS] = useState({}) - const [selectedPolicyToEdit, setSelectedPolicyToEdit] = useState({}) - const [selectedPolicyToDelete, setSelectedPolicyToDelete] = useState({}) - - const closePolicyEditorModal = () => { - setSelectedPolicyToEdit({}) - setSelectedSchemaAndTable({}) - } - - const closeConfirmModal = () => { - setSelectedPolicyToDelete({}) - setSelectedTableToToggleRLS({}) - } - - const onSelectToggleRLS = (table: any) => { - setSelectedTableToToggleRLS(table) - } - - const onSelectCreatePolicy = (table: any) => { - setSelectedSchemaAndTable({ schema: table.schema, table: table.name }) - } - - const onSelectEditPolicy = (policy: any) => { - setSelectedPolicyToEdit(policy) - setSelectedSchemaAndTable({ schema: policy.schema, table: policy.table }) - } - - const onSelectDeletePolicy = (policy: any) => { - setSelectedPolicyToDelete(policy) - } - - const onSavePolicySuccess = async () => { - ui.setNotification({ category: 'success', message: 'Policy successfully saved!' }) - closePolicyEditorModal() - } - - // Methods that involve some API - const onToggleRLS = async () => { - const payload = { - id: selectedTableToToggleRLS.id, - rls_enabled: !selectedTableToToggleRLS.rls_enabled, - } - - const res: any = await meta.tables.update(payload.id, payload) - if (res.error) { - ui.setNotification({ - category: 'error', - message: `Failed to toggle RLS: ${res.error.message}`, - }) - } - closeConfirmModal() - } - - const onCreatePolicy = async (payload: any) => { - const res = await meta.policies.create(payload) - if (res.error) { - ui.setNotification({ - category: 'error', - message: `Error adding policy: ${res.error.message}`, - }) - return true - } - return false - } - - const onUpdatePolicy = async (payload: any) => { - const res = await meta.policies.update(payload.id, payload) - if (res.error) { - ui.setNotification({ - category: 'error', - message: `Error updating policy: ${res.error.message}`, - }) - return true - } - return false - } - - const onDeletePolicy = async () => { - const res = await meta.policies.del(selectedPolicyToDelete.id) - if (typeof res !== 'boolean' && res.error) { - ui.setNotification({ - category: 'error', - message: `Error deleting policy: ${res.error.message}`, - }) - } else { - ui.setNotification({ category: 'success', message: 'Successfully deleted policy!' }) - } - closeConfirmModal() - } - - return ( - <> - {tables.length > 0 ? ( - tables.map((table: any) => ( -
- -
- )) - ) : hasPublicTables ? ( - - ) : ( -
- router.push(`/project/${ref}/editor`)} - > -
- } - description={ -
-

- Policies restrict, on a per-user basis, which rows can be returned by normal - queries, or inserted, updated, or deleted by data modification commands. -

-

- This is also known as Row-Level Security (RLS). Each policy is attached to a - table, and the policy is executed everytime a time is accessed. -

-
- } - /> -

- A table within the public schema is required before you can create a Row-Level - Security policy. -

-
-
-
- )} - - - - - - - - ) -}) diff --git a/studio/pages/project/[ref]/editor/[id].tsx b/studio/pages/project/[ref]/editor/[id].tsx index e273343bcd9..32893d76bec 100644 --- a/studio/pages/project/[ref]/editor/[id].tsx +++ b/studio/pages/project/[ref]/editor/[id].tsx @@ -7,17 +7,16 @@ import { isUndefined, isNaN } from 'lodash' import { Alert, Button, Checkbox, IconExternalLink, Modal } from 'ui' import { PostgresTable, PostgresColumn } from '@supabase/postgres-meta' -import Base64 from 'lib/base64' -import { tryParseJson } from 'lib/helpers' -import { useStore, withAuth, useUrlState } from 'hooks' +import { useStore, withAuth, useUrlState, useParams } from 'hooks' import { Dictionary } from 'components/grid' import { TableEditorLayout } from 'components/layouts' import { TableGridEditor } from 'components/interfaces' import ConfirmationModal from 'components/ui/ConfirmationModal' +import { SchemaView } from 'types' const TableEditorPage: NextPage = () => { const router = useRouter() - const { id }: any = router.query + const { id } = useParams() const [_, setParams] = useUrlState({ arrayKeys: ['filter', 'sort'] }) const { meta, ui } = useStore() @@ -37,13 +36,17 @@ const TableEditorPage: NextPage = () => { const projectRef = ui.selectedProject?.ref const tables: PostgresTable[] = meta.tables.list() + const views: SchemaView[] = meta.views.list() const foreignTables: Partial[] = meta.foreignTables.list() const selectedTable = !isNaN(Number(id)) ? // @ts-ignore - tables.concat(foreignTables).find((table) => table.id === Number(id)) - : id !== undefined - ? tryParseJson(Base64.decode(id)) + tables + // @ts-ignore + .concat(views) + // @ts-ignore + .concat(foreignTables) + .find((table) => table.id === Number(id)) : undefined useEffect(() => { @@ -140,7 +143,7 @@ const TableEditorPage: NextPage = () => { }) await meta.tables.loadById(selectedColumnToDelete!.table_id) - if (selectedSchema) await meta.schemas.loadViews(selectedSchema) + if (selectedSchema) await meta.views.loadBySchema(selectedSchema) } catch (error: any) { ui.setNotification({ category: 'error', @@ -171,7 +174,7 @@ const TableEditorPage: NextPage = () => { category: 'success', message: `Successfully deleted table "${selectedTableToDelete.name}"`, }) - if (selectedSchema) await meta.schemas.loadViews(selectedSchema) + if (selectedSchema) await meta.views.loadBySchema(selectedSchema) } catch (error: any) { ui.setNotification({ error, diff --git a/studio/pages/project/[ref]/editor/index.tsx b/studio/pages/project/[ref]/editor/index.tsx index 7532dd7fc3c..ee5e4ffc1cf 100644 --- a/studio/pages/project/[ref]/editor/index.tsx +++ b/studio/pages/project/[ref]/editor/index.tsx @@ -55,7 +55,7 @@ const Editor: NextPage = () => { category: 'success', message: `Successfully deleted ${selectedTableToDelete!.name}`, }) - await meta.schemas.loadViews(selectedSchema) + await meta.views.loadBySchema(selectedSchema) } catch (error: any) { ui.setNotification({ category: 'error', diff --git a/studio/stores/common/PostgresMetaInterface.ts b/studio/stores/common/PostgresMetaInterface.ts index 5345022f277..07445b75e46 100644 --- a/studio/stores/common/PostgresMetaInterface.ts +++ b/studio/stores/common/PostgresMetaInterface.ts @@ -15,6 +15,7 @@ export interface IPostgresMetaInterface { isInitialized: boolean load: () => void + loadBySchema: (schema: string) => Promise create: (payload: any) => Promise update: (id: number | string, updates: any) => Promise del: (id: number | string, cascade?: boolean) => Promise @@ -116,6 +117,33 @@ export default class PostgresMetaInterface implements IPostgresMetaInterface< } } + async loadBySchema(schema: string) { + let { LOADING, ERROR, LOADED } = this.STATES + try { + this.setError(null) + this.setState(LOADING) + + const url = this.url.includes('?') + ? `${this.url}&included_schemas=${schema}` + : `${this.url}?included_schemas=${schema}` + const response = await get(url, { headers: this.headers }) + if (response.error) throw response.error + + const data = response as T[] + const formattedData = keyBy(data, this.identifier) + + this.data = { ...this.data, ...formattedData } + this.setState(LOADED) + + return data + } catch (error: any) { + console.error('Error in loadBySchema:', error.message) + this.setError(error) + this.setState(ERROR) + return { error } + } + } + initialDataArray(value: T[]) { if (this.state === this.STATES.INITIAL) { this.data = keyBy(value, this.identifier) diff --git a/studio/stores/pgmeta/ExtensionsStore.ts b/studio/stores/pgmeta/ExtensionsStore.ts index 9df35a1ab4e..fbb26f7d5e8 100644 --- a/studio/stores/pgmeta/ExtensionsStore.ts +++ b/studio/stores/pgmeta/ExtensionsStore.ts @@ -13,4 +13,9 @@ export default class ExtensionsStore extends PostgresMetaInterface - schemas: ISchemaStore + schemas: IPostgresMetaInterface + views: IPostgresMetaInterface foreignTables: IPostgresMetaInterface> hooks: IPostgresMetaInterface @@ -132,6 +135,7 @@ export default class MetaStore implements IMetaStore { tables: TableStore columns: ColumnStore schemas: SchemaStore + views: ViewStore foreignTables: ForeignTableStore hooks: HooksStore @@ -184,6 +188,7 @@ export default class MetaStore implements IMetaStore { this.tables = new TableStore(this.rootStore, `${this.baseUrl}/tables`, this.headers) this.columns = new ColumnStore(this.rootStore, `${this.baseUrl}/columns`, this.headers) this.schemas = new SchemaStore(this.rootStore, `${this.baseUrl}/schemas`, this.headers) + this.views = new ViewStore(this.rootStore, `${this.baseUrl}/views`, this.headers) this.foreignTables = new ForeignTableStore( this.rootStore, `${this.baseUrl}/foreign-tables`, @@ -903,6 +908,9 @@ export default class MetaStore implements IMetaStore { this.schemas.setUrl(`${this.baseUrl}/schemas`) this.schemas.setHeaders(this.headers) + this.views.setUrl(`${this.baseUrl}/views`) + this.views.setHeaders(this.headers) + this.foreignTables.setUrl(`${this.baseUrl}/foreign-tables`) this.foreignTables.setHeaders(this.headers) diff --git a/studio/stores/pgmeta/PublicationStore.ts b/studio/stores/pgmeta/PublicationStore.ts index b892d15057d..86b5cf25642 100644 --- a/studio/stores/pgmeta/PublicationStore.ts +++ b/studio/stores/pgmeta/PublicationStore.ts @@ -12,4 +12,9 @@ export default class PublicationStore extends PostgresMetaInterface { ) { super(rootStore, dataUrl, headers, options) } + + // loadBySchema is not supported in this store + async loadBySchema(schema: string) { + return [] + } } diff --git a/studio/stores/pgmeta/RolesStore.ts b/studio/stores/pgmeta/RolesStore.ts index 5bd5d59fc1b..b4bb4d0c055 100644 --- a/studio/stores/pgmeta/RolesStore.ts +++ b/studio/stores/pgmeta/RolesStore.ts @@ -28,4 +28,9 @@ export default class RolesStore extends PostgresMetaInterface { 'pg_read_all_data', 'pg_write_all_data', ] + + // loadBySchema is not supported in this store + async loadBySchema(schema: string) { + return [] + } } diff --git a/studio/stores/pgmeta/SchemaStore.ts b/studio/stores/pgmeta/SchemaStore.ts index 1fd1ff89ac5..09236d13166 100644 --- a/studio/stores/pgmeta/SchemaStore.ts +++ b/studio/stores/pgmeta/SchemaStore.ts @@ -1,17 +1,8 @@ -import { observable, makeObservable } from 'mobx' import { PostgresSchema } from '@supabase/postgres-meta' - -import PostgresMetaInterface, { IPostgresMetaInterface } from '../common/PostgresMetaInterface' +import PostgresMetaInterface from '../common/PostgresMetaInterface' import { IRootStore } from '../RootStore' -import { SchemaView, ResponseError } from 'types' -export interface ISchemaStore extends IPostgresMetaInterface { - views: SchemaView[] - loadViews: (schema: string) => Promise -} export default class SchemaStore extends PostgresMetaInterface { - views: SchemaView[] = [] - constructor( rootStore: IRootStore, dataUrl: string, @@ -21,10 +12,6 @@ export default class SchemaStore extends PostgresMetaInterface { options?: { identifier: string } ) { super(rootStore, dataUrl, headers, options) - makeObservable(this, { - views: observable, - }) - this.views = [] } // Dashboard to hide schemas with pg_ prefix @@ -34,15 +21,4 @@ export default class SchemaStore extends PostgresMetaInterface { return super.list(schemasFilter) } - - async loadViews(schema: string) { - const query = ` - select schemaname as schema, viewname as name from pg_catalog.pg_views - where schemaname = '${schema}' - order by schemaname, viewname; - ` - const views = await this.rootStore.meta.query(query) - this.views = views - return views - } } diff --git a/studio/stores/pgmeta/ViewStore.ts b/studio/stores/pgmeta/ViewStore.ts new file mode 100644 index 00000000000..63b74701e42 --- /dev/null +++ b/studio/stores/pgmeta/ViewStore.ts @@ -0,0 +1,16 @@ +import { SchemaView } from 'types' +import PostgresMetaInterface from '../common/PostgresMetaInterface' +import { IRootStore } from '../RootStore' + +export default class ViewStore extends PostgresMetaInterface { + constructor( + rootStore: IRootStore, + dataUrl: string, + headers?: { + [prop: string]: any + }, + options?: { identifier: string } + ) { + super(rootStore, dataUrl, headers, options) + } +} diff --git a/studio/stores/project/VaultStore.ts b/studio/stores/project/VaultStore.ts index a470a864470..166f59bf7fe 100644 --- a/studio/stores/project/VaultStore.ts +++ b/studio/stores/project/VaultStore.ts @@ -1,7 +1,7 @@ import { PostgresColumn } from '@supabase/postgres-meta' import { Query } from 'components/grid' import { makeAutoObservable } from 'mobx' -import { VaultSecret } from 'types' +import { SchemaView, VaultSecret } from 'types' import { IRootStore } from '../RootStore' export interface IVaultStore { @@ -211,9 +211,9 @@ export default class VaultStore implements IVaultStore { async listEncryptedColumns(schema: string, table: string) { if (!table) return [] - await this.rootStore.meta.schemas.loadViews(schema) - const decryptedView = this.rootStore.meta.schemas.views.find( - (view) => view.name === `decrypted_${table}` + await this.rootStore.meta.views.loadBySchema(schema) + const decryptedView = this.rootStore.meta.views.find( + (view: SchemaView) => view.name === `decrypted_${table}` ) if (!decryptedView) return [] diff --git a/studio/styles/contextMenu.scss b/studio/styles/contextMenu.scss index 88d9680880d..418b2fcd9a0 100644 --- a/studio/styles/contextMenu.scss +++ b/studio/styles/contextMenu.scss @@ -4,6 +4,9 @@ .react-contexify__item__content { @apply dark:text-white text-sm; } + .react-contexify__submenu { + @apply dark:bg-gray-300 border; + } } .react-contexify__item:not(.react-contexify__item--disabled):hover > .react-contexify__item__content, diff --git a/studio/tailwind.config.js b/studio/tailwind.config.js index a8cab68a3f1..21148b6013e 100644 --- a/studio/tailwind.config.js +++ b/studio/tailwind.config.js @@ -1,3 +1,4 @@ +const { repeat } = require('lodash') const ui = require('./../packages/config/ui.config.js') const blueGray = { @@ -120,6 +121,9 @@ module.exports = ui({ fontSize: { grid: '13px', }, + gridTemplateColumns: { + billingWithTeam: 'repeat(3, minmax(0, 1fr)) 0.9fr', + }, colors: { // gray: { ...gray }, // green: { ...green }, diff --git a/studio/types/ui.ts b/studio/types/ui.ts index f8d5308ac25..edeb42ec9ac 100644 --- a/studio/types/ui.ts +++ b/studio/types/ui.ts @@ -1,3 +1,5 @@ +import { PostgresColumn } from '@supabase/postgres-meta' + export interface Notification { category: 'info' | 'error' | 'success' | 'loading' message: string // Readable message for users to understand @@ -33,6 +35,10 @@ export interface VaultSecret { } export interface SchemaView { + id: number name: string schema: string + is_updatable: boolean + comment?: string + columns: PostgresColumn[] }