Merge branch 'feat/docs2.0' of github.com:supabase/supabase into feat/docs2.0

This commit is contained in:
Terry Sutton committed 2022-12-12 09:47:57 -03:30
commit 08df81b3eb
139 files changed
+3965 -694

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ title: introduction
hideTitle: true
---
<div className="flex items-start gap-6 not-prose">
<div className="flex items-start gap-6 not-prose" id="introduction">
<img src="/docs/img/icons/menu/reference-api.svg" className="w-8 h-8 rounded" />
<div className="flex flex-col gap-2">
<h1 className="text-3xl text-scale-1200 m-0">Management API</h1>
+1 -1
View File
@@ -4,7 +4,7 @@ title: Introduction
hideTitle: true
---
<div className="flex items-start gap-6 not-prose">
<div className="flex items-start gap-6 not-prose" id="introduction">
<img src="/docs/img/icons/menu/reference-cli.svg" className="w-8 h-8 rounded" />
<div className="flex flex-col gap-2">
<h1 className="text-3xl text-scale-1200 m-0">Supabase CLI</h1>
@@ -4,7 +4,7 @@ title: Introduction
hideTitle: true
---
<div className="flex items-start gap-6 not-prose">
<div className="flex items-start gap-6 not-prose" id="introduction">
<img src="/docs/img/icons/menu/reference-javascript.svg" className="w-8 h-8 rounded" />
<div className="flex flex-col gap-2">
<h1 className="text-3xl text-scale-1200 m-0">Javascript Client Library</h1>
@@ -4,7 +4,7 @@ title: Introduction
hideTitle: true
---
<div className="flex items-start gap-6 not-prose">
<div className="flex items-start gap-6 not-prose" id="introduction">
<img src="/docs/img/icons/javascript-icon.svg" className="w-8 h-8 rounded" />
<div className="flex flex-col gap-2">
<h1 className="text-3xl text-scale-1200 m-0">Javascript Client Library</h1>
@@ -4,7 +4,7 @@ title: Introduction
hideTitle: true
---
<div className="flex items-start gap-6 not-prose">
<div className="flex items-start gap-6 not-prose" id="introduction">
<img src="/docs/img/icons/javascript-icon.svg" className="w-8 h-8 rounded" />
<div className="flex flex-col gap-2">
<h1 className="text-3xl text-scale-1200 m-0">Javascript Client Library</h1>
@@ -4,7 +4,7 @@ title: Introduction
hideTitle: true
---
<div className="flex items-start gap-6 not-prose">
<div className="flex items-start gap-6 not-prose" id="introduction">
<img src="/docs/img/icons/javascript-icon.svg" className="w-8 h-8 rounded" />
<div className="flex flex-col gap-2">
<h1 className="text-3xl text-scale-1200 m-0">Javascript Client Library</h1>
@@ -4,7 +4,7 @@ title: Introduction
hideTitle: true
---
<div className="flex items-start gap-6 not-prose">
<div className="flex items-start gap-6 not-prose" id="introduction">
<img src="/docs/img/icons/javascript-icon.svg" className="w-8 h-8 rounded" />
<div className="flex flex-col gap-2">
<h1 className="text-3xl text-scale-1200 m-0">Javascript Client Library</h1>
+8
View File
@@ -11,6 +11,10 @@ export const menuState = proxy({
setMenuLevelId: (value) => {
menuState.menuLevelId = value
},
menuMobileOpen: false,
setMenuMobileOpen: (value) => {
menuState.menuMobileOpen = value
},
})
export const useMenuActiveRefId = () => {
@@ -19,3 +23,7 @@ export const useMenuActiveRefId = () => {
export const useMenuLevelId = () => {
return useSnapshot(menuState).menuLevelId
}
export const useMenuMobileOpen = () => {
return useSnapshot(menuState).menuMobileOpen
}
+14 -9
View File
@@ -10,15 +10,16 @@ import { useEffect, useState, memo } from 'react'
import FooterHelpCallout from '~/components/FooterHelpCallout'
import { NavigationMenuContextProvider } from '~/components/Navigation/NavigationMenu/NavigationMenu.Context'
import { useMenuLevelId } from '~/hooks/useMenuState'
import { useMenuLevelId, useMenuMobileOpen } from '~/hooks/useMenuState'
import { menuState } from '~/hooks/useMenuState'
const SiteRefLayout = ({ children }) => {
const router = useRouter()
const { isDarkMode } = useTheme()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const menuLevel = useMenuLevelId()
const mobileMenuOpen = useMenuMobileOpen()
console.log('menuLevel', menuLevel)
@@ -194,10 +195,14 @@ const SiteRefLayout = ({ children }) => {
'transition-all ease-out z-10',
'top-0',
mobileMenuOpen && 'absolute',
'flex items-center gap-3 h-[40px]',
'flex items-center h-[40px]',
mobileMenuOpen ? 'gap-0' : 'gap-3',
].join(' ')}
>
<button className="mr-2" onClick={() => setMobileMenuOpen(!mobileMenuOpen)}>
<button
className={['mr-2', mobileMenuOpen && 'mt-0.5'].join(' ')}
onClick={() => menuState.setMenuMobileOpen(!mobileMenuOpen)}
>
<div
className={[
'space-y-1 group cursor-pointer relative',
@@ -237,7 +242,7 @@ const SiteRefLayout = ({ children }) => {
className={[
'transition-all duration-200',
'text-scale-1200',
mobileMenuOpen ? 'text-base' : 'text-sm',
mobileMenuOpen ? 'text-xs' : 'text-sm',
].join(' ')}
>
{mobileMenuOpen
@@ -251,11 +256,11 @@ const SiteRefLayout = ({ children }) => {
className={[
'transition-all ease-out duration-200',
'absolute left-0 right-0 h-screen',
'py-8 px-5 pl-14 pt-16',
'px-5 pl-5 py-16',
'top-[0px]',
'bg-scale-200',
mobileMenuOpen
? 'opacity-100 left-[0px] visible'
? 'overflow-y-auto opacity-100 left-[0px] visible'
: 'left-[-40px] h-0 opacity-0 invisible',
].join(' ')}
>
@@ -303,7 +308,7 @@ const SiteRefLayout = ({ children }) => {
'backdrop-blur-sm backdrop-filter bg-white-1200 dark:bg-blackA-600',
mobileMenuOpen ? 'absolute MOBILE-MENU-OPEN' : 'hidden h-0 MOBILE-MENU-CLOSED',
].join(' ')}
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
onClick={() => menuState.setMenuMobileOpen(!mobileMenuOpen)}
></div>
</div>
</div>
+1 -1
View File
@@ -93,7 +93,7 @@ const Layout: FC<Props> = (props) => {
<div
className={[
'relative',
!props.hideToc ? 'col-span-9' : 'col-span-12',
!props.hideToc ? 'col-span-12 md:col-span-9' : 'col-span-12',
'transition-all ease-out',
'duration-100',
active ? 'opacity-100 left-0' : 'opacity-0 left-6',
+2
View File
@@ -13,6 +13,7 @@ import '../styles/main.scss?v=1.0.0'
import '../styles/new-docs.scss'
import '../styles/prism-okaidia.scss'
import { post } from '~/lib/fetchWrappers'
import { menuState } from '~/hooks/useMenuState'
function MyApp({ Component, pageProps }: AppPropsWithLayout) {
const router = useRouter()
@@ -44,6 +45,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
if (container) container.scrollTop = 0
}
}
menuState.setMenuMobileOpen(false)
}
function handlePagrScroll() {}
@@ -210,7 +210,7 @@ Create a `/components/supabase-listener.jsx` file and add the following:
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
import supabase from '../utils/supabase'
import supabase from '../utils/supabase-browser'
export default function SupabaseListener({ accessToken }) {
const router = useRouter()
+88 -18
View File
@@ -7,13 +7,6 @@ export const meta = {
'Add an additional layer of security to your apps with Supabase Auth multi-factor authentication.',
}
<Admonition type="note">
Multi-Factor Authentication is in early access preview only. Although we
believe it is production ready, APIs and some behavior may change based on
feedback we receive in the preview period.
</Admonition>
Multi-factor authentication (MFA), sometimes called two-factor
authentication (2FA), adds an additional layer of security to your
@@ -94,17 +87,20 @@ for your application. JWTs without an `aal` claim are at the `aal1` level.
## Adding to your app
Adding MFA to your app involves these three steps:
Adding MFA to your app involves these four steps:
1. **Add enrollment flow.**
You need to provide a UI within your app that your users will be able to set-up
MFA in. You can add this right after sign-up, or as part of a separate flow in
the settings portion of your app.
2. **Add challenge step to login.**
2. **Add unenrollment flow.**
You need to support a UI through which users can see existing devices and unenroll
devices which are no longer relevant.
3. **Add challenge step to login.**
If a user has set-up MFA, your app's login flow needs to present a challenge
screen to the user asking them to prove they have access to the additional
factor.
3. **Enforce rules for MFA logins.**
4. **Enforce rules for MFA logins.**
Once your users have a way to enroll and log in with MFA, you need to enforce
authorization rules across your app: on the frontend, backend, API servers or
Row-Level Security policies.
@@ -141,6 +137,7 @@ Enrolling a factor for use with MFA takes three steps:
immediately becomes active for the user account. If not, you should repeat
steps 2 and 3.
#### Example: React
Below is an example that creates a new `EnrollMFA` component that illustrates
@@ -241,6 +238,83 @@ export function EnrollMFA({
}
```
### Add unenrollment flow
An unenrollment flow provides a UI for users to manage and unenroll factors linked to their accounts.
Most applications do so via a factor management page where users can view and unlink selected factors.
When a user unenrolls a factor, call `supabase.auth.mfa.unenroll()` with the ID of the factor.
For example, call `supabase.auth.mfa.unenroll({factorId: "d30fd651-184e-4748-a928-0a4b9be1d429"})` to unenroll a factor with ID `d30fd651-184e-4748-a928-0a4b9be1d429`.
#### Example: React
Below is an example that creates a new `UnenrollMFA` component that illustrates
the important pieces of the MFA enrollment flow. Note that users can only unenroll a factor after completing the enrollment flow and obtaining
an `aal2` JWT claim.Here are some points of note:
- When the component appears on screen, the `supabase.auth.mfa.listFactors()` endpoint
fetches all existing factors together with their details.
- The existing factors for a user are displayed in a table.
- Once the user has selected a factor to unenroll, they can type in the factorId and click **Unenroll**
which creates a confirmation modal.
<Admonition type="note">
Unenrolling a factor will downgrade the assurance level from `aal2` to `aal1` only after the refresh interval has lapsed.
For an immediate downgrade from `aal2` to `aal1` after enrolling one will need to manually call `refreshSession()`
</Admonition>
```tsx
/**
* UnenrollMFA shows a simple table with the list of factors together with a button to unenroll.
* When a user types in the factorId of the factor that they wish to unenroll and clicks unenroll
* the corresponding factor will be unenrolled.
*/
export function UnenrollMFA() {
const [factorId, setFactorId] = useState('')
const [factors, setFactors] = useState([])
const [error, setError] = useState('') // holds an error message
useEffect(() => {
;(async () => {
const { data, error } = await supabase.auth.mfa.listFactors()
if (error) {
throw error
}
setFactors(data.totp)
})()
}, [])
return (
<>
{error && <div className="error">{error}</div>}
<tbody>
<tr>
<td>Factor ID</td>
<td>Friendly Name</td>
<td>Factor Status</td>
</tr>
{factors.map(factor => (
<tr>
<td>{factor.id}</td>
<td>{factor.friendly_name}</td>
<td>{factor.factor_type}</td>
<td>{factor.status}</td>
</tr>
))}
</tbody>
<input
type="text"
value={verifyCode}
onChange={(e) => setFactorId(e.target.value.trim())}
/>
<button onClick={()=> supabase.auth.mfa.unenroll({factorId})}>Unenroll</button>
</>
)
}
```
### Add challenge step to login
Once a user has logged in via their first factor (email+password, magic link,
@@ -460,7 +534,7 @@ create policy "Policy name."
select
case
when created_at >= '2022-12-12T00:00:00Z' then array['aal2']
else array['aal1', 'aal2', NULL]
else array['aal1', 'aal2']
end as aal
from auth.users
where auth.uid() = id));
@@ -471,8 +545,6 @@ create policy "Policy name."
`aal2` for all other timestamps.
- The `<@` operator is PostgreSQL's ["contained in"
operator.](https://www.postgresql.org/docs/current/functions-array.html)
- `NULL` appears because some JWTs originating from prior to the introduction
of MFA in Supabase Auth will not contain an `aal` claim.
- **Using `as restrictive` ensures this policy will restrict all commands on the
table regardless of other policies!**
@@ -491,7 +563,7 @@ create policy "Policy name."
select
case
when count(id) > 0 then array['aal2']
else array['aal1', 'aal2', NULL]
else array['aal1', 'aal2']
end as aal
from auth.mfa_factors
where auth.uid() = user_id and status = 'verified'
@@ -503,8 +575,6 @@ create policy "Policy name."
- Otherwise, it will accept both `aal1` and `aal2`.
- The `<@` operator is PostgreSQL's ["contained in"
operator.](https://www.postgresql.org/docs/current/functions-array.html)
- `NULL` appears because some JWTs originating from prior to the introduction
of MFA in Supabase Auth will not contain an `aal` claim.
- **Using `as restrictive` ensures this policy will restrict all commands on the
table regardless of other policies!**
@@ -589,7 +659,7 @@ seconds later. The entries are ordered most recent method first!
{
"amr": [
{
"method": "mfa/totp",
"method": "totp",
"timestamp": 1666086056
},
{
@@ -625,7 +695,7 @@ Currently recognized methods are:
- `otp` - any one-time password based sign in (email code, SMS code, magic
link).
- `oauth` - any OAuth based sign in (social login).
- `mfa/totp` - a TOTP additional factor.
- `totp` - a TOTP additional factor.
This list will expand in the future.
@@ -23,7 +23,7 @@ To get started:
## Configure a Custom Domain
This example assumes your Supabase project is `foobar` with a corresponding API URL `foobarbaz.supabase.co` and configures a custom domain at `api.example.com`.
This example assumes your Supabase project is `foobarbaz` with a corresponding API URL `foobarbaz.supabase.co` and configures a custom domain at `api.example.com`.
### Configure a CNAME
@@ -34,7 +34,7 @@ Set up a CNAME record for `api.example.com`, resolving to `foobarbaz.supabase.co
Use the `create` subcommand of the CLI to notify Supabase of your domain and retrieve TXT verification records:
```bash
supabase domains create --project-ref foobar --custom-hostname api.example.com --experimental
supabase domains create --project-ref foobarbaz --custom-hostname api.example.com --experimental
```
The output of the `create` command includes two TXT records[^1] you will need to set up, in order to verify your control over the domain in question,
@@ -62,7 +62,7 @@ You might need to wait a few minutes before your updated DNS records are propaga
### Activate your domain
The final activation step reconfigures your project to start serving traffic on your custom domain (`api.example.com`).
The auth service, in particular, will no longer work with the original URL (`foobar.supabase.co`).
The auth service, in particular, will no longer work with the original URL (`foobarbaz.supabase.co`).
As such, it is recommended that you schedule a downtime window of 20-30 minutes, depending on the complexity of your project, to update all the services that need to know about your custom domain:
- any client code (e.g., frontends, mobile apps)
+199 -199
View File
@@ -61,25 +61,19 @@
</url>
<url>
<loc>https://supabase.com/docs/company/aup</loc>
<loc>https://supabase.com/docs/handbook/contributing</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/company/privacy</loc>
<loc>https://supabase.com/docs/handbook/introduction</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/company/sla</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/company/terms</loc>
<loc>https://supabase.com/docs/handbook/supasquad</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
@@ -240,6 +234,18 @@
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/new/auth</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/new/index</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/reference/index</loc>
<changefreq>weekly</changefreq>
@@ -253,31 +259,55 @@
</url>
<url>
<loc>https://supabase.com/docs/handbook/contributing</loc>
<loc>https://supabase.com/docs/company/aup</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/handbook/introduction</loc>
<loc>https://supabase.com/docs/company/privacy</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/handbook/supasquad</loc>
<loc>https://supabase.com/docs/company/sla</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/new/auth</loc>
<loc>https://supabase.com/docs/company/terms</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/new/index</loc>
<loc>https://supabase.com/docs/learn/auth-deep-dive/auth-deep-dive-jwts</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/learn/auth-deep-dive/auth-google-oauth</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/learn/auth-deep-dive/auth-gotrue</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/learn/auth-deep-dive/auth-policies</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/learn/auth-deep-dive/auth-row-level-security</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
@@ -390,30 +420,6 @@
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/functions/auth</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/functions/best-practices</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/functions/cicd-workflow</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/functions/quickstart</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/auth/auth-apple</loc>
<changefreq>weekly</changefreq>
@@ -600,6 +606,30 @@
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/functions/auth</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/functions/best-practices</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/functions/cicd-workflow</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/functions/quickstart</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/getting-started/architecture</loc>
<changefreq>weekly</changefreq>
@@ -768,6 +798,78 @@
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/realtime/postgres-changes</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/realtime/quickstart</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/realtime/rate-limits</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/migrations/firebase-auth</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/migrations/firebase-storage</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/migrations/firestore-data</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/migrations/heroku</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/resources/examples</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/resources/glossary</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/resources/migrating-to-supabase</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/resources/self-hosting</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/resources/supabase-cli</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/platform/compute-add-ons</loc>
<changefreq>weekly</changefreq>
@@ -822,78 +924,6 @@
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/migrations/firebase-auth</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/migrations/firebase-storage</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/migrations/firestore-data</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/migrations/heroku</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/realtime/postgres-changes</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/realtime/quickstart</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/realtime/rate-limits</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/resources/examples</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/resources/glossary</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/resources/migrating-to-supabase</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/resources/self-hosting</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/resources/supabase-cli</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/storage/access-control</loc>
<changefreq>weekly</changefreq>
@@ -924,72 +954,6 @@
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/learn/auth-deep-dive/auth-deep-dive-jwts</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/learn/auth-deep-dive/auth-google-oauth</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/learn/auth-deep-dive/auth-gotrue</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/learn/auth-deep-dive/auth-policies</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/learn/auth-deep-dive/auth-row-level-security</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/functions/examples/og-image</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/auth/auth-helpers/auth-ui</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/auth/auth-helpers/nextjs-server-components</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/auth/auth-helpers/nextjs</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/auth/auth-helpers/remix</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/auth/auth-helpers/sveltekit</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/database/extensions/http</loc>
<changefreq>weekly</changefreq>
@@ -1027,43 +991,31 @@
</url>
<url>
<loc>https://supabase.com/docs/guides/auth/passwordless-login/email-otp-magic-link</loc>
<loc>https://supabase.com/docs/guides/auth/auth-helpers/auth-ui</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/auth/passwordless-login/phone-sms-otp-messagebird</loc>
<loc>https://supabase.com/docs/guides/auth/auth-helpers/nextjs-server-components</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/auth/passwordless-login/phone-sms-otp-twilio</loc>
<loc>https://supabase.com/docs/guides/auth/auth-helpers/nextjs</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/auth/passwordless-login/phone-sms-otp-vonage</loc>
<loc>https://supabase.com/docs/guides/auth/auth-helpers/remix</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/getting-started/quickstarts/nextjs</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/getting-started/quickstarts/nuxtjs</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/getting-started/quickstarts/reactjs</loc>
<loc>https://supabase.com/docs/guides/auth/auth-helpers/sveltekit</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
@@ -1176,6 +1128,54 @@
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/functions/examples/og-image</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/auth/passwordless-login/email-otp-magic-link</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/auth/passwordless-login/phone-sms-otp-messagebird</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/auth/passwordless-login/phone-sms-otp-twilio</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/auth/passwordless-login/phone-sms-otp-vonage</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/getting-started/quickstarts/nextjs</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/getting-started/quickstarts/nuxtjs</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/getting-started/quickstarts/reactjs</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/getting-started/tutorials/with-angular</loc>
<changefreq>weekly</changefreq>
@@ -1260,12 +1260,6 @@
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/resources/self-hosting/docker</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/resources/migrating-to-supabase/firebase-auth</loc>
<changefreq>weekly</changefreq>
@@ -1290,6 +1284,12 @@
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/resources/self-hosting/docker</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/docs/guides/resources/supabase-cli/local-development</loc>
<changefreq>weekly</changefreq>
@@ -29,7 +29,7 @@ More than 30 creators participated in creating content in different formats and
- [Hoppscotch with GraphQL](https://www.youtube.com/watch?v=HoBXQng3aK4) by Jamie Barton.
- [Supabase with TypeScript: using tRPC and Prisma to achieve end-to-end typesafety](https://noahflk.com/blog/supabase-typescript-trpc) by Noah.
- [How to Connect Browser Extensions to Supabase](https://akoskm.com/how-to-connect-browser-extensions-to-supabase) by Ákos Kőműves.
- [A Chili Cookoff with Rust, Rocket, Render, and Supabase](bradcypert.com/chili-cookoff-with-rust-rocket-render-and-supabase/) by Brad Cypert.
- [A Chili Cookoff with Rust, Rocket, Render, and Supabase](https://bradcypert.com/chili-cookoff-with-rust-rocket-render-and-supabase/) by Brad Cypert.
- [Authenticating users with Remix and Supabase](https://makerkit.dev/blog/tutorials/remix-supabase-auth) by Giancarlo Buomprisco.
- [Automatic Spotify Playlist Creation with Pipedream and Supabase](https://www.ianwootten.co.uk/2022/12/06/automatic-spotify-playlist-creation-with-pipedream-and-supabase/) by Ian Wootten.
- [Accept Payments For Your African-Based Business Using Supabase Edge Functions and Paystack](https://blog.hijabicoder.dev/accept-payments-for-your-african-based-business-using-supabase-edge-functions-and-paystack) by Fatuma Abdullahi.
@@ -24,7 +24,7 @@ Next week, we go all out for LW6. It’s 5 days of shipping, including major fea
![Remix Auth Helpers](/images/blog/2022-12-07-november-beta-update/remix-auth-helpers.jpg)
It’s here! The much-awaited Remix Auth Helpers make server-side auth even easier and with a better experience for server-side rendering. Up to date with supabase-js V2 and can be used with Typescript.
It’s here! The much-awaited Remix Auth Helpers make server-side auth even easier and with a better experience. Up to date with supabase-js V2 and can be used with Typescript.
[Read the docs](https://supabase.com/docs/guides/auth/auth-helpers/remix)
@@ -40,7 +40,7 @@ Plus, we published three new functions examples: [Generate OG Images](https://gi
- Fixed two issues with the `supabase.auth.signOut` function: [cookies not clearing properly](https://github.com/supabase/gotrue/pull/830) and [session not removed](https://github.com/supabase/gotrue-js/pull/541).
- You can now customize magic links by providing access to the `HashedToken`. [PR](https://github.com/supabase/gotrue/pull/707)
- You can now customize magic links by providing access to the `{{ .TokenHash }}`. [PR](https://github.com/supabase/gotrue/pull/707)
- Now you can do redirects from your functions. [PR](https://github.com/supabase/supabase/issues/10325)
@@ -93,8 +93,6 @@ Discover other great projects: [Made with Supabase](https://www.madewithsupabas
- ReactJS + Supabase Fullstack CRUD Application Development. [Youtube Playlist](https://youtube.com/playlist?list=PLl6EcvA_AoxEFqwT_rNs1uZnnf7LSEoji)
- How to connect a ESP8266 to a Streamlit App using Supabase database. [Video](https://www.youtube.com/watch?v=wI209WK3-VM)
## Meme Zone
This month’s meme comes courtesy of Fireship. [Follow us on Twitter](https://twitter.com/supabase) for more.
@@ -0,0 +1,115 @@
---
title: 'Launch Week 6 Hackathon'
description: Build an Open Source Project, Win $1500 and the Supabase Darkmode Keyboard
author: ant_wilson
image: lw6-hackathon/thumbnail.jpg
thumb: lw6-hackathon/thumbnail.jpg
tags:
- launch-week
- hackathon
date: '2022-12-09'
toc_depth: 3
---
The official Supabase Hackathon starts on Friday 9th December at 8am Pacific Time and ends Monday 19th December at 00:01am.
[Launch Week](https://supabase.com/launch-week) is about to kick off and we're running a hackathon in parallel.
You can win $1500 in GitHub sponsorships and an official Supabase Keyboard (extremely limited edition), along with a bunch of other prizes!
See all the [winners from all the previous hackathons](https://supabase.com/blog/tags/hackathon) for inspiration.
![Supabase Keyboard](/images/blog/lw6-hackathon/keyboard.png)
## Key Facts
- You have 10 days to build a new **Open Source** project using Supabase.
- Starting 8:00am PT Friday 9th December 2022
- Build whatever you want - a project, app, tool, library, anything
- Enter as an individual, or as a team of up to 5 people
- Submission deadline is 00:01am early Monday morning PT 19th December 2022
- There are 5 prize categories.
- Best overall project will win $1500 in GitHub sponsorships, and a Supabase Keyboard for each team member.
- Most fun/interesting, best Flutter project, and best storage project will all win a limited edition Hackathon swag kit for each team member!
- Our friends at [Deno](https://deno.land/) will be judging a special guest category: Best Edge Functions Project! They will be providing some [special swag](https://deno.com/blog/edge-functions-supabase-launch-week-6-hackathon) for the winners!
## Details
### Schedule
- The Hackathon begins at 8:00am PT Friday 9th December 2022
- Work on your project any time for the next 10 days
- Submission deadline (00:01am early Monday morning PT 19th December 2022)
### Prizes
There are 5 chances to win, there will be prizes for:
- Best Overall Project ($1500 in GitHub Sponsorship & Supabase Keyboards)
- to be paid as 3x$500 GitHub Sponsorships (over 3 months)
- Most Fun/Interesting (Swag kit)
- Best Flutter Project (Swag kit)
- Best Storage Project (Swag kit)
- Best Edge Functions Project (Deno Swag kit)
There will be winner and runner-up prizes for each category. Every team member on winning/runner-up teams gets a supaverified swag kit.
![Swag kit](/images/blog/lw6-hackathon/swagkit.png)
### Submission
You should submit your project using [this form](https://www.madewithsupabase.com/launch-week-6).
### Judges
Best Edge Functions Project category will be judged by our friends at [Deno](https://deno.land/), and other categories will be judged by the Supabase team.
We will be looking for:
- creativity/inventiveness
- functions correctly/smoothly
- visually pleasing
- technically impressive
- use of Supabase features
- deep usage of a single feature or
- broad usage are both ok
- FUN! 😃
### Rules
- Team size 1-5 (all team members on winning teams will receive a prize)
- You cannot be in multiple teams
- One submission per team
- All design elements, code, etc. for your project must be created **during** the event
- All entries must be Open Source (link to source code required in entry)
- Must use Supabase in some capacity
- Can be any language or framework
- You must submit before the deadline (no late entries)
### Community
The Supabase Team will be taking part in the Hackathon and you'll find us live building in our discord all week. Please join us by building in public:
- Text channel: hackathon
- Audio channel: hackathon
If you need help or advice when building, find other people to join your team, or if you just want to chill and watch people build, come and join us!
[Join our Discord](https://discord.supabase.com)
![Discord Hangout](/images/blog/hackathon/community.png)
### Launch Week
Don't forget to checkout the new feature's being announced as part of [Launch Week](/launch-week).
- [Previous Hackathon Prize Winners](https://supabase.com/blog/launch-week-5-hackathon-winners)
- [Who We Hire at Supabase - Founders Chat](https://youtu.be/-BG9XptyCKI)
### Additional Info
- Any intellectual property developed during the hackathon will belong to the team that developed it. We expect that each team will have an agreement between themselves regarding the IP, but this is not required
- By making a submission you grant Supabase permission to use screenshots, code-snippets and/or links to your project or content of your README on our Twitter, blog, website, email updates, and in the Supabase discord server. Supabase does not make any claims over your IP.
- $1500 prize will be paid by making a GitHub sponsorship to the winning repo, $500/month for 3 months. The goal here is to create a sustainable project that will be continue to be maintained for the duration of the sponsorship period (and hopefully beyond :) )
+106
View File
@@ -0,0 +1,106 @@
---
title: 'Who We Hire at Supabase'
description: Traits we look for to maintain a culture of shipping fast and often
author: ant_wilson
image: who-we-hire.jpg
thumb: who-we-hire-cover.jpg
tags:
- launch-week
date: '2022-12-09'
toc_depth: 3
youtubeHero: https://www.youtube-nocookie.com/embed/-BG9XptyCKI
---
It’s been 12 months since I wrote [How we launch at Supabase](https://supabase.com/blog/supabase-how-we-launch), which details our Launch strategy. This methodology helped us grow our hosted platform 46% month-over-month for the first 18 months since [first launching on Hacker News](https://news.ycombinator.com/item?id=23319901) in summer 2020.
In the last 12 months we’ve continued to grow the number of databases deployed on our platform by a further 3.5x and increased revenue by 1300%, all thanks to a relentless team and intense shipping schedule.
We’ve upgraded our methodology in a few ways, which I’ll touch on briefly, but it’s become clear in the time that we’ve been running Supabase that whilst methodology is important - having the right people on the bus is paramount. It’s the team who are relentlessly pushing us to do more, better, faster.
Here I’ll discuss some of the traits that have shaped culture at Supabase, and defined what we look for in new candidates.
![A chart showing growth at Supabase](/images/blog/how-it-started.jpg)
Here’s some traits we’ve observed in people at Supabase that make them effective team members:
### Extreme effectiveness when working asynchronously
People who require a lot of face-to-face collaboration are unlikely to be successful at Supabase. Major projects have gone from inception, through implementation, to marketing and launch without a single synchronous meeting. Our team is now 60 people spread across 25 different countries. We focus on hiring the best person for the job regardless of geography. A nice side effect of this is excellent global support coverage (everyone from the CFO to the infra team has “front line support” listed as the first item in their job description) and a true appreciation of decreasing latency globally and not just for `us-east-1`.
With so many timezones, a dependence on scheduled face time would kill our momentum, so meetings are used as a last resort. This isn’t to say that there are zero recurring meetings at Supabase, but in our ideal world there would be.
### Egoless
A willingness to do the [schlep](http://www.paulgraham.com/schlep.html) is essential to working at Supabase. Behind every shiny new feature we ship is thousands of hours of ruthless hard work and maintenance. The best people in the company have come to be known as “Supa-unblockers”; people who take work off your plate rather than piling it on. And being senior is no exception. We don’t have managers, which means all individuals need to:
- do IC (independent contributor) work as their primary job.
- self manage, and develop the skills necessary to ruthlessly prioritise tasks with the business goals always in mind.
- have the full picture - everyone in the team has access to our metrics and we share a long list of product principles which enables everyone to make high-level decisions autonomously.
### Kaizen mindset
[Kaizen](https://en.wikipedia.org/wiki/Kaizen) in a Supabase context is the idea that each process should be continuously and incrementally improved, with small and frequent changes being preferred over larger ones. This is true for all process, from company culture to product updates. It allows us to chunk down large projects (with the bonus that new starters can start contributing in their first week) and ensures smooth gradual changes to culture.
### Default to action
The very first day of Supabase (6th Jan 2020), I met Copple for an all-day white-boarding session where we explored in detail everything that we thought Supabase could be, and how we were going to achieve it. We dumped down ideas on fund raising, product, competition, marketing, everything. At the end of what felt like a marathon, around the point that most people would pop the kettle on and start rummaging in the cupboards for the last remaining packet of Custard Creams (just me?), Copple opened his laptop and immediately started work. It was a simple act, but in that moment he set the pace for development at Supabase. Never would there ever be a whiff of “Let’s meet next week to discuss”. The words we encourage people to default to instead is “why not now?”.
### Unreasonable levels of ambition
When you find all of the above traits in a single person, your definition of the word ‘feasible’ changes dramatically. At the start of every product cycle, we ask the question “what is the most ambitious thing we can hope to achieve in the next 3 months?”, and the team never fail to push the boundary on what we previously considered reasonable. Maybe it’s because we work in Dev Tools, but it’s amazing to watch developers productise solutions to their own problems. The right people don’t need to run a customer survey to know what to work on next, they’re obsessed with solving their own problems, have strong opinions on what great looks like, and won’t stop until they get there.
### A founder mindset
We have a bias towards people who previously ran their own companies. This typically hints towards high levels of personal ambition. Ex-founders tend to be very flexible in their role. They can identify low hanging fruit and change focus quickly to address the needs of the business as a whole. If the person is technical, it also reduces the translation needed across the tech and business sides of the company. Something we especially value are team members who take on responsibility without asking and without being asked. Founders do this regularly. Clearly these traits are not only found in people who’ve started companies, but it can be an indication. It’s not the credentials that we value most, but the mindset. We expect everyone who works at Supabase to act like a business owner, and to solidify this part of everyone’s compensation package is an ESOP apportionment with [a 10 year exercise window](https://github.com/holman/extended-exercise-windows).
---
By now, it should be no surprise that I estimate our product and growth momentum is actually 90% about the people, and only 10% about structure. But I’ll detail the structure anyway, since it’s still an important part of keeping people unblocked.
## Team structure
We often joke that building Supabase is like building 5 startups simultaneously, due to the number of modular products that make up the Supabase stack (Database, Auth, Realtime, etc.). Our culture has grown to support this idea - with an extremely high degree of autonomy bestowed to teams (and in fact, to everyone who works here).
### Independent teams
Previously there was a lot of crossover when it came to who worked on what, in the early days we had more products to manage than people. But these days the product team is broken roughly into:
- Postgres / PostgREST
- Platform
- Auth
- Storage
- Realtime
- Functions
- CLI / API
- QA (cross cutting)
- Docs (cross cutting)
- Design (cross cutting)
- Front End (cross cutting)
With some of these breaking down further into sub-teams (e.g. Realtime is actually more like an Elixir team that handles Observability, Logflare, and more).
Inter-team communication is one of the harder problems we’ve had to solve over the past year. As the teams grow and move faster internally, how do we make sure they can stay in sync with the rest of the org? For example, things like Front End, QA, and Docs need to have a level of consistency to ensure Supabase is a coherent product. One way we’ve solved this is by making sure the cross-cutting teams act like a consultancy rather than a service. When a product team like Auth require some Front End work, it’s very rarely a case of “throwing tasks over the fence” to the cross cutting teams, rather those teams will consult with the product team on how they can work together to get a project over the line. Another example is the docs team. They won’t write all the docs for a feature, that’s still the job of the team that implemented the feature (it’s important that we view docs as part of the product itself and not a byproduct). The team in question will consult with Team Docs on where the docs should live, and the format they should take to maintain coherence across all products in the stack.
Staying aligned on goals is also key. Each team is aware of the overall business metrics, but also the number of active, and paying projects that make use of their product’s APIs. It’s up to them to dig into the data and best decide how to boost their metrics, and to establish a hypothesis of how their numbers drive the business goals.
We receive a firehose of user feedback across multiple channels including Dashboard widget, support, socials, Discord, customer interviews, dogfooding. This feedback is triaged and piped directly to the relevant team for them to assess.
Ultimately, a team and the individuals within should have all the tools they need to be able to make decisions quickly and independently. We’re also not prescriptive on how teams organise their work, it’s up to them to decide.
### Cadence
Thanks to the Agile Manifesto, most software teams for the last decade ended up running some variation of a 2 week sprint on an endless loop, and yes its as exhausting as it sounds. Due to our desire to [recreate a Y-combinator-like environment internally](https://supabase.com/blog/supabase-how-we-launch) we landed on something that’s closer to 3 month product cycles. We found that it allows for more ambitious goal setting, accounts for the realities of people being sick or on holiday, and also allows teams to work through different phases.
An intense shipping schedule can lead to an accrual of technical debt. To solve for this during the last year we introduced a series of “Kaizen Weeks” that run within the first month of each 3 month cycle. The three weeks we ran as part of the last one were:
- QA week
- Docs week
- Performance week
The goal of each week wasn’t only to make step changes in each area, but to find ways to bake this work into the team’s ongoing workflow. Performance testing for example requires some upfront work to get things up and running, but once this is done it’s fairly straightforward to automate this as part of your CI/CD pipeline.
---
Monday 12th December is Supabase Launch Week 6 where you can find out what we’ve been grinding on for the last 3 months. All the details will be revealed here: [supabase.com/launch-week](https://supabase.com/launch-week)
If you’d like to work with us, [check out our careers page](https://supabase.com/careers).
+145
View File
@@ -0,0 +1,145 @@
---
title: 'pg_crdt - an experimental CRDT extension for Postgres'
description: "Embedding Yjs and Automerge into Postgres for collaborative applications."
author: paul_copplestone
image: crdt/crdt-blog.png
thumb: crdt/crdt-blog.png
tags:
- launch-week
date: '2022-12-10'
toc_depth: 2
---
Today we’re open-sourcing an EXPERIMENTAL extension for CRDTs, `pg_crdt`. The GitHub repo is [here](https://github.com/supabase/pg_crdt). There are [instructions](https://github.com/supabase/pg_crdt#installation) for running it locally in the README.
When we released the new [multiplayer features](/blog/supabase-realtime-multiplayer-general-availability) for our Realtime engine,
it took 30 minutes for someone to ask if we’d add CRDT support.
> *Anyone from Supabase here, do you have any plans to build in support for CRDT toolkits such as Yjs or AutoMerge for these features? It would make working with them so much easier if there was a plug and play backend.*
<small><i>@samwillis on [HackerNews](https://news.ycombinator.com/item?id=32510820)</i></small>
pg_crdt has not been released onto the Supabase platform (and it may never be). We’re considering many options for offline-sync/support and, while CRDTs will undoubtedly factor in, we’re not sure if this is the *right* approach. Hopefully this release generates a healthy discussion about the various ways we can do it at Supabase.
## What’s a CRDT?
A CRDT (Conflict-free Replicated Data Type) is a data structure. More accurately, a family of data structures. They enable collaborative apps like [Figma](https://www.figma.com/blog/how-figmas-multiplayer-technology-works/).
You already know what a “data structure” is: an Array is a good example. CRDTs are a *special* type of data structure designed to solve a specific problem: they can merge changes in a way that the final state of the data will be the same, no matter the order in which the updates were applied.
In simple terms, a CRDT allows multiple users to make changes to the same data without the need for a central authority to coordinate their actions.
Let’s use our Array example to demonstrate the problem they solve. Imagine you have an array (which is not a CRDT):
```jsx
let fruit = ['Apple', 'Banana', 'Orange']
```
Now imagine you have 2 developers updating this array on their local computers. They both want to replace the fruit at the start of the array. The first user, let’s call her “Jenny”, does this:
```js
fruit[0] = 'Grape' // the array is ['Grape', 'Banana', 'Orange']
```
The developer sitting next to her, let’s call him “Jonny”, does the same:
```js
fruit[0] = 'Mango' // the array is ['Mango', 'Banana', 'Orange']
```
And now, since they are both developing on different machines, they push their changes to a remote server. When the updates land on the remote system, what will `fruit[0]` be? “Grape” or “Mango”?
![Array - which will be saved?](/images/blog/crdt/array.png)
Since this is one of those *basic* arrays, the answer is "the fruit array that arrives last”.
1. If Jonny’s fruit array arrives first, it will be saved.
2. After that, Jenny’s fruit arrived and overwrites Jonny’s changes.
But therein lies the problem: Jonny was the last developer to change his array of fruit, shouldn’t his changes be the ones that are saved?
That’s one of the things that CRDTs solve. There is an “array CRDT” which, when merged, will always have the same result. It doesn’t matter if Jonny’s array arrives first, or if Jenny’s arrives first - every time you merge them they would be able to determine that the Jonny’s was the last change the fruit array.
How does it do that? Some sort of algorithm, but you can ask ChatGPT to explain that one.
## Offline philosophy
Collaborative apps are becoming more prolific as legacy software is rebuilt within a browser environment.
Collaboration is largely a data problem - how do we get one user’s changes (data) to merge with another user’s changes (data)? As a database provider it’s natural fit for Supabase to provide the tooling for developers to build collaborative apps.
Before going too far down the “solution” rabbit hole at Supabase, we try to think about the long-term implications of adopting any technology. We’re pretty boring - we don’t make bets on technologies unless we think they will exist in 20 years.
I personally believe CRDTs are the future. For *some things*. If databases were invented today, I’m certain most of the effort would be spent developing CRDT databases or something with “offline algorithms” built-in. But tech is a real-world demonstration of the Lindy effect: the longer something has existed, the longer it’s likely to exist in the future. That’s why we bet on Postgres - with more than 30 years history, it’s here to stay.
Faced with this reality, we should consider how to solve *offline with Postgres* and where CRDTs might fit into that picture.
For a long time I thought there could be a way to shoehorn Postgres row-level data into a CRDT, to give *truly* offline support. This may even still be [possible](https://electric-sql.com/), and it’s one of the ideas we’ll continue to pursue. But Postgres is a rich and evolving ecosystem, and I don’t know whether it will be possible to -
a) find a merge strategy for an entire row, while simultaneously:
b) finding a merge strategy for every data type *within* that row (especially with the number of data types available through extensions)
It’s possible that developers will need to be selective about their “level” of offline support, at least if they plan to use an “incumbent” databases. For the cases, a simple “last write wins” strategy is probably acceptable (see the excellent [Replicache](https://doc.replicache.dev/examples/repliear) and [Watermelon](https://nozbe.github.io/WatermelonDB/) libraries), but there will be important pieces of their application where it is not.
Take this table of blog posts as an example:
```sql
create table posts (
id serial primary key,
title text,
content text default ''
);
```
Perhaps it’s not that important if the `title` has a “last write wins” strategy, because it’s rarely updated and less likely to have a merge conflict. But it is critical to use a smarter algorithm for the `content` of the blog post, especially in a team where multiple users are editing a blog post at the same time.
That’s a lot of background to get to what you probably want to know about:
## Postgres CRDT extension
`pg_crdt` is an extension which adds CRDT support to Postgres as a data type. For example, using our table from above:
```sql
create table posts (
id serial primary key,
title text,
content crdt.ydoc default crdt.new_ydoc()
);
```
The `content` column is now a [Yjs Doc](https://docs.yjs.dev/api/y.doc). Updates to this column are *commutative* and *idempotent*. This means that they can be applied in any order and multiple times, and the result will always be the same. Two people can edit the blog content at the same time, and they won’t have any issues when their changes are saved in the database.
### Support for Yjs, Automerge, and beyond
The Yjs and Automerge teams have done some excellent work to create Rust libraries for their CRDTs implementations. It was relatively trivial to wrap the libraries into a Postgres extension using the [pgx](https://github.com/tcdi/pgx) framework.
At this stage it makes sense to give developers as many choices as possible in one extension. The CRDT space is nascent the algorithms are rapidly changing.
Importantly, both Yjs and Automerge have *JavaScript* and Rust implementations, which means they work natively in both a browser and a Postgres environment. Since collaborative applications are largely a client-side problem, CRDTs are more useful for developers if they have robust JavaScript libraries. In the future, if this extension becomes the approach we take, then Supabase will focus some resources on building Yjs/Automerge libraries for mobile devices too (Swift for iOS, Kotlin for Android).
## Why not build this into Realtime?
An alternative approach for CRDT support in Supabase is to build support directly into [Realtime](https://github.com/supabase/realtime) and use it as an Authoritative server. In this scenario, Realtime would serialize the CRDT and save it to Postgres (probably as a `bytea` data type). Yjs has the concept of [Providers](https://github.com/yjs/yjs#providers) which would facilitate this. You can see the difference between the approaches in the diagram below - on the left, Realtime acts as the “middleman” for saving the CRDT in the database, whereas on the right the CRDT is pushed directly to the database, and Realtime receives updates from Postgres. (Note also that clients can send peer-to-peer updates.)
![Realtime as an authority](/images/blog/crdt/authority.png)
This might still be our best option, but it should not be our first attempt. If we make Realtime the authority, it strongly couples developers to the Supabase infrastructure. By placing the CRDT into the database, it simplifies the architecture, enables other tools (like Debezium), and provides the possibility to update the database directly (for semi-realtime events like counters or page-views).
![External providers](/images/blog/crdt/semi-realtime.png)
The Realtime engine seems like a great *compliment* for `pg_crdt`, but before we put it into production we need to solve a few (major) limitations.
## Limitations
These are a few of the *known* limitations:
- Realtime broadcasts database changes from the Postgres write ahead log (WAL). The WAL includes a complete copy of the the underlying data so small updates cause the entire document to broadcast to all collaborators
- Frequently updated CRDTs produce a lot of WAL and dead tuples
- Large CRDT types in Postgres generate significant serialization/deserialization overhead on-update.
We’re likely to discover more (no doubt from a few friendly HN comments).
## Next steps
If you want to help with `pg_crdt` the best way is to get involved in the GitHub repo. We have enabled [Discussions](https://github.com/supabase/pg_crdt/discussions) for any and all ideas. If you have experience with CRDTs and you like this approach, don’t hesitate to contact one of the team.
@@ -169,7 +169,6 @@ export default function Form({ sharePage, align = 'Center' }: Props) {
align === 'Left' ? 'text-center xl:text-left' : 'text-center'
)}
>
<p className="text-scale-1200 text-base w-full">Coming soon</p>
<p className="text-scale-1000 text-base max-w-[420px]">
Register to get your ticket and stay tuned all week for daily announcements
</p>
@@ -0,0 +1,62 @@
[
{
"title": "Day 1",
"shipped": false,
"date": "12 Dec 2022",
"description": "Day 1",
"d": 1,
"dd": "Monday",
"youtube_id": "OpPOaJI_Z28",
"blogpost": "https://supabase.com/blog",
"docs": "https://supabase.com/docs",
"steps": []
},
{
"title": "Day 2",
"shipped": false,
"date": "13 Dec 2022",
"description": "Day 2",
"d": 2,
"dd": "Tuesday",
"youtube_id": "iqZlPtl_b-I",
"blogpost": "https://supabase.com/blog",
"docs": "https://supabase.com/docs",
"steps": []
},
{
"title": "Day 3",
"shipped": false,
"date": "14 Dec 2022",
"description": "Day 3",
"d": 3,
"dd": "Wednesday",
"youtube_id": "6bGQotxisoY",
"blogpost": "https://supabase.com/blog",
"docs": "https://supabase.com/docs",
"steps": []
},
{
"title": "Day 4",
"shipped": false,
"date": "15 Dec 2022",
"description": "Day 4",
"d": 4,
"dd": "Thursday",
"youtube_id": "CGZr5tybW18",
"blogpost": "https://supabase.com/blog",
"docs": "https://supabase.com/docs",
"steps": []
},
{
"title": "Day 5",
"shipped": false,
"date": "16 Dec 2022",
"description": "Day 5",
"d": 5,
"dd": "Friday",
"youtube_id": "CGZr5tybW18",
"blogpost": "https://supabase.com/blog",
"docs": "https://supabase.com/docs",
"steps": []
}
]
+8
View File
@@ -18,6 +18,13 @@ export type Product = {
description?: string
}
export type Step = {
title: string
url: string
docs: string
description?: string
}
export interface WeekDayProps {
shipped: boolean
title: string
@@ -32,4 +39,5 @@ export interface WeekDayProps {
index: number
shippingHasStarted?: boolean
youtube_id?: string
steps: Step[]
}
+25 -10
View File
@@ -227,17 +227,32 @@ function BlogPostPage(props: any) {
<div className="grid grid-cols-12 lg:gap-16 xl:gap-8">
{/* Content */}
<div className="col-span-12 lg:col-span-7 xl:col-span-7">
{props.blog.thumb && (
<div className="relative mb-8 h-96 w-full overflow-auto rounded-lg border">
<Image
src={'/images/blog/' + props.blog.thumb}
layout="fill"
objectFit="cover"
/>
<article>
<div className={['prose prose-docs'].join(' ')}>
{props.blog.youtubeHero ? (
<iframe
className="w-full"
width="700"
height="350"
src={props.blog.youtubeHero}
frameBorder="0"
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
// @ts-expect-error
allowfullscreen={true}
></iframe>
) : (
props.blog.thumb && (
<div className="relative mb-8 h-96 w-full overflow-auto rounded-lg border">
<Image
src={'/images/blog/' + props.blog.thumb}
layout="fill"
objectFit="cover"
/>
</div>
)
)}
<MDXRemote {...content} components={mdxComponents()} />
</div>
)}
<article className={['prose prose-docs'].join(' ')}>
<MDXRemote {...content} components={mdxComponents()} />
</article>
<div className="py-16">
<div className="text-scale-900 dark:text-scale-1000 text-sm">
+464 -20
View File
@@ -1,5 +1,8 @@
// @ts-nocheck
import { NextSeo } from 'next-seo'
import _days from '~/components/LaunchWeek/days.json'
import _days from '~/components/LaunchWeek/lw6_days.json'
import DefaultLayout from '~/components/Layouts/Default'
import SectionContainer from '~/components/Layouts/SectionContainer'
@@ -11,9 +14,13 @@ import { useTheme } from '~/components/Providers'
import classNames from 'classnames'
import styleUtils from '~/components/LaunchWeek/Ticket/utils.module.css'
import { SITE_ORIGIN } from '~/lib/constants'
import { Accordion, Badge, IconExternalLink } from 'ui'
import { WeekDayProps } from '~/components/LaunchWeek/types'
import { Badge, IconExternalLink } from '~/../../packages/ui'
import styles from './launchWeek.module.css'
import Image from 'next/image'
const days = _days as WeekDayProps[]
const constellation = [
[60, 8],
[13, 20],
@@ -29,13 +36,13 @@ const constellation = [
export default function launchweek() {
const { isDarkMode } = useTheme()
const title = 'Launch Week 6'
const description = 'Supabase Launch Week 6 | 12-18 Dec 2022'
const [supabase] = useState(() =>
createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!)
)
const [session, setSession] = useState<Session | null>(null)
const [creators, setCreators] = useState<any>([])
const [activeCreator, setActiveCreator] = useState<any>(null)
@@ -82,6 +89,46 @@ export default function launchweek() {
}
}
const AccordionHeader = ({ date, day, title, shipped }: any) => {
return (
<div className="flex flex-1 flex-col sm:flex-row">
<div className="flex gap-4 min-w-[320px] items-center">
<Badge className="!bg-transparent !py-1 !px-4 text-transparent bg-clip-text bg-gradient-to-r from-[#99bbab] to-[#396f55] border-[#598973] dark:from-white dark:to-[#1a7a4ca1] dark:border-[#598973] h-fit">
{shipped ? 'Shipped' : 'Coming Soon'}
</Badge>
<span className="text-scale-900 text-sm">
{day} ・ {date}
</span>
</div>
<span className="text-scale-1200 mt-3 sm:mt-0">{title}</span>
</div>
)
}
const SectionButtons = ({ blog, docs }) => {
return (
<div className="flex gap-2 z-10">
<a href={blog} target="_blank" rel="noopener">
<div className="flex items-center border border-slate-400 bg-gradient-to-r from-[#fcfcfc] to-[#f2f2f2] text-black dark:text-white dark:from-[#191919] dark:to-[#464444] rounded-full text-sm py-2 pl-3 pr-2">
Blog post
<div className="bg-[#eeeeee] dark:bg-[#313131] rounded-full inline-block p-1 ml-2">
<IconExternalLink className="text-[#4cc38a] w-3 h-3" />
</div>
</div>
</a>
<a href={docs} target="_blank" rel="noopener">
<div className="flex items-center border border-slate-400 bg-gradient-to-r from-[#fcfcfc] to-[#f2f2f2] text-black dark:text-white dark:from-[#191919] dark:to-[#464444] rounded-full text-sm py-2 pl-3 pr-2">
Docs
<div className="bg-[#eeeeee] dark:bg-[#313131] rounded-full inline-block p-1 ml-2">
<IconExternalLink className="text-[#4cc38a] w-3 h-3" />
</div>
</div>
</a>
</div>
)
}
const [day1, day2, day3, day4, day5] = days
return (
<>
<NextSeo
@@ -98,7 +145,7 @@ export default function launchweek() {
}}
/>
<DefaultLayout>
<SectionContainer className="flex flex-col !pb-1 items-center lg:pt-32 gap-32">
<SectionContainer className="flex flex-col !pb-1 items-center lg:pt-32 gap-24">
<div
className={classNames(
styleUtils.appear,
@@ -116,16 +163,18 @@ export default function launchweek() {
className="hidden w-40 dark:flex lg:w-80"
/>
</div>
<p className="text-scale-1100 text-sm text-center">Dec 12 – 16 at 8 AM PT | 11 AM ET</p>
</div>
<div className={classNames(styleUtils.appear, styleUtils['appear-second'])}>
<TicketContainer
supabase={supabase}
session={session}
defaultUserData={defaultUserData}
defaultPageState={query.ticketNumber ? 'ticket' : 'registration'}
/>
<p className="text-scale-1100 text-sm text-center">Dec 12 – 16 at 6 AM PT | 9 AM ET</p>
</div>
{process.env.NEXT_PUBLIC_LW_STARTED && (
<div className={classNames(styleUtils.appear, styleUtils['appear-second'])}>
<TicketContainer
supabase={supabase}
session={session}
defaultUserData={defaultUserData}
defaultPageState={query.ticketNumber ? 'ticket' : 'registration'}
/>
</div>
)}
</SectionContainer>
<div
className={classNames(
@@ -154,9 +203,401 @@ export default function launchweek() {
)}
></div>
</div>
{process.env.NEXT_PUBLIC_LW_STARTED && (
<>
<SectionContainer className="!pt-0 ">
<div className="border rounded-2xl border-slate-400 text-sm px-5 py-4 flex flex-col sm:flex-row justify-between items-center">
<div className="relative flex items-center mb-4 sm:mb-0">
<div className="flex">
<Image
src="/images/launchweek/antcopple.png"
className="rounded-xl w-32 h-32"
width={128}
height={72}
></Image>
</div>
<div className="flex flex-col lg:flex-row ml-8 sm:ml-10">
<span className="text-black dark:text-white mr-2">Who we hire at Supabase</span>
<span className="text-slate-900">Fireside chat with founders</span>
</div>
</div>
<div className="flex gap-2 z-10">
<a href={'https://youtu.be/-BG9XptyCKI'} target="_blank" rel="noopener">
<div className="flex items-center border border-slate-400 bg-gradient-to-r from-[#fcfcfc] to-[#f2f2f2] hover:to-[#d5d5d5] text-black dark:text-white dark:from-[#191919] dark:to-[#464444] dark:hover:to-[#4e4e4e] rounded-full text-sm py-2 pl-3 pr-2 min-w-[130px]">
Watch video
<div className="bg-[#eeeeee] dark:bg-[#313131] rounded-full inline-block p-1 ml-2">
<img src="/images/launchweek/video-icon.svg" className="w-3 h-3"></img>
</div>
</div>
</a>
<a href={'/blog/who-we-hire'} target="_blank" rel="noopener">
<div className="flex items-center border border-slate-400 bg-gradient-to-r from-[#fcfcfc] to-[#f2f2f2] hover:to-[#d5d5d5] text-black dark:text-white dark:from-[#191919] dark:to-[#464444] dark:hover:to-[#4e4e4e] rounded-full text-sm py-2 pl-3 pr-2 min-w-[142px]">
Read blogpost
<div className="bg-[#eeeeee] dark:bg-[#313131] rounded-full inline-block p-1 ml-2">
<img src="/images/launchweek/blog-icon.svg" className="w-3 h-3"></img>
</div>
</div>
</a>
</div>
</div>
</SectionContainer>
<SectionContainer className="!pt-0">
<Accordion
type="default"
openBehaviour="multiple"
size="large"
className="text-scale-900 dark:text-white"
justified={false}
bordered={false}
chevronAlign="right"
>
<div className="border-b pb-3">
<Accordion.Item
header={
<AccordionHeader
date={day1.date}
day={day1.dd}
title={day1.title}
shipped={day1.shipped}
/>
}
disabled={!day1.shipped}
className="h-[79px]"
id={day1.d.toString()}
>
{day1.steps.length > 0 && (
<div className="h-[400px] flex flex-col lg:flex-row">
<div
className={`flex flex-col flex-1 justify-between border rounded-xl h-full relative after:opacity-10 xl:after:opacity-90 after:absolute after:bg-no-repeat after:bg-[center_top_50px] lg:after:bg-[right_20%_top_50px] after:bg-contain after:bg-[url('/images/launchweek/docs-update-bg.png')] after:top-0 after:right-0 after:bottom-0 after:left-0 p-14 text-2xl`}
>
<div className="flex items-center relative z-10">
<span>{day1.description}</span>
<Badge className="!bg-transparent h-fit ml-4 text-sm !py-1 !px-4 text-transparent bg-clip-text bg-gradient-to-r from-[#99bbab] to-[#396f55] border-[#598973] dark:from-white dark:to-[#1a7a4ca1] dark:border-[#598973]">
Redesigned
</Badge>
</div>
<SectionButtons docs={day1.steps[0].docs} blog={day1.steps[0].blog} />
</div>
</div>
)}
</Accordion.Item>
</div>
<div className="border-b pb-3">
<Accordion.Item
header={
<AccordionHeader
date={day2.date}
day={day2.dd}
title={day2.title}
shipped={day2.shipped}
/>
}
disabled={!day2.shipped}
className="h-[79px]"
id={day2.d.toString()}
>
{day2.steps.length > 0 && (
<div className="h-[800px] lg:h-[400px] flex flex-col gap-5 lg:flex-row">
<div
className={`flex-1 flex flex-col justify-between basis-1/2 lg:basis-2/3 border rounded-xl h-full p-14 text-2xl bg-no-repeat bg-[url('/images/launchweek/image-processing-bg-light.png')] dark:bg-[url('/images/launchweek/image-processing-bg.png')] bg-[right_28px_top_35px] bg-contain`}
>
<div className="flex items-center">
<div>{day2.steps[0].title}</div>
<Badge className="!bg-transparent h-fit ml-4 text-sm !py-1 !px-4 text-transparent bg-clip-text bg-gradient-to-r from-[#99bbab] to-[#396f55] border-[#598973] dark:from-white dark:to-[#1a7a4ca1] dark:border-[#598973]">
New
</Badge>
</div>
<SectionButtons docs={day2.steps[0].docs} blog={day2.steps[0].blog} />
</div>
<div
className={`flex-1 flex flex-col items-center justify-between basis-1/2 lg:basis-1/3 border rounded-xl h-full bg-no-repeat p-14 text-2xl bg-[url('/images/launchweek/cdn-caching-bg-light.png')] dark:bg-[url('/images/launchweek/cdn-caching-bg.png')] bg-[center_bottom] bg-contain`}
>
<div className="flex flex-col items-center gap-2 min-w-[300px]">
<Badge className="!bg-transparent h-fit text-sm !py-1 !px-4 text-transparent bg-clip-text bg-gradient-to-r from-[#99bbab] to-[#396f55] border-[#598973] dark:from-white dark:to-[#1a7a4ca1] dark:border-[#598973]">
New
</Badge>
<span>{day2.steps[1].title}</span>
<p className="text-sm text-slate-900">{day2.steps[1].description}</p>
</div>
<SectionButtons docs={day2.steps[1].docs} blog={day2.steps[1].blog} />
</div>
</div>
)}
</Accordion.Item>
</div>
<div className="border-b pb-3">
<Accordion.Item
header={
<AccordionHeader
date={day3.date}
day={day3.dd}
title={day3.title}
shipped={day3.shipped}
/>
}
disabled={!day3.shipped}
className="h-[79px]"
id={day3.d.toString()}
>
{day3.steps.length > 0 && (
<div className="h-[800px] lg:h-[400px] flex flex-col gap-5 lg:flex-row">
<div
className={`flex flex-col justify-between flex-1 basis-1/2 lg:basis-2/3 border rounded-xl h-full p-14 text-2xl bg-no-repeat bg-contain bg-[url('/images/launchweek/vault-bg-light.png')] dark:bg-[url('/images/launchweek/vault-bg.png')] bg-[center_bottom]`}
>
<div className="flex items-center">
<span>{day3.steps[0].title}</span>
<Badge className="!bg-transparent h-fit ml-4 text-sm !py-1 !px-4 text-transparent bg-clip-text bg-gradient-to-r from-[#99bbab] to-[#396f55] border-[#598973] dark:from-white dark:to-[#1a7a4ca1] dark:border-[#598973]">
New
</Badge>
</div>
<SectionButtons docs={day3.steps[0].docs} blog={day3.steps[0].blog} />
</div>
<div
className={`flex-1 flex flex-col items-center justify-between basis-1/2 lg:basis-1/3 border rounded-xl h-full bg-no-repeat p-14 text-2xl bg-[url('/images/launchweek/encryption-bg-light.png')] dark:bg-[url('/images/launchweek/encryption-bg.svg')] bg-contain bg-[center_center] shadow-[inset_0px_130px_50px_-52p_rgb(10,31,30)]`}
>
{/* inset 0px 130px 50px -52px #121f1e; */}
<div className="flex flex-col items-center gap-2 min-w-[300px]">
<Badge className="!bg-transparent h-fit text-sm !py-1 !px-4 text-transparent bg-clip-text bg-gradient-to-r from-[#99bbab] to-[#396f55] border-[#598973] dark:from-white dark:to-[#1a7a4ca1] dark:border-[#598973]">
New
</Badge>
<span className="text-center">{day3.steps[1].title}</span>
</div>
<SectionButtons docs={day3.steps[1].docs} blog={day3.steps[1].blog} />
</div>
</div>
)}
</Accordion.Item>
</div>
<div className="border-b pb-3">
<Accordion.Item
header={
<AccordionHeader
date={day4.date}
day={day4.dd}
title={day4.title}
shipped={day4.shipped}
/>
}
disabled={!day4.shipped}
className="h-[79px]"
id={day4.d.toString()}
>
{day4.steps.length > 0 && (
<div className="h-[400px] flex gap-5">
<div
className={`flex flex-col justify-between flex-1 border rounded-xl h-full bg-no-repeat p-14 text-2xl bg-[url('/images/launchweek/mfa-bg-light.png')] dark:bg-[url('/images/launchweek/mfa-bg.png')] bg-[bottom_right_30%] bg-contain`}
>
<div className="flex items-center">
<span>{day4.steps[0].title}</span>
<Badge className="!bg-transparent h-fit ml-4 text-sm !py-1 !px-4 text-transparent bg-clip-text bg-gradient-to-r from-white to-[#1a7a4ca1] border-[#598973]">
Updated
</Badge>
</div>
<SectionButtons docs={day4.steps[0].docs} blog={day4.steps[0].blog} />
</div>
</div>
)}
</Accordion.Item>
</div>
<div className="border-b pb-3">
<Accordion.Item
header={
<AccordionHeader
date={day5.date}
day={day5.dd}
title={day5.title}
shipped={day5.shipped}
/>
}
disabled={!day5.shipped}
className="h-[79px]"
id={day5.d.toString()}
>
{day5.steps.length > 0 && (
<div className="h-[800px] lg:h-[400px] flex flex-col gap-5 lg:flex-row">
<div
className={`flex flex-col justify-between flex-1 basis-1/2 lg:basis-2/3 border rounded-xl h-full bg-no-repeat p-14 text-2xl bg-[url('/images/launchweek/wrappers-bg-light.png')] dark:bg-[url('/images/launchweek/wrappers-bg.png')] bg-contain bg-[right]`}
>
<div className="flex items-center">
<span>{day5.steps[0].title}</span>
<Badge className="!bg-transparent h-fit ml-4 text-sm !py-1 !px-4 text-transparent bg-clip-text bg-gradient-to-r from-[#99bbab] to-[#396f55] border-[#598973] dark:from-white dark:to-[#1a7a4ca1] dark:border-[#598973]">
New
</Badge>
</div>
<SectionButtons docs={day5.steps[0].docs} blog={day5.steps[0].blog} />
</div>
<div
className={`flex-1 flex flex-col items-center justify-between basis-1/2 lg:basis-1/3 border rounded-xl h-full bg-no-repeat p-14 text-2xl bg-[url('/images/launchweek/pitr-bg-light.png')] dark:bg-[url('/images/launchweek/pitr-bg.png')] bg-cover bg-[center_center]`}
>
<div className="flex flex-col items-center gap-2 min-w-[300px]">
<Badge className="!bg-transparent h-fit text-sm !py-1 !px-4 text-transparent bg-clip-text bg-gradient-to-r from-[#99bbab] to-[#396f55] border-[#598973] dark:from-white dark:to-[#1a7a4ca1] dark:border-[#598973]">
New
</Badge>
<span>{day5.steps[1].title}</span>
</div>
<SectionButtons docs={day5.steps[1].docs} blog={day5.steps[1].blog} />
</div>
</div>
)}
</Accordion.Item>
</div>
</Accordion>
</SectionContainer>
</>
)}
<SectionContainer className="!pt-0 sm:!pb-20 !pb-40">
<div className="flex flex-col mb-16">
<Badge className="!bg-transparent h-fit text-sm !py-1 !px-4 text-transparent bg-clip-text bg-gradient-to-r from-[#99bbab] to-[#396f55] border-[#598973] dark:from-white dark:to-[#1a7a4ca1] dark:border-[#598973] w-fit after:bg-white mb-4">
Happening all week
</Badge>
<h2 className="text-3xl text-black dark:text-white mb-2">Launch Week Hackathon</h2>
<p className="text-slate-900 w-[80%] lg:w-[50%]">
The traditional parallel Hackathon is back! Build a new open source project with
Supabase and you can win $1500 in GitHub sponsorships and a coveted Supabase Darkmode
Keyboard! For more info check the{' '}
<a
href="https://supabase.com/blog/launch-week-6-hackathon"
target="_blank"
rel="nooper noreferrer"
className="text-brand-900"
>
blog post
</a>
.
</p>
</div>
<div className="flex flex-col lg:flex-row gap-10 lg:gap-0">
<div className="sm:grid grid-cols-2 grid-rows-2 gap-x-4 gap-y-7 sm:gap-y-10 basis-2/3 flex flex-col">
<div className="flex flex-col">
<h3 className="text-lg text-black dark:text-white mb-2">Prizes</h3>
<p className="text-slate-900 w-[90%] lg:w-[80%]">
There are 5 categories to win, with prizes for winners and runner-ups of each
category. Each team member gets a prize.
</p>
</div>
<div className="flex flex-col">
<h3 className="text-lg text-black dark:text-white mb-2">Judges</h3>
<p className="text-slate-900 w-[90%] lg:w-[80%]">
The Supabase team will judge all the categories except the Best Edge Functions
Project, which will be judged by our friends at Deno.
</p>
</div>
<div className="flex flex-col">
<div className="flex flex-col">
<h3 className="text-lg text-black dark:text-white mb-2">Community</h3>
<p className="text-slate-900 w-[90%] lg:w-[80%]">
If you need help or advice when building, find other people to join your team,
or if you just want to chill and watch people build, come and join us!
</p>
<div></div>
<a
href="https://discord.supabase.com/"
rel="noopener noreferrer"
className="text-brand-900 flex items-center"
target="_blank"
>
Join our Discord <IconExternalLink size="small" className="inline-block ml-2" />
</a>
</div>
</div>
<div className={'flex flex-col'}>
<h3 className="text-lg text-black dark:text-white mb-2">Submission</h3>
<p className="text-slate-900 w-[90%] lg:w-[80%]">
Submit your project through{' '}
<a
className="text-brand-900"
href="https://www.madewithsupabase.com/launch-week-6"
target="_blank"
>
madewithsupabase.com
</a>
. All submissions must be open source and publically available. Submissions close
Monday 19th Dec 00:01 AM PT.
</p>
</div>
</div>
<div
className={classNames(
isDarkMode ? styles.dark_community : styles.community,
'flex basis-1/3 flex-col px-5'
)}
>
<a
href="https://github.com/psteinroe/supabase-cache-helpers"
target="_blank"
rel="noopener noreferrer"
className="self-end"
>
<div className="border border-slate-400 dark:border-[#2E2E2E] rounded-xl pl-5 pr-9 py-4 mb-4 bg-white dark:bg-[#121212] w-fit relative max-w-[250px]">
<img
src="/images/launchweek/link.svg"
className="absolute top-[10px] right-[10px] text-brand-900"
></img>
<h3 className="text-black dark:text-white">Supabase Cache Helpers</h3>
<p className="text-slate-1000 text-xs">Previous Best Overall Project Winner</p>
</div>
</a>
<a
href="https://github.com/pheralb/superui"
target="_blank"
rel="noopener noreferrer"
>
<div className="border border-slate-400 dark:border-[#2E2E2E] rounded-xl pl-5 pr-9 py-4 mb-4 bg-white dark:bg-[#121212] w-fit relative max-w-[250px]">
<img
src="/images/launchweek/link.svg"
className="absolute top-[10px] right-[10px] text-brand-900"
></img>
<h3 className="text-black dark:text-white">Super UI</h3>
<p className="text-slate-1000 text-xs">Previous Best Overall Project Runner Up</p>
</div>
</a>
<a
href="https://github.com/Myzel394/quid_faciam_hodie"
target="_blank"
rel="noopener noreferrer"
className="self-end"
>
<div className="border border-slate-400 dark:border-[#2E2E2E] rounded-xl pl-5 pr-9 py-4 mb-4 bg-white dark:bg-[#121212] w-fit relative max-w-[250px]">
<img
src="/images/launchweek/link.svg"
className="absolute top-[10px] right-[10px] text-brand-900"
></img>
<h3 className="text-black dark:text-white">Quid Faciam Hodie?</h3>
<p className="text-slate-1000 text-xs">Winner Best Flutter Project</p>
</div>
</a>
<a href="https://github.com/laznic/hotdogs" target="_blank" rel="noopener noreferrer">
<div className="border border-slate-400 dark:border-[#2E2E2E] rounded-xl pl-5 pr-9 py-4 mb-4 bg-white dark:bg-[#121212] w-fit relative max-w-[250px]">
<img
src="/images/launchweek/link.svg"
className="absolute top-[10px] right-[10px] text-brand-900"
></img>
<h3 className="text-black dark:text-white">That Hot Dog Game</h3>
<p className="text-slate-1000 text-xs">Previous Winner: Most Fun/Interesting</p>
</div>
</a>
<a
href="https://github.com/vvidday/repo-watch"
target="_blank"
rel="noopener noreferrer"
className="self-end"
>
<div className="border border-slate-400 dark:border-[#2E2E2E] rounded-xl pl-5 pr-9 py-4 bg-white dark:bg-[#121212] w-fit relative max-w-[250px]">
<img
src="/images/launchweek/link.svg"
className="absolute top-[10px] right-[10px] text-brand-900"
></img>
<h3 className="text-black dark:text-white">RepoWatch</h3>
<p className="text-slate-1000 text-xs">Previous Winner Best Realtime Project</p>
</div>
</a>
</div>
</div>
</SectionContainer>
<SectionContainer
className={classNames(
'flex gap-6 min-h-[350px] !py-3 mb-11 flex-col-reverse md:flex-row',
'flex gap-6 min-h-[350px] !py-3 mb-11 flex-col-reverse md:flex-row mt-24',
styleUtils.appear,
styleUtils['appear-third']
)}
@@ -191,14 +632,14 @@ export default function launchweek() {
</div>
<div className="flex-1">
<Badge className="mb-6 bg-gradient-to-r from-[#0E3737C2] to-[#67947F] hover:to-[#39617D94] dark:hover:to-[#A6FFD899] text-whiteA-1200 dark:text-black font-normal !py-1 !px-4 dark:from-white dark:via-white dark:to-[#1a7a4c75] bg-slate-1200">
Currently happening
Live
</Badge>
<h2 className="text-4xl dark:text-white mb-2">The Supabase Content Storm</h2>
<h2 className="text-3xl dark:text-white mb-2">The Supabase Content Storm</h2>
<p className="text-slate-900 md:max-w-[80%] mb-16">
We worked with +30 content creators from around the world to drop a mountain of
content simultaneously!
We worked with more than 30 content creators from around the world to drop a mountain
of content simultaneously!
<a rel="noopener" target="_blank" href="/blog/the-supabase-content-storm">
<div className="text-brand-900 flex items-center">
<div className="text-brand-900 flex items-center mt-2">
See all the content
<IconExternalLink size="small" className="inline-block ml-1" />
</div>
@@ -211,7 +652,10 @@ export default function launchweek() {
? `${creators[activeCreator].first_name} ${creators[activeCreator].last_name}`
: 'Title'}
</h3>
<p className="text-brand-900">
{activeCreator !== null && (
<p className="text-slate-900 ">{creators[activeCreator].description}</p>
)}
<p className="text-brand-900 mt-1">
<a rel="noopener" target="_blank" href={creators[activeCreator].link}>
<span>
{creators[activeCreator].link_title}
@@ -1,3 +1,11 @@
.mask {
mask-image: radial-gradient(black, transparent);
}
.dark_community {
background-image: radial-gradient(closest-side at 50% 50%, #132121, #132121db, #121212);
}
.community {
background-image: radial-gradient(closest-side at 50% 50%, #d9eeef, #dbeef0, white);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 991 KiB

@@ -0,0 +1,8 @@
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.57812 5H9.81396" stroke="#4CC38A"/>
<path d="M4.57812 7H8.01088M12.3433 7H10.8695" stroke="#4CC38A"/>
<path d="M4.57812 9H6.68417M12.3433 9H8.64858" stroke="#4CC38A"/>
<path d="M4.57812 11H12.3433" stroke="#4CC38A"/>
<path d="M13.0927 2.02734H2.46094V14.0273H14.4609V3.08444" stroke="#4CC38A" stroke-miterlimit="10" stroke-linejoin="bevel"/>
<path d="M7.27023 7.61649L13.8876 0.999099L15.3018 2.41331L8.68445 9.0307L6.41804 9.88289L7.27023 7.61649Z" stroke="#4CC38A" stroke-miterlimit="10" stroke-linejoin="bevel"/>
</svg>

After

Width:  |  Height:  |  Size: 631 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

@@ -0,0 +1,89 @@
<svg width="449" height="401" viewBox="0 0 449 401" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M66.7756 213.131C67.9456 213.131 68.8606 212.711 69.5806 212.231L69.1456 211.406C68.4706 211.856 67.7806 212.126 66.9256 212.126C65.3356 212.126 64.1956 211.061 64.1356 209.411H69.8356C69.8656 209.216 69.8806 208.946 69.8806 208.691C69.8806 206.531 68.6506 205.106 66.5656 205.106C64.6756 205.106 62.8606 206.636 62.8606 209.126C62.8606 211.631 64.6156 213.131 66.7756 213.131ZM64.1206 208.511C64.3156 206.981 65.3956 206.081 66.5806 206.081C67.9306 206.081 68.7406 206.951 68.7406 208.511H64.1206ZM72.4847 216.191C74.1347 216.191 75.0197 215.036 75.5897 213.566L78.7847 205.286H77.6147L76.0247 209.591C75.7697 210.281 75.4997 211.076 75.2447 211.796H75.1847C74.8847 211.061 74.5697 210.266 74.2847 209.591L72.4997 205.286H71.2547L74.6597 212.966L74.4497 213.506C74.0597 214.511 73.4597 215.171 72.4397 215.171C72.2147 215.171 71.9747 215.126 71.7797 215.066L71.5247 216.041C71.7797 216.131 72.1397 216.191 72.4847 216.191ZM83.3539 213.116C85.7989 213.116 86.6089 211.616 86.6089 209.771V203.021H81.2389V204.071H85.3639V209.666C85.3639 211.301 84.7339 212.021 83.3239 212.021C82.5589 212.021 81.8089 211.601 81.2389 210.716L80.4289 211.466C81.1039 212.621 82.2439 213.116 83.3539 213.116ZM92.708 203.576C93.293 203.576 93.758 203.156 93.758 202.541C93.758 201.911 93.293 201.476 92.708 201.476C92.123 201.476 91.658 201.911 91.658 202.541C91.658 203.156 92.123 203.576 92.708 203.576ZM89.408 212.936H96.308V211.916H93.473V205.286H89.408V206.291H92.243V211.916H89.408V212.936ZM97.5772 212.936H104.687V211.871H99.0922L104.612 203.771V203.021H98.0572V204.071H103.082L97.5772 212.186V212.936ZM71.3297 232.936H72.6497L74.1197 230.251C74.3747 229.771 74.6297 229.276 74.9147 228.676H74.9747C75.3047 229.276 75.5747 229.786 75.8447 230.266L77.3297 232.936H78.7097L75.7697 227.881L78.5147 223.036H77.1947L75.8447 225.571C75.6047 226.021 75.4097 226.441 75.1397 227.011H75.0797C74.7647 226.441 74.5397 226.021 74.2847 225.571L72.9047 223.036H71.5247L74.2697 227.821L71.3297 232.936ZM80.4589 232.936H81.6439V227.746C81.6439 226.726 81.5239 225.661 81.4789 224.656H81.5089L82.4989 226.681L85.6939 232.936H86.9689V223.036H85.7839V228.181C85.7839 229.216 85.9039 230.341 85.9489 231.316H85.9189L84.9289 229.291L81.7339 223.036H80.4589V232.936ZM92.408 233.116C94.538 233.116 95.843 231.241 95.843 227.956C95.843 224.641 94.538 222.856 92.408 222.856C90.278 222.856 88.973 224.641 88.973 227.956C88.973 231.241 90.278 233.116 92.408 233.116ZM92.408 232.126C91.073 232.126 90.128 230.806 90.128 227.956C90.128 225.091 91.073 223.846 92.408 223.846C93.743 223.846 94.688 225.091 94.688 227.956C94.688 230.806 93.743 232.126 92.408 232.126ZM92.408 228.871C92.903 228.871 93.338 228.496 93.338 227.851C93.338 227.206 92.903 226.831 92.408 226.831C91.913 226.831 91.478 227.206 91.478 227.851C91.478 228.496 91.913 228.871 92.408 228.871Z" fill="url(#paint0_linear_946_100257)"/>
<path d="M132.276 212.936H133.596L135.066 210.251C135.321 209.771 135.576 209.276 135.861 208.676H135.921C136.251 209.276 136.521 209.786 136.791 210.266L138.276 212.936H139.656L136.716 207.881L139.461 203.036H138.141L136.791 205.571C136.551 206.021 136.356 206.441 136.086 207.011H136.026C135.711 206.441 135.486 206.021 135.231 205.571L133.851 203.036H132.471L135.216 207.821L132.276 212.936ZM141.255 212.936H147.99V211.871H144.84C144.27 211.871 143.64 211.916 143.055 211.961C145.665 209.471 147.465 207.626 147.465 205.766C147.465 204.011 146.265 202.856 144.345 202.856C142.995 202.856 142.05 203.456 141.195 204.356L141.9 205.061C142.515 204.386 143.295 203.861 144.195 203.861C145.605 203.861 146.28 204.686 146.28 205.811C146.28 207.476 144.495 209.216 141.255 212.201V212.936ZM150.354 212.936H151.599V208.751H153.354L155.724 212.936H157.149L154.644 208.616C155.979 208.271 156.849 207.356 156.849 205.811C156.849 203.756 155.409 203.036 153.414 203.036H150.354V212.936ZM151.599 207.731V204.041H153.234C154.764 204.041 155.604 204.506 155.604 205.811C155.604 207.101 154.764 207.731 153.234 207.731H151.599ZM158.944 212.936H160.174V207.596C161.029 206.636 161.614 206.171 162.484 206.171C163.684 206.171 164.164 206.876 164.164 208.346V212.936H165.394V208.181C165.394 206.171 164.599 205.106 162.874 205.106C161.734 205.106 160.894 205.751 160.114 206.621L160.174 204.791V202.031H158.944V212.936ZM170.308 213.131C171.208 213.131 172.078 212.561 172.678 211.916H172.723L172.828 212.936H173.848V202.031H172.618V204.671L172.663 206.111C171.988 205.481 171.343 205.106 170.458 205.106C168.733 205.106 167.143 206.651 167.143 209.126C167.143 211.676 168.403 213.131 170.308 213.131ZM170.563 212.096C169.213 212.096 168.418 210.956 168.418 209.111C168.418 207.356 169.438 206.141 170.698 206.141C171.328 206.141 171.958 206.396 172.618 207.056V210.911C171.973 211.691 171.283 212.096 170.563 212.096ZM145.215 233.116C146.49 233.116 147.525 232.606 148.155 231.976V227.806H145.035V228.841H147V231.436C146.625 231.796 145.98 232.021 145.29 232.021C143.355 232.021 142.26 230.446 142.26 227.956C142.26 225.496 143.475 223.951 145.32 223.951C146.265 223.951 146.85 224.341 147.33 224.851L148.035 224.056C147.48 223.471 146.625 222.856 145.32 222.856C142.77 222.856 140.955 224.791 140.955 227.986C140.955 231.211 142.74 233.116 145.215 233.116ZM150.879 232.936H152.124V228.496H156.159V227.446H152.124V224.086H156.894V223.036H150.879V232.936ZM162.349 223.576C162.934 223.576 163.399 223.156 163.399 222.541C163.399 221.911 162.934 221.476 162.349 221.476C161.764 221.476 161.299 221.911 161.299 222.541C161.299 223.156 161.764 223.576 162.349 223.576ZM159.049 232.936H165.949V231.916H163.114V225.286H159.049V226.291H161.884V231.916H159.049V232.936Z" fill="url(#paint1_linear_946_100257)"/>
<path d="M207.522 212.936H208.782V209.396L212.082 203.036H210.792L209.367 205.961C208.992 206.726 208.602 207.491 208.212 208.286H208.152C207.747 207.491 207.402 206.741 207.027 205.991L205.542 203.036H204.222L207.522 209.396V212.936ZM213.156 212.936H214.476L215.946 210.251C216.201 209.771 216.456 209.276 216.741 208.676H216.801C217.131 209.276 217.401 209.786 217.671 210.266L219.156 212.936H220.536L217.596 207.881L220.341 203.036H219.021L217.671 205.571C217.431 206.021 217.236 206.441 216.966 207.011H216.906C216.591 206.441 216.366 206.021 216.111 205.571L214.731 203.036H213.351L216.096 207.821L213.156 212.936ZM222.285 212.936H223.47V207.746C223.47 206.726 223.35 205.661 223.305 204.656H223.335L224.325 206.681L227.52 212.936H228.795V203.036H227.61V208.181C227.61 209.216 227.73 210.341 227.775 211.316H227.745L226.755 209.291L223.56 203.036H222.285V212.936ZM235.824 213.131C236.619 213.131 237.129 212.921 237.789 212.666L237.474 211.736C236.934 211.991 236.514 212.111 236.094 212.111C235.224 212.111 234.669 211.616 234.669 210.491V202.031H230.949V203.036H233.439V210.401C233.439 212.201 234.279 213.131 235.824 213.131ZM239.853 212.936H246.003V211.871H243.558V204.086H246.003V203.036H239.853V204.086H242.298V211.871H239.853V212.936ZM217.566 223.576C218.151 223.576 218.616 223.156 218.616 222.541C218.616 221.911 218.151 221.476 217.566 221.476C216.981 221.476 216.516 221.911 216.516 222.541C216.516 223.156 216.981 223.576 217.566 223.576ZM215.256 236.191C217.611 236.191 218.316 234.871 218.316 232.981V225.286H213.696V226.291H217.086V233.086C217.086 234.556 216.696 235.186 215.241 235.186C214.686 235.186 214.101 235.021 213.576 234.766L213.171 235.681C213.756 235.981 214.491 236.191 215.256 236.191ZM225.54 233.131C227.46 233.131 229.14 231.661 229.14 229.126C229.14 226.561 227.46 225.106 225.54 225.106C223.62 225.106 221.94 226.561 221.94 229.126C221.94 231.661 223.62 233.131 225.54 233.131ZM225.54 232.111C224.145 232.111 223.215 230.911 223.215 229.126C223.215 227.341 224.145 226.126 225.54 226.126C226.935 226.126 227.865 227.341 227.865 229.126C227.865 230.911 226.935 232.111 225.54 232.111ZM234.534 223.576C235.119 223.576 235.584 223.156 235.584 222.541C235.584 221.911 235.119 221.476 234.534 221.476C233.949 221.476 233.484 221.911 233.484 222.541C233.484 223.156 233.949 223.576 234.534 223.576ZM231.234 232.936H238.134V231.916H235.299V225.286H231.234V226.291H234.069V231.916H231.234V232.936Z" fill="url(#paint2_linear_946_100257)"/>
<path d="M278.078 213.131C279.158 213.131 280.178 212.681 280.973 211.931L280.433 211.136C279.818 211.676 279.068 212.111 278.168 212.111C276.488 212.111 275.363 210.911 275.363 209.126C275.363 207.341 276.518 206.126 278.198 206.126C278.963 206.126 279.593 206.456 280.193 207.026L280.808 206.231C280.208 205.616 279.338 205.106 278.153 205.106C275.948 205.106 274.088 206.561 274.088 209.126C274.088 211.661 275.798 213.131 278.078 213.131ZM285.887 213.116C287.792 213.116 289.352 212.051 289.352 210.296C289.352 208.931 288.257 208.061 286.982 207.791V207.716C288.167 207.326 289.022 206.561 289.022 205.361C289.022 203.771 287.657 202.856 285.857 202.856C284.627 202.856 283.592 203.381 282.797 204.101L283.457 204.881C284.087 204.281 284.912 203.861 285.812 203.861C286.982 203.861 287.762 204.476 287.762 205.451C287.762 206.501 286.892 207.311 284.627 207.311V208.271C287.147 208.271 288.107 209.066 288.107 210.266C288.107 211.376 287.117 212.096 285.812 212.096C284.582 212.096 283.697 211.541 283.067 210.911L282.437 211.721C283.142 212.456 284.237 213.116 285.887 213.116ZM294.056 212.936H295.511L298.631 203.036H297.356L295.781 208.391C295.436 209.561 295.196 210.491 294.836 211.661H294.776C294.416 210.491 294.176 209.561 293.831 208.391L292.241 203.036H290.921L294.056 212.936ZM300.726 212.936H302.196L303.036 208.991C303.216 208.121 303.321 207.431 303.441 206.771H303.501C303.651 207.536 303.771 208.256 303.906 208.976L304.776 212.936H306.261L307.851 205.286H306.681L305.811 209.771C305.676 210.491 305.571 211.181 305.436 211.886H305.376C305.226 211.181 305.091 210.491 304.926 209.771L304.026 205.706H302.976L302.091 209.771C301.926 210.476 301.791 211.181 301.656 211.886H301.596C301.491 211.181 301.371 210.491 301.236 209.771L300.351 205.286H299.091L300.726 212.936ZM311.535 212.936H312.795V209.396L316.095 203.036H314.805L313.38 205.961C313.005 206.726 312.615 207.491 312.225 208.286H312.165C311.76 207.491 311.415 206.741 311.04 205.991L309.555 203.036H308.235L311.535 209.396V212.936ZM283.247 232.936H284.597L285.677 228.781C285.827 228.196 285.947 227.581 286.067 226.996H286.112C286.217 227.581 286.337 228.196 286.472 228.781L287.597 232.936H288.977L290.432 223.021H289.247L288.497 229.246C288.437 230.056 288.347 230.881 288.287 231.691H288.227C288.017 230.881 287.792 230.041 287.612 229.246L286.577 225.586H285.692L284.657 229.246C284.462 230.056 284.237 230.881 284.027 231.691H283.982C283.922 230.881 283.817 230.071 283.772 229.246L282.977 223.021H281.732L283.247 232.936ZM294.416 233.116C296.861 233.116 297.671 231.616 297.671 229.771V223.021H292.301V224.071H296.426V229.666C296.426 231.301 295.796 232.021 294.386 232.021C293.621 232.021 292.871 231.601 292.301 230.716L291.491 231.466C292.166 232.621 293.306 233.116 294.416 233.116ZM300.366 232.936H301.596V227.596C302.451 226.636 303.036 226.171 303.906 226.171C305.106 226.171 305.586 226.876 305.586 228.346V232.936H306.816V228.181C306.816 226.171 306.021 225.106 304.296 225.106C303.156 225.106 302.316 225.751 301.536 226.621L301.596 224.791V222.031H300.366V232.936Z" fill="url(#paint3_linear_946_100257)"/>
<path d="M348.336 213.131C349.416 213.131 350.436 212.681 351.231 211.931L350.691 211.136C350.076 211.676 349.326 212.111 348.426 212.111C346.746 212.111 345.621 210.911 345.621 209.126C345.621 207.341 346.776 206.126 348.456 206.126C349.221 206.126 349.851 206.456 350.451 207.026L351.066 206.231C350.466 205.616 349.596 205.106 348.411 205.106C346.206 205.106 344.346 206.561 344.346 209.126C344.346 211.661 346.056 213.131 348.336 213.131ZM352.935 212.936H359.67V211.871H356.52C355.95 211.871 355.32 211.916 354.735 211.961C357.345 209.471 359.145 207.626 359.145 205.766C359.145 204.011 357.945 202.856 356.025 202.856C354.675 202.856 353.73 203.456 352.875 204.356L353.58 205.061C354.195 204.386 354.975 203.861 355.875 203.861C357.285 203.861 357.96 204.686 357.96 205.811C357.96 207.476 356.175 209.216 352.935 212.201V212.936ZM365.049 213.116C366.969 213.116 368.349 212.036 368.349 209.201V203.036H367.134V209.216C367.134 211.286 366.234 212.021 365.049 212.021C363.864 212.021 362.979 211.286 362.979 209.216V203.036H361.719V209.201C361.719 212.036 363.114 213.116 365.049 213.116ZM374.028 203.576C374.613 203.576 375.078 203.156 375.078 202.541C375.078 201.911 374.613 201.476 374.028 201.476C373.443 201.476 372.978 201.911 372.978 202.541C372.978 203.156 373.443 203.576 374.028 203.576ZM370.728 212.936H377.628V211.916H374.793V205.286H370.728V206.291H373.563V211.916H370.728V212.936ZM381.552 212.936H382.767V206.291H385.827V205.286H382.767V204.896C382.767 203.636 383.307 202.856 384.612 202.856C385.242 202.856 385.707 202.931 386.307 203.231L386.592 202.301C385.902 201.986 385.212 201.851 384.492 201.851C382.482 201.851 381.552 203.066 381.552 204.941V205.286L379.467 205.376V206.291H381.552V212.936ZM359.025 235.396C359.46 235.396 359.82 235.306 360.135 235.186L359.91 234.181C359.73 234.256 359.475 234.331 359.175 234.331C358.155 234.331 357.375 233.896 357.045 233.041C358.86 232.666 360.06 230.761 360.06 227.941C360.06 224.791 358.545 222.841 356.325 222.841C354.09 222.841 352.575 224.776 352.575 227.941C352.575 230.806 353.82 232.726 355.71 233.056C356.205 234.406 357.285 235.396 359.025 235.396ZM356.325 232.036C354.81 232.036 353.85 230.401 353.85 227.941C353.85 225.481 354.81 223.921 356.325 223.921C357.825 223.921 358.785 225.481 358.785 227.941C358.785 230.401 357.825 232.036 356.325 232.036ZM361.809 226.846H368.259V225.916H361.809V226.846ZM361.809 230.056H368.259V229.126H361.809V230.056ZM370.503 226.846H376.953V225.916H370.503V226.846ZM370.503 230.056H376.953V229.126H370.503V230.056Z" fill="url(#paint4_linear_946_100257)"/>
<rect x="56.457" y="164.4" width="54.8775" height="118.142" fill="url(#paint5_linear_946_100257)" stroke="url(#paint6_linear_946_100257)" stroke-width="0.5"/>
<rect x="126.762" y="164.4" width="54.8775" height="118.142" fill="url(#paint7_linear_946_100257)" stroke="url(#paint8_linear_946_100257)" stroke-width="0.5"/>
<rect x="197.07" y="164.4" width="54.8775" height="118.142" fill="url(#paint9_linear_946_100257)" stroke="url(#paint10_linear_946_100257)" stroke-width="0.5"/>
<rect x="267.367" y="164.4" width="54.8775" height="118.142" fill="url(#paint11_linear_946_100257)" stroke="url(#paint12_linear_946_100257)" stroke-width="0.5"/>
<rect x="337.668" y="164.4" width="54.8775" height="118.142" fill="url(#paint13_linear_946_100257)" stroke="url(#paint14_linear_946_100257)" stroke-width="0.5"/>
<rect x="0.472656" y="-0.417969" width="448.055" height="402.196" fill="url(#paint15_radial_946_100257)"/>
<defs>
<linearGradient id="paint0_linear_946_100257" x1="83.7109" y1="188.936" x2="83.7109" y2="246.439" gradientUnits="userSpaceOnUse">
<stop stop-color="#346458"/>
<stop offset="1" stop-color="#07B07E" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint1_linear_946_100257" x1="153.352" y1="188.936" x2="153.352" y2="246.439" gradientUnits="userSpaceOnUse">
<stop stop-color="#346458"/>
<stop offset="1" stop-color="#07B07E" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint2_linear_946_100257" x1="225.537" y1="188.936" x2="225.537" y2="246.439" gradientUnits="userSpaceOnUse">
<stop stop-color="#346458"/>
<stop offset="1" stop-color="#07B07E" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint3_linear_946_100257" x1="294.773" y1="188.936" x2="294.773" y2="246.439" gradientUnits="userSpaceOnUse">
<stop stop-color="#346458"/>
<stop offset="1" stop-color="#07B07E" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint4_linear_946_100257" x1="365.031" y1="188.936" x2="365.031" y2="246.439" gradientUnits="userSpaceOnUse">
<stop stop-color="#346458"/>
<stop offset="1" stop-color="#07B07E" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint5_linear_946_100257" x1="111.335" y1="164.4" x2="118.667" y2="274.002" gradientUnits="userSpaceOnUse">
<stop stop-color="#133030" stop-opacity="0"/>
<stop offset="0.473958" stop-color="#577F82" stop-opacity="0.17"/>
<stop offset="1" stop-color="#131313" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint6_linear_946_100257" x1="83.8958" y1="164.4" x2="83.8958" y2="282.543" gradientUnits="userSpaceOnUse">
<stop offset="0.078125" stop-color="#121212" stop-opacity="0"/>
<stop offset="0.489583" stop-color="#375965"/>
<stop offset="0.859375" stop-color="#121212" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint7_linear_946_100257" x1="181.639" y1="164.4" x2="188.972" y2="274.002" gradientUnits="userSpaceOnUse">
<stop stop-color="#133030" stop-opacity="0"/>
<stop offset="0.473958" stop-color="#577F82" stop-opacity="0.17"/>
<stop offset="1" stop-color="#131313" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint8_linear_946_100257" x1="154.2" y1="164.4" x2="154.2" y2="282.543" gradientUnits="userSpaceOnUse">
<stop offset="0.078125" stop-color="#121212" stop-opacity="0"/>
<stop offset="0.489583" stop-color="#375965"/>
<stop offset="0.859375" stop-color="#121212" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint9_linear_946_100257" x1="251.948" y1="164.4" x2="259.28" y2="274.002" gradientUnits="userSpaceOnUse">
<stop stop-color="#133030" stop-opacity="0"/>
<stop offset="0.473958" stop-color="#577F82" stop-opacity="0.17"/>
<stop offset="1" stop-color="#131313" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint10_linear_946_100257" x1="224.509" y1="164.4" x2="224.509" y2="282.543" gradientUnits="userSpaceOnUse">
<stop offset="0.078125" stop-color="#121212" stop-opacity="0"/>
<stop offset="0.489583" stop-color="#375965"/>
<stop offset="0.859375" stop-color="#121212" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint11_linear_946_100257" x1="322.245" y1="164.4" x2="329.577" y2="274.002" gradientUnits="userSpaceOnUse">
<stop stop-color="#133030" stop-opacity="0"/>
<stop offset="0.473958" stop-color="#577F82" stop-opacity="0.17"/>
<stop offset="1" stop-color="#131313" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint12_linear_946_100257" x1="294.806" y1="164.4" x2="294.806" y2="282.543" gradientUnits="userSpaceOnUse">
<stop offset="0.078125" stop-color="#121212" stop-opacity="0"/>
<stop offset="0.489583" stop-color="#375965"/>
<stop offset="0.859375" stop-color="#121212" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint13_linear_946_100257" x1="392.545" y1="164.4" x2="399.878" y2="274.002" gradientUnits="userSpaceOnUse">
<stop stop-color="#133030" stop-opacity="0"/>
<stop offset="0.473958" stop-color="#577F82" stop-opacity="0.17"/>
<stop offset="1" stop-color="#131313" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint14_linear_946_100257" x1="365.107" y1="164.4" x2="365.107" y2="282.543" gradientUnits="userSpaceOnUse">
<stop offset="0.078125" stop-color="#121212" stop-opacity="0"/>
<stop offset="0.489583" stop-color="#375965"/>
<stop offset="0.859375" stop-color="#121212" stop-opacity="0"/>
</linearGradient>
<radialGradient id="paint15_radial_946_100257" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(231.616 219.659) rotate(90) scale(437.142 680.656)">
<stop stop-color="#121212" stop-opacity="0"/>
<stop offset="1" stop-color="#121212"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 735 KiB

@@ -0,0 +1,4 @@
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.05859 17.333L17.6065 6.78516" stroke="#4F4F4F" stroke-width="0.963216" stroke-linecap="round"/>
<path d="M7.05859 6.78516H17.6064V17.333" stroke="#4F4F4F" stroke-width="0.963216" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 802 KiB

@@ -0,0 +1,3 @@
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.8906 7.99927L9.20953 10.7019L4.52844 13.4045L4.52844 7.99927L4.52844 2.59401L9.20953 5.29664L13.8906 7.99927Z" stroke="#4CC38A" stroke-miterlimit="10" stroke-linejoin="bevel"/>
</svg>

After

Width:  |  Height:  |  Size: 293 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 KiB

@@ -0,0 +1,63 @@
<svg width="434" height="315" viewBox="0 0 434 315" fill="none" xmlns="http://www.w3.org/2000/svg" transform="scale(-1,1)">
<mask id="mask0_660_34120" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="34" y="150" width="141" height="126">
<ellipse cx="104.25" cy="212.822" rx="59.54" ry="72.9115" transform="rotate(-61.8798 104.25 212.822)" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask0_660_34120)">
<ellipse cx="91.9153" cy="222.503" rx="52.2627" ry="62.4336" transform="rotate(-61.8798 91.9153 222.503)" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
<path d="M21.6808 231.928C38.8704 253.899 60.2957 267.589 87.3289 277.842C124.323 281.847 160.487 257.356 150.45 217.864C141.681 183.353 116.187 168.152 77.8932 168.082C39.9327 171.553 25.6479 200.418 32.666 235.327L21.6808 231.928Z" fill="url(#paint0_linear_660_34120)" fill-opacity="0.7"/>
<ellipse cx="81.4928" cy="234.524" rx="42.7327" ry="52.3296" transform="rotate(-61.8798 81.4928 234.524)" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
<ellipse cx="72.8304" cy="243.403" rx="30.8186" ry="41.1949" transform="rotate(-61.8798 72.8304 243.403)" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
<ellipse cx="64.5837" cy="254.426" rx="20.3274" ry="29.7799" transform="rotate(-61.8798 64.5837 254.426)" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
</g>
<ellipse cx="110.501" cy="203.914" rx="70.3434" ry="86.3827" transform="rotate(-61.8798 110.501 203.914)" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
<ellipse cx="104.25" cy="212.822" rx="59.54" ry="72.9115" transform="rotate(-61.8798 104.25 212.822)" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
<ellipse cx="114.397" cy="193.837" rx="81.0647" ry="99.2702" transform="rotate(-61.8798 114.397 193.837)" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
<ellipse cx="122.751" cy="184.278" rx="93.4342" ry="114.392" transform="rotate(-61.8798 122.751 184.278)" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
<ellipse cx="130.95" cy="172.177" rx="107.969" ry="131.311" transform="rotate(-61.8798 130.95 172.177)" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
<ellipse cx="136.996" cy="162.661" rx="122.268" ry="151.988" transform="rotate(-61.8798 136.996 162.661)" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
<ellipse cx="140.488" cy="156.129" rx="133.57" ry="163.629" transform="rotate(-61.8798 140.488 156.129)" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
<ellipse cx="143.399" cy="148.888" rx="146.147" ry="177.743" transform="rotate(-61.8798 143.399 148.888)" stroke="#577F82" stroke-linecap="round" stroke-dasharray="0.2 2"/>
<ellipse cx="143.398" cy="148.889" rx="157.084" ry="191.045" transform="rotate(-61.8798 143.398 148.889)" stroke="#577F82" stroke-linecap="round" stroke-dasharray="0.2 2"/>
<ellipse cx="144.068" cy="146.636" rx="171.225" ry="208.244" transform="rotate(-61.8798 144.068 146.636)" stroke="#577F82" stroke-linecap="round" stroke-dasharray="0.2 2"/>
<ellipse cx="144.064" cy="146.636" rx="181.765" ry="221.062" transform="rotate(-61.8798 144.064 146.636)" stroke="#577F82" stroke-linecap="round" stroke-dasharray="0.2 2"/>
<mask id="mask1_660_34120" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="-70" y="-59" width="433" height="405">
<path d="M242.575 -16.8122C308.202 18.2551 499.682 265.335 193.697 345.533C187.474 296.498 141.078 274.246 120.307 275.46C106.058 276.292 90.6408 273.057 76.1901 265.335C54.8846 253.949 40.5208 235.395 35.7836 216.11C32.6242 203.249 14.1795 151.358 -69.7906 148.741C-69.7913 -143.039 176.94 -51.8711 242.575 -16.8122Z" fill="#ADEFF3"/>
</mask>
<g mask="url(#mask1_660_34120)">
<path d="M309.472 314.256C284.499 294.503 225.875 261.371 158.429 263.668C141.405 264.248 99.7938 272.304 91.9531 273.021" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
<path d="M38.7633 226.199C36.9867 204.538 21.7315 172.228 -13.668 158.73" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
<path d="M39.3945 228.794C43.8784 187.088 22.4282 96.8672 -21.7031 74.2246" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
<path d="M40.9688 233.941C51.5598 201.98 65.1169 113.217 34.6172 13.8633" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
<path d="M46.8086 246.415C56.4017 226.829 81.1974 170.011 95.3052 116.162C109.413 62.3136 117.671 2.97391 120.036 -19.9648" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
<path d="M65.3477 260.716C102.51 224.747 190.768 142.185 232.235 110.89C273.703 79.5952 341.445 33.5215 370.133 14.3965" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
<path d="M74.6562 267.118C89.3248 256.468 141.15 224.141 208.436 190.759C275.722 157.378 353.395 140.991 383.821 136.971" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
<path d="M85.2188 272.189C109.341 258.389 181.119 228.838 254.403 228.576C327.688 228.315 380.564 240.855 397.842 247.157" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
<path d="M196.088 343.238C191.762 318.121 169.135 266.373 96.6133 274.933" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
<path d="M241.887 -25.6777C202.442 37.1306 89.276 207.05 55.3711 253.732" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
</g>
<mask id="mask2_660_34120" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="-48" y="178" width="265" height="188">
<path d="M-0.398959 326.065C155.262 414.688 258.382 331.466 198.973 293.479C171.664 282.165 146.453 271.059 129.63 274.299C112.973 277.508 93.8578 274.774 76.1884 265.332C40.6737 246.352 24.4473 207.456 39.9459 178.455C-102.374 170.119 -35.9137 307.086 -0.398959 326.065Z" fill="#ADEFF3"/>
</mask>
<g mask="url(#mask2_660_34120)">
<path d="M66.7344 339.892C75.2448 327.613 92.1234 293.146 88.2624 267.172" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
<path d="M-14.0547 240.386C-7.99594 236.844 8.25974 229.285 24.8125 227.384C41.3653 225.483 53.6124 233.396 57.6668 237.59" stroke="#577F82" stroke-width="0.5" stroke-linecap="round"/>
</g>
<g filter="url(#filter0_f_660_34120)">
<path d="M20.0955 224.648C41.9148 252.536 72.505 274.928 106.819 287.943C153.776 293.027 205.892 256.253 193.152 206.125C182.022 162.319 137.617 125.82 89.0098 125.731C40.4022 125.645 11.1834 180.337 20.0917 224.648H20.0955Z" fill="url(#paint1_linear_660_34120)" fill-opacity="0.6"/>
</g>
<defs>
<filter id="filter0_f_660_34120" x="-116.377" y="-9.14636" width="446.355" height="432.435" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="67.4384" result="effect1_foregroundBlur_660_34120"/>
</filter>
<linearGradient id="paint0_linear_660_34120" x1="100.839" y1="198.228" x2="29.4546" y2="274.265" gradientUnits="userSpaceOnUse">
<stop stop-color="#397D79" stop-opacity="0"/>
<stop offset="1" stop-color="#00DEA9" stop-opacity="0.91"/>
</linearGradient>
<linearGradient id="paint1_linear_660_34120" x1="148.98" y1="54.9662" x2="17.0834" y2="274.049" gradientUnits="userSpaceOnUse">
<stop stop-color="#39617D" stop-opacity="0.32"/>
<stop offset="1" stop-color="#00DEC3"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 7.2 KiB

+22 -1
View File
@@ -5,9 +5,30 @@
<link>https://supabase.com</link>
<description>Latest news from Supabase</description>
<language>en</language>
<lastBuildDate>Tue, 06 Dec 2022 16:04:00 +0000</lastBuildDate>
<lastBuildDate>Fri, 09 Dec 2022 16:04:00 +0000</lastBuildDate>
<atom:link href="https://supabase.com/rss.xml" rel="self" type="application/rss+xml"/>
<item>
<guid>https://supabase.com/blog/postgres-crdt</guid>
<title>pg_crdt - an experimental CRDT extension for Postgres</title>
<link>https://supabase.com/blog/postgres-crdt</link>
<description>Embedding Yjs and Automerge into Postgres for collaborative applications.</description>
<pubDate>Fri, 09 Dec 2022 16:04:00 +0000</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/who-we-hire</guid>
<title>Who We Hire at Supabase</title>
<link>https://supabase.com/blog/who-we-hire</link>
<description>Traits we look for to maintain a culture of shipping fast and often</description>
<pubDate>Thu, 08 Dec 2022 16:04:00 +0000</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/launch-week-6-hackathon</guid>
<title>Launch Week 6 Hackathon</title>
<link>https://supabase.com/blog/launch-week-6-hackathon</link>
<description>Build an Open Source Project, Win $1500 and the Supabase Darkmode Keyboard</description>
<pubDate>Thu, 08 Dec 2022 16:04:00 +0000</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/supabase-beta-november-2022</guid>
<title>Supabase Beta November 2022</title>
<link>https://supabase.com/blog/supabase-beta-november-2022</link>
+24 -6
View File
@@ -84,12 +84,6 @@
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/launch-week/ticket-image</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/legal/dpa</loc>
<changefreq>weekly</changefreq>
@@ -102,6 +96,12 @@
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/launch-week/ticket-image</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/nextjs</loc>
<changefreq>weekly</changefreq>
@@ -938,6 +938,24 @@
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/blog/launch-week-6-hackathon</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/blog/who-we-hire</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/blog/postgres-crdt</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/alternatives/supabase-vs-firebase</loc>
<changefreq>weekly</changefreq>
+13 -1
View File
@@ -130,7 +130,7 @@ services:
storage:
container_name: supabase-storage
image: supabase/storage-api:v0.21.4
image: supabase/storage-api:v0.26.1
depends_on:
db: # Disable this if you are using an external Postgres database
condition: service_healthy
@@ -150,6 +150,18 @@ services:
# TODO: https://github.com/supabase/storage-api/issues/55
REGION: stub
GLOBAL_S3_BUCKET: stub
ENABLE_IMAGE_TRANSFORMATION: "true"
IMGPROXY_URL: http://imgproxy:5001
volumes:
- ./volumes/storage:/var/lib/storage
imgproxy:
container_name: supabase-imgproxy
image: darthsim/imgproxy:v3.11
environment:
IMGPROXY_BIND: ":5001"
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
IMGPROXY_USE_ETAG: "true"
volumes:
- ./volumes/storage:/var/lib/storage
@@ -113,11 +113,12 @@ interface ItemProps {
header: React.ReactNode
id: string
icon?: React.ReactNode
disabled?: boolean
}
export function Item({ children, className, header, id, icon }: ItemProps) {
export function Item({ children, className, header, id, icon, disabled }: ItemProps) {
const __styles = styleHandler('accordion')
// const [open, setOpen] = useState(false)
const [open, setOpen] = useState(false)
const {
type,
@@ -134,12 +135,24 @@ export function Item({ children, className, header, id, icon }: ItemProps) {
let chevronClasses = [__styles.chevron.base, __styles.chevron.align[chevronAlign]]
// console.log('currentItems', currentItems)
if (open && !disabled) {
chevronClasses.unshift('!rotate-180')
}
return (
<RadixAccordion.Item value={id} className={__styles.variants[type].container}>
<RadixAccordion.Item
value={id}
className={__styles.variants[type].container}
disabled={disabled}
onClick={() => {
setOpen(!open)
}}
>
<RadixAccordion.Trigger className={triggerClasses.join(' ')}>
{header}
<IconChevronDown aria-hidden className={chevronClasses.join(' ')} strokeWidth={2} />
{!disabled && (
<IconChevronDown aria-hidden className={chevronClasses.join(' ')} strokeWidth={2} />
)}
</RadixAccordion.Trigger>
<RadixAccordion.Content className={__styles.variants[type].content}>
<div className={__styles.variants[type].panel}>{children}</div>
+2 -1
View File
@@ -115,13 +115,14 @@ const Modal = ({
customFooter
) : (
<Space
className="flex w-full space-x-2"
style={{
width: '100%',
justifyContent:
layout === 'vertical' ? 'center' : alignFooter === 'right' ? 'flex-end' : 'flex-start',
}}
>
<Button type="secondary" onClick={onCancel} disabled={loading}>
<Button type="default" onClick={onCancel} disabled={loading}>
{cancelText}
</Button>
<Button onClick={onConfirm} loading={loading} danger={variant === 'danger'}>
@@ -22,7 +22,7 @@ interface CustomProps {
children?: React.ReactNode
header?: string | React.ReactNode
visible: boolean
size?: 'medium' | 'large'
size?: 'medium' | 'large' | 'xlarge'
loading?: boolean
align?: 'right' | 'left'
hideFooter?: boolean
+2 -9
View File
@@ -2,15 +2,7 @@ import React from 'react'
// @ts-ignore
// import SpaceStyles from './Space.module.css'
function Space({
direction,
size = 2,
className,
block,
style,
minus,
children,
}: any) {
function Space({ direction, size = 2, className, block, style, minus, children }: any) {
// const classes = []
// classes.push(direction === 'vertical' ? 'sbui-space-col' : 'sbui-space-row')
// classes.push(
@@ -33,6 +25,7 @@ function Space({
return (
<div
// className={classes.join(' ')}
className={className}
style={style}
>
{children}
+3 -2
View File
@@ -718,7 +718,7 @@ export default {
...defaults.size.text,
},
description: {
base: `text-scale-900`,
base: `text-scale-1000`,
...defaults.size.text,
},
group: `space-y-3`,
@@ -997,6 +997,7 @@ export default {
size: {
medium: `w-screen max-w-md h-full`,
large: `w-screen max-w-2xl h-full`,
xlarge: `w-screen max-w-3xl h-full`,
},
align: {
left: `
@@ -1139,7 +1140,7 @@ export default {
},
},
description: {
base: 'mt-2 text-scale-900',
base: 'mt-2 text-scale-900 leading-normal',
size: {
...defaults.size.text,
},
+10 -6
View File
@@ -188,12 +188,15 @@ async function fetchReadOnlyInfo(
return null
}
export function parseSupaTable(data: {
table: Dictionary<any>
columns: Dictionary<any>[]
primaryKeys: Dictionary<any>[]
relationships: Dictionary<any>[]
}): SupaTable {
export function parseSupaTable(
data: {
table: Dictionary<any>
columns: Dictionary<any>[]
primaryKeys: Dictionary<any>[]
relationships: Dictionary<any>[]
},
encryptedColumns: string[] = []
): SupaTable {
const { table, columns, primaryKeys, relationships } = data
const supaColumns: SupaColumn[] = columns.map((column) => {
@@ -208,6 +211,7 @@ export function parseSupaTable(data: {
isGeneratable: column.identity_generation == 'BY DEFAULT',
isNullable: column.is_nullable,
isUpdatable: column.is_updatable,
isEncrypted: encryptedColumns.includes(column.name),
enum: column.enums,
comment: column.comment,
targetTableSchema: null,
@@ -1,5 +1,6 @@
import * as React from 'react'
import { IconKey, IconLink } from 'ui'
import { IconKey, IconLink, IconLock } from 'ui'
import * as Tooltip from '@radix-ui/react-tooltip'
import { useDrag, useDrop, DropTargetMonitor } from 'react-dnd'
import { XYCoord } from 'dnd-core'
import { useDispatch } from '../../store'
@@ -11,6 +12,7 @@ export function ColumnHeader<R>({
column,
columnType,
isPrimaryKey,
isEncrypted,
format,
}: ColumnHeaderProps<R>) {
const ref = React.useRef<HTMLDivElement>(null)
@@ -119,14 +121,47 @@ export function ColumnHeader<R>({
<div className="sb-grid-column-header__inner">
{renderColumnIcon(columnType)}
{isPrimaryKey && (
<div className="sb-grid-column-header__inner__primary-key">
<IconKey size="tiny" strokeWidth={2} />
</div>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<div className="sb-grid-column-header__inner__primary-key">
<IconKey size="tiny" strokeWidth={2} />
</div>
</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',
'border border-scale-200',
].join(' ')}
>
<span className="text-xs text-scale-1200">Primary key</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
)}
<span className="sb-grid-column-header__inner__name" title={hoverValue}>
{column.name}
</span>
<span className="sb-grid-column-header__inner__format">{columnFormat}</span>
{isEncrypted && (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<IconLock size="tiny" strokeWidth={2} />
</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',
'border border-scale-200',
].join(' ')}
>
<span className="text-xs text-scale-1200">Encrypted column</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
<ColumnMenu column={column} />
</div>
+1
View File
@@ -58,5 +58,6 @@ export type ColumnType =
export interface ColumnHeaderProps<R> extends HeaderRendererProps<R> {
columnType: ColumnType
isPrimaryKey: Boolean | undefined
isEncrypted: Boolean | undefined
format: string
}
+1
View File
@@ -12,6 +12,7 @@ export interface SupaColumn {
readonly isGeneratable?: boolean
readonly isNullable?: boolean
readonly isUpdatable?: boolean
readonly isEncrypted?: boolean
readonly targetTableSchema?: string | null
readonly targetTableName?: string | null
readonly targetColumnName?: string | null
@@ -72,6 +72,7 @@ export function getGridColumns(
{...props}
columnType={columnType}
isPrimaryKey={x.isPrimaryKey}
isEncrypted={x.isEncrypted}
format={x.format}
/>
),
+2 -1
View File
@@ -1,7 +1,8 @@
import AuthProvidersForm from './AuthProvidersForm/AuthProvidersForm'
import AutoSchemaForm from './AutoSchemaForm'
import EmailTemplates from './EmailTemplates/EmailTemplates'
import SmtpForm from './SmtpForm/SmtpForm'
import Users from './Users'
export { Users, AuthProvidersForm, EmailTemplates, SmtpForm }
export { Users, AuthProvidersForm, AutoSchemaForm, EmailTemplates, SmtpForm }
@@ -1,4 +1,4 @@
export interface DatabaseAddon {
export interface SubscriptionAddon {
id?: string
name: string
description?: string | null
@@ -8,6 +8,8 @@ export interface DatabaseAddon {
default_price_id?: string
supabase_prod_id: string
}
// UI specific, do not allow changes if add on has a $0 price to it
isLocked?: boolean
}
export interface AddonPrice {
@@ -1,4 +1,4 @@
import { DatabaseAddon } from './AddOns.types'
import { SubscriptionAddon } from './AddOns.types'
export const getSemanticVersion = (version: string) => {
if (!version) return 0
@@ -14,7 +14,7 @@ export const getSemanticVersion = (version: string) => {
return Number(formattedSemver.split('.').join(''))
}
export const formatComputeSizes = (addons: DatabaseAddon[]) => {
export const formatComputeSizes = (addons: SubscriptionAddon[]) => {
const addonsOrder = [
'addon_instance_small',
'addon_instance_medium',
@@ -27,7 +27,7 @@ export const formatComputeSizes = (addons: DatabaseAddon[]) => {
'addon_instance_16xlarge',
]
const microOption: DatabaseAddon = {
const microOption: SubscriptionAddon = {
id: undefined,
name: 'Micro Add-on',
metadata: {
@@ -55,15 +55,15 @@ export const formatComputeSizes = (addons: DatabaseAddon[]) => {
.concat(
addonsOrder.map((id: string) => {
return addons.find((option) => option.metadata.supabase_prod_id === id)
}) as DatabaseAddon[]
}) as SubscriptionAddon[]
)
.filter((option) => option !== undefined)
}
export const formatPITROptions = (addons: DatabaseAddon[]) => {
export const formatPITROptions = (addons: SubscriptionAddon[]) => {
const pitrOrder = ['addon_pitr_7days', 'addon_pitr_14days', 'addon_pitr_28days']
const noPITROption: DatabaseAddon = {
const noPITROption: SubscriptionAddon = {
id: undefined,
name: 'Disable PITR',
metadata: {
@@ -91,15 +91,15 @@ export const formatPITROptions = (addons: DatabaseAddon[]) => {
.map((id: string) => {
return addons.find((option) => option.metadata.supabase_prod_id === id)
})
.filter((option) => option !== undefined) as DatabaseAddon[]
.filter((option) => option !== undefined) as SubscriptionAddon[]
if (pitrOptions.length === 0) return []
else return [noPITROption].concat(pitrOptions)
}
export const formatCustomDomainOptions = (addons: DatabaseAddon[]) => {
export const formatCustomDomainOptions = (addons: SubscriptionAddon[]) => {
const customDomainOrder = ['addon_custom_domains']
const noCustomDomainOption: DatabaseAddon = {
const noCustomDomainOption: SubscriptionAddon = {
id: undefined,
name: 'Disable Custom Domains',
metadata: {
@@ -127,7 +127,7 @@ export const formatCustomDomainOptions = (addons: DatabaseAddon[]) => {
.map((id: string) => {
return addons.find((option) => option.metadata.supabase_prod_id === id)
})
.filter((option) => option !== undefined) as DatabaseAddon[]
.filter((option) => option !== undefined) as SubscriptionAddon[]
if (customDomainOptions.length === 0) return []
else return [noCustomDomainOption].concat(customDomainOptions)
@@ -2,14 +2,15 @@ import { FC } from 'react'
import Link from 'next/link'
import { Badge, Button, Radio } from 'ui'
import { useStore, useFlag } from 'hooks'
import { useFlag, useParams } from 'hooks'
import { getProductPrice } from '../Billing.utils'
import DisabledWarningDueToIncident from 'components/ui/DisabledWarningDueToIncident'
import { SubscriptionAddon } from './AddOns.types'
interface Props {
computeSizes: any[]
currentComputeSize?: any
selectedComputeSize: any
computeSizes: SubscriptionAddon[]
currentComputeSize?: SubscriptionAddon
selectedComputeSize: SubscriptionAddon
onSelectOption: (option: any) => void
}
@@ -19,8 +20,7 @@ const ComputeSizeSelection: FC<Props> = ({
selectedComputeSize,
onSelectOption,
}) => {
const { ui } = useStore()
const projectRef = ui.selectedProjectRef
const { ref } = useParams()
const addonUpdateDisabled = useFlag('disableProjectCreationAndUpdate')
return (
@@ -36,6 +36,31 @@ const ComputeSizeSelection: FC<Props> = ({
</div>
{addonUpdateDisabled ? (
<DisabledWarningDueToIncident title="Updating database add-ons is currently disabled" />
) : currentComputeSize?.isLocked ? (
<div
className={[
'flex items-center justify-between block w-full rounded px-4 py-3',
'border border-scale-600 bg-scale-100 dark:border-scale-500 dark:bg-scale-400',
].join(' ')}
>
<div className="space-y-3">
<h5 className="text-sm text-scale-1200">
Your project currently has the {currentComputeSize.name} included
</h5>
<p className="text-sm text-scale-1100">
If you would like to change your compute size, do reach out to us
</p>
</div>
<div className="">
<Link
href={`/support/new?ref=${ref}&category=sales&subject=Disable%20custom%20domains%20`}
>
<a>
<Button>Contact us</Button>
</a>
</Link>
</div>
</div>
) : (
<Radio.Group type="cards" className="billing-compute-radio">
{computeSizes.map((option: any) => {
@@ -75,7 +100,9 @@ const ComputeSizeSelection: FC<Props> = ({
label="Need a larger add on?"
description="Reach out to us - we've got you covered!"
optionalLabel={
<Link href={`/support/new?ref=${projectRef}&category=sales`}>
<Link
href={`/support/new?ref=${ref}&category=sales&subject=Enquiry%20for%20larger%20compute%20size`}
>
<a>
<Button>Contact us</Button>
</a>
@@ -1,14 +1,16 @@
import Link from 'next/link'
import { FC } from 'react'
import { Badge, Radio } from 'ui'
import { Badge, Button, Radio } from 'ui'
import { useStore, useFlag } from 'hooks'
import { useFlag, useParams } from 'hooks'
import { getProductPrice } from '../Billing.utils'
import DisabledWarningDueToIncident from 'components/ui/DisabledWarningDueToIncident'
import { SubscriptionAddon } from './AddOns.types'
interface Props {
options: any[]
currentOption?: any
selectedOption: any
options: SubscriptionAddon[]
currentOption?: SubscriptionAddon
selectedOption: SubscriptionAddon
onSelectOption: (option: any) => void
}
@@ -18,7 +20,9 @@ const CustomDomainSelection: FC<Props> = ({
selectedOption,
onSelectOption,
}) => {
const { ref } = useParams()
const addonUpdateDisabled = useFlag('disableProjectCreationAndUpdate')
return (
<div className="space-y-4">
<div>
@@ -30,6 +34,31 @@ const CustomDomainSelection: FC<Props> = ({
</div>
{addonUpdateDisabled ? (
<DisabledWarningDueToIncident title="Updating database add-ons is currently disabled" />
) : currentOption?.isLocked ? (
<div
className={[
'flex items-center justify-between block w-full rounded px-4 py-3',
'border border-scale-600 bg-scale-100 dark:border-scale-500 dark:bg-scale-400',
].join(' ')}
>
<div className="space-y-3">
<h5 className="text-sm text-scale-1200">
Your project currently has custom domains included
</h5>
<p className="text-sm text-scale-1100">
If you would like to disable custom domains, do reach out to us
</p>
</div>
<div className="">
<Link
href={`/support/new?ref=${ref}&category=sales&subject=Disable%20custom%20domains%20`}
>
<a>
<Button>Contact us</Button>
</a>
</Link>
</div>
</div>
) : (
<Radio.Group type="cards" className="billing-compute-radio">
{options.map((option: any) => {
@@ -3,7 +3,7 @@ import { useRouter } from 'next/router'
import { Badge, IconAlertCircle, Radio, Button } from 'ui'
import { useFlag, useStore } from 'hooks'
import { DatabaseAddon } from './AddOns.types'
import { SubscriptionAddon } from './AddOns.types'
import { getProductPrice } from '../Billing.utils'
import DisabledWarningDueToIncident from 'components/ui/DisabledWarningDueToIncident'
import InformationBox from 'components/ui/InformationBox'
@@ -11,9 +11,9 @@ import { getSemanticVersion } from './AddOns.utils'
import Link from 'next/link'
interface Props {
pitrDurationOptions: DatabaseAddon[]
currentPitrDuration?: DatabaseAddon
selectedPitrDuration?: DatabaseAddon
pitrDurationOptions: SubscriptionAddon[]
currentPitrDuration?: SubscriptionAddon
selectedPitrDuration?: SubscriptionAddon
onSelectOption: (option: any) => void
}
@@ -41,7 +41,7 @@ const PITRDurationSelection: FC<Props> = ({
<p className="text-sm text-scale-1100">
Restore your database from a specific point in time
</p>
{canUsePITR && (
{canUsePITR && !currentPitrDuration?.isLocked && (
<div className="mt-2">
<InformationBox
icon={<IconAlertCircle strokeWidth={2} />}
@@ -72,6 +72,29 @@ const PITRDurationSelection: FC<Props> = ({
}
icon={<IconAlertCircle strokeWidth={2} />}
/>
) : currentPitrDuration?.isLocked ? (
<div
className={[
'flex items-center justify-between block w-full rounded px-4 py-3',
'border border-scale-600 bg-scale-100 dark:border-scale-500 dark:bg-scale-400',
].join(' ')}
>
<div className="space-y-3">
<h5 className="text-sm text-scale-1200">
Your project currently has {currentPitrDuration.name} included
</h5>
<p className="text-sm text-scale-1100">
If you would like to change your PITR duration, do reach out to us
</p>
</div>
<div className="">
<Link href={`/support/new?ref=${ref}&category=sales&subject=Change%20PITR%20duration`}>
<a>
<Button>Contact us</Button>
</a>
</Link>
</div>
</div>
) : (
<Radio.Group type="cards" className="billing-compute-radio">
{pitrDurationOptions.map((option: any) => {
@@ -0,0 +1,46 @@
import { FC } from 'react'
import Link from 'next/link'
import { Button } from 'ui'
import { useParams } from 'hooks'
interface Props {
currentOption?: any
}
const SupportPlan: FC<Props> = ({ currentOption }) => {
const { ref } = useParams()
return (
<div className="space-y-4">
<div>
<div className="flex items-center space-x-2">
<h4 className="text-lg">Support Plan</h4>
</div>
<p className="text-sm text-scale-1100">Designated support with guaranteed response times</p>
</div>
<div
className={[
'flex items-center justify-between block w-full rounded px-4 py-3',
'border border-scale-600 bg-scale-100 dark:border-scale-500 dark:bg-scale-400',
].join(' ')}
>
<div className="space-y-3">
<h5 className="text-sm text-scale-1200">
Your project is currently on {currentOption.name}
</h5>
<p className="text-sm text-scale-1100">
If you would like to change your support plan, do reach out to us
</p>
</div>
<div className="">
<Link href={`/support/new?ref=${ref}&category=sales&subject=Change%20support%20plan`}>
<a>
<Button>Contact us</Button>
</a>
</Link>
</div>
</div>
</div>
)
}
export default SupportPlan
@@ -1,4 +1,4 @@
import { DatabaseAddon } from './AddOns/AddOns.types'
import { SubscriptionAddon } from './AddOns/AddOns.types'
import {
formatComputeSizes,
formatCustomDomainOptions,
@@ -6,6 +6,7 @@ import {
} from './AddOns/AddOns.utils'
import { StripeSubscription } from './Subscription/Subscription.types'
// Retrieve the price based on the default price id of the product
export const getProductPrice = (product: any) => {
const defaultPriceId = product.metadata?.default_price_id
const price =
@@ -15,10 +16,22 @@ export const getProductPrice = (product: any) => {
return price
}
// Products can have multiple prices, this is to get the correct one based on the subscription
const getProductPriceId = (
currentSubscription: StripeSubscription,
product: any
): string | undefined => {
const existingAddon = currentSubscription.addons.find((addon) => addon.prod_id === product.id)
const existingAddonPriceId = product.prices.find(
(price: any) => price.unit_amount === existingAddon?.unit_amount
)
return existingAddonPriceId?.id || getProductPrice(product)?.id
}
export const validateSubscriptionUpdatePayload = (selectedAddons: {
computeSize: DatabaseAddon
pitrDuration: DatabaseAddon
customDomains: DatabaseAddon
computeSize: SubscriptionAddon
pitrDuration: SubscriptionAddon
customDomains: SubscriptionAddon
}) => {
if (
selectedAddons.pitrDuration?.id !== undefined &&
@@ -30,74 +43,104 @@ export const validateSubscriptionUpdatePayload = (selectedAddons: {
}
export const formSubscriptionUpdatePayload = (
currentSubscription: StripeSubscription,
selectedTier: any,
selectedAddons: {
computeSize: DatabaseAddon
pitrDuration: DatabaseAddon
customDomains: DatabaseAddon
computeSize: SubscriptionAddon
pitrDuration: SubscriptionAddon
customDomains: SubscriptionAddon
},
nonChangeableAddons: SubscriptionAddon[],
selectedPaymentMethod: string,
region: string
) => {
const { computeSize, pitrDuration, customDomains } = selectedAddons
const defaultPrice = selectedTier ? getProductPrice(selectedTier) : undefined
const computeSizePriceId = getProductPriceId(currentSubscription, computeSize)
const pitrAddonPriceId = getProductPriceId(currentSubscription, pitrDuration)
const customDomainPriceId = getProductPriceId(currentSubscription, customDomains)
const tierPriceId = selectedTier ? getProductPrice(selectedTier) : undefined
const nonChangeablePriceIds = nonChangeableAddons.map((addon) =>
getProductPriceId(currentSubscription, addon)
)
const addons =
region === 'af-south-1'
? []
: [
computeSize.prices[0].id,
pitrDuration?.prices?.[0].id,
customDomains?.prices?.[0].id,
].filter((x) => x !== undefined)
: [computeSizePriceId, pitrAddonPriceId, customDomainPriceId]
.concat(nonChangeablePriceIds)
.filter((x) => x !== undefined)
const proration_date = Math.floor(Date.now() / 1000)
return {
...(defaultPrice && { tier: defaultPrice.id }),
...(tierPriceId && { tier: tierPriceId.id }),
addons,
proration_date,
payment_method: selectedPaymentMethod,
}
}
const findAddon = (
subscription: StripeSubscription,
addons: SubscriptionAddon[],
key: string,
defaultKey: string
): SubscriptionAddon => {
const product = addons.find((option) => {
const subscriptionAddon = subscription.addons.find((addon) =>
addon.supabase_prod_id.includes(key)
)
return option.id === subscriptionAddon?.prod_id
})
if (product === undefined) {
return addons.find(
(addon) => addon.metadata.supabase_prod_id === defaultKey
) as SubscriptionAddon
} else {
const subscriptionAddon = subscription.addons.find((addon) => addon.prod_id === product.id)
return { ...product, isLocked: subscriptionAddon?.unit_amount === 0 }
}
}
export const getCurrentAddons = (
currentSubscription: StripeSubscription,
addons: DatabaseAddon[]
) => {
addons: SubscriptionAddon[]
): {
computeSize: SubscriptionAddon
pitrDuration: SubscriptionAddon
customDomains: SubscriptionAddon
supportPlan?: SubscriptionAddon
} => {
const computeSizes = formatComputeSizes(addons)
const pitrDurationOptions = formatPITROptions(addons)
const customDomainOptions = formatCustomDomainOptions(addons)
const computeSize =
computeSizes.find((option: any) => {
const subscriptionComputeSize = currentSubscription?.addons.find((addon) =>
addon.supabase_prod_id.includes('_instance_')
)
return option.id === subscriptionComputeSize?.prod_id
}) ||
(computeSizes.find(
(option: any) => option.metadata.supabase_prod_id === 'addon_instance_micro'
) as DatabaseAddon)
const computeSize = findAddon(
currentSubscription,
computeSizes,
'_instance_',
'addon_instance_micro'
)
const pitrDuration = findAddon(
currentSubscription,
pitrDurationOptions,
'_pitr_',
'addon_pitr_0days'
)
const customDomains = findAddon(
currentSubscription,
customDomainOptions,
'_custom_domains',
'addon_custom_domains_disabled'
)
const pitrDuration =
pitrDurationOptions.find((option: any) => {
const subscriptionComputeSize = currentSubscription?.addons.find((addon) =>
addon.supabase_prod_id.includes('_pitr_')
)
return option.id === subscriptionComputeSize?.prod_id
}) ||
(pitrDurationOptions.find(
(option: any) => option.metadata.supabase_prod_id === 'addon_pitr_0days'
) as DatabaseAddon)
const supportPlan = addons.find((option) => {
const subscriptionSupportPlan = currentSubscription?.addons.find((addon) =>
addon.supabase_prod_id.includes('_support_')
)
return option.id === subscriptionSupportPlan?.prod_id
})
const customDomains =
customDomainOptions.find((option: any) => {
const subscriptionComputeSize = currentSubscription?.addons.find((addon) =>
addon.supabase_prod_id.includes('_custom_domains')
)
return option.id === subscriptionComputeSize?.prod_id
}) ||
(customDomainOptions.find(
(option: any) => option.metadata.supabase_prod_id === 'addon__custom_domains_disabled'
) as DatabaseAddon)
return { computeSize, pitrDuration, customDomains }
return { computeSize, pitrDuration, customDomains, supportPlan }
}
@@ -8,7 +8,7 @@ import { post, patch } from 'lib/common/fetch'
import { API_URL, PROJECT_STATUS } from 'lib/constants'
import Divider from 'components/ui/Divider'
import { DatabaseAddon } from './AddOns/AddOns.types'
import { SubscriptionAddon } from './AddOns/AddOns.types'
import {
formatComputeSizes,
formatCustomDomainOptions,
@@ -25,9 +25,10 @@ import {
} from './'
import { PaymentMethod, SubscriptionPreview } from './Billing.types'
import { formSubscriptionUpdatePayload, getCurrentAddons } from './Billing.utils'
import SupportPlan from './AddOns/SupportPlan'
interface Props {
products: { tiers: any[]; addons: DatabaseAddon[] }
products: { tiers: any[]; addons: SubscriptionAddon[] }
paymentMethods?: PaymentMethod[]
currentSubscription: StripeSubscription
isLoadingPaymentMethods: boolean
@@ -57,15 +58,20 @@ const EnterpriseUpdate: FC<Props> = ({
// [Joshen TODO] Ideally we just have a state to hold all the add ons selection, rather than individual
// Even better if we can just use the <Form> component to handle all of these. Mainly to reduce the amount
// of unnecessary state management on this complex page.
const [selectedComputeSize, setSelectedComputeSize] = useState<DatabaseAddon>(
const [selectedComputeSize, setSelectedComputeSize] = useState<SubscriptionAddon>(
currentAddons.computeSize
)
const [selectedPITRDuration, setSelectedPITRDuration] = useState<DatabaseAddon>(
const [selectedPITRDuration, setSelectedPITRDuration] = useState<SubscriptionAddon>(
currentAddons.pitrDuration
)
const [selectedCustomDomainOption, setSelectedCustomDomainOption] = useState<DatabaseAddon>(
const [selectedCustomDomainOption, setSelectedCustomDomainOption] = useState<SubscriptionAddon>(
currentAddons.customDomains
)
// [Joshen TODO] Future - We may need to also include any add ons outside of
// compute size, pitr, custom domain and support plan, although we dont have any now
const nonChangeableAddons = [currentAddons.supportPlan].filter(
(x) => x !== undefined
) as SubscriptionAddon[]
// [Joshen] Scaffolded here
const selectedAddons = {
@@ -86,7 +92,7 @@ const EnterpriseUpdate: FC<Props> = ({
useEffect(() => {
getSubscriptionPreview()
}, [selectedComputeSize, selectedPITRDuration])
}, [selectedComputeSize, selectedPITRDuration, selectedCustomDomainOption])
useEffect(() => {
if (!isLoadingPaymentMethods && paymentMethods && paymentMethods.length > 0) {
@@ -99,8 +105,10 @@ const EnterpriseUpdate: FC<Props> = ({
// Only allow add-ons changing
const payload = {
...formSubscriptionUpdatePayload(
currentSubscription,
null,
selectedAddons,
nonChangeableAddons,
selectedPaymentMethodId,
projectRegion
),
@@ -123,8 +131,10 @@ const EnterpriseUpdate: FC<Props> = ({
const onConfirmPayment = async () => {
const payload = {
...formSubscriptionUpdatePayload(
currentSubscription,
null,
selectedAddons,
nonChangeableAddons,
selectedPaymentMethodId,
projectRegion
),
@@ -199,6 +209,12 @@ const EnterpriseUpdate: FC<Props> = ({
</div>
{projectRegion !== 'af-south-1' && (
<>
{currentAddons.supportPlan !== undefined && (
<>
<Divider light />
<SupportPlan currentOption={currentAddons.supportPlan} />
</>
)}
{isCustomDomainsEnabled && customDomainOptions.length > 0 && (
<>
<Divider light />
@@ -239,6 +255,7 @@ const EnterpriseUpdate: FC<Props> = ({
isSpendCapEnabled={true}
isSubmitting={isSubmitting}
isRefreshingPreview={isRefreshingPreview}
currentSubscription={currentSubscription}
subscriptionPreview={subscriptionPreview}
// Current subscription configuration based on DB
currentPlan={currentSubscription.tier}
@@ -3,34 +3,37 @@ import * as Tooltip from '@radix-ui/react-tooltip'
import { Listbox, IconLoader, Button, IconPlus, IconAlertCircle, IconCreditCard } from 'ui'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { checkPermissions, useFlag, useStore } from 'hooks'
import { checkPermissions, useStore } from 'hooks'
import { PRICING_TIER_PRODUCT_IDS, STRIPE_PRODUCT_IDS } from 'lib/constants'
import { SubscriptionPreview } from '../Billing.types'
import { getProductPrice, validateSubscriptionUpdatePayload } from '../Billing.utils'
import PaymentTotal from './PaymentTotal'
import InformationBox from 'components/ui/InformationBox'
import { DatabaseAddon } from '../AddOns/AddOns.types'
import { SubscriptionAddon } from '../AddOns/AddOns.types'
import { getPITRDays } from './PaymentSummaryPanel.utils'
import ConfirmPaymentModal from './ConfirmPaymentModal'
import { StripeSubscription } from '../Subscription/Subscription.types'
// [Joshen] PITR stuff can be undefined for now until we officially launch PITR self serve
interface Props {
isRefreshingPreview: boolean
currentSubscription?: StripeSubscription
subscriptionPreview?: SubscriptionPreview
currentPlan: any
currentAddons: {
computeSize: DatabaseAddon
pitrDuration: DatabaseAddon
customDomains: DatabaseAddon
computeSize: SubscriptionAddon
pitrDuration: SubscriptionAddon
customDomains: SubscriptionAddon
supportPlan?: SubscriptionAddon
}
selectedPlan?: any
selectedAddons: {
computeSize: DatabaseAddon
pitrDuration: DatabaseAddon
customDomains: DatabaseAddon
computeSize: SubscriptionAddon
pitrDuration: SubscriptionAddon
customDomains: SubscriptionAddon
}
isSpendCapEnabled: boolean
@@ -53,6 +56,7 @@ interface Props {
const PaymentSummaryPanel: FC<Props> = ({
isRefreshingPreview,
isSpendCapEnabled,
currentSubscription,
subscriptionPreview,
currentPlan,
@@ -107,6 +111,15 @@ const PaymentSummaryPanel: FC<Props> = ({
} else return plan.name
}
const getAddonPriceFromSubscription = (
key: 'computeSize' | 'pitrDuration' | 'customDomains' | 'supportPlan'
) => {
return (
((currentSubscription?.addons ?? []).find((addon) => addon.prod_id === currentAddons[key]?.id)
?.unit_amount ?? 0) / 100
).toFixed(2)
}
const validateOrder = () => {
const error = validateSubscriptionUpdatePayload(selectedAddons)
if (error) {
@@ -154,7 +167,9 @@ const PaymentSummaryPanel: FC<Props> = ({
{/* Add on details */}
{projectRegion !== 'af-south-1' && (
<div className="space-y-1">
<p className="text-sm">Selected add-ons</p>
<p className="text-sm text-scale-1100">Selected add-ons</p>
{/* Compute size */}
<div className="flex items-center justify-between">
<p
className={`${isChangingComputeSize ? 'text-scale-1100 line-through' : ''} text-sm`}
@@ -164,7 +179,7 @@ const PaymentSummaryPanel: FC<Props> = ({
<p
className={`${isChangingComputeSize ? 'text-scale-1100 line-through' : ''} text-sm`}
>
${(getProductPrice(currentAddons.computeSize).unit_amount / 100).toFixed(2)}
${getAddonPriceFromSubscription('computeSize')}
</p>
</div>
{isChangingComputeSize && (
@@ -175,6 +190,8 @@ const PaymentSummaryPanel: FC<Props> = ({
</p>
</div>
)}
{/* PITR Duration */}
{currentAddons.pitrDuration?.id !== undefined && (
<div className="flex items-center justify-between">
<p
@@ -189,7 +206,7 @@ const PaymentSummaryPanel: FC<Props> = ({
isChangingPITRDuration ? 'text-scale-1100 line-through' : ''
} text-sm`}
>
${(getProductPrice(currentAddons.pitrDuration).unit_amount / 100).toFixed(2)}
${getAddonPriceFromSubscription('pitrDuration')}
</p>
</div>
)}
@@ -201,6 +218,8 @@ const PaymentSummaryPanel: FC<Props> = ({
</p>
</div>
)}
{/* Custom Domains */}
{currentAddons.customDomains?.id !== undefined && (
<div className="flex items-center justify-between">
<p
@@ -215,7 +234,7 @@ const PaymentSummaryPanel: FC<Props> = ({
isChangingCustomDomains ? 'text-scale-1100 line-through' : ''
} text-sm`}
>
${(getProductPrice(currentAddons.customDomains).unit_amount / 100).toFixed(2)}
${getAddonPriceFromSubscription('customDomains')}
</p>
</div>
)}
@@ -228,6 +247,14 @@ const PaymentSummaryPanel: FC<Props> = ({
</div>
)}
{/* Support Plan */}
{currentAddons.supportPlan !== undefined && (
<div className="flex items-center justify-between">
<p className="text-sm">{currentAddons.supportPlan?.name}</p>
<p className="text-sm">${getAddonPriceFromSubscription('supportPlan')}</p>
</div>
)}
{/* Show information boxes with regards to change in subscriptions */}
<div className="!mt-4 space-y-4">
{isChangingPITRDuration && (
@@ -1,6 +1,6 @@
import { DatabaseAddon } from '../AddOns/AddOns.types'
import { SubscriptionAddon } from '../AddOns/AddOns.types'
export const getPITRDays = (pitrAddon: DatabaseAddon) => {
export const getPITRDays = (pitrAddon: SubscriptionAddon) => {
const prodId = pitrAddon.metadata.supabase_prod_id
const daysString = prodId.split('_')[2]
return Number(daysString.split('days')[0])
@@ -21,18 +21,19 @@ import { STRIPE_PRODUCT_IDS } from 'lib/constants'
import UpdateSuccess from './UpdateSuccess'
import { PaymentMethod, SubscriptionPreview } from './Billing.types'
import { formSubscriptionUpdatePayload, getCurrentAddons } from './Billing.utils'
import { DatabaseAddon } from './AddOns/AddOns.types'
import { SubscriptionAddon } from './AddOns/AddOns.types'
import {
formatComputeSizes,
formatCustomDomainOptions,
formatPITROptions,
} from './AddOns/AddOns.utils'
import BackButton from 'components/ui/BackButton'
import SupportPlan from './AddOns/SupportPlan'
// Do not allow compute size changes for af-south-1
interface Props {
products: { tiers: any[]; addons: DatabaseAddon[] }
products: { tiers: any[]; addons: SubscriptionAddon[] }
paymentMethods?: PaymentMethod[]
currentSubscription: StripeSubscription
isLoadingPaymentMethods: boolean
@@ -77,16 +78,22 @@ const ProUpgrade: FC<Props> = ({
// [Joshen TODO] Ideally we just have a state to hold all the add ons selection, rather than individual
// Even better if we can just use the <Form> component to handle all of these. Mainly to reduce the amount
// of unnecessary state management on this complex page.
const [selectedComputeSize, setSelectedComputeSize] = useState<DatabaseAddon>(
const [selectedComputeSize, setSelectedComputeSize] = useState<SubscriptionAddon>(
currentAddons.computeSize
)
const [selectedPITRDuration, setSelectedPITRDuration] = useState<DatabaseAddon>(
const [selectedPITRDuration, setSelectedPITRDuration] = useState<SubscriptionAddon>(
currentAddons.pitrDuration
)
const [selectedCustomDomainOption, setSelectedCustomDomainOption] = useState<DatabaseAddon>(
const [selectedCustomDomainOption, setSelectedCustomDomainOption] = useState<SubscriptionAddon>(
currentAddons.customDomains
)
// [Joshen TODO] Future - We may need to also include any add ons outside of
// compute size, pitr, custom domain and support plan, although we dont have any now
const nonChangeableAddons = [currentAddons.supportPlan].filter(
(x) => x !== undefined
) as SubscriptionAddon[]
// [Joshen] Scaffolded here
const selectedAddons = {
computeSize: selectedComputeSize,
@@ -121,8 +128,10 @@ const ProUpgrade: FC<Props> = ({
if (!selectedTier) return
const payload = formSubscriptionUpdatePayload(
currentSubscription,
selectedTier,
selectedAddons,
nonChangeableAddons,
selectedPaymentMethodId,
projectRegion
)
@@ -141,8 +150,10 @@ const ProUpgrade: FC<Props> = ({
const onConfirmPayment = async () => {
const payload = formSubscriptionUpdatePayload(
currentSubscription,
selectedTier,
selectedAddons,
nonChangeableAddons,
selectedPaymentMethodId,
projectRegion
)
@@ -242,6 +253,12 @@ const ProUpgrade: FC<Props> = ({
</div>
{projectRegion !== 'af-south-1' && (
<>
{currentAddons.supportPlan !== undefined && (
<>
<Divider light />
<SupportPlan currentOption={currentAddons.supportPlan} />
</>
)}
{isCustomDomainsEnabled && customDomainOptions.length > 0 && (
<>
<Divider light />
@@ -289,6 +306,7 @@ const ProUpgrade: FC<Props> = ({
// Current subscription configuration based on DB
currentPlan={currentSubscription.tier}
currentAddons={currentAddons}
currentSubscription={currentSubscription}
// Selected subscription configuration based on UI
selectedPlan={selectedTier}
selectedAddons={selectedAddons}
@@ -54,34 +54,36 @@ const EnableExtensionModal: FC<Props> = ({ visible, extension, onCancel }) => {
const onSubmit = async (values: any, { setSubmitting }: any) => {
setSubmitting(true)
if (values.schema === 'custom') {
const { error } = await meta.query(`create schema if not exists ${values.name}`)
if (error) {
return ui.setNotification({
error,
category: 'error',
message: `Failed to create schema: ${error.message}`,
})
}
const schema =
defaultSchema !== undefined
? defaultSchema
: values.schema === 'custom'
? values.name
: values.schema
const { error: createSchemaError } = await meta.query(`create schema if not exists ${schema}`)
if (createSchemaError) {
return ui.setNotification({
error: createSchemaError,
category: 'error',
message: `Failed to create schema: ${createSchemaError.message}`,
})
}
const schema = defaultSchema
? defaultSchema
: values.schema === 'custom'
? values.name
: values.schema
const { error } = await meta.extensions.create({
const { error: createExtensionError } = await meta.extensions.create({
schema,
name: extension.name,
version: extension.default_version,
cascade: true,
})
if (error) {
if (createExtensionError) {
ui.setNotification({
error,
error: createExtensionError,
category: 'error',
message: `Failed to toggle ${extension.name.toUpperCase()}: ${error.message}`,
message: `Failed to toggle ${extension.name.toUpperCase()}: ${
createExtensionError.message
}`,
})
} else {
ui.setNotification({
@@ -11,4 +11,5 @@ export const HIDDEN_EXTENSIONS = [
'pg_surgery',
'pg_visibility',
'pgstattuple',
'supabase_vault',
]
@@ -1,10 +1,10 @@
import { FC, useState } from 'react'
import { FC, useEffect, useState } from 'react'
import { observer } from 'mobx-react-lite'
import { partition, isNull } from 'lodash'
import { Input, IconSearch, IconAlertCircle } from 'ui'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useStore, useFlag, checkPermissions } from 'hooks'
import { useStore, checkPermissions, useParams } from 'hooks'
import ExtensionCard from './ExtensionCard'
import { HIDDEN_EXTENSIONS } from './Extensions.constants'
import NoSearchResults from 'components/to-be-cleaned/NoSearchResults'
@@ -14,19 +14,15 @@ interface Props {}
const Extensions: FC<Props> = ({}) => {
const { meta } = useStore()
const { filter } = useParams()
const [filterString, setFilterString] = useState<string>('')
const enableVaultExtension = useFlag('vaultExtension')
const hiddenExtensions = enableVaultExtension
? HIDDEN_EXTENSIONS
: HIDDEN_EXTENSIONS.concat(['vault'])
const extensions =
filterString.length === 0
? meta.extensions.list()
: meta.extensions.list((ext: any) => ext.name.includes(filterString))
const extensionsWithoutHidden = extensions.filter(
(ext: any) => !hiddenExtensions.includes(ext.name)
(ext: any) => !HIDDEN_EXTENSIONS.includes(ext.name)
)
const [enabledExtensions, disabledExtensions] = partition(
extensionsWithoutHidden,
@@ -38,6 +34,10 @@ const Extensions: FC<Props> = ({}) => {
'extensions'
)
useEffect(() => {
if (filter !== undefined) setFilterString(filter as string)
}, [filter])
return (
<div className="p-4">
<div className="mb-4">
@@ -96,10 +96,10 @@ const ColumnList: FC<Props> = ({
)}
</Table.td>
<Table.td>
<code className="text-sm">{x.data_type}</code>
<code className="text-xs">{x.data_type}</code>
</Table.td>
<Table.td className="font-mono text-xs">
<code className="text-sm">{x.format}</code>
<code className="text-xs">{x.format}</code>
</Table.td>
<Table.td className="px-4 py-3 pr-2">
<div className="flex justify-end gap-2">
@@ -60,46 +60,44 @@ const ServiceList: FC<Props> = ({ projectRef }) => {
const apiConfig = apiService?.app_config
return (
<>
<div className="">
<h3 className="text-scale-1200 mb-6 text-xl">API Settings</h3>
<section>
<Panel title={<h5 className="mb-0">Project URL</h5>}>
<Panel.Content>
{isError ? (
<div className="flex items-center justify-center space-x-2 py-4">
<IconAlertCircle size={16} strokeWidth={1.5} />
<p className="text-sm text-scale-1100">Failed to retrieve project URL</p>
</div>
) : (
<Input
copy
label="URL"
readOnly
disabled
className="input-mono"
value={`https://${apiConfig?.endpoint ?? '-'}`}
descriptionText="A RESTful endpoint for querying and managing your database."
layout="horizontal"
/>
)}
</Panel.Content>
</Panel>
</section>
<div>
<h3 className="text-scale-1200 mb-6 text-xl">API Settings</h3>
<section>
<Panel title={<h5 className="mb-0">Project URL</h5>}>
<Panel.Content>
{isError ? (
<div className="flex items-center justify-center space-x-2 py-4">
<IconAlertCircle size={16} strokeWidth={1.5} />
<p className="text-sm text-scale-1100">Failed to retrieve project URL</p>
</div>
) : (
<Input
copy
label="URL"
readOnly
disabled
className="input-mono"
value={`https://${apiConfig?.endpoint ?? '-'}`}
descriptionText="A RESTful endpoint for querying and managing your database."
layout="horizontal"
/>
)}
</Panel.Content>
</Panel>
</section>
<section>
<DisplayApiSettings key="DisplayAPISettings" />
</section>
<section>
<DisplayApiSettings key="DisplayAPISettings" />
</section>
<section>
<JWTSettings />
</section>
<section>
<JWTSettings />
</section>
<section>
<PostgrestConfig />
</section>
</div>
</>
<section>
<PostgrestConfig />
</section>
</div>
)
}
@@ -0,0 +1,77 @@
import { FC } from 'react'
import { Input, Listbox, Modal, IconPlus } from 'ui'
import { useStore } from 'hooks'
interface Props {
id?: string
nameId?: string
label?: string
labelOptional?: string
selectedKeyId?: any
error?: string
onSelectKey: (keyId: string) => void
onUpdateDescription?: (desc: string) => void
}
const EncryptionKeySelector: FC<Props> = ({
id = 'keyId',
nameId = 'keyName',
label = 'Encryption key',
labelOptional,
selectedKeyId,
error,
onSelectKey = () => {},
onUpdateDescription = () => {},
}) => {
const { vault } = useStore()
const keys = vault.listKeys()
return (
<>
<Listbox
id={id}
label={label}
size="small"
defaultValue={selectedKeyId}
value={selectedKeyId}
labelOptional={labelOptional}
onChange={onSelectKey}
>
<Listbox.Option
key="create-new"
id="create-new"
value="create-new"
label="Create a new Encryption Key"
addOnBefore={() => <IconPlus size={16} strokeWidth={1.5} />}
>
Create a new Encryption Key
</Listbox.Option>
<Modal.Separator />
{keys.map((key) => (
<Listbox.Option
key={key.id}
label={key.name === 'default_vault_key' ? `${key.id} (Default)` : key.id}
value={key.id}
>
<div className="space-y-1">
<p>{key.name || 'No name provided'}</p>
<p className="text-xs">
ID: <span className="font-mono">{key.id}</span>
</p>
</div>
</Listbox.Option>
))}
</Listbox>
{selectedKeyId === 'create-new' && (
<Input
id={nameId}
label="Name"
error={error}
onChange={(event) => onUpdateDescription(event.target.value)}
/>
)}
</>
)
}
export default EncryptionKeySelector
@@ -0,0 +1,325 @@
import dayjs from 'dayjs'
import { observer } from 'mobx-react-lite'
import * as Tooltip from '@radix-ui/react-tooltip'
import { FC, Fragment, useEffect, useState } from 'react'
import {
Alert,
Input,
IconSearch,
Listbox,
Button,
Modal,
Form,
IconTrash,
Badge,
IconKey,
IconLoader,
IconX,
} from 'ui'
import { useParams, useStore } from 'hooks'
import Divider from 'components/ui/Divider'
const DEFAULT_KEY_NAME = 'No description provided'
interface Props {}
const EncryptionKeysManagement: FC<Props> = ({}) => {
const { vault, ui } = useStore()
const { id } = useParams()
const [searchValue, setSearchValue] = useState<string>('')
const [selectedSort, setSelectedSort] = useState<'name' | 'created'>('created')
const [showAddKeyModal, setShowAddKeyModal] = useState(false)
const [selectedKeyToRemove, setSelectedKeyToRemove] = useState<any>()
const [isDeletingKey, setIsDeletingKey] = useState(false)
useEffect(() => {
if (id !== undefined) setSearchValue(id)
}, [id])
const keys = (
searchValue
? vault.listKeys(
(key: any) =>
(key?.name ?? '').toLowerCase().includes(searchValue.toLowerCase()) ||
key.id.toLowerCase().includes(searchValue.toLowerCase())
)
: vault.listKeys()
).sort((a: any, b: any) => {
if (selectedSort === 'created') {
return Number(new Date(a.created)) - Number(new Date(b.created))
} else {
return a[selectedSort] > b[selectedSort] ? 1 : -1
}
})
const addKey = async (values: any, { setSubmitting }: any) => {
setSubmitting(true)
const res = await vault.addKey(values.name)
if (!res.error) {
ui.setNotification({ category: 'success', message: 'Successfully added new key' })
setShowAddKeyModal(false)
} else {
ui.setNotification({
error: res.error,
category: 'error',
message: `Failed to add new key: ${res.error.message}`,
})
}
setSubmitting(false)
}
const confirmDeleteKey = async () => {
if (!selectedKeyToRemove) return
setIsDeletingKey(true)
const res = await vault.deleteKey(selectedKeyToRemove.id)
if (!res.error) {
ui.setNotification({ category: 'success', message: `Successfully deleted encryption key` })
setSelectedKeyToRemove(undefined)
} else {
ui.setNotification({
error: res.error,
category: 'error',
message: `Failed to delete encryption key: ${res.error.message}`,
})
}
setIsDeletingKey(false)
}
return (
<>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Input
className="w-64 input-clear"
size="small"
placeholder="Search by name or ID"
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
icon={<IconSearch strokeWidth={2} size={16} />}
actions={
searchValue.length > 0
? [
<Button
size="tiny"
type="text"
icon={<IconX />}
className="px-1"
onClick={() => setSearchValue('')}
/>,
]
: []
}
/>
<div className="w-32">
<Listbox size="small" value={selectedSort} onChange={setSelectedSort}>
<Listbox.Option
id="created"
className="max-w-[180px]"
value="created"
label="Sort by created at"
>
Created at
</Listbox.Option>
<Listbox.Option
id="name"
className="max-w-[180px]"
value="name"
label="Sort by name"
>
Name
</Listbox.Option>
</Listbox>
</div>
</div>
<Button type="primary" onClick={() => setShowAddKeyModal(true)}>
Add new key
</Button>
</div>
{/* Table of keys */}
<div className="border border-scale-500 rounded">
{!vault.isLoaded ? (
<div className="px-6 py-6 space-x-2 flex items-center justify-center">
<IconLoader className="animate-spin text-scale-1100" size={16} strokeWidth={1.5} />
<p className="text-sm text-scale-1200">Loading keys from the Vault</p>
</div>
) : (
<>
{keys.map((key, idx) => {
return (
<Fragment key={key.key_id}>
<div className="px-6 py-4 flex items-center space-x-4">
<IconKey className="text-scale-1100" strokeWidth={2} />
<div className="space-y-1 min-w-[37%] max-w-[37%]">
<p
className="text-sm truncate text-scale-1200"
title={key.name || DEFAULT_KEY_NAME}
>
{key.name || DEFAULT_KEY_NAME}
</p>
<p title={key.id} className="text-xs text-scale-1100 font-bold truncate">
ID: <span className="font-mono">{key.id}</span>
</p>
</div>
<div className="flex items-center space-x-2 w-[33%]">
{key.name === 'default_vault_key' && <Badge color="green">Default</Badge>}
</div>
<div className="flex items-center justify-end w-[30%] space-x-4">
<p className="text-sm text-scale-1100">
Added on {dayjs(key.created).format('MMM D, YYYY')}
</p>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Button
type="default"
className="py-2"
icon={<IconTrash />}
disabled={key.name === 'default_vault_key'}
onClick={() => setSelectedKeyToRemove(key)}
/>
</Tooltip.Trigger>
{key.name === 'default_vault_key' && (
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
'border border-scale-200',
].join(' ')}
>
<span className="text-xs text-scale-1200">
The default key cannot be deleted
</span>
</div>
</Tooltip.Content>
)}
</Tooltip.Root>
</div>
</div>
{idx !== keys.length - 1 && <Divider light />}
</Fragment>
)
})}
{keys.length === 0 && (
<>
{searchValue.length === 0 ? (
<div className="px-6 py-6 space-y-1 flex flex-col items-center justify-center">
<p className="text-sm text-scale-1200">No encryption keys added yet</p>
<p className="text-sm text-scale-1100">
Encryption keys are created by the pgsodium extension and can be used to
encrypt your columns and secrets
</p>
</div>
) : (
<div className="px-6 py-4 space-y-1">
<p className="text-sm text-scale-1200">No results found</p>
<p className="text-sm text-scale-1100">
Your search for "{searchValue}" did not return any results
</p>
</div>
)}
</>
)}
</>
)}
</div>
</div>
<Modal
closable
size="medium"
alignFooter="right"
visible={selectedKeyToRemove !== undefined}
onCancel={() => setSelectedKeyToRemove(undefined)}
onConfirm={confirmDeleteKey}
loading={isDeletingKey}
header={<h5 className="text-sm text-scale-1200">Confirm to delete key</h5>}
>
<div className="py-4">
<Modal.Content>
<div className="space-y-4">
<Alert
withIcon
variant="warning"
title="Deleting a key that's in use will cause any secret or column which depends on it to be unusable."
>
Do ensure that the key is not currently in use to prevent any issues.
</Alert>
<p className="text-sm">
The following key will be permanently removed and cannot be recovered.
</p>
<div className="space-y-2">
<p className="text-sm text-scale-1200">
{selectedKeyToRemove?.name ?? DEFAULT_KEY_NAME}
</p>
<p className="text-xs text-scale-1100">
<code className="!mx-0">ID: {selectedKeyToRemove?.id}</code>
</p>
</div>
</div>
</Modal.Content>
</div>
</Modal>
<Modal
closable
hideFooter
size="medium"
visible={showAddKeyModal}
onCancel={() => setShowAddKeyModal(false)}
header={<h5 className="text-sm text-scale-1200">Add a new key</h5>}
>
<Form
id="add-new-key-form"
validateOnBlur={false}
initialValues={{ name: '' }}
validate={(values: any) => {
const errors: any = {}
if (values.name.length === 0) errors.name = 'Please provide a name for your key'
return errors
}}
onSubmit={addKey}
>
{({ isSubmitting }: any) => {
return (
<div className="py-4">
<Modal.Content>
<p className="text-sm mb-4">
Provide a name for your key for easier identification.
</p>
<div className="space-y-4 pb-4">
<Input
id="name"
label="Key Name"
descriptionText="Keys are of standard UUID types."
/>
</div>
</Modal.Content>
<Modal.Separator />
<Modal.Content>
<div className="flex items-center justify-end space-x-2">
<Button
type="default"
disabled={isSubmitting}
onClick={() => setShowAddKeyModal(false)}
>
Cancel
</Button>
<Button htmlType="submit" disabled={isSubmitting} loading={isSubmitting}>
Add key
</Button>
</div>
</Modal.Content>
</div>
)
}}
</Form>
</Modal>
</>
)
}
export default observer(EncryptionKeysManagement)
@@ -0,0 +1,164 @@
import { FC, useState, useEffect } from 'react'
import { Modal, Form, Input, IconHelpCircle, Button, IconEyeOff, IconEye } from 'ui'
import { useStore } from 'hooks'
import EncryptionKeySelector from '../Keys/EncryptionKeySelector'
import InformationBox from 'components/ui/InformationBox'
interface Props {
visible: boolean
onClose: () => void
}
const AddNewSecretModal: FC<Props> = ({ visible, onClose }) => {
const { vault, ui } = useStore()
const [showSecretValue, setShowSecretValue] = useState(false)
const [selectedKeyId, setSelectedKeyId] = useState<string>()
const keys = vault.listKeys()
const defaultKey = keys.find((key) => key.name === 'default_vault_key') || keys[0]
useEffect(() => {
if (visible) setSelectedKeyId(defaultKey?.id)
}, [visible])
const validate = (values: any) => {
const errors: any = {}
if (values.name.length === 0) errors.name = 'Please provide a name for your secret'
if (values.secret.length === 0) errors.secret = 'Please enter your secret value'
if (selectedKeyId === 'create-new' && values.keyName.length === 0)
errors.keyName = 'Please provide a name for your new key'
return errors
}
const onAddNewSecret = async (values: any, { setSubmitting }: any) => {
setSubmitting(true)
let encryptionKeyId = selectedKeyId
if (values.keyId === 'create-new') {
const addKeyRes = await vault.addKey(values.keyName || undefined)
if (addKeyRes.error) {
return ui.setNotification({
error: addKeyRes.error,
category: 'error',
message: `Failed to create new key: ${addKeyRes.error.message}`,
})
} else {
encryptionKeyId = addKeyRes[0].id
}
}
const res = await vault.addSecret({
name: values.name,
description: values.description,
secret: values.secret,
key_id: encryptionKeyId,
})
if (!res.error) {
ui.setNotification({
category: 'success',
message: `Successfully added new secret ${values.name}`,
})
onClose()
setSubmitting(false)
} else {
ui.setNotification({
error: res.error,
category: 'error',
message: `Failed to add secret ${values.name}: ${res.error.message}`,
})
setSubmitting(false)
}
}
return (
<Modal
closable
hideFooter
size="medium"
visible={visible}
onCancel={onClose}
header={<h5 className="text-sm text-scale-1200">Add new secret</h5>}
>
<Form
id="add-new-secret-form"
initialValues={{ name: '', description: '', secret: '', keyId: '', keyName: '' }}
validate={validate}
validateOnBlur={false}
onSubmit={onAddNewSecret}
>
{({ isSubmitting }: any) => {
return (
<div className="py-4">
<Modal.Content>
<div className="space-y-4 pb-4">
<Input id="name" label="Name" />
<Input id="description" label="Description" labelOptional="Optional" />
<Input
id="secret"
type={showSecretValue ? 'text' : 'password'}
label="Secret value"
actions={
<div className="mr-1">
<Button
type="default"
icon={showSecretValue ? <IconEyeOff /> : <IconEye />}
onClick={() => setShowSecretValue(!showSecretValue)}
/>
</div>
}
/>
</div>
</Modal.Content>
<Modal.Separator />
<Modal.Content>
<div className="py-4 space-y-4">
<EncryptionKeySelector
id="keyId"
nameId="keyName"
label="Select a key to encrypt your secret with"
labelOptional="Optional"
selectedKeyId={selectedKeyId}
onSelectKey={setSelectedKeyId}
/>
<InformationBox
icon={<IconHelpCircle size={18} strokeWidth={2} />}
url="asd"
urlLabel="Vault documentation"
title="What is a key?"
description={
<div className="space-y-2">
<p>
Every secret in the Vault is encrypted with a key. The Vault comes with a
default value for this key which is sufficient for simple purposes.
</p>
<p>
However, you may also use a custom key for more advanced use cases, such
as having different secrets visible to different users
</p>
</div>
}
/>
</div>
</Modal.Content>
<Modal.Separator />
<Modal.Content>
<div className="flex items-center justify-end space-x-2">
<Button type="default" disabled={isSubmitting} onClick={onClose}>
Cancel
</Button>
<Button htmlType="submit" disabled={isSubmitting} loading={isSubmitting}>
Add secret
</Button>
</div>
</Modal.Content>
</div>
)
}}
</Form>
</Modal>
)
}
export default AddNewSecretModal
@@ -0,0 +1,66 @@
import { FC, useState } from 'react'
import { Modal } from 'ui'
import { VaultSecret } from 'types'
import { useStore } from 'hooks'
interface Props {
selectedSecret: VaultSecret
onClose: () => void
}
const DeleteSecretModal: FC<Props> = ({ selectedSecret, onClose }) => {
const { vault, ui } = useStore()
const [isDeleting, setIsDeleting] = useState(false)
const onConfirmDeleteSecret = async () => {
setIsDeleting(true)
const res = await vault.deleteSecret(selectedSecret.id)
if (res.error) {
ui.setNotification({
error: res.error,
category: 'error',
message: `Failed to delete secret: ${res.error.message}`,
})
} else {
ui.setNotification({
category: 'success',
message: `Successfully deleted secret ${selectedSecret.name}`,
})
onClose()
}
setIsDeleting(false)
}
return (
<Modal
closable
size="small"
alignFooter="right"
visible={selectedSecret !== undefined}
onCancel={onClose}
onConfirm={onConfirmDeleteSecret}
loading={isDeleting}
header={<h5 className="text-sm text-scale-1200">Confirm to delete secret</h5>}
>
<div className="py-4">
<Modal.Content>
<div className="space-y-4">
<p className="text-sm">
The following secret will be permanently removed and cannot be recovered. Are you
sure?
</p>
<div className="space-y-1">
<p className="text-sm">{selectedSecret?.description}</p>
<p className="text-sm text-scale-1100">
ID: <span className="font-mono">{selectedSecret?.key_id}</span>
</p>
</div>
</div>
</Modal.Content>
</div>
</Modal>
)
}
export default DeleteSecretModal
@@ -0,0 +1,180 @@
import { isEmpty } from 'lodash'
import { FC, useState, useEffect } from 'react'
import { Modal, Form, Input, Button, IconEyeOff, IconEye } from 'ui'
import { useStore } from 'hooks'
import { VaultSecret } from 'types'
import EncryptionKeySelector from '../Keys/EncryptionKeySelector'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
interface Props {
selectedSecret: VaultSecret
onClose: () => void
}
const EditSecretModal: FC<Props> = ({ selectedSecret, onClose }) => {
const { ui, vault } = useStore()
const [selectedKeyId, setSelectedKeyId] = useState<string>()
const [showSecretValue, setShowSecretValue] = useState(false)
const [isLoadingSecretValue, setIsLoadingSecretValue] = useState(false)
let INITIAL_VALUES = {
name: selectedSecret?.name ?? '',
description: selectedSecret?.description ?? '',
secret: selectedSecret?.decryptedSecret ?? '',
}
useEffect(() => {
if (selectedSecret !== undefined) {
setShowSecretValue(false)
setSelectedKeyId(selectedSecret.key_id)
}
}, [selectedSecret])
const validate = (values: any) => {
const errors: any = {}
if (values.name.length === 0) errors.name = 'Please provide a name for your secret'
if (values.secret.length === 0) errors.secret = 'Please enter your secret value'
return errors
}
const onUpdateSecret = async (values: any, { setSubmitting }: any) => {
const payload: Partial<VaultSecret> = {}
if (values.name !== selectedSecret.name) payload.name = values.name
if (values.description !== selectedSecret.description) payload.description = values.description
if (selectedKeyId !== selectedSecret.key_id) {
let encryptionKeyId = selectedKeyId
if (values.keyId === 'create-new') {
const addKeyRes = await vault.addKey(values.keyName || undefined)
if (addKeyRes.error) {
return ui.setNotification({
error: addKeyRes.error,
category: 'error',
message: `Failed to create new key: ${addKeyRes.error.message}`,
})
} else {
encryptionKeyId = addKeyRes[0].id
}
}
payload.key_id = encryptionKeyId
}
payload.secret = values.secret
if (!isEmpty(payload)) {
setSubmitting(true)
const res = await vault.updateSecret(selectedSecret.id, payload)
if (!res.error) {
ui.setNotification({ category: 'success', message: 'Successfully updated secret' })
setSubmitting(false)
onClose()
} else {
ui.setNotification({
error: res.error,
category: 'error',
message: `Failed to update secret: ${res.error.message}`,
})
setSubmitting(false)
}
}
}
return (
<Modal
closable
hideFooter
size="medium"
visible={selectedSecret !== undefined}
onCancel={onClose}
header={<h5 className="text-sm text-scale-1200">Edit secret</h5>}
>
<Form
id="add-new-secret-form"
initialValues={INITIAL_VALUES}
validate={validate}
validateOnBlur={false}
onSubmit={onUpdateSecret}
>
{({ isSubmitting, resetForm }: any) => {
useEffect(() => {
if (selectedSecret !== undefined && selectedSecret.decryptedSecret === undefined) {
fetchSecretValue()
}
}, [selectedSecret])
const fetchSecretValue = async () => {
if (selectedSecret === undefined) return
setIsLoadingSecretValue(true)
const res = await vault.fetchSecretValue(selectedSecret.id)
resetForm({
values: { ...INITIAL_VALUES, secret: res },
initialValues: { ...INITIAL_VALUES, secret: res },
})
setIsLoadingSecretValue(false)
}
return isLoadingSecretValue ? (
<div className="space-y-2 py-4 px-2">
<ShimmeringLoader />
<div className="w-3/4">
<ShimmeringLoader />
</div>
<div className="w-1/2">
<ShimmeringLoader />
</div>
</div>
) : (
<div className="py-4">
<Modal.Content>
<div className="space-y-4 pb-4">
<Input id="name" label="Name" />
<Input id="description" label="Description" labelOptional="Optional" />
<Input
id="secret"
type={showSecretValue ? 'text' : 'password'}
label="Secret value"
actions={
<div className="mr-1">
<Button
type="default"
icon={showSecretValue ? <IconEyeOff /> : <IconEye />}
onClick={() => setShowSecretValue(!showSecretValue)}
/>
</div>
}
/>
</div>
</Modal.Content>
<Modal.Separator />
<Modal.Content>
<div className="py-4 space-y-4">
<EncryptionKeySelector
id="keyId"
nameId="keyName"
label="Select a key to encrypt your secret with"
labelOptional="Optional"
selectedKeyId={selectedKeyId}
onSelectKey={setSelectedKeyId}
/>
</div>
</Modal.Content>
<Modal.Separator />
<Modal.Content>
<div className="flex items-center justify-end space-x-2">
<Button type="default" disabled={isSubmitting} onClick={onClose}>
Cancel
</Button>
<Button htmlType="submit" disabled={isSubmitting} loading={isSubmitting}>
Update secret
</Button>
</div>
</Modal.Content>
</div>
)
}}
</Form>
</Modal>
)
}
export default EditSecretModal
@@ -0,0 +1,128 @@
import dayjs from 'dayjs'
import { FC, useState } from 'react'
import {
Button,
IconEye,
IconEyeOff,
IconTrash,
Input,
Dropdown,
IconEdit3,
IconKey,
IconMoreVertical,
IconLoader,
} from 'ui'
import { VaultSecret } from 'types'
import { useParams, useStore } from 'hooks'
import Link from 'next/link'
interface Props {
secret: VaultSecret
onSelectEdit: (secret: any) => void
onSelectRemove: (secret: any) => void
}
const SecretRow: FC<Props> = ({ secret, onSelectEdit, onSelectRemove }) => {
const { vault } = useStore()
const { ref } = useParams()
const [isLoading, setIsLoading] = useState(false)
const [revealedValue, setRevealedValue] = useState<string>()
const name = secret?.name ?? 'No name provided'
const revealSecret = async () => {
setIsLoading(true)
if (revealedValue === undefined) {
setRevealedValue(
secret.decryptedSecret !== undefined
? secret.decryptedSecret
: await vault.fetchSecretValue(secret.id)
)
} else {
setRevealedValue(undefined)
}
setIsLoading(false)
}
return (
<div className="px-6 py-4 flex items-center space-x-4">
<div className="space-y-1 min-w-[35%] max-w-[35%]">
<div>
<p className="text-sm text-scale-1200" title={name}>
{name}
</p>
{secret.description !== undefined && (
<p className="text-sm text-scale-1100" title={secret.description}>
{secret.description}
</p>
)}
</div>
<div className="flex items-center space-x-2 group">
<IconKey
size={14}
strokeWidth={2}
className="text-scale-1000 transition group-hover:text-brand-900"
/>
<Link href={`/project/${ref}/settings/vault/keys?id=${secret.key_id}`}>
<a
className="text-scale-1100 font-mono text-xs cursor-pointer transition group-hover:text-brand-900"
title={secret.key_id}
>
{secret.key_id}
</a>
</Link>
</div>
</div>
<div className="flex items-center space-x-2 w-[40%]">
<Button
type="text"
className="px-1.5"
icon={
isLoading ? (
<IconLoader className="animate-spin" size={16} strokeWidth={1.5} />
) : revealedValue === undefined ? (
<IconEye size={16} strokeWidth={1.5} />
) : (
<IconEyeOff size={16} strokeWidth={1.5} />
)
}
onClick={() => revealSecret()}
/>
<div className="flex-grow">
{revealedValue === undefined ? (
<p className="text-sm font-mono">••••••••••••••••••</p>
) : (
<Input copy size="small" className="font-mono" value={revealedValue} />
)}
</div>
</div>
<div className="flex items-center justify-end w-[25%] space-x-4">
<p className="text-sm text-scale-1100">
{secret.updated_at === secret.created_at ? 'Added' : 'Updated'} on{' '}
{dayjs(secret.updated_at).format('MMM D, YYYY')}
</p>
<Dropdown
side="bottom"
className="w-[120px]"
overlay={
<>
<Dropdown.Item icon={<IconEdit3 size="tiny" />} onClick={() => onSelectEdit(secret)}>
Edit
</Dropdown.Item>
<Dropdown.Item
icon={<IconTrash stroke="red" size="tiny" />}
onClick={() => onSelectRemove(secret)}
>
Delete
</Dropdown.Item>
</>
}
>
<Button as="span" type="text" className="px-1" icon={<IconMoreVertical />} />
</Dropdown>
</div>
</div>
)
}
export default SecretRow
@@ -0,0 +1,146 @@
import { observer } from 'mobx-react-lite'
import { FC, Fragment, useState } from 'react'
import { IconSearch, Input, Button, Listbox, IconLoader } from 'ui'
import SecretRow from './SecretRow'
import EditSecretModal from './EditSecretModal'
import DeleteSecretModal from './DeleteSecretModal'
import AddNewSecretModal from './AddNewSecretModal'
import Divider from 'components/ui/Divider'
import { useStore } from 'hooks'
interface Props {}
const SecretsManagement: FC<Props> = ({}) => {
const { vault } = useStore()
const [searchValue, setSearchValue] = useState<string>('')
const [selectedSort, setSelectedSort] = useState<'updated_at' | 'name'>('updated_at')
const [showAddSecretModal, setShowAddSecretModal] = useState(false)
const [selectedSecretToEdit, setSelectedSecretToEdit] = useState<any>()
const [selectedSecretToRemove, setSelectedSecretToRemove] = useState<any>()
const secrets = (
searchValue.length > 0
? vault.listSecrets(
(secret: any) =>
(secret?.name ?? '').toLowerCase().includes(searchValue.toLowerCase()) ||
secret.key_id.toLowerCase().includes(searchValue.toLowerCase())
)
: vault.listSecrets()
).sort((a: any, b: any) => {
if (selectedSort === 'updated_at') {
return Number(new Date(a.updated_at)) - Number(new Date(b.updated_at))
} else {
return a[selectedSort] > b[selectedSort] ? 1 : -1
}
})
return (
<>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Input
className="w-64"
size="small"
placeholder="Search by name or key ID"
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
icon={<IconSearch strokeWidth={2} size={16} />}
/>
<div className="w-32">
<Listbox size="small" value={selectedSort} onChange={setSelectedSort}>
<Listbox.Option
id="updated_at"
className="max-w-[180px]"
value="updated_at"
label="Sort by updated at"
>
Updated at
</Listbox.Option>
<Listbox.Option
id="name"
className="max-w-[180px]"
value="name"
label="Sort by name"
>
Name
</Listbox.Option>
<Listbox.Option
id="key_id"
className="max-w-[180px]"
value="key_id"
label="Sort by key ID"
>
Key ID
</Listbox.Option>
</Listbox>
</div>
</div>
<Button type="primary" onClick={() => setShowAddSecretModal(true)}>
Add new secret
</Button>
</div>
{/* Table of secrets */}
<div className="border border-scale-500 rounded">
{!vault.isLoaded ? (
<div className="px-6 py-6 space-x-2 flex items-center justify-center">
<IconLoader className="animate-spin text-scale-1100" size={16} strokeWidth={1.5} />
<p className="text-sm text-scale-1200">Loading secrets from the Vault</p>
</div>
) : (
<>
{secrets.map((secret, idx) => {
return (
<Fragment key={`secret-${idx}`}>
<SecretRow
secret={secret}
onSelectEdit={setSelectedSecretToEdit}
onSelectRemove={setSelectedSecretToRemove}
/>
{idx !== secrets.length - 1 && <Divider light />}
</Fragment>
)
})}
{secrets.length === 0 && (
<>
{searchValue.length === 0 ? (
<div className="px-6 py-6 space-y-1 flex flex-col items-center justify-center">
<p className="text-sm text-scale-1200">No secrets added yet</p>
<p className="text-sm text-scale-1100">
The Vault allows you to store sensitive information like API keys
</p>
</div>
) : (
<div className="px-6 py-4 space-y-1">
<p className="text-sm text-scale-1200">No results found</p>
<p className="text-sm text-scale-1100">
Your search for "{searchValue}" did not return any results
</p>
</div>
)}
</>
)}
</>
)}
</div>
</div>
<EditSecretModal
selectedSecret={selectedSecretToEdit}
onClose={() => setSelectedSecretToEdit(undefined)}
/>
<DeleteSecretModal
selectedSecret={selectedSecretToRemove}
onClose={() => setSelectedSecretToRemove(undefined)}
/>
<AddNewSecretModal
visible={showAddSecretModal}
onClose={() => setShowAddSecretModal(false)}
/>
</>
)
}
export default observer(SecretsManagement)
@@ -0,0 +1,153 @@
import Link from 'next/link'
import { observer } from 'mobx-react-lite'
import { FC, useState } from 'react'
import * as Tooltip from '@radix-ui/react-tooltip'
import { Button, IconAlertCircle, IconExternalLink } from 'ui'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { checkPermissions, useParams, useStore } from 'hooks'
import InformationBox from 'components/ui/InformationBox'
interface Props {}
const VaultToggle: FC<Props> = () => {
const { meta, ui } = useStore()
const { ref } = useParams()
const [isEnabling, setIsEnabling] = useState(false)
const canToggleVault = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'extensions')
const vaultExtension = meta.extensions.byId('supabase_vault')
const isNotAvailable = vaultExtension === undefined
const onEnableVault = async () => {
if (vaultExtension === undefined) return
setIsEnabling(true)
const { error: createSchemaError } = await meta.query(
`create schema if not exists ${vaultExtension.schema ?? 'vault'};`
)
if (createSchemaError) {
setIsEnabling(false)
return ui.setNotification({
error: createSchemaError,
category: 'error',
message: `Failed to create schema: ${createSchemaError.message}`,
})
}
const { error: createExtensionError } = await meta.extensions.create({
schema: vaultExtension.schema ?? 'vault',
name: vaultExtension.name,
version: vaultExtension.default_version,
cascade: true,
})
if (createExtensionError) {
ui.setNotification({
error: createExtensionError,
category: 'error',
message: `Failed to enable Vault for your project: ${createExtensionError.message}`,
})
setIsEnabling(false)
} else {
ui.setNotification({
category: 'success',
message: 'Vault is now enabled for your project!',
})
}
}
return (
<div>
<div
className="px-12 py-12 w-full bg-white dark:bg-scale-200 border border-scale-500 rounded bg-no-repeat"
style={{
backgroundSize: isNotAvailable ? '50%' : '40%',
backgroundPosition: '100% 24%',
backgroundImage: ui.isDarkTheme
? 'url("/img/vault-dark.png")'
: 'url("/img/vault-light.png")',
}}
>
<div className="w-3/5 space-y-8">
<div className="space-y-2">
<h4 className="text-lg">Enable Vault today</h4>
<p className="text-sm text-scale-1100">
Supabase Vault is a Postgres extension for managing secrets and encryption inside your
database without leaking any sensitive information.
</p>
</div>
{isNotAvailable ? (
<InformationBox
hideCollapse
defaultVisibility
icon={<IconAlertCircle strokeWidth={1.5} size={18} />}
title="Vault is not available for your project"
description={
<>
<p className="mb-4">
Do reach out to us if you're interested in having Vault for this project!
</p>
<div className="flex items-center space-x-2 my-1 ml-[1px]">
<Link href="https://supabase.com/docs">
<a target="_blank">
<Button type="default" icon={<IconExternalLink />}>
About Vault
</Button>
</a>
</Link>
<Link
href={`/support/new?ref=${ref}&category=sales&subject=Request%20for%20access%20to%20vault`}
>
<a target="_blank">
<Button type="primary">Contact us</Button>
</a>
</Link>
</div>
</>
}
/>
) : (
<div className="flex items-center space-x-2">
<Link href="https://supabase.com/docs">
<a target="_blank">
<Button type="default" icon={<IconExternalLink />}>
About Vault
</Button>
</a>
</Link>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Button
type="primary"
loading={isEnabling}
disabled={isNotAvailable || isEnabling || !canToggleVault}
onClick={() => onEnableVault()}
>
Enable Vault
</Button>
</Tooltip.Trigger>
{!canToggleVault && (
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
'border border-scale-200',
].join(' ')}
>
<span className="text-xs text-scale-1200">
You need additional permissions to enable Vault for this project
</span>
</div>
</Tooltip.Content>
)}
</Tooltip.Root>
</div>
)}
</div>
</div>
</div>
)
}
export default observer(VaultToggle)
@@ -0,0 +1,6 @@
import VaultToggle from './VaultToggle'
import SecretsManagement from './Secrets/SecretsManagement'
import EncryptionKeySelector from './Keys/EncryptionKeySelector'
import EncryptionKeysManagement from './Keys/EncryptionKeysManagement'
export { VaultToggle, SecretsManagement, EncryptionKeySelector, EncryptionKeysManagement }
@@ -33,8 +33,8 @@ const ActionBar: FC<ActionBarProps> = ({
}
return (
<div className="flex w-full justify-between space-x-3 border-t border-scale-500 px-3 py-4">
<Button size="small" onClick={closePanel} type="default" htmlType="button">
<div className="flex w-full justify-end space-x-3 border-t border-scale-500 px-3 py-4">
<Button size="small" type="default" htmlType="button" onClick={closePanel}>
{backButtonLabel}
</Button>
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff. Show more