Merge pull request #11699 from supabase/master

Prod deploy dashboard
This commit is contained in:
Joshen Lim authored and GitHub committed 2023-01-18 10:56:34 +08:00
commit 1d5ebd6748
93 files changed
+2521 -1925

No files matched your search

+6 -2
View File
@@ -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
@@ -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',
}
@@ -143,18 +143,24 @@ const RefFunctionSection: React.FC<IRefFunctionSection> = (props) => {
label={example.name}
className="flex flex-col gap-3"
>
{example.description && (
<div className="prose">
<ReactMarkdown className="text-sm">
{example.description}
</ReactMarkdown>
</div>
)}
<CodeBlock
className="useless-code-block-class"
language={codeBlockLang}
hideLineNumbers={true}
>
{exampleString +
(example.code &&
example.code
.replace(/```/g, '')
.replace('js', '')
.replace('ts', '')
.replace('dart', ''))}
</CodeBlock>
{((tables && tables.length > 0) || sql) && (
<RefDetailCollapse
id={`${example.id}-${exampleIndex}-data`}
label="Example data source"
label="Data source"
defaultOpen={false}
>
<>
@@ -189,28 +195,14 @@ const RefFunctionSection: React.FC<IRefFunctionSection> = (props) => {
</RefDetailCollapse>
)}
<CodeBlock
className="useless-code-block-class"
language={codeBlockLang}
hideLineNumbers={true}
>
{exampleString +
(example.code &&
example.code
.replace(/```/g, '')
.replace('js', '')
.replace('ts', '')
.replace('dart', ''))}
</CodeBlock>
{response && (
<RefDetailCollapse
id={`${example.id}-${exampleIndex}-response`}
label="Example response"
label="Response"
defaultOpen={false}
>
<CodeBlock
className="useless-code-block-class"
className="useless-code-block-class rounded !rounded-tl-none !rounded-tr-none border border-scale-500"
language={codeBlockLang}
hideLineNumbers={true}
>
@@ -218,6 +210,20 @@ const RefFunctionSection: React.FC<IRefFunctionSection> = (props) => {
</CodeBlock>
</RefDetailCollapse>
)}
{example.description && (
<RefDetailCollapse
id={`${example.id}-${exampleIndex}-notes`}
label="Notes"
defaultOpen={false}
>
<div className="bg-scale-300 border border-scale-500 rounded !rounded-tl-none !rounded-tr-none prose max-w-none px-5 py-2">
<ReactMarkdown className="text-sm">
{example.description}
</ReactMarkdown>
</div>
</RefDetailCollapse>
)}
</Tabs.Panel>
)
})}
+2 -2
View File
@@ -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',
+8 -7
View File
@@ -13,11 +13,12 @@ hideTitle: true
</div>
<div className="max-w-xl">
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.
<br />
<br />
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.
</div>
@@ -0,0 +1,28 @@
---
id: introduction
title: Introduction
hideTitle: true
---
<div className="flex items-start gap-6 not-prose" id="introduction">
<img src="/docs/img/icons/menu/reference-dart.svg" className="w-8 h-8 rounded" />
<div className="flex flex-col gap-2">
<h1 className="text-3xl text-scale-1200 m-0">Flutter Client Library</h1>
<h2 className="text-base font-mono text-scale-1100">supabase-flutter</h2>
</div>
</div>
<div className="max-w-xl">
<Admonition type="caution">
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).
</Admonition>
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.
</div>
@@ -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
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
Update the package in your pubspec.yaml file.
</RefSubLayout.Details>
<RefSubLayout.Examples>
```yaml
supabase_flutter: ^1.0.0
```
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
## Error handling
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
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.
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
scrollable
size="small"
@@ -52,11 +64,20 @@ try {
</TabPanel>
</Tabs>
## Auth classes / methods
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
## 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.
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
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.
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
scrollable
@@ -97,10 +118,19 @@ await Supabase.initialize(
</TabPanel>
</Tabs>
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
### Listening to auth state change
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
`onAuthStateChange` now returns a `Stream`.
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
scrollable
size="small"
@@ -135,9 +165,18 @@ authSubscription.cancel();
</TabPanel>
</Tabs>
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
### 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).
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
`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).
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
scrollable
@@ -161,8 +200,17 @@ await supabase.auth.signInWithPassword(email: email, password: password);
</TabPanel>
</Tabs>
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
### Sign in with magic link
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
scrollable
size="small"
@@ -185,8 +233,17 @@ await supabase.auth.signInWithOtp(email: email);
</TabPanel>
</Tabs>
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
### Sign in with a third-party OAuth provider
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
scrollable
size="small"
@@ -218,8 +275,17 @@ await supabase.auth.signInWithOAuth(
</TabPanel>
</Tabs>
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
### Sign in with phone
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
scrollable
size="small"
@@ -248,8 +314,17 @@ await supabase.auth.signInWithPassword(
</TabPanel>
</Tabs>
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
### Sign in with phone using OTP
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
scrollable
size="small"
@@ -281,8 +356,17 @@ await supabase.auth.verifyOTP(
</TabPanel>
</Tabs>
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
### Reset password for email
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
scrollable
size="small"
@@ -312,8 +396,17 @@ await supabase.auth.resetPasswordForEmail(
</TabPanel>
</Tabs>
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
### Get the user's current session
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
scrollable
size="small"
@@ -336,8 +429,17 @@ final Session? session = supabase.auth.currentSession;
</TabPanel>
</Tabs>
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
### Get the logged-in user
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
scrollable
size="small"
@@ -360,8 +462,17 @@ final User? user = supabase.auth.currentUser;
</TabPanel>
</Tabs>
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
### Update user data for a logged-in user
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
scrollable
size="small"
@@ -390,16 +501,23 @@ await supabase.updateUser(
</TabPanel>
</Tabs>
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
## 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.
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
### Insert without returning inserted data
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
scrollable
size="small"
@@ -426,8 +544,17 @@ await supabase.from('my_table').insert(data);
</TabPanel>
</Tabs>
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
### Insert with returning inserted data
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
scrollable
size="small"
@@ -453,12 +580,21 @@ final insertedData = await supabase.from('my_table').insert(data).select();
</TabPanel>
</Tabs>
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
## Realtime methods
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
### 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.
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
scrollable
size="small"
@@ -486,8 +622,17 @@ supabase.from('my_table')
</TabPanel>
</Tabs>
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
### Subscribe
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
scrollable
size="small"
@@ -522,8 +667,17 @@ channel.on(
</TabPanel>
</Tabs>
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
<RefSubLayout.EducationRow>
<RefSubLayout.Details>
### Unsubscribe
</RefSubLayout.Details>
<RefSubLayout.Examples>
<Tabs
scrollable
size="small"
@@ -545,3 +699,6 @@ await supabase.removeChannel(channel);
</TabPanel>
</Tabs>
</RefSubLayout.Examples>
</RefSubLayout.EducationRow>
@@ -13,8 +13,15 @@ hideTitle: true
</div>
<div className="max-w-xl">
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.
<Admonition type="caution">
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).
</Admonition>
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.
</div>
@@ -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:
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="1.0x"
>
<TabPanel id="1.0x" label="Before">
```ts title=src/supabaseClient.ts
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
schema: 'custom',
persistSession: false,
})
```
</TabPanel>
<TabPanel id="2.0x" label="After">
```ts title=src/supabaseClient.ts
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
db: {
schema: 'custom',
},
auth: {
persistSession: true,
},
})
```
</TabPanel>
</Tabs>
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
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="1.0x"
>
<TabPanel id="1.0x" label="Before">
```ts
const { user, error } = await supabase.auth.signIn({ email, password })
```
</TabPanel>
<TabPanel id="2.0x" label="After">
```ts
const {
data: { user },
error,
} = await supabase.auth.signInWithPassword({ email, password })
```
</TabPanel>
</Tabs>
#### Sign in with magic link
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="1.0x"
>
<TabPanel id="1.0x" label="Before">
```ts
const { error } = await supabase.auth.signIn({ email })
```
</TabPanel>
<TabPanel id="2.0x" label="After">
```ts
const { error } = await supabase.auth.signInWithOtp({ email })
```
</TabPanel>
</Tabs>
#### Sign in with a third-party provider
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="1.0x"
>
<TabPanel id="1.0x" label="Before">
```ts
const { error } = await supabase.auth.signIn({ provider })
```
</TabPanel>
<TabPanel id="2.0x" label="After">
```ts
const { error } = await supabase.auth.signInWithOAuth({ provider })
```
</TabPanel>
</Tabs>
#### Sign in with phone
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="1.0x"
>
<TabPanel id="1.0x" label="Before">
```ts
const { error } = await supabase.auth.signIn({ phone, password })
```
</TabPanel>
<TabPanel id="2.0x" label="After">
```ts
const { error } = await supabase.auth.signInWithPassword({ phone, password })
```
</TabPanel>
</Tabs>
#### Sign in with phone using OTP
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="1.0x"
>
<TabPanel id="1.0x" label="Before">
```ts
const { error } = await supabase.auth.api.sendMobileOTP(phone)
```
</TabPanel>
<TabPanel id="2.0x" label="After">
```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 })
```
</TabPanel>
</Tabs>
#### Reset password for email
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="1.0x"
>
<TabPanel id="1.0x" label="Before">
```ts
const { data, error } = await supabase.auth.api.resetPasswordForEmail(email)
```
</TabPanel>
<TabPanel id="2.0x" label="After">
```ts
const { data, error } = await supabase.auth.resetPasswordForEmail(email)
```
</TabPanel>
</Tabs>
#### Get the user's current session
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="1.0x"
>
<TabPanel id="1.0x" label="Before">
```ts
const session = supabase.auth.session()
```
</TabPanel>
<TabPanel id="2.0x" label="After">
```ts
const {
data: { session },
} = await supabase.auth.getSession()
```
</TabPanel>
</Tabs>
#### Get the logged-in user
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="1.0x"
>
<TabPanel id="1.0x" label="Before">
```ts
const user = supabase.auth.user()
```
</TabPanel>
<TabPanel id="2.0x" label="After">
```ts
const {
data: { session },
} = await supabase.auth.getSession()
const { user } = session
```
</TabPanel>
</Tabs>
#### Update user data for a logged-in user
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="1.0x"
>
<TabPanel id="1.0x" label="Before">
```ts
const { user, error } = await supabase.auth.update({ attributes })
```
</TabPanel>
<TabPanel id="2.0x" label="After">
```ts
const {
data: { user },
error,
} = await supabase.auth.updateUser({ attributes })
```
</TabPanel>
</Tabs>
#### Use a custom `access_token` JWT with Supabase
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="1.0x"
>
<TabPanel id="1.0x" label="Before">
```ts
const { user, error } = supabase.auth.setAuth(access_token)
```
</TabPanel>
<TabPanel id="2.0x" label="After">
```ts
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
global: {
headers: {
Authorization: `Bearer ${access_token}`,
},
},
})
```
</TabPanel>
</Tabs>
#### 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
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="1.0x"
>
<TabPanel id="1.0x" label="Before">
```ts
const { data, error } = await supabase.auth.insert({ new_data })
```
</TabPanel>
<TabPanel id="2.0x" label="After">
```ts
const { data, error } = await supabase.auth.insert({ new_data }).select()
```
</TabPanel>
</Tabs>
#### Update and return data
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="1.0x"
>
<TabPanel id="1.0x" label="Before">
```ts
const { data, error } = await supabase.auth.update({ new_data }).eq('id', id)
```
</TabPanel>
<TabPanel id="2.0x" label="After">
```ts
const { data, error } = await supabase.auth.update({ new_data }).eq('id', id).select()
```
</TabPanel>
</Tabs>
### Realtime methods
#### Subscribe
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="1.0x"
>
<TabPanel id="1.0x" label="Before">
```ts
const userListener = supabase
.from('users')
.on('*', (payload) => handleAllEventsPayload(payload.new))
.subscribe()
```
</TabPanel>
<TabPanel id="2.0x" label="After">
```ts
const userListener = supabase
.channel('public:user')
.on('postgres_changes', { event: '*', schema: 'public', table: 'user' }, (payload) =>
handleAllEventsPayload()
)
.subscribe()
```
</TabPanel>
</Tabs>
#### Unsubscribe
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="1.0x"
>
<TabPanel id="1.0x" label="Before">
```ts
userListener.unsubscribe()
```
</TabPanel>
<TabPanel id="2.0x" label="After">
```ts
supabase.removeChannel(userListener)
```
</TabPanel>
</Tabs>
@@ -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:
@@ -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',
}
)
},
})
}
```
@@ -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'`
@@ -53,11 +53,11 @@ Procedural languages are automatically installed within `pg_catalog`, so you don
</TabPanel>
</Tabs>
## `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]
<Admonition type="caution">
@@ -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]
<Admonition type="caution">
@@ -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/)
@@ -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 }) => <Layout meta={meta} children={children} />
export default Page
@@ -35,6 +35,15 @@ jobs:
- run: supabase functions deploy your-function-name --project-ref $PROJECT_ID
```
<div class="video-container">
<iframe
src="https://www.youtube-nocookie.com/embed/6OMVWiiycLs"
frameBorder="1"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
</div>
See the [example on GitHub](https://github.com/supabase/supabase/blob/master/examples/edge-functions/.github/workflows/deploy.yaml).
export const Page = ({ children }) => <Layout meta={meta} children={children} />
+59
View File
@@ -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 }) => <Layout meta={meta} children={children} />
export default Page
@@ -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 }) => <Layout meta={meta} children={children} />
export default Page
@@ -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.',
}
<div class="video-container">
<iframe
src="https://www.youtube-nocookie.com/embed/OwW0znboh60"
frameBorder="1"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
</div>
[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 }) => <Layout meta={meta} children={children} />
export default Page
@@ -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.',
}
<div class="video-container">
<iframe
src="https://www.youtube-nocookie.com/embed/6OMVWiiycLs"
frameBorder="1"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
</div>
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 }) => <Layout meta={meta} children={children} />
export default Page
@@ -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'
@@ -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).',
}
<div class="video-container">
<iframe
src="https://www.youtube-nocookie.com/embed/wW6L52v9Ldo"
frameBorder="1"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
</div>
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 }) => <Layout meta={meta} children={children} />
export default Page
@@ -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.',
}
<div class="video-container">
<iframe
src="https://www.youtube-nocookie.com/embed/6OMVWiiycLs"
frameBorder="1"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
</div>
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 }) => <Layout meta={meta} children={children} />
export default Page
@@ -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.',
}
<div class="video-container">
<iframe
src="https://www.youtube-nocookie.com/embed/AWfE3a9J_uo"
frameBorder="1"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
</div>
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 }) => <Layout meta={meta} children={children} />
export default Page
@@ -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 <string>` 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 }) => <Layout meta={meta} children={children} />
export default Page
@@ -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. <br />
Modify the `--data '{"name":"Functions"}'` line to `--data '{"name":"World"}'` and try invoking the command again!
<Admonition type="note">
- 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`)
</Admonition>
export const Page = ({ children }) => <Layout meta={meta} children={children} />
export default Page
+43 -117
View File
@@ -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
</Admonition>
## 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://<project_ref>.functions.supabase.co/hello-world' \
@@ -66,135 +66,61 @@ curl --request POST 'https://<project_ref>.functions.supabase.co/hello-world' \
--data '{ "name":"Functions" }'
```
<Admonition type="note">
If you receive an error `Invalid JWT`, find the `ANON_KEY` of your project in the Dashboard under `Settings > API`.
</Admonition>
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!" }`.
<details>
<summary>Implementation details</summary>
- 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`)
</details>
<br />
If you execute Function with a different payload the response will change. <br />
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.
<Admonition type="caution">
Never check your .env files into Git!
</Admonition>
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.
<Admonition type="caution">
Never check your `.env` files into Git!
</Admonition>
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
@@ -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 }) => <Layout meta={meta} children={children} />
export default Page
@@ -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.
<Admonition type="caution">
Never check your .env files into Git!
</Admonition>
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.
<Admonition type="caution">
Never check your `.env` files into Git!
</Admonition>
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 }) => <Layout meta={meta} children={children} />
export default Page
@@ -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:
+3 -3
View File
@@ -13,7 +13,7 @@ toc_depth: 3
<div className="bg-gray-300 rounded-lg p-6 italic">
<br /> ⚠️ UPDATED 20/10: supabase-js v2 is fully released 🥳<br />
<a href="https://supabase.com/docs/reference/javascript">Check the updated docs</a> and <a href="https://supabase.com/docs/reference/javascript/upgrade-guide">migration guide</a>.
<a href="https://supabase.com/docs/reference/javascript">Check the updated docs</a> and <a href="https://supabase.com/docs/reference/javascript/v1/upgrade-guide">migration guide</a>.
</div>
@@ -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)
@@ -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)
@@ -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)
+3 -3
View File
@@ -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: () => (
+15
View File
@@ -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',
},
]
+1 -1
View File
@@ -238,7 +238,7 @@ function RealtimePage() {
</p>,
]}
// [TODO] Point to the correct docs URL
documentation_link={'/docs/guides//docs/guides/realtime/broadcast'}
documentation_link={'/docs/guides/realtime#broadcast'}
/>
</SectionContainer>
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -650,7 +650,7 @@
{
"id": "invoke",
"title": "Invoke a function",
"slug": "auth-admin-invoke",
"slug": "functions-invoke",
"product": "functions",
"type": "function"
}
+1 -1
View File
@@ -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": [
{
+1 -1
View File
@@ -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": [
{
@@ -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": [
{
+2 -2
View File
@@ -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:
@@ -40,10 +40,7 @@ const Header: FC<HeaderProps> = ({ sorts, filters, onAddColumn, onAddRow, header
onAddRow={onAddRow}
/>
)}
<div className="sb-grid-header__inner">
<RefreshButton filters={filters} sorts={sorts} />
{headerActions}
</div>
<div className="sb-grid-header__inner">{headerActions}</div>
</div>
)
}
@@ -77,6 +74,7 @@ const DefaultHeader: FC<DefaultHeaderProps> = ({ sorts, filters, onAddColumn, on
return (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<RefreshButton filters={filters} sorts={sorts} />
<FilterDropdown />
<SortPopover />
</div>
@@ -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<Props> = ({ 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<any>({})
const [selectedTableToToggleRLS, setSelectedTableToToggleRLS] = useState<any>({})
const [selectedPolicyToEdit, setSelectedPolicyToEdit] = useState<any>({})
const [selectedPolicyToDelete, setSelectedPolicyToDelete] = useState<any>({})
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) => (
<section key={table.id}>
<PolicyTableRow
table={table}
isLocked={isLocked}
onSelectToggleRLS={onSelectToggleRLS}
onSelectCreatePolicy={onSelectCreatePolicy}
onSelectEditPolicy={onSelectEditPolicy}
onSelectDeletePolicy={onSelectDeletePolicy}
/>
</section>
))
) : hasTables ? (
<NoSearchResults />
) : (
<div className="flex-grow">
<ProductEmptyState
size="large"
title="Row-Level Security (RLS) Policies"
ctaButtonLabel="Create a table"
infoButtonLabel="What is RLS?"
infoButtonUrl="https://supabase.com/docs/guides/auth/row-level-security"
onClickCta={() => router.push(`/project/${ref}/editor`)}
>
<div className="space-y-4">
<InformationBox
title="What are policies?"
icon={<IconHelpCircle strokeWidth={2} />}
description={
<div className="space-y-2">
<p className="text-sm">
Policies restrict, on a per-user basis, which rows can be returned by normal
queries, or inserted, updated, or deleted by data modification commands.
</p>
<p className="text-sm">
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.
</p>
</div>
}
/>
<p className="text-sm text-scale-1100">
Create a table in this schema first before creating a policy.
</p>
</div>
</ProductEmptyState>
</div>
)}
<PolicyEditorModal
visible={!isEmpty(selectedSchemaAndTable)}
roles={roles}
schema={selectedSchemaAndTable.schema}
table={selectedSchemaAndTable.table}
selectedPolicyToEdit={selectedPolicyToEdit}
onSelectCancel={closePolicyEditorModal}
// @ts-ignore
onCreatePolicy={onCreatePolicy}
// @ts-ignore
onUpdatePolicy={onUpdatePolicy}
onSaveSuccess={onSavePolicySuccess}
/>
<ConfirmModal
danger
visible={!isEmpty(selectedPolicyToDelete)}
title="Confirm to delete policy"
description={`This is permanent! Are you sure you want to delete the policy "${selectedPolicyToDelete.name}"`}
buttonLabel="Delete"
buttonLoadingLabel="Deleting"
onSelectCancel={closeConfirmModal}
onSelectConfirm={onDeletePolicy}
/>
<ConfirmModal
danger={selectedTableToToggleRLS.rls_enabled}
visible={!isEmpty(selectedTableToToggleRLS)}
title={`Confirm to ${selectedTableToToggleRLS.rls_enabled ? 'disable' : 'enable'} RLS`}
description={`Are you sure you want to ${
selectedTableToToggleRLS.rls_enabled ? 'disable' : 'enable'
} row level security for the table "${selectedTableToToggleRLS.name}"?`}
buttonLabel="Confirm"
buttonLoadingLabel="Saving"
onSelectCancel={closeConfirmModal}
onSelectConfirm={onToggleRLS}
/>
</>
)
}
export default observer(Policies)
@@ -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<Props> = ({
table,
isLocked,
onSelectToggleRLS = () => {},
onSelectCreatePolicy = () => {},
}) => {
@@ -35,63 +37,65 @@ const PolicyTableRowHeader: FC<Props> = ({
{table.rls_enabled ? 'RLS enabled' : 'RLS disabled'}
</Badge>
</div>
<div className="flex-1">
<div className="flex flex-row-reverse">
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Button
type="outline"
disabled={!canCreatePolicies}
className="ml-2"
onClick={() => onSelectCreatePolicy(table)}
>
New Policy
</Button>
</Tooltip.Trigger>
{!canCreatePolicies && (
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
'border border-scale-200',
].join(' ')}
{!isLocked && (
<div className="flex-1">
<div className="flex flex-row-reverse">
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Button
type="outline"
disabled={!canCreatePolicies}
className="ml-2"
onClick={() => onSelectCreatePolicy(table)}
>
<span className="text-xs text-scale-1200">
You need additional permissions to create RLS policies
</span>
</div>
</Tooltip.Content>
)}
</Tooltip.Root>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Button
type="default"
disabled={!canToggleRLS}
onClick={() => onSelectToggleRLS(table)}
>
{table.rls_enabled ? 'Disable RLS' : 'Enable RLS'}
</Button>
</Tooltip.Trigger>
{!canToggleRLS && (
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
'border border-scale-200',
].join(' ')}
New Policy
</Button>
</Tooltip.Trigger>
{!canCreatePolicies && (
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
'border border-scale-200',
].join(' ')}
>
<span className="text-xs text-scale-1200">
You need additional permissions to create RLS policies
</span>
</div>
</Tooltip.Content>
)}
</Tooltip.Root>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Button
type="default"
disabled={!canToggleRLS}
onClick={() => onSelectToggleRLS(table)}
>
<span className="text-xs text-scale-1200">
You need additional permissions to toggle RLS
</span>
</div>
</Tooltip.Content>
)}
</Tooltip.Root>
{table.rls_enabled ? 'Disable RLS' : 'Enable RLS'}
</Button>
</Tooltip.Trigger>
{!canToggleRLS && (
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
'border border-scale-200',
].join(' ')}
>
<span className="text-xs text-scale-1200">
You need additional permissions to toggle RLS
</span>
</div>
</Tooltip.Content>
)}
</Tooltip.Root>
</div>
</div>
</div>
)}
</div>
)
}
@@ -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<Props> = observer(
({
table,
onSelectToggleRLS = () => {},
onSelectCreatePolicy = () => {},
onSelectEditPolicy = () => {},
onSelectDeletePolicy = () => {},
}) => {
const { meta } = useStore()
const policies = meta.policies.list((x: PostgresPolicy) => x.table === table.name)
return (
<Panel
title={
<PolicyTableRowHeader
table={table}
onSelectToggleRLS={onSelectToggleRLS}
onSelectCreatePolicy={onSelectCreatePolicy}
/>
}
>
{policies.length === 0 && (
<div className="p-4 px-6">
<p className="text-scale-900 text-sm">No policies created yet</p>
</div>
)}
const PolicyTableRow: FC<Props> = ({
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) => (
<PolicyRow
key={policy.id}
policy={policy}
onSelectEditPolicy={onSelectEditPolicy}
onSelectDeletePolicy={onSelectDeletePolicy}
/>
))}
</Panel>
)
}
)
return (
<Panel
title={
<PolicyTableRowHeader
table={table}
isLocked={isLocked}
onSelectToggleRLS={onSelectToggleRLS}
onSelectCreatePolicy={onSelectCreatePolicy}
/>
}
>
{policies.length === 0 && (
<div className="p-4 px-6 space-y-1">
<p className="text-scale-1100 text-sm">No policies created yet</p>
{table.rls_enabled ? (
<p className="text-scale-1000 text-sm">
RLS is enabled - create a policy to allow access to this table.
</p>
) : (
<p className="text-amber-900 text-sm opacity-50">
Warning: RLS is disabled - anonymous access is allowed to this table
</p>
)}
</div>
)}
export default PolicyTableRow
{/* @ts-ignore */}
{policies.map((policy) => (
<PolicyRow
key={policy.id}
policy={policy}
onSelectEditPolicy={onSelectEditPolicy}
onSelectDeletePolicy={onSelectDeletePolicy}
/>
))}
</Panel>
)
}
export default observer(PolicyTableRow)
@@ -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 }
@@ -158,18 +158,6 @@ const EnterpriseUpdate: FC<Props> = ({
// 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<Props> = ({
<div className="flex-grow mt-10">
<div className="relative space-y-4">
<div className="space-y-8">
<div className="space-y-4 2xl:min-w-5xl mx-auto px-32">
<div className="space-y-4 2xl:max-w-5xl mx-auto px-32">
<h4 className="text-lg text-scale-900 !mb-8">Change your project's subscription</h4>
</div>
<div
className="space-y-8 overflow-y-auto pb-8 2xl:min-w-5xl mx-auto px-32"
className="space-y-8 overflow-y-auto pb-8 2xl:max-w-5xl mx-auto px-32"
style={{ height: 'calc(100vh - 6.4rem - 57px)' }}
>
<h3 className="text-xl">
@@ -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<Props> = ({ 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 (
<Transition
@@ -28,15 +32,27 @@ const PlanSelection: FC<Props> = ({ visible, tiers, currentPlan, onSelectPlan })
<div>
<h4 className="text-lg mb-8">Change your project's subscription</h4>
<div className={`grid py-8 grid-cols-2 ${gridCols}`}>
<Plans plans={formattedTiers} currentPlan={currentPlan} onSelectPlan={onSelectPlan} />
{formattedTiers.map((plan) => {
return (
<PlanCard
key={plan.id}
plan={plan}
currentPlan={currentPlan}
onSelectPlan={() => onSelectPlan(plan)}
/>
)
})}
</div>
<div className="flex justify-center items-center mt-4">
<Link href="https://supabase.com/pricing">
<a
target="_blank"
className="text-sm underline text-scale-1100 hover:text-scale-1200 transition"
>
See detailed comparisons across plans
<a target="_blank">
<Button
type="link"
icon={<IconExternalLink size={14} strokeWidth={1.5} />}
className="text-sm text-scale-1000 hover:text-scale-1100 hover:bg-scale-400"
>
See detailed comparisons across plans
</Button>
</a>
</Link>
</div>
@@ -17,8 +17,8 @@ const PlanCTAButton: FC<Props> = ({ 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<Props> = ({ plan, currentPlan, onSelectPlan }) => {
return (
<Link href="https://supabase.com/contact/enterprise">
<a>
<Button disabled={disabled} type={type} block size="medium">
<Button disabled={disabled} type={type} block>
{ctaText}
</Button>
</a>
@@ -100,20 +100,33 @@ const PlanCTAButton: FC<Props> = ({ plan, currentPlan, onSelectPlan }) => {
return (
<div className="flex flex-col items-center justify-center space-y-2">
<Button
disabled={disabled || projectUpdateDisabled}
type={type}
onClick={() => onSelectPlan(plan)}
block
size="medium"
>
{ctaText}
</Button>
{!disabled && projectUpdateDisabled && (
<p className="text-sm text-scale-1100">
Subscription changes are currently disabled, our engineers are working on a fix
</p>
)}
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger className="w-full">
<Button
block
type={type}
disabled={disabled || projectUpdateDisabled}
onClick={() => onSelectPlan(plan)}
>
{ctaText}
</Button>
</Tooltip.Trigger>
{!disabled && projectUpdateDisabled && (
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
'border border-scale-200 w-[260px] flex items-center justify-center',
].join(' ')}
>
<span className="text-xs text-scale-1200 text-center">
Subscription changes are currently disabled, our engineers are working on a fix
</span>
</div>
</Tooltip.Content>
)}
</Tooltip.Root>
</div>
)
}
@@ -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<Props> = ({ 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 (
<div
className={[
'px-6 py-6 border rounded flex flex-col justify-between',
`${isEnterprise ? 'bg-scale-200' : 'bg-scale-300'}`,
`${isEnterprise && teamTierEnabled ? 'lg:col-span-3 xl:col-auto' : ''}`,
].join(' ')}
>
<div
className={[
'space-y-8',
`${isEnterprise ? 'flex flex-col xl:space-x-0 xl:flex-col' : ''}`,
`${isEnterprise && teamTierEnabled ? 'lg:flex-row lg:space-x-24' : ''}`,
].join(' ')}
>
<div
className={[
'space-y-4',
`${isEnterprise ? 'xl:w-auto' : ''}`,
`${isEnterprise && teamTierEnabled ? 'xl:w-auto' : ''}`,
].join(' ')}
>
<div className="flex items-center space-x-2">
<p
className={[
'text-brand-900 text-sm',
`${isEnterprise ? 'lg:text-base xl:text-sm font-bold font-mono uppercase' : ''}`,
].join(' ')}
>
{planMeta.name}
</p>
{isCurrentPlan ? (
<div className="text-xs bg-scale-500 text-scale-1000 rounded px-2 py-0.5">
Current plan
</div>
) : planMeta.new ? (
<div className="text-xs bg-brand-400 text-brand-900 rounded px-2 py-0.5">New</div>
) : (
<></>
)}
</div>
<p className="text-sm text-scale-1100">{planMeta.description}</p>
<div
className={[
'space-y-2',
`${
isEnterprise
? 'pb-6 border-b lg:pt-6 lg:border-b-0 lg:border-t xl:pt-0 xl:pb-6 xl:border-t-0 xl:border-b'
: 'border-b border-t py-8'
}`,
].join(' ')}
>
{planMeta.priceMonthly !== undefined ? (
<>
<p className="text-sm text-scale-1000">Starting from</p>
<div className="space-y-1">
<p className="text-[3.4rem] leading-none">${planMeta.priceMonthly}</p>
<p className="text-sm text-scale-1000">{planMeta.priceUnit}</p>
</div>
<div className="text-xs bg-brand-400 text-brand-900 rounded px-2 py-0.5 w-fit">
{planMeta.warning}
</div>
</>
) : (
<PlanCTAButton plan={plan} currentPlan={currentPlan} onSelectPlan={onSelectPlan} />
)}
</div>
</div>
<div className={`${isEnterprise ? 'lg:space-y-0 lg:!mt-0 xl:space-y-4' : 'space-y-4'}`}>
<p className="text-xs text-scale-1000">{planMeta.preface}</p>
<ul
role="list"
className={[
`${isEnterprise ? 'xl:gap-0 xl:columns-1' : ''}`,
`${isEnterprise && teamTierEnabled ? 'lg:gap-16 lg:columns-2' : ''}`,
].join(' ')}
>
{planMeta.features.map((feature) => (
<li key={feature} className="flex py-2">
<div className="w-[12px]">
<IconCheck
className="h-3 w-3 text-brand-900 translate-y-[2.5px]"
aria-hidden="true"
strokeWidth={3}
/>
</div>
<p className="ml-3 text-xs text-scale-1100">{feature}</p>
</li>
))}
</ul>
</div>
</div>
{!isEnterprise && (
<div className="space-y-4 !mt-20">
<div className="space-y-2">
<p className="text-sm text-scale-1100">{planMeta.additional}</p>
<p className="text-xs text-scale-1000">{planMeta.scale}</p>
</div>
<PlanCTAButton plan={plan} currentPlan={currentPlan} onSelectPlan={onSelectPlan} />
</div>
)}
</div>
)
}
export default PlanCard
@@ -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',
},
}
@@ -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 = () => (
<div
className={[
'absolute top-0 left-0 h-full w-full animate-sway',
'bg-gradient-to-b from-transparent via-transparent to-green-900',
].join(' ')}
/>
)
const CurrentSubscriptionBanner = () => (
<div
className={[
'absolute -top-7 left-0 h-8 w-full',
'flex items-center justify-center',
'bg-brand-400 text-brand-900',
'rounded-t-md border border-t border-l border-r border-b-0',
].join(' ')}
>
<p className="text-xs">Current plan</p>
</div>
)
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<Props> = ({ 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 (
<div key={PRICING_META[plan.id].name} className="relative h-full">
{plan.id === STRIPE_PRODUCT_IDS.PRO && (
<div className="absolute left-0 bottom-0 h-full w-full overflow-hidden opacity-50">
<AnimatedGradientBackground />
</div>
)}
<div className="flex h-full flex-col overflow-hidden rounded border">
{isCurrentPlan && <CurrentSubscriptionBanner />}
<div className="bg-white px-6 py-6 dark:bg-scale-300">
<span
className="text-cd inline-flex rounded-full text-base font-normal tracking-wide text-scale-1200"
id="tier-standard"
>
{PRICING_META[plan.id].name}
</span>
<div className="mt-2 flex items-baseline">
<div className="flex space-x-2">
{PRICING_META[plan.id].priceMonthly !== undefined ? (
<>
<div>
{PRICING_META[plan.id].from && (
<span className="text-sm font-medium text-scale-1200 mr-1">From</span>
)}
<span className="text-2xl">${PRICING_META[plan.id].priceMonthly}</span>
<span className="text-xl font-medium text-scale-900">/mo</span>
</div>
</>
) : (
<span className="text-2xl">Contact Us</span>
)}
<div className="flex h-8 -mt-[2px]">
{PRICING_META[plan.id].warning && (
<div className="mt-2 rounded-md bg-brand-300 bg-opacity-30 px-2 py-1 text-xs text-brand-1000">
{PRICING_META[plan.id].warning}
</div>
)}
</div>
</div>
</div>
<div className="py-4">
<PlanCTAButton
plan={plan}
currentPlan={currentPlan}
onSelectPlan={onSelectPlan}
/>
</div>
<p className="text-sm text-scale-1100">{PRICING_META[plan.id].description}</p>
</div>
<div
className={[
'flex-1 flex-col justify-between',
'space-y-6 border-t dark:border-scale-400',
'bg-scale-100 dark:bg-scale-300',
'hidden h-full px-6 py-6 lg:flex',
].join(' ')}
>
<div className="space-y-6">
{PRICING_META[plan.id].preface && (
<p className="text-sm text-scale-1200">{PRICING_META[plan.id].preface}</p>
)}
<ul role="list" className="divide-y dark:divide-scale-400">
{PRICING_META[plan.id].features.map((feature) => (
<li key={feature} className="flex py-2">
<div className="w-[12px]">
<IconCheck
className="h-3 w-3 text-brand-900 translate-y-[2.5px]"
aria-hidden="true"
strokeWidth={3}
/>
</div>
<p className="mb-0 ml-3 text-xs text-scale-1100 dark:text-scale-1200">
{feature}
</p>
</li>
))}
</ul>
</div>
<div className="flex flex-col gap-6">
<div className="space-y-2">
{PRICING_META[plan.id].additional && (
<p className="text-sm text-scale-1200">{PRICING_META[plan.id].additional}</p>
)}
{PRICING_META[plan.id].scale && (
<p className="text-xs text-scale-900">{PRICING_META[plan.id].scale}</p>
)}
{PRICING_META[plan.id].shutdown && (
<p className="text-xs text-scale-900">{PRICING_META[plan.id].shutdown}</p>
)}
</div>
</div>
</div>
</div>
</div>
)
})}
</>
)
}
export default Plans
@@ -177,6 +177,7 @@ const ProUpgrade: FC<Props> = ({
}
const onConfirmPayment = async () => {
setIsSubmitting(true)
const payload = formSubscriptionUpdatePayload(
currentSubscription,
selectedTier,
@@ -231,13 +232,13 @@ const ProUpgrade: FC<Props> = ({
>
<div className="flex-grow mt-10">
<div className="relative space-y-4">
<div className="px-32 mx-auto space-y-4 2xl:min-w-5xl">
<div className="relative px-32 mx-auto space-y-4 2xl:max-w-5xl">
<BackButton onClick={() => onSelectBack()} />
<h4 className="text-lg text-scale-900 !mb-8">Change your project's subscription</h4>
</div>
<div
className="px-32 pb-8 mx-auto space-y-8 overflow-y-auto 2xl:min-w-5xl"
className="px-32 pb-8 mx-auto space-y-8 overflow-y-auto 2xl:max-w-5xl"
style={{ height: 'calc(100vh - 9rem - 57px)' }}
>
<div className="space-y-2">
@@ -169,6 +169,7 @@ const TeamUpgrade: FC<Props> = ({
message: 'Team Plan is not enabled yet.',
})
}
setIsSubmitting(true)
const payload = formSubscriptionUpdatePayload(
currentSubscription,
selectedTier,
@@ -223,13 +224,13 @@ const TeamUpgrade: FC<Props> = ({
>
<div className="flex-grow mt-10">
<div className="relative space-y-4">
<div className="px-32 mx-auto space-y-4 2xl:min-w-5xl">
<div className="px-32 mx-auto space-y-4 2xl:max-w-5xl">
<BackButton onClick={() => onSelectBack()} />
<h4 className="text-lg text-scale-900 !mb-8">Change your project's subscription</h4>
</div>
<div
className="px-32 pb-8 mx-auto space-y-8 overflow-y-auto 2xl:min-w-5xl"
className="px-32 pb-8 mx-auto space-y-8 overflow-y-auto 2xl:max-w-5xl"
style={{ height: 'calc(100vh - 9rem - 57px)' }}
>
<div className="space-y-2">
@@ -67,7 +67,7 @@ const TableList: FC<Props> = ({
size="small"
value={selectedSchema}
onChange={onSelectSchema}
icon={isLocked && <IconLock size={16} strokeWidth={2} />}
icon={isLocked && <IconLock size={14} strokeWidth={2} />}
>
<Listbox.Option disabled key="normal-schemas" value="normal-schemas" label="Schemas">
<p className="text-sm">Schemas</p>
@@ -144,8 +144,8 @@ const SidePanelEditor: FC<Props> = ({
closePanel()
}
if (configuration.isEncrypted) {
await meta.schemas.loadViews(selectedTable?.schema ?? '')
if (configuration.isEncrypted && selectedTable?.schema) {
await meta.views.loadBySchema(selectedTable.schema)
}
resolve()
@@ -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<Props> = ({
<p>
Restrict access to your table by enabling RLS and writing Postgres policies.
</p>
<p>
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.
</p>
{!tableFields.isRLSEnabled && (
<Alert
withIcon
variant="warning"
className="!px-4 !py-3 mt-3"
// @ts-ignore
title={
<span className="text-amber-1100 text-sm">
Turning off RLS means that you are allowing anonymous access to your table
</span>
}
/>
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.
</Alert>
)}
</>
}
@@ -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<Props> = ({
// @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')
@@ -28,6 +28,7 @@ const AuthLayout: FC<Props> = ({ title, children }) => {
meta.policies.load()
meta.tables.load()
meta.roles.load()
meta.schemas.load()
}
}, [ui.selectedProject?.ref])
@@ -20,7 +20,10 @@ const FeedbackDropdown = () => {
return (
<Popover
open={isOpen}
onOpenChange={(e) => setIsOpen(e)}
onOpenChange={(e) => {
setIsOpen(e)
if (!e) setScreenshot(undefined)
}}
size="content"
side="bottom"
align="end"
@@ -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<Props> = ({
const { ui } = useStore()
const inputRef = useRef<any>(null)
const uploadButtonRef = useRef()
const [isSending, setSending] = useState(false)
const [isSavingScreenshot, setIsSavingScreenshot] = useState(false)
@@ -42,7 +43,7 @@ const FeedbackWidget: FC<Props> = ({
setFeedback(e.target.value)
}
const captureScreenshot = () => {
const captureScreenshot = async () => {
setIsSavingScreenshot(true)
function filter(node: HTMLElement) {
@@ -52,6 +53,8 @@ const FeedbackWidget: FC<Props> = ({
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<Props> = ({
})
}
const onFilesUpload = async (event: ChangeEvent<HTMLInputElement>) => {
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<Props> = ({
}
return (
<div id="feedback-widget">
<div id="feedback-widget" className="text-area-text-sm">
<Input.TextArea
className="w-80 p-3"
size="small"
@@ -138,33 +153,44 @@ const FeedbackWidget: FC<Props> = ({
</div>
</div>
) : (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Button
as="span"
type="default"
disabled={isSavingScreenshot}
loading={isSavingScreenshot}
className="px-2 py-1.5"
<Dropdown
className="feedback-dropdown"
size="small"
overlay={[
<Dropdown.Item
icon={<IconUpload size={14} />}
onClick={() => {
if (uploadButtonRef.current) (uploadButtonRef.current as any).click()
}}
>
Upload screenshot
</Dropdown.Item>,
<Dropdown.Item
icon={<IconCamera size={14} />}
onClick={() => captureScreenshot()}
/>
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'bg-scale-100 rounded py-1 px-2 leading-none shadow', // background
'w-[130px] text-center border-scale-200 border', //border
].join(' ')}
>
<span className="text-scale-1200 text-xs">
Capture screenshot of current view
</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
Capture screenshot
</Dropdown.Item>,
]}
>
<Button
as="span"
type="default"
disabled={isSavingScreenshot}
loading={isSavingScreenshot}
className="px-2 py-1.5"
icon={<IconImage size={14} />}
/>
</Dropdown>
)}
<input
type="file"
// @ts-ignore
ref={uploadButtonRef}
className="hidden"
accept="image/png"
onChange={onFilesUpload}
/>
<Button disabled={isSending} loading={isSending} onClick={sendFeedback}>
Send feedback
</Button>
@@ -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<Props> = ({
bucket = {},
projectRef = '',
isSelected = false,
onSelectDeleteBucket = () => {},
onSelectToggleBucketPublic = () => {},
}) => {
return (
<ProductMenuItem
key={bucket.id}
name={
<div className="flex items-center justify-between space-x-2 truncate w-full">
<p className="truncate" title={bucket.name}>
{bucket.name}
</p>
{bucket.public && <Badge color="yellow">Public</Badge>}
</div>
}
url={`/project/${projectRef}/storage/buckets/${bucket.id}`}
isActive={isSelected}
action={
bucket.status === STORAGE_ROW_STATUS.LOADING ? (
<IconLoader className="animate-spin" size={16} strokeWidth={2} />
) : bucket.status === STORAGE_ROW_STATUS.READY ? (
<Dropdown
side="bottom"
align="start"
overlay={[
<Dropdown.Item
key="toggle-private"
onClick={() => onSelectToggleBucketPublic(bucket)}
>
{bucket.public ? 'Make private' : 'Make public'}
</Dropdown.Item>,
<Dropdown.Separator key="bucket-separator" />,
<Dropdown.Item
icon={<IconTrash size="tiny" />}
key="delete-bucket"
onClick={() => onSelectDeleteBucket(bucket)}
>
Delete bucket
</Dropdown.Item>,
]}
>
<IconMoreVertical
className="opacity-0 group-hover:opacity-100"
size="tiny"
strokeWidth={2}
/>
</Dropdown>
) : (
<div />
)
}
/>
)
}
export default BucketRow
@@ -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<Props> = () => {
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<Props> = () => {
| 'usage'
| 'logs'
const { ui } = useStore()
const projectRef = ui.selectedProject?.ref
const storageExplorerStore = useStorageStore()
const {
loaded,
@@ -98,7 +85,7 @@ const StorageMenu: FC<Props> = () => {
<div className="h-px w-full bg-scale-500"></div>
<div className="">
<Menu.Group title="Configuration" />
<Link href={`/project/${projectRef}/storage/policies`}>
<Link href={`/project/${ref}/storage/policies`}>
<Menu.Item rounded active={page === 'policies'}>
<p className="truncate">Policies</p>
</Menu.Item>
@@ -110,61 +97,3 @@ const StorageMenu: FC<Props> = () => {
}
export default observer(StorageMenu)
const BucketRow = ({
bucket = {},
projectRef = '',
isSelected = false,
onSelectDeleteBucket = () => {},
onSelectToggleBucketPublic = () => {},
}: any) => {
return (
<ProductMenuItem
key={bucket.id}
name={
<div className="flex items-center justify-between space-x-2 truncate w-full">
<p className="truncate" title={bucket.name}>
{bucket.name}
</p>
{bucket.public && <Badge color="yellow">Public</Badge>}
</div>
}
url={`/project/${projectRef}/storage/buckets/${bucket.id}`}
isActive={isSelected}
action={
bucket.status === STORAGE_ROW_STATUS.LOADING ? (
<IconLoader className="animate-spin" size={16} strokeWidth={2} />
) : bucket.status === STORAGE_ROW_STATUS.READY ? (
<Dropdown
side="bottom"
align="start"
overlay={[
<Dropdown.Item
key="toggle-private"
onClick={() => onSelectToggleBucketPublic(bucket)}
>
{bucket.public ? 'Make private' : 'Make public'}
</Dropdown.Item>,
<Dropdown.Separator key="bucket-separator" />,
<Dropdown.Item
icon={<IconTrash size="tiny" />}
key="delete-bucket"
onClick={() => onSelectDeleteBucket(bucket)}
>
Delete bucket
</Dropdown.Item>,
]}
>
<IconMoreVertical
className="opacity-0 group-hover:opacity-100"
size="tiny"
strokeWidth={2}
/>
</Dropdown>
) : (
<div />
)
}
/>
)
}
@@ -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<Props> = ({
children,
}) => {
const { vault, meta, ui } = useStore()
const { id, type } = useParams()
const { isInitialized, isLoading, error } = meta.tables
const [loaded, setLoaded] = useState<boolean>(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
@@ -1,4 +0,0 @@
export interface SchemaView {
schema: string
name: string
}
@@ -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<Props> = ({
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<PostgresTable>[] = meta.foreignTables.list(
(table: PostgresTable) => table.schema === selectedSchema
(table: Partial<PostgresTable>) => 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<string>('')
const [isRefreshing, setIsRefreshing] = useState<boolean>(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<Props> = ({
const filteredViews =
searchText.length === 0
? meta.schemas.views || []
: (meta.schemas.views || []).filter((view) =>
view.name.toLowerCase().includes(searchText.toLowerCase())
? views || []
: (views || []).filter((view: Partial<PostgresTable>) =>
(view?.name ?? '').toLowerCase().includes(searchText.toLowerCase())
)
const filteredForeignTables =
@@ -229,147 +218,165 @@ const TableEditorMenu: FC<Props> = ({
</div>
</div>
<div className="flex-auto px-4 overflow-y-auto space-y-6 pb-4">
{/* List of tables belonging to selected schema */}
{filteredTables.length > 0 && (
<Menu type="pills">
<Menu.Group
// @ts-ignore
title={
<>
<div className="flex w-full items-center justify-between">
<div className="flex items-center space-x-1">
<p>Tables</p>
<p style={{ fontVariantNumeric: 'tabular-nums' }}>
({filteredTables.length})
</p>
{isFetchingTables ? (
<div className="mx-7 flex items-center space-x-2">
<IconLoader className="animate-spin" size={14} strokeWidth={1.5} />
<p className="text-sm text-scale-1000">Loading tables...</p>
</div>
) : searchText.length === 0 && filteredTables.length === 0 && filteredViews.length === 0 ? (
<div className="mx-7 space-y-1 rounded-md border border-scale-400 bg-scale-300 py-3 px-4">
<p className="text-xs">No tables available</p>
<p className="text-xs text-scale-1100">This schema has no tables available yet</p>
</div>
) : (
<div className="flex-auto px-4 overflow-y-auto space-y-6 pb-4">
{/* List of tables belonging to selected schema */}
{filteredTables.length > 0 && (
<Menu type="pills">
<Menu.Group
// @ts-ignore
title={
<>
<div className="flex w-full items-center justify-between">
<div className="flex items-center space-x-1">
<p>Tables</p>
<p style={{ fontVariantNumeric: 'tabular-nums' }}>
({filteredTables.length})
</p>
</div>
<button className="cursor-pointer" onClick={refreshTables}>
<IconRefreshCw className={isRefreshing ? 'animate-spin' : ''} size={14} />
</button>
</div>
<button className="cursor-pointer" onClick={refreshTables}>
<IconRefreshCw className={isRefreshing ? 'animate-spin' : ''} size={14} />
</button>
</div>
</>
}
/>
</>
}
/>
<div>
{filteredTables.map((table) => {
const isActive = Number(id) === table.id
<div>
{filteredTables.map((table) => {
const isActive = Number(id) === table.id
return (
<ProductMenuItem
key={table.name}
url={`/project/${ref}/editor/${table.id}`}
name={table.name}
hoverText={table.comment ? table.comment : table.name}
isActive={isActive}
action={
isActive &&
!isLocked && (
<Dropdown
size="small"
side="bottom"
align="start"
overlay={[
<Dropdown.Item
key="edit-table"
icon={<IconEdit size="tiny" />}
onClick={() => onEditTable(table)}
>
Edit Table
</Dropdown.Item>,
<Dropdown.Item
key="duplicate-table"
icon={<IconCopy size="tiny" />}
onClick={() => onDuplicateTable(table)}
>
Duplicate Table
</Dropdown.Item>,
<Link href={`/project/${ref}/auth/policies?search=${table.id}`}>
<a>
<Dropdown.Item key="delete-table" icon={<IconLock size="tiny" />}>
View Policies
</Dropdown.Item>
</a>
</Link>,
<Dropdown.Separator key="separator" />,
<Dropdown.Item
key="delete-table"
icon={<IconTrash size="tiny" />}
onClick={() => onDeleteTable(table)}
>
Delete Table
</Dropdown.Item>,
]}
>
<div className="text-scale-900 transition-colors hover:text-scale-1200">
<IconChevronDown size={14} strokeWidth={2} />
</div>
</Dropdown>
)
}
/>
)
})}
</div>
</Menu>
)}
{/* List of views belonging to selected schema */}
{filteredViews.length > 0 && (
<Menu type="pills">
<Menu.Group
// @ts-ignore
title={
<div className="flex w-full items-center space-x-1">
<p>Views</p>
<p style={{ fontVariantNumeric: 'tabular-nums' }}>({filteredViews.length})</p>
</div>
}
/>
{filteredViews.map((view: SchemaView) => {
const isActive = Number(id) === view.id
return (
<ProductMenuItem
key={table.name}
url={`/project/${projectRef}/editor/${table.id}`}
name={table.name}
hoverText={table.comment ? table.comment : table.name}
isActive={isActive}
action={
isActive &&
!isLocked && (
<Dropdown
size="small"
side="bottom"
align="start"
overlay={[
<Dropdown.Item
key="edit-table"
icon={<IconEdit size="tiny" />}
onClick={() => onEditTable(table)}
>
Edit Table
</Dropdown.Item>,
<Dropdown.Item
key="duplicate-table"
icon={<IconCopy size="tiny" />}
onClick={() => onDuplicateTable(table)}
>
Duplicate Table
</Dropdown.Item>,
<Dropdown.Separator key="separator" />,
<Dropdown.Item
key="delete-table"
icon={<IconTrash size="tiny" />}
onClick={() => onDeleteTable(table)}
>
Delete Table
</Dropdown.Item>,
]}
>
<div className="text-scale-900 transition-colors hover:text-scale-1200">
<IconChevronDown size={14} strokeWidth={2} />
</div>
</Dropdown>
)
}
/>
<Link key={view.id} href={`/project/${ref}/editor/${view.id}?type=view`}>
<a>
<Menu.Item key={view.id} rounded active={isActive}>
<div className="flex justify-between">
<p className="truncate">{view.name}</p>
</div>
</Menu.Item>
</a>
</Link>
)
})}
</div>
</Menu>
)}
</Menu>
)}
{/* List of views belonging to selected schema */}
{filteredViews.length > 0 && (
<Menu type="pills">
<Menu.Group
// @ts-ignore
title={
<div className="flex w-full items-center space-x-1">
<p>Views</p>
<p style={{ fontVariantNumeric: 'tabular-nums' }}>({filteredViews.length})</p>
</div>
}
/>
{/* List of foreign tables belonging to selected schema */}
{filteredForeignTables.length > 0 && (
<Menu type="pills">
<Menu.Group
// @ts-ignore
title={
<div className="flex w-full items-center space-x-1">
<p>Foreign Tables</p>
<p style={{ fontVariantNumeric: 'tabular-nums' }}>
({filteredForeignTables.length})
</p>
</div>
}
/>
{filteredViews.map((view: SchemaView) => {
const viewId = Base64.encode(JSON.stringify(view))
const isActive = id === viewId
return (
<Link key={viewId} href={`/project/${projectRef}/editor/${viewId}`}>
<a>
<Menu.Item key={viewId} rounded active={isActive}>
<div className="flex justify-between">
<p className="truncate">{view.name}</p>
</div>
</Menu.Item>
</a>
</Link>
)
})}
</Menu>
)}
{/* List of foreign tables belonging to selected schema */}
{filteredForeignTables.length > 0 && (
<Menu type="pills">
<Menu.Group
// @ts-ignore
title={
<div className="flex w-full items-center space-x-1">
<p>Foreign Tables</p>
<p style={{ fontVariantNumeric: 'tabular-nums' }}>
({filteredForeignTables.length})
</p>
</div>
}
/>
{filteredForeignTables.map((table: Partial<PostgresTable>) => {
const isActive = Number(id) === table.id
return (
<Link key={table.id} href={`/project/${projectRef}/editor/${table.id}`}>
<a>
<Menu.Item key={table.id} rounded active={isActive}>
<div className="flex justify-between">
<p className="truncate">{table.name}</p>
</div>
</Menu.Item>
</a>
</Link>
)
})}
</Menu>
)}
</div>
{filteredForeignTables.map((table: Partial<PostgresTable>) => {
const isActive = Number(id) === table.id
return (
<Link key={table.id} href={`/project/${ref}/editor/${table.id}?type=foreign`}>
<a>
<Menu.Item key={table.id} rounded active={isActive}>
<div className="flex justify-between">
<p className="truncate">{table.name}</p>
</div>
</Menu.Item>
</a>
</Link>
)
})}
</Menu>
)}
</div>
)}
{searchText.length > 0 && filteredTables.length === 0 && filteredViews.length === 0 && (
<div className="!mt-0 mx-7 space-y-1 rounded-md border border-scale-400 bg-scale-300 py-3 px-4">
@@ -379,13 +386,6 @@ const TableEditorMenu: FC<Props> = ({
</p>
</div>
)}
{searchText.length === 0 && filteredTables.length === 0 && filteredViews.length === 0 && (
<div className="!mt-0 mx-7 space-y-1 rounded-md border border-scale-400 bg-scale-300 py-3 px-4">
<p className="text-xs">No tables available</p>
<p className="text-xs text-scale-1100">This schema has no tables available yet</p>
</div>
)}
</div>
)
}
@@ -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 (
<Dropdown
side="bottom"
@@ -263,7 +273,7 @@ const ResultsDropdown = observer(() => {
<CSVLink
ref={csvRef}
className="hidden"
data={sqlEditorStore.activeTab?.csvData || ''}
data={formatDataForCSV(sqlEditorStore.activeTab?.csvData || '')}
filename={`supabase_${sqlEditorStore.projectRef}_${sqlEditorStore.activeTab.name}`}
/>
</Dropdown>
@@ -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',
@@ -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<Props> = () => {
const storageExplorerStore = useStorageStore()
const { selectedFileCustomExpiry, setSelectedFileCustomExpiry, copyFileURLToClipboard } =
storageExplorerStore
const visible = selectedFileCustomExpiry !== undefined
const onClose = () => setSelectedFileCustomExpiry(undefined)
return (
<Modal
hideFooter
size="small"
header="Custom expiry for signed URL"
visible={visible}
alignFooter="right"
confirmText="Get URL"
onCancel={() => onClose()}
>
<Form
validateOnBlur
initialValues={{ expiresIn: '' }}
onSubmit={async (values: any, { setSubmitting }: any) => {
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 }) => (
<>
<div className="py-6">
<Modal.Content>
<Input
disabled={isSubmitting}
type="number"
id="expiresIn"
label="Enter the duration for which the URL will be valid:"
actions={<p className="text-sm text-scale-1000 mr-2">seconds</p>}
/>
</Modal.Content>
</div>
<Modal.Separator />
<Modal.Content>
<div className="flex items-center justify-end space-x-2 pt-1 pb-3">
<Button type="default" onClick={() => onClose()}>
Cancel
</Button>
<Button
disabled={values.expiresIn === '' || isSubmitting}
loading={isSubmitting}
htmlType="submit"
type="primary"
>
Get signed URL
</Button>
</div>
</Modal.Content>
</>
)}
</Form>
</Modal>
)
}
export default observer(CustomExpiryModal)
@@ -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}
/>
<ItemContextMenu
id={CONTEXT_MENU_KEYS.STORAGE_ITEM}
onCopyFileURL={onCopyFileURL}
onSelectItemRename={onSelectItemRename}
onSelectItemMove={onSelectItemMove}
onDownloadFile={onDownloadFile}
onSelectItemDelete={onSelectItemDelete}
/>
<ItemContextMenu id={CONTEXT_MENU_KEYS.STORAGE_ITEM} />
<FolderContextMenu
id={CONTEXT_MENU_KEYS.STORAGE_FOLDER}
onRenameFolder={onSelectItemRename}
onDeleteFolder={onSelectItemDelete}
onRenameFolder={(folder) => setSelectedItemToRename(folder)}
onDeleteFolder={(folder) => setSelectedItemsToDelete([folder])}
/>
{view === STORAGE_VIEWS.COLUMNS ? (
<div className="flex">
@@ -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)
@@ -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)}
@@ -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 <IconLoader size={16} strokeWidth={2} className="animate-spin" />
}
@@ -43,7 +45,6 @@ const RowIcon = ({ view, status, fileType, mimeType }) => {
return (
<SVG
src={iconSrc}
alt={fileType}
preProcessor={(code) =>
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 <IconFile size={16} strokeWidth={2} />
}
const FileExplorerRow = ({
interface Props {
item: any
view: string
columnIndex: number
selectedItems: any[]
openedFolders: any[]
selectedFilePreview: any
}
const FileExplorerRow: FC<Props> = ({
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<any>(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: <IconEdit size="tiny" />,
onClick: () => onSelectItemRename(itemWithColumnIndex),
onClick: () => setSelectedItemToRename(itemWithColumnIndex),
},
{
name: 'Download',
icon: <IconDownload size="tiny" />,
onClick: () => downloadFolder(itemWithColumnIndex),
},
{ name: 'Separator' },
{ name: 'Separator', icon: undefined, onClick: undefined },
{
name: 'Delete',
icon: <IconTrash size="tiny" />,
onClick: () => onSelectItemDelete(itemWithColumnIndex),
icon: <IconTrash2 size="tiny" />,
onClick: () => setSelectedItemsToDelete([itemWithColumnIndex]),
},
]
: [
...(!item.isCorrupted
? [
{
name: 'Copy URL',
icon: <IconCopy size="tiny" />,
onClick: () => onCopyFileURL(itemWithColumnIndex),
name: 'Get URL',
icon: <IconClipboard size="tiny" />,
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: <IconEdit size="tiny" />,
onClick: () => onSelectItemRename(itemWithColumnIndex),
onClick: () => setSelectedItemToRename(itemWithColumnIndex),
},
{
name: 'Move',
icon: <IconMove size="tiny" />,
onClick: () => onSelectItemMove(itemWithColumnIndex),
onClick: () => setSelectedItemsToMove([itemWithColumnIndex]),
},
{
name: 'Download',
icon: <IconDownload size="tiny" />,
onClick: () => onDownloadFile(itemWithColumnIndex),
onClick: async () => await downloadFile(itemWithColumnIndex),
},
{ name: 'Separator' },
{ name: 'Separator', icon: undefined, onClick: undefined },
]
: []),
{
name: 'Delete',
icon: <IconTrash size="tiny" />,
onClick: () => onSelectItemDelete(itemWithColumnIndex),
icon: <IconTrash2 size="tiny" />,
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 (
<Dropdown
isNested
key={option.name}
side="right"
align="start"
overlay={(option?.children ?? [])?.map((child) => {
return (
<Dropdown.Item key={child.name} onClick={child.onClick}>
{child.name}
</Dropdown.Item>
)
})}
>
<div
className={[
'flex items-center justify-between px-4 py-1.5 text-xs text-scale-1100',
'w-full focus:bg-scale-300 dark:focus:bg-scale-500 focus:text-scale-1200',
].join(' ')}
>
<div className="flex items-center space-x-2">
{option.icon}
<p>{option.name}</p>
</div>
<IconChevronRight size="tiny" />
</div>
</Dropdown>
)
} else if (option.name === 'Separator') {
return <Dropdown.Separator key="row-separator" />
} else {
return (
@@ -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 (
<Menu id={id} animation="fade" className="!bg-scale-300 border border-scale-500">
<Item
onClick={({ props }) => (!props.item.isCorrupted ? onCopyFileURL(props.item) : () => {})}
>
<span className="text-xs">Copy URL</span>
{isPublic ? (
<Item onClick={({ props }) => onHandleClick('copy', props.item)}>
<IconClipboard size="tiny" />
<span className="ml-2 text-xs">Get URL</span>
</Item>
) : (
<Submenu
label={
<div className="flex items-center space-x-2">
<IconClipboard size="tiny" />
<span className="text-xs">Get URL</span>
</div>
}
arrow={<IconChevronRight size="tiny" />}
>
<Item
onClick={({ props }) => onHandleClick('copy', props.item, URL_EXPIRY_DURATION.WEEK)}
>
<span className="ml-2 text-xs">Expire in 1 week</span>
</Item>
<Item
onClick={({ props }) => onHandleClick('copy', props.item, URL_EXPIRY_DURATION.MONTH)}
>
<span className="ml-2 text-xs">Expire in 1 month</span>
</Item>
<Item
onClick={({ props }) => onHandleClick('copy', props.item, URL_EXPIRY_DURATION.YEAR)}
>
<span className="ml-2 text-xs">Expire in 1 year</span>
</Item>
<Item onClick={({ props }) => onHandleClick('copy', props.item, -1)}>
<span className="ml-2 text-xs">Custom expiry</span>
</Item>
</Submenu>
)}
<Item onClick={({ props }) => onHandleClick('rename', props.item)}>
<IconEdit size="tiny" />
<span className="ml-2 text-xs">Rename</span>
</Item>
<Item
onClick={({ props }) =>
!props.item.isCorrupted ? onSelectItemRename(props.item) : () => {}
}
>
<span className="text-xs">Rename</span>
<Item onClick={({ props }) => onHandleClick('move', props.item)}>
<IconMove size="tiny" />
<span className="ml-2 text-xs">Move</span>
</Item>
<Item
onClick={({ props }) => (!props.item.isCorrupted ? onSelectItemMove(props.item) : () => {})}
>
<span className="text-xs">Move</span>
<Item onClick={({ props }) => onHandleClick('download', props.item)}>
<IconDownload size="tiny" />
<span className="ml-2 text-xs">Download</span>
</Item>
<Item
onClick={({ props }) => (!props.item.isCorrupted ? onDownloadFile(props.item) : () => {})}
>
<span className="text-xs">Download</span>
</Item>
<Item onClick={({ props }) => onSelectItemDelete(props.item)}>
<span className="text-xs">Delete</span>
<Separator />
<Item onClick={({ props }) => setSelectedItemsToDelete([props.item])}>
<IconTrash2 size="tiny" />
<span className="ml-2 text-xs">Delete</span>
</Item>
</Menu>
)
}
export default ItemContextMenu
export default observer(ItemContextMenu)
@@ -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 (
<Transition
show={isOpen}
enter="transition ease-out duration-150"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition ease-in duration-100"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<div
className="
<>
<Transition
show={isOpen}
enter="transition ease-out duration-150"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition ease-in duration-100"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<div
className="
h-full border-l
border-panel-border-light bg-panel-header-light p-4 dark:border-panel-border-dark dark:bg-panel-header-dark"
style={{ width }}
>
{/* Preview Header */}
<div className="flex w-full justify-end text-scale-900 transition-colors hover:text-scale-1200">
<IconX
className="cursor-pointer"
size={14}
strokeWidth={2}
onClick={() => closeFilePreview()}
/>
</div>
{/* Preview Thumbnail*/}
<div className="my-4 border border-panel-border-light dark:border-panel-border-dark">
<div className="flex h-56 w-full items-center 2xl:h-72">
<PreviewFile mimeType={mimeType} previewUrl={file.previewUrl} />
style={{ width }}
>
{/* Preview Header */}
<div className="flex w-full justify-end text-scale-900 transition-colors hover:text-scale-1200">
<IconX
className="cursor-pointer"
size={14}
strokeWidth={2}
onClick={() => closeFilePreview()}
/>
</div>
</div>
<div className="w-full space-y-6">
{/* Preview Information */}
<div className="space-y-1">
<h5 className="break-words text-base text-scale-1200">{file.name}</h5>
{file.isCorrupted && (
<div className="flex items-center space-x-2">
<IconAlertCircle size={14} strokeWidth={2} className="text-scale-1100" />
{/* Preview Thumbnail*/}
<div className="my-4 border border-panel-border-light dark:border-panel-border-dark">
<div className="flex h-56 w-full items-center 2xl:h-72">
<PreviewFile mimeType={mimeType} previewUrl={file.previewUrl} />
</div>
</div>
<div className="w-full space-y-6">
{/* Preview Information */}
<div className="space-y-1">
<h5 className="break-words text-base text-scale-1200">{file.name}</h5>
{file.isCorrupted && (
<div className="flex items-center space-x-2">
<IconAlertCircle size={14} strokeWidth={2} className="text-scale-1100" />
<p className="text-sm text-scale-1100">
File is corrupted, please delete and reupload this file again
</p>
</div>
)}
{mimeType && (
<p className="text-sm text-scale-1100">
File is corrupted, please delete and reupload this file again
{mimeType}
{size && <span> - {size}</span>}
</p>
)}
</div>
{/* Preview Metadata */}
<div className="space-y-2">
<div>
<label className="mb-1 text-xs text-scale-900">Added on</label>
<p className="text-sm text-scale-1100">{createdAt}</p>
</div>
<div>
<label className="mb-1 text-xs text-scale-900">Last modified</label>
<p className="text-sm text-scale-1100">{updatedAt}</p>
</div>
)}
{mimeType && (
<p className="text-sm text-scale-1100">
{mimeType}
{size && <span> - {size}</span>}
</p>
)}
</div>
{/* Preview Metadata */}
<div className="space-y-2">
<div>
<label className="mb-1 text-xs text-scale-900">Added on</label>
<p className="text-sm text-scale-1100">{createdAt}</p>
</div>
<div>
<label className="mb-1 text-xs text-scale-900">Last modified</label>
<p className="text-sm text-scale-1100">{updatedAt}</p>
</div>
</div>
{/* Actions */}
<div className="flex space-x-2 border-b border-panel-border-light pb-4 dark:border-panel-border-dark">
<Link href={`${file.previewUrl}&download`}>
<a>
{/* Actions */}
<div className="flex space-x-2 border-b border-panel-border-light pb-4 dark:border-panel-border-dark">
<Link href={`${file.previewUrl}&download`}>
<a>
<Button
type="default"
icon={<IconDownload size={16} strokeWidth={2} />}
disabled={file.isCorrupted}
>
Download
</Button>
</a>
</Link>
{selectedBucket.public ? (
<Button
type="default"
icon={<IconDownload size={16} strokeWidth={2} />}
type="outline"
icon={<IconClipboard size={16} strokeWidth={2} />}
onClick={async () => await copyFileURLToClipboard(file)}
disabled={file.isCorrupted}
>
Download
Get URL
</Button>
</a>
</Link>
) : (
<Dropdown
side="bottom"
align="center"
overlay={[
<Dropdown.Item
onClick={async () =>
await copyFileURLToClipboard(file, URL_EXPIRY_DURATION.WEEK)
}
>
Expire in 1 week
</Dropdown.Item>,
<Dropdown.Item
onClick={async () =>
await copyFileURLToClipboard(file, URL_EXPIRY_DURATION.MONTH)
}
>
Expire in 1 month
</Dropdown.Item>,
<Dropdown.Item
onClick={async () =>
await copyFileURLToClipboard(file, URL_EXPIRY_DURATION.YEAR)
}
>
Expire in 1 year
</Dropdown.Item>,
<Dropdown.Item onClick={() => setSelectedFileCustomExpiry(file)}>
Custom expiry
</Dropdown.Item>,
]}
>
<Button
type="outline"
icon={<IconClipboard size={16} strokeWidth={2} />}
iconRight={<IconChevronDown />}
disabled={file.isCorrupted}
>
Get URL
</Button>
</Dropdown>
)}
</div>
<Button
type="outline"
icon={<IconClipboard size={16} strokeWidth={2} />}
onClick={async () => await copyFileURLToClipboard(file)}
disabled={file.isCorrupted}
shadow={false}
size="tiny"
icon={<IconTrash2 size={16} strokeWidth={2} />}
onClick={() => setSelectedItemsToDelete([file])}
>
Copy URL
Delete file
</Button>
</div>
<Button
type="outline"
shadow={false}
size="tiny"
icon={<IconTrash2 size={16} strokeWidth={2} />}
onClick={() => setSelectedItemsToDelete([file])}
>
Delete file
</Button>
</div>
</div>
</Transition>
</Transition>
</>
)
}
@@ -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}
/>
<CustomExpiryModal />
</div>
)
})
+1 -1
View File
@@ -2,7 +2,7 @@ import { Button, IconArrowLeft } from 'ui'
export function BackButton({ onClick }: { onClick: () => void }) {
return (
<div className="-ml-2 2xl:absolute 2xl:top-[2px] 2xl:-left-24">
<div className="-ml-2 2xl:absolute 2xl:top-[2px] 2xl:left-0">
<Button type="text" icon={<IconArrowLeft />} onClick={onClick}>
Back
</Button>
@@ -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
+96 -232
View File
@@ -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<string>('public')
const [searchString, setSearchString] = useState<string>('')
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<string | undefined>(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 (
<div className="flex flex-col h-full">
{(publicTables || []).length > 0 && (
<div className="mb-4">
<div className="flex items-center justify-between">
<div className="mb-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="w-[230px]">
<Listbox
size="small"
value={selectedSchema}
onChange={(schema: string) => {
setSelectedSchema(schema)
setSearchString('')
}}
icon={isLocked && <IconLock size={14} strokeWidth={2} />}
>
<Listbox.Option
disabled
key="normal-schemas"
value="normal-schemas"
label="Schemas"
>
<p className="text-sm">Schemas</p>
</Listbox.Option>
{/* @ts-ignore */}
{openSchemas.map((schema) => (
<Listbox.Option
key={schema.id}
value={schema.name}
label={schema.name}
addOnBefore={() => <span className="text-scale-900">schema</span>}
>
<span className="text-scale-1200 text-sm">{schema.name}</span>
</Listbox.Option>
))}
<Listbox.Option
disabled
key="protected-schemas"
value="protected-schemas"
label="Protected schemas"
>
<p className="text-sm">Protected schemas</p>
</Listbox.Option>
{protectedSchemas.map((schema) => (
<Listbox.Option
key={schema.id}
value={schema.name}
label={schema.name}
addOnBefore={() => <span className="text-scale-900">schema</span>}
>
<span className="text-scale-1200 text-sm">{schema.name}</span>
</Listbox.Option>
))}
</Listbox>
</div>
<Input
size="small"
placeholder="Filter tables and policies"
className="block w-64 text-sm placeholder-gray-400"
value={policiesFilter}
onChange={(e) => setPoliciesFilter(e.target.value)}
value={searchString}
onChange={(e) => setSearchString(e.target.value)}
icon={<IconSearch size="tiny" />}
/>
<Button type="link" icon={<IconExternalLink size={14} strokeWidth={1.5} />}>
<a
target="_blank"
href="https://supabase.com/docs/learn/auth-deep-dive/auth-row-level-security"
>
What is RLS?
</a>
</Button>
</div>
<Button type="link" icon={<IconExternalLink size={14} strokeWidth={1.5} />}>
<a
target="_blank"
href="https://supabase.com/docs/learn/auth-deep-dive/auth-row-level-security"
>
What is RLS?
</a>
</Button>
</div>
)}
<AuthPoliciesTables hasPublicTables={publicTables.length > 0} tables={filteredTables} />
</div>
<Policies tables={filteredTables} hasTables={tables.length > 0} isLocked={isLocked} />
</div>
)
}
@@ -101,198 +160,3 @@ AuthPoliciesPage.getLayout = (page) => (
)
export default observer(AuthPoliciesPage)
interface AuthPoliciesTablesProps {
hasPublicTables: boolean
tables: any[]
}
const AuthPoliciesTables: FC<AuthPoliciesTablesProps> = 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<any>({})
const [selectedTableToToggleRLS, setSelectedTableToToggleRLS] = useState<any>({})
const [selectedPolicyToEdit, setSelectedPolicyToEdit] = useState<any>({})
const [selectedPolicyToDelete, setSelectedPolicyToDelete] = useState<any>({})
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) => (
<section key={table.id}>
<PolicyTableRow
table={table}
onSelectToggleRLS={onSelectToggleRLS}
onSelectCreatePolicy={onSelectCreatePolicy}
onSelectEditPolicy={onSelectEditPolicy}
onSelectDeletePolicy={onSelectDeletePolicy}
/>
</section>
))
) : hasPublicTables ? (
<NoSearchResults />
) : (
<div className="flex-grow">
<ProductEmptyState
size="large"
title="Postgres Policies"
ctaButtonLabel="Create a table"
infoButtonLabel="What is RLS?"
infoButtonUrl="https://supabase.com/docs/guides/auth/row-level-security"
onClickCta={() => router.push(`/project/${ref}/editor`)}
>
<div className="space-y-4">
<InformationBox
title="What are policies?"
icon={<IconHelpCircle strokeWidth={2} />}
description={
<div className="space-y-2">
<p className="text-sm">
Policies restrict, on a per-user basis, which rows can be returned by normal
queries, or inserted, updated, or deleted by data modification commands.
</p>
<p className="text-sm">
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.
</p>
</div>
}
/>
<p className="text-sm text-scale-1100">
A table within the public schema is required before you can create a Row-Level
Security policy.
</p>
</div>
</ProductEmptyState>
</div>
)}
<PolicyEditorModal
visible={!isEmpty(selectedSchemaAndTable)}
roles={roles}
schema={selectedSchemaAndTable.schema}
table={selectedSchemaAndTable.table}
selectedPolicyToEdit={selectedPolicyToEdit}
onSelectCancel={closePolicyEditorModal}
// @ts-ignore
onCreatePolicy={onCreatePolicy}
// @ts-ignore
onUpdatePolicy={onUpdatePolicy}
onSaveSuccess={onSavePolicySuccess}
/>
<ConfirmModal
danger
visible={!isEmpty(selectedPolicyToDelete)}
title="Confirm to delete policy"
description={`This is permanent! Are you sure you want to delete the policy "${selectedPolicyToDelete.name}"`}
buttonLabel="Delete"
buttonLoadingLabel="Deleting"
onSelectCancel={closeConfirmModal}
onSelectConfirm={onDeletePolicy}
/>
<ConfirmModal
danger={selectedTableToToggleRLS.rls_enabled}
visible={!isEmpty(selectedTableToToggleRLS)}
title={`Confirm to ${selectedTableToToggleRLS.rls_enabled ? 'disable' : 'enable'} RLS`}
description={`Are you sure you want to ${
selectedTableToToggleRLS.rls_enabled ? 'disable' : 'enable'
} row level security for the table "${selectedTableToToggleRLS.name}"?`}
buttonLabel="Confirm"
buttonLoadingLabel="Saving"
onSelectCancel={closeConfirmModal}
onSelectConfirm={onToggleRLS}
/>
</>
)
})
+12 -9
View File
@@ -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<PostgresTable>[] = 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,
+1 -1
View File
@@ -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',
@@ -15,6 +15,7 @@ export interface IPostgresMetaInterface<T> {
isInitialized: boolean
load: () => void
loadBySchema: (schema: string) => Promise<T[] | { error: ResponseError }>
create: (payload: any) => Promise<T | { error: ResponseError }>
update: (id: number | string, updates: any) => Promise<T | { error: ResponseError }>
del: (id: number | string, cascade?: boolean) => Promise<boolean | { error: ResponseError }>
@@ -116,6 +117,33 @@ export default class PostgresMetaInterface<T> 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)
+5
View File
@@ -13,4 +13,9 @@ export default class ExtensionsStore extends PostgresMetaInterface<PostgresColum
) {
super(rootStore, dataUrl, headers, options)
}
// loadBySchema is not supported in this store
async loadBySchema(schema: string) {
return []
}
}
@@ -13,4 +13,9 @@ export default class ForeignTableStore extends PostgresMetaInterface<Partial<Pos
) {
super(rootStore, dataUrl, headers, options)
}
// loadBySchema is not supported in this store
async loadBySchema(schema: string) {
return []
}
}
+12 -4
View File
@@ -1,6 +1,6 @@
import Papa from 'papaparse'
import { makeObservable, observable } from 'mobx'
import { find, isUndefined, isEqual, isEmpty, chunk, values } from 'lodash'
import { find, isUndefined, isEqual, isEmpty, chunk } from 'lodash'
import { Query } from 'components/grid/query/Query'
import {
@@ -8,16 +8,17 @@ import {
PostgresTable,
PostgresRelationship,
PostgresPrimaryKey,
PostgresSchema,
} from '@supabase/postgres-meta'
import { IS_PLATFORM, API_URL } from 'lib/constants'
import { post } from 'lib/common/fetch'
import { timeout } from 'lib/helpers'
import { ResponseError } from 'types'
import { ResponseError, SchemaView } from 'types'
import { IRootStore } from '../RootStore'
import ColumnStore from './ColumnStore'
import SchemaStore, { ISchemaStore } from './SchemaStore'
import SchemaStore from './SchemaStore'
import TableStore, { ITableStore } from './TableStore'
import OpenApiStore, { IOpenApiStore } from './OpenApiStore'
import { IPostgresMetaInterface } from '../common/PostgresMetaInterface'
@@ -41,6 +42,7 @@ import HooksStore from './HooksStore'
import ExtensionsStore from './ExtensionsStore'
import TypesStore from './TypesStore'
import ForeignTableStore from './ForeignTableStore'
import ViewStore from './ViewStore'
const BATCH_SIZE = 1000
const CHUNK_SIZE = 1024 * 1024 * 0.1 // 0.1MB
@@ -51,7 +53,8 @@ export interface IMetaStore {
openApi: IOpenApiStore
tables: ITableStore
columns: IPostgresMetaInterface<PostgresColumn>
schemas: ISchemaStore
schemas: IPostgresMetaInterface<PostgresSchema>
views: IPostgresMetaInterface<SchemaView>
foreignTables: IPostgresMetaInterface<Partial<PostgresTable>>
hooks: IPostgresMetaInterface<any>
@@ -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)
+5
View File
@@ -12,4 +12,9 @@ export default class PublicationStore extends PostgresMetaInterface<any> {
) {
super(rootStore, dataUrl, headers, options)
}
// loadBySchema is not supported in this store
async loadBySchema(schema: string) {
return []
}
}
+5
View File
@@ -28,4 +28,9 @@ export default class RolesStore extends PostgresMetaInterface<any> {
'pg_read_all_data',
'pg_write_all_data',
]
// loadBySchema is not supported in this store
async loadBySchema(schema: string) {
return []
}
}
+1 -25
View File
@@ -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<PostgresSchema> {
views: SchemaView[]
loadViews: (schema: string) => Promise<any | { error: ResponseError }>
}
export default class SchemaStore extends PostgresMetaInterface<PostgresSchema> {
views: SchemaView[] = []
constructor(
rootStore: IRootStore,
dataUrl: string,
@@ -21,10 +12,6 @@ export default class SchemaStore extends PostgresMetaInterface<PostgresSchema> {
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<PostgresSchema> {
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
}
}
+16
View File
@@ -0,0 +1,16 @@
import { SchemaView } from 'types'
import PostgresMetaInterface from '../common/PostgresMetaInterface'
import { IRootStore } from '../RootStore'
export default class ViewStore extends PostgresMetaInterface<SchemaView> {
constructor(
rootStore: IRootStore,
dataUrl: string,
headers?: {
[prop: string]: any
},
options?: { identifier: string }
) {
super(rootStore, dataUrl, headers, options)
}
}
+4 -4
View File
@@ -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 []
+3
View File
@@ -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,
+4
View File
@@ -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 },
+6
View File
@@ -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[]
}