Merge pull request #10903 from supabase/master

Prod deploy dashboard
This commit is contained in:
Joshen Lim authored and GitHub committed 2022-12-13 17:21:32 +07:00
commit 3505e9e915
42 files changed
+10347 -6923

No files matched your search

+4 -1
View File
@@ -9,4 +9,7 @@ NEXT_PUBLIC_DOCS_URL="http://localhost:3005"
NEXT_PUBLIC_STUDIO_URL="https://localhost:8082"
NEXT_PUBLIC_LAUNCHWEEKSITE_URL="https://localhost:3008"
NEXT_PUBLIC_REFERENCE_DOCS_URL="https://localhost:3010"
NEXT_PUBLIC_LW_STARTED=true
NEXT_PUBLIC_SITE_ORIGIN=http://localhost:3000
NEXT_PUBLIC_SUPABASE_URL=https://obuldanrptloktxcffvn.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9idWxkYW5ycHRsb2t0eGNmZnZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE2Njk3MjcwMTIsImV4cCI6MTk4NTMwMzAxMn0.SZLqryz_-stF8dgzeVXmzZWPOqdOrBwqJROlFES8v3I
+1 -1
View File
@@ -264,7 +264,7 @@ export default function launchweek() {
justified={false}
bordered={false}
chevronAlign="right"
defaultValue={day1.d.toString()}
defaultValue={[day1.d.toString()]}
>
<div className="border-b pb-3">
<Accordion.Item
@@ -18,8 +18,9 @@ type Props = {
}
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_SECRET!
process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'http://localhost:54321',
process.env.SUPABASE_SERVICE_ROLE_SECRET ??
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9idWxkYW5ycHRsb2t0eGNmZnZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE2Njk3MjcwMTIsImV4cCI6MTk4NTMwMzAxMn0.SZLqryz_-stF8dgzeVXmzZWPOqdOrBwqJROlFES8v3I'
)
export default function TicketShare({ username, ticketNumber, name, golden }: Props) {
+26
View File
@@ -10268,6 +10268,12 @@
"@babel/types": "^7.3.0"
}
},
"node_modules/@types/blueimp-md5": {
"version": "2.18.0",
"resolved": "https://registry.npmjs.org/@types/blueimp-md5/-/blueimp-md5-2.18.0.tgz",
"integrity": "sha512-f4A+++lGZGJvVSgeyMkqA7BEf2BVQli6F+qEykKb49c5ieWQBkfpn6CP5c1IZr2Yi2Ofl6Fj+v0e1fN18Z8Cnw==",
"dev": true
},
"node_modules/@types/btoa-lite": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/btoa-lite/-/btoa-lite-1.0.0.tgz",
@@ -12819,6 +12825,11 @@
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
},
"node_modules/blueimp-md5": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz",
"integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w=="
},
"node_modules/bn.js": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
@@ -37844,6 +37855,7 @@
"ajv": "^8.6.3",
"analytics-node": "^3.5.0",
"awesome-debounce-promise": "^2.1.0",
"blueimp-md5": "^2.19.0",
"clipboard": "^2.0.8",
"common": "*",
"config": "*",
@@ -37913,6 +37925,7 @@
"@testing-library/dom": "^8.11.1",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
"@types/blueimp-md5": "^2.18.0",
"@types/crypto-js": "^4.0.2",
"@types/file-saver": "^2.0.2",
"@types/json-logic-js": "^1.2.1",
@@ -45119,6 +45132,12 @@
"@babel/types": "^7.3.0"
}
},
"@types/blueimp-md5": {
"version": "2.18.0",
"resolved": "https://registry.npmjs.org/@types/blueimp-md5/-/blueimp-md5-2.18.0.tgz",
"integrity": "sha512-f4A+++lGZGJvVSgeyMkqA7BEf2BVQli6F+qEykKb49c5ieWQBkfpn6CP5c1IZr2Yi2Ofl6Fj+v0e1fN18Z8Cnw==",
"dev": true
},
"@types/btoa-lite": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/btoa-lite/-/btoa-lite-1.0.0.tgz",
@@ -47178,6 +47197,11 @@
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
},
"blueimp-md5": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz",
"integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w=="
},
"bn.js": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
@@ -63844,6 +63868,7 @@
"@testing-library/dom": "^8.11.1",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
"@types/blueimp-md5": "^2.18.0",
"@types/crypto-js": "^4.0.2",
"@types/file-saver": "^2.0.2",
"@types/json-logic-js": "^1.2.1",
@@ -63869,6 +63894,7 @@
"autoprefixer": "10.4.2",
"awesome-debounce-promise": "^2.1.0",
"babel-loader": "^8.2.3",
"blueimp-md5": "^2.19.0",
"clipboard": "^2.0.8",
"common": "*",
"config": "*",
+4 -4
View File
@@ -1116,18 +1116,18 @@
"PostgrestConfigResponse": {
"type": "object",
"properties": {
"max_rows": { "type": "integer" },
"db_schema": { "type": "string" },
"max_rows": { "type": "number" },
"db_extra_search_path": { "type": "string" }
},
"required": ["db_schema", "max_rows", "db_extra_search_path"]
"required": ["max_rows", "db_schema", "db_extra_search_path"]
},
"UpdatePostgrestConfigBody": {
"type": "object",
"properties": {
"max_rows": { "type": "integer", "minimum": 0, "maximum": 1000000 },
"db_extra_search_path": { "type": "string" },
"db_schema": { "type": "string" },
"max_rows": { "type": "number", "maximum": 1000000 }
"db_schema": { "type": "string" }
}
},
"UpdateCustomHostnameResponse": {
+150 -76
View File
@@ -387,6 +387,13 @@
"title": "Auth",
"libs": ["js", "js_v1", "dart", "dart_v0"],
"items": [
{
"id": "auth-api",
"title": "Overview",
"slug": "auth-api",
"libs": ["js"],
"type": "function"
},
{
"id": "sign-up",
"title": "Create a new user",
@@ -547,7 +554,6 @@
"type": "function",
"libs": ["js"]
},
{
"id": "on-auth-state-change",
"title": "Listen to auth events",
@@ -564,86 +570,154 @@
"type": "function",
"libs": ["js", "js_v1", "dart", "dart_v0"]
},
{
"id": "mfa-enroll",
"title": "Enroll a factor",
"slug": "auth-mfa-enroll",
"product": "auth",
"type": "function",
"libs": ["js"]
},
{
"id": "mfa-challenge",
"title": "Create a challenge",
"slug": "auth-mfa-challenge",
"product": "auth",
"type": "function",
"libs": ["js"]
},
{
"id": "mfa-verify",
"title": "Verify a challenge",
"slug": "auth-mfa-verify",
"product": "auth",
"type": "function",
"libs": ["js"]
},
{
"id": "mfa-challenge-and-verify",
"title": "Create and verify a challenge",
"slug": "auth-mfa-challengeandverify",
"product": "auth",
"type": "function",
"libs": ["js"]
},
{
"id": "mfa-unenroll",
"title": "Unenroll a factor",
"slug": "auth-mfa-unenroll",
"product": "auth",
"type": "function",
"libs": ["js"]
},
{
"id": "mfa-get-authenticator-assurance-level",
"title": "Get Authenticator Assurance Level",
"slug": "auth-mfa-getauthenticatorassurancelevel",
"product": "auth",
"type": "function",
"libs": ["js"]
}
]
},
{
"title": "Auth Admin",
"libs": ["js", "js_v1"],
"items": [
{
"id": "admin-api",
"title": "Auth (server-only)",
"title": "Overview",
"slug": "admin-api",
"libs": ["js", "js_v1"],
"type": "function"
},
{
"id": "get-user-by-id",
"title": "Retrieve a user",
"slug": "auth-admin-getuserbyid",
"product": "auth-admin",
"type": "function",
"items": [
{
"id": "get-user-by-id",
"title": "Retrieve a user",
"slug": "auth-admin-getuserbyid",
"product": "auth-admin",
"type": "function",
"libs": ["js"]
},
{
"id": "list-users",
"title": "List all users",
"slug": "auth-admin-listusers",
"product": "auth-admin",
"type": "function",
"libs": ["js", "js_v1"]
},
{
"id": "create-user",
"title": "Create a user",
"slug": "auth-admin-createuser",
"product": "auth-admin",
"type": "function",
"libs": ["js", "js_v1"]
},
{
"id": "delete-user",
"title": "Delete a user",
"slug": "auth-admin-deleteuser",
"product": "auth-admin",
"type": "function",
"libs": ["js", "js_v1"]
},
{
"id": "invite-user-by-email",
"title": "Send an email invite link",
"slug": "auth-admin-inviteuserbyemail",
"product": "auth-admin",
"type": "function",
"libs": ["js", "js_v1"]
},
{
"id": "reset-password-for-email",
"title": "Send a password reset request",
"slug": "auth-admin-resetpasswordforemail",
"product": "auth-admin",
"type": "function",
"libs": ["js"]
},
{
"id": "generate-link",
"title": "Generate an email link",
"slug": "auth-admin-generatelink",
"product": "auth-admin",
"type": "function",
"libs": ["js"]
},
{
"id": "send-mobile-otp",
"title": "Send a one-time passcode",
"slug": "auth-api-sendmobileotp",
"product": "auth-server",
"type": "function",
"libs": ["js_v1"]
},
{
"id": "update-user-by-id",
"title": "Update a user",
"slug": "auth-admin-updateuserbyid",
"product": "auth-admin",
"type": "function",
"libs": ["js", "js_v1"]
}
]
"libs": ["js"]
},
{
"id": "list-users",
"title": "List all users",
"slug": "auth-admin-listusers",
"product": "auth-admin",
"type": "function",
"libs": ["js", "js_v1"]
},
{
"id": "create-user",
"title": "Create a user",
"slug": "auth-admin-createuser",
"product": "auth-admin",
"type": "function",
"libs": ["js", "js_v1"]
},
{
"id": "delete-user",
"title": "Delete a user",
"slug": "auth-admin-deleteuser",
"product": "auth-admin",
"type": "function",
"libs": ["js", "js_v1"]
},
{
"id": "invite-user-by-email",
"title": "Send an email invite link",
"slug": "auth-admin-inviteuserbyemail",
"product": "auth-admin",
"type": "function",
"libs": ["js", "js_v1"]
},
{
"id": "reset-password-for-email",
"title": "Send a password reset request",
"slug": "auth-admin-resetpasswordforemail",
"product": "auth-admin",
"type": "function",
"libs": ["js"]
},
{
"id": "generate-link",
"title": "Generate an email link",
"slug": "auth-admin-generatelink",
"product": "auth-admin",
"type": "function",
"libs": ["js"]
},
{
"id": "send-mobile-otp",
"title": "Send a one-time passcode",
"slug": "auth-api-sendmobileotp",
"product": "auth-server",
"type": "function",
"libs": ["js_v1"]
},
{
"id": "update-user-by-id",
"title": "Update a user",
"slug": "auth-admin-updateuserbyid",
"product": "auth-admin",
"type": "function",
"libs": ["js", "js_v1"]
},
{
"id": "mfa-list-factors",
"title": "List all factors for a user",
"slug": "auth-admin-mfa-listfactors",
"product": "auth-admin",
"type": "function",
"libs": ["js"]
},
{
"id": "mfa-delete-factor",
"title": "Delete a factor for a user",
"slug": "auth-admin-mfa-deletefactor",
"product": "auth-admin",
"type": "function",
"libs": ["js"]
}
]
},
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+237 -38
View File
@@ -103,6 +103,35 @@ functions:
```
For React Native we recommend using `AsyncStorage` as the storage implementation for Supabase Auth.
- id: auth-api
title: 'Overview'
notes: |
- The auth methods can be accessed via the `supabase.auth` namespace.
examples:
- id: create-auth-client
name: Create auth client
isSpotlight: true
code: |
```js
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(supabase_url, anon_key)
```
- id: create-auth-client-server-side
name: Create auth client (server-side)
isSpotlight: false
code: |
```js
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(supabase_url, anon_key, {
auth: {
autoRefreshToken: false,
persistSession: false,
detectSessionInUrl: false
}
})
```
- id: sign-up
title: 'signUp()'
$ref: '@supabase/gotrue-js.GoTrueClient.signUp'
@@ -118,7 +147,7 @@ functions:
- To fetch the currently logged-in user, refer to [`getUser()`](/docs/reference/javascript/auth-getuser).
examples:
- id: sign-up
name: Sign up.
name: Sign up
isSpotlight: true
code: |
```js
@@ -128,7 +157,7 @@ functions:
})
```
- id: sign-up-with-additional-user-metadata.
name: Sign up with additional user metadata.
name: Sign up with additional user metadata
isSpotlight: false
code: |
```js
@@ -188,7 +217,7 @@ functions:
- The magic link's destination URL is determined by the [`SITE_URL`](https://supabase.com/docs/reference/auth/config#site_url). You can modify the `SITE_URL` or add additional redirect urls in [your project](https://app.supabase.com/project/_/auth/settings).
examples:
- id: sign-in-with-email
name: Sign in with email.
name: Sign in with email
isSpotlight: true
description: The user will be sent an email which contains either a magiclink or a OTP or both. By default, a given user can only request a OTP once every 60 seconds.
code: |
@@ -198,7 +227,7 @@ functions:
})
```
- id: sign-in-with-sms-otp
name: Sign in with SMS OTP.
name: Sign in with SMS OTP
isSpotlight: false
description: The user will be sent a SMS which contains a OTP. By default, a given user can only request a OTP once every 60 seconds.
code: |
@@ -314,7 +343,7 @@ functions:
const { data: { user } } = await supabase.auth.getUser()
```
- id: get-the-logged-in-user-with-a-custom-access-token-jwt
name: Get the logged in user with a custom access token jwt.
name: Get the logged in user with a custom access token jwt
isSpotlight: false
code: |
```js
@@ -367,34 +396,41 @@ functions:
isSpotlight: true
code: |
```js
const { data, error } = supabase.auth.setSession(refresh_token)
const { data, error } = supabase.auth.setSession({
access_token,
refresh_token
)
```
- id: refresh-session
title: 'refreshSession()'
$ref: '@supabase/gotrue-js.GoTrueClient.refreshSession'
notes: |
- This method will refresh the session whether the current one is expired or not.
- Both examples destructure `user` and `session` from `data`. This is not required; so `const { data, error } =` is also valid.
examples:
- id: refresh-session-using-the-current-session
name: Refresh session using the current session
isSpotlight: true
code: |
```js
const { data: { user, session }, error } = await supabase.auth.refreshSession()
const { data, error } = await supabase.auth.refreshSession()
const { session, user } = data
```
- id: refresh-session-using-a-passed-in-session
name: Refresh session using a passed-in session
isSpotlight: true
name: Refresh session using a refresh token
isSpotlight: false
code: |
```js
const { data: { user, session }, error } = await supabase.auth.refreshSession({ refresh_token })
const { data, error } = await supabase.auth.refreshSession({ refresh_token })
const { session, user } = data
```
- id: on-auth-state-change
title: 'onAuthStateChange()'
$ref: '@supabase/gotrue-js.GoTrueClient.onAuthStateChange'
notes: |
- Types of auth events: `SIGNED_IN`, `SIGNED_OUT`, `TOKEN_REFRESHED`, `USER_UPDATED`, `USER_DELETED`, `PASSWORD_RECOVERY`
- Types of auth events: `SIGNED_IN`, `SIGNED_OUT`, `TOKEN_REFRESHED`, `USER_UPDATED`, `PASSWORD_RECOVERY`
- Currently, `onAuthStateChange()` does not work across tabs.
For instance, in the case of a password reset flow, the original tab which requested for the password reset link will not receive the `SIGNED_IN` and `PASSWORD_RECOVERY` event when the user clicks on the link.
examples:
- id: listen-to-auth-changes
name: Listen to auth changes
@@ -405,6 +441,19 @@ functions:
console.log(event, session)
})
```
- id: listen-to-password-recovery-events
name: Listen to password recovery events
code: |
```js
supabase.auth.onAuthStateChange((event, session) => {
if (event == 'PASSWORD_RECOVERY') {
console.log('PASSWORD_RECOVERY', session)
// show screen to update user's password
showPasswordResetScreen(true)
}
})
```
- id: listen-to-sign-in
name: Listen to sign in
code: |
@@ -437,27 +486,136 @@ functions:
if (event == 'USER_UPDATED') console.log('USER_UPDATED', session)
})
```
- id: listen-to-user-deleted
name: Listen to user deleted
- id: mfa-enroll
title: 'mfa.enroll()'
$ref: '@supabase/gotrue-js.GoTrueMFAApi.enroll'
notes: |
- Currently, `totp` is the only supported `factorType`. The returned `id` should be used to create a challenge.
- To create a challenge, see [`mfa.challenge()`](/docs/reference/javascript/auth-mfa-challenge).
- To verify a challenge, see [`mfa.verify()`](/docs/reference/javascript/auth-mfa-verify).
- To create and verify a challenge in a single step, see [`mfa.challengeAndVerify()`](/docs/reference/javascript/auth-mfa-challengeandverify).
- To generate a QR code for the `totp` secret in nextjs, you can do the following:
```html
<Image src={data.totp.qr_code} alt={data.totp.uri} layout="fill"></Image>
```
examples:
- id: enroll-totp-factor
name: Enroll a time-based, one-time password (TOTP) factor
isSpotlight: true
code: |
```js
supabase.auth.onAuthStateChange((event, session) => {
if (event == 'USER_DELETED') console.log('USER_DELETED', session)
const { data, error } = await supabase.auth.mfa.enroll({
factorType: 'totp'
})
// Use the id to create a challenge.
// The challenge can be verified by entering the code generated from the authenticator app.
// The code will be generated upon scanning the qr_code or entering the secret into the authenticator app.
const { id, type, totp: { qr_code, secret, uri } } = data
```
- id: mfa-challenge
title: 'mfa.challenge()'
$ref: '@supabase/gotrue-js.GoTrueMFAApi.challenge'
notes: |
- An [enrolled factor](/docs/reference/javascript/auth-mfa-enroll) is required before creating a challenge.
- To verify a challenge, see [`mfa.verify()`](/docs/reference/javascript/auth-mfa-verify).
examples:
- id: create-mfa-challenge
name: Create a challenge for a factor
isSpotlight: true
code: |
```js
const { data, error } = await supabase.auth.mfa.challenge({
factorId: '34e770dd-9ff9-416c-87fa-43b31d7ef225'
})
```
- id: listen-to-password-recovery-events
name: Listen to password recovery events
- id: mfa-verify
title: 'mfa.verify()'
$ref: '@supabase/gotrue-js.GoTrueMFAApi.verify'
notes: |
- To verify a challenge, please [create a challenge](/docs/reference/javascript/auth-mfa-challenge) first.
examples:
- id: verify-challenge
name: Verify a challenge for a factor
isSpotlight: true
code: |
```js
supabase.auth.onAuthStateChange((event, session) => {
if (event == 'PASSWORD_RECOVERY') console.log('PASSWORD_RECOVERY', session)
const { data, error } = await supabase.auth.mfa.verify({
factorId: '34e770dd-9ff9-416c-87fa-43b31d7ef225',
challengeId: '4034ae6f-a8ce-4fb5-8ee5-69a5863a7c15',
code: '123456'
})
```
- id: mfa-challenge-and-verify
title: 'mfa.challengeAndVerify()'
$ref: '@supabase/gotrue-js.GoTrueMFAApi.challengeAndVerify'
notes: |
- An [enrolled factor](/docs/reference/javascript/auth-mfa-enroll) is required before invoking `challengeAndVerify()`.
- Executes [`mfa.challenge()`](/docs/reference/javascript/auth-mfa-challenge) and [`mfa.verify()`](/docs/reference/javascript/auth-mfa-verify) in a single step.
examples:
- id: challenge-and-verify
name: Create and verify a challenge for a factor
isSpotlight: true
code: |
```js
const { data, error } = await supabase.auth.mfa.challengeAndVerify({
factorId: '34e770dd-9ff9-416c-87fa-43b31d7ef225',
challengeId: '4034ae6f-a8ce-4fb5-8ee5-69a5863a7c15',
code: '123456'
})
```
- id: mfa-unenroll
title: 'mfa.unenroll()'
$ref: '@supabase/gotrue-js.GoTrueMFAApi.unenroll'
examples:
- id: unenroll-a-factor
name: Unenroll a factor
isSpotlight: true
code: |
```js
const { data, error } = await supabase.auth.mfa.unenroll({
factorId: '34e770dd-9ff9-416c-87fa-43b31d7ef225',
})
```
- id: mfa-get-authenticator-assurance-level
title: 'mfa.getAuthenticatorAssuranceLevel()'
$ref: '@supabase/gotrue-js.GoTrueMFAApi.getAuthenticatorAssuranceLevel'
notes: |
- Authenticator Assurance Level (AAL) is the measure of the strength of an authentication mechanism.
- In Supabase, having an AAL of `aal1` refers to having the 1st factor of authentication such as an email and password or OAuth sign-in while `aal2` refers to the 2nd factor of authentication such as a time-based, one-time-password (TOTP).
- If the user has a verified factor, the `nextLevel` field will return `aal2`, else, it will return `aal1`.
examples:
- id: get-aal
name: Get the AAL details of a session
isSpotlight: true
code: |
```js
const { data, error } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
const { currentLevel, nextLevel, currentAuthenticationMethods } = data
```
- id: admin-api
title: 'Overview'
notes: |
- Any method under the `supabase.auth.admin` namespace requires a `service_role` key.
- These methods are considered admin methods and should be called on a trusted server. Never expose your `service_role` key in the browser.
examples:
- id: create-auth-admin-client
name: Create server-side auth client
isSpotlight: true
code: |
```js
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(supabase_url, service_role_key, {
auth: {
autoRefreshToken: false,
persistSession: false
}
})
// Access auth admin api
const adminAuthClient = supabase.auth.admin
```
- id: get-user-by-id
title: 'getUserById()'
@@ -467,7 +625,7 @@ functions:
- The `getUserById()` method requires the user's id which maps to the `auth.users.id` column.
examples:
- id: fetch-the-user-object-using-the-access-token-jwt
name: Fetch the user object using the access_token jwt.
name: Fetch the user object using the access_token jwt
isSpotlight: true
code: |
```js
@@ -477,14 +635,26 @@ functions:
- id: list-users
title: 'listUsers()'
$ref: '@supabase/gotrue-js.GoTrueAdminApi.listUsers'
notes: |
- Defaults to return 50 users per page.
examples:
- id: get-a-full-list-of-users
name: Get a full list of users.
name: Get a full list of users
isSpotlight: true
code: |
```js
const { data: { users }, error } = await supabase.auth.admin.listUsers()
```
- id: get-paginated-list-of-users
name: Paginated list of users
isSpotlight: false
code: |
```js
const { data: { users }, error } = await supabase.auth.admin.listUsers({
page: 1,
perPage: 1000
})
```
- id: create-user
title: 'createUser()'
$ref: '@supabase/gotrue-js.GoTrueAdminApi.createUser'
@@ -492,7 +662,7 @@ functions:
- To confirm the user's email address or phone number, set `email_confirm` or `phone_confirm` to true. Both arguments default to false.
examples:
- id: create-a-new-user-with-custom-user-metadata
name: Create a new user with custom user metadata
name: With custom user metadata
isSpotlight: true
code: |
```js
@@ -554,12 +724,17 @@ functions:
title: 'resetPasswordForEmail()'
$ref: '@supabase/gotrue-js.GoTrueClient.resetPasswordForEmail'
notes: |
Sends a password reset request to an email address.
When the user clicks the reset link in the email they are redirected back to your application.
Prompt the user for a new password and call `auth.updateUser()`:
- The password reset flow consist of 2 broad steps: (i) Allow the user to login via the password reset link; (ii) Update the user's password.
- The `resetPasswordForEmail()` only sends a password reset link to the user's email.
To update the user's password, see [`updateUser()`](/docs/reference/javascript/auth-updateuser).
- A `SIGNED_IN` and `PASSWORD_RECOVERY` event will be emitted when the password recovery link is clicked.
You can use [`onAuthStateChange()`](/docs/reference/javascript/auth-onauthstatechange) to listen and invoke a callback function on these events.
- When the user clicks the reset link in the email they are redirected back to your application.
Prompt the user for a new password and call `updateUser()`:
```js
const { data, error } = await supabase.auth
.updateUser({ password: new_password })
const { data, error } = await supabase.auth.updateUser({
password: new_password
})
```
examples:
- id: reset-password
@@ -673,7 +848,7 @@ functions:
$ref: '@supabase/gotrue-js.GoTrueAdminApi.updateUserById'
examples:
- id: updates-a-users-email
name: Updates a user's email.
name: Updates a user's email
isSpotlight: false
code: |
```js
@@ -683,7 +858,7 @@ functions:
)
```
- id: updates-a-users-password
name: Updates a user's password.
name: Updates a user's password
isSpotlight: false
code: |
```js
@@ -693,7 +868,7 @@ functions:
)
```
- id: updates-a-users-metadata
name: Updates a user's metadata.
name: Updates a user's metadata
isSpotlight: true
code: |
```js
@@ -703,7 +878,7 @@ functions:
)
```
- id: updates-a-users-app-metadata
name: Updates a user's app_metadata.
name: Updates a user's app_metadata
isSpotlight: false
code: |
```js
@@ -713,7 +888,7 @@ functions:
)
```
- id: confirms-a-users-email-address
name: Confirms a user's email address.
name: Confirms a user's email address
isSpotlight: false
code: |
```js
@@ -723,7 +898,7 @@ functions:
)
```
- id: confirms-a-users-phone-number
name: Confirms a user's phone number.
name: Confirms a user's phone number
isSpotlight: false
code: |
```js
@@ -732,7 +907,31 @@ functions:
{ phone_confirm: true }
)
```
- id: mfa-list-factors
title: 'mfa.listFactors()'
$ref: '@supabase/gotrue-js.GoTrueAdminMFAApi.listFactors'
examples:
- id: list-factors
name: List all factors for a user
isSpotlight: true
code: |
```js
const { data, error } = await supabase.auth.admin.mfa.listFactors()
```
- id: mfa-delete-factor
title: 'mfa.deleteFactor()'
$ref: '@supabase/gotrue-js.GoTrueAdminMFAApi.deleteFactor'
examples:
- id: delete-factor
name: Delete a factor for a user
isSpotlight: true
code: |
```js
const { data, error } = await supabase.auth.admin.mfa.deleteFactor({
id: '34e770dd-9ff9-416c-87fa-43b31d7ef225',
userId: 'a89baba7-b1b7-440f-b4bb-91026967f66b',
})
```
- id: select
title: 'Fetch data: select()'
$ref: '@supabase/postgrest-js.PostgrestQueryBuilder.select'
@@ -6765,7 +6964,7 @@ functions:
- Responses are automatically parsed as `json`, `blob` and `form-data` depending on the `Content-Type` header sent by your function. Responses are parsed as `text` by default.
examples:
- id: basic-invocation
name: Basic invocation.
name: Basic invocation
description:
isSpotlight: true
code: |
@@ -6775,7 +6974,7 @@ functions:
})
```
- id: error-handling
name: Error handling.
name: Error handling
description: |
A `FunctionsHttpError` error is returned if your function throws an error, `FunctionsRelayError` if the Supabase Relay has an error processing your function and `FunctionsFetchError` if there is a network error in calling your function.
isSpotlight: true
@@ -6799,7 +6998,7 @@ functions:
}
```
- id: passing-custom-headers
name: Passing custom headers.
name: Passing custom headers
description: |
You can pass custom headers to your function. Note: supabase-js automatically passes the `Authorization` header with the signed in user's JWT.
isSpotlight: true
+22 -20
View File
@@ -1078,17 +1078,17 @@
"schema": {
"type": "object",
"properties": {
"max_rows": {
"type": "integer"
},
"db_schema": {
"type": "string"
},
"max_rows": {
"type": "number"
},
"db_extra_search_path": {
"type": "string"
}
},
"required": ["db_schema", "max_rows", "db_extra_search_path"]
"required": ["max_rows", "db_schema", "db_extra_search_path"]
}
}
}
@@ -1130,15 +1130,16 @@
"schema": {
"type": "object",
"properties": {
"max_rows": {
"type": "integer",
"minimum": 0,
"maximum": 1000000
},
"db_extra_search_path": {
"type": "string"
},
"db_schema": {
"type": "string"
},
"max_rows": {
"type": "number",
"maximum": 1000000
}
}
}
@@ -1153,17 +1154,17 @@
"schema": {
"type": "object",
"properties": {
"max_rows": {
"type": "integer"
},
"db_schema": {
"type": "string"
},
"max_rows": {
"type": "number"
},
"db_extra_search_path": {
"type": "string"
}
},
"required": ["db_schema", "max_rows", "db_extra_search_path"]
"required": ["max_rows", "db_schema", "db_extra_search_path"]
}
}
}
@@ -2396,30 +2397,31 @@
"PostgrestConfigResponse": {
"type": "object",
"properties": {
"max_rows": {
"type": "integer"
},
"db_schema": {
"type": "string"
},
"max_rows": {
"type": "number"
},
"db_extra_search_path": {
"type": "string"
}
},
"required": ["db_schema", "max_rows", "db_extra_search_path"]
"required": ["max_rows", "db_schema", "db_extra_search_path"]
},
"UpdatePostgrestConfigBody": {
"type": "object",
"properties": {
"max_rows": {
"type": "integer",
"minimum": 0,
"maximum": 1000000
},
"db_extra_search_path": {
"type": "string"
},
"db_schema": {
"type": "string"
},
"max_rows": {
"type": "number",
"maximum": 1000000
}
}
},
@@ -185,7 +185,7 @@ const PITRSelection = ({}) => {
<Button
as="span"
type="warning"
disabled={isSelectedOutOfRange}
disabled={isSelectedOutOfRange || !selectedDate}
onClick={() => setShowConfirmation(true)}
>
Review restore details
@@ -0,0 +1,384 @@
import Link from 'next/link'
import Image from 'next/image'
import { isEmpty } from 'lodash'
import { FC, useState } from 'react'
import {
Collapsible,
Input,
IconCheck,
IconChevronUp,
Button,
IconEdit,
IconTrash,
IconEye,
IconEyeOff,
IconCheckCircle,
IconExternalLink,
IconLoader,
} from 'ui'
import { useStore } from 'hooks'
import { Wrapper } from './Wrappers.types'
import { makeValidateRequired } from './Wrappers.utils'
import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation'
import { useFDWDeleteMutation } from 'data/fdw/fdw-delete-mutation'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import WrapperTableEditor from './WrapperTableEditor'
import InformationBox from 'components/ui/InformationBox'
import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal'
interface Props {
wrapper: Wrapper
isLoading: boolean
isEnabled: boolean
isOpen: boolean
onOpen: (wrapper: string) => void
}
const InputField: FC<{ option: any; value: any; error: any; onChange: any }> = ({
option,
value,
error,
onChange,
}: any) => {
const [showHidden, setShowHidden] = useState(!option.hidden)
return (
<Input
key={option.name}
id={option.name}
name={option.name}
label={option.label}
defaultValue={option.defaultValue ?? ''}
required={option.required ?? false}
value={value}
onChange={onChange}
error={error}
className="input-mono"
type={!option.hidden ? 'text' : showHidden ? 'text' : 'password'}
actions={
option.hidden ? (
<div className="flex items-center justify-center mr-1">
<Button
type="default"
icon={showHidden ? <IconEye /> : <IconEyeOff />}
onClick={() => setShowHidden(!showHidden)}
/>
</div>
) : null
}
/>
)
}
const WrapperRow: FC<Props> = ({ wrapper, isLoading, isEnabled, isOpen, onOpen }) => {
const getInitialFormState = () =>
Object.fromEntries(
wrapper.server.options.map((option) => [option.name, option.defaultValue ?? ''])
)
const { ui } = useStore()
const { project } = useProjectContext()
const { mutateAsync: createFDW } = useFDWCreateMutation()
const { mutateAsync: deleteFDW } = useFDWDeleteMutation()
const [isSubmitting, setIsSubmitting] = useState(false)
const [isEditingTable, setIsEditingTable] = useState(false)
const [selectedTableToEdit, setSelectedTableToEdit] = useState()
const [newTables, setNewTables] = useState<any[]>([])
const [formState, setFormState] = useState(getInitialFormState)
const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({})
// [Joshen TODO] Fix this logic
const noChanges = false
// const noChanges = JSON.stringify(getInitialFormState()) === JSON.stringify(formState)
const onUpdateTable = (values: any) => {
setNewTables((prev) => {
// if the new values have tableIndex, we are editing an existing table
if (values.tableIndex !== undefined) {
const tableIndex = values.tableIndex
const newTables = [...prev]
delete values.tableIndex
newTables[tableIndex] = values
return newTables
}
return [...prev, values]
})
setIsEditingTable(false)
setSelectedTableToEdit(undefined)
}
const onSaveWrapper = async () => {
const validate = makeValidateRequired(wrapper.server.options)
const errors: any = validate(formState)
if (newTables.length === 0) errors.tables = 'Please add at least one table'
if (!isEmpty(errors)) {
setFormErrors(errors)
return
}
setIsSubmitting(true)
try {
await createFDW({
projectRef: project?.ref,
connectionString: project?.connectionString,
wrapper,
formState,
newTables,
})
ui.setNotification({
category: 'success',
message: `Successfully created ${wrapper.label} foreign data wrapper`,
})
setNewTables([])
setFormState(getInitialFormState)
} catch (error: any) {
ui.setNotification({
error,
category: 'error',
message: `Failed to create ${wrapper.label} foreign data wrapper: ${error.message}`,
})
} finally {
setIsSubmitting(false)
}
}
const onDeleteWrapper = () => {
confirmAlert({
title: `Confirm to disable ${wrapper.label} wrapper`,
message: `Are you sure you want to disable the ${wrapper.label} wrapper? This will also remove all tables created with this wrapper.`,
onAsyncConfirm: async () => {
try {
await deleteFDW({
projectRef: project?.ref,
connectionString: project?.connectionString,
wrapper,
})
} catch (error: any) {
ui.setNotification({
error,
category: 'error',
message: `Disabling ${wrapper.name} failed: ${error.message}`,
})
}
},
})
}
return (
<>
<Collapsible
open={isOpen}
onOpenChange={() => onOpen(wrapper.name)}
className={[
'bg-scale-100 dark:bg-scale-300 ',
'hover:bg-scale-200 dark:hover:bg-scale-500',
'data-open:bg-scale-200 dark:data-open:bg-scale-500',
'border-scale-300',
'dark:border-scale-500 hover:border-scale-500',
'dark:hover:border-scale-700 data-open:border-scale-700',
'data-open:pb-px col-span-12 mx-auto',
'-space-y-px overflow-hidden',
'transition border shadow hover:z-50',
'first:rounded-tl first:rounded-tr',
'last:rounded-bl last:rounded-br',
].join(' ')}
>
<Collapsible.Trigger asChild>
<button
type="button"
className="flex items-center justify-between w-full px-6 py-3 rounded group text-scale-1200"
>
<div className="flex items-center gap-3">
<IconChevronUp
className="transition text-scale-800 data-open-parent:rotate-0 data-closed-parent:rotate-180"
strokeWidth={2}
width={14}
/>
<Image
src={wrapper.icon}
width={20}
height={20}
alt={`${wrapper.name} wrapper icon`}
/>
<span className="text-sm capitalize">{wrapper.label}</span>
</div>
<div className="flex items-center gap-3">
{isLoading ? (
<div className="flex items-center gap-1 px-1 py-1 text-xs rounded-full">
<IconLoader className="animate-spin" size={18} />
</div>
) : isEnabled ? (
<div className="flex items-center gap-1 px-1 py-1 text-xs border rounded-full border-brand-700 bg-brand-200 text-brand-900">
<span className="rounded-full bg-brand-900 p-0.5 text-xs text-brand-200">
<IconCheck strokeWidth={2} size={12} />
</span>
<span className="px-1">Enabled</span>
</div>
) : (
<div className="px-3 py-1 text-xs border rounded-md border-scale-500 bg-scale-100 text-scale-900 dark:border-scale-700 dark:bg-scale-300">
Disabled
</div>
)}
</div>
</button>
</Collapsible.Trigger>
{!isLoading && (
<>
{isEnabled ? (
<Collapsible.Content>
<div className="px-6 py-6 border-t group border-scale-500 bg-scale-100 text-scale-1200 dark:bg-scale-300">
<div className="max-w-lg mx-auto my-6 space-y-6">
<InformationBox
hideCollapse
defaultVisibility
icon={
<IconCheckCircle strokeWidth={1.5} size={18} className="text-brand-900" />
}
title={`${wrapper.label} foreign data wrapper is currently enabled`}
description="If you'd like to edit this wrapper, you'll need to disable the wrapper first and create it again with any updated configuration."
/>
<div className="flex items-center justify-end !mt-8">
<div className="flex items-center space-x-3">
<Link href={wrapper.docsUrl}>
<a target="_blank">
<Button type="default" icon={<IconExternalLink />}>
Documentation
</Button>
</a>
</Link>
<Button
type="default"
loading={isSubmitting}
disabled={noChanges}
onClick={() => onDeleteWrapper()}
>
Disable wrapper
</Button>
</div>
</div>
</div>
</div>
</Collapsible.Content>
) : (
<Collapsible.Content>
<div className="px-6 py-6 border-t group border-scale-500 bg-scale-100 text-scale-1200 dark:bg-scale-300">
<div className="max-w-lg mx-auto my-6 space-y-6">
{wrapper.server.options.map((option) => (
<InputField
option={option}
value={formState[option.name]}
error={formErrors[option.name]}
onChange={(e: any) => {
setFormState((prev) => ({ ...prev, [option.name]: e.target.value }))
setFormErrors((prev) => ({ ...prev, [option.name]: '' }))
}}
/>
))}
<div className="space-y-2">
<div className="space-y-1">
<div className="flex items-center justify-between">
<p className="text-sm text-scale-1100">Foreign Tables</p>
<Button type="default" onClick={() => setIsEditingTable(true)}>
Add table
</Button>
</div>
</div>
<div className="space-y-2">
{newTables.length === 0 && (
<div className="flex items-center justify-center px-4 py-4 border rounded-md border-scale-600">
<p className="text-sm text-scale-1000">
Add foreign tables to query from after the wrapper is enabled
</p>
</div>
)}
{newTables.map((table, i) => (
<div className="flex items-center justify-between px-4 py-2 space-y-1 border rounded-md border-scale-600">
<div>
<p className="text-sm">
{table.schema_name}.{table.table_name}
</p>
<p className="text-sm text-scale-1000">
{wrapper.tables[table.index].label}: {table.columns.join(', ')}
</p>
</div>
<div className="flex items-center space-x-2">
<Button
type="default"
className="px-1"
icon={<IconEdit />}
onClick={() => {
setIsEditingTable(true)
setSelectedTableToEdit({ ...table, tableIndex: i })
}}
/>
<Button
type="default"
className="px-1"
icon={<IconTrash />}
onClick={() => {
setNewTables((prev) => prev.filter((_, j) => j !== i))
}}
/>
</div>
</div>
))}
{newTables.length === 0 && formErrors.tables && (
<p className="text-sm text-red-900">{formErrors.tables}</p>
)}
</div>
<div className="flex items-center justify-between !mt-8">
<Link href={wrapper.docsUrl}>
<a target="_blank">
<Button type="default" icon={<IconExternalLink />}>
Documentation
</Button>
</a>
</Link>
<div className="flex items-center space-x-3">
<Button
type="default"
htmlType="reset"
onClick={() => {
onOpen('')
setNewTables([])
setFormState(getInitialFormState)
}}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
loading={isSubmitting}
disabled={noChanges}
onClick={() => onSaveWrapper()}
>
Save
</Button>
</div>
</div>
</div>
</div>
</div>
</Collapsible.Content>
)}
</>
)}
</Collapsible>
<WrapperTableEditor
visible={isEditingTable}
tables={wrapper.tables}
onCancel={() => {
setSelectedTableToEdit(undefined)
setIsEditingTable(false)
}}
onSave={onUpdateTable}
initialData={selectedTableToEdit}
/>
</>
)
}
export default WrapperRow
@@ -0,0 +1,231 @@
import { useEffect, useState } from 'react'
import { Form, IconDatabase, Input, Listbox, SidePanel, Modal, IconPlus } from 'ui'
import { useStore } from 'hooks'
import { Table, TableOption } from './Wrappers.types'
import { makeValidateRequired } from './Wrappers.utils'
import MultiSelect from 'components/ui/MultiSelect'
import ActionBar from 'components/interfaces/TableGridEditor/SidePanelEditor/ActionBar'
export type WrapperTableEditorProps = {
visible: boolean
onCancel: () => void
onSave: (values: any) => void
tables: Table[]
initialData: any
}
type OnSubmitFn = (values: any, { resetForm }: { resetForm: () => void }) => void
const WrapperTableEditor = ({
visible,
onCancel,
onSave,
tables,
initialData,
}: WrapperTableEditorProps) => {
const [selectedTableIndex, setSelectedTableIndex] = useState<string>('')
useEffect(() => {
if (initialData) {
setSelectedTableIndex(String(initialData.index))
}
}, [initialData])
const selectedTable = selectedTableIndex === '' ? undefined : tables[parseInt(selectedTableIndex)]
const handleCancel = () => {
setSelectedTableIndex('')
onCancel()
}
const onSubmit: OnSubmitFn = (values, { resetForm }) => {
onSave({
...values,
index: parseInt(selectedTableIndex),
schema_name: values.schema === 'custom' ? values.schema_name : values.schema,
is_new_schema: values.schema === 'custom',
})
resetForm()
setSelectedTableIndex('')
}
return (
<SidePanel
key="WrapperTableEditor"
size="medium"
visible={visible}
onCancel={handleCancel}
header={<span>Edit foreign table</span>}
customFooter={
<ActionBar
backButtonLabel="Cancel"
applyButtonLabel="Save"
formId="wrapper-table-editor-form"
closePanel={handleCancel}
/>
}
>
<SidePanel.Content>
<div className="mt-4 space-y-6">
<Listbox
label="Select a target the table will point to"
value={selectedTableIndex}
onChange={(value) => setSelectedTableIndex(value)}
>
<Listbox.Option key="empty" value="" label="---">
---
</Listbox.Option>
{tables.map((table, i) => {
return (
<Listbox.Option
className="group"
key={String(i)}
value={String(i)}
label={table.label}
>
<div className="space-y-1">
<p>{table.label}</p>
<p className="opacity-50">{table.description}</p>
</div>
</Listbox.Option>
)
})}
</Listbox>
{selectedTable && (
<TableForm table={selectedTable} onSubmit={onSubmit} initialData={initialData} />
)}
</div>
</SidePanel.Content>
</SidePanel>
)
}
export default WrapperTableEditor
const Option = ({ option }: { option: TableOption }) => {
return (
<Input
key={option.name}
id={option.name}
name={option.name}
label={option.label}
placeholder={option.placeholder ?? ''}
defaultValue={option.defaultValue ?? ''}
/>
)
}
const TableForm = ({
table,
onSubmit,
initialData,
}: {
table: Table
onSubmit: OnSubmitFn
initialData: any
}) => {
const { meta } = useStore()
const schemas = meta.schemas.list()
const requiredOptions =
table.options.filter((option) => option.editable && option.required && !option.defaultValue) ??
[]
const optionalOptions =
table.options.filter(
(option) => option.editable && (!option.required || option.defaultValue)
) ?? []
const initialValues = initialData ?? {
table_name: '',
columns: table.availableColumns.map((column) => column.name),
...Object.fromEntries(table.options.map((option) => [option.name, option.defaultValue ?? ''])),
schema: 'public',
schema_name: '',
}
const validate = makeValidateRequired([
...table.options,
{ name: 'table_name', required: true },
{ name: 'columns', required: true },
])
return (
<Form
id="wrapper-table-editor-form"
initialValues={initialValues}
validate={validate}
onSubmit={onSubmit}
enableReinitialize={true}
>
{({ errors, values, resetForm }: any) => {
return (
<div className="space-y-4">
<Listbox size="small" name="schema" label="Select a schema for the foreign table">
<Listbox.Option
key="custom"
id="custom"
label={`Create a new schema`}
value="custom"
addOnBefore={() => <IconPlus size={16} strokeWidth={1.5} />}
>
Create a new schema
</Listbox.Option>
<Modal.Separator />
{/* @ts-ignore */}
{schemas.map((schema: PostgresSchema) => {
return (
<Listbox.Option
key={schema.id}
id={schema.name}
label={schema.name}
value={schema.name}
addOnBefore={() => <IconDatabase size={16} strokeWidth={1.5} />}
>
{schema.name}
</Listbox.Option>
)
})}
</Listbox>
{values.schema === 'custom' && (
<Input id="schema_name" name="schema_name" label="Schema name" />
)}
<Input
id="table_name"
name="table_name"
label="Table name"
descriptionText="You can query from this table after the wrapper is enabled."
/>
{requiredOptions.map((option) => (
<Option key={option.name} option={option} />
))}
<MultiSelect
label="Columns"
options={table.availableColumns.map((column) => {
return {
id: column.name,
value: column.name,
name: column.name,
description: column.type,
disabled: false,
}
})}
error={errors.columns}
value={values.columns}
placeholder="Select at least one column"
searchPlaceholder="Search for a column"
onChange={(columns) => {
resetForm({ values: { ...values, columns } })
}}
/>
{optionalOptions.map((option) => (
<Option key={option.name} option={option} />
))}
</div>
)
}}
</Form>
)
}
@@ -0,0 +1,536 @@
import { Wrapper } from './Wrappers.types'
export const WRAPPERS: Wrapper[] = [
{
name: 'stripe_wrapper',
handlerName: 'stripe_fdw_handler',
validatorName: 'stripe_fdw_validator',
icon: '/img/icons/stripe-icon.svg',
extensionName: 'StripeFdw',
label: 'Stripe',
docsUrl: 'https://supabase.github.io/wrappers/stripe/',
server: {
name: 'stripe_server',
options: [
{
name: 'api_key_id',
label: 'Stripe Secret Key',
required: true,
encrypted: true,
hidden: true,
},
{
name: 'api_url',
label: 'Stripe API URL',
defaultValue: 'https://api.stripe.com/v1',
required: false,
encrypted: false,
hidden: false,
},
],
},
tables: [
{
label: 'Balance',
description: 'The balance currently on your Stripe account',
availableColumns: [
{
name: 'balance_type',
type: 'text',
},
{
name: 'amount',
type: 'bigint',
},
{
name: 'currency',
type: 'text',
},
{
name: 'attrs',
type: 'jsonb',
},
],
options: [
{
name: 'object',
defaultValue: 'balance',
editable: false,
required: true,
},
],
},
{
label: 'Balance Transactions',
description: 'Transactions that have contributed to the balance on your Stripe account',
availableColumns: [
{
name: 'id',
type: 'text',
},
{
name: 'amount',
type: 'bigint',
},
{
name: 'currency',
type: 'text',
},
{
name: 'description',
type: 'text',
},
{
name: 'fee',
type: 'bigint',
},
{
name: 'net',
type: 'bigint',
},
{
name: 'status',
type: 'text',
},
{
name: 'type',
type: 'text',
},
{
name: 'created',
type: 'timestamp',
},
{
name: 'attrs',
type: 'jsonb',
},
],
options: [
{
name: 'object',
defaultValue: 'balance_transactions',
editable: false,
required: true,
},
],
},
{
label: 'Charges',
description: 'Charges made on your Stripe account',
availableColumns: [
{
name: 'id',
type: 'text',
},
{
name: 'amount',
type: 'bigint',
},
{
name: 'currency',
type: 'text',
},
{
name: 'customer',
type: 'text',
},
{
name: 'description',
type: 'text',
},
{
name: 'invoice',
type: 'text',
},
{
name: 'payment_intent',
type: 'text',
},
{
name: 'status',
type: 'text',
},
{
name: 'created',
type: 'timestamp',
},
{
name: 'attrs',
type: 'jsonb',
},
],
options: [
{
name: 'object',
defaultValue: 'charges',
editable: false,
required: true,
},
],
},
{
label: 'Customers',
description: 'Customers on your Stripe account',
availableColumns: [
{
name: 'id',
type: 'text',
},
{
name: 'email',
type: 'text',
},
{
name: 'name',
type: 'text',
},
{
name: 'description',
type: 'text',
},
{
name: 'created',
type: 'timestamp',
},
{
name: 'attrs',
type: 'jsonb',
},
],
options: [
{
name: 'object',
defaultValue: 'customers',
editable: false,
required: true,
},
{
name: 'rowid_column',
label: 'Row ID Column',
defaultValue: 'id',
editable: true,
required: true,
},
],
},
{
label: 'Invoices',
description: 'Invoices on your Stripe account',
availableColumns: [
{
name: 'id',
type: 'text',
},
{
name: 'customer',
type: 'text',
},
{
name: 'subscription',
type: 'text',
},
{
name: 'status',
type: 'text',
},
{
name: 'total',
type: 'bigint',
},
{
name: 'currency',
type: 'text',
},
{
name: 'period_start',
type: 'timestamp',
},
{
name: 'period_end',
type: 'timestamp',
},
{
name: 'attrs',
type: 'jsonb',
},
],
options: [
{
name: 'object',
defaultValue: 'invoices',
editable: false,
required: true,
},
],
},
{
label: 'Payment Intents',
description: 'Payment Intents on your Stripe account',
availableColumns: [
{
name: 'id',
type: 'text',
},
{
name: 'customer',
type: 'text',
},
{
name: 'amount',
type: 'bigint',
},
{
name: 'currency',
type: 'text',
},
{
name: 'payment_method',
type: 'text',
},
{
name: 'created',
type: 'timestamp',
},
{
name: 'attrs',
type: 'jsonb',
},
],
options: [
{
name: 'object',
defaultValue: 'payment_intents',
editable: false,
required: true,
},
],
},
{
label: 'Products',
description: 'Products on your Stripe account',
availableColumns: [
{
name: 'id',
type: 'text',
},
{
name: 'name',
type: 'text',
},
{
name: 'active',
type: 'bool',
},
{
name: 'default_price',
type: 'text',
},
{
name: 'description',
type: 'text',
},
{
name: 'created',
type: 'timestamp',
},
{
name: 'updated',
type: 'timestamp',
},
{
name: 'attrs',
type: 'jsonb',
},
],
options: [
{
name: 'object',
defaultValue: 'products',
editable: false,
required: true,
},
{
name: 'rowid_column',
label: 'Row ID Column',
defaultValue: 'id',
editable: true,
required: true,
},
],
},
{
label: 'Subscriptions',
description: 'Subscriptions on your Stripe account',
availableColumns: [
{
name: 'id',
type: 'text',
},
{
name: 'customer',
type: 'text',
},
{
name: 'currency',
type: 'text',
},
{
name: 'current_period_start',
type: 'timestamp',
},
{
name: 'current_period_end',
type: 'timestamp',
},
{
name: 'attrs',
type: 'jsonb',
},
],
options: [
{
name: 'object',
defaultValue: 'subscriptions',
editable: false,
required: true,
},
{
name: 'rowid_column',
label: 'Row ID Column',
defaultValue: 'id',
editable: true,
required: true,
},
],
},
],
},
{
name: 'firebase_wrapper',
handlerName: 'firebase_fdw_handler',
validatorName: 'firebase_fdw_validator',
icon: '/img/icons/firebase-icon.svg',
extensionName: 'FirebaseFdw',
label: 'Firebase',
docsUrl: 'https://supabase.github.io/wrappers/firebase/',
server: {
name: 'firebase_server',
options: [
{
name: 'project_id',
label: 'Project ID',
required: true,
encrypted: false,
hidden: false,
},
{
name: 'sa_key_id',
label: 'Service Account Key',
required: true,
encrypted: true,
hidden: true,
},
// NOTE(alaister): this is a valid option, but it may confuse the basic use case
// so I'm omitting it for now. We can add back once we make it's use clearer.
// {
// name: 'access_token',
// label: 'OAuth2 token to access Firebase',
// required: false,
// encrypted: false,
// hidden: false,
// },
],
},
tables: [
{
label: 'Users',
description: 'Shows your Firebase users',
availableColumns: [
{
name: 'uid',
type: 'text',
},
{
name: 'email',
type: 'text',
},
{
name: 'created_at',
type: 'timestamp',
},
{
name: 'attrs',
type: 'jsonb',
},
],
options: [
{
name: 'object',
defaultValue: 'auth/users',
editable: false,
required: true,
},
{
name: 'base_url',
label: 'Base URL',
defaultValue: 'https://identitytoolkit.googleapis.com/v1/projects',
editable: true,
required: true,
},
{
name: 'limit',
label: 'Limit',
defaultValue: '10000',
editable: true,
required: true,
},
],
},
{
label: 'Firestore Collection',
description: 'Map to a Firestore collection',
availableColumns: [
{
name: 'name',
type: 'text',
},
{
name: 'created_at',
type: 'timestamp',
},
{
name: 'updated_at',
type: 'timestamp',
},
{
name: 'attrs',
type: 'jsonb',
},
],
options: [
{
name: 'object',
label: 'Object',
placeholder: 'firestore/[collection_id]',
editable: true,
required: true,
},
{
name: 'base_url',
label: 'Base URL',
defaultValue: 'https://firestore.googleapis.com/v1beta1/projects',
editable: true,
required: true,
},
{
name: 'limit',
label: 'Limit',
defaultValue: '10000',
editable: true,
required: true,
},
],
},
],
},
]
@@ -0,0 +1,239 @@
import Link from 'next/link'
import { useState } from 'react'
import { Button, IconExternalLink } from 'ui'
import { observer } from 'mobx-react-lite'
import * as Tooltip from '@radix-ui/react-tooltip'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { checkPermissions, useParams, useStore } from 'hooks'
import { useFDWsQuery } from 'data/fdw/fdws-query'
import { WRAPPERS } from './Wrappers.constants'
import WrapperRow from './WrapperRow'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
const Wrappers = () => {
const { ref } = useParams()
const { ui, meta } = useStore()
const { project } = useProjectContext()
const { data, isLoading } = useFDWsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const enabledWrapperNamesSet = new Set(data?.result.map((fdw) => fdw.name))
const [open, setOpen] = useState<string>('')
const [isEnabling, setIsEnabling] = useState<boolean>(false)
const wrappersExtension = meta.extensions.byId('wrappers')
const vaultExtension = meta.extensions.byId('supabase_vault')
const isNotAvailable = wrappersExtension === undefined || vaultExtension === undefined
const isWrappersEnabled =
wrappersExtension !== undefined &&
wrappersExtension?.installed_version !== null &&
vaultExtension !== undefined &&
vaultExtension?.installed_version !== null
const canToggleWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'extensions')
const onEnableWrappers = async () => {
if (wrappersExtension === undefined || vaultExtension === undefined) return
setIsEnabling(true)
const requiredExtensions = await Promise.all([
await meta.extensions.create({
schema: wrappersExtension.schema ?? 'extensions',
name: wrappersExtension.name,
version: wrappersExtension.default_version,
cascade: true,
}),
await meta.extensions.create({
schema: vaultExtension.schema ?? 'vault',
name: vaultExtension.name,
version: vaultExtension.default_version,
cascade: true,
}),
])
const errors = requiredExtensions.filter(
(res) => res.error && !res.error.message.includes('already exists')
)
if (errors.length > 0) {
ui.setNotification({
error: errors,
category: 'error',
message: `Failed to enable Wrappers for your project: ${errors
.map((x) => x.message)
.join(', ')}`,
})
} else {
ui.setNotification({
category: 'success',
message: 'Wrappers is now enabled for your project!',
})
}
setIsEnabling(false)
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="mb-2 text-xl text-scale-1200">Foreign Data Wrappers</h3>
<div className="text-sm text-scale-900">
Query your data warehouse directly from your database, or third-party APIs using SQL.
</div>
</div>
{/* [Joshen TODO] For when we support multiple instances per wrapper */}
{/* <div>
<Dropdown
side="bottom"
align="end"
size="small"
overlay={
<>
{WRAPPERS.map((wrapper, idx) => (
<>
<Dropdown.Item
key={wrapper.name}
icon={
<Image
src={wrapper.icon}
width={20}
height={20}
alt={`${wrapper.name} wrapper icon`}
/>
}
onClick={() => {}}
>
{wrapper.label}
</Dropdown.Item>
{idx !== WRAPPERS.length - 1 && <Dropdown.Separator />}
</>
))}
</>
}
>
<Button type="primary">Add wrapper</Button>
</Dropdown>
</div> */}
</div>
{isWrappersEnabled ? (
<div>
{WRAPPERS.map((wrapper, i) => {
return (
<WrapperRow
key={i}
wrapper={wrapper}
isLoading={isLoading}
isEnabled={enabledWrapperNamesSet.has(wrapper.server.name)}
isOpen={open === wrapper.name}
onOpen={(wrapperName) => {
if (open !== wrapperName) setOpen(wrapperName)
else setOpen('')
}}
/>
)
})}
</div>
) : (
<div>
<div
className="w-full px-12 py-12 bg-white bg-no-repeat border rounded dark:bg-scale-200 border-scale-500"
style={{
backgroundSize: '45%',
backgroundPosition: '105% 40%',
backgroundImage: ui.isDarkTheme
? 'url("/img/wrappers-dark.png")'
: 'url("/img/wrappers-light.png")',
}}
>
<div className="w-3/5 space-y-8">
<div className="space-y-2">
<h4 className="text-lg">Supabase Wrappers</h4>
<p className="text-sm text-scale-1100">
Supabase Wrappers is a framework for building Postgres Foreign Data Wrappers (FDW)
which connect Postgres to external systems. Query your data warehouse or
third-party APIs directly from your database.
</p>
</div>
{isNotAvailable ? (
<div className="space-y-4">
<div className="rounded border border-scale-500 px-4 py-2 flex items-center justify-between">
<div>
<p className="text-scale-1100 text-sm">
Wrappers is not available for this project yet.
</p>
<p className="text-scale-1000 text-sm">
Do reach out to us if you're interested!
</p>
</div>
<div>
<Link
href={`/support/new?ref=${ref}&category=sales&subject=Request%20for%20access%20to%20wrappers`}
>
<a target="_blank">
<Button type="primary">Contact us</Button>
</a>
</Link>
</div>
</div>
<div className="flex items-center space-x-2 my-1 ml-[1px]">
<Link href="https://supabase.com/docs">
<a target="_blank">
<Button type="default" icon={<IconExternalLink />}>
About Wrappers
</Button>
</a>
</Link>
</div>
</div>
) : (
<div className="flex items-center space-x-2">
<Link href="https://supabase.com/docs">
<a target="_blank">
<Button type="default" icon={<IconExternalLink />}>
About Wrappers
</Button>
</a>
</Link>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Button
type="primary"
loading={isEnabling}
disabled={isNotAvailable || isEnabling || !canToggleWrappers}
onClick={() => onEnableWrappers()}
>
Enable Wrappers
</Button>
</Tooltip.Trigger>
{!canToggleWrappers && (
<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 enable Wrappers for this project
</span>
</div>
</Tooltip.Content>
)}
</Tooltip.Root>
</div>
)}
</div>
</div>
</div>
)}
</div>
)
}
export default observer(Wrappers)
@@ -0,0 +1,46 @@
export type Wrapper = {
name: string
handlerName: string
validatorName: string
icon: string
extensionName: string
label: string
server: Server
tables: Table[]
docsUrl: string
}
export type ServerOption = {
name: string
label: string
required: boolean
encrypted: boolean
hidden: boolean
defaultValue?: string
}
export type Server = {
name: string
options: ServerOption[]
}
export type TableOption = {
name: string
defaultValue?: string
editable: boolean
required: boolean
label?: string
placeholder?: string
}
export type Table = {
label: string
description?: string
availableColumns: AvailableColumn[]
options: TableOption[]
}
export type AvailableColumn = {
name: string
type: string
}
@@ -0,0 +1,21 @@
export const makeValidateRequired =
(options: { name: string; required: boolean }[]) => (values: any) => {
const requiredOptionsSet = new Set(
options.filter((option) => option.required).map((option) => option.name)
)
const errors = Object.fromEntries(
Object.entries(values)
.filter(
([key, value]) =>
requiredOptionsSet.has(key) && (Array.isArray(value) ? value.length < 1 : !value)
)
.map(([key]) => {
if (key === 'table_name') return [key, 'Please provide a name for your table']
else if (key === 'columns') return [key, 'Please select at least one column']
else return [key, 'This field is required']
})
)
return errors
}
@@ -6,6 +6,8 @@ import RolesSettings from './Roles/RolesSettings'
import Extensions from './Extensions/Extensions'
import Wrappers from './Wrappers/Wrappers'
import PublicationsList from './Publications/PublicationsList'
import PublicationsTables from './Publications/PublicationsTables'
@@ -25,6 +27,7 @@ export {
RolesList,
RolesSettings,
Extensions,
Wrappers,
PublicationsList,
PublicationsTables,
BackupsList,
@@ -19,7 +19,10 @@ const AddNewSecretModal: FC<Props> = ({ visible, onClose }) => {
const defaultKey = keys.find((key) => key.name === 'default_vault_key') || keys[0]
useEffect(() => {
if (visible) setSelectedKeyId(defaultKey?.id)
if (visible) {
setShowSecretValue(false)
setSelectedKeyId(defaultKey?.id)
}
}, [visible])
const validate = (values: any) => {
@@ -2,11 +2,10 @@ import Link from 'next/link'
import { observer } from 'mobx-react-lite'
import { FC, useState } from 'react'
import * as Tooltip from '@radix-ui/react-tooltip'
import { Button, IconAlertCircle, IconExternalLink } from 'ui'
import { Button, IconExternalLink } from 'ui'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { checkPermissions, useParams, useStore } from 'hooks'
import InformationBox from 'components/ui/InformationBox'
interface Props {}
@@ -77,35 +76,36 @@ const VaultToggle: FC<Props> = () => {
</p>
</div>
{isNotAvailable ? (
<InformationBox
hideCollapse
defaultVisibility
icon={<IconAlertCircle strokeWidth={1.5} size={18} />}
title="Vault is not available for your project"
description={
<>
<p className="mb-4">
Do reach out to us if you're interested in having Vault for this project!
<div className="space-y-4">
<div className="rounded border border-scale-500 px-4 py-2 flex items-center justify-between">
<div>
<p className="text-scale-1100 text-sm">
Vault is not available for this project yet.
</p>
<div className="flex items-center space-x-2 my-1 ml-[1px]">
<Link href="https://supabase.com/docs">
<a target="_blank">
<Button type="default" icon={<IconExternalLink />}>
About Vault
</Button>
</a>
</Link>
<Link
href={`/support/new?ref=${ref}&category=sales&subject=Request%20for%20access%20to%20vault`}
>
<a target="_blank">
<Button type="primary">Contact us</Button>
</a>
</Link>
</div>
</>
}
/>
<p className="text-scale-1000 text-sm">
Do reach out to us if you're interested!
</p>
</div>
<div>
<Link
href={`/support/new?ref=${ref}&category=sales&subject=Request%20for%20access%20to%20vault`}
>
<a target="_blank">
<Button type="primary">Contact us</Button>
</a>
</Link>
</div>
</div>
<div className="flex items-center space-x-2 my-1 ml-[1px]">
<Link href="https://supabase.com/docs">
<a target="_blank">
<Button type="default" icon={<IconExternalLink />}>
About Vault
</Button>
</a>
</Link>
</div>
</div>
) : (
<div className="flex items-center space-x-2">
<Link href="https://supabase.com/docs">
@@ -10,6 +10,7 @@ interface ActionBarProps {
backButtonLabel?: string
applyFunction?: (resolve: any) => void
closePanel: () => void
formId?: string
}
const ActionBar: FC<ActionBarProps> = ({
loading = false,
@@ -20,6 +21,7 @@ const ActionBar: FC<ActionBarProps> = ({
backButtonLabel = 'Back',
applyFunction = undefined,
closePanel = () => {},
formId,
}) => {
const [isRunning, setIsRunning] = useState(false)
@@ -53,7 +55,13 @@ const ActionBar: FC<ActionBarProps> = ({
) : !hideApply ? (
// New solution, when using the Form component, loading is handled by the Form itself
// Does not require applyFunction() callback
<Button size="small" disabled={disableApply} loading={loading} htmlType="submit">
<Button
size="small"
disabled={disableApply}
loading={loading}
htmlType="submit"
form={formId}
>
{applyButtonLabel}
</Button>
) : (
@@ -1,5 +1,6 @@
import { Project } from 'types'
import { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types'
import { useFlag } from 'hooks'
export const generateDatabaseMenu = (project?: Project): ProductMenuGroup[] => {
const ref = project?.ref ?? 'default'
@@ -7,6 +8,8 @@ export const generateDatabaseMenu = (project?: Project): ProductMenuGroup[] => {
const HOOKS_RELEASED = '2021-07-30T15:33:54.383Z'
const showHooksRoute = project?.inserted_at ? project.inserted_at > HOOKS_RELEASED : false
const foreignDataWrappersEnabled = useFlag('foreignDataWrappers')
return [
{
title: 'Database',
@@ -32,6 +35,16 @@ export const generateDatabaseMenu = (project?: Project): ProductMenuGroup[] => {
url: `/project/${ref}/database/extensions`,
items: [],
},
...(foreignDataWrappersEnabled
? [
{
name: 'Wrappers',
key: 'wrappers',
url: `/project/${ref}/database/wrappers`,
items: [],
},
]
: []),
{ name: 'Roles', key: 'roles', url: `/project/${ref}/database/roles`, items: [] },
{
name: 'Replication',
@@ -0,0 +1,46 @@
import { useStore } from 'hooks'
import { observer } from 'mobx-react-lite'
import { createContext, PropsWithChildren, useContext, useMemo } from 'react'
import { Project } from 'types'
type ProjectContextType = {
project: Project | undefined
isLoading: boolean
}
const ProjectContext = createContext<ProjectContextType>({
project: undefined,
isLoading: true,
})
export default ProjectContext
export const useProjectContext = () => useContext(ProjectContext)
type ProjectContextProviderProps = {
projectRef: string | undefined
}
export const ProjectContextProvider = observer(
({ projectRef, children }: PropsWithChildren<ProjectContextProviderProps>) => {
// TODO(alaister): This will eventually be replaced with a react-query query
// once we remove mobx
const { ui } = useStore()
const value = useMemo<ProjectContextType>(() => {
if (ui.selectedProject?.ref === projectRef) {
return {
project: ui.selectedProject,
isLoading: false,
}
}
return {
project: undefined,
isLoading: true,
}
}, [ui.selectedProject?.ref, projectRef])
return <ProjectContext.Provider value={value}>{children}</ProjectContext.Provider>
}
)
@@ -2,7 +2,7 @@ import Head from 'next/head'
import { FC, ReactNode, PropsWithChildren, Fragment } from 'react'
import { observer } from 'mobx-react-lite'
import { useRouter } from 'next/router'
import { useStore, withAuth, useFlag } from 'hooks'
import { useStore, withAuth, useFlag, useParams } from 'hooks'
import { PROJECT_STATUS } from 'lib/constants'
import Connecting from 'components/ui/Loading'
@@ -12,6 +12,7 @@ import LayoutHeader from './LayoutHeader'
import ConnectingState from './ConnectingState'
import PausingState from './PausingState'
import BuildingState from './BuildingState'
import { ProjectContextProvider } from './ProjectContext'
import RestoringState from './RestoringState'
interface Props {
@@ -32,12 +33,13 @@ const ProjectLayout = ({
hideHeader = false,
hideIconBar = false,
}: PropsWithChildren<Props>) => {
const { ref: projectRef } = useParams()
const { ui } = useStore()
const ongoingIncident = useFlag('ongoingIncident')
const projectName = ui.selectedProject?.name
return (
<>
<ProjectContextProvider projectRef={projectRef}>
<Head>
<title>
{title ? `${title} | Supabase` : projectName ? `${projectName} | Supabase` : 'Supabase'}
@@ -55,14 +57,14 @@ const ProjectLayout = ({
</MenuBarWrapper>
<main
className="flex w-full flex-1 flex-col overflow-x-hidden"
className="flex flex-col flex-1 w-full overflow-x-hidden"
style={{ height: ongoingIncident ? 'calc(100vh - 44px)' : '100vh' }}
>
{!hideHeader && <LayoutHeader />}
<ContentWrapper isLoading={isLoading}>{children}</ContentWrapper>
</main>
</div>
</>
</ProjectContextProvider>
)
}
@@ -23,6 +23,7 @@ export const BadgeSelected: FC<{ name: string; handleRemove: () => void }> = ({
'flex items-center space-x-2 rounded bg-gray-500',
'py-0.5 px-2 text-sm',
].join(' ')}
onClick={(e: any) => e.preventDefault()}
>
<span>{name}</span>
<IconX
+8 -2
View File
@@ -8,6 +8,7 @@ export interface MultiSelectOption {
id: string | number
value: string
name: string
description?: string
disabled: boolean
}
@@ -112,7 +113,7 @@ export default function MultiSelect({
return (
<div className={`form-group ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
{label && <label>{label}</label>}
{label && <label className="!w-full">{label}</label>}
<div
className={[
'form-control form-control--multi-select',
@@ -166,7 +167,12 @@ export default function MultiSelect({
`${active ? ' dark:bg-green-600 dark:bg-opacity-25' : ''}`,
].join(' ')}
>
<span>{option.name}</span>
<div className="flex items-center space-x-2">
<p>{option.name}</p>
{option.description !== undefined && (
<p className="opacity-50">{option.description}</p>
)}
</div>
{active && (
<IconCheck
size={16}
@@ -1,6 +1,5 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { delete_ } from 'lib/common/fetch'
import { getCustomDomains } from './custom-domains-query'
import { customDomainKeys } from './keys'
export type CustomDomainDeleteVariables = {
+169
View File
@@ -0,0 +1,169 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { AvailableColumn, Wrapper } from 'components/interfaces/Database/Wrappers/Wrappers.types'
import { executeSql } from 'data/sql/execute-sql-query'
import { sqlKeys } from 'data/sql/keys'
import { useStore } from 'hooks'
export type FDWCreateVariables = {
projectRef?: string
connectionString?: string
wrapper: Wrapper
formState: {
[k: string]: string
}
newTables: any[]
}
export function getFDWCreateSql({
wrapper,
formState,
newTables,
}: Pick<FDWCreateVariables, 'wrapper' | 'formState' | 'newTables'>) {
const newSchemasSql = newTables
.filter((table) => table.is_new_schema)
.map((table) => /* SQL */ `create schema if not exists ${table.schema_name};`)
.join('\n')
const createWrapperSql = /* SQL */ `
create foreign data wrapper ${wrapper.name}
handler ${wrapper.handlerName}
validator ${wrapper.validatorName};
`
const encryptedOptions = wrapper.server.options.filter((option) => option.encrypted)
const unencryptedOptions = wrapper.server.options.filter((option) => !option.encrypted)
const createEncryptedKeysSqlArray = encryptedOptions.map((option) => {
const key = `${wrapper.name}_${option.name}`
// Escape single quotes in postgresql by doubling them up
const value = (formState[option.name] || '').replace(/'/g, "''")
return /* SQL */ `
select pgsodium.create_key(
name := '${key}'
);
select vault.create_secret (
new_secret := '${value}',
new_name := '${key}',
new_key_id := (select id from pgsodium.valid_key where name = '${key}')
);
`
})
const createEncryptedKeysSql = createEncryptedKeysSqlArray.join('\n')
const encryptedOptionsSqlArray = encryptedOptions.map((option) => `${option.name} ''%s''`)
const unencryptedOptionsSqlArray = unencryptedOptions.map(
(option) => `${option.name} ''${formState[option.name]}''`
)
const optionsSqlArray = [...encryptedOptionsSqlArray, ...unencryptedOptionsSqlArray].join(',')
const createServerSql = /* SQL */ `
do $$
declare
${encryptedOptions.map((option) => `v_${option.name} text;`).join('\n')}
begin
${encryptedOptions
.map(
(option) =>
/* SQL */ `select id into v_${option.name} from pgsodium.valid_key where name = '${wrapper.name}_${option.name}' limit 1;`
)
.join('\n')}
execute format(
E'create server ${wrapper.server.name}\\n'
' foreign data wrapper ${wrapper.name}\\n'
' options (\\n'
' ${optionsSqlArray}\\n'
' );',
${encryptedOptions.map((option) => `v_${option.name}`).join(',\n')}
);
end $$;
`
const createTablesSql = newTables
.map((newTable) => {
const table = wrapper.tables[newTable.index]
const columns: AvailableColumn[] = newTable.columns
.map((name: string) => table.availableColumns.find((c) => c.name === name))
.filter(Boolean)
return /* SQL */ `
create foreign table ${newTable.schema_name}.${newTable.table_name} (
${columns.map((column) => `${column.name} ${column.type}`).join(',\n ')}
)
server ${wrapper.server.name}
options (
${Object.entries(newTable)
.filter(
([key]) =>
key !== 'table_name' &&
key !== 'schema_name' &&
key !== 'columns' &&
key !== 'index'
)
.map(([key, value]) => `${key} '${value}'`)
.join(',\n ')}
);
`
})
.join('\n\n')
const sql = /* SQL */ `
begin;
${newSchemasSql}
${createWrapperSql}
${createEncryptedKeysSql}
${createServerSql}
${createTablesSql}
commit;
`
return sql
}
export async function createFDW({
projectRef,
connectionString,
wrapper,
formState,
newTables,
}: FDWCreateVariables) {
const sql = getFDWCreateSql({ wrapper, formState, newTables })
const { result } = await executeSql({ projectRef, connectionString, sql })
return result
}
type FDWCreateData = Awaited<ReturnType<typeof createFDW>>
export const useFDWCreateMutation = ({
onSuccess,
...options
}: Omit<UseMutationOptions<FDWCreateData, unknown, FDWCreateVariables>, 'mutationFn'> = {}) => {
const queryClient = useQueryClient()
const { vault } = useStore()
return useMutation<FDWCreateData, unknown, FDWCreateVariables>((vars) => createFDW(vars), {
async onSuccess(data, variables, context) {
const { projectRef } = variables
await Promise.all([
queryClient.invalidateQueries(sqlKeys.query(projectRef, ['fdws'])),
vault.load(),
])
await onSuccess?.(data, variables, context)
},
...options,
})
}
+75
View File
@@ -0,0 +1,75 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { Wrapper } from 'components/interfaces/Database/Wrappers/Wrappers.types'
import { executeSql } from 'data/sql/execute-sql-query'
import { sqlKeys } from 'data/sql/keys'
import { useStore } from 'hooks'
export type FDWDeleteVariables = {
projectRef?: string
connectionString?: string
wrapper: Wrapper
}
export const getDeleteFDWSql = ({ wrapper }: Pick<FDWDeleteVariables, 'wrapper'>) => {
const encryptedOptions = wrapper.server.options.filter((option) => option.encrypted)
const deleteEncryptedSecretsSqlArray = encryptedOptions.map((option) => {
const key = `${wrapper.name}_${option.name}`
return /* SQL */ `
delete from vault.secrets where key_id = (select id from pgsodium.valid_key where name = '${key}');
delete from pgsodium.key where name = '${key}';
`
})
const deleteEncryptedSecretsSql = deleteEncryptedSecretsSqlArray.join('\n')
const sql = /* SQL */ `
begin;
drop foreign data wrapper if exists ${wrapper.name} cascade;
${deleteEncryptedSecretsSql}
commit;
`
return sql
}
export async function deleteFDW({ projectRef, connectionString, wrapper }: FDWDeleteVariables) {
if (!projectRef) {
throw new Error('projectRef is required')
}
const sql = getDeleteFDWSql({ wrapper })
const { result } = await executeSql({ projectRef, connectionString, sql })
return result
}
type FDWDeleteData = Awaited<ReturnType<typeof deleteFDW>>
export const useFDWDeleteMutation = ({
onSuccess,
...options
}: Omit<UseMutationOptions<FDWDeleteData, unknown, FDWDeleteVariables>, 'mutationFn'> = {}) => {
const queryClient = useQueryClient()
const { vault } = useStore()
return useMutation<FDWDeleteData, unknown, FDWDeleteVariables>((vars) => deleteFDW(vars), {
async onSuccess(data, variables, context) {
const { projectRef } = variables
await Promise.all([
queryClient.invalidateQueries(sqlKeys.query(projectRef, ['fdws'])),
vault.load(),
])
await onSuccess?.(data, variables, context)
},
...options,
})
}
+85
View File
@@ -0,0 +1,85 @@
import { UseQueryOptions } from '@tanstack/react-query'
import { ExecuteSqlData, useExecuteSqlPrefetch, useExecuteSqlQuery } from '../sql/execute-sql-query'
export const getFDWsSql = () => {
const sql = /* SQL */ `
select
s.oid as "id",
s.srvname as "name",
(
select jsonb_agg(
jsonb_build_object(
'id', c.oid,
'name', c.relname,
'columns', (
select jsonb_agg(
jsonb_build_object(
'name', a.attname,
'type', pg_catalog.format_type(a.atttypid, a.atttypmod)
)
)
from pg_catalog.pg_attribute a
where a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped
)
)
)
from pg_catalog.pg_class c
where c.oid = any (select t.ftrelid from pg_catalog.pg_foreign_table t where t.ftserver = s.oid)
) as "tables"
from pg_catalog.pg_foreign_server s;
`
return sql
}
export type FDWColumn = {
name: string
type: string
}
export type FDWTable = {
id: string
name: string
columns: FDWColumn[]
}
export type FDW = {
id: string
name: string
tables: FDWTable[]
}
export type FDWsResponse = {
result: FDW[]
}
export type FDWsVariables = {
projectRef?: string
connectionString?: string
}
export type FDWsData = FDWsResponse
export type FDWsError = unknown
export const useFDWsQuery = <TData extends FDWsData = FDWsData>(
{ projectRef, connectionString }: FDWsVariables,
options: UseQueryOptions<ExecuteSqlData, FDWsError, TData> = {}
) =>
useExecuteSqlQuery(
{
projectRef,
connectionString,
sql: getFDWsSql(),
queryKey: ['fdws'],
},
options
)
export const useFDWsPrefetch = ({ projectRef, connectionString }: FDWsVariables) => {
return useExecuteSqlPrefetch({
projectRef,
connectionString,
sql: getFDWsSql(),
queryKey: ['fdws'],
})
}
+16
View File
@@ -0,0 +1,16 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { executeSql, ExecuteSqlData, ExecuteSqlVariables } from './execute-sql-query'
/* Execute Query */
export const useExecuteSqlMutation = ({
onSuccess,
...options
}: Omit<UseMutationOptions<ExecuteSqlData, unknown, ExecuteSqlVariables>, 'mutationFn'> = {}) => {
return useMutation<ExecuteSqlData, unknown, ExecuteSqlVariables>((args) => executeSql(args), {
async onSuccess(data, variables, context) {
await onSuccess?.(data, variables, context)
},
...options,
})
}
+69
View File
@@ -0,0 +1,69 @@
import { QueryKey, useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query'
import md5 from 'blueimp-md5'
import { post } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import { useCallback } from 'react'
import { sqlKeys } from './keys'
export type ExecuteSqlVariables = {
projectRef?: string
connectionString?: string
sql: string
queryKey?: QueryKey
}
export async function executeSql(
{ projectRef, connectionString, sql }: ExecuteSqlVariables,
signal?: AbortSignal
) {
if (!projectRef) {
throw new Error('projectRef is required')
}
let headers = new Headers()
if (connectionString) {
headers.set('x-connection-encrypted', connectionString)
}
const response = await post(
`${API_URL}/pg-meta/${projectRef}/query`,
{ query: sql },
{ headers: Object.fromEntries(headers), signal }
)
if (response.error) {
throw response.error
}
return { result: response }
}
export type ExecuteSqlData = Awaited<ReturnType<typeof executeSql>>
export type ExecuteSqlError = unknown
export const useExecuteSqlQuery = <TData = ExecuteSqlData>(
{ projectRef, connectionString, sql, queryKey }: ExecuteSqlVariables,
{ enabled = true, ...options }: UseQueryOptions<ExecuteSqlData, ExecuteSqlError, TData> = {}
) =>
useQuery<ExecuteSqlData, ExecuteSqlError, TData>(
sqlKeys.query(projectRef, queryKey ?? [md5(sql)]),
({ signal }) => executeSql({ projectRef, connectionString, sql }, signal),
{ enabled: enabled && typeof projectRef !== 'undefined', ...options }
)
export const useExecuteSqlPrefetch = ({
projectRef,
connectionString,
sql,
queryKey,
}: ExecuteSqlVariables) => {
const client = useQueryClient()
return useCallback(() => {
if (projectRef) {
client.prefetchQuery(sqlKeys.query(projectRef, queryKey ?? [md5(sql)]), ({ signal }) =>
executeSql({ projectRef, connectionString, sql }, signal)
)
}
}, [projectRef])
}
+6
View File
@@ -0,0 +1,6 @@
import { QueryKey } from '@tanstack/react-query'
export const sqlKeys = {
query: (projectRef: string | undefined, queryKey: QueryKey) =>
['projects', projectRef, 'query', ...queryKey] as const,
}
+2
View File
@@ -34,6 +34,7 @@
"ajv": "^8.6.3",
"analytics-node": "^3.5.0",
"awesome-debounce-promise": "^2.1.0",
"blueimp-md5": "^2.19.0",
"clipboard": "^2.0.8",
"common": "*",
"config": "*",
@@ -103,6 +104,7 @@
"@testing-library/dom": "^8.11.1",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
"@types/blueimp-md5": "^2.18.0",
"@types/crypto-js": "^4.0.2",
"@types/file-saver": "^2.0.2",
"@types/json-logic-js": "^1.2.1",
@@ -0,0 +1,26 @@
import { observer } from 'mobx-react-lite'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { NextPageWithLayout } from 'types'
import { checkPermissions } from 'hooks'
import { DatabaseLayout } from 'components/layouts'
import { Wrappers } from 'components/interfaces/Database'
import NoPermission from 'components/ui/NoPermission'
import { FormsContainer } from 'components/ui/Forms'
const DatabaseWrappers: NextPageWithLayout = () => {
const canReadWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'wrappers')
if (!canReadWrappers) {
return <NoPermission isFullPage resourceText="view foreign data wrappers" />
}
return <Wrappers />
}
DatabaseWrappers.getLayout = (page) => (
<DatabaseLayout title="Wrappers">
<FormsContainer>{page}</FormsContainer>
</DatabaseLayout>
)
export default observer(DatabaseWrappers)
+51
View File
@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="111px" height="151px" viewBox="0 0 111 151" version="1.1">
<!-- Generator: Sketch 47.1 (45422) - http://www.bohemiancoding.com/sketch -->
<title>logo_lockup_firebase_vertical</title>
<desc>Created with Sketch.</desc>
<defs>
<linearGradient x1="-3.48177083%" y1="17.0266727%" x2="100.833333%" y2="92.8868033%" id="linearGradient-1">
<stop stop-color="#FFFFFF" stop-opacity="0.1" offset="0%"/>
<stop stop-color="#FFFFFF" stop-opacity="0" offset="100%"/>
</linearGradient>
<path d="M95.7984919,33.6246194 C95.5859256,32.3610745 94.6848395,31.3206592 93.4615125,30.9262876 C92.2381855,30.5319161 90.8962602,30.8492378 89.9812105,31.7492661 L72.2727216,49.5651223 L58.2428077,23.0544463 C57.6801918,21.9189555 56.5194912,21.2001264 55.2486188,21.2001264 C53.9777463,21.2001264 52.8170458,21.9189555 52.2544298,23.0544463 L44.6406351,37.5458126 L25.3922776,1.91410025 C23.852409,-0.984173003 19.489448,-0.216983024 18.9761585,3.02226355 L0.497735304,121.16952 L50.0301752,148.873603 C53.1471684,150.578603 56.9223279,150.578603 60.0393211,148.873603 L109.999502,121.16952 L95.7984919,33.6246194 Z" id="path-2"/>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="styleguide" transform="translate(-664.000000, -1272.000000)">
<g id="Group-16" transform="translate(40.000000, 1187.000000)">
<g id="Group-9" transform="translate(114.000000, 0.000000)">
<g id="Group-26" transform="translate(405.000000, 1.000000)">
<g id="logo_lockup_firebase_vertical" transform="translate(105.193370, 83.977901)">
<g id="_Group_">
<mask id="mask-3" fill="white">
<use xlink:href="#path-2"/>
</mask>
<use id="_Path_5" fill="url(#linearGradient-1)" fill-rule="nonzero" xlink:href="#path-2"/>
<g id="_Group_2" mask="url(#mask-3)">
<g transform="translate(-6.346125, -0.160154)">
<g id="_Group_3" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M6.84386044,121.329674 L25.4078319,3.18241769 C25.9211214,-0.0568288873 30.2840824,-0.824018866 31.823951,2.07425439 L50.9867602,37.7059667 L58.600555,23.2146005 C59.189165,22.0849984 60.360174,21.3760986 61.637518,21.3760986 C62.9148621,21.3760986 64.0858711,22.0849984 64.6744811,23.2146005 L116.345627,121.329674 L6.84386044,121.329674 Z" id="_Path_" fill="#FFA000" fill-rule="nonzero"/>
<g id="_Group_4" transform="translate(0.000000, 30.119310)">
<image id="Bitmap" opacity="0.12" x="0" y="0" width="74.4269822" height="94.6200974" xlink:href="data:image/png;base64,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 truncated
<polygon id="Shape" fill="#F57C00" fill-rule="nonzero" points="70.8339555 45.1789654 50.901212 7.58665646 6.84386044 91.2103641"/>
</g>
</g>
<g id="_Group_5" stroke-width="1" fill="none" fill-rule="evenodd" transform="translate(3.421930, 23.299844)">
<image id="_Image_" opacity="0.2" x="0" y="0" width="119.767558" height="131.27473" xlink:href="data:image/png;base64,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 truncated
<g id="_Group_6" transform="translate(3.421930, 7.387755)" fill-rule="nonzero" fill="#FFCA28">
<path d="M109.501767,90.6420753 L95.3007566,3.09717436 C95.0881903,1.8336295 94.1871042,0.793214154 92.9637772,0.398842606 C91.7404502,0.00447105695 90.3985249,0.321792836 89.4834752,1.22182108 L0,90.6420753 L49.5324399,118.346158 C52.6494331,120.051158 56.4245926,120.051158 59.5415858,118.346158 L109.501767,90.6420753 Z" id="_Path_2"/>
</g>
</g>
<path d="M102.144617,33.7847735 C101.932051,32.5212286 101.030965,31.4808133 99.8076376,31.0864418 C98.5843106,30.6920702 97.2423854,31.009392 96.3273356,31.9094202 L78.6188467,49.7252764 L64.5889329,23.2146005 C64.0263169,22.0791096 62.8656164,21.3602806 61.5947439,21.3602806 C60.3238715,21.3602806 59.1631709,22.0791096 58.600555,23.2146005 L50.9867602,37.7059667 L31.7384028,2.07425439 C30.1985342,-0.824018866 25.8355731,-0.0568288873 25.3222836,3.18241769 L6.84386044,121.329674 L6.75831218,121.329674 L6.84386044,121.414918 L7.35714997,121.670648 L78.5332985,50.5777097 L96.3273356,32.8470969 C97.2423854,31.9470686 98.5843106,31.6297468 99.8076376,32.0241184 C101.030965,32.4184899 101.932051,33.4589053 102.144617,34.7224501 L116.174531,121.414918 L116.345627,121.329674 L102.144617,33.7847735 Z M7.01495695,121.159188 L25.4078319,4.034851 C25.9211214,0.795604422 30.2840824,0.0284144437 31.823951,2.9266877 L50.9867602,38.5584 L58.600555,24.0670338 C59.189165,22.9374317 60.360174,22.2285319 61.637518,22.2285319 C62.9148621,22.2285319 64.0858711,22.9374317 64.6744811,24.0670338 L78.362202,49.9810064 L7.01495695,121.159188 Z" id="_Compound_Path_" fill-opacity="0.2" fill="#FFFFFF" fill-rule="nonzero"/>
<path d="M66.3854462,148.181324 C63.2684531,149.886324 59.4932935,149.886324 56.3763003,148.181324 L6.92940869,120.562484 L6.84386044,121.329674 L56.3763003,149.033757 C59.4932935,150.738757 63.2684531,150.738757 66.3854462,149.033757 L116.345627,121.329674 L116.260079,120.562484 L66.3854462,148.181324 Z" id="_Path_3" fill="#A52714" fill-rule="nonzero" opacity="0.2"/>
</g>
</g>
<polygon id="_Path_4" mask="url(#mask-3)" points="-26.8777064 -6.69547618 137.374944 -6.69547618 137.374944 156.971719 -26.8777064 156.971719"/>
<path d="M95.7984919,33.6246194 C95.5859256,32.3610745 94.6848395,31.3206592 93.4615125,30.9262876 C92.2381855,30.5319161 90.8962602,30.8492378 89.9812105,31.7492661 L72.2727216,49.5651223 L58.2428077,23.0544463 C57.6801918,21.9189555 56.5194912,21.2001264 55.2486188,21.2001264 C53.9777463,21.2001264 52.8170458,21.9189555 52.2544298,23.0544463 L44.6406351,37.5458126 L25.3922776,1.91410025 C23.852409,-0.984173003 19.489448,-0.216983024 18.9761585,3.02226355 L0.497735304,121.16952 L50.0301752,148.873603 C53.1471684,150.578603 56.9223279,150.578603 60.0393211,148.873603 L109.999502,121.16952 L95.7984919,33.6246194 Z" id="_Path_5" fill="url(#linearGradient-1)" fill-rule="nonzero" mask="url(#mask-3)"/>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 21 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28.87 28.87"><g data-name="Layer 2"><g data-name="Layer 1"><rect width="28.87" height="28.87" fill="#6772e5" rx="6.48" ry="6.48"/><path fill="#fff" fill-rule="evenodd" d="M13.3 11.2c0-.69.57-1 1.49-1a9.84 9.84 0 0 1 4.37 1.13V7.24a11.6 11.6 0 0 0-4.36-.8c-3.56 0-5.94 1.86-5.94 5 0 4.86 6.68 4.07 6.68 6.17 0 .81-.71 1.07-1.68 1.07A11.06 11.06 0 0 1 9 17.25v4.19a12.19 12.19 0 0 0 4.8 1c3.65 0 6.17-1.8 6.17-5 .03-5.21-6.67-4.27-6.67-6.24z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 509 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB