No files matched your search
@@ -253,20 +253,20 @@ export const PhoneLoginsItems = [
|
||||
name: 'MessageBird SMS Login',
|
||||
icon: '/docs/img/icons/messagebird-icon',
|
||||
linkDescription: 'Communication between businesses and their customers — across any channel.',
|
||||
url: '/guides/auth/passwordless-login/phone-sms-otp-messagebird',
|
||||
url: '/guides/auth/phone-login/messagebird',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
name: 'Twilio SMS Login',
|
||||
icon: '/docs/img/icons/twilio-icon',
|
||||
url: '/guides/auth/passwordless-login/phone-sms-otp-twilio',
|
||||
url: '/guides/auth/phone-login/twilio',
|
||||
linkDescription: 'Customer engagement platform used by hundreds of thousands of businesses.',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
name: 'Vonage SMS Login',
|
||||
icon: '/docs/img/icons/vonage-icon',
|
||||
url: '/guides/auth/passwordless-login/phone-sms-otp-vonage',
|
||||
url: '/guides/auth/phone-login/vonage',
|
||||
linkDescription:
|
||||
'Vonage is a communication platform as a service (CPaaS) provider for consumers and businesses.',
|
||||
items: [],
|
||||
@@ -287,18 +287,11 @@ export const auth = {
|
||||
url: undefined,
|
||||
items: [
|
||||
{ name: 'Email Login', url: '/guides/auth/auth-email', items: [] },
|
||||
{ name: 'Magic Link Login', url: '/guides/auth/auth-magic-link', items: [] },
|
||||
{
|
||||
name: 'Passwordless Login',
|
||||
url: '/guides/auth/passwordless-login',
|
||||
items: [
|
||||
{
|
||||
name: 'Magic Link Login',
|
||||
icon: '',
|
||||
url: '/guides/auth/passwordless-login/email-otp-magic-link',
|
||||
items: [],
|
||||
},
|
||||
...PhoneLoginsItems,
|
||||
],
|
||||
name: 'Phone Login',
|
||||
url: '/guides/auth/phone-login',
|
||||
items: [...PhoneLoginsItems],
|
||||
},
|
||||
{
|
||||
name: 'Social Login',
|
||||
|
||||
@@ -30,7 +30,6 @@ Setting up Magic Link logins for your Supabase application.
|
||||
defaultActiveId="js"
|
||||
>
|
||||
<TabPanel id="js" label="JavaScript">
|
||||
|
||||
When your user signs in, call [signInWithOtp()](/docs/reference/javascript/auth-signinwithotp) with their email address:
|
||||
|
||||
```js
|
||||
@@ -65,7 +64,6 @@ Future<void> signInWithEmail() async {
|
||||
defaultActiveId="js"
|
||||
>
|
||||
<TabPanel id="js" label="JavaScript">
|
||||
|
||||
When your user signs out, call [signOut()](/docs/reference/javascript/auth-signout) to remove them from the browser session and any objects from localStorage:
|
||||
|
||||
```js
|
||||
@@ -1,74 +0,0 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import { IconPanel, GlassPanel, IconMail } from 'ui'
|
||||
import Link from 'next/link'
|
||||
import { PhoneLoginsItems } from '~/components/Navigation/NavigationMenu/NavigationMenu.constants'
|
||||
|
||||
export const meta = {
|
||||
title: 'Passwordless Login',
|
||||
description:
|
||||
'Learn about logging in with email one-time passwords (magic links) and SMS one-time passwords.',
|
||||
}
|
||||
|
||||
Passwordless login is a method of authentication that allows users to log in to a website or application without using a password. Instead of entering a password, the user may be asked to provide some other form of authentication, such as a one-time code sent via email or SMS, or by using a biometric factor like their fingerprint or facial recognition.
|
||||
|
||||
## Benefits
|
||||
|
||||
There are several benefits of implementing passwordless login in your applications:
|
||||
|
||||
1. **Improved user experience**: By eliminating the need for users to remember and enter complex passwords, passwordless login can make it easier and more convenient for users to log in to your application. This can improve the overall user experience and make it more enjoyable for users to interact with your application.
|
||||
|
||||
2. **Increased security**: Passwordless login can improve the security of your application by reducing the risk of password-related security breaches, such as password reuse and weak passwords. By using alternative forms of authentication, such as one-time codes or biometric factors, you can make it more difficult for unauthorized users to access your application.
|
||||
|
||||
3. **Reduced support burden**: Passwordless login can also help reduce the support burden for your team by eliminating the need to reset forgotten passwords or deal with other password-related issues. This can free up your team to focus on other important tasks and improve the efficiency of your operation.
|
||||
|
||||
## Set up Passwordless Login with Supabase Auth
|
||||
|
||||
Supabase supports passwordless logins through "magic links" (instead of entering a password, the user is sent
|
||||
a link which they can click to log in to their account) and one-time passwords via SMS through several communications platforms. Follow the guides below to set up a passwordless login flow with Supabase Auth.
|
||||
|
||||
### Email One-Time Password (OTP)
|
||||
|
||||
<div className="grid grid-cols-2 gap-12">
|
||||
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-10 not-prose py-8">
|
||||
<Link href={`/guides/auth/auth-magic-link`} passHref>
|
||||
<a>
|
||||
<IconPanel title={'Magic Link'} span="col-span-6" icon={
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 text-gold-1000">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
||||
</svg>
|
||||
}>
|
||||
|
||||
Implement Magic Link Login in your application
|
||||
|
||||
</IconPanel>
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
</div>
|
||||
|
||||
### Phone SMS One-Time Password (OTP)
|
||||
|
||||
<div className="grid grid-cols-12 gap-10 not-prose py-8">
|
||||
{PhoneLoginsItems.map((item) => (
|
||||
<Link href={`/${item.url}`} key={item.name} passHref>
|
||||
<a className="col-span-4">
|
||||
<IconPanel
|
||||
title={item.name}
|
||||
span="col-span-6"
|
||||
icon={item.icon}
|
||||
isDarkMode={item.isDarkMode}
|
||||
>
|
||||
{item.linkDescription}
|
||||
</IconPanel>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
export const Page = ({ children }) => <Layout meta={meta} children={children} />
|
||||
|
||||
export default Page
|
||||
@@ -0,0 +1,47 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import { IconPanel, GlassPanel, IconMail } from 'ui'
|
||||
import Link from 'next/link'
|
||||
import { PhoneLoginsItems } from '~/components/Navigation/NavigationMenu/NavigationMenu.constants'
|
||||
|
||||
export const meta = {
|
||||
title: 'Phone Login',
|
||||
description: 'Learn about logging in to your platform using SMS one-time passwords.',
|
||||
}
|
||||
|
||||
Phone Login is a method of authentication that allows users to log in to a website or application without using a password. Instead of entering a password, the user provides another form of authentication through a one-time code sent via SMS.
|
||||
|
||||
## Benefits
|
||||
|
||||
There are several reasons why you might want to add phone login to your applications:
|
||||
|
||||
- **Improved user experience**: By eliminating the need for users to remember and enter complex passwords, phone login can make it easier and more convenient for users to log in to your application. This can improve the overall user experience and make it more enjoyable for users to interact with your application.
|
||||
|
||||
- **Increased security**: Phone login can improve the security of your application by reducing the risk of password-related security breaches, such as password reuse and weak passwords. By using alternative forms of authentication, such as one-time codes or biometric factors, you can make it more difficult for unauthorized users to access your application.
|
||||
|
||||
- **Reduced support burden**: Phone login can also help reduce the support burden for your team by eliminating the need to handle password recovery flows or deal with other password-related issues. This can free up your team to focus on other important tasks and improve the efficiency of your operation.
|
||||
|
||||
## Set up a provider with Supabase Auth
|
||||
|
||||
Supabase supports Phone Login with several communications platforms. Follow the guides below to set up a provider with Supabase Auth.
|
||||
|
||||
<div className="grid grid-cols-12 gap-10 not-prose py-8">
|
||||
{PhoneLoginsItems.map((item) => (
|
||||
<Link href={`${item.url}`} key={item.name} passHref>
|
||||
<a className="col-span-4">
|
||||
<IconPanel
|
||||
title={item.name}
|
||||
span="col-span-6"
|
||||
icon={item.icon}
|
||||
isDarkMode={item.isDarkMode}
|
||||
>
|
||||
{item.linkDescription}
|
||||
</IconPanel>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
export const Page = ({ children }) => <Layout meta={meta} children={children} />
|
||||
|
||||
export default Page
|
||||
@@ -16,11 +16,11 @@ OAuth is commonly used for things like logging in to a social media account from
|
||||
|
||||
There are several reasons why you might want to add social login to your applications:
|
||||
|
||||
1. **Improved user experience**: Users can register and log in to your application using their existing social media accounts, which can be faster and more convenient than creating a new account from scratch. This makes it easier for users to access your application, improving their overall experience.
|
||||
- **Improved user experience**: Users can register and log in to your application using their existing social media accounts, which can be faster and more convenient than creating a new account from scratch. This makes it easier for users to access your application, improving their overall experience.
|
||||
|
||||
2. **Better user engagement**: You can access additional data and insights about your users, such as their interests, demographics, and social connections. This can help you tailor your content and marketing efforts to better engage with your users and provide a more personalized experience.
|
||||
- **Better user engagement**: You can access additional data and insights about your users, such as their interests, demographics, and social connections. This can help you tailor your content and marketing efforts to better engage with your users and provide a more personalized experience.
|
||||
|
||||
3. **Increased security**: Social login can improve the security of your application by leveraging the security measures and authentication protocols of the social media platforms that your users are logging in with. This can help protect against unauthorized access and account takeovers.
|
||||
- **Increased security**: Social login can improve the security of your application by leveraging the security measures and authentication protocols of the social media platforms that your users are logging in with. This can help protect against unauthorized access and account takeovers.
|
||||
|
||||
## Set up a social provider with Supabase Auth
|
||||
|
||||
@@ -28,7 +28,7 @@ Supabase supports a suite of social providers. Follow these guides to configure
|
||||
|
||||
<div className="grid grid-cols-12 gap-10 not-prose py-8">
|
||||
{SocialLoginItems.map((item) => (
|
||||
<Link href={`/${item.url}`} key={item.name} passHref>
|
||||
<Link href={`${item.url}`} key={item.name} passHref>
|
||||
<a className="col-span-6 lg:col-span-4 xl:col-span-3">
|
||||
<IconPanel
|
||||
title={item.name}
|
||||
|
||||
@@ -59,10 +59,10 @@ export const topArticles = [
|
||||
description: 'Learn how to add an email and password login flow to your platform.',
|
||||
},
|
||||
{
|
||||
title: 'Passwordless Login',
|
||||
title: 'Phone Login',
|
||||
header: '/docs/img/cards/sample-card-header-4.svg',
|
||||
href: '/guides/auth/passwordless-login',
|
||||
description: 'Learn how to add a login flow so your users can access your platform through SMS or without a password.',
|
||||
href: '/guides/auth/phone-login',
|
||||
description: 'Learn how to add a login flow so your users can access your platform through one-time passwords via SMS.',
|
||||
},
|
||||
{
|
||||
title: 'Social Login',
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import Layout from '~/layouts/tutorials/TutorialLayout'
|
||||
import Link from 'next/link'
|
||||
import { GlassPanel } from 'ui'
|
||||
|
||||
export const meta = {
|
||||
title: 'Auth login',
|
||||
}
|
||||
|
||||
## Learn how to use Supabase Auth to handle authentication and authorization of your users blah blah i am test text
|
||||
|
||||
Auth0 Universal Login defines your login flow, which is the key feature of an Authorization Server.
|
||||
Each time a user needs to prove their identity, your applications redirect to Universal Login and Auth0 will do what is needed to guarantee the user's identity.
|
||||
|
||||
By choosing Universal Login, you don't have to do any integration work to handle the various flavors of authentication. You can start off using a simple username and password.
|
||||
You can also add other features such as social login and multi-factor authentication (MFA). All of this is dynamic and adjustable in real-time without requiring application-level changes because all functionality is driven by the web pages served by the centralized Authentication Server.
|
||||
Your application will benefit from any improvements Auth0 makes in the login flow without the need for you to change your code.
|
||||
|
||||
Create a consistent, branded login experience by customizing the login page appearance and behavior from the Auth0 Dashboard including the logo and colors.
|
||||
For more advanced use cases, you can change the code of each page.
|
||||
|
||||
To learn more about the differences between Universal Login and embedded login, read Centralized Universal Login vs. Embedded Login.
|
||||
|
||||
export const Page = ({ children }) => <Layout meta={meta} children={children} />
|
||||
|
||||
export default Page
|
||||
@@ -1 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 298"><g fill="none" fill-rule="nonzero"><path fill="#00C58E" d="M227.92099 82.07407l-13.6889 23.7037-46.8148-81.08641L23.7037 273.58025h97.3037c0 13.0912 10.61252 23.7037 23.70371 23.7037H23.70371c-8.46771 0-16.29145-4.52017-20.5246-11.85382-4.23315-7.33366-4.23272-16.36849.00114-23.70174L146.89383 12.83951c4.23415-7.33433 12.0596-11.85252 20.5284-11.85252 8.46878 0 16.29423 4.51819 20.52839 11.85252l39.97037 69.23456z"/><path fill="#2F495E" d="M331.6642 261.7284l-90.05432-155.95062-13.6889-23.7037-13.68888 23.7037-90.04445 155.95061c-4.23385 7.33325-4.23428 16.36808-.00113 23.70174 4.23314 7.33365 12.05689 11.85382 20.5246 11.85382h166.4c8.46946 0 16.29644-4.51525 20.532-11.84955 4.23555-7.3343 4.23606-16.37123.00132-23.706h.01976zM144.7111 273.58024L227.921 129.48148l83.19012 144.09877h-166.4z"/><path fill="#108775" d="M396.04938 285.4321c-4.23344 7.33254-12.05656 11.85185-20.52345 11.85185H311.1111c13.0912 0 23.7037-10.6125 23.7037-23.7037h40.66173L260.09877 73.74815l-18.4889 32.02963-13.68888-23.7037L239.5753 61.8963c4.23416-7.33433 12.0596-11.85252 20.5284-11.85252 8.46879 0 16.29423 4.51819 20.52839 11.85252l115.41728 199.8321c4.23426 7.33395 4.23426 16.36975 0 23.7037z"/></g></svg>
|
||||
<svg width="61" height="40" viewBox="0 0 61 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M33.9971 39.539H56.5247C57.2402 39.539 57.9432 39.3564 58.5628 39.0093C59.1825 38.6623 59.697 38.1631 60.0546 37.5619C60.4122 36.9608 60.6003 36.2789 60.6 35.5849C60.5997 34.8908 60.411 34.2091 60.0528 33.6083L44.9239 8.1904C44.5663 7.5894 44.0519 7.09032 43.4324 6.74332C42.8129 6.39632 42.1101 6.21363 41.3947 6.21363C40.6793 6.21363 39.9766 6.39632 39.357 6.74332C38.7375 7.09032 38.2231 7.5894 37.8655 8.1904L33.9971 14.6939L26.4338 1.97648C26.0759 1.37553 25.5613 0.876518 24.9416 0.52958C24.3219 0.182643 23.619 0 22.9035 0C22.188 0 21.4851 0.182643 20.8654 0.52958C20.2457 0.876518 19.7311 1.37553 19.3732 1.97648L0.547184 33.6083C0.189033 34.2091 0.00031171 34.8908 3.85805e-07 35.5849C-0.000310938 36.2789 0.187799 36.9608 0.54541 37.5619C0.903021 38.1631 1.41752 38.6623 2.03717 39.0093C2.65681 39.3564 3.35975 39.539 4.07528 39.539H18.2162C23.819 39.539 27.9509 37.1518 30.794 32.4945L37.6965 20.8993L41.3936 14.6939L52.4895 33.3335H37.6965L33.9971 39.539ZM17.9857 33.3272L8.11711 33.325L22.9101 8.47362L30.2912 20.8993L25.3492 29.2044C23.4611 32.2262 21.3162 33.3272 17.9857 33.3272Z" fill="#00DC82"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
@@ -463,7 +463,7 @@
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://supabase.com/docs/guides/auth/passwordless-login</loc>
|
||||
<loc>https://supabase.com/docs/guides/auth/phone-login</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<changefreq>0.5</changefreq>
|
||||
</url>
|
||||
@@ -1135,25 +1135,25 @@
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://supabase.com/docs/guides/auth/passwordless-login/email-otp-magic-link</loc>
|
||||
<loc>https://supabase.com/docs/guides/auth/auth-magic-link</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<changefreq>0.5</changefreq>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://supabase.com/docs/guides/auth/passwordless-login/phone-sms-otp-messagebird</loc>
|
||||
<loc>https://supabase.com/docs/guides/auth/phone-login/messagebird</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<changefreq>0.5</changefreq>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://supabase.com/docs/guides/auth/passwordless-login/phone-sms-otp-twilio</loc>
|
||||
<loc>https://supabase.com/docs/guides/auth/phone-login/twilio</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<changefreq>0.5</changefreq>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://supabase.com/docs/guides/auth/passwordless-login/phone-sms-otp-vonage</loc>
|
||||
<loc>https://supabase.com/docs/guides/auth/phone-login/vonage</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<changefreq>0.5</changefreq>
|
||||
</url>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
title: 'Multi-factor Authentication via Row Level Security Enforcement'
|
||||
description: MFA Auth with enforcement via RLS
|
||||
author: joel,kangmingtay,stojan
|
||||
image: lw6-auth/mfa-banner.jpg
|
||||
thumb: lw6-auth/mfa-banner.jpg
|
||||
tags:
|
||||
- launch-week
|
||||
- auth
|
||||
- mfa
|
||||
- saml
|
||||
date: '2022-12-14'
|
||||
toc_depth: 3
|
||||
---
|
||||
Today, we’re releasing Multi Factor Authentication for everyone.
|
||||
|
||||
Additionally, in preparation for releasing SAML, we're "dogfooding" the feature with the introduction of Single Sign On (SSO) on our dashboard. Contact us at [growth@supabase.com](mailto:growth@supabase.com) if you want to [enable this in your Enterprise or Pro plan](https://docs.supabase.com/guides/platform/sso/enable-sso).
|
||||
|
||||
## What is MFA?
|
||||
|
||||
Multi-factor authentication (MFA), sometimes called two-factor authentication (2FA), adds an additional layer of security to your application by letting you verify users’ identity through extra steps. This typically consists of something you know, like a password, and something you have, like an authenticator application. We built MFA in response to customer requests - developers wanted enhanced security - be it for compliance, client requirements, or simply for peace of mind. As such, we started by building MFA support for Time-Based One Time Passwords (TOTP).
|
||||
|
||||
|
||||
## An Overview of TOTP
|
||||
|
||||
[TOTP](https://www.rfc-editor.org/rfc/rfc6238) works by generating a unique, one-time password that is valid for a limited amount of time, usually 30 seconds or less. This password is generated using a shared secret key that is known only to the device and Supabase Auth, along with the current time. To exchange the shared secret key, a user scans a QR code generated by the server in order to establish a connection. The QR code can be represented by a URI which conforms to the [Google Authenticator Key URI format](https://github.com/google/google-authenticator/wiki/Key-Uri-Format):
|
||||
|
||||
```bash
|
||||
otpauth://totp/supabase.io:j@supacats.io?algorithm=SHA1&digits=6&issuer=supabase.io&period=30&secret=BFSXQHFB2BGAZIOQWCDBJUF7B54A52JQ
|
||||
```
|
||||
|
||||
The first portion of `otpauth://totp/supabase.io` describes the protocol and issuer while `j@supacats.io` refers to the user. The remaining parameters refer to specifics around OTP generation. In this case, the OTP code is generated using a SHA1 hash of the secret combined with the timestamp and the OTP code is valid for `30s`
|
||||
|
||||
In the event that the user faces difficulties entering a QR code the user can also opt to manually type the secret into the authenticator device.
|
||||
|
||||
## MFA Flows: Enrollment and Verification
|
||||
|
||||
An MFA flow can be broken into two key steps: Enrollment and Verification. During the *Enrollment* process Supabase Auth exchanges a randomly generated secret with the user’s authenticator application. During the *Verification* process, the device makes use of the timestamp together with the secret to produce a six digit code that the server can verify.
|
||||
|
||||

|
||||
**Enrollment**
|
||||
|
||||
To generate a QR code, call the `/enroll` endpoint which returns an SVG encoded QR and the secret. Thereafter, create a challenge by calling the `/challenge` endpoint. Once the user has entered the six digit TOTP code generated by their authenticator app, call the`/verify` endpoint with the corresponding factor and challenge details.
|
||||
|
||||
You might wonder: why the need for the "challenge" step? This step creates an interval between MFA initiation and the action of making a verification. This is useful in cases like Yubikey authentication where a user might need to request a challenge before placing their finger on the device.
|
||||
|
||||
|
||||

|
||||
*Overview of Enrollment Flow*
|
||||
|
||||
**Verification**
|
||||
|
||||
On subsequent logins attempts, redirect a user to an MFA verification after they have completed the conventional sign in process. On the verification page, wait for the user to enter the six digit OTP code from the authenticator application and then call the`/challenge` endpoint followed by the `/verify` endpoint. If a correct code is submitted, a JWT will be created with a few additional fields.
|
||||
|
||||

|
||||
*Overview of Verification Flow*
|
||||
|
||||
## Enforcement via Row Level Security
|
||||
|
||||
Using MFA without enforcing it is like buying an expensive door and never locking it. We love Postgres RLS at Supabase. To support RLS integration, JWTs issued by Supabase Auth now contain two additional pieces of information:
|
||||
|
||||
1. An Authenticator Assurance Level (AAL) claim. Use this to quickly identify the level of identity checks the user has performed. `aal1` is reserved for conventional sign in, while `aal2` is issued only after the user has verified with an additional factor.
|
||||
2. An Authenticator Method Reference (AMR) claim. Use this to identify all of the authentication methods used by the user. This is also useful if you wish to implement step-up login scenarios.
|
||||
|
||||
```jsx
|
||||
{
|
||||
"sub": "8802c1d6-c555-46e3-aacd-b61198b058d9",
|
||||
"email": "j0@supacats.io",
|
||||
"aud": "authenticated",
|
||||
"exp": 1670929371,
|
||||
"aal": "aal2",
|
||||
"amr": [
|
||||
{
|
||||
"method": "password",
|
||||
"timestamp": 1670924394
|
||||
},
|
||||
{
|
||||
"method": "totp",
|
||||
"timestamp": 1670925771
|
||||
}
|
||||
],
|
||||
// ...
|
||||
}
|
||||
````
|
||||
|
||||
The information encoded in these claims can be used for both full enforcement and partial enforcement across database queries.
|
||||
|
||||
```sql
|
||||
create policy "Enforce MFA for all end users."
|
||||
on table_name
|
||||
as restrictive
|
||||
to authenticated
|
||||
using ( auth.jwt()->>'aal' = 'aal2' );
|
||||
```
|
||||
|
||||
*Enforce MFA for all end users*
|
||||
|
||||
```sql
|
||||
create policy "Allow access on table only if user has gone through MFA"
|
||||
on table_name
|
||||
as restrictive -- very important!
|
||||
to authenticated
|
||||
using (
|
||||
array[auth.jwt()->>'aal'] <@ (
|
||||
select
|
||||
case
|
||||
when count(id) > 0 then array['aal2']
|
||||
else array['aal1', 'aal2']
|
||||
end as aal
|
||||
from auth.mfa_factors
|
||||
where auth.uid() = user_id and status = 'verified'
|
||||
));
|
||||
```
|
||||
|
||||
*Enforce MFA for selected users*
|
||||
|
||||
Note that both RLS policies are restrictive. By default, overlapping policies in PostgreSQL are permissive rather than restrictive. This means that RLS policies are combined with an `OR` clause and only one policy needs to pass in order for a row to be operated on. Therefore, we set RLS policies as restrictive to enforce the checks from multiple policies.
|
||||
|
||||
Be mindful of your user’s preference, though. If a user has enabled MFA, they are expecting a higher level of security for their account. Consequently, we recommend that developers enforce MFA across all operations if a user has MFA enabled. You can check out [our MFA guide](https://supabase.com/docs/guides/auth/auth-mfa) for more details about MFA enforcement.
|
||||
|
||||
|
||||
## What's Next
|
||||
|
||||
For starters, we are looking to support WebAuthn and FIDO2 compliant devices such as Yubikeys. We also hope to allow users to receive email notifications when selected MFA actions are triggered. If you have MFA requirements which are not covered here feel free to write to us at support[at]supabase.io .
|
||||
|
||||
We are grateful to our early MFA users for the support and feedback provided throughout this period. In particular, we would like to thank [Fabian Beer](https://madebyfabian.com), [Cogram](https://www.cogram.com), and [Happl](https://happl.com) whose detailed feedback helped to shape our implementation. We would also like to specially thank the community behind the [pquerna/otp](https://github.com/pquerna/otp) and [ajstarks/svgo](https://github.com/ajstarks/svgo) libraries - their work is indispensable to this implementation.
|
||||
@@ -47,16 +47,24 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Day 3",
|
||||
"shipped": false,
|
||||
"title": "Multi-factor Authentication",
|
||||
"shipped": true,
|
||||
"date": "14 Dec 2022",
|
||||
"description": "Day 3",
|
||||
"description": "Multi-factor Authentication",
|
||||
"d": 3,
|
||||
"dd": "Wednesday",
|
||||
"youtube_id": "6bGQotxisoY",
|
||||
"youtube_id": "CGZr5tybW18",
|
||||
"blogpost": "https://supabase.com/blog",
|
||||
"docs": "https://supabase.com/docs",
|
||||
"steps": []
|
||||
"steps": [
|
||||
{
|
||||
"title": "Multi-factor Authentication",
|
||||
"blog": "/blog/mfa-auth-via-rls",
|
||||
"docs": "/docs/guides/auth/auth-mfa",
|
||||
"isNew": false,
|
||||
"description": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Day 4",
|
||||
|
||||
@@ -1,33 +1,32 @@
|
||||
[
|
||||
{
|
||||
"type": "Announcement",
|
||||
"title": "Works With Supabase - announcing our Partner Gallery",
|
||||
"description": "Introducing our Partner Gallery - open source and made with Supabase.",
|
||||
"imgUrl": "images/blog/partner-gallery/partner-showcase-thumb.png",
|
||||
"logoUrl": "images/blog/partner-gallery/partner-showcase-thumb.png",
|
||||
"title": "Supabase Launch Week 6",
|
||||
"description": "One new feature (or more) every day during a week.",
|
||||
"imgUrl": "/images/launchweek/launch-week-6.jpg",
|
||||
"logoUrl": "/images/launchweek/launch-week-6.jpg",
|
||||
"organization": "Supabase",
|
||||
"url": "/blog/2022/04/20/partner-gallery-works-with-supabase",
|
||||
"url": "/launch-week",
|
||||
"postMeta": {
|
||||
"name": "Thor Shcaeff, Alaister Young, Shane Rice",
|
||||
"publishDate": "Apr 20, 2022",
|
||||
"name": "Paul Copplestone",
|
||||
"publishDate": "Dec 12, 2022",
|
||||
"readLength": 5
|
||||
},
|
||||
"ctaText": "Read blog post"
|
||||
"ctaText": "Learn more"
|
||||
},
|
||||
{
|
||||
"type": "Announcement",
|
||||
"title": "Supabase Launch Week 4",
|
||||
"description": "Launch Week 4 had something for everybody: Community Day, GraphQL support, Edge Functions, and more.",
|
||||
"imgUrl": "images/blog/launch-week-4/friday-before/launchweek-4-promo-cal.png",
|
||||
"logoUrl": "images/blog/launch-week-4/friday-before/launchweek-4-promo.png",
|
||||
"title": "The Supabase Content Storm",
|
||||
"description": "We worked with dozens of content creators from around the world to create Supabase-related content and publish everything on the same day!",
|
||||
"imgUrl": "/images/blog/2022-12-06-content-storm/content-storm.jpg",
|
||||
"logoUrl": "/images/blog/2022-12-06-content-storm/content-storm.jpg",
|
||||
"organization": "Supabase",
|
||||
"url": "/blog/2022/03/25/supabase-launch-week-four",
|
||||
"url": "/blog/the-supabase-content-storm",
|
||||
"postMeta": {
|
||||
"name": "Paul Copplestone",
|
||||
"avatarUrl": "https://avatars0.githubusercontent.com/u/10214025?v=4",
|
||||
"publishDate": "Mar 25, 2022",
|
||||
"name": "Ramiro",
|
||||
"publishDate": "Dec 06, 2022",
|
||||
"readLength": 4
|
||||
},
|
||||
"ctaText": "Read blog post"
|
||||
"ctaText": "See the full list"
|
||||
}
|
||||
]
|
||||
@@ -1603,28 +1603,24 @@ module.exports = [
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/auth',
|
||||
destination: '/docs/guides/auth/overview',
|
||||
source: '/docs/guides/auth/passwordless-login',
|
||||
destination: '/docs/guides/auth/phone-login',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/auth/auth-magic-link',
|
||||
destination: '/docs/guides/auth/passworldless-login/email-otp-magic-link',
|
||||
source: '/docs/guides/auth/passwordless-login/phone-sms-otp-messagebird',
|
||||
destination: '/docs/guides/auth/phone-login/messagebird',
|
||||
},
|
||||
{ permanent: true, source: '/docs/guides/auth', destination: '/docs/guides/auth/overview' },
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/auth/auth-messagebird',
|
||||
destination: '/docs/guides/auth/passworldless-login/phone-sms-otp-messagebird',
|
||||
destination: '/docs/guides/auth/phone-login/messagebird',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/auth/auth-twilio',
|
||||
destination: '/docs/guides/auth/passworldless-login/phone-sms-otp-twilio',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/auth/auth-vonage',
|
||||
destination: '/docs/guides/auth/passworldless-login/phone-sms-otp-vonage',
|
||||
source: '/docs/guides/auth-twilio',
|
||||
destination: '/docs/guides/auth/phone-login/twilio',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function launchweek() {
|
||||
const { isDarkMode } = useTheme()
|
||||
const title = 'Launch Week 6'
|
||||
const description = 'Supabase Launch Week 6 | 12-18 Dec 2022'
|
||||
const liveDay = 'Tuesday'
|
||||
const liveDay = 'Wednesday'
|
||||
|
||||
const [supabase] = useState(() =>
|
||||
createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!)
|
||||
@@ -93,7 +93,7 @@ export default function launchweek() {
|
||||
const AccordionHeader = ({ date, day, title, shipped }: any) => {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col sm:flex-row">
|
||||
<div className="flex gap-4 min-w-[320px] items-center">
|
||||
<div className="flex gap-4 min-w-[380px] items-center">
|
||||
{liveDay === day ? (
|
||||
<Badge className="bg-gradient-to-r from-[#0E3737C2] to-[#67947F] hover:to-[#39617D94] dark:hover:to-[#A6FFD899] text-whiteA-1200 dark:text-black font-normal !py-1 !px-4 dark:from-white dark:via-white dark:to-[#1a7a4c75] bg-slate-1200 border-[#DFFFF1]">
|
||||
Currently Happening
|
||||
@@ -264,7 +264,7 @@ export default function launchweek() {
|
||||
justified={false}
|
||||
bordered={false}
|
||||
chevronAlign="right"
|
||||
defaultValue={[day2.d.toString()]}
|
||||
defaultValue={[day1.d.toString(), day2.d.toString(), day3.d.toString()]}
|
||||
>
|
||||
<div className="border-b pb-3">
|
||||
<Accordion.Item
|
||||
@@ -281,7 +281,15 @@ export default function launchweek() {
|
||||
id={day1.d.toString()}
|
||||
>
|
||||
{day1.steps.length > 0 && (
|
||||
<div className="h-[400px] flex flex-col lg:flex-row">
|
||||
<div className="h-[400px] flex flex-col lg:flex-row group/day1 relative overflow-hidden">
|
||||
<div
|
||||
className="absolute group-hover/day1:scale-105 opacity-60 group-hover/day1:opacity-100 w-full h-full -z-10 transition-all duration-500"
|
||||
style={{
|
||||
background: `radial-gradient(650px 150px at 50% 100%, ${
|
||||
isDarkMode ? '#103633' : '#b8e8e7'
|
||||
}, transparent)`,
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
className={`flex flex-col flex-1 items-center gap-5 lg:items-start lg:justify-between border rounded-xl h-full relative overflow-hidden after:opacity- after:absolute after:bg-no-repeat after:bg-[center_bottom] lg:after:bg-[right_15%_top_100px] xl:after:bg-[right_15%_top_60px] after:bg-[length:300px_180px] after:lg:bg-[length:450px_300px] after:xl:bg-[length:528px_367px] after:bg-[url('/images/launchweek/docs-update-bg.png')] after:top-0 after:right-0 after:bottom-0 after:left-0 p-14 text-2xl before:absolute before:w-full before:h-full before:top-52 before:right-0 before:bottom-0 before:left-0 before:border-[#1f3536] before:-z-10 !px-3 sm:!px-14`}
|
||||
>
|
||||
@@ -291,7 +299,6 @@ export default function launchweek() {
|
||||
Redesigned
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<SectionButtons
|
||||
docs={day1.steps[0].docs}
|
||||
blog={day1.steps[0].blog}
|
||||
@@ -321,8 +328,16 @@ export default function launchweek() {
|
||||
{day2.steps.length > 0 && (
|
||||
<div className="h-[800px] lg:h-[400px] flex flex-col gap-5 lg:flex-row">
|
||||
<div
|
||||
className={`flex-1 flex flex-col items-center gap-5 lg:items-start justify-between basis-1/2 lg:basis-2/3 border rounded-xl h-full p-14 xs:text-2xl text-xl text-center bg-no-repeat bg-[url('/images/launchweek/image-processing-bg-light-alt.png')] xs:bg-[url('/images/launchweek/image-processing-bg-light.png')] dark:bg-[url('/images/launchweek/image-processing-bg-alt.png')] xs:dark:bg-[url('/images/launchweek/image-processing-bg.png')] bg-[right_30px_top_35px] lg:bg-[top_25px_right_25px] bg-contain`}
|
||||
className={`relative overflow-hidden group/2 flex-1 flex flex-col items-center gap-5 lg:items-start justify-between basis-1/2 lg:basis-2/3 border rounded-xl h-full p-14 xs:text-2xl text-xl text-center bg-no-repeat bg-[url('/images/launchweek/image-processing-bg-light-alt.png')] xs:bg-[url('/images/launchweek/image-processing-bg-light.png')] dark:bg-[url('/images/launchweek/image-processing-bg-alt.png')] xs:dark:bg-[url('/images/launchweek/image-processing-bg.png')] bg-[right_30px_top_35px] lg:bg-[top_25px_right_25px] bg-contain`}
|
||||
>
|
||||
<div
|
||||
className="top-0 absolute group-hover/2:scale-105 opacity-60 group-hover/2:opacity-100 w-full h-full -z-10 transition-all duration-500"
|
||||
style={{
|
||||
background: `radial-gradient(90% 130px at 80% 0px, ${
|
||||
isDarkMode ? '#103633' : '#b8e8e7'
|
||||
}, transparent)`,
|
||||
}}
|
||||
></div>
|
||||
<div className="flex items-center justify-between flex-col-reverse lg:flex-row lg:justify-start gap-2 text-black dark:text-white">
|
||||
<div>{day2.steps[0].title}</div>
|
||||
<Badge className="!bg-transparent h-fit ml-4 text-sm !py-1 !px-4 text-transparent bg-clip-text bg-gradient-to-r from-[#99bbab] to-[#396f55] border-[#598973] dark:from-white dark:to-[#1a7a4ca1] dark:border-[#DFFFF1]">
|
||||
@@ -332,8 +347,16 @@ export default function launchweek() {
|
||||
<SectionButtons docs={day2.steps[0].docs} blog={day2.steps[0].blog} />
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 flex flex-col items-center justify-between basis-1/2 lg:basis-1/3 border rounded-xl h-full bg-no-repeat p-14 text-2xl bg-[url('/images/launchweek/cdn-caching-bg-light.png')] dark:bg-[url('/images/launchweek/cdn-caching-bg.png')] bg-[top_170px_center] lg:bg-[center_bottom] bg-contain`}
|
||||
className={`relative overflow-hidden group/3 flex-1 flex flex-col items-center justify-between basis-1/2 lg:basis-1/3 border rounded-xl h-full bg-no-repeat p-14 text-2xl bg-[url('/images/launchweek/cdn-caching-bg-light.png')] dark:bg-[url('/images/launchweek/cdn-caching-bg.png')] bg-[top_170px_center] lg:bg-[center_bottom] bg-contain`}
|
||||
>
|
||||
<div
|
||||
className="top-0 absolute group-hover/3:scale-105 opacity-60 group-hover/3:opacity-100 w-full h-full -z-10 transition-all duration-500"
|
||||
style={{
|
||||
background: `radial-gradient(90% 130px at 50% 0px, ${
|
||||
isDarkMode ? '#103633' : '#b8e8e7'
|
||||
}, transparent)`,
|
||||
}}
|
||||
></div>
|
||||
<div className="flex flex-col items-center gap-2 min-w-[300px]">
|
||||
<Badge className="!bg-transparent h-fit text-sm !py-1 !px-4 text-transparent bg-clip-text bg-gradient-to-r from-[#99bbab] to-[#396f55] border-[#598973] dark:from-white dark:to-[#1a7a4ca1] dark:border-[#DFFFF1]">
|
||||
New
|
||||
@@ -364,30 +387,44 @@ export default function launchweek() {
|
||||
id={day3.d.toString()}
|
||||
>
|
||||
{day3.steps.length > 0 && (
|
||||
<div className="h-[800px] lg:h-[400px] flex flex-col gap-5 lg:flex-row">
|
||||
<div className="h-[400px] flex gap-5 group">
|
||||
<div
|
||||
className={`flex flex-col justify-between flex-1 basis-1/2 lg:basis-2/3 border rounded-xl h-full p-14 text-2xl bg-no-repeat bg-contain bg-[url('/images/launchweek/vault-bg-light.png')] dark:bg-[url('/images/launchweek/vault-bg.png')] bg-[center_bottom]`}
|
||||
className={`flex flex-col text-center items-center lg:items-start justify-between flex-1 border rounded-xl h-full bg-no-repeat p-14 text-2xl relative`} // bg-[url('/images/launchweek/mfa-bg-light.png')] dark:bg-[url('/images/launchweek/mfa-bg.png')] bg-[bottom_right_30%] bg-contain
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="absolute top-0 right-0 w-full h-full -z-20 ">
|
||||
<Image
|
||||
src={
|
||||
isDarkMode
|
||||
? '/images/launchweek/mfa-dark.png'
|
||||
: '/images/launchweek/mfa-light.png'
|
||||
}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
quality={100}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute top-0 right-0 w-full h-full -z-10 opacity-0 group-hover:opacity-100 duration-500 transition-all">
|
||||
<Image
|
||||
src={
|
||||
isDarkMode
|
||||
? '/images/launchweek/mfa-dark-hover.png'
|
||||
: '/images/launchweek/mfa-light-hover.png'
|
||||
}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
quality={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between flex-col-reverse lg:flex-row lg:justify-start text-black dark:text-white">
|
||||
<span>{day3.steps[0].title}</span>
|
||||
<Badge className="!bg-transparent h-fit ml-4 text-sm !py-1 !px-4 text-transparent bg-clip-text bg-gradient-to-r from-[#99bbab] to-[#396f55] border-[#598973] dark:from-white dark:to-[#1a7a4ca1] dark:border-[#598973]">
|
||||
New
|
||||
<Badge className="!bg-transparent h-fit ml-4 text-sm !py-1 !px-4 text-transparent bg-clip-text bg-gradient-to-r from-[#99bbab] to-[#396f55] border-[#598973] dark:from-white dark:to-[#1a7a4ca1] dark:border-[#DFFFF1]">
|
||||
Updated
|
||||
</Badge>
|
||||
</div>
|
||||
<SectionButtons docs={day3.steps[0].docs} blog={day3.steps[0].blog} />
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 flex flex-col items-center justify-between basis-1/2 lg:basis-1/3 border rounded-xl h-full bg-no-repeat p-14 text-2xl bg-[url('/images/launchweek/encryption-bg-light.png')] dark:bg-[url('/images/launchweek/encryption-bg.svg')] bg-contain bg-[center_center] shadow-[inset_0px_130px_50px_-52p_rgb(10,31,30)]`}
|
||||
>
|
||||
{/* inset 0px 130px 50px -52px #121f1e; */}
|
||||
<div className="flex flex-col items-center gap-2 min-w-[300px]">
|
||||
<Badge className="!bg-transparent h-fit text-sm !py-1 !px-4 text-transparent bg-clip-text bg-gradient-to-r from-[#99bbab] to-[#396f55] border-[#598973] dark:from-white dark:to-[#1a7a4ca1] dark:border-[#598973]">
|
||||
New
|
||||
</Badge>
|
||||
<span className="text-center">{day3.steps[1].title}</span>
|
||||
</div>
|
||||
<SectionButtons docs={day3.steps[1].docs} blog={day3.steps[1].blog} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Accordion.Item>
|
||||
@@ -407,18 +444,30 @@ export default function launchweek() {
|
||||
id={day4.d.toString()}
|
||||
>
|
||||
{day4.steps.length > 0 && (
|
||||
<div className="h-[400px] flex gap-5">
|
||||
<div className="h-[800px] lg:h-[400px] flex flex-col gap-5 lg:flex-row">
|
||||
<div
|
||||
className={`flex flex-col justify-between flex-1 border rounded-xl h-full bg-no-repeat p-14 text-2xl bg-[url('/images/launchweek/mfa-bg-light.png')] dark:bg-[url('/images/launchweek/mfa-bg.png')] bg-[bottom_right_30%] bg-contain`}
|
||||
className={`flex flex-col justify-between flex-1 basis-1/2 lg:basis-2/3 border rounded-xl h-full p-14 text-2xl bg-no-repeat bg-contain bg-[url('/images/launchweek/vault-bg-light.png')] dark:bg-[url('/images/launchweek/vault-bg.png')] bg-[center_bottom]`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span>{day4.steps[0].title}</span>
|
||||
<Badge className="!bg-transparent h-fit ml-4 text-sm !py-1 !px-4 text-transparent bg-clip-text bg-gradient-to-r from-white to-[#1a7a4ca1] border-[#598973]">
|
||||
Updated
|
||||
<Badge className="!bg-transparent h-fit ml-4 text-sm !py-1 !px-4 text-transparent bg-clip-text bg-gradient-to-r from-[#99bbab] to-[#396f55] border-[#598973] dark:from-white dark:to-[#1a7a4ca1] dark:border-[#598973]">
|
||||
New
|
||||
</Badge>
|
||||
</div>
|
||||
<SectionButtons docs={day4.steps[0].docs} blog={day4.steps[0].blog} />
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 flex flex-col items-center justify-between basis-1/2 lg:basis-1/3 border rounded-xl h-full bg-no-repeat p-14 text-2xl bg-[url('/images/launchweek/encryption-bg-light.png')] dark:bg-[url('/images/launchweek/encryption-bg.svg')] bg-contain bg-[center_center] shadow-[inset_0px_130px_50px_-52p_rgb(10,31,30)]`}
|
||||
>
|
||||
{/* inset 0px 130px 50px -52px #121f1e; */}
|
||||
<div className="flex flex-col items-center gap-2 min-w-[300px]">
|
||||
<Badge className="!bg-transparent h-fit text-sm !py-1 !px-4 text-transparent bg-clip-text bg-gradient-to-r from-[#99bbab] to-[#396f55] border-[#598973] dark:from-white dark:to-[#1a7a4ca1] dark:border-[#598973]">
|
||||
New
|
||||
</Badge>
|
||||
<span className="text-center">{day4.steps[1].title}</span>
|
||||
</div>
|
||||
<SectionButtons docs={day4.steps[1].docs} blog={day4.steps[1].blog} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Accordion.Item>
|
||||
|
||||
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 434 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 86 KiB |
@@ -1 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 298"><g fill="none" fill-rule="nonzero"><path fill="#00C58E" d="M227.92099 82.07407l-13.6889 23.7037-46.8148-81.08641L23.7037 273.58025h97.3037c0 13.0912 10.61252 23.7037 23.70371 23.7037H23.70371c-8.46771 0-16.29145-4.52017-20.5246-11.85382-4.23315-7.33366-4.23272-16.36849.00114-23.70174L146.89383 12.83951c4.23415-7.33433 12.0596-11.85252 20.5284-11.85252 8.46878 0 16.29423 4.51819 20.52839 11.85252l39.97037 69.23456z"/><path fill="#2F495E" d="M331.6642 261.7284l-90.05432-155.95062-13.6889-23.7037-13.68888 23.7037-90.04445 155.95061c-4.23385 7.33325-4.23428 16.36808-.00113 23.70174 4.23314 7.33365 12.05689 11.85382 20.5246 11.85382h166.4c8.46946 0 16.29644-4.51525 20.532-11.84955 4.23555-7.3343 4.23606-16.37123.00132-23.706h.01976zM144.7111 273.58024L227.921 129.48148l83.19012 144.09877h-166.4z"/><path fill="#108775" d="M396.04938 285.4321c-4.23344 7.33254-12.05656 11.85185-20.52345 11.85185H311.1111c13.0912 0 23.7037-10.6125 23.7037-23.7037h40.66173L260.09877 73.74815l-18.4889 32.02963-13.68888-23.7037L239.5753 61.8963c4.23416-7.33433 12.0596-11.85252 20.5284-11.85252 8.46879 0 16.29423 4.51819 20.52839 11.85252l115.41728 199.8321c4.23426 7.33395 4.23426 16.36975 0 23.7037z"/></g></svg>
|
||||
<svg width="61" height="40" viewBox="0 0 61 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M33.9971 39.539H56.5247C57.2402 39.539 57.9432 39.3564 58.5628 39.0093C59.1825 38.6623 59.697 38.1631 60.0546 37.5619C60.4122 36.9608 60.6003 36.2789 60.6 35.5849C60.5997 34.8908 60.411 34.2091 60.0528 33.6083L44.9239 8.1904C44.5663 7.5894 44.0519 7.09032 43.4324 6.74332C42.8129 6.39632 42.1101 6.21363 41.3947 6.21363C40.6793 6.21363 39.9766 6.39632 39.357 6.74332C38.7375 7.09032 38.2231 7.5894 37.8655 8.1904L33.9971 14.6939L26.4338 1.97648C26.0759 1.37553 25.5613 0.876518 24.9416 0.52958C24.3219 0.182643 23.619 0 22.9035 0C22.188 0 21.4851 0.182643 20.8654 0.52958C20.2457 0.876518 19.7311 1.37553 19.3732 1.97648L0.547184 33.6083C0.189033 34.2091 0.00031171 34.8908 3.85805e-07 35.5849C-0.000310938 36.2789 0.187799 36.9608 0.54541 37.5619C0.903021 38.1631 1.41752 38.6623 2.03717 39.0093C2.65681 39.3564 3.35975 39.539 4.07528 39.539H18.2162C23.819 39.539 27.9509 37.1518 30.794 32.4945L37.6965 20.8993L41.3936 14.6939L52.4895 33.3335H37.6965L33.9971 39.539ZM17.9857 33.3272L8.11711 33.325L22.9101 8.47362L30.2912 20.8993L25.3492 29.2044C23.4611 32.2262 21.3162 33.3272 17.9857 33.3272Z" fill="#00DC82"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
@@ -74,4 +74,4 @@ STUDIO_DEFAULT_ORGANIZATION=Default Organization
|
||||
STUDIO_DEFAULT_PROJECT=Default Project
|
||||
|
||||
STUDIO_PORT=3000
|
||||
SUPABASE_PUBLIC_URL=https://localhost:8443 # replace if you intend to use Studio outside of localhost
|
||||
SUPABASE_PUBLIC_URL=http://localhost:8000 # replace if you intend to use Studio outside of localhost
|
||||
@@ -9,7 +9,7 @@ version: "3.8"
|
||||
services:
|
||||
studio:
|
||||
container_name: supabase-studio
|
||||
image: supabase/studio:0.22.08
|
||||
image: supabase/studio:20221214-4eecc99
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- ${STUDIO_PORT}:3000/tcp
|
||||
|
||||
@@ -182,7 +182,7 @@ export interface TextAreaProps
|
||||
descriptionText?: string
|
||||
error?: string
|
||||
icon?: any
|
||||
label?: string
|
||||
label?: string | React.ReactNode
|
||||
afterLabel?: string
|
||||
beforeLabel?: string
|
||||
labelOptional?: string
|
||||
|
||||
@@ -163,7 +163,7 @@ export function ColumnHeader<R>({
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
<ColumnMenu column={column} />
|
||||
<ColumnMenu column={column} isEncrypted={isEncrypted} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CalculatedColumn } from '@supabase/react-data-grid'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
@@ -14,9 +15,10 @@ import { useDispatch, useTrackedState } from '../../store'
|
||||
|
||||
type ColumnMenuProps = {
|
||||
column: CalculatedColumn<any, unknown>
|
||||
isEncrypted?: boolean
|
||||
}
|
||||
|
||||
const ColumnMenu: React.FC<ColumnMenuProps> = ({ column }) => {
|
||||
const ColumnMenu: React.FC<ColumnMenuProps> = ({ column, isEncrypted }) => {
|
||||
const state = useTrackedState()
|
||||
const dispatch = useDispatch()
|
||||
const { onEditColumn: onEditColumnFunc, onDeleteColumn: onDeleteColumnFunc } = state
|
||||
@@ -43,9 +45,32 @@ const ColumnMenu: React.FC<ColumnMenuProps> = ({ column }) => {
|
||||
return (
|
||||
<>
|
||||
{state.editable && onEditColumn !== undefined && (
|
||||
<Dropdown.Item onClick={onEditColumn} icon={<IconEdit size="tiny" />}>
|
||||
Edit column
|
||||
</Dropdown.Item>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger className={`w-full ${isEncrypted ? 'opacity-50' : ''}`}>
|
||||
<Dropdown.Item
|
||||
onClick={onEditColumn}
|
||||
disabled={isEncrypted}
|
||||
icon={<IconEdit size="tiny" />}
|
||||
>
|
||||
Edit column
|
||||
</Dropdown.Item>
|
||||
</Tooltip.Trigger>
|
||||
{isEncrypted && (
|
||||
<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">
|
||||
Encrypted columns cannot be edited
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
<Dropdown.Item
|
||||
onClick={column.frozen ? onUnfreezeColumn : onFreezeColumn}
|
||||
|
||||
@@ -57,7 +57,7 @@ export type ColumnType =
|
||||
|
||||
export interface ColumnHeaderProps<R> extends HeaderRendererProps<R> {
|
||||
columnType: ColumnType
|
||||
isPrimaryKey: Boolean | undefined
|
||||
isEncrypted: Boolean | undefined
|
||||
isPrimaryKey: boolean | undefined
|
||||
isEncrypted: boolean | undefined
|
||||
format: string
|
||||
}
|
||||
@@ -15,10 +15,11 @@ import {
|
||||
IconCheckCircle,
|
||||
IconExternalLink,
|
||||
IconLoader,
|
||||
IconHelpCircle,
|
||||
} from 'ui'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import { Wrapper } from './Wrappers.types'
|
||||
import { ServerOption, 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'
|
||||
@@ -35,39 +36,90 @@ interface Props {
|
||||
onOpen: (wrapper: string) => void
|
||||
}
|
||||
|
||||
const InputField: FC<{ option: any; value: any; error: any; onChange: any }> = ({
|
||||
option,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
}: any) => {
|
||||
const InputField: FC<{
|
||||
option: ServerOption
|
||||
value: any
|
||||
error: any
|
||||
onChange: (e: any) => void
|
||||
}> = ({ option, value, error, onChange }) => {
|
||||
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)}
|
||||
/>
|
||||
|
||||
if (option.isTextArea) {
|
||||
return (
|
||||
<div className="text-area-text-sm text-area-resize-none">
|
||||
<Input.TextArea
|
||||
key={option.name}
|
||||
id={option.name}
|
||||
name={option.name}
|
||||
label={
|
||||
<div className="flex items-center space-x-2">
|
||||
<p>{option.label}</p>
|
||||
{option.urlHelper !== undefined && (
|
||||
<Link href={option.urlHelper}>
|
||||
<a target="_blank">
|
||||
<IconHelpCircle
|
||||
strokeWidth={2}
|
||||
size={14}
|
||||
className="text-scale-1000 hover:text-scale-1200 cursor-pointer transition"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
defaultValue={option.defaultValue ?? ''}
|
||||
required={option.required ?? false}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
className="input-mono"
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Input
|
||||
key={option.name}
|
||||
id={option.name}
|
||||
name={option.name}
|
||||
label={
|
||||
<div className="flex items-center space-x-2">
|
||||
<p>{option.label}</p>
|
||||
{option.urlHelper !== undefined && (
|
||||
<Link href={option.urlHelper}>
|
||||
<a target="_blank">
|
||||
<IconHelpCircle
|
||||
strokeWidth={2}
|
||||
size={14}
|
||||
className="text-scale-1000 hover:text-scale-1200 cursor-pointer transition"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
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 }) => {
|
||||
|
||||
@@ -8,7 +8,7 @@ export const WRAPPERS: Wrapper[] = [
|
||||
icon: '/img/icons/stripe-icon.svg',
|
||||
extensionName: 'StripeFdw',
|
||||
label: 'Stripe',
|
||||
docsUrl: 'https://supabase.github.io/wrappers/stripe/',
|
||||
docsUrl: 'https://supabase.com/docs/guides/database/wrappers/stripe',
|
||||
server: {
|
||||
name: 'stripe_server',
|
||||
options: [
|
||||
@@ -18,6 +18,7 @@ export const WRAPPERS: Wrapper[] = [
|
||||
required: true,
|
||||
encrypted: true,
|
||||
hidden: true,
|
||||
urlHelper: 'https://stripe.com/docs/keys',
|
||||
},
|
||||
{
|
||||
name: 'api_url',
|
||||
@@ -412,7 +413,7 @@ export const WRAPPERS: Wrapper[] = [
|
||||
icon: '/img/icons/firebase-icon.svg',
|
||||
extensionName: 'FirebaseFdw',
|
||||
label: 'Firebase',
|
||||
docsUrl: 'https://supabase.github.io/wrappers/firebase/',
|
||||
docsUrl: 'https://supabase.com/docs/guides/database/wrappers/firebase',
|
||||
server: {
|
||||
name: 'firebase_server',
|
||||
options: [
|
||||
@@ -429,6 +430,8 @@ export const WRAPPERS: Wrapper[] = [
|
||||
required: true,
|
||||
encrypted: true,
|
||||
hidden: true,
|
||||
isTextArea: true,
|
||||
urlHelper: 'https://firebase.google.com/docs/admin/setup#initialize-sdk',
|
||||
},
|
||||
// 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.
|
||||
|
||||
@@ -189,7 +189,7 @@ const Wrappers = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 my-1 ml-[1px]">
|
||||
<Link href="https://supabase.com/docs">
|
||||
<Link href="https://supabase.com/docs/guides/database/wrappers">
|
||||
<a target="_blank">
|
||||
<Button type="default" icon={<IconExternalLink />}>
|
||||
About Wrappers
|
||||
@@ -200,7 +200,7 @@ const Wrappers = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link href="https://supabase.com/docs">
|
||||
<Link href="https://supabase.com/docs/guides/database/wrappers">
|
||||
<a target="_blank">
|
||||
<Button type="default" icon={<IconExternalLink />}>
|
||||
About Wrappers
|
||||
|
||||
@@ -16,6 +16,8 @@ export type ServerOption = {
|
||||
required: boolean
|
||||
encrypted: boolean
|
||||
hidden: boolean
|
||||
isTextArea?: boolean
|
||||
urlHelper?: string
|
||||
defaultValue?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ const VaultToggle: FC<Props> = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 my-1 ml-[1px]">
|
||||
<Link href="https://supabase.com/docs">
|
||||
<Link href="https://supabase.com/docs/guides/database/vault">
|
||||
<a target="_blank">
|
||||
<Button type="default" icon={<IconExternalLink />}>
|
||||
About Vault
|
||||
@@ -108,7 +108,7 @@ const VaultToggle: FC<Props> = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link href="https://supabase.com/docs">
|
||||
<Link href="https://supabase.com/docs/guides/database/vault">
|
||||
<a target="_blank">
|
||||
<Button type="default" icon={<IconExternalLink />}>
|
||||
About Vault
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import { FC } from 'react'
|
||||
import { IconAlertCircle } from 'ui'
|
||||
|
||||
interface Props {
|
||||
id: string | number
|
||||
@@ -7,7 +9,12 @@ interface Props {
|
||||
const NotFoundState: FC<Props> = ({ id }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p>Oops! Unable to find your table with the ID {id}</p>
|
||||
<div className="w-[400px]">
|
||||
<InformationBox
|
||||
icon={<IconAlertCircle strokeWidth={2} />}
|
||||
title={`Unable to find your table with ID ${id}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { find, isUndefined } from 'lodash'
|
||||
import { PostgresColumn, PostgresTable } from '@supabase/postgres-meta'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { checkPermissions, useStore } from 'hooks'
|
||||
import { checkPermissions, useFlag, useStore } from 'hooks'
|
||||
import GridHeaderActions from './GridHeaderActions'
|
||||
import NotFoundState from './NotFoundState'
|
||||
import SidePanelEditor from './SidePanelEditor'
|
||||
@@ -60,14 +60,15 @@ const TableGridEditor: FC<Props> = ({
|
||||
const projectRef = ui.selectedProject?.ref
|
||||
|
||||
const [encryptedColumns, setEncryptedColumns] = useState([])
|
||||
const isVaultEnabled = useFlag('vaultExtension')
|
||||
|
||||
const getEncryptedColumns = async (table: any) => {
|
||||
const columns = await vault.listEncryptedColumns(table.name)
|
||||
const columns = await vault.listEncryptedColumns(table.schema, table.name)
|
||||
setEncryptedColumns(columns)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTable !== undefined && selectedTable.id !== undefined) {
|
||||
if (selectedTable !== undefined && selectedTable.id !== undefined && isVaultEnabled) {
|
||||
getEncryptedColumns(selectedTable)
|
||||
}
|
||||
}, [selectedTable?.id])
|
||||
@@ -81,20 +82,23 @@ const TableGridEditor: FC<Props> = ({
|
||||
// @ts-ignore
|
||||
const schema = meta.schemas.list().find((schema) => schema.name === selectedSchema)
|
||||
const isViewSelected = Object.keys(selectedTable).length === 2
|
||||
const isForeignTableSelected = meta.foreignTables.byId(selectedTable.id) !== undefined
|
||||
const isLocked = meta.excludedSchemas.includes(schema?.name ?? '')
|
||||
const canUpdateTables = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables')
|
||||
const canEditViaTableEditor = !isViewSelected && !isForeignTableSelected && !isLocked
|
||||
|
||||
const gridTable = !isViewSelected
|
||||
? parseSupaTable(
|
||||
{
|
||||
table: selectedTable as PostgresTable,
|
||||
columns: (selectedTable as PostgresTable).columns,
|
||||
primaryKeys: (selectedTable as PostgresTable).primary_keys,
|
||||
relationships: (selectedTable as PostgresTable).relationships,
|
||||
},
|
||||
encryptedColumns
|
||||
)
|
||||
: (selectedTable as SchemaView).name
|
||||
const gridTable =
|
||||
!isViewSelected && !isForeignTableSelected
|
||||
? parseSupaTable(
|
||||
{
|
||||
table: selectedTable as PostgresTable,
|
||||
columns: (selectedTable as PostgresTable).columns,
|
||||
primaryKeys: (selectedTable as PostgresTable).primary_keys,
|
||||
relationships: (selectedTable as PostgresTable).relationships,
|
||||
},
|
||||
encryptedColumns
|
||||
)
|
||||
: (selectedTable as SchemaView).name
|
||||
|
||||
const gridKey = `${selectedTable.schema}_${selectedTable.name}`
|
||||
|
||||
@@ -161,14 +165,11 @@ const TableGridEditor: FC<Props> = ({
|
||||
theme={theme}
|
||||
gridProps={{ height: '100%' }}
|
||||
storageRef={projectRef}
|
||||
editable={canUpdateTables && !isViewSelected && !isLocked}
|
||||
editable={canUpdateTables && canEditViaTableEditor}
|
||||
schema={selectedTable.schema}
|
||||
table={gridTable}
|
||||
headerActions={
|
||||
!isViewSelected &&
|
||||
selectedTable.schema === 'public' && (
|
||||
<GridHeaderActions table={selectedTable as PostgresTable} />
|
||||
)
|
||||
canEditViaTableEditor && <GridHeaderActions table={selectedTable as PostgresTable} />
|
||||
}
|
||||
onAddColumn={onAddColumn}
|
||||
onEditColumn={onSelectEditColumn}
|
||||
|
||||
@@ -20,14 +20,12 @@ export const generateDatabaseMenu = (project?: Project): ProductMenuGroup[] => {
|
||||
key: 'triggers',
|
||||
url: `/project/${ref}/database/triggers`,
|
||||
items: [],
|
||||
isPreview: false,
|
||||
},
|
||||
{
|
||||
name: 'Functions',
|
||||
key: 'functions',
|
||||
url: `/project/${ref}/database/functions`,
|
||||
items: [],
|
||||
isPreview: false,
|
||||
},
|
||||
{
|
||||
name: 'Extensions',
|
||||
@@ -35,16 +33,6 @@ 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',
|
||||
@@ -59,7 +47,18 @@ export const generateDatabaseMenu = (project?: Project): ProductMenuGroup[] => {
|
||||
key: 'hooks',
|
||||
url: `/project/${ref}/database/hooks`,
|
||||
items: [],
|
||||
isPreview: true,
|
||||
label: 'ALPHA',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(foreignDataWrappersEnabled
|
||||
? [
|
||||
{
|
||||
name: 'Wrappers',
|
||||
key: 'wrappers',
|
||||
url: `/project/${ref}/database/wrappers`,
|
||||
items: [],
|
||||
label: 'ALPHA',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -58,6 +58,7 @@ export const generateSettingsMenu = (ref: string, project?: ProjectBase): Produc
|
||||
key: 'vault',
|
||||
url: isProjectBuilding ? buildingUrl : `/project/${ref}/settings/vault/secrets`,
|
||||
items: [],
|
||||
label: 'BETA',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -46,6 +46,7 @@ const TableEditorLayout: FC<Props> = ({
|
||||
meta.policies.load()
|
||||
meta.publications.load()
|
||||
meta.extensions.load()
|
||||
meta.foreignTables.load()
|
||||
}
|
||||
}, [ui.selectedProject?.ref])
|
||||
|
||||
|
||||
@@ -53,6 +53,9 @@ const TableEditorMenu: FC<Props> = ({
|
||||
const tables: PostgresTable[] = meta.tables.list(
|
||||
(table: PostgresTable) => table.schema === selectedSchema
|
||||
)
|
||||
const foreignTables: Partial<PostgresTable>[] = meta.foreignTables.list(
|
||||
(table: PostgresTable) => table.schema === selectedSchema
|
||||
)
|
||||
|
||||
// @ts-ignore
|
||||
const schema = schemas.find((schema) => schema.name === selectedSchema)
|
||||
@@ -82,16 +85,25 @@ const TableEditorMenu: FC<Props> = ({
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
|
||||
const schemaTables =
|
||||
const filteredTables =
|
||||
searchText.length === 0
|
||||
? tables
|
||||
: // @ts-ignore
|
||||
tables.filter((table) => table.name.toLowerCase().includes(searchText.toLowerCase()))
|
||||
|
||||
const filteredSchemaViews =
|
||||
const filteredViews =
|
||||
searchText.length === 0
|
||||
? meta.schemas.views || []
|
||||
: (meta.schemas.views || []).filter((view) => view.name.includes(searchText))
|
||||
: (meta.schemas.views || []).filter((view) =>
|
||||
view.name.toLowerCase().includes(searchText.toLowerCase())
|
||||
)
|
||||
|
||||
const filteredForeignTables =
|
||||
searchText.length === 0
|
||||
? foreignTables
|
||||
: foreignTables.filter((table) =>
|
||||
(table?.name ?? '').toLowerCase().includes(searchText.toLowerCase())
|
||||
)
|
||||
|
||||
const [protectedSchemas, openSchemas] = partition(schemas, (schema) =>
|
||||
meta.excludedSchemas.includes(schema?.name ?? '')
|
||||
@@ -99,9 +111,12 @@ const TableEditorMenu: FC<Props> = ({
|
||||
const isLocked = protectedSchemas.some((s) => s.id === schema?.id)
|
||||
|
||||
return (
|
||||
<div className="my-6 mx-4 flex flex-grow flex-col space-y-6">
|
||||
<div
|
||||
className="pt-6 flex flex-grow flex-col space-y-6"
|
||||
style={{ maxHeight: 'calc(100vh - 48px)' }}
|
||||
>
|
||||
{/* Schema selection dropdown */}
|
||||
<div className="px-3">
|
||||
<div className="px-3 mx-4">
|
||||
{!meta.schemas.isInitialized ? (
|
||||
<div className="flex h-[26px] items-center space-x-3 rounded border border-gray-500 px-3">
|
||||
<IconLoader className="animate-spin" size={12} />
|
||||
@@ -153,7 +168,7 @@ const TableEditorMenu: FC<Props> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1 mx-4">
|
||||
{!isLocked && (
|
||||
<div className="px-3">
|
||||
{/* Add new table button */}
|
||||
@@ -214,110 +229,150 @@ const TableEditorMenu: FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List of tables belonging to selected schema */}
|
||||
{schemaTables.length > 0 && (
|
||||
<Menu type="pills">
|
||||
<Menu.Group
|
||||
// @ts-ignore
|
||||
title={
|
||||
<>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span>All tables</span>
|
||||
<button className="cursor-pointer" onClick={refreshTables}>
|
||||
<IconRefreshCw className={isRefreshing ? 'animate-spin' : ''} size={14} />
|
||||
</button>
|
||||
</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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{schemaTables.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/${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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</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 viewId = Base64.encode(JSON.stringify(view))
|
||||
const isActive = id === viewId
|
||||
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={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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Menu>
|
||||
)}
|
||||
</Menu>
|
||||
)}
|
||||
|
||||
{/* List of views belonging to selected schema */}
|
||||
{filteredSchemaViews.length > 0 && (
|
||||
<Menu type="pills">
|
||||
<Menu.Group
|
||||
// @ts-ignore
|
||||
title={
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span>All views</span>
|
||||
</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>
|
||||
}
|
||||
/>
|
||||
|
||||
{meta.schemas.views.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>
|
||||
)}
|
||||
{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>
|
||||
|
||||
{searchText.length > 0 && schemaTables.length === 0 && filteredSchemaViews.length === 0 && (
|
||||
<div className="mx-3 space-y-1 rounded-md border border-scale-400 bg-scale-300 py-3 px-4">
|
||||
{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 results found</p>
|
||||
<p className="text-xs text-scale-1100">
|
||||
There are no tables or views that match your search
|
||||
@@ -325,8 +380,8 @@ const TableEditorMenu: FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchText.length === 0 && schemaTables.length === 0 && filteredSchemaViews.length === 0 && (
|
||||
<div className="mx-3 space-y-1 rounded-md border border-scale-400 bg-scale-300 py-3 px-4">
|
||||
{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>
|
||||
|
||||
@@ -37,7 +37,7 @@ const ProductMenu: FC<Props> = ({ page, menu }) => {
|
||||
isActive={page === item.key}
|
||||
isExternal={item.isExternal}
|
||||
target={item.isExternal ? '_blank' : '_self'}
|
||||
isPreview={item.isPreview}
|
||||
label={item.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -14,5 +14,5 @@ export interface ProductMenuGroupItem {
|
||||
icon?: ReactNode
|
||||
isExternal?: boolean
|
||||
disabled?: boolean
|
||||
isPreview?: boolean
|
||||
label?: string
|
||||
}
|
||||
@@ -13,7 +13,7 @@ interface Props {
|
||||
onClick?: () => void
|
||||
textClassName?: string
|
||||
hoverText?: string
|
||||
isPreview?: boolean
|
||||
label?: string
|
||||
}
|
||||
|
||||
const ProductMenuItem: FC<Props> = ({
|
||||
@@ -27,7 +27,7 @@ const ProductMenuItem: FC<Props> = ({
|
||||
onClick,
|
||||
textClassName = '',
|
||||
hoverText = '',
|
||||
isPreview = false,
|
||||
label,
|
||||
}) => {
|
||||
const menuItem = (
|
||||
<Menu.Item icon={icon} rounded active={isActive} onClick={onClick}>
|
||||
@@ -37,8 +37,8 @@ const ProductMenuItem: FC<Props> = ({
|
||||
className={'flex items-center gap-2 truncate ' + textClassName}
|
||||
>
|
||||
{name}{' '}
|
||||
{isPreview && (
|
||||
<span className="uppercase text-orange-800 text-xs font-normal">Alpha</span>
|
||||
{label !== undefined && (
|
||||
<span className="text-orange-800 text-xs font-normal">{label}</span>
|
||||
)}
|
||||
</span>
|
||||
{action}
|
||||
|
||||
@@ -37,9 +37,11 @@ const TableEditorPage: NextPage = () => {
|
||||
|
||||
const projectRef = ui.selectedProject?.ref
|
||||
const tables: PostgresTable[] = meta.tables.list()
|
||||
const foreignTables: Partial<PostgresTable>[] = meta.foreignTables.list()
|
||||
|
||||
const selectedTable = !isNaN(Number(id))
|
||||
? // @ts-ignore
|
||||
tables.find((table) => table.id === Number(id))
|
||||
tables.concat(foreignTables).find((table) => table.id === Number(id))
|
||||
: id !== undefined
|
||||
? tryParseJson(Base64.decode(id))
|
||||
: undefined
|
||||
@@ -125,12 +127,20 @@ const TableEditorPage: NextPage = () => {
|
||||
|
||||
const onConfirmDeleteColumn = async () => {
|
||||
try {
|
||||
const response: any = await meta.columns.del(selectedColumnToDelete!.id, isDeleteWithCascade)
|
||||
if (selectedColumnToDelete === undefined) return
|
||||
|
||||
const response: any = await meta.columns.del(selectedColumnToDelete.id, isDeleteWithCascade)
|
||||
if (response.error) throw response.error
|
||||
|
||||
removeDeletedColumnFromFiltersAndSorts(selectedColumnToDelete!.name)
|
||||
removeDeletedColumnFromFiltersAndSorts(selectedColumnToDelete.name)
|
||||
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `Successfully deleted column "${selectedColumnToDelete.name}"`,
|
||||
})
|
||||
|
||||
await meta.tables.loadById(selectedColumnToDelete!.table_id)
|
||||
if (selectedSchema) await meta.schemas.loadViews(selectedSchema)
|
||||
} catch (error: any) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
@@ -144,7 +154,7 @@ const TableEditorPage: NextPage = () => {
|
||||
|
||||
const onConfirmDeleteTable = async () => {
|
||||
try {
|
||||
if (isUndefined(selectedTableToDelete)) return
|
||||
if (selectedTableToDelete === undefined) return
|
||||
|
||||
const response: any = await meta.tables.del(selectedTableToDelete.id, isDeleteWithCascade)
|
||||
if (response.error) throw response.error
|
||||
@@ -159,7 +169,7 @@ const TableEditorPage: NextPage = () => {
|
||||
}
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `Successfully deleted ${selectedTableToDelete.name}`,
|
||||
message: `Successfully deleted table "${selectedTableToDelete.name}"`,
|
||||
})
|
||||
if (selectedSchema) await meta.schemas.loadViews(selectedSchema)
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { PostgresTable } from '@supabase/postgres-meta'
|
||||
import PostgresMetaInterface from '../common/PostgresMetaInterface'
|
||||
import { IRootStore } from '../RootStore'
|
||||
|
||||
export default class ForeignTableStore extends PostgresMetaInterface<Partial<PostgresTable>> {
|
||||
constructor(
|
||||
rootStore: IRootStore,
|
||||
dataUrl: string,
|
||||
headers?: {
|
||||
[prop: string]: any
|
||||
},
|
||||
options?: { identifier: string }
|
||||
) {
|
||||
super(rootStore, dataUrl, headers, options)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import FunctionsStore from './FunctionsStore'
|
||||
import HooksStore from './HooksStore'
|
||||
import ExtensionsStore from './ExtensionsStore'
|
||||
import TypesStore from './TypesStore'
|
||||
import ForeignTableStore from './ForeignTableStore'
|
||||
|
||||
const BATCH_SIZE = 1000
|
||||
const CHUNK_SIZE = 1024 * 1024 * 0.1 // 0.1MB
|
||||
@@ -51,6 +52,7 @@ export interface IMetaStore {
|
||||
tables: ITableStore
|
||||
columns: IPostgresMetaInterface<PostgresColumn>
|
||||
schemas: ISchemaStore
|
||||
foreignTables: IPostgresMetaInterface<Partial<PostgresTable>>
|
||||
|
||||
hooks: IPostgresMetaInterface<any>
|
||||
roles: IRolesStore
|
||||
@@ -130,6 +132,7 @@ export default class MetaStore implements IMetaStore {
|
||||
tables: TableStore
|
||||
columns: ColumnStore
|
||||
schemas: SchemaStore
|
||||
foreignTables: ForeignTableStore
|
||||
|
||||
hooks: HooksStore
|
||||
roles: RolesStore
|
||||
@@ -182,6 +185,11 @@ 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.foreignTables = new ForeignTableStore(
|
||||
this.rootStore,
|
||||
`${this.baseUrl}/foreign-tables`,
|
||||
this.headers
|
||||
)
|
||||
|
||||
this.roles = new RolesStore(this.rootStore, `${this.baseUrl}/roles`, this.headers)
|
||||
this.policies = new PoliciesStore(this.rootStore, `${this.baseUrl}/policies`, this.headers)
|
||||
@@ -896,6 +904,9 @@ export default class MetaStore implements IMetaStore {
|
||||
this.schemas.setUrl(`${this.baseUrl}/schemas`)
|
||||
this.schemas.setHeaders(this.headers)
|
||||
|
||||
this.foreignTables.setUrl(`${this.baseUrl}/foreign-tables`)
|
||||
this.foreignTables.setHeaders(this.headers)
|
||||
|
||||
this.roles.setUrl(`${this.baseUrl}/roles`)
|
||||
this.roles.setHeaders(this.headers)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface IVaultStore {
|
||||
updateSecret: (id: string, payload: Partial<VaultSecret>) => any
|
||||
deleteSecret: (id: string) => any
|
||||
fetchSecretValue: (id: string) => any
|
||||
listEncryptedColumns: (table: string) => any
|
||||
listEncryptedColumns: (schema: string, table: string) => any
|
||||
}
|
||||
|
||||
interface EncryptionKey {
|
||||
@@ -208,9 +208,10 @@ export default class VaultStore implements IVaultStore {
|
||||
return res[0].decrypted_secret
|
||||
}
|
||||
|
||||
async listEncryptedColumns(table: string) {
|
||||
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}`
|
||||
)
|
||||
|
||||
@@ -216,6 +216,12 @@ div.fixed.inset-0 {
|
||||
}
|
||||
}
|
||||
|
||||
.text-area-resize-none {
|
||||
textarea {
|
||||
@apply resize-none;
|
||||
}
|
||||
}
|
||||
|
||||
.capitalize-sentence:first-letter {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||