Merge pull request #12904 from supabase/master

Prod deploy dashboard
This commit is contained in:
Joshen Lim authored and GitHub committed 2023-03-09 15:32:56 +08:00
commit a5bce1226c
49 files changed
+1472 -503

No files matched your search

+4 -4
View File
@@ -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
@@ -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: [] },
],
},
@@ -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 }) => <Layout meta={meta} children={children} />
export default Page
@@ -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.
<Admonition type="info">
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.
</Admonition>
## 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 `<form>` 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://<project>.supabase.co/auth/v1/sso/saml/metadata` |
| Metadata URL | `https://<project>.supabase.co/auth/v1/sso/saml/metadata` |
| ACS URL | `https://<project>.supabase.co/auth/v1/sso/saml/acs` |
| SLO URL | `https://<project>.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`
<Admonition type="caution">
If you use [Multi-Factor Authentication](/guides/auth/auth-mfa) with SSO, the `amr` array may have
a different method at index `0`!
</Admonition>
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: <anon-jwt>' \
-H 'Authorization: Bearer <service_role-jwt>' \
--data-binary '@/tmp/body.json' \
'https://<project>.supabase.co/auth/v1/admin/sso/providers/<provider-uuid>'
```
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
<saml:AttributeStatement>
<!-- will be mapped to the email key -->
<saml:Attribute
Name="mail"
NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"
>
<saml:AttributeValue xsi:type="xs:string">
jane.doe@company.com
</saml:AttributeValue>
</saml:Attribute>
<!-- will be mapped to the first_name key -->
<saml:Attribute
Name="givenName"
NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"
>
<saml:AttributeValue xsi:type="xs:string">
Jane Doe
</saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>
```
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: <anon-jwt>' \
-H 'Authorization: Bearer <service_role-jwt>' \
'https://<project>.supabase.co/auth/v1/admin/sso/providers/<provider-uuid>'
```
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: <anon-jwt>' \
-H 'Authorization: Bearer <service_role-jwt>' \
--data-binary '@/tmp/body.json' \
'https://<project>.supabase.co/auth/v1/admin/sso/providers/<provider-uuid>'
```
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 }) => <Layout meta={meta} children={children} />
export default Page
@@ -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'
@@ -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
<script lang="ts">
import { supabase } from '$lib/supabaseClient'
import { invalidate } from '$app/navigation'
import { onMount } from 'svelte'
import './styles.css'
```ts title=src/hooks.server.ts
// src/hooks.server.ts
import {
PUBLIC_SUPABASE_URL,
PUBLIC_SUPABASE_ANON_KEY
} from '$env/static/public';
import { createSupabaseServerClient } from '@supabase/auth-helpers-sveltekit';
import type { Handle } from '@sveltejs/kit';
onMount(() => {
export const handle: Handle = async ({ event, resolve }) => {
event.locals.supabase = createSupabaseServerClient({
supabaseUrl: PUBLIC_SUPABASE_URL,
supabaseKey: PUBLIC_SUPABASE_ANON_KEY,
event
});
/**
* A convenience helper so we can just call await getSession() instead const { data: { session } } = await supabase.auth.getSession()
*/
event.locals.getSession = async () => {
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(() => {
invalidate('supabase:auth')
})
data: { session }
} = await event.locals.supabase.auth.getSession();
return session;
};
return () => {
subscription.unsubscribe()
return resolve(event, {
/**
* There´s an issue with `filterSerializedResponseHeaders` not working when using `sequence`
*
* https://github.com/sveltejs/kit/issues/8061
*/
filterSerializedResponseHeaders(name) {
return name === 'content-range';
}
})
</script>
<div class="container" style="padding: 50px 0 100px 0">
<slot />
</div>
});
};
```
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<Session | null>;
}
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
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { supabase } from '$lib/supabaseClient'
import '../styles.css';
import { invalidate } from '$app/navigation'
import { onMount } from 'svelte'
import type { PageData } from './$types'
export let data: PageData
let loading = false
let email: string
$: ({ supabase } = data)
const handleLogin = async () => {
try {
loading = true
const { error } = await supabase.auth.signInWithOtp({ email })
if (error) throw error
alert('Check your email for the login link!')
} catch (error) {
if (error instanceof Error) {
alert(error.message)
}
} finally {
loading = false
}
}
onMount(() => {
const { data } = supabase.auth.onAuthStateChange(() => {
invalidate('supabase:auth')
})
return () => data.subscription.unsubscribe()
})
</script>
<form class="row flex-center flex" on:submit|preventDefault="{handleLogin}">
<div class="col-6 form-widget">
<h1 class="header">Supabase + SvelteKit</h1>
<p class="description">Sign in via magic link with your email below</p>
<div>
<input class="inputField" type="email" placeholder="Your email" bind:value="{email}" />
</div>
<div>
<input type="submit" class="button block" value={loading ? 'Loading' : 'Send magic link'}
disabled={loading} />
</div>
</div>
</form>
<svelte:head>
<title>User Management</title>
</svelte:head>
<div class="container" style="padding: 50px 0 100px 0">
<slot />
</div>
```
### 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
<!-- src/routes/+page.svelte -->
<script lang="ts">
import { Auth } from '@supabase/auth-ui-svelte';
import { ThemeSupa } from '@supabase/auth-ui-shared';
import type { PageData } from './$types';
export let data: PageData;
</script>
<svelte:head>
<title>User Management</title>
</svelte:head>
<div class="row flex-center flex">
<div class="col-6 form-widget">
<Auth
supabaseClient={data.supabase}
view="magic_link"
redirectTo={`${data.url}/logging-in?redirect=/`}
showLinks={false}
appearance={{ theme: ThemeSupa, style: { input: 'color: #fff' } }}
/>
</div>
</div>
```
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
<!-- src/routes/logging-in/+page.svelte -->
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import type { PageData } from './$types';
export let data: PageData;
$: {
const redirectTo = $page.url.searchParams.get('redirect');
// check if user has been set in session store then redirect
if (browser && data.session) {
goto(redirectTo ?? '/account');
}
}
</script>
<section>
"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
</section>
```
### 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
<!-- src/routes/account/+page.svelte -->
<script lang="ts">
import { onMount } from 'svelte'
import type { AuthSession } from '@supabase/supabase-js'
import { supabase } from '$lib/supabaseClient'
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
export let session: AuthSession
export let data: PageData;
export let form: ActionData;
let loading = false
let username: string | null = null
let website: string | null = null
let avatarUrl: string | null = null
let { session, profile } = data;
onMount(() => {
getProfile()
})
let profileForm: any;
let loading = false;
let fullName: string | null = profile?.full_name;
let username: string | null = profile?.username;
let website: string | null = profile?.website;
let avatarUrl: string | null = profile?.avatar_url;
const getProfile = async () => {
try {
loading = true
const { user } = session
const { data, error, status } = await supabase
.from('profiles')
.select(`username, website, avatar_url`)
.eq('id', user.id)
.single()
if (data) {
username = data.username
website = data.website
avatarUrl = data.avatar_url
}
if (error && status !== 406) throw error
} catch (error) {
if (error instanceof Error) {
alert(error.message)
}
} finally {
loading = false
}
}
async function updateProfile() {
try {
loading = true
const { user } = session
const updates = {
id: user.id,
username,
website,
avatar_url: avatarUrl,
updated_at: new Date(),
}
let { error } = await supabase.from('profiles').upsert(updates)
if (error) throw error
} catch (error) {
if (error instanceof Error) {
alert(error.message)
}
} finally {
loading = false
}
}
async function signOut() {
try {
loading = true
let { error } = await supabase.auth.signOut()
if (error) throw error
} catch (error) {
if (error instanceof Error) {
alert(error.message)
}
} finally {
loading = false
}
}
function handleSubmit() {
loading = true;
return async () => {
loading = false;
};
}
</script>
<form class="form-widget" on:submit|preventDefault="{updateProfile}">
<div>
<label for="email">Email</label>
<input id="email" type="text" value="{session.user.email}" disabled />
</div>
<div>
<label for="username">Name</label>
<input id="username" type="text" bind:value="{username}" />
</div>
<div>
<label for="website">Website</label>
<input id="website" type="website" bind:value="{website}" />
</div>
<div class="form-widget">
<form
class="form-widget"
method="post"
action="?/update"
use:enhance={handleSubmit}
bind:this={profileForm}
>
<div>
<label for="email">Email</label>
<input id="email" type="text" value={session.user.email} disabled />
</div>
<div>
<input type="submit" class="button block primary" value={loading ? 'Loading...' : 'Update'}
disabled={loading} />
</div>
<div>
<label for="fullName">Full Name</label>
<input id="fullName" name="fullName" type="text" value={form?.fullName ?? fullName} />
</div>
<div>
<button class="button block" on:click="{signOut}" disabled="{loading}">Sign Out</button>
</div>
</form>
<div>
<label for="username">Username</label>
<input id="username" name="username" type="text" value={form?.username ?? username} />
</div>
<div>
<label for="website">Website</label>
<input id="website" name="website" type="website" value={form?.website ?? website} />
</div>
<div>
<input
type="submit"
class="button block primary"
value={loading ? 'Loading...' : 'Update'}
disabled={loading}
/>
</div>
</form>
<form method="post" action="?/signout" use:enhance={handleSubmit}>
<div>
<button class="button block" disabled={loading}>Sign Out</button>
</div>
</form>
</div>
```
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
<script>
import { page } from '$app/stores'
import Account from './Account.svelte'
import Auth from './Auth.svelte'
</script>
<svelte:head>
<title>Supabase + SvelteKit</title>
<meta name="description" content="SvelteKit using supabase-js v2" />
</svelte:head>
{#if !$page.data.session}
<Auth />
{:else}
<Account session="{$page.data.session}" />
{/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
<!-- src/routes/account/Avatar.svelte -->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { supabase } from '$lib/supabaseClient'
import type { SupabaseClient } from '@supabase/supabase-js';
import { createEventDispatcher } from 'svelte';
export let size = 10
export let url: string
export let size = 10;
export let url: string;
export let supabase: SupabaseClient;
let avatarUrl: string | null = null
let uploading = false
let files: FileList
let avatarUrl: string | null = null;
let uploading = false;
let files: FileList;
const dispatch = createEventDispatcher()
const dispatch = createEventDispatcher();
const downloadImage = async (path: string) => {
try {
const { data, error } = await supabase.storage.from('avatars').download(path)
const downloadImage = async (path: string) => {
try {
const { data, error } = await supabase.storage.from('avatars').download(path);
if (error) {
throw error
}
if (error) {
throw error;
}
const url = URL.createObjectURL(data)
avatarUrl = url
} catch (error) {
if (error instanceof Error) {
console.log('Error downloading image: ', error.message)
}
}
}
const url = URL.createObjectURL(data);
avatarUrl = url;
} catch (error) {
if (error instanceof Error) {
console.log('Error downloading image: ', error.message);
}
}
};
const uploadAvatar = async () => {
try {
uploading = true
const uploadAvatar = async () => {
try {
uploading = true;
if (!files || files.length === 0) {
throw new Error('You must select an image to upload.')
}
if (!files || files.length === 0) {
throw new Error('You must select an image to upload.');
}
const file = files[0]
const fileExt = file.name.split('.').pop()
const filePath = `${Math.random()}.${fileExt}`
const file = files[0];
const fileExt = file.name.split('.').pop();
url = `${Math.random()}.${fileExt}`;
let { error } = await supabase.storage.from('avatars').upload(filePath, file)
let { error } = await supabase.storage.from('avatars').upload(url, file);
if (error) {
throw error
}
if (error) {
throw error;
}
url = filePath
dispatch('upload')
} catch (error) {
if (error instanceof Error) {
alert(error.message)
}
} finally {
uploading = false
}
}
dispatch('upload');
} catch (error) {
if (error instanceof Error) {
alert(error.message);
}
} finally {
uploading = false;
}
};
$: if (url) downloadImage(url)
$: if (url) downloadImage(url);
</script>
<div>
{#if avatarUrl} <img src={avatarUrl} alt={avatarUrl ? 'Avatar' : 'No image'} class="avatar image"
style="height: {size}em; width: {size}em;" /> {:else}
<div class="avatar no-image" style="height: {size}em; width: {size}em;" />
{/if}
{#if avatarUrl}
<img
src={avatarUrl}
alt={avatarUrl ? 'Avatar' : 'No image'}
class="avatar image"
style="height: {size}em; width: {size}em;"
/>
{:else}
<div class="avatar no-image" style="height: {size}em; width: {size}em;" />
{/if}
<input type="hidden" name="avatarUrl" value={url} />
<div style="width: {size}em;">
<label class="button primary block" for="single">
{uploading ? 'Uploading ...' : 'Upload'}
</label>
<input
style="visibility: hidden; position:absolute;"
type="file"
id="single"
accept="image/*"
bind:files
on:change="{uploadAvatar}"
disabled="{uploading}"
/>
</div>
<div style="width: {size}em;">
<label class="button primary block" for="single">
{uploading ? 'Uploading ...' : 'Upload'}
</label>
<input
style="visibility: hidden; position:absolute;"
type="file"
id="single"
accept="image/*"
bind:files
on:change={uploadAvatar}
disabled={uploading}
/>
</div>
</div>
```
@@ -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
<!-- src/routes/account/+page.svelte -->
<script>
// Import the new component
import Avatar from './Avatar.svelte'
</script>
<form use:getProfile class="form-widget" on:submit|preventDefault="{updateProfile}">
<!-- Add to body -->
<Avatar bind:url="{avatarUrl}" size="{10}" on:upload="{updateProfile}" />
<div class="form-widget">
<form
class="form-widget"
method="post"
action="?/update"
use:enhance={handleSubmit}
bind:this={profileForm}
>
<!-- Add to body -->
<Avatar
{supabase}
bind:url={avatarUrl}
size={10}
on:upload={() => {
profileForm.requestSubmit();
}}
/>
<!-- Other form elements -->
</form>
<!-- Other form elements -->
</form>
</div>
```
### Storage management
+1
View File
@@ -18,6 +18,7 @@ Div Arora
Egor Romanov
Fabrizio Fenoglio
Francesco Sansalvadore
Greg Richardson
Haydn Maley
Hieu Pham
Inian P
@@ -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<MyType>()` 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/) <br/>
- 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/) <br/>
- Supabase GeoQueries with PostGIS and Storage Upload. [Video](https://www.youtube.com/watch?v=-_Wpr7rwU8I) <br/>
- 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) <br/>
- 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)<br/>
- 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)<br/>
- How to build a collaborative SaaS product using Next.js and ZenStack's access control policy. [Tutorial](https://zenstack.dev/blog/saas-demo)<br/>
- Nuxt 3, Tailwind, and Supabase Crash Course. [Course](https://dev.to/jacobandrewsky/nuxt-3-tailwind-and-supabase-crash-course-1482)<br/>
- A sneak peek at the Supabase and Next.js RSC SaaS kit. [Article](https://makerkit.dev/blog/changelog/nextjs-supabase-kit-update)<br/>
- 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)<br/>
- 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)<br/>
## 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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

+3
View File
@@ -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
+8 -7
View File
@@ -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
+1
View File
@@ -5,6 +5,7 @@
"requires": true,
"packages": {
"": {
"name": "supabase",
"version": "0.0.0",
"license": "Apache-2.0",
"workspaces": [
@@ -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, { label: string; badgeColor: string }> = {
[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<Props> = ({ status }) => {
const statusMapping = invoiceStatusMapping[status]
return (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Badge
size="small"
className="capitalize"
// @ts-ignore
color={statusMapping?.badgeColor || 'gray'}
>
{statusMapping?.label || status}
</Badge>
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
'w-[300px] space-y-2 border border-scale-200',
].join(' ')}
>
{[InvoiceStatus.OPEN, InvoiceStatus.UNCOLLECTIBLE].includes(status) && (
<p className="text-xs text-scale-1200">
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.
</p>
)}
{status === InvoiceStatus.DRAFT && (
<p className="text-xs text-scale-1200">
The invoice will soon be finalized and charged for.
</p>
)}
{status === InvoiceStatus.PAID && (
<p className="text-xs text-scale-1200">
The invoice has been paid successfully. No action is required on your side.
</p>
)}
{status === InvoiceStatus.VOID && (
<p className="text-xs text-scale-1200">
This invoice has been forgiven. No action is required on your side.
</p>
)}
</div>
</Tooltip.Content>
</Tooltip.Root>
)
}
export default InvoiceStatusBadge
@@ -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<Props> = ({ projectRef }) => {
const [page, setPage] = useState(1)
const [count, setCount] = useState(0)
const [invoices, setInvoices] = useState<any>([])
const [invoices, setInvoices] = useState<Invoice[]>([])
const offset = (page - 1) * PAGE_LIMIT
@@ -85,6 +89,9 @@ const Invoices: FC<Props> = ({ projectRef }) => {
<Table.th key="header-date">Date</Table.th>,
<Table.th key="header-amount">Amount due</Table.th>,
<Table.th key="header-invoice">Invoice number</Table.th>,
<Table.th key="header-invoice" className="flex items-center">
Status
</Table.th>,
<Table.th key="header-download" className="text-right"></Table.th>,
]}
body={
@@ -98,7 +105,7 @@ const Invoices: FC<Props> = ({ projectRef }) => {
</Table.tr>
) : (
<>
{invoices.map((x: any) => {
{invoices.map((x) => {
return (
<Table.tr key={x.id}>
<Table.td>
@@ -117,11 +124,22 @@ const Invoices: FC<Props> = ({ projectRef }) => {
<Table.td>
<p>{x.number}</p>
</Table.td>
<Table.td>
<InvoiceStatusBadge status={x.status} />
</Table.td>
<Table.td className="align-right">
<div className="flex items-center justify-end space-x-2">
{[InvoiceStatus.UNCOLLECTIBLE, InvoiceStatus.OPEN].includes(x.status) && (
<Link href={`https://redirect.revops.supabase.com/pay-invoice/${x.id}`}>
<a target="_blank">
<Button>Pay Now</Button>
</a>
</Link>
)}
<Button
type="outline"
icon={<IconDownload />}
icon={<IconDownload size={16} strokeWidth={1.5} />}
onClick={() => fetchInvoice(x.id)}
/>
</div>
@@ -130,7 +148,7 @@ const Invoices: FC<Props> = ({ projectRef }) => {
)
})}
<Table.tr key="navigation">
<Table.td colSpan={5}>
<Table.td colSpan={6}>
<div className="flex items-center justify-between">
<p className="text-sm opacity-50">
Showing {offset + 1} to {offset + invoices.length} out of {count} invoices
@@ -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
}
@@ -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<any>([])
const [invoices, setInvoices] = useState<Invoice[]>([])
const { stripe_customer_id } = ui.selectedOrganization ?? {}
const offset = (page - 1) * PAGE_LIMIT
@@ -96,6 +99,9 @@ const InvoicesSettings = () => {
<Table.th key="header-date">Date</Table.th>,
<Table.th key="header-amount">Amount due</Table.th>,
<Table.th key="header-invoice">Invoice number</Table.th>,
<Table.th key="header-invoice" className="flex items-center">
Status
</Table.th>,
<Table.th key="header-download" className="text-right"></Table.th>,
]}
body={
@@ -109,7 +115,7 @@ const InvoicesSettings = () => {
</Table.tr>
) : (
<>
{invoices.map((x: any) => {
{invoices.map((x) => {
return (
<Table.tr key={x.id}>
<Table.td>
@@ -124,11 +130,22 @@ const InvoicesSettings = () => {
<Table.td>
<p>{x.number}</p>
</Table.td>
<Table.td>
<InvoiceStatusBadge status={x.status} />
</Table.td>
<Table.td className="align-right">
<div className="flex items-center justify-end space-x-2">
{[InvoiceStatus.UNCOLLECTIBLE, InvoiceStatus.OPEN].includes(x.status) && (
<Link href={`https://redirect.revops.supabase.com/pay-invoice/${x.id}`}>
<a target="_blank">
<Button>Pay Now</Button>
</a>
</Link>
)}
<Button
type="outline"
icon={<IconDownload />}
icon={<IconDownload size={16} strokeWidth={1.5} />}
onClick={() => fetchInvoice(x.id)}
/>
</div>
@@ -137,7 +154,7 @@ const InvoicesSettings = () => {
)
})}
<Table.tr key="navigation">
<Table.td colSpan={5}>
<Table.td colSpan={6}>
<div className="flex items-center justify-between">
<p className="text-sm opacity-50">
Showing {offset + 1} to {offset + invoices.length} out of {count} invoices
@@ -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<Props> = ({ user, members = [], roles = [], rolesAddable = [] }) => {
const InviteMemberButton = ({
userId,
members = [],
roles = [],
rolesAddable = [],
}: InviteMemberButtonProps) => {
const { ui } = useStore()
const { slug } = useParams()
@@ -67,7 +70,7 @@ const InviteMemberButton: FC<Props> = ({ user, members = [], roles = [], rolesAd
const response = await mutateAsync({
slug,
invitedEmail: values.email.toLowerCase(),
ownerId: user.id,
ownerId: userId,
roleId,
})
@@ -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<Props> = ({ 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<Props> = ({ 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
@@ -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"
/>
<div className="flex items-center space-x-4">
{canAddMembers && user !== undefined && (
{canAddMembers && profile !== undefined && (
<div>
<InviteMemberButton
user={user}
userId={profile.id}
members={members}
roles={roles}
rolesAddable={rolesAddable}
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import * as Tooltip from '@radix-ui/react-tooltip'
import { groupBy, isNull } from 'lodash'
import { toJS } from 'mobx'
@@ -17,10 +16,11 @@ import {
} from 'ui'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { checkPermissions, useStore } from 'hooks'
import { checkPermissions, useParams } from 'hooks'
import { uuidv4 } from 'lib/helpers'
import { METRIC_CATEGORIES, METRICS, TIME_PERIODS_REPORTS } from 'lib/constants'
import { useProjectContentStore } from 'stores/projectContentStore'
import { useProfileQuery } from 'data/profile/profile-query'
import Loading from 'components/ui/Loading'
import DateRangePicker from 'components/to-be-cleaned/DateRangePicker'
import NoPermission from 'components/ui/NoPermission'
@@ -31,10 +31,8 @@ const DEFAULT_CHART_COLUMN_COUNT = 12
const DEFAULT_CHART_ROW_COUNT = 4
const Reports = () => {
const { ui } = useStore()
const router = useRouter()
const { id, ref } = router.query
const { id, ref } = useParams()
const { data: profile } = useProfileQuery()
const [report, setReport] = useState<any>()
@@ -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 },
})
/*
@@ -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) {
@@ -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<Props> = ({ 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<Props> = ({ updateSqlSnippet }) => {
)
}
export default observer(UtilityActions)
export default UtilityActions
@@ -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<Props> = ({
const LogsQueryPanel = ({
templates = [],
onSelectTemplate,
hasEditorValue,
@@ -42,18 +43,15 @@ const LogsQueryPanel: React.FC<Props> = ({
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 (
<div
className=" rounded rounded-bl-none rounded-br-none border border-panel-border-light bg-panel-header-light dark:border-panel-border-dark dark:bg-panel-header-dark
"
>
<div className="rounded rounded-bl-none rounded-br-none border border-panel-border-light bg-panel-header-light dark:border-panel-border-dark dark:bg-panel-header-dark">
<div className="flex w-full items-center justify-between px-5 py-2">
<div className="flex w-full flex-row items-center justify-between gap-x-4">
<div className="flex items-center gap-2">
@@ -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<Props> = ({ 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])
@@ -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)
@@ -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(() => {
@@ -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)
+10 -12
View File
@@ -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 <Provider value={store}>{children}</Provider>
}
export default observer(FlagProvider)
export default FlagProvider
+9 -3
View File
@@ -1,3 +1,4 @@
import clsx from 'clsx'
import { FC } from 'react'
interface Props {
@@ -30,10 +31,15 @@ const SparkBar: FC<Props> = ({
<div className="flex flex-col w-full">
{hasLabels && (
<div className="flex align-baseline justify-between pb-1 space-x-8">
<span className="text-scale-1200 text-sm truncate capitalize-sentence">
<p
className={clsx(
'text-scale-1200 text-sm truncate capitalize-sentence',
labelTop.length > 0 && 'max-w-[75%]'
)}
>
{labelBottom}
</span>
<span className="text-scale-1100 text-sm tabular-nums">{labelTop}</span>
</p>
<p className="text-scale-1100 text-sm tabular-nums">{labelTop}</p>
</div>
)}
<div
@@ -0,0 +1,51 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { patch } from 'lib/common/fetch'
import { API_ADMIN_URL } from 'lib/constants'
import { edgeFunctionsKeys } from './keys'
export type EdgeFunctionsUpdateVariables = {
projectRef: string
slug: string
payload: {
name?: string
verifyJwt?: boolean
importMap?: boolean
}
}
export async function updateEdgeFunction({
projectRef,
slug,
payload,
}: EdgeFunctionsUpdateVariables) {
if (!projectRef) throw new Error('projectRef is required')
const response = await patch(`${API_ADMIN_URL}/projects/${projectRef}/functions/${slug}`, payload)
if (response.error) throw response.error
return response
}
type EdgeFunctionsUpdateData = Awaited<ReturnType<typeof updateEdgeFunction>>
export const useEdgeFunctionUpdateMutation = ({
onSuccess,
...options
}: Omit<
UseMutationOptions<EdgeFunctionsUpdateData, unknown, EdgeFunctionsUpdateVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<EdgeFunctionsUpdateData, unknown, EdgeFunctionsUpdateVariables>(
(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,
}
)
}
+4 -1
View File
@@ -40,7 +40,10 @@ export const useProfileQuery = <TData = ProfileData>({
useQuery<ProfileData, ProfileError, TData>(
profileKeys.profile(),
({ signal }) => getProfile(signal),
options
{
staleTime: 1000 * 60 * 30, // default good for 30 mins
...options,
}
)
export const useProfilePrefetch = () => {
@@ -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]
)
}
+25 -30
View File
@@ -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<T>(
WrappedComponent: ComponentType<T> | NextPageWithLayout<T, T>,
options?: {
redirectTo: string
/* run the redirect if the user is logged in */
redirectIfFound?: boolean
}
) {
const WithAuthHOC: ComponentType<T> = (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<T>(
},
})
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<T>(
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 <Error500 />
}
@@ -101,10 +97,10 @@ export function withAuth<T>(
)
}
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<T, T>).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
}
+144
View File
@@ -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 | null> } & (
| {
session: Session
isLoading: false
}
| {
session: null
isLoading: true
}
| {
session: null
isLoading: false
}
)
export const AuthContext = createContext<AuthContext>({
session: null,
isLoading: true,
refreshSession: () => Promise.resolve(null),
})
export type AuthProviderProps = {}
export const AuthProvider = ({ children }: PropsWithChildren<AuthProviderProps>) => {
const { ui } = useStore()
const [session, setSession] = useState<Session | null>(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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
/* 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
}
+27 -41
View File
@@ -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) {
<QueryClientProvider client={queryClient}>
<Hydrate state={pageProps.dehydratedState}>
<StoreProvider rootStore={rootStore}>
<FlagProvider>
<Head>
<title>Supabase</title>
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
</Head>
<AuthProvider>
<FlagProvider>
<Head>
<title>Supabase</title>
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
</Head>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID}`}
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID}`}
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){window.dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID}', { 'send_page_view': false });
`}
</Script>
</Script>
<PageTelemetry>
<RouteValidationWrapper>
<AppBannerWrapper>{getLayout(<Component {...pageProps} />)}</AppBannerWrapper>
</RouteValidationWrapper>
</PageTelemetry>
<PageTelemetry>
<RouteValidationWrapper>
<AppBannerWrapper>{getLayout(<Component {...pageProps} />)}</AppBannerWrapper>
</RouteValidationWrapper>
</PageTelemetry>
<HCaptchaLoadedStore />
<PortalToast />
<ReactQueryDevtools initialIsOpen={false} position="bottom-right" />
</FlagProvider>
<HCaptchaLoadedStore />
<PortalToast />
<ReactQueryDevtools initialIsOpen={false} position="bottom-right" />
</FlagProvider>
</AuthProvider>
</StoreProvider>
</Hydrate>
</QueryClientProvider>
)
}
export default MyApp
export default CustomApp
+15 -30
View File
@@ -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<Session | null>(null)
useEffect(() => {
let cancel = false
;(async () => {
const {
data: { session },
} = await auth.getSession()
if (session && !cancel) setSession(session)
})()
return () => {
cancel = true
}
}, [])
return (
<article className="max-w-4xl p-4">
<section>
<Profile session={session} />
<Profile profile={profile} />
</section>
<section>
@@ -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 (
<Panel
@@ -125,14 +110,14 @@ const Profile = observer(({ session }: any) => {
disabled
label="Username"
layout="horizontal"
value={ui.profile?.username ?? ''}
value={profile?.username ?? ''}
/>
<Input
readOnly
disabled
label="Email"
layout="horizontal"
value={ui.profile?.primary_email ?? ''}
value={profile?.primary_email ?? ''}
/>
{session?.user.app_metadata.provider === 'email' && (
<div className="text-sm grid gap-2 md:grid md:grid-cols-12 md:gap-x-4">
@@ -154,7 +139,7 @@ const Profile = observer(({ session }: any) => {
</Panel.Content>
</Panel>
)
})
}
const ThemeSettings = observer(() => {
const { ui } = useStore()
+6 -4
View File
@@ -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')
+3 -9
View File
@@ -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) {
+7 -1
View File
@@ -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<P = {}, IP = P> = NextPage<P, IP> & {
getLayout?: (page: ReactElement) => ReactNode
}
export function isNextPageWithLayout<T>(
Component: ComponentType<T> | NextPageWithLayout<T, T>
): Component is NextPageWithLayout<T, T> {
return 'getLayout' in Component && typeof Component.getLayout === 'function'
}