diff --git a/.github/workflows/og_images.yml b/.github/workflows/og_images.yml index b03695a3c72..c3cc6568e73 100644 --- a/.github/workflows/og_images.yml +++ b/.github/workflows/og_images.yml @@ -5,7 +5,7 @@ on: branches: - master paths: - - 'supabase/functions/og-images' + - 'supabase/functions/og-images/**' workflow_dispatch: jobs: @@ -18,11 +18,11 @@ jobs: steps: - name: Check out repo - - uses: actions/checkout@v3 + uses: actions/checkout@v3 - name: Setup the Supabase CLI - - uses: supabase/setup-cli@v1 + uses: supabase/setup-cli@v1 with: - version: 1.0.0 + version: latest - run: supabase functions deploy og-images --project-ref $PROJECT_ID --no-verify-jwt diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index f367d6aad97..528afb8db03 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -308,6 +308,17 @@ export const auth = { url: '/guides/auth/social-login', items: [...SocialLoginItems], }, + { + name: 'Enterprise SSO', + url: '/guides/auth/enterprise-sso', + items: [ + { + name: 'SAML 2.0 (Beta)', + url: '/guides/auth/sso/auth-sso-saml', + items: [], + }, + ], + }, { name: 'Email Templates', url: '/guides/auth/auth-email-templates', items: [] }, ], }, diff --git a/apps/docs/pages/guides/auth/enterprise-sso.mdx b/apps/docs/pages/guides/auth/enterprise-sso.mdx new file mode 100644 index 00000000000..fbfb7b73915 --- /dev/null +++ b/apps/docs/pages/guides/auth/enterprise-sso.mdx @@ -0,0 +1,14 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + title: 'Enterprise SSO', + description: 'Learn about Single Sign-On support in Supabase Auth for enterprise applications', +} + +Supabase Auth supports building enterprise applications that require Single Sign-On (SSO) authentication. At this time only [SSO with SAML 2.0](/guides/auth/sso/auth-sso-saml) is supported in an early beta. + +If you are interested in using SAML 2.0 SSO with your Supabase project, please [open a new support ticket](https://app.supabase.com/support/new). + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/auth/sso/auth-sso-saml.mdx b/apps/docs/pages/guides/auth/sso/auth-sso-saml.mdx new file mode 100644 index 00000000000..57654f7fbbc --- /dev/null +++ b/apps/docs/pages/guides/auth/sso/auth-sso-saml.mdx @@ -0,0 +1,316 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'auth-sso-saml', + title: 'Single Sign-On with SAML 2.0', + description: 'Use Single Sign-On (SSO) authentication with SAML 2.0', + video: 'https://www.youtube.com/v/em1cpOAXknM', +} + +Supabase Auth supports enterprise-level Single Sign-On (SSO) for any identity providers compatible with the using the SAML 2.0 protocol. + + +This is an early beta release of these APIs. CLI and Dashboard support for SSO is under development. + +If you are comfortable using these APIs and would like to try out SSO with SAML 2.0 for your project, please [open a support ticket](https://app.supabase.com/support/new). + +These APIs are not expected to change before the feature is generally available, but we do reserve the right to modify them. Projects in the Beta will be notified of any changes. + + + +## Terminology + +The number of SAML and SSO acronyms can often overwhelming. Here's a glossary which you can refer back to at any time: + +- **Identity Provider**, **IdP**, or **IDP** + This is software that manages user accounts at a company or organization. It can verify the identity of a user and exchange that information with your Supabase project. Commonly used identity providers are: Microsoft Active Directory (Azure AD, Microsoft Entra), Okta, Google Workspaces (GSuite), PingIdentity, OneLogin, and many others. +- **Service Provider**, **SP** + This is the software that is asking for user information from an identity provider. In Supabase, this is your project's Auth server. +- **Assertion** + An assertion is a statement issued by an identity provider that contains information about a user. +- **EntityID** + A globally unique ID (usually a URL) that identifies an Identity Provider or Service Provider across the world. +- **NameID** + A unique ID (usually an email address) that identifies a user at an Identity Provider. +- **Metadata** + An XML document that describes the features and configuration of an Identity Provider or Service Provider. It can be as a standalone document or as a URL. Usually (but not always) the `EntityID` is the URL at which you can access the Metadata. +- **Certificate** + Supabase Auth (the Service Provider) trusts assertions from an Identity Provider based on the signature attached to the assertion. The signature is verified according to the certificate present in the Metadata. +- **Assertion Consumer Service (ACS) URL** + This is one of the most important SAML URLs. It is the URL where Supabase Auth will accept assertions from an identity provider. Basically, once the identity provider verifies the user's identity it will redirect to this URL and the redirect request will contain the assertion. +- **Binding (Redirect, POST, or Artifact)** + This is a description of the way an identity provider communicates with Supabase Auth. When using the Redirect binding, the communication occurs using HTTP 301 redirects. When it's `POST`, it's using `POST` requests sent with `
` elements on a page. When using Artifact, it's using a more secure exchange over a Redirect or `POST`. +- **RelayState** + State used by Supabase Auth to hold information about a request to verify the identity of a user. + +## Important SAML 2.0 information + +Below is information about your project's SAML 2.0 configuration which you can share with the company or organization that you're trying to on-board. + +| Name | Value | +| ------------ | --------------------------------------------------------- | +| EntityID | `https://.supabase.co/auth/v1/sso/saml/metadata` | +| Metadata URL | `https://.supabase.co/auth/v1/sso/saml/metadata` | +| ACS URL | `https://.supabase.co/auth/v1/sso/saml/acs` | +| SLO URL | `https://.supabase.co/auth/v1/sso/slo` | +| NameID | Required `emailAddress` or `persistent` | + +Note that SLO (Single Logout) is not supported at this time with Supabase Auth as it is a rarely supported feature by identity providers. However, the URL is registered and advertised for when this does become available. + +Append `?download=true` to the Metadata URL to get a downloadable Metadata XML file. + +### User accounts and identities + +User accounts and identities created via SSO differ from regular (email, phone, password, social login...) accounts in these ways: + +- **No automatic linking.** + Each user account verified using a SSO identity provider will not be automatically linked to existing user accounts in the system. That is, if a user `jane.doe@company.com` had signed up with a password, and then uses their company SSO login with your project, there will be two `jane.doe@company.com` user accounts in the system. +- **Emails are not necessarily unique.** + Given the behavior with no automatic linking, email addresses are no longer a unique identifier for a user account. Please always use the user's UUID to correctly reference user accounts. +- **Sessions may have a maximum duration.** + Depending on the configuration of the identity provider, a login session established with SSO may forcibly log out a user after a certain period of time. + +### Row Level Security + +You can use information about the SSO identity provider in Row Level Security policies. + +Here are some commonly used statements to extract SSO related information from the user's JWT: + +- `auth.jwt()#>>'{amr,0,method}'` + Returns the name of the last method used to verify the identity of this user. With SAML SSO this is `sso/saml`. +- `auth.jwt()#>>'{amr,0,provider}'` + Returns the UUID of the SSO identity provider used by the user to sign-in. +- `auth.jwt()#>>'{user_metadata,iss}'` + Returns the identity provider's SAML 2.0 `EntityID` + + + If you use [Multi-Factor Authentication](/guides/auth/auth-mfa) with SSO, the `amr` array may have + a different method at index `0`! + + +A common use case with SSO is to use the UUID of the identity provider as the identifier for the organization the user belongs to -- frequently known as a tenant. By associating the identity provider's UUID with your tenants, you can use restrictive RLS policies to scope down actions and data that a user is able to access. + +For example, let's say you have a table like: + +```sql +CREATE TABLE + organization_settings ( + -- the organization's unique ID + id UUID NOT NULL PRIMARY KEY, + -- the organization's SSO identity provider + sso_provider_id UUID UNIQUE, + -- name of the organization + name TEXT, + -- billing plan (paid, free, enterprise) + billing_plan TEXT + ); +``` + +You can use the information present in the user's JWT to scope down which rows from this table the user can see, without doing any additional user management: + +```sql +CREATE POLICY "View organization settings." + ON organization_settings + AS RESTRICTIVE + USING ( + sso_provider_id = auth.jwt()#>>'{amr,0,provider}' + ); +``` + +## Managing SAML 2.0 connections + +### Prerequisites + +SSO support with SAML 2.0 is in an early beta release. This guide uses the following software which you need to install on your machine to configure your project: + +- [**cURL**](https://curl.se) + It is typically pre-installed in macOS and GNU/Linux distributions. +- [**jq**](https://stedolan.github.io/jq/) + You can install it with `brew install jq` on macOS or using your distribution's package manager. + +You would need access to two keys -- the `anon` and `service_role` key. You can obtain these on the [Project API Keys](https://app.supabase.com/project/_/settings/api) page in the dashboard. + +We publish an [OpenAPI specification](https://github.com/supabase/gotrue/blob/master/openapi.yaml) which you can refer to at any time. + +### Add a connection + +To establish a connection to a SAML 2.0 Identity Provider (IdP) you will need: + +- A SAML 2.0 Metadata XML file, or a SAML 2.0 Metadata URL pointing to an XML file +- (Optional) Email domains that the organization's IdP uses +- (Optional) Attribute mappings between the user properties of the IdP and the claims stored by Supabase Auth + +You should obtain the SAML 2.0 Metadata XML file or URL from the organization whose IdP you wish to connect. Most SAML 2.0 Identity Providers support the Metadata URL standard, and we recommend using a URL if this is available. + +Commonly used SAML 2.0 Identity Providers that support Metadata URLs: + +- Okta +- Azure AD (Microsoft Entra) +- PingIdentity + +Commonly used SAML 2.0 Identity Providers that only support Metadata XML files: + +- Google Workspaces (GSuite) + +Once you've obtained the SAML 2.0 Metadata XML file or URL you can establish a connection with your project's Supabase Auth server by invoking this API: + +```bash +curl -X POST \ + -H 'Content-Type: application/json' \ + -H 'apikey: ' \ + -H 'Authorization: Bearer ' \ + --data-binary '@/tmp/body.json' \ + 'https://.supabase.co/auth/v1/admin/sso/providers/' +``` + +To create the `/tmp/body.json` file you can use this: + +```bash +jq --null-input \ + --arg metadata_url "https://..." \ + '{ "type": "saml", "metadata_url": $metadata_url, "domains": ["company.com"] }' \ + > /tmp/body.json +``` + +If you wish to use a Metadata XML file instead, you can use: + +```bash +jq --null-input \ + -M \ + --rawfile metadata_file /path/to/metadata.xml \ + '{ "type": "saml", "metadata_xml": $metadata_file, "domains": ["company.com"] }' \ + > /tmp/body.json +``` + +Once you've executed the cURL command with the correct body, you should see details about the registered SAML 2.0 Identity Provider. + +To initiate a sign-in request from your front-end application you can use: + +```typescript +supabase.auth.signInWithSSO({ + domain: 'company.com', +}) +``` + +Which will start the sign-in process using the SSO Identity Provider registered for the `company.com` domain name. If the SSO Identity Provider does not have an associated domain name, you can use `providerId` instead. + +### Understanding attribute mappings + +When a user signs in using the SAML 2.0 Single Sign-On protocol, an XML document called the SAML Assertion is exchanged between the Identity Provider and Supabase Auth. + +This assertion contains information about the user's identity and other authentication information, such as: + +- Unique ID of the user (called `NameID` in SAML) +- Email address +- Name of the user +- Department or organization +- Other attributes present in the users directory managed by the identity provider + +Other than the unique ID of the user, SAML does not make it mandatory that any other attributes appear in the assertion. Identity Providers are configured about what user information is shared with your project. + +Your project can be configured to recognize these attributes and map them into your project's database using a JSON structure. This process is called attribute mapping, and varies according to the configuration of the Identity Provider. + +For example, the following JSON structure configures attribute mapping for the `email` and `first_name` user identity properties. + +```json +{ + "keys": { + "email": { + "name": "mail" + }, + "first_name": { + "name": "givenName" + } + } +} +``` + +You can include this structure in the `POST /auth/v1/admin/sso/providers` call under the `attribute_mapping` property. + +Given a SAML 2.0 assertion that includes these attributes: + +```xml + + + + + jane.doe@company.com + + + + + + + Jane Doe + + + +``` + +Will result in the following claims in the user's identity in the database and JWT: + +```json +{ + "email": "jane.doe@company.com", + "custom_claims": { + "first_name": "Jane Doe" + } +} +``` + +Supabase Auth does not require specifying attribute mappings if you only need access to the user's email. It will attempt to find an email attribute specified in the assertion. All other properties will not be automatically included, and it is those you need to map. + +At this time it is not possible to have users without an email address, so SAML assertions without one will be rejected. + +Most SAML 2.0 identity providers use LDAP attribute names. However, due to their variability and complexity operators of Identity Providers are able to customize both the `Name` and attribute value that is sent to Supabase Auth in an assertion. Please refer to the identity provider's documentation and contact the operator for details on what attributes are mapped for your project. + +### Remove a connection + +Once a connection to an identity provider is established, you can remove it by invoking the `DELETE` method on it: + +```bash +curl -X DELETE \ + -H 'Content-Type: application/json' \ + -H 'apikey: ' \ + -H 'Authorization: Bearer ' \ + 'https://.supabase.co/auth/v1/admin/sso/providers/' +``` + +Once a connection is removed, all user accounts from that identity provider will be immediately logged out. User information will remain in the system, but it will no longer be possible for any of those accounts to be accessed in the future, even if you add the connection again. + +If you need to reassign those user accounts to another identity provider, please [open a support ticket](https://app.supabase.com/support/new). + +### Update a connection + +You may wish to update settings about a connection to a SAML 2.0 identity provider. + +Commonly this is necessary when: + +- Cryptographic keys are rotated or have expired +- Metadata URL has changed, but is the same Identity Provider +- Other SAML 2.0 Metadata attributes have changed, but it is still the same Identity Provider +- You are updating the domains or attribute mapping + +```bash +curl -X PUT \ + -H 'Content-Type: application/json' \ + -H 'apikey: ' \ + -H 'Authorization: Bearer ' \ + --data-binary '@/tmp/body.json' \ + 'https://.supabase.co/auth/v1/admin/sso/providers/' + +``` + +The request body has the same structure as when you're adding a connection to an Identity Provider. + +It is not possible to change the Identity Provider's unique SAML identifier known as `EntityID`. Everything else can be updated. If the SAML `EntityID` of your identity provider has changed, it is regarded as a new identity provider and you will have to register it like a new connection. + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/getting-started/tutorials/with-nextjs.mdx b/apps/docs/pages/guides/getting-started/tutorials/with-nextjs.mdx index bbee6933442..2c2c5de48a5 100644 --- a/apps/docs/pages/guides/getting-started/tutorials/with-nextjs.mdx +++ b/apps/docs/pages/guides/getting-started/tutorials/with-nextjs.mdx @@ -166,7 +166,8 @@ npm install @supabase/auth-ui-react Add the `Auth` component to your home page ```jsx title=pages/index.js -import { Auth, ThemeSupa } from '@supabase/auth-ui-react' +import { Auth } from '@supabase/auth-ui-react' +import { ThemeSupa } from '@supabase/auth-ui-shared' import { useSession, useSupabaseClient } from '@supabase/auth-helpers-react' const Home = () => { @@ -448,7 +449,8 @@ export default function Account({ session }: { session: Session }) { Now that we have all the components in place, let's update `pages/index.js`: ```jsx lines=3,14 title=pages/index.js -import { Auth, ThemeSupa } from '@supabase/auth-ui-react' +import { Auth } from '@supabase/auth-ui-react' +import { ThemeSupa } from '@supabase/auth-ui-shared' import { useSession, useSupabaseClient } from '@supabase/auth-helpers-react' import Account from '../components/Account' diff --git a/apps/docs/pages/guides/getting-started/tutorials/with-sveltekit.mdx b/apps/docs/pages/guides/getting-started/tutorials/with-sveltekit.mdx index 1daf9a881ec..a6be2841872 100644 --- a/apps/docs/pages/guides/getting-started/tutorials/with-sveltekit.mdx +++ b/apps/docs/pages/guides/getting-started/tutorials/with-sveltekit.mdx @@ -26,12 +26,12 @@ We can use the [SvelteKit Skeleton Project](https://kit.svelte.dev/docs) to init an app called `supabase-sveltekit` (for this tutorial we will be using TypeScript): ```bash -npm init svelte@next supabase-sveltekit +npm create svelte@latest supabase-sveltekit cd supabase-sveltekit npm install ``` -Then let's install the only additional dependency: [supabase-js](https://github.com/supabase/supabase-js) +Then install the Supabase client library: [supabase-js](https://github.com/supabase/supabase-js) ```bash npm install @supabase/supabase-js @@ -45,17 +45,7 @@ PUBLIC_SUPABASE_URL="YOUR_SUPABASE_URL" PUBLIC_SUPABASE_ANON_KEY="YOUR_SUPABASE_KEY" ``` -Now that we have the API credentials in place, create a `src/lib/supabaseClient.ts` helper file to initialize the Supabase client. -These variables will be exposed on the browser, and that's completely fine since we have [Row Level Security](/docs/guides/auth#row-level-security) enabled on our Database. - -```js title=src/lib/supabaseClient.ts -import { createClient } from '@supabase/auth-helpers-sveltekit' -import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public' - -export const supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY) -``` - -Optionally, update `src/routes/styles.css` with the [CSS from the example](https://raw.githubusercontent.com/supabase/supabase/master/examples/user-management/svelte-user-management/src/app.css). +Optionally, add `src/styles.css` with the [CSS from the example](https://raw.githubusercontent.com/supabase/supabase/master/examples/user-management/svelte-user-management/src/app.css). ### Supabase Auth Helpers @@ -69,249 +59,390 @@ Install the auth helpers for SvelteKit: npm install @supabase/auth-helpers-sveltekit ``` -Update your `src/routes/+layout.svelte`: +Add the code below to your `src/hooks.server.ts` to initialize the client on the server: -```html title=src/routes/+layout.svelte - - -
- -
+ }); +}; ``` -Create a new `src/routes/+layout.ts` file to handle the session on the client-side. +If you are using TypeScript the compiler might complain about `event.locals.supabase` and `event.locals.getSession`, this can be fixed by updating your `src/app.d.ts` with the content below: -```ts title=src/routes/+layout.ts -import type { LayoutLoad } from './$types' -import { getSupabase } from '@supabase/auth-helpers-sveltekit' +```ts title=src/app.d.ts +// src/app.d.ts -export const load: LayoutLoad = async (event) => { - const { session } = await getSupabase(event) - return { session } +import { SupabaseClient, Session } from '@supabase/supabase-js'; + +declare global { + namespace App { + interface Locals { + supabase: SupabaseClient; + getSession(): Promise; + } + interface PageData { + session: Session | null; + } + // interface Error {} + // interface Platform {} + } } ``` Create a new `src/routes/+layout.server.ts` file to handle the session on the server-side. ```ts title=src/routes/+layout.server.ts -import type { LayoutServerLoad } from './$types' -import { getServerSession } from '@supabase/auth-helpers-sveltekit' +// src/routes/+layout.server.ts +import type { LayoutServerLoad } from './$types'; -export const load: LayoutServerLoad = async (event) => { +export const load: LayoutServerLoad = async ({ locals: { getSession } }) => { return { - session: await getServerSession(event), - } -} + session: getSession() + }; +}; ``` -Be sure to create `src/hooks.client.ts` and `src/hooks.server.ts` in order to get the auth helper started on the client and server-side. +> Start your dev server (`npm run dev`) in order to generate the `./$types` files we are referencing in our project. -```ts title=src/hooks.client.ts -import '$lib/supabaseClient' +Create a new `src/routes/+layout.ts` file to handle the session and the supabase object on the client-side. + +```ts title=src/routes/+layout.ts +// src/routes/+layout.ts +import { invalidate } from '$app/navigation'; +import { + PUBLIC_SUPABASE_ANON_KEY, + PUBLIC_SUPABASE_URL +} from '$env/static/public'; +import { createSupabaseLoadClient } from '@supabase/auth-helpers-sveltekit'; +import type { LayoutLoad } from './$types'; + +export const load: LayoutLoad = async ({ fetch, data, depends }) => { + depends('supabase:auth'); + + const supabase = createSupabaseLoadClient({ + supabaseUrl: PUBLIC_SUPABASE_URL, + supabaseKey: PUBLIC_SUPABASE_ANON_KEY, + event: { fetch }, + serverSession: data.session + }); + + const { + data: { session } + } = await supabase.auth.getSession(); + + return { supabase, session }; +}; ``` -```ts title=src/hooks.server.ts -import '$lib/supabaseClient' -``` +Update your `src/routes/+layout.svelte`: -### Set up a Login component - -Let's set up a Svelte component to manage logins and sign ups. We'll use Magic Links, so users can sign in with their email without using passwords. -Create a new `Auth.svelte` component in the `src/routes` directory to handle this functionality. - -```html title=src/routes/Auth.svelte +```svelte title=src/routes/+layout.svelte + - -
-

Supabase + SvelteKit

-

Sign in via magic link with your email below

-
- -
-
- -
-
- + + User Management + + +
+ +
``` -### Account component +### Set up a Login page + +#### Supabase Auth UI + +We can use the [Supabase Auth UI](/docs/guides/auth/auth-helpers/auth-ui), a pre-built Svelte component, for authenticating users via OAuth, email, and magic links. + +Install the Supabase Auth UI for Svelte + +```bash +npm install @supabase/auth-ui-svelte @supabase/auth-ui-shared +``` + +Add the `Auth` component to your home page + +```svelte title=src/routes/+page.svelte + + + + + User Management + + +
+
+ +
+
+``` + +Create a `src/routes/+page.server.ts` file that will return our website url to be used in our `redirectTo` above. + +> This is necessary because the current Supabase auth flow uses implicit grant which returns the tokens as part of the url fragment (#). +The redirect to a non server protected page will make sure that the client captures the url fragment as we cannot read these on the server +and send it over to the server. This all happens behind the scenes and isn't something you will need to do manually. + +```ts +// src/routes/+page.server.ts +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ url, locals: { getSession } }) => { + const session = await getSession(); + + // if the user is already logged in return them to the account page + if (session) { + throw redirect(303, '/account'); + } + + return { url: url.origin }; +}; +``` + +### Loading page + +Create a `src/routes/logging-in/+page.svelte` file with the code below. + +This page is used for waiting while your client-side code inside of `src/routes/+layout.ts` sends the tokens over to your server-side code to store it in a cookie. + +```svelte + + + +
+ "Because as we know, there are known knowns; there are things we know we know. We also know there + are known unknowns; that is to say we know there are some things we do not know. But there are + also unknown unknowns—the ones we don't know we don't know" - Donald Rumsfeld +
+``` + +### Account page After a user is signed in, they need to be able to edit their profile details and manage their account. -Create a new `Account.svelte` component in the `src/routes` directory to handle this functionality. +Create a new `src/routes/account/+page.svelte` file with the content below. -```html title=src/routes/Account.svelte +```svelte title=src/routes/account/+page.svelte + -
-
- - -
-
- - -
-
- - -
+
+ +
+ + +
-
- -
+
+ + +
-
- -
- +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+
+
+``` + +Now create the associated `src/routes/account/+page.server.ts` file that will handle loading our data from the server through the `load` function +and handle all our form actions through the `actions` object. + +```ts +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +export const load = (async ({ locals: { supabase, getSession } }) => { + const session = await getSession(); + + if (!session) { + throw redirect(303, '/'); + } + + const { data: profile } = await supabase + .from('profiles') + .select(`username, full_name, website, avatar_url`) + .eq('id', session.user.id) + .single(); + + return { session, profile }; +}) satisfies PageServerLoad; + +export const actions = { + update: async ({ request, locals: { supabase, getSession } }) => { + const formData = await request.formData(); + const fullName = formData.get('fullName') as string; + const username = formData.get('username') as string; + const website = formData.get('website') as string; + const avatarUrl = formData.get('avatarUrl') as string; + + const session = await getSession(); + + const { error } = await supabase.from('profiles').upsert({ + id: session?.user.id, + full_name: fullName, + username, + website, + avatar_url: avatarUrl, + updated_at: new Date() + }); + + if (error) { + return fail(500, { + fullName, + username, + website, + avatarUrl + }); + } + + return { + fullName, + username, + website, + avatarUrl + }; + }, + signout: async ({ locals: { supabase, getSession } }) => { + const session = await getSession(); + if (session) { + await supabase.auth.signOut(); + throw redirect(303, '/'); + } + } +} satisfies Actions; ``` ### Launch! -Now that we have all the components in place, let's update `src/routes/+page.svelte`: - -```html title=src/routes/+page.svelte - - - - Supabase + SvelteKit - - - -{#if !$page.data.session} - -{:else} - -{/if} -``` - -Once that's done, run this in a terminal window: +Now that we have all the pages in place, run this in a terminal window: ```bash npm run dev @@ -327,91 +458,99 @@ Every Supabase project is configured with [Storage](/docs/guides/storage) for ma ### Create an upload widget -Let's create an avatar for the user so that they can upload a profile photo. We can start by creating a new component called `Avatar.svelte` in the `src/routes` directory: +Let's create an avatar for the user so that they can upload a profile photo. We can start by creating a new component called `Avatar.svelte` in the `src/routes/account` directory: -```html title=src/routes/Avatar.svelte +```svelte title=src/routes/account/Avatar.svelte +
- {#if avatarUrl} {avatarUrl {:else} -
- {/if} + {#if avatarUrl} + {avatarUrl + {:else} +
+ {/if} + -
- - -
+
+ + +
``` @@ -419,18 +558,34 @@ Let's create an avatar for the user so that they can upload a profile photo. We And then we can add the widget to the Account page: -```html title=src/routes/Account.svelte +```svelte title=src/routes/account/+page.svelte + -
- - +
+ + + { + profileForm.requestSubmit(); + }} + /> - - + + +
``` ### Storage management diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 01df68c58f9..a87525bceb6 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -18,6 +18,7 @@ Div Arora Egor Romanov Fabrizio Fenoglio Francesco Sansalvadore +Greg Richardson Haydn Maley Hieu Pham Inian P diff --git a/apps/www/_blog/2023-03-09-supabase-beta-update-february-2023.mdx b/apps/www/_blog/2023-03-09-supabase-beta-update-february-2023.mdx new file mode 100644 index 00000000000..2f6f20dbf6a --- /dev/null +++ b/apps/www/_blog/2023-03-09-supabase-beta-update-february-2023.mdx @@ -0,0 +1,146 @@ +--- +title: Supabase Beta February 2023 +description: There is something for everybody this month - AI, Auth, Database, Edge Functions, GraphQL … you name it! +author: ant_wilson +image: 2023-03-09-beta-update-february/supabase-beta-update-february-2023.jpg +thumb: 2023-03-09-beta-update-february/supabase-beta-update-february-2023.jpg +tags: + - release-notes +date: '2023-03-09' +toc_depth: 3 +--- + +There’s something for everybody this month: AI, Auth, Database, Edge Functions, GraphQL … you name it! + +## GraphiQL editor in the dashboard + +![GraphiQL editor in the dashboard](/images/blog/2023-03-09-beta-update-february/graphiql-dashboard.png) + +The most popular GraphQL IDE/API explorer is now built into the dashboard! You can now explore and query your GraphQL API produced by `pg_graphql`. + +[Try it now](https://app.supabase.com/project/_/api/graphiql). + +## Supabase + OpenAI search + +![Supabase + OpenAI search](/images/blog/2023-03-09-beta-update-february/openai-supabase.png) + +We've updated our Docs search functionality to use `pgvector` + OpenAI. Still no cease and desist from Microsoft, so you can continue to ask Clippy any Supabase-specific questions 📎💚 + +[Ask Clippy.](https://supabase.com/docs) + +## Serve all the functions! + +![Serve all the functions!](/images/blog/2023-03-09-beta-update-february/supabase-cli-edge-functions.png) + +Do you use multiple Edge Functions in your project? Then celebrate! Supabase CLI 1.36+ now supports serving multiple Edge Functions at the same time. + +To enable the feature, just run `supabase functions serve` in your project. + +[Check the docs.](https://supabase.com/docs/reference/cli/supabase-functions-serve) + +## Smaller Postgres docker images for everyone + +![Smaller Postgres docker images for everyone](/images/blog/2023-03-09-beta-update-february/postgres-docker-images.png) + +We rewrote the Postgres Dockerfile with multi-stage builds so that each extension is compiled in its own separate stage. This reduces the size of the image from 1.3GB to 250MB, enabling a much faster boot time. + +[See it yourself](https://hub.docker.com/r/supabase/postgres/tags) + +## New UI for Postgres Roles + +![New UI for Postgres Roles](/images/blog/2023-03-09-beta-update-february/postgres-roles.png) + +We've improved database role management. You can create, update, and delete database roles through the dashboard. Just one small step towards column-level security + +[Check it out.](https://app.supabase.com/project/_/editor) + +## API docs in the table editor + +![API docs in the table editor](/images/blog/2023-03-09-beta-update-february/api-docs.png) + +API docs got a light touchup and were moved to the table editor. You can now look up API methods and generate & download type files right there ✨ + +[Check it out.](https://app.supabase.com/project/_/editor) + +## Quick product updates + +- **Postgres Extensions**: We're rolling out some fixes for several Postgres extensions. Check your Dashboard notifications to see if you need to take any actions. + +- **Auth**: Added full OpenAPI 3.0 spec which provides a comprehensive overview of the API with documentation on each request. [PR](https://github.com/supabase/gotrue/pull/918) + +- **Database**: supabase-js now infers the response type from your query. If the inferred type is incorrect, you can use `.returns()` to override it. [Doc](https://supabase.com/docs/reference/javascript/db-returns) + +- **Dashboard**: Improved database roles management, you can now create, update and delete database roles through the dashboard. [Dashboard](https://app.supabase.com/project/_/database/roles) + +- **Dashboard**: We've provided a reference panel showing all available paths that can be queried from each respective source that improves the Logs Explorer experience. [Dashboard](https://app.supabase.com/project/_/logs/explorer) + +- **Edge Functions**: upgraded to Deno 1.30.3, that supports TypeScript 4.9.x and introduces `satisfies`. Thanks to [Benjamin Dobell](https://github.com/Benjamin-Dobell) 🙏. [PR](https://github.com/supabase/functions-relay/pull/30) + +- Realtime: Broadcast only primary key(s) for deleting records when RLS is enabled and replica identity is full. [PR](https://github.com/supabase/realtime/pull/461) + +## Next.js with Supabase + +![Next.js with Supabase](/images/blog/2023-03-09-beta-update-february/nextjs-supabase.png) + +Building the next big thing with Supabase and Next.js? Then make sure to check Jon's new video series to help get you (and your app directory) off the ground. + +- [Fetching and Caching data in Next.js Async Server Components](https://www.youtube.com/watch?v=GniRj1jIhFw) + +- [Client vs Server Components in Next.js app directory // Merging server state with realtime updates](https://www.youtube.com/watch?v=YR-xP6PPXXA) + +- [Delicious Server Component Cookies // Auth in the Next.js app directory](https://www.youtube.com/watch?v=Bh1TOpOcGJQ) + +![Made with Supabase](/images/blog/2022-12-07-november-beta-update/madewithsupabase.jpg) + +[Domain-specific ChatGPT](https://github.com/gannonh/gpt3.5-turbo-pgvector) | This starter app uses embeddings coupled with vector search to show how the OpenAI API can be used to create a conversational interface to domain-specific knowledge. Built by [Gannon Hall](https://github.com/gannonh) with Next.js, Vercel, gpt-3.5-turbo, pgvector, and Supabase. + +Discover 100+ other apps in this [tweet](https://twitter.com/supabase/status/1628843899996209155). + +## Extended Community Highlights + +![Community](/images/blog/2022-june/community.jpg) + +- Langchain supports our database as a vector store, using pgvector. [Doc](https://hwchase17.github.io/langchainjs/docs/modules/indexes/vector_stores/supabase/)
+ +- refine week: building a CRUD app with refine & Supabase. [Full series](https://refine.dev/week-of-refine/) + +- Build an Instagram Web App with Supabase and Next.js. [Tutorial](https://livecycle.io/blogs/nextjs-supabase-instagram/)
+ +- Supabase GeoQueries with PostGIS and Storage Upload. [Video](https://www.youtube.com/watch?v=-_Wpr7rwU8I)
+ +- How I used Supabase to build a winning hackathon project. [Article](https://blog.matt.lgbt/how-i-used-supabase-to-build-a-winning-hackathon-project) + +- Using a Supabase API within FlutterFlow. [Video](https://www.youtube.com/watch?v=YzSXwlA3vWM)
+ +- Getting started with Supabase in Android. [Part 1](https://www.youtube.com/watch?v=SGr73sWMX6w) | [Part 2](https://www.youtube.com/watch?v=NWaIIRfVpuo) + +- Streaming User Events from PostgreSQL (Supabase) to Serverless Kafka. [Article](https://upstash.com/blog/postgre-supabase-connector)
+ +- Building a CRUD API with FastAPI and Supabase: A Step-by-Step Guide. [Tutorial](https://blog.theinfosecguy.xyz/building-a-crud-api-with-fastapi-and-supabase-a-step-by-step-guide)
+ +- How to build a collaborative SaaS product using Next.js and ZenStack's access control policy. [Tutorial](https://zenstack.dev/blog/saas-demo)
+ +- Nuxt 3, Tailwind, and Supabase Crash Course. [Course](https://dev.to/jacobandrewsky/nuxt-3-tailwind-and-supabase-crash-course-1482)
+ +- A sneak peek at the Supabase and Next.js RSC SaaS kit. [Article](https://makerkit.dev/blog/changelog/nextjs-supabase-kit-update)
+ +- Build a Fullstack serverless application using React, Chakra UI, Hookstate, and Supabase. [Tutorial](https://clx.hashnode.dev/build-a-fullstack-serverless-application-using-react-chakra-ui-hookstate-and-supabase)
+ +- Implement Sign in with Google using Supabase Auth in NextJS. [Tutorial](https://dev.to/irwanphan/implement-sign-in-with-google-using-supabase-auth-in-nextjs-1jj1)
+ +## We're Hiring + +Come join one of the fastest-growing open source projects ever 🤗 + +- [Technical partnerships lead (Next.js, Node,js, Supabase)](https://boards.greenhouse.io/supabase/jobs/4775849004)\ +- [Junior Developer Advocate (SMM & Community)](https://boards.greenhouse.io/supabase/jobs/4777008004) + +## Meme Zone + +As always, one of our favorite memes from last month. [Follow us on Twitter](https://twitter.com/supabase) for more. + +![Best meme from February 2023](/images/blog/2023-03-09-beta-update-february/beta-update-february-meme.jpeg) + +## It's happenning! Coming in April + +![Supabase Launch Week 7](/images/blog/2023-03-09-beta-update-february/supabase-launch-week-7.png) diff --git a/apps/www/public/images/blog/2023-03-09-beta-update-february/api-docs.png b/apps/www/public/images/blog/2023-03-09-beta-update-february/api-docs.png new file mode 100644 index 00000000000..841ce2192b4 Binary files /dev/null and b/apps/www/public/images/blog/2023-03-09-beta-update-february/api-docs.png differ diff --git a/apps/www/public/images/blog/2023-03-09-beta-update-february/beta-update-february-meme.jpeg b/apps/www/public/images/blog/2023-03-09-beta-update-february/beta-update-february-meme.jpeg new file mode 100644 index 00000000000..796210010da Binary files /dev/null and b/apps/www/public/images/blog/2023-03-09-beta-update-february/beta-update-february-meme.jpeg differ diff --git a/apps/www/public/images/blog/2023-03-09-beta-update-february/clippy.png b/apps/www/public/images/blog/2023-03-09-beta-update-february/clippy.png new file mode 100644 index 00000000000..136daa00731 Binary files /dev/null and b/apps/www/public/images/blog/2023-03-09-beta-update-february/clippy.png differ diff --git a/apps/www/public/images/blog/2023-03-09-beta-update-february/graphiql-dashboard.png b/apps/www/public/images/blog/2023-03-09-beta-update-february/graphiql-dashboard.png new file mode 100644 index 00000000000..40d0784ec15 Binary files /dev/null and b/apps/www/public/images/blog/2023-03-09-beta-update-february/graphiql-dashboard.png differ diff --git a/apps/www/public/images/blog/2023-03-09-beta-update-february/nextjs-supabase.png b/apps/www/public/images/blog/2023-03-09-beta-update-february/nextjs-supabase.png new file mode 100644 index 00000000000..e2ee380b6fb Binary files /dev/null and b/apps/www/public/images/blog/2023-03-09-beta-update-february/nextjs-supabase.png differ diff --git a/apps/www/public/images/blog/2023-03-09-beta-update-february/openai-supabase.png b/apps/www/public/images/blog/2023-03-09-beta-update-february/openai-supabase.png new file mode 100644 index 00000000000..12e27c66e4e Binary files /dev/null and b/apps/www/public/images/blog/2023-03-09-beta-update-february/openai-supabase.png differ diff --git a/apps/www/public/images/blog/2023-03-09-beta-update-february/postgres-docker-images.png b/apps/www/public/images/blog/2023-03-09-beta-update-february/postgres-docker-images.png new file mode 100644 index 00000000000..ea30654294d Binary files /dev/null and b/apps/www/public/images/blog/2023-03-09-beta-update-february/postgres-docker-images.png differ diff --git a/apps/www/public/images/blog/2023-03-09-beta-update-february/postgres-roles.png b/apps/www/public/images/blog/2023-03-09-beta-update-february/postgres-roles.png new file mode 100644 index 00000000000..b8b8f36b7ea Binary files /dev/null and b/apps/www/public/images/blog/2023-03-09-beta-update-february/postgres-roles.png differ diff --git a/apps/www/public/images/blog/2023-03-09-beta-update-february/supabase-beta-update-february-2023.jpg b/apps/www/public/images/blog/2023-03-09-beta-update-february/supabase-beta-update-february-2023.jpg new file mode 100644 index 00000000000..db500fbd764 Binary files /dev/null and b/apps/www/public/images/blog/2023-03-09-beta-update-february/supabase-beta-update-february-2023.jpg differ diff --git a/apps/www/public/images/blog/2023-03-09-beta-update-february/supabase-cli-edge-functions.png b/apps/www/public/images/blog/2023-03-09-beta-update-february/supabase-cli-edge-functions.png new file mode 100644 index 00000000000..fe8f94a1f64 Binary files /dev/null and b/apps/www/public/images/blog/2023-03-09-beta-update-february/supabase-cli-edge-functions.png differ diff --git a/apps/www/public/images/blog/2023-03-09-beta-update-february/supabase-launch-week-7.png b/apps/www/public/images/blog/2023-03-09-beta-update-february/supabase-launch-week-7.png new file mode 100644 index 00000000000..d5398a04983 Binary files /dev/null and b/apps/www/public/images/blog/2023-03-09-beta-update-february/supabase-launch-week-7.png differ diff --git a/docker/.env.example b/docker/.env.example index 1ee5ffc0bdb..786316fdd96 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -75,3 +75,6 @@ STUDIO_DEFAULT_PROJECT=Default Project STUDIO_PORT=3000 # replace if you intend to use Studio outside of localhost SUPABASE_PUBLIC_URL=http://localhost:8000 + +# Enable webp support +IMGPROXY_ENABLE_WEBP_DETECTION=true diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 65156cf692a..06d2e738255 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -175,11 +175,11 @@ services: ENABLE_IMAGE_TRANSFORMATION: "true" IMGPROXY_URL: http://imgproxy:5001 volumes: - - ./volumes/storage:/var/lib/storage + - ./volumes/storage:/var/lib/storage:z imgproxy: container_name: supabase-imgproxy - image: darthsim/imgproxy:v3.11 + image: darthsim/imgproxy:v3.13 healthcheck: test: [ "CMD", "imgproxy", "health" ] timeout: 5s @@ -189,8 +189,9 @@ services: IMGPROXY_BIND: ":5001" IMGPROXY_LOCAL_FILESYSTEM_ROOT: / IMGPROXY_USE_ETAG: "true" + IMGPROXY_ENABLE_WEBP_DETECTION: ${IMGPROXY_ENABLE_WEBP_DETECTION} volumes: - - ./volumes/storage:/var/lib/storage + - ./volumes/storage:/var/lib/storage:z meta: container_name: supabase-meta @@ -235,8 +236,8 @@ services: PGDATABASE: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB} volumes: - - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/realtime.sql - - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/roles.sql + - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/realtime.sql:Z + - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/roles.sql:Z # Must be superuser to enable pg_net extension - - ./volumes/db/webhooks.sql:/etc/postgresql.schema.sql - - ./volumes/db/data:/var/lib/postgresql/data + - ./volumes/db/webhooks.sql:/etc/postgresql.schema.sql:Z + - ./volumes/db/data:/var/lib/postgresql/data:Z diff --git a/package-lock.json b/package-lock.json index afbf9e45754..b0bcd07d8d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "supabase", "version": "0.0.0", "license": "Apache-2.0", "workspaces": [ diff --git a/studio/components/interfaces/Billing/InvoiceStatusBadge.tsx b/studio/components/interfaces/Billing/InvoiceStatusBadge.tsx new file mode 100644 index 00000000000..d355b584fea --- /dev/null +++ b/studio/components/interfaces/Billing/InvoiceStatusBadge.tsx @@ -0,0 +1,90 @@ +import { FC } from 'react' +import * as Tooltip from '@radix-ui/react-tooltip' +import { Badge } from 'ui' +import { InvoiceStatus } from './Invoices.types' + +interface Props { + status: InvoiceStatus +} + +export const invoiceStatusMapping: Record = { + [InvoiceStatus.DRAFT]: { + label: 'Upcoming', + badgeColor: 'yellow', + }, + [InvoiceStatus.PAID]: { + label: 'Paid', + badgeColor: 'green', + }, + [InvoiceStatus.VOID]: { + label: 'Forgiven', + badgeColor: 'green', + }, + + // We do not want to overcomplicate it for the user, so we'll treat uncollectible/open the same from a user perspective + // it's an outstanding invoice + [InvoiceStatus.UNCOLLECTIBLE]: { + label: 'Outstanding', + badgeColor: 'red', + }, + [InvoiceStatus.OPEN]: { + label: 'Outstanding', + badgeColor: 'red', + }, +} + +const InvoiceStatusBadge: FC = ({ status }) => { + const statusMapping = invoiceStatusMapping[status] + + return ( + + + + {statusMapping?.label || status} + + + + +
+ {[InvoiceStatus.OPEN, InvoiceStatus.UNCOLLECTIBLE].includes(status) && ( +

+ We were not able to collect the money. Make sure you have a valid payment method and + enough funds. Outstanding invoices may cause restrictions. You can manually pay the + using the "Pay Now" button. +

+ )} + + {status === InvoiceStatus.DRAFT && ( +

+ The invoice will soon be finalized and charged for. +

+ )} + + {status === InvoiceStatus.PAID && ( +

+ The invoice has been paid successfully. No action is required on your side. +

+ )} + + {status === InvoiceStatus.VOID && ( +

+ This invoice has been forgiven. No action is required on your side. +

+ )} +
+
+
+ ) +} + +export default InvoiceStatusBadge diff --git a/studio/components/interfaces/Billing/Invoices.tsx b/studio/components/interfaces/Billing/Invoices.tsx index 6cde250cd9c..fb255c38353 100644 --- a/studio/components/interfaces/Billing/Invoices.tsx +++ b/studio/components/interfaces/Billing/Invoices.tsx @@ -1,11 +1,15 @@ import { FC, useState, useEffect } from 'react' import { Button, Loading, IconFileText, IconDownload, IconChevronLeft, IconChevronRight } from 'ui' +import Link from 'next/link' import { useStore } from 'hooks' import { API_URL } from 'lib/constants' import { get, head } from 'lib/common/fetch' import Table from 'components/to-be-cleaned/Table' +import InvoiceStatusBadge from './InvoiceStatusBadge' +import { Invoice, InvoiceStatus } from './Invoices.types' + const PAGE_LIMIT = 10 interface Props { @@ -18,7 +22,7 @@ const Invoices: FC = ({ projectRef }) => { const [page, setPage] = useState(1) const [count, setCount] = useState(0) - const [invoices, setInvoices] = useState([]) + const [invoices, setInvoices] = useState([]) const offset = (page - 1) * PAGE_LIMIT @@ -85,6 +89,9 @@ const Invoices: FC = ({ projectRef }) => { Date, Amount due, Invoice number, + + Status + , , ]} body={ @@ -98,7 +105,7 @@ const Invoices: FC = ({ projectRef }) => { ) : ( <> - {invoices.map((x: any) => { + {invoices.map((x) => { return ( @@ -117,11 +124,22 @@ const Invoices: FC = ({ projectRef }) => {

{x.number}

+ + +
+ {[InvoiceStatus.UNCOLLECTIBLE, InvoiceStatus.OPEN].includes(x.status) && ( + + + + + + )} +
@@ -130,7 +148,7 @@ const Invoices: FC = ({ projectRef }) => { ) })} - +

Showing {offset + 1} to {offset + invoices.length} out of {count} invoices diff --git a/studio/components/interfaces/Billing/Invoices.types.ts b/studio/components/interfaces/Billing/Invoices.types.ts new file mode 100644 index 00000000000..97b8916c3e3 --- /dev/null +++ b/studio/components/interfaces/Billing/Invoices.types.ts @@ -0,0 +1,15 @@ +export enum InvoiceStatus { + DRAFT = 'draft', + PAID = 'paid', + VOID = 'void', + UNCOLLECTIBLE = 'uncollectible', + OPEN = 'open', +} + +export type Invoice = { + id: string + number: string + period_end: number + subtotal: number + status: InvoiceStatus +} diff --git a/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx b/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx index a7b6931b604..200f1fdd719 100644 --- a/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx +++ b/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx @@ -1,12 +1,15 @@ import { useState, useEffect } from 'react' import { Button, Loading, IconFileText, IconDownload, IconChevronLeft, IconChevronRight } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' +import Link from 'next/link' import { checkPermissions, useStore } from 'hooks' import { API_URL } from 'lib/constants' import { get, head } from 'lib/common/fetch' import Table from 'components/to-be-cleaned/Table' import NoPermission from 'components/ui/NoPermission' +import InvoiceStatusBadge from 'components/interfaces/Billing/InvoiceStatusBadge' +import { Invoice, InvoiceStatus } from 'components/interfaces/Billing/Invoices.types' const PAGE_LIMIT = 10 @@ -18,7 +21,7 @@ const InvoicesSettings = () => { const [page, setPage] = useState(1) const [count, setCount] = useState(0) - const [invoices, setInvoices] = useState([]) + const [invoices, setInvoices] = useState([]) const { stripe_customer_id } = ui.selectedOrganization ?? {} const offset = (page - 1) * PAGE_LIMIT @@ -96,6 +99,9 @@ const InvoicesSettings = () => { Date, Amount due, Invoice number, + + Status + , , ]} body={ @@ -109,7 +115,7 @@ const InvoicesSettings = () => { ) : ( <> - {invoices.map((x: any) => { + {invoices.map((x) => { return ( @@ -124,11 +130,22 @@ const InvoicesSettings = () => {

{x.number}

+ + +
+ {[InvoiceStatus.UNCOLLECTIBLE, InvoiceStatus.OPEN].includes(x.status) && ( + + + + + + )} +
@@ -137,7 +154,7 @@ const InvoicesSettings = () => { ) })} - +

Showing {offset + 1} to {offset + invoices.length} out of {count} invoices diff --git a/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index 3f73035e896..8dd437ebe3d 100644 --- a/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -1,24 +1,27 @@ import { isNil } from 'lodash' -import { FC, useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { object, string } from 'yup' import * as Tooltip from '@radix-ui/react-tooltip' import { Button, Form, IconMail, Input, Modal, Select } from 'ui' -import { Member, User, Role } from 'types' +import { Member, Role } from 'types' import { checkPermissions, useParams, useStore } from 'hooks' -import { post } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useOrganizationMemberInviteCreateMutation } from 'data/organizations/organization-member-invite-create-mutation' -interface Props { - user: User +export interface InviteMemberButtonProps { + userId: number members: Member[] roles: Role[] rolesAddable: Number[] } -const InviteMemberButton: FC = ({ user, members = [], roles = [], rolesAddable = [] }) => { +const InviteMemberButton = ({ + userId, + members = [], + roles = [], + rolesAddable = [], +}: InviteMemberButtonProps) => { const { ui } = useStore() const { slug } = useParams() @@ -67,7 +70,7 @@ const InviteMemberButton: FC = ({ user, members = [], roles = [], rolesAd const response = await mutateAsync({ slug, invitedEmail: values.email.toLowerCase(), - ownerId: user.id, + ownerId: userId, roleId, }) diff --git a/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx b/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx index 925ddf81cc7..f9e5e0b40ed 100644 --- a/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx +++ b/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx @@ -8,28 +8,29 @@ import { Member, Role } from 'types' import { useStore, useParams } from 'hooks' import { isInviteExpired, getUserDisplayName } from '../Organization.utils' +import { useProfileQuery } from 'data/profile/profile-query' +import { useOrganizationMemberUpdateMutation } from 'data/organizations/organization-member-update-mutation' import Table from 'components/to-be-cleaned/Table' import MemberActions from './MemberActions' import RolesHelperModal from './RolesHelperModal/RolesHelperModal' import { getRolesManagementPermissions } from './TeamSettings.utils' -import { useOrganizationMemberUpdateMutation } from 'data/organizations/organization-member-update-mutation' interface SelectedMember extends Member { oldRoleId: number newRoleId: number } -interface Props { +export interface MembersViewProps { roles: Role[] members: Member[] searchString: string } -const MembersView: FC = ({ searchString, roles, members }) => { +const MembersView = ({ searchString, roles, members }: MembersViewProps) => { const { ui } = useStore() const { slug } = useParams() - const user = ui.profile + const { data: profile } = useProfileQuery() const { rolesAddable, rolesRemovable } = getRolesManagementPermissions(roles) @@ -108,7 +109,7 @@ const MembersView: FC = ({ searchString, roles, members }) => { ...filteredMembers.map((x: Member, i: number) => { const [memberRoleId] = x.role_ids ?? [] const role = (roles || []).find((role) => role.id === memberRoleId) - const memberIsUser = x.primary_email == user?.primary_email + const memberIsUser = x.primary_email == profile?.primary_email const memberIsPendingInvite = !!x.invited_id const canRemoveRole = rolesRemovable.includes(memberRoleId) const disableRoleEdit = !canRemoveRole || memberIsUser || memberIsPendingInvite diff --git a/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx b/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx index 98212c2153a..9289a210943 100644 --- a/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx +++ b/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx @@ -6,6 +6,7 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { useStore, useParams } from 'hooks' import { post } from 'lib/common/fetch' import { API_URL } from 'lib/constants' +import { useProfileQuery } from 'data/profile/profile-query' import InviteMemberButton from './InviteMemberButton' import MembersView from './MembersView' import { getRolesManagementPermissions, hasMultipleOwners } from './TeamSettings.utils' @@ -17,7 +18,7 @@ const TeamSettings = () => { const { ui } = useStore() const { slug } = useParams() - const user = ui.profile + const { data: profile } = useProfileQuery() const isOwner = ui.selectedOrganization?.is_owner const { data: detailData } = useOrganizationDetailQuery({ slug }) @@ -73,10 +74,10 @@ const TeamSettings = () => { placeholder="Filter members" />

- {canAddMembers && user !== undefined && ( + {canAddMembers && profile !== undefined && (
{ - const { ui } = useStore() - - const router = useRouter() - const { id, ref } = router.query + const { id, ref } = useParams() + const { data: profile } = useProfileQuery() const [report, setReport] = useState() @@ -53,7 +51,7 @@ const Reports = () => { visibility: report?.visibility, owner_id: report?.owner_id, }, - subject: { id: ui.profile?.id }, + subject: { id: profile?.id }, }) const canUpdateReport = checkPermissions(PermissionAction.UPDATE, 'user_content', { resource: { @@ -61,7 +59,7 @@ const Reports = () => { visibility: report?.visibility, owner_id: report?.owner_id, }, - subject: { id: ui.profile?.id }, + subject: { id: profile?.id }, }) /* diff --git a/studio/components/interfaces/SQLEditor/TabSqlQuery/FavouriteButton.tsx b/studio/components/interfaces/SQLEditor/TabSqlQuery/FavouriteButton.tsx index 19ab8769d49..59cd87f4141 100644 --- a/studio/components/interfaces/SQLEditor/TabSqlQuery/FavouriteButton.tsx +++ b/studio/components/interfaces/SQLEditor/TabSqlQuery/FavouriteButton.tsx @@ -5,11 +5,12 @@ import { Button, IconHeart } from 'ui' import * as Tooltip from '@radix-ui/react-tooltip' import { useStore } from 'hooks' import Telemetry from 'lib/telemetry' +import { useProfileQuery } from 'data/profile/profile-query' import { useSqlStore } from 'localStores/sqlEditor/SqlEditorStore' const FavouriteButton = () => { const { ui, content: contentStore } = useStore() - const { profile: user } = ui + const { data: profile } = useProfileQuery() const sqlEditorStore: any = useSqlStore() @@ -52,7 +53,7 @@ const FavouriteButton = () => { /* * reload sql data in store and re-select tab */ - sqlEditorStore.loadTabs(sqlEditorStore.tabsFromContentStore(contentStore, user?.id), false) + sqlEditorStore.loadTabs(sqlEditorStore.tabsFromContentStore(contentStore, profile?.id), false) sqlEditorStore.selectTab(id) setLoading(false) @@ -93,7 +94,7 @@ const FavouriteButton = () => { /* * reload sql data in store and re-select tab */ - sqlEditorStore.loadTabs(sqlEditorStore.tabsFromContentStore(contentStore, user?.id), false) + sqlEditorStore.loadTabs(sqlEditorStore.tabsFromContentStore(contentStore, profile?.id), false) sqlEditorStore.selectTab(id) setLoading(false) } catch (error: any) { diff --git a/studio/components/interfaces/SQLEditor/TabSqlQuery/UtilityActions.tsx b/studio/components/interfaces/SQLEditor/TabSqlQuery/UtilityActions.tsx index 1dd3270dba5..fa06f309151 100644 --- a/studio/components/interfaces/SQLEditor/TabSqlQuery/UtilityActions.tsx +++ b/studio/components/interfaces/SQLEditor/TabSqlQuery/UtilityActions.tsx @@ -1,27 +1,26 @@ -import { FC } from 'react' -import { observer } from 'mobx-react-lite' import { Button, IconAlertCircle } from 'ui' import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useKeyboardShortcuts, useStore, checkPermissions } from 'hooks' +import { useKeyboardShortcuts, checkPermissions } from 'hooks' import { IS_PLATFORM } from 'lib/constants' +import { useProfileQuery } from 'data/profile/profile-query' import { useSqlStore } from 'localStores/sqlEditor/SqlEditorStore' import SavingIndicator from './SavingIndicator' import FavouriteButton from './FavouriteButton' import SizeToggleButton from './SizeToggleButton' -interface Props { +export interface UtilityActionsProps { updateSqlSnippet: (value: any) => void } -const UtilityActions: FC = ({ updateSqlSnippet }) => { - const { ui } = useStore() +const UtilityActions = ({ updateSqlSnippet }: UtilityActionsProps) => { + const { data: profile } = useProfileQuery() const sqlEditorStore: any = useSqlStore() const canCreateSQLSnippet = checkPermissions(PermissionAction.CREATE, 'user_content', { - resource: { type: 'sql', owner_id: ui.profile?.id }, - subject: { id: ui.profile?.id }, + resource: { type: 'sql', owner_id: profile?.id }, + subject: { id: profile?.id }, }) useKeyboardShortcuts( @@ -80,4 +79,4 @@ const UtilityActions: FC = ({ updateSqlSnippet }) => { ) } -export default observer(UtilityActions) +export default UtilityActions diff --git a/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx b/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx index d29400e1a1b..3707cf06f8d 100644 --- a/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx +++ b/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx @@ -12,9 +12,10 @@ import { import DatePickers from './Logs.DatePickers' import Link from 'next/link' import React from 'react' -import { checkPermissions, useStore } from 'hooks' +import { checkPermissions } from 'hooks' +import { useProfileQuery } from 'data/profile/profile-query' -interface Props { +export interface LogsQueryPanelProps { templates?: LogTemplate[] onSelectTemplate: (template: LogTemplate) => void onSelectSource: (source: LogsTableName) => void @@ -29,7 +30,7 @@ interface Props { warnings: LogsWarning[] } -const LogsQueryPanel: React.FC = ({ +const LogsQueryPanel = ({ templates = [], onSelectTemplate, hasEditorValue, @@ -42,18 +43,15 @@ const LogsQueryPanel: React.FC = ({ defaultTo, onDateChange, warnings, -}) => { - const { ui } = useStore() +}: LogsQueryPanelProps) => { + const { data: profile } = useProfileQuery() const canCreateLogQuery = checkPermissions(PermissionAction.CREATE, 'user_content', { - resource: { type: 'log_sql', owner_id: ui.profile?.id }, - subject: { id: ui.profile?.id }, + resource: { type: 'log_sql', owner_id: profile?.id }, + subject: { id: profile?.id }, }) return ( -
+
diff --git a/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx b/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx index 57dac2a1607..bee4b202877 100644 --- a/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx +++ b/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx @@ -1,27 +1,28 @@ -import { FC, ReactNode, useEffect } from 'react' +import { ReactNode, useEffect } from 'react' import { observer } from 'mobx-react-lite' import { useStore, withAuth } from 'hooks' +import { useProfileQuery } from 'data/profile/profile-query' import Error from 'components/ui/Error' import { useSqlEditorStore, SqlEditorContext } from 'localStores/sqlEditor/SqlEditorStore' import ProjectLayout from '../ProjectLayout/ProjectLayout' import SQLEditorMenu from './SQLEditorMenu' -interface Props { +export interface SQLEditorLayoutProps { title: string children: ReactNode } -const SQLEditorLayout: FC = ({ title, children }) => { +const SQLEditorLayout = ({ title, children }: SQLEditorLayoutProps) => { const { ui, content, meta } = useStore() - const { profile: user } = ui + const { data: profile } = useProfileQuery() const sqlEditorStore = useSqlEditorStore(ui.selectedProject?.ref, meta) useEffect(() => { if (sqlEditorStore) { // this calls content.load() for us, as well as loading tabs - sqlEditorStore.loadRemotePersistentData(content, user?.id) + sqlEditorStore.loadRemotePersistentData(content, profile?.id) } }, [sqlEditorStore]) diff --git a/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx b/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx index 262e106b899..699839ab3df 100644 --- a/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx +++ b/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx @@ -20,6 +20,7 @@ import { useOptimisticSqlSnippetCreate, useStore, checkPermissions } from 'hooks import { IS_PLATFORM } from 'lib/constants' import QueryTab from 'localStores/sqlEditor/QueryTab' import { useSqlStore, TAB_TYPES } from 'localStores/sqlEditor/SqlEditorStore' +import { useProfileQuery } from 'data/profile/profile-query' import RenameQuery from 'components/to-be-cleaned/SqlEditor/RenameQuery' import ConfirmationModal from 'components/ui/ConfirmationModal' @@ -45,10 +46,8 @@ const OpenQueryItem = observer( ) const DropdownMenu = observer(({ tabInfo }: { tabInfo: QueryTab }) => { - const { - ui: { profile: user }, - content: contentStore, - } = useStore() + const { data: profile } = useProfileQuery() + const { content: contentStore } = useStore() const sqlEditorStore: any = useSqlStore() @@ -114,7 +113,7 @@ const DropdownMenu = observer(({ tabInfo }: { tabInfo: QueryTab }) => { await contentStore.del(id) sqlEditorStore.loadTabs( - sqlEditorStore.tabsFromContentStore(contentStore, user?.id), + sqlEditorStore.tabsFromContentStore(contentStore, profile?.id), false ) }} @@ -129,13 +128,13 @@ const DropdownMenu = observer(({ tabInfo }: { tabInfo: QueryTab }) => { }) const SideBarContent = observer(() => { - const { ui } = useStore() + const { data: profile } = useProfileQuery() const sqlEditorStore: any = useSqlStore() const [filterString, setFilterString] = useState('') const canCreateSQLSnippet = checkPermissions(PermissionAction.CREATE, 'user_content', { - resource: { type: 'sql', owner_id: ui.profile?.id }, - subject: { id: ui.profile?.id }, + resource: { type: 'sql', owner_id: profile?.id }, + subject: { id: profile?.id }, }) const handleNewQuery = useOptimisticSqlSnippetCreate(canCreateSQLSnippet) diff --git a/studio/components/to-be-cleaned/SqlEditor/TabSqlQuery.js b/studio/components/to-be-cleaned/SqlEditor/TabSqlQuery.js index f5cd6f030ea..4c16b6571b7 100644 --- a/studio/components/to-be-cleaned/SqlEditor/TabSqlQuery.js +++ b/studio/components/to-be-cleaned/SqlEditor/TabSqlQuery.js @@ -12,13 +12,15 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useKeyboardShortcuts, useStore, useWindowDimensions, checkPermissions } from 'hooks' import Telemetry from 'lib/telemetry' import { copyToClipboard, timeout } from 'lib/helpers' +import { useProfileQuery } from 'data/profile/profile-query' import { useSqlStore, UTILITY_TAB_TYPES } from 'localStores/sqlEditor/SqlEditorStore' import { SQL_SNIPPET_SCHEMA_VERSION } from './SqlEditor.constants' import UtilityActions from 'components/interfaces/SQLEditor/TabSqlQuery/UtilityActions' const TabSqlQuery = observer(() => { const sqlEditorStore = useSqlStore() - const { ui, content: contentStore } = useStore() + const { data: profile } = useProfileQuery() + const { content: contentStore } = useStore() const { height: screenHeight } = useWindowDimensions() const snapOffset = 50 @@ -27,8 +29,8 @@ const TabSqlQuery = observer(() => { const offset = 3 const canCreateSQLSnippet = checkPermissions(PermissionAction.CREATE, 'user_content', { - resource: { type: 'sql', owner_id: ui.profile?.id }, - subject: { id: ui.profile?.id }, + resource: { type: 'sql', owner_id: profile?.id }, + subject: { id: profile?.id }, }) useEffect(() => { diff --git a/studio/components/to-be-cleaned/SqlEditor/TabWelcome.js b/studio/components/to-be-cleaned/SqlEditor/TabWelcome.js index 9faa88f801b..5ca96a1c348 100644 --- a/studio/components/to-be-cleaned/SqlEditor/TabWelcome.js +++ b/studio/components/to-be-cleaned/SqlEditor/TabWelcome.js @@ -5,15 +5,17 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import Telemetry from 'lib/telemetry' import { useOptimisticSqlSnippetCreate, checkPermissions, useStore } from 'hooks' +import { useProfileQuery } from 'data/profile/profile-query' import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.constants' import CardButton from 'components/ui/CardButton' const TabWelcome = observer(() => { const { ui } = useStore() + const { data: profile } = useProfileQuery() const [sql, quickStart] = partition(SQL_TEMPLATES, { type: 'template' }) const canCreateSQLSnippet = checkPermissions(PermissionAction.CREATE, 'user_content', { - resource: { type: 'sql', owner_id: ui.profile?.id }, - subject: { id: ui.profile?.id }, + resource: { type: 'sql', owner_id: profile?.id }, + subject: { id: profile?.id }, }) const handleNewQuery = useOptimisticSqlSnippetCreate(canCreateSQLSnippet) diff --git a/studio/components/ui/Flag/FlagProvider.tsx b/studio/components/ui/Flag/FlagProvider.tsx index 40d1ac7dae8..187f80e9bd9 100644 --- a/studio/components/ui/Flag/FlagProvider.tsx +++ b/studio/components/ui/Flag/FlagProvider.tsx @@ -1,17 +1,15 @@ -import { FC, useEffect, useState } from 'react' -import { observer } from 'mobx-react-lite' +import { User } from '@supabase/supabase-js' +import { PropsWithChildren, useEffect, useState } from 'react' import * as configcat from 'configcat-js' -import { User } from 'types' -import { useStore } from 'hooks' import { IS_PLATFORM } from 'lib/constants' import FlagContext from './FlagContext' +import { useUser } from 'lib/auth' let client: configcat.IConfigCatClient -const FlagProvider: FC = ({ children }) => { - const { ui } = useStore() - const { profile } = ui +const FlagProvider = ({ children }: PropsWithChildren<{}>) => { + const user = useUser() const { Provider } = FlagContext const [store, setStore] = useState({}) @@ -27,8 +25,8 @@ const FlagProvider: FC = ({ children }) => { const flagStore: any = {} const flagValues = - user !== undefined - ? await client.getAllValuesAsync(new configcat.User(user.primary_email)) + user?.email !== undefined + ? await client.getAllValuesAsync(new configcat.User(user.email)) : await client.getAllValuesAsync() flagValues.forEach((item: any) => { flagStore[item.settingKey] = item.settingValue @@ -41,10 +39,10 @@ const FlagProvider: FC = ({ children }) => { // as per https://configcat.com/docs/sdk-reference/js/#polling-modes: // The polling downloads the config.json at the set interval and are stored in the internal cache // which subsequently all getValueAsync() calls are served from there - if (IS_PLATFORM) getFlags(profile) - }, [profile]) + if (IS_PLATFORM) getFlags(user ?? undefined) + }, [user]) return {children} } -export default observer(FlagProvider) +export default FlagProvider diff --git a/studio/components/ui/SparkBar.tsx b/studio/components/ui/SparkBar.tsx index a4f45c1ce30..4d89e5f80ad 100644 --- a/studio/components/ui/SparkBar.tsx +++ b/studio/components/ui/SparkBar.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx' import { FC } from 'react' interface Props { @@ -30,10 +31,15 @@ const SparkBar: FC = ({
{hasLabels && (
- +

0 && 'max-w-[75%]' + )} + > {labelBottom} - - {labelTop} +

+

{labelTop}

)}
> + +export const useEdgeFunctionUpdateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => updateEdgeFunction(vars), + { + async onSuccess(data, variables, context) { + const { projectRef, slug } = variables + await queryClient.invalidateQueries(edgeFunctionsKeys.detail(projectRef, slug)) + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/data/profile/profile-query.ts b/studio/data/profile/profile-query.ts index d48256ef0d3..479cb7233aa 100644 --- a/studio/data/profile/profile-query.ts +++ b/studio/data/profile/profile-query.ts @@ -40,7 +40,10 @@ export const useProfileQuery = ({ useQuery( profileKeys.profile(), ({ signal }) => getProfile(signal), - options + { + staleTime: 1000 * 60 * 30, // default good for 30 mins + ...options, + } ) export const useProfilePrefetch = () => { diff --git a/studio/hooks/misc/useOptimisticSqlSnippetCreate.ts b/studio/hooks/misc/useOptimisticSqlSnippetCreate.ts index aaf7c304491..8791a61ee86 100644 --- a/studio/hooks/misc/useOptimisticSqlSnippetCreate.ts +++ b/studio/hooks/misc/useOptimisticSqlSnippetCreate.ts @@ -1,11 +1,12 @@ import { createSqlSnippetSkeleton } from 'components/to-be-cleaned/SqlEditor/SqlEditor.utils' +import { useProfileQuery } from 'data/profile/profile-query' import { useSqlStore } from 'localStores/sqlEditor/SqlEditorStore' import { useCallback } from 'react' import { useStore } from './useStore' export function useOptimisticSqlSnippetCreate(canCreateSQLSnippet: boolean) { const { ui, content: contentStore } = useStore() - const { profile: user } = ui + const { data: profile } = useProfileQuery() const sqlEditorStore: any = useSqlStore() @@ -14,13 +15,16 @@ export function useOptimisticSqlSnippetCreate(canCreateSQLSnippet: boolean) { // get currently selected tab id in case we need to roll back to it const previouslySelectedTabId = sqlEditorStore.selectedTabId - const snippet = createSqlSnippetSkeleton({ owner_id: user?.id, ...args }) + const snippet = createSqlSnippetSkeleton({ + owner_id: profile?.id, + ...args, + }) // save the snippet in memory in the content store with a client-generated id const { data } = contentStore.createOptimistically(snippet) // update the tabs in the sql editor storage to match the content store - const tabs = sqlEditorStore.tabsFromContentStore(contentStore, user?.id) + const tabs = sqlEditorStore.tabsFromContentStore(contentStore, profile?.id) sqlEditorStore.loadTabs(tabs, false) // select tab with new snippet @@ -42,6 +46,6 @@ export function useOptimisticSqlSnippetCreate(canCreateSQLSnippet: boolean) { }) } }, - [user, sqlEditorStore, contentStore] + [profile, sqlEditorStore, contentStore] ) } diff --git a/studio/hooks/misc/withAuth.tsx b/studio/hooks/misc/withAuth.tsx index d2cdc5d94fd..787b0ab730e 100644 --- a/studio/hooks/misc/withAuth.tsx +++ b/studio/hooks/misc/withAuth.tsx @@ -1,13 +1,13 @@ import Head from 'next/head' -import { NextRouter, useRouter } from 'next/router' +import { useRouter } from 'next/router' import { ComponentType, useEffect } from 'react' import { usePermissionsQuery } from 'data/permissions/permissions-query' -import { useProfileQuery } from 'data/profile/profile-query' -import { useStore } from 'hooks' +import { useParams, useStore } from 'hooks' +import { useAuth } from 'lib/auth' import { IS_PLATFORM } from 'lib/constants' import { getReturnToPath, STORAGE_KEY } from 'lib/gotrue' -import { NextPageWithLayout } from 'types' +import { isNextPageWithLayout, NextPageWithLayout } from 'types' import Error500 from '../../pages/500' const PLATFORM_ONLY_PAGES = [ @@ -22,32 +22,26 @@ export function withAuth( WrappedComponent: ComponentType | NextPageWithLayout, options?: { redirectTo: string + /* run the redirect if the user is logged in */ redirectIfFound?: boolean } ) { const WithAuthHOC: ComponentType = (props: any) => { const router = useRouter() + const { ref, slug } = useParams() const rootStore = useStore() + const { isLoading, session } = useAuth() - const { ref, slug } = router.query const { app, ui } = rootStore const page = router.pathname.split('/').slice(3).join('/') const redirectTo = options?.redirectTo ?? defaultRedirectTo(ref) const redirectIfFound = options?.redirectIfFound - const { - data: profile, - isLoading, - error, - } = useProfileQuery({ - onSuccess(profile) { - ui.setProfile(profile) - - if (!app.organizations.isInitialized) app.organizations.load() - if (!app.projects.isInitialized) app.projects.load() - }, - }) + useEffect(() => { + if (!app.organizations.isInitialized) app.organizations.load() + if (!app.projects.isInitialized) app.projects.load() + }, [app.organizations.isInitialized, app.projects.isInitialized]) usePermissionsQuery({ enabled: IS_PLATFORM, @@ -56,12 +50,14 @@ export function withAuth( }, }) + const isLoggedIn = Boolean(session) + const isAccessingBlockedPage = !IS_PLATFORM && PLATFORM_ONLY_PAGES.some((platformOnlyPage) => page.startsWith(platformOnlyPage)) const isRedirecting = isAccessingBlockedPage || - checkRedirectTo(isLoading, router, profile, error, redirectTo, redirectIfFound) + checkRedirectTo(isLoading, router.pathname, isLoggedIn, redirectTo, redirectIfFound) useEffect(() => { // This should run after setting store data @@ -73,13 +69,13 @@ export function withAuth( useEffect(() => { if (router.isReady) { if (ref) { - rootStore.setProjectRef(Array.isArray(ref) ? ref[0] : ref) + rootStore.setProjectRef(ref) } - rootStore.setOrganizationSlug(slug ? String(slug) : undefined) + rootStore.setOrganizationSlug(slug) } }, [isLoading, router.isReady, ref, slug]) - if (!isLoading && !isRedirecting && !profile && error) { + if (!isLoading && !isRedirecting && !isLoggedIn) { return } @@ -101,10 +97,10 @@ export function withAuth( ) } - WithAuthHOC.displayName = `WithAuth(${WrappedComponent.displayName})` + WithAuthHOC.displayName = `withAuth(${WrappedComponent.displayName})` - if ('getLayout' in WrappedComponent) { - ;(WithAuthHOC as any).getLayout = WrappedComponent.getLayout + if (isNextPageWithLayout(WrappedComponent)) { + ;(WithAuthHOC as NextPageWithLayout).getLayout = WrappedComponent.getLayout } return WithAuthHOC @@ -116,20 +112,19 @@ function defaultRedirectTo(ref: string | string[] | undefined) { function checkRedirectTo( loading: boolean, - router: NextRouter, - profile: any, - profileError: any, + pathname: string, + isLoggedIn: boolean, redirectTo: string, redirectIfFound?: boolean ) { if (loading) return false - if (router.pathname == redirectTo) return false + if (pathname === redirectTo) return false // If redirectTo is set, redirect if the user is not logged in. - if (redirectTo && !redirectIfFound && profileError?.code === 401) return true + if (redirectTo && !redirectIfFound && !isLoggedIn) return true // If redirectIfFound is also set, redirect if the user was found - if (redirectIfFound && profile) return true + if (redirectIfFound && isLoggedIn) return true return false } diff --git a/studio/lib/auth.tsx b/studio/lib/auth.tsx new file mode 100644 index 00000000000..a2066c37ff0 --- /dev/null +++ b/studio/lib/auth.tsx @@ -0,0 +1,144 @@ +import { Session } from '@supabase/supabase-js' +import { useProfileQuery } from 'data/profile/profile-query' +import { useStore } from 'hooks' +import { auth } from 'lib/gotrue' +import { + createContext, + PropsWithChildren, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react' +import { GOTRUE_ERRORS } from './constants' + +/* Auth Context */ + +export type AuthContext = { refreshSession: () => Promise } & ( + | { + session: Session + isLoading: false + } + | { + session: null + isLoading: true + } + | { + session: null + isLoading: false + } +) + +export const AuthContext = createContext({ + session: null, + isLoading: true, + refreshSession: () => Promise.resolve(null), +}) + +export type AuthProviderProps = {} + +export const AuthProvider = ({ children }: PropsWithChildren) => { + const { ui } = useStore() + const [session, setSession] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + // Check for unverified GitHub users after a GitHub sign in + useEffect(() => { + async function handleEmailVerificationError() { + const { error } = await auth.initialize() + + if (error?.message === GOTRUE_ERRORS.UNVERIFIED_GITHUB_USER) { + ui.setNotification({ + category: 'error', + message: + 'Please verify your email on GitHub first, then reach out to us at support@supabase.io to log into the dashboard', + }) + } + } + + handleEmailVerificationError() + }, []) + + // Setup a possible existing session + useEffect(() => { + let mounted = true + + auth + .getSession() + .then(({ data: { session } }) => { + if (mounted) { + if (session) { + setSession(session) + } + + setIsLoading(false) + } + }) + .catch(() => { + if (mounted) { + setIsLoading(false) + } + }) + + return () => { + mounted = false + } + }, []) + + // Keep the session in sync + useEffect(() => { + const { + data: { subscription }, + } = auth.onAuthStateChange((_event, session) => { + setSession(session) + setIsLoading(false) + }) + + return subscription.unsubscribe + }, []) + + // Track telemetry for the current user + useProfileQuery({ + onSuccess(profile) { + ui.setProfile(profile) + }, + // Never rerun the query + staleTime: Infinity, + cacheTime: Infinity, + }) + + // Helper method to refresh the session. + // For example after a user updates their profile + const refreshSession = useCallback(async () => { + const { + data: { session }, + } = await auth.refreshSession() + + return session + }, []) + + const value = useMemo(() => { + if (session) { + return { session, isLoading: false, refreshSession } as const + } + + return { session: null, isLoading: isLoading, refreshSession } as const + }, [session, isLoading, refreshSession]) + + return {children} +} + +/* Auth Utils */ + +export const useAuth = () => useContext(AuthContext) + +export const useSession = () => useAuth().session + +export const useUser = () => useSession()?.user ?? null + +export const useIsLoggedIn = () => { + const user = useUser() + + return user !== null +} diff --git a/studio/pages/_app.tsx b/studio/pages/_app.tsx index 9f03a99259e..96d5f954a5d 100644 --- a/studio/pages/_app.tsx +++ b/studio/pages/_app.tsx @@ -32,8 +32,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { RootStore } from 'stores' import HCaptchaLoadedStore from 'stores/hcaptcha-loaded-store' import { StoreProvider } from 'hooks' -import { GOTRUE_ERRORS } from 'lib/constants' -import { auth } from 'lib/gotrue' +import { AuthProvider } from 'lib/auth' import { dart } from 'lib/constants/prism' import { useRootQueryClient } from 'data/query-client' @@ -49,26 +48,10 @@ dayjs.extend(relativeTime) dart(Prism) -function MyApp({ Component, pageProps }: AppPropsWithLayout) { +function CustomApp({ Component, pageProps }: AppPropsWithLayout) { const queryClient = useRootQueryClient() const [rootStore] = useState(() => new RootStore()) - useEffect(() => { - async function handleEmailVerificationError() { - const { error } = await auth.initialize() - - if (error?.message === GOTRUE_ERRORS.UNVERIFIED_GITHUB_USER) { - rootStore.ui.setNotification({ - category: 'error', - message: - 'Please verify your email on GitHub first, then reach out to us at support@supabase.io to log into the dashboard', - }) - } - } - - handleEmailVerificationError() - }, []) - const getSavingState = () => rootStore.content.savingState // prompt the user if they try and leave with unsaved content store changes @@ -108,39 +91,42 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { - - - Supabase - - + + + + Supabase + + - + - - - {getLayout()} - - + + + {getLayout()} + + - - - - + + + + + ) } -export default MyApp + +export default CustomApp diff --git a/studio/pages/account/me.tsx b/studio/pages/account/me.tsx index e5cdeeeb8bd..6c00e76aa4e 100644 --- a/studio/pages/account/me.tsx +++ b/studio/pages/account/me.tsx @@ -1,14 +1,13 @@ import { observer } from 'mobx-react-lite' -import { useEffect, useState } from 'react' import { Button, IconMoon, IconSun, Input, Listbox } from 'ui' -import { Session } from '@supabase/supabase-js' import { AccountLayout } from 'components/layouts' import SchemaFormPanel from 'components/to-be-cleaned/forms/SchemaFormPanel' import Panel from 'components/ui/Panel' +import { Profile as ProfileType, useProfileQuery } from 'data/profile/profile-query' import { useProfileUpdateMutation } from 'data/profile/profile-update-mutation' import { useStore } from 'hooks' -import { auth } from 'lib/gotrue' +import { useSession } from 'lib/auth' import Link from 'next/link' import { NextPageWithLayout } from 'types' @@ -38,12 +37,14 @@ export default User const ProfileCard = observer(() => { const { ui } = useStore() - const { mutate } = useProfileUpdateMutation() - const user = ui.profile + const { mutateAsync } = useProfileUpdateMutation() + + const { data: profile } = useProfileQuery() + // TODO: ^ handle loading state const updateUser = async (model: any) => { try { - mutate({ + await mutateAsync({ firstName: model.first_name, lastName: model.last_name, }) @@ -58,26 +59,10 @@ const ProfileCard = observer(() => { } } - const [session, setSession] = useState(null) - - useEffect(() => { - let cancel = false - ;(async () => { - const { - data: { session }, - } = await auth.getSession() - if (session && !cancel) setSession(session) - })() - - return () => { - cancel = true - } - }, []) - return (
- +
@@ -93,8 +78,8 @@ const ProfileCard = observer(() => { }, }} model={{ - first_name: user?.first_name ?? '', - last_name: user?.last_name ?? '', + first_name: profile?.first_name ?? '', + last_name: profile?.last_name ?? '', }} onSubmit={updateUser} /> @@ -107,8 +92,8 @@ const ProfileCard = observer(() => { ) }) -const Profile = observer(({ session }: any) => { - const { ui } = useStore() +const Profile = ({ profile }: { profile?: ProfileType }) => { + const session = useSession() return ( { disabled label="Username" layout="horizontal" - value={ui.profile?.username ?? ''} + value={profile?.username ?? ''} /> {session?.user.app_metadata.provider === 'email' && (
@@ -154,7 +139,7 @@ const Profile = observer(({ session }: any) => { ) -}) +} const ThemeSettings = observer(() => { const { ui } = useStore() diff --git a/studio/pages/project/[ref]/reports/index.tsx b/studio/pages/project/[ref]/reports/index.tsx index f4ae714fbba..aece220eb06 100644 --- a/studio/pages/project/[ref]/reports/index.tsx +++ b/studio/pages/project/[ref]/reports/index.tsx @@ -4,10 +4,11 @@ import { useRouter } from 'next/router' import { PermissionAction } from '@supabase/shared-types/out/constants' import { NextPageWithLayout } from 'types' -import { checkPermissions, useFlag, useStore } from 'hooks' +import { checkPermissions, useFlag, useParams, useStore } from 'hooks' import { post } from 'lib/common/fetch' import { API_URL, PROJECT_STATUS } from 'lib/constants' import { useProjectContentStore } from 'stores/projectContentStore' +import { useProfileQuery } from 'data/profile/profile-query' import Loading from 'components/ui/Loading' import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' import { createReport } from 'components/to-be-cleaned/Reports/Reports.utils' @@ -17,15 +18,16 @@ export const UserReportPage: NextPageWithLayout = () => { const [loading, setLoading] = useState(true) const router = useRouter() - const { ref } = router.query + const { ref } = useParams() + const { data: profile } = useProfileQuery() const { ui } = useStore() const project = ui.selectedProject const contentStore = useProjectContentStore(ref) const canCreateReport = checkPermissions(PermissionAction.CREATE, 'user_content', { - resource: { type: 'report', owner_id: ui.profile?.id }, - subject: { id: ui.profile?.id }, + resource: { type: 'report', owner_id: profile?.id }, + subject: { id: profile?.id }, }) const kpsEnabled = useFlag('initWithKps') diff --git a/studio/stores/UiStore.ts b/studio/stores/UiStore.ts index 74202e1623e..b568fcc85ab 100644 --- a/studio/stores/UiStore.ts +++ b/studio/stores/UiStore.ts @@ -15,7 +15,6 @@ export interface IUiStore { selectedProjectBaseInfo?: ProjectBase selectedOrganization?: Organization notification?: Notification - profile?: User permissions?: Permission[] googleAnalyticsProps?: GoogleAnalyticsProps @@ -26,7 +25,7 @@ export interface IUiStore { setProjectRef: (ref?: string) => void setOrganizationSlug: (slug?: string) => void setNotification: (notification: Notification) => string - setProfile: (value?: User) => void + setProfile: (value: User) => void setPermissions: (permissions?: Permission[]) => void setGaClientId: (clientId?: string) => void } @@ -39,7 +38,6 @@ export default class UiStore implements IUiStore { selectedProjectRef?: string selectedOrganizationSlug?: string notification?: Notification - profile?: User permissions?: Permission[] = [] constructor(rootStore: IRootStore) { @@ -158,12 +156,8 @@ export default class UiStore implements IUiStore { return id } - setProfile(value?: User) { - if (value && value?.id !== this.profile?.id) { - Telemetry.sendIdentify(value, this.googleAnalyticsProps) - } - - this.profile = value + setProfile(value: User) { + Telemetry.sendIdentify(value, this.googleAnalyticsProps) } setPermissions(permissions?: any) { diff --git a/studio/types/next.ts b/studio/types/next.ts index 04bd6659eb4..629d626cc8b 100644 --- a/studio/types/next.ts +++ b/studio/types/next.ts @@ -1,6 +1,6 @@ import { NextPage } from 'next' import { AppProps } from 'next/app' -import { ReactElement, ReactNode } from 'react' +import { ComponentType, ReactElement, ReactNode } from 'react' export type AppPropsWithLayout = AppProps & { Component: NextPageWithLayout @@ -9,3 +9,9 @@ export type AppPropsWithLayout = AppProps & { export type NextPageWithLayout

= NextPage & { getLayout?: (page: ReactElement) => ReactNode } + +export function isNextPageWithLayout( + Component: ComponentType | NextPageWithLayout +): Component is NextPageWithLayout { + return 'getLayout' in Component && typeof Component.getLayout === 'function' +}