diff --git a/apps/docs/docs/ref/api/introduction.mdx b/apps/docs/docs/ref/api/introduction.mdx index d8070cc9433..47dbbcf3cd4 100644 --- a/apps/docs/docs/ref/api/introduction.mdx +++ b/apps/docs/docs/ref/api/introduction.mdx @@ -4,7 +4,7 @@ title: introduction hideTitle: true --- -
+

Management API

diff --git a/apps/docs/docs/ref/cli/introduction.mdx b/apps/docs/docs/ref/cli/introduction.mdx index 44434875560..bb12eea9481 100644 --- a/apps/docs/docs/ref/cli/introduction.mdx +++ b/apps/docs/docs/ref/cli/introduction.mdx @@ -4,7 +4,7 @@ title: Introduction hideTitle: true --- -
+

Supabase CLI

diff --git a/apps/docs/docs/ref/javascript/introduction.mdx b/apps/docs/docs/ref/javascript/introduction.mdx index f3abed16238..d3c14042cb6 100644 --- a/apps/docs/docs/ref/javascript/introduction.mdx +++ b/apps/docs/docs/ref/javascript/introduction.mdx @@ -4,7 +4,7 @@ title: Introduction hideTitle: true --- -
+

Javascript Client Library

diff --git a/apps/docs/docs/ref/javascript/v1/introduction.mdx b/apps/docs/docs/ref/javascript/v1/introduction.mdx index 95e99f2d978..3e1af1ff9dd 100644 --- a/apps/docs/docs/ref/javascript/v1/introduction.mdx +++ b/apps/docs/docs/ref/javascript/v1/introduction.mdx @@ -4,7 +4,7 @@ title: Introduction hideTitle: true --- -
+

Javascript Client Library

diff --git a/apps/docs/docs/ref/self-hosting-auth/introduction.mdx b/apps/docs/docs/ref/self-hosting-auth/introduction.mdx index 95e99f2d978..3e1af1ff9dd 100644 --- a/apps/docs/docs/ref/self-hosting-auth/introduction.mdx +++ b/apps/docs/docs/ref/self-hosting-auth/introduction.mdx @@ -4,7 +4,7 @@ title: Introduction hideTitle: true --- -
+

Javascript Client Library

diff --git a/apps/docs/docs/ref/self-hosting-realtime/introduction.mdx b/apps/docs/docs/ref/self-hosting-realtime/introduction.mdx index 95e99f2d978..3e1af1ff9dd 100644 --- a/apps/docs/docs/ref/self-hosting-realtime/introduction.mdx +++ b/apps/docs/docs/ref/self-hosting-realtime/introduction.mdx @@ -4,7 +4,7 @@ title: Introduction hideTitle: true --- -
+

Javascript Client Library

diff --git a/apps/docs/docs/ref/self-hosting-storage/introduction.mdx b/apps/docs/docs/ref/self-hosting-storage/introduction.mdx index 95e99f2d978..3e1af1ff9dd 100644 --- a/apps/docs/docs/ref/self-hosting-storage/introduction.mdx +++ b/apps/docs/docs/ref/self-hosting-storage/introduction.mdx @@ -4,7 +4,7 @@ title: Introduction hideTitle: true --- -
+

Javascript Client Library

diff --git a/apps/docs/hooks/useMenuState.ts b/apps/docs/hooks/useMenuState.ts index 04535ab6726..ed1818bd4d8 100644 --- a/apps/docs/hooks/useMenuState.ts +++ b/apps/docs/hooks/useMenuState.ts @@ -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 +} diff --git a/apps/docs/layouts/SiteRefLayout.tsx b/apps/docs/layouts/SiteRefLayout.tsx index 4142ed91471..1914ecf2ac9 100644 --- a/apps/docs/layouts/SiteRefLayout.tsx +++ b/apps/docs/layouts/SiteRefLayout.tsx @@ -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(' ')} > -
diff --git a/apps/docs/layouts/guides/current.tsx b/apps/docs/layouts/guides/current.tsx index 6b3e9038518..3d583c453f9 100644 --- a/apps/docs/layouts/guides/current.tsx +++ b/apps/docs/layouts/guides/current.tsx @@ -93,7 +93,7 @@ const Layout: FC = (props) => {
- -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. - - 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. + + +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()` + + +```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 &&
{error}
} + + + Factor ID + Friendly Name + Factor Status + + {factors.map(factor => ( + + {factor.id} + {factor.friendly_name} + {factor.factor_type} + {factor.status} + + ))} + + setFactorId(e.target.value.trim())} + /> + + + ) +} +``` + ### 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. diff --git a/apps/docs/pages/guides/platform/custom-domains.mdx b/apps/docs/pages/guides/platform/custom-domains.mdx index 0ff3d80bb49..b6750653aba 100644 --- a/apps/docs/pages/guides/platform/custom-domains.mdx +++ b/apps/docs/pages/guides/platform/custom-domains.mdx @@ -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) diff --git a/apps/docs/public/sitemap.xml b/apps/docs/public/sitemap.xml index 24c3d5a4bf2..5885d248a98 100644 --- a/apps/docs/public/sitemap.xml +++ b/apps/docs/public/sitemap.xml @@ -61,25 +61,19 @@ - https://supabase.com/docs/company/aup + https://supabase.com/docs/handbook/contributing weekly 0.5 - https://supabase.com/docs/company/privacy + https://supabase.com/docs/handbook/introduction weekly 0.5 - https://supabase.com/docs/company/sla - weekly - 0.5 - - - - https://supabase.com/docs/company/terms + https://supabase.com/docs/handbook/supasquad weekly 0.5 @@ -240,6 +234,18 @@ 0.5 + + https://supabase.com/docs/new/auth + weekly + 0.5 + + + + https://supabase.com/docs/new/index + weekly + 0.5 + + https://supabase.com/docs/reference/index weekly @@ -253,31 +259,55 @@ - https://supabase.com/docs/handbook/contributing + https://supabase.com/docs/company/aup weekly 0.5 - https://supabase.com/docs/handbook/introduction + https://supabase.com/docs/company/privacy weekly 0.5 - https://supabase.com/docs/handbook/supasquad + https://supabase.com/docs/company/sla weekly 0.5 - https://supabase.com/docs/new/auth + https://supabase.com/docs/company/terms weekly 0.5 - https://supabase.com/docs/new/index + https://supabase.com/docs/learn/auth-deep-dive/auth-deep-dive-jwts + weekly + 0.5 + + + + https://supabase.com/docs/learn/auth-deep-dive/auth-google-oauth + weekly + 0.5 + + + + https://supabase.com/docs/learn/auth-deep-dive/auth-gotrue + weekly + 0.5 + + + + https://supabase.com/docs/learn/auth-deep-dive/auth-policies + weekly + 0.5 + + + + https://supabase.com/docs/learn/auth-deep-dive/auth-row-level-security weekly 0.5 @@ -390,30 +420,6 @@ 0.5 - - https://supabase.com/docs/guides/functions/auth - weekly - 0.5 - - - - https://supabase.com/docs/guides/functions/best-practices - weekly - 0.5 - - - - https://supabase.com/docs/guides/functions/cicd-workflow - weekly - 0.5 - - - - https://supabase.com/docs/guides/functions/quickstart - weekly - 0.5 - - https://supabase.com/docs/guides/auth/auth-apple weekly @@ -600,6 +606,30 @@ 0.5 + + https://supabase.com/docs/guides/functions/auth + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/best-practices + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/cicd-workflow + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/quickstart + weekly + 0.5 + + https://supabase.com/docs/guides/getting-started/architecture weekly @@ -768,6 +798,78 @@ 0.5 + + https://supabase.com/docs/guides/realtime/postgres-changes + weekly + 0.5 + + + + https://supabase.com/docs/guides/realtime/quickstart + weekly + 0.5 + + + + https://supabase.com/docs/guides/realtime/rate-limits + weekly + 0.5 + + + + https://supabase.com/docs/guides/migrations/firebase-auth + weekly + 0.5 + + + + https://supabase.com/docs/guides/migrations/firebase-storage + weekly + 0.5 + + + + https://supabase.com/docs/guides/migrations/firestore-data + weekly + 0.5 + + + + https://supabase.com/docs/guides/migrations/heroku + weekly + 0.5 + + + + https://supabase.com/docs/guides/resources/examples + weekly + 0.5 + + + + https://supabase.com/docs/guides/resources/glossary + weekly + 0.5 + + + + https://supabase.com/docs/guides/resources/migrating-to-supabase + weekly + 0.5 + + + + https://supabase.com/docs/guides/resources/self-hosting + weekly + 0.5 + + + + https://supabase.com/docs/guides/resources/supabase-cli + weekly + 0.5 + + https://supabase.com/docs/guides/platform/compute-add-ons weekly @@ -822,78 +924,6 @@ 0.5 - - https://supabase.com/docs/guides/migrations/firebase-auth - weekly - 0.5 - - - - https://supabase.com/docs/guides/migrations/firebase-storage - weekly - 0.5 - - - - https://supabase.com/docs/guides/migrations/firestore-data - weekly - 0.5 - - - - https://supabase.com/docs/guides/migrations/heroku - weekly - 0.5 - - - - https://supabase.com/docs/guides/realtime/postgres-changes - weekly - 0.5 - - - - https://supabase.com/docs/guides/realtime/quickstart - weekly - 0.5 - - - - https://supabase.com/docs/guides/realtime/rate-limits - weekly - 0.5 - - - - https://supabase.com/docs/guides/resources/examples - weekly - 0.5 - - - - https://supabase.com/docs/guides/resources/glossary - weekly - 0.5 - - - - https://supabase.com/docs/guides/resources/migrating-to-supabase - weekly - 0.5 - - - - https://supabase.com/docs/guides/resources/self-hosting - weekly - 0.5 - - - - https://supabase.com/docs/guides/resources/supabase-cli - weekly - 0.5 - - https://supabase.com/docs/guides/storage/access-control weekly @@ -924,72 +954,6 @@ 0.5 - - https://supabase.com/docs/learn/auth-deep-dive/auth-deep-dive-jwts - weekly - 0.5 - - - - https://supabase.com/docs/learn/auth-deep-dive/auth-google-oauth - weekly - 0.5 - - - - https://supabase.com/docs/learn/auth-deep-dive/auth-gotrue - weekly - 0.5 - - - - https://supabase.com/docs/learn/auth-deep-dive/auth-policies - weekly - 0.5 - - - - https://supabase.com/docs/learn/auth-deep-dive/auth-row-level-security - weekly - 0.5 - - - - https://supabase.com/docs/guides/functions/examples/og-image - weekly - 0.5 - - - - https://supabase.com/docs/guides/auth/auth-helpers/auth-ui - weekly - 0.5 - - - - https://supabase.com/docs/guides/auth/auth-helpers/nextjs-server-components - weekly - 0.5 - - - - https://supabase.com/docs/guides/auth/auth-helpers/nextjs - weekly - 0.5 - - - - https://supabase.com/docs/guides/auth/auth-helpers/remix - weekly - 0.5 - - - - https://supabase.com/docs/guides/auth/auth-helpers/sveltekit - weekly - 0.5 - - https://supabase.com/docs/guides/database/extensions/http weekly @@ -1027,43 +991,31 @@ - https://supabase.com/docs/guides/auth/passwordless-login/email-otp-magic-link + https://supabase.com/docs/guides/auth/auth-helpers/auth-ui weekly 0.5 - https://supabase.com/docs/guides/auth/passwordless-login/phone-sms-otp-messagebird + https://supabase.com/docs/guides/auth/auth-helpers/nextjs-server-components weekly 0.5 - https://supabase.com/docs/guides/auth/passwordless-login/phone-sms-otp-twilio + https://supabase.com/docs/guides/auth/auth-helpers/nextjs weekly 0.5 - https://supabase.com/docs/guides/auth/passwordless-login/phone-sms-otp-vonage + https://supabase.com/docs/guides/auth/auth-helpers/remix weekly 0.5 - https://supabase.com/docs/guides/getting-started/quickstarts/nextjs - weekly - 0.5 - - - - https://supabase.com/docs/guides/getting-started/quickstarts/nuxtjs - weekly - 0.5 - - - - https://supabase.com/docs/guides/getting-started/quickstarts/reactjs + https://supabase.com/docs/guides/auth/auth-helpers/sveltekit weekly 0.5 @@ -1176,6 +1128,54 @@ 0.5 + + https://supabase.com/docs/guides/functions/examples/og-image + weekly + 0.5 + + + + https://supabase.com/docs/guides/auth/passwordless-login/email-otp-magic-link + weekly + 0.5 + + + + https://supabase.com/docs/guides/auth/passwordless-login/phone-sms-otp-messagebird + weekly + 0.5 + + + + https://supabase.com/docs/guides/auth/passwordless-login/phone-sms-otp-twilio + weekly + 0.5 + + + + https://supabase.com/docs/guides/auth/passwordless-login/phone-sms-otp-vonage + weekly + 0.5 + + + + https://supabase.com/docs/guides/getting-started/quickstarts/nextjs + weekly + 0.5 + + + + https://supabase.com/docs/guides/getting-started/quickstarts/nuxtjs + weekly + 0.5 + + + + https://supabase.com/docs/guides/getting-started/quickstarts/reactjs + weekly + 0.5 + + https://supabase.com/docs/guides/getting-started/tutorials/with-angular weekly @@ -1260,12 +1260,6 @@ 0.5 - - https://supabase.com/docs/guides/resources/self-hosting/docker - weekly - 0.5 - - https://supabase.com/docs/guides/resources/migrating-to-supabase/firebase-auth weekly @@ -1290,6 +1284,12 @@ 0.5 + + https://supabase.com/docs/guides/resources/self-hosting/docker + weekly + 0.5 + + https://supabase.com/docs/guides/resources/supabase-cli/local-development weekly diff --git a/apps/www/_blog/2022-12-06-the-supabase-content-storm.mdx b/apps/www/_blog/2022-12-06-the-supabase-content-storm.mdx index 07404711e42..f25e4dc11fb 100644 --- a/apps/www/_blog/2022-12-06-the-supabase-content-storm.mdx +++ b/apps/www/_blog/2022-12-06-the-supabase-content-storm.mdx @@ -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. diff --git a/apps/www/_blog/2022-12-07-supabase-beta-november-2022.mdx b/apps/www/_blog/2022-12-07-supabase-beta-november-2022.mdx index d1387baddc8..e60f479dff9 100644 --- a/apps/www/_blog/2022-12-07-supabase-beta-november-2022.mdx +++ b/apps/www/_blog/2022-12-07-supabase-beta-november-2022.mdx @@ -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. diff --git a/apps/www/_blog/2022-12-09-launch-week-6-hackathon.mdx b/apps/www/_blog/2022-12-09-launch-week-6-hackathon.mdx new file mode 100644 index 00000000000..212e9cce132 --- /dev/null +++ b/apps/www/_blog/2022-12-09-launch-week-6-hackathon.mdx @@ -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 :) ) diff --git a/apps/www/_blog/2022-12-09-who-we-hire.mdx b/apps/www/_blog/2022-12-09-who-we-hire.mdx new file mode 100644 index 00000000000..4ebfa438775 --- /dev/null +++ b/apps/www/_blog/2022-12-09-who-we-hire.mdx @@ -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). diff --git a/apps/www/_blog/2022-12-10-postgres-crdt.mdx b/apps/www/_blog/2022-12-10-postgres-crdt.mdx new file mode 100644 index 00000000000..36dd3a90cbc --- /dev/null +++ b/apps/www/_blog/2022-12-10-postgres-crdt.mdx @@ -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.* + +@samwillis on [HackerNews](https://news.ycombinator.com/item?id=32510820) + +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. \ No newline at end of file diff --git a/apps/www/components/LaunchWeek/Ticket/form.tsx b/apps/www/components/LaunchWeek/Ticket/form.tsx index 3595be0f946..59dbcb902ac 100644 --- a/apps/www/components/LaunchWeek/Ticket/form.tsx +++ b/apps/www/components/LaunchWeek/Ticket/form.tsx @@ -169,7 +169,6 @@ export default function Form({ sharePage, align = 'Center' }: Props) { align === 'Left' ? 'text-center xl:text-left' : 'text-center' )} > -

Coming soon

Register to get your ticket and stay tuned all week for daily announcements

diff --git a/apps/www/components/LaunchWeek/lw6_days.json b/apps/www/components/LaunchWeek/lw6_days.json new file mode 100644 index 00000000000..ff86205a2da --- /dev/null +++ b/apps/www/components/LaunchWeek/lw6_days.json @@ -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": [] + } +] diff --git a/apps/www/components/LaunchWeek/types.d.ts b/apps/www/components/LaunchWeek/types.d.ts index 767d9d57111..fbea7cf9d0f 100644 --- a/apps/www/components/LaunchWeek/types.d.ts +++ b/apps/www/components/LaunchWeek/types.d.ts @@ -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[] } diff --git a/apps/www/pages/blog/[slug].tsx b/apps/www/pages/blog/[slug].tsx index a82e6ba33cd..36ac58de88c 100644 --- a/apps/www/pages/blog/[slug].tsx +++ b/apps/www/pages/blog/[slug].tsx @@ -227,17 +227,32 @@ function BlogPostPage(props: any) {
{/* Content */}
- {props.blog.thumb && ( -
- +
+
+ {props.blog.youtubeHero ? ( + + ) : ( + props.blog.thumb && ( +
+ +
+ ) + )} +
- )} -
-
diff --git a/apps/www/pages/launch-week/index.tsx b/apps/www/pages/launch-week/index.tsx index 9a3945146f4..56ec471ef57 100644 --- a/apps/www/pages/launch-week/index.tsx +++ b/apps/www/pages/launch-week/index.tsx @@ -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(null) const [creators, setCreators] = useState([]) const [activeCreator, setActiveCreator] = useState(null) @@ -82,6 +89,46 @@ export default function launchweek() { } } + const AccordionHeader = ({ date, day, title, shipped }: any) => { + return ( +
+
+ + {shipped ? 'Shipped' : 'Coming Soon'} + + + {day} ・ {date} + +
+ {title} +
+ ) + } + + const SectionButtons = ({ blog, docs }) => { + return ( + + ) + } + const [day1, day2, day3, day4, day5] = days + return ( <> - +
-

Dec 12 – 16 at 8 AM PT | 11 AM ET

-
-
- +

Dec 12 – 16 at 6 AM PT | 9 AM ET

+ {process.env.NEXT_PUBLIC_LW_STARTED && ( +
+ +
+ )}
+ {process.env.NEXT_PUBLIC_LW_STARTED && ( + <> + +
+
+
+ +
+
+ Who we hire at Supabase + Fireside chat with founders +
+
+ +
+
+ + +
+ + } + disabled={!day1.shipped} + className="h-[79px]" + id={day1.d.toString()} + > + {day1.steps.length > 0 && ( +
+
+
+ {day1.description} + + Redesigned + +
+ + +
+
+ )} +
+
+
+ + } + disabled={!day2.shipped} + className="h-[79px]" + id={day2.d.toString()} + > + {day2.steps.length > 0 && ( +
+
+
+
{day2.steps[0].title}
+ + New + +
+ +
+
+
+ + New + + {day2.steps[1].title} +

{day2.steps[1].description}

+
+ +
+
+ )} +
+
+
+ + } + disabled={!day3.shipped} + className="h-[79px]" + id={day3.d.toString()} + > + {day3.steps.length > 0 && ( +
+
+
+ {day3.steps[0].title} + + New + +
+ +
+
+ {/* inset 0px 130px 50px -52px #121f1e; */} +
+ + New + + {day3.steps[1].title} +
+ +
+
+ )} +
+
+
+ + } + disabled={!day4.shipped} + className="h-[79px]" + id={day4.d.toString()} + > + {day4.steps.length > 0 && ( +
+
+
+ {day4.steps[0].title} + + Updated + +
+ +
+
+ )} +
+
+
+ + } + disabled={!day5.shipped} + className="h-[79px]" + id={day5.d.toString()} + > + {day5.steps.length > 0 && ( +
+
+
+ {day5.steps[0].title} + + New + +
+ +
+
+
+ + New + + {day5.steps[1].title} +
+ +
+
+ )} +
+
+
+
+ + )} + +
+ + Happening all week + +

Launch Week Hackathon

+

+ 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{' '} + + blog post + + . +

+
+
+
+
+

Prizes

+

+ There are 5 categories to win, with prizes for winners and runner-ups of each + category. Each team member gets a prize. +

+
+
+

Judges

+

+ The Supabase team will judge all the categories except the Best Edge Functions + Project, which will be judged by our friends at Deno. +

+
+
+
+

Community

+

+ 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 + +
+
+
+

Submission

+

+ Submit your project through{' '} + + madewithsupabase.com + + . All submissions must be open source and publically available. Submissions close + Monday 19th Dec 00:01 AM PT. +

+
+
+ +
+
- Currently happening + Live -

The Supabase Content Storm

+

The Supabase Content Storm

- 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! -

+
See all the content
@@ -211,7 +652,10 @@ export default function launchweek() { ? `${creators[activeCreator].first_name} ${creators[activeCreator].last_name}` : 'Title'} -

+ {activeCreator !== null && ( +

{creators[activeCreator].description}

+ )} +

{creators[activeCreator].link_title} diff --git a/apps/www/pages/launch-week/launchWeek.module.css b/apps/www/pages/launch-week/launchWeek.module.css index 574027b2053..c37b14deb0e 100644 --- a/apps/www/pages/launch-week/launchWeek.module.css +++ b/apps/www/pages/launch-week/launchWeek.module.css @@ -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); +} diff --git a/apps/www/public/images/blog/crdt/array.png b/apps/www/public/images/blog/crdt/array.png new file mode 100644 index 00000000000..49e8efc7710 Binary files /dev/null and b/apps/www/public/images/blog/crdt/array.png differ diff --git a/apps/www/public/images/blog/crdt/authority.png b/apps/www/public/images/blog/crdt/authority.png new file mode 100644 index 00000000000..8ed4f6b10fd Binary files /dev/null and b/apps/www/public/images/blog/crdt/authority.png differ diff --git a/apps/www/public/images/blog/crdt/crdt-blog.jpg b/apps/www/public/images/blog/crdt/crdt-blog.jpg new file mode 100644 index 00000000000..99c7021c2dd Binary files /dev/null and b/apps/www/public/images/blog/crdt/crdt-blog.jpg differ diff --git a/apps/www/public/images/blog/crdt/crdt-blog.png b/apps/www/public/images/blog/crdt/crdt-blog.png new file mode 100644 index 00000000000..4d4cc7418d1 Binary files /dev/null and b/apps/www/public/images/blog/crdt/crdt-blog.png differ diff --git a/apps/www/public/images/blog/crdt/semi-realtime.png b/apps/www/public/images/blog/crdt/semi-realtime.png new file mode 100644 index 00000000000..6d3b2a51d72 Binary files /dev/null and b/apps/www/public/images/blog/crdt/semi-realtime.png differ diff --git a/apps/www/public/images/blog/how-it-started.jpg b/apps/www/public/images/blog/how-it-started.jpg new file mode 100644 index 00000000000..2bd11dfb68b Binary files /dev/null and b/apps/www/public/images/blog/how-it-started.jpg differ diff --git a/apps/www/public/images/blog/lw6-hackathon/keyboard.png b/apps/www/public/images/blog/lw6-hackathon/keyboard.png new file mode 100644 index 00000000000..61061a1239a Binary files /dev/null and b/apps/www/public/images/blog/lw6-hackathon/keyboard.png differ diff --git a/apps/www/public/images/blog/lw6-hackathon/swagkit.png b/apps/www/public/images/blog/lw6-hackathon/swagkit.png new file mode 100644 index 00000000000..dc375783452 Binary files /dev/null and b/apps/www/public/images/blog/lw6-hackathon/swagkit.png differ diff --git a/apps/www/public/images/blog/lw6-hackathon/thumbnail.jpg b/apps/www/public/images/blog/lw6-hackathon/thumbnail.jpg new file mode 100644 index 00000000000..4566dc72cf8 Binary files /dev/null and b/apps/www/public/images/blog/lw6-hackathon/thumbnail.jpg differ diff --git a/apps/www/public/images/blog/who-we-hire-cover.jpg b/apps/www/public/images/blog/who-we-hire-cover.jpg new file mode 100644 index 00000000000..80615372a87 Binary files /dev/null and b/apps/www/public/images/blog/who-we-hire-cover.jpg differ diff --git a/apps/www/public/images/blog/who-we-hire.jpg b/apps/www/public/images/blog/who-we-hire.jpg new file mode 100644 index 00000000000..484b7729020 Binary files /dev/null and b/apps/www/public/images/blog/who-we-hire.jpg differ diff --git a/apps/www/public/images/launchweek/ant.png b/apps/www/public/images/launchweek/ant.png new file mode 100644 index 00000000000..c0413ad7765 Binary files /dev/null and b/apps/www/public/images/launchweek/ant.png differ diff --git a/apps/www/public/images/launchweek/antcopple.png b/apps/www/public/images/launchweek/antcopple.png new file mode 100644 index 00000000000..769cd8b3b4f Binary files /dev/null and b/apps/www/public/images/launchweek/antcopple.png differ diff --git a/apps/www/public/images/launchweek/blog-icon.svg b/apps/www/public/images/launchweek/blog-icon.svg new file mode 100644 index 00000000000..7540a84898d --- /dev/null +++ b/apps/www/public/images/launchweek/blog-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/www/public/images/launchweek/cdn-caching-bg-light.png b/apps/www/public/images/launchweek/cdn-caching-bg-light.png new file mode 100644 index 00000000000..6fad05721a2 Binary files /dev/null and b/apps/www/public/images/launchweek/cdn-caching-bg-light.png differ diff --git a/apps/www/public/images/launchweek/cdn-caching-bg.png b/apps/www/public/images/launchweek/cdn-caching-bg.png new file mode 100644 index 00000000000..bab530bd1ce Binary files /dev/null and b/apps/www/public/images/launchweek/cdn-caching-bg.png differ diff --git a/apps/www/public/images/launchweek/copple.png b/apps/www/public/images/launchweek/copple.png new file mode 100644 index 00000000000..18fbefa8aad Binary files /dev/null and b/apps/www/public/images/launchweek/copple.png differ diff --git a/apps/www/public/images/launchweek/docs-update-bg.png b/apps/www/public/images/launchweek/docs-update-bg.png new file mode 100644 index 00000000000..12de95b1b48 Binary files /dev/null and b/apps/www/public/images/launchweek/docs-update-bg.png differ diff --git a/apps/www/public/images/launchweek/encryption-bg-light.png b/apps/www/public/images/launchweek/encryption-bg-light.png new file mode 100644 index 00000000000..9de76cf9127 Binary files /dev/null and b/apps/www/public/images/launchweek/encryption-bg-light.png differ diff --git a/apps/www/public/images/launchweek/encryption-bg.png b/apps/www/public/images/launchweek/encryption-bg.png new file mode 100644 index 00000000000..01dff450301 Binary files /dev/null and b/apps/www/public/images/launchweek/encryption-bg.png differ diff --git a/apps/www/public/images/launchweek/encryption-bg.svg b/apps/www/public/images/launchweek/encryption-bg.svg new file mode 100644 index 00000000000..4dabc6af972 --- /dev/null +++ b/apps/www/public/images/launchweek/encryption-bg.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/www/public/images/launchweek/image-processing-bg-light.png b/apps/www/public/images/launchweek/image-processing-bg-light.png new file mode 100644 index 00000000000..72eed0b732a Binary files /dev/null and b/apps/www/public/images/launchweek/image-processing-bg-light.png differ diff --git a/apps/www/public/images/launchweek/image-processing-bg.png b/apps/www/public/images/launchweek/image-processing-bg.png new file mode 100644 index 00000000000..ae3334d8502 Binary files /dev/null and b/apps/www/public/images/launchweek/image-processing-bg.png differ diff --git a/apps/www/public/images/launchweek/link.svg b/apps/www/public/images/launchweek/link.svg new file mode 100644 index 00000000000..940f584166d --- /dev/null +++ b/apps/www/public/images/launchweek/link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/www/public/images/launchweek/mfa-bg-light.png b/apps/www/public/images/launchweek/mfa-bg-light.png new file mode 100644 index 00000000000..6ee535ba251 Binary files /dev/null and b/apps/www/public/images/launchweek/mfa-bg-light.png differ diff --git a/apps/www/public/images/launchweek/mfa-bg.png b/apps/www/public/images/launchweek/mfa-bg.png new file mode 100644 index 00000000000..7ab2738a2e5 Binary files /dev/null and b/apps/www/public/images/launchweek/mfa-bg.png differ diff --git a/apps/www/public/images/launchweek/pitr-bg-light.png b/apps/www/public/images/launchweek/pitr-bg-light.png new file mode 100644 index 00000000000..5960c9314bd Binary files /dev/null and b/apps/www/public/images/launchweek/pitr-bg-light.png differ diff --git a/apps/www/public/images/launchweek/pitr-bg.png b/apps/www/public/images/launchweek/pitr-bg.png new file mode 100644 index 00000000000..efc950051d4 Binary files /dev/null and b/apps/www/public/images/launchweek/pitr-bg.png differ diff --git a/apps/www/public/images/launchweek/vault-bg-light.png b/apps/www/public/images/launchweek/vault-bg-light.png new file mode 100644 index 00000000000..50ae3c9462f Binary files /dev/null and b/apps/www/public/images/launchweek/vault-bg-light.png differ diff --git a/apps/www/public/images/launchweek/vault-bg.png b/apps/www/public/images/launchweek/vault-bg.png new file mode 100644 index 00000000000..2b927dcbab8 Binary files /dev/null and b/apps/www/public/images/launchweek/vault-bg.png differ diff --git a/apps/www/public/images/launchweek/video-icon.svg b/apps/www/public/images/launchweek/video-icon.svg new file mode 100644 index 00000000000..e2a3438dfae --- /dev/null +++ b/apps/www/public/images/launchweek/video-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/www/public/images/launchweek/wrappers-bg-light.png b/apps/www/public/images/launchweek/wrappers-bg-light.png new file mode 100644 index 00000000000..e70745d217c Binary files /dev/null and b/apps/www/public/images/launchweek/wrappers-bg-light.png differ diff --git a/apps/www/public/images/launchweek/wrappers-bg.png b/apps/www/public/images/launchweek/wrappers-bg.png new file mode 100644 index 00000000000..cef57946f8b Binary files /dev/null and b/apps/www/public/images/launchweek/wrappers-bg.png differ diff --git a/apps/www/public/images/launchweek/wrappers-bg.svg b/apps/www/public/images/launchweek/wrappers-bg.svg new file mode 100644 index 00000000000..8698edaf7d2 --- /dev/null +++ b/apps/www/public/images/launchweek/wrappers-bg.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/www/public/rss.xml b/apps/www/public/rss.xml index 146122ccf83..625ea895d5f 100644 --- a/apps/www/public/rss.xml +++ b/apps/www/public/rss.xml @@ -5,9 +5,30 @@ https://supabase.com Latest news from Supabase en - Tue, 06 Dec 2022 16:04:00 +0000 + Fri, 09 Dec 2022 16:04:00 +0000 + https://supabase.com/blog/postgres-crdt + pg_crdt - an experimental CRDT extension for Postgres + https://supabase.com/blog/postgres-crdt + Embedding Yjs and Automerge into Postgres for collaborative applications. + Fri, 09 Dec 2022 16:04:00 +0000 + + + https://supabase.com/blog/who-we-hire + Who We Hire at Supabase + https://supabase.com/blog/who-we-hire + Traits we look for to maintain a culture of shipping fast and often + Thu, 08 Dec 2022 16:04:00 +0000 + + + https://supabase.com/blog/launch-week-6-hackathon + Launch Week 6 Hackathon + https://supabase.com/blog/launch-week-6-hackathon + Build an Open Source Project, Win $1500 and the Supabase Darkmode Keyboard + Thu, 08 Dec 2022 16:04:00 +0000 + + https://supabase.com/blog/supabase-beta-november-2022 Supabase Beta November 2022 https://supabase.com/blog/supabase-beta-november-2022 diff --git a/apps/www/public/sitemap_www.xml b/apps/www/public/sitemap_www.xml index 55bd4d51e8d..358f85668fe 100644 --- a/apps/www/public/sitemap_www.xml +++ b/apps/www/public/sitemap_www.xml @@ -84,12 +84,6 @@ 0.5 - - https://supabase.com/launch-week/ticket-image - weekly - 0.5 - - https://supabase.com/legal/dpa weekly @@ -102,6 +96,12 @@ 0.5 + + https://supabase.com/launch-week/ticket-image + weekly + 0.5 + + https://supabase.com/nextjs weekly @@ -938,6 +938,24 @@ 0.5 + + https://supabase.com/blog/launch-week-6-hackathon + weekly + 0.5 + + + + https://supabase.com/blog/who-we-hire + weekly + 0.5 + + + + https://supabase.com/blog/postgres-crdt + weekly + 0.5 + + https://supabase.com/alternatives/supabase-vs-firebase weekly diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 7d041164e75..949b5971275 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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 diff --git a/packages/ui/src/components/Accordion/Accordion.tsx b/packages/ui/src/components/Accordion/Accordion.tsx index 3b40089684a..4a802c44ed6 100644 --- a/packages/ui/src/components/Accordion/Accordion.tsx +++ b/packages/ui/src/components/Accordion/Accordion.tsx @@ -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 ( - + { + setOpen(!open) + }} + > {header} - + {!disabled && ( + + )}

diff --git a/packages/ui/src/components/Modal/Modal.tsx b/packages/ui/src/components/Modal/Modal.tsx index 9cf8a159a47..4c536622e39 100644 --- a/packages/ui/src/components/Modal/Modal.tsx +++ b/packages/ui/src/components/Modal/Modal.tsx @@ -115,13 +115,14 @@ const Modal = ({ customFooter ) : ( -
{addonUpdateDisabled ? ( + ) : currentComputeSize?.isLocked ? ( + ) : ( {computeSizes.map((option: any) => { @@ -75,7 +100,9 @@ const ComputeSizeSelection: FC = ({ label="Need a larger add on?" description="Reach out to us - we've got you covered!" optionalLabel={ - + diff --git a/studio/components/interfaces/Billing/AddOns/CustomDomainSelection.tsx b/studio/components/interfaces/Billing/AddOns/CustomDomainSelection.tsx index a43d9f17f34..8e5515013f9 100644 --- a/studio/components/interfaces/Billing/AddOns/CustomDomainSelection.tsx +++ b/studio/components/interfaces/Billing/AddOns/CustomDomainSelection.tsx @@ -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 = ({ selectedOption, onSelectOption, }) => { + const { ref } = useParams() const addonUpdateDisabled = useFlag('disableProjectCreationAndUpdate') + return (
@@ -30,6 +34,31 @@ const CustomDomainSelection: FC = ({
{addonUpdateDisabled ? ( + ) : currentOption?.isLocked ? ( +
+
+
+ Your project currently has custom domains included +
+

+ If you would like to disable custom domains, do reach out to us +

+
+ +
) : ( {options.map((option: any) => { diff --git a/studio/components/interfaces/Billing/AddOns/PITRDurationSelection.tsx b/studio/components/interfaces/Billing/AddOns/PITRDurationSelection.tsx index ec5ed6bf2a6..7f3a6f2e9ed 100644 --- a/studio/components/interfaces/Billing/AddOns/PITRDurationSelection.tsx +++ b/studio/components/interfaces/Billing/AddOns/PITRDurationSelection.tsx @@ -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 = ({

Restore your database from a specific point in time

- {canUsePITR && ( + {canUsePITR && !currentPitrDuration?.isLocked && (
} @@ -72,6 +72,29 @@ const PITRDurationSelection: FC = ({ } icon={} /> + ) : currentPitrDuration?.isLocked ? ( +
+
+
+ Your project currently has {currentPitrDuration.name} included +
+

+ If you would like to change your PITR duration, do reach out to us +

+
+ +
) : ( {pitrDurationOptions.map((option: any) => { diff --git a/studio/components/interfaces/Billing/AddOns/SupportPlan.tsx b/studio/components/interfaces/Billing/AddOns/SupportPlan.tsx new file mode 100644 index 00000000000..56f22afce68 --- /dev/null +++ b/studio/components/interfaces/Billing/AddOns/SupportPlan.tsx @@ -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 = ({ currentOption }) => { + const { ref } = useParams() + return ( +
+
+
+

Support Plan

+
+

Designated support with guaranteed response times

+
+
+
+
+ Your project is currently on {currentOption.name} +
+

+ If you would like to change your support plan, do reach out to us +

+
+ +
+
+ ) +} + +export default SupportPlan diff --git a/studio/components/interfaces/Billing/Billing.utils.ts b/studio/components/interfaces/Billing/Billing.utils.ts index d6d21a9652f..84e1e448694 100644 --- a/studio/components/interfaces/Billing/Billing.utils.ts +++ b/studio/components/interfaces/Billing/Billing.utils.ts @@ -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 } } diff --git a/studio/components/interfaces/Billing/EnterpriseUpdate.tsx b/studio/components/interfaces/Billing/EnterpriseUpdate.tsx index 504d5931f8a..1755c1dc42e 100644 --- a/studio/components/interfaces/Billing/EnterpriseUpdate.tsx +++ b/studio/components/interfaces/Billing/EnterpriseUpdate.tsx @@ -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 = ({ // [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
component to handle all of these. Mainly to reduce the amount // of unnecessary state management on this complex page. - const [selectedComputeSize, setSelectedComputeSize] = useState( + const [selectedComputeSize, setSelectedComputeSize] = useState( currentAddons.computeSize ) - const [selectedPITRDuration, setSelectedPITRDuration] = useState( + const [selectedPITRDuration, setSelectedPITRDuration] = useState( currentAddons.pitrDuration ) - const [selectedCustomDomainOption, setSelectedCustomDomainOption] = useState( + const [selectedCustomDomainOption, setSelectedCustomDomainOption] = useState( 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 = ({ useEffect(() => { getSubscriptionPreview() - }, [selectedComputeSize, selectedPITRDuration]) + }, [selectedComputeSize, selectedPITRDuration, selectedCustomDomainOption]) useEffect(() => { if (!isLoadingPaymentMethods && paymentMethods && paymentMethods.length > 0) { @@ -99,8 +105,10 @@ const EnterpriseUpdate: FC = ({ // Only allow add-ons changing const payload = { ...formSubscriptionUpdatePayload( + currentSubscription, null, selectedAddons, + nonChangeableAddons, selectedPaymentMethodId, projectRegion ), @@ -123,8 +131,10 @@ const EnterpriseUpdate: FC = ({ const onConfirmPayment = async () => { const payload = { ...formSubscriptionUpdatePayload( + currentSubscription, null, selectedAddons, + nonChangeableAddons, selectedPaymentMethodId, projectRegion ), @@ -199,6 +209,12 @@ const EnterpriseUpdate: FC = ({
{projectRegion !== 'af-south-1' && ( <> + {currentAddons.supportPlan !== undefined && ( + <> + + + + )} {isCustomDomainsEnabled && customDomainOptions.length > 0 && ( <> @@ -239,6 +255,7 @@ const EnterpriseUpdate: FC = ({ isSpendCapEnabled={true} isSubmitting={isSubmitting} isRefreshingPreview={isRefreshingPreview} + currentSubscription={currentSubscription} subscriptionPreview={subscriptionPreview} // Current subscription configuration based on DB currentPlan={currentSubscription.tier} diff --git a/studio/components/interfaces/Billing/PaymentSummaryPanel/PaymentSummaryPanel.tsx b/studio/components/interfaces/Billing/PaymentSummaryPanel/PaymentSummaryPanel.tsx index 7405534e4d2..078d2a067d2 100644 --- a/studio/components/interfaces/Billing/PaymentSummaryPanel/PaymentSummaryPanel.tsx +++ b/studio/components/interfaces/Billing/PaymentSummaryPanel/PaymentSummaryPanel.tsx @@ -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 = ({ isRefreshingPreview, isSpendCapEnabled, + currentSubscription, subscriptionPreview, currentPlan, @@ -107,6 +111,15 @@ const PaymentSummaryPanel: FC = ({ } 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 = ({ {/* Add on details */} {projectRegion !== 'af-south-1' && (
-

Selected add-ons

+

Selected add-ons

+ + {/* Compute size */}

= ({

- ${(getProductPrice(currentAddons.computeSize).unit_amount / 100).toFixed(2)} + ${getAddonPriceFromSubscription('computeSize')}

{isChangingComputeSize && ( @@ -175,6 +190,8 @@ const PaymentSummaryPanel: FC = ({

)} + + {/* PITR Duration */} {currentAddons.pitrDuration?.id !== undefined && (

= ({ isChangingPITRDuration ? 'text-scale-1100 line-through' : '' } text-sm`} > - ${(getProductPrice(currentAddons.pitrDuration).unit_amount / 100).toFixed(2)} + ${getAddonPriceFromSubscription('pitrDuration')}

)} @@ -201,6 +218,8 @@ const PaymentSummaryPanel: FC = ({

)} + + {/* Custom Domains */} {currentAddons.customDomains?.id !== undefined && (

= ({ isChangingCustomDomains ? 'text-scale-1100 line-through' : '' } text-sm`} > - ${(getProductPrice(currentAddons.customDomains).unit_amount / 100).toFixed(2)} + ${getAddonPriceFromSubscription('customDomains')}

)} @@ -228,6 +247,14 @@ const PaymentSummaryPanel: FC = ({
)} + {/* Support Plan */} + {currentAddons.supportPlan !== undefined && ( +
+

{currentAddons.supportPlan?.name}

+

${getAddonPriceFromSubscription('supportPlan')}

+
+ )} + {/* Show information boxes with regards to change in subscriptions */}
{isChangingPITRDuration && ( diff --git a/studio/components/interfaces/Billing/PaymentSummaryPanel/PaymentSummaryPanel.utils.ts b/studio/components/interfaces/Billing/PaymentSummaryPanel/PaymentSummaryPanel.utils.ts index a6088970839..dab0fd5c88f 100644 --- a/studio/components/interfaces/Billing/PaymentSummaryPanel/PaymentSummaryPanel.utils.ts +++ b/studio/components/interfaces/Billing/PaymentSummaryPanel/PaymentSummaryPanel.utils.ts @@ -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]) diff --git a/studio/components/interfaces/Billing/ProUpgrade.tsx b/studio/components/interfaces/Billing/ProUpgrade.tsx index 5ff559adc1a..2a9f6134004 100644 --- a/studio/components/interfaces/Billing/ProUpgrade.tsx +++ b/studio/components/interfaces/Billing/ProUpgrade.tsx @@ -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 = ({ // [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 component to handle all of these. Mainly to reduce the amount // of unnecessary state management on this complex page. - const [selectedComputeSize, setSelectedComputeSize] = useState( + const [selectedComputeSize, setSelectedComputeSize] = useState( currentAddons.computeSize ) - const [selectedPITRDuration, setSelectedPITRDuration] = useState( + const [selectedPITRDuration, setSelectedPITRDuration] = useState( currentAddons.pitrDuration ) - const [selectedCustomDomainOption, setSelectedCustomDomainOption] = useState( + const [selectedCustomDomainOption, setSelectedCustomDomainOption] = useState( 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 = ({ if (!selectedTier) return const payload = formSubscriptionUpdatePayload( + currentSubscription, selectedTier, selectedAddons, + nonChangeableAddons, selectedPaymentMethodId, projectRegion ) @@ -141,8 +150,10 @@ const ProUpgrade: FC = ({ const onConfirmPayment = async () => { const payload = formSubscriptionUpdatePayload( + currentSubscription, selectedTier, selectedAddons, + nonChangeableAddons, selectedPaymentMethodId, projectRegion ) @@ -242,6 +253,12 @@ const ProUpgrade: FC = ({
{projectRegion !== 'af-south-1' && ( <> + {currentAddons.supportPlan !== undefined && ( + <> + + + + )} {isCustomDomainsEnabled && customDomainOptions.length > 0 && ( <> @@ -289,6 +306,7 @@ const ProUpgrade: FC = ({ // Current subscription configuration based on DB currentPlan={currentSubscription.tier} currentAddons={currentAddons} + currentSubscription={currentSubscription} // Selected subscription configuration based on UI selectedPlan={selectedTier} selectedAddons={selectedAddons} diff --git a/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx b/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx index 1e871404386..46c5105d027 100644 --- a/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx +++ b/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx @@ -54,34 +54,36 @@ const EnableExtensionModal: FC = ({ 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({ diff --git a/studio/components/interfaces/Database/Extensions/Extensions.constants.ts b/studio/components/interfaces/Database/Extensions/Extensions.constants.ts index 0816f74ffca..6d8dfbb1e66 100644 --- a/studio/components/interfaces/Database/Extensions/Extensions.constants.ts +++ b/studio/components/interfaces/Database/Extensions/Extensions.constants.ts @@ -11,4 +11,5 @@ export const HIDDEN_EXTENSIONS = [ 'pg_surgery', 'pg_visibility', 'pgstattuple', + 'supabase_vault', ] diff --git a/studio/components/interfaces/Database/Extensions/Extensions.tsx b/studio/components/interfaces/Database/Extensions/Extensions.tsx index 67c74ed127b..95c061d1370 100644 --- a/studio/components/interfaces/Database/Extensions/Extensions.tsx +++ b/studio/components/interfaces/Database/Extensions/Extensions.tsx @@ -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 = ({}) => { const { meta } = useStore() + const { filter } = useParams() const [filterString, setFilterString] = useState('') - 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 = ({}) => { 'extensions' ) + useEffect(() => { + if (filter !== undefined) setFilterString(filter as string) + }, [filter]) + return (
diff --git a/studio/components/interfaces/Database/Tables/ColumnList.tsx b/studio/components/interfaces/Database/Tables/ColumnList.tsx index 17e2a6f20c3..add64c1ac20 100644 --- a/studio/components/interfaces/Database/Tables/ColumnList.tsx +++ b/studio/components/interfaces/Database/Tables/ColumnList.tsx @@ -96,10 +96,10 @@ const ColumnList: FC = ({ )} - {x.data_type} + {x.data_type} - {x.format} + {x.format}
diff --git a/studio/components/interfaces/Settings/API/ServiceList.tsx b/studio/components/interfaces/Settings/API/ServiceList.tsx index f1dcce06dca..aba88820aac 100644 --- a/studio/components/interfaces/Settings/API/ServiceList.tsx +++ b/studio/components/interfaces/Settings/API/ServiceList.tsx @@ -60,46 +60,44 @@ const ServiceList: FC = ({ projectRef }) => { const apiConfig = apiService?.app_config return ( - <> -
-

API Settings

-
- Project URL}> - - {isError ? ( -
- -

Failed to retrieve project URL

-
- ) : ( - - )} -
-
-
+
+

API Settings

+
+ Project URL}> + + {isError ? ( +
+ +

Failed to retrieve project URL

+
+ ) : ( + + )} +
+
+
-
- -
+
+ +
-
- -
+
+ +
-
- -
-
- +
+ +
+
) } diff --git a/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeySelector.tsx b/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeySelector.tsx new file mode 100644 index 00000000000..64aeac9c118 --- /dev/null +++ b/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeySelector.tsx @@ -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 = ({ + id = 'keyId', + nameId = 'keyName', + label = 'Encryption key', + labelOptional, + selectedKeyId, + error, + onSelectKey = () => {}, + onUpdateDescription = () => {}, +}) => { + const { vault } = useStore() + const keys = vault.listKeys() + + return ( + <> + + } + > + Create a new Encryption Key + + + {keys.map((key) => ( + +
+

{key.name || 'No name provided'}

+

+ ID: {key.id} +

+
+
+ ))} +
+ {selectedKeyId === 'create-new' && ( + onUpdateDescription(event.target.value)} + /> + )} + + ) +} + +export default EncryptionKeySelector diff --git a/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeysManagement.tsx b/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeysManagement.tsx new file mode 100644 index 00000000000..423cfcb3775 --- /dev/null +++ b/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeysManagement.tsx @@ -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 = ({}) => { + const { vault, ui } = useStore() + const { id } = useParams() + + const [searchValue, setSearchValue] = useState('') + const [selectedSort, setSelectedSort] = useState<'name' | 'created'>('created') + const [showAddKeyModal, setShowAddKeyModal] = useState(false) + const [selectedKeyToRemove, setSelectedKeyToRemove] = useState() + 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 ( + <> +
+
+
+ setSearchValue(event.target.value)} + icon={} + actions={ + searchValue.length > 0 + ? [ +
+ +
+ + {/* Table of keys */} +
+ {!vault.isLoaded ? ( +
+ +

Loading keys from the Vault

+
+ ) : ( + <> + {keys.map((key, idx) => { + return ( + +
+ +
+

+ {key.name || DEFAULT_KEY_NAME} +

+

+ ID: {key.id} +

+
+
+ {key.name === 'default_vault_key' && Default} +
+
+

+ Added on {dayjs(key.created).format('MMM D, YYYY')} +

+ + +
+
+ {idx !== keys.length - 1 && } +
+ ) + })} + {keys.length === 0 && ( + <> + {searchValue.length === 0 ? ( +
+

No encryption keys added yet

+

+ Encryption keys are created by the pgsodium extension and can be used to + encrypt your columns and secrets +

+
+ ) : ( +
+

No results found

+

+ Your search for "{searchValue}" did not return any results +

+
+ )} + + )} + + )} +
+
+ + setSelectedKeyToRemove(undefined)} + onConfirm={confirmDeleteKey} + loading={isDeletingKey} + header={
Confirm to delete key
} + > +
+ +
+ + Do ensure that the key is not currently in use to prevent any issues. + +

+ The following key will be permanently removed and cannot be recovered. +

+
+

+ {selectedKeyToRemove?.name ?? DEFAULT_KEY_NAME} +

+

+ ID: {selectedKeyToRemove?.id} +

+
+
+
+
+
+ + setShowAddKeyModal(false)} + header={
Add a new key
} + > + { + const errors: any = {} + if (values.name.length === 0) errors.name = 'Please provide a name for your key' + return errors + }} + onSubmit={addKey} + > + {({ isSubmitting }: any) => { + return ( +
+ +

+ Provide a name for your key for easier identification. +

+
+ +
+
+ + +
+ + +
+
+
+ ) + }} + +
+ + ) +} + +export default observer(EncryptionKeysManagement) diff --git a/studio/components/interfaces/Settings/Vault/Secrets/AddNewSecretModal.tsx b/studio/components/interfaces/Settings/Vault/Secrets/AddNewSecretModal.tsx new file mode 100644 index 00000000000..f43299a8672 --- /dev/null +++ b/studio/components/interfaces/Settings/Vault/Secrets/AddNewSecretModal.tsx @@ -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 = ({ visible, onClose }) => { + const { vault, ui } = useStore() + const [showSecretValue, setShowSecretValue] = useState(false) + const [selectedKeyId, setSelectedKeyId] = useState() + + 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 ( + Add new secret} + > +
+ {({ isSubmitting }: any) => { + return ( +
+ +
+ + + +
+ } + /> +
+ + + +
+ + } + url="asd" + urlLabel="Vault documentation" + title="What is a key?" + description={ +
+

+ 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. +

+

+ However, you may also use a custom key for more advanced use cases, such + as having different secrets visible to different users +

+
+ } + /> +
+
+ + +
+ + +
+
+
+ ) + }} + + + ) +} + +export default AddNewSecretModal diff --git a/studio/components/interfaces/Settings/Vault/Secrets/DeleteSecretModal.tsx b/studio/components/interfaces/Settings/Vault/Secrets/DeleteSecretModal.tsx new file mode 100644 index 00000000000..54c30e7c3bc --- /dev/null +++ b/studio/components/interfaces/Settings/Vault/Secrets/DeleteSecretModal.tsx @@ -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 = ({ 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 ( + Confirm to delete secret} + > +
+ +
+

+ The following secret will be permanently removed and cannot be recovered. Are you + sure? +

+
+

{selectedSecret?.description}

+

+ ID: {selectedSecret?.key_id} +

+
+
+
+
+
+ ) +} + +export default DeleteSecretModal diff --git a/studio/components/interfaces/Settings/Vault/Secrets/EditSecretModal.tsx b/studio/components/interfaces/Settings/Vault/Secrets/EditSecretModal.tsx new file mode 100644 index 00000000000..d089a2f4a33 --- /dev/null +++ b/studio/components/interfaces/Settings/Vault/Secrets/EditSecretModal.tsx @@ -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 = ({ selectedSecret, onClose }) => { + const { ui, vault } = useStore() + const [selectedKeyId, setSelectedKeyId] = useState() + 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 = {} + 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 ( + Edit secret} + > +
+ {({ 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 ? ( +
+ +
+ +
+
+ +
+
+ ) : ( +
+ +
+ + + +
+ } + /> +
+ + + +
+ +
+
+ + +
+ + +
+
+
+ ) + }} + + + ) +} + +export default EditSecretModal diff --git a/studio/components/interfaces/Settings/Vault/Secrets/SecretRow.tsx b/studio/components/interfaces/Settings/Vault/Secrets/SecretRow.tsx new file mode 100644 index 00000000000..412435028c3 --- /dev/null +++ b/studio/components/interfaces/Settings/Vault/Secrets/SecretRow.tsx @@ -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 = ({ secret, onSelectEdit, onSelectRemove }) => { + const { vault } = useStore() + const { ref } = useParams() + + const [isLoading, setIsLoading] = useState(false) + const [revealedValue, setRevealedValue] = useState() + 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 ( +
+
+
+

+ {name} +

+ {secret.description !== undefined && ( +

+ {secret.description} +

+ )} +
+ +
+
+
+
+

+ {secret.updated_at === secret.created_at ? 'Added' : 'Updated'} on{' '} + {dayjs(secret.updated_at).format('MMM D, YYYY')} +

+ + } onClick={() => onSelectEdit(secret)}> + Edit + + } + onClick={() => onSelectRemove(secret)} + > + Delete + + + } + > +
+
+ ) +} + +export default SecretRow diff --git a/studio/components/interfaces/Settings/Vault/Secrets/SecretsManagement.tsx b/studio/components/interfaces/Settings/Vault/Secrets/SecretsManagement.tsx new file mode 100644 index 00000000000..de6ab42ae2e --- /dev/null +++ b/studio/components/interfaces/Settings/Vault/Secrets/SecretsManagement.tsx @@ -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 = ({}) => { + const { vault } = useStore() + const [searchValue, setSearchValue] = useState('') + const [selectedSort, setSelectedSort] = useState<'updated_at' | 'name'>('updated_at') + const [showAddSecretModal, setShowAddSecretModal] = useState(false) + const [selectedSecretToEdit, setSelectedSecretToEdit] = useState() + const [selectedSecretToRemove, setSelectedSecretToRemove] = useState() + + 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 ( + <> +
+
+
+ setSearchValue(event.target.value)} + icon={} + /> +
+ + + Updated at + + + Name + + + Key ID + + +
+
+ +
+ + {/* Table of secrets */} +
+ {!vault.isLoaded ? ( +
+ +

Loading secrets from the Vault

+
+ ) : ( + <> + {secrets.map((secret, idx) => { + return ( + + + {idx !== secrets.length - 1 && } + + ) + })} + {secrets.length === 0 && ( + <> + {searchValue.length === 0 ? ( +
+

No secrets added yet

+

+ The Vault allows you to store sensitive information like API keys +

+
+ ) : ( +
+

No results found

+

+ Your search for "{searchValue}" did not return any results +

+
+ )} + + )} + + )} +
+
+ + setSelectedSecretToEdit(undefined)} + /> + setSelectedSecretToRemove(undefined)} + /> + setShowAddSecretModal(false)} + /> + + ) +} + +export default observer(SecretsManagement) diff --git a/studio/components/interfaces/Settings/Vault/VaultToggle.tsx b/studio/components/interfaces/Settings/Vault/VaultToggle.tsx new file mode 100644 index 00000000000..1fdd70b2156 --- /dev/null +++ b/studio/components/interfaces/Settings/Vault/VaultToggle.tsx @@ -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 = () => { + 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 ( +
+
+
+
+

Enable Vault today

+

+ Supabase Vault is a Postgres extension for managing secrets and encryption inside your + database without leaking any sensitive information. +

+
+ {isNotAvailable ? ( + } + title="Vault is not available for your project" + description={ + <> +

+ Do reach out to us if you're interested in having Vault for this project! +

+ + + } + /> + ) : ( +
+ + + + + + + + + + {!canToggleVault && ( + + +
+ + You need additional permissions to enable Vault for this project + +
+
+ )} +
+
+ )} +
+
+
+ ) +} + +export default observer(VaultToggle) diff --git a/studio/components/interfaces/Settings/Vault/index.ts b/studio/components/interfaces/Settings/Vault/index.ts new file mode 100644 index 00000000000..41f32a26d17 --- /dev/null +++ b/studio/components/interfaces/Settings/Vault/index.ts @@ -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 } diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx index 03f8b01dc8d..fd0cbe367be 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx @@ -33,8 +33,8 @@ const ActionBar: FC = ({ } return ( -
- diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnDefaultValue.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnDefaultValue.tsx index e8f039ce254..ca2f6095a86 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnDefaultValue.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnDefaultValue.tsx @@ -51,7 +51,7 @@ const ColumnDefaultValue: FC = ({ return ( void saveChanges: ( payload: CreateColumnPayload | UpdateColumnPayload, foreignKey: Partial | undefined, isNewRecord: boolean, - configuration: { columnId: string | undefined }, + configuration: { + columnId: string | undefined + isEncrypted: boolean + keyId?: string + keyName?: string + }, resolve: any ) => void updateEditorDirty: () => void @@ -48,24 +56,37 @@ const ColumnEditor: FC = ({ column, selectedTable, tables = [], - enumTypes = [], visible = false, closePanel = () => {}, saveChanges = () => {}, updateEditorDirty = () => {}, }) => { + const { ref } = useParams() + const { meta, vault } = useStore() + const isNewRecord = isUndefined(column) const originalForeignKey = column ? getColumnForeignKey(column, selectedTable) : undefined + const enumTypes = meta.types.list( + (type: PostgresType) => !meta.excludedSchemas.includes(type.schema) + ) + const keys = vault.listKeys() + const defaultKey = keys.find((key) => key.name === 'default_vault_key') || keys[0] + const [errors, setErrors] = useState>({}) const [columnFields, setColumnFields] = useState() const [isEditingRelation, setIsEditingRelation] = useState(false) + const [pgsodiumExtension] = meta.extensions.list( + (ext: PostgresExtension) => ext.name.toLowerCase() === 'pgsodium' + ) + const isPgSodiumInstalled = pgsodiumExtension?.installed_version !== null + useEffect(() => { if (visible) { setErrors({}) const columnFields = isNewRecord - ? generateColumnField() + ? { ...generateColumnField(), keyId: defaultKey?.id } : generateColumnFieldFromPostgresColumn(column!, selectedTable) setColumnFields(columnFields) } @@ -80,6 +101,10 @@ const ColumnEditor: FC = ({ } const updatedColumnFields = { ...columnFields, ...changes } as ColumnField + if (changes.format && changes.format !== 'text' && columnFields.isEncrypted) { + updatedColumnFields.isEncrypted = false + } + setColumnFields(updatedColumnFields) updateEditorDirty() @@ -126,7 +151,12 @@ const ColumnEditor: FC = ({ const foreignKey = columnFields.foreignKey ? { ...columnFields.foreignKey, source_column_name: columnFields.name } : undefined - const configuration = { columnId: column?.id } + const configuration = { + columnId: column?.id, + isEncrypted: columnFields.isEncrypted, + keyId: columnFields.keyId, + keyName: columnFields.keyName, + } saveChanges(payload, foreignKey, isNewRecord, configuration, resolve) } else { resolve() @@ -136,7 +166,7 @@ const ColumnEditor: FC = ({ return ( = ({ } }} > - -
+ General}> + = ({ /> onUpdateField({ comment: event.target.value })} /> -
-
- onUpdateField({ isPrimaryKey: !columnFields?.isPrimaryKey })} - /> -
+ + + + Foreign Key Relation + } + > + +
+ setIsEditingRelation(true)} + onSelectRemoveRelation={() => onUpdateField({ foreignKey: undefined })} + />
-
- - +
+
- - -
- setIsEditingRelation(true)} - onSelectRemoveRelation={() => onUpdateField({ foreignKey: undefined })} - /> -
-
- - -
+ Data Type} + > + = ({ } onOptionSelect={(format: string) => onUpdateField({ format, defaultValue: null })} /> - {isUndefined(columnFields.foreignKey) && ( -
+
{columnFields.format.includes('int') && ( -
+
= ({
)} {!columnFields.isPrimaryKey && ( -
+
= ({ enumTypes={enumTypes} onUpdateField={onUpdateField} /> - {!columnFields.isPrimaryKey && ( - <> -
-
- onUpdateField({ isNullable: !columnFields.isNullable })} - /> -
-
- onUpdateField({ isUnique: !columnFields.isUnique })} - /> -
-
- - )} - - setIsEditingRelation(false)} - saveChanges={saveColumnForeignKey} + + + + Configuration} + > + + onUpdateField({ isPrimaryKey: !columnFields?.isPrimaryKey })} /> -
- + onUpdateField({ isNullable: !columnFields.isNullable })} + /> + onUpdateField({ isUnique: !columnFields.isUnique })} + /> + + + {isNewRecord && ( + <> + + Security} + > + + +

+ Encrypt the column's data with pgsodium's Transparent Column Encryption (TCE). + Decrypted values will be stored within the "decrypted_{selectedTable.name}" + view. +

+ {!isPgSodiumInstalled ? ( +

+ You will need to{' '} + + install + {' '} + the extension pgsodium first before being + able to encrypt your column. +

+ ) : ( +

+ Note: Only columns of text type can be + encrypted. +

+ )} +
+ } + checked={columnFields.isEncrypted} + onChange={() => onUpdateField({ isEncrypted: !columnFields.isEncrypted })} + /> + {columnFields.isEncrypted && ( + onUpdateField({ keyId: id })} + onUpdateDescription={(name) => onUpdateField({ keyName: name })} + /> + )} + + + + )} + + setIsEditingRelation(false)} + saveChanges={saveColumnForeignKey} + /> ) } diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils.ts b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils.ts index 299dbfad1d8..c3e33805929 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils.ts +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils.ts @@ -43,6 +43,7 @@ export const generateColumnField = (field: any = {}): ColumnField => { isPrimaryKey: false, isIdentity: false, isNewColumn: true, + isEncrypted: false, } } @@ -69,6 +70,7 @@ export const generateColumnFieldFromPostgresColumn = ( isUnique: column.is_unique, isNewColumn: false, + isEncrypted: false, isPrimaryKey: primaryKeyColumns.includes(column.name), } } @@ -163,6 +165,9 @@ export const validateFields = (field: ColumnField) => { if (field.format.length === 0) { errors['format'] = `Please select a type for your column` } + if (field.keyId === 'create-new' && (field?.keyName ?? '').length === 0) { + errors['keyName'] = 'Please provide a name for your new key' + } return errors } diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnForeignKey.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnForeignKey.tsx index cd3a32e6ca0..45a09d5a3cd 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnForeignKey.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnForeignKey.tsx @@ -1,6 +1,6 @@ import { FC } from 'react' import { isUndefined } from 'lodash' -import { Button, IconArrowRight, IconLink } from 'ui' +import { Button, IconArrowRight } from 'ui' import { ColumnField } from '../SidePanelEditor.types' import InformationBox from 'components/ui/InformationBox' @@ -86,9 +86,8 @@ const ColumnForeignKeyInformation: FC<{ return ( } title={ -
+
This column has the following foreign key relation:
@@ -123,20 +122,23 @@ const ColumnForeignKeyAdded: FC<{ return ( } title={ -
+
The following foreign key relation will be{' '} added:
- 0 ? 'text-code' : ''} max-w-xs truncate`}> + 0 ? 'text-code font-mono text-xs' : '' + } max-w-xs truncate`} + > {columnName || 'This column'} - + {foreignKey?.target_table_schema}.{foreignKey?.target_table_name}. {foreignKey?.target_column_name} @@ -164,9 +166,8 @@ const ColumnForeignKeyRemoved: FC<{ return ( } title={ -
+

The following foreign key relation will be{' '} @@ -208,9 +209,8 @@ const ColumnForeignKeyUpdated: FC<{ return ( } title={ -

+

The foreign key relation will be updated as diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx index a31f97e92ac..3cfdb838a50 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx @@ -8,6 +8,7 @@ interface Props { value: string enumTypes: PostgresType[] size?: 'tiny' | 'small' | 'medium' | 'large' | 'xlarge' + layout?: 'vertical' | 'horizontal' className?: string error?: any disabled?: boolean @@ -21,6 +22,7 @@ const ColumnType: FC = ({ enumTypes = [], className, size = 'medium', + layout, error, disabled = false, showLabel = true, @@ -74,7 +76,7 @@ const ColumnType: FC = ({ return ( = ({ // @ts-ignore header={ - Edit foreign key relation for{' '} - {get(column, ['name'], '')} + Edit foreign key relation{' '} + {column?.name && ( + <> + for {get(column, ['name'], '')} + + )} } customFooter={ @@ -125,7 +129,7 @@ const ForeignKeySelector: FC = ({ } > -

+
} title="What are foreign keys?" diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 8a0d3ee57d3..7e410f7d2ab 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -2,12 +2,7 @@ import { FC, useState } from 'react' import { find, isEmpty, isUndefined } from 'lodash' import { Query, Dictionary } from 'components/grid' import { Modal } from 'ui' -import { - PostgresRelationship, - PostgresTable, - PostgresColumn, - PostgresType, -} from '@supabase/postgres-meta' +import { PostgresRelationship, PostgresTable, PostgresColumn } from '@supabase/postgres-meta' import { useStore } from 'hooks' import { RowEditor, ColumnEditor, TableEditor } from '.' @@ -30,7 +25,7 @@ interface Props { // Because the panel is shared between grid editor and database pages // Both require different responses upon success of these events onTableCreated?: (table: PostgresTable) => void - onColumnSaved?: () => void + onColumnSaved?: (hasEncryptedColumns?: boolean) => void } const SidePanelEditor: FC = ({ @@ -53,9 +48,6 @@ const SidePanelEditor: FC = ({ const [isClosingPanel, setIsClosingPanel] = useState(false) const tables = meta.tables.list() - const enumTypes = meta.types.list( - (type: PostgresType) => !meta.excludedSchemas.includes(type.schema) - ) const saveRow = async ( payload: any, @@ -125,17 +117,19 @@ const SidePanelEditor: FC = ({ payload: CreateColumnPayload | UpdateColumnPayload, foreignKey: Partial | undefined, isNewRecord: boolean, - configuration: { columnId?: string }, + configuration: { columnId?: string; isEncrypted: boolean; keyId?: string; keyName?: string }, resolve: any ) => { + const { columnId, ...securityConfig } = configuration const response = isNewRecord ? await meta.createColumn( payload as CreateColumnPayload, selectedTable as PostgresTable, - foreignKey + foreignKey, + securityConfig ) : await meta.updateColumn( - configuration.columnId as string, + columnId as string, payload as UpdateColumnPayload, selectedTable as PostgresTable, foreignKey @@ -145,11 +139,15 @@ const SidePanelEditor: FC = ({ ui.setNotification({ category: 'error', message: response.error.message }) } else { await meta.tables.loadById(selectedTable!.id) - onColumnSaved() + onColumnSaved(configuration.isEncrypted) setIsEdited(false) closePanel() } + if (configuration.isEncrypted) { + await meta.schemas.loadViews(selectedTable?.schema ?? '') + } + resolve() } @@ -271,7 +269,6 @@ const SidePanelEditor: FC = ({ )} {!isUndefined(selectedTable) && ( { [Key: string]: T diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.constants.ts b/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.constants.ts index 7a20ab648ab..7a5a4fc9b47 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.constants.ts +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.constants.ts @@ -14,6 +14,7 @@ export const DEFAULT_COLUMNS: ColumnField[] = [ isPrimaryKey: true, isIdentity: true, isNewColumn: true, + isEncrypted: false, }, { id: uuidv4(), @@ -27,5 +28,6 @@ export const DEFAULT_COLUMNS: ColumnField[] = [ isPrimaryKey: false, isIdentity: false, isNewColumn: true, + isEncrypted: false, }, ] diff --git a/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index 0905149b791..818f0d97832 100644 --- a/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -1,4 +1,4 @@ -import { FC, useRef } from 'react' +import { FC, useRef, useEffect, useState } from 'react' import { observer } from 'mobx-react-lite' import { useRouter } from 'next/router' import { find, isUndefined } from 'lodash' @@ -54,11 +54,24 @@ const TableGridEditor: FC = ({ onDeleteColumn = () => {}, onClosePanel = () => {}, }) => { - const { meta, ui } = useStore() + const { meta, ui, vault } = useStore() const router = useRouter() const gridRef = useRef(null) const projectRef = ui.selectedProject?.ref + const [encryptedColumns, setEncryptedColumns] = useState([]) + + const getEncryptedColumns = async (table: any) => { + const columns = await vault.listEncryptedColumns(table.name) + setEncryptedColumns(columns) + } + + useEffect(() => { + if (selectedTable !== undefined && selectedTable.id !== undefined) { + getEncryptedColumns(selectedTable) + } + }, [selectedTable?.id]) + if (isUndefined(selectedTable)) { return } @@ -72,12 +85,15 @@ const TableGridEditor: FC = ({ const canUpdateTables = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') const gridTable = !isViewSelected - ? parseSupaTable({ - table: selectedTable as PostgresTable, - columns: (selectedTable as PostgresTable).columns, - primaryKeys: (selectedTable as PostgresTable).primary_keys, - relationships: (selectedTable as PostgresTable).relationships, - }) + ? parseSupaTable( + { + table: selectedTable as PostgresTable, + columns: (selectedTable as PostgresTable).columns, + primaryKeys: (selectedTable as PostgresTable).primary_keys, + relationships: (selectedTable as PostgresTable).relationships, + }, + encryptedColumns + ) : (selectedTable as SchemaView).name const gridKey = `${selectedTable.schema}_${selectedTable.name}` @@ -90,6 +106,10 @@ const TableGridEditor: FC = ({ if (gridRef.current) gridRef.current.rowEdited(row, idx) } + const onColumnSaved = (hasEncryptedColumns = false) => { + if (hasEncryptedColumns) getEncryptedColumns(selectedTable) + } + const onTableCreated = (table: PostgresTable) => { router.push(`/project/${projectRef}/editor/${table.id}`) } @@ -169,6 +189,7 @@ const TableGridEditor: FC = ({ sidePanelKey={sidePanelKey} onRowCreated={onRowCreated} onRowUpdated={onRowUpdated} + onColumnSaved={onColumnSaved} onTableCreated={onTableCreated} closePanel={onClosePanel} /> diff --git a/studio/components/layouts/SettingsLayout/SettingsLayout.tsx b/studio/components/layouts/SettingsLayout/SettingsLayout.tsx index 4f98e620b59..d60013f47b0 100644 --- a/studio/components/layouts/SettingsLayout/SettingsLayout.tsx +++ b/studio/components/layouts/SettingsLayout/SettingsLayout.tsx @@ -1,4 +1,4 @@ -import { FC, ReactNode } from 'react' +import { FC, ReactNode, useEffect } from 'react' import { observer } from 'mobx-react-lite' import { useRouter } from 'next/router' import { useStore, withAuth } from 'hooks' @@ -13,7 +13,7 @@ interface Props { } const SettingsLayout: FC = ({ title, children }) => { - const { ui } = useStore() + const { ui, meta } = useStore() const projectRef = ui.selectedProjectRef as string const projectBaseInfo = ui.selectedProjectBaseInfo @@ -26,6 +26,12 @@ const SettingsLayout: FC = ({ title, children }) => { const menuRoutes = generateSettingsMenu(projectRef, projectBaseInfo) + useEffect(() => { + if (ui.selectedProject?.ref) { + meta.extensions.load() + } + }, [ui.selectedProject?.ref]) + return ( { + const isVaultEnabled = useFlag('vaultExtension') + const isProjectBuilding = project?.status !== PROJECT_STATUS.ACTIVE_HEALTHY const buildingUrl = `/project/${ref}/building` - const canReadStorage = checkPermissions(PermissionAction.STORAGE_ADMIN_READ, '*') - const canReadInvoices = checkPermissions(PermissionAction.BILLING_READ, 'stripe.invoices') - const canReadSubscriptions = checkPermissions( - PermissionAction.BILLING_READ, - 'stripe.subscriptions' - ) - return [ { title: 'Project Settings', @@ -58,6 +51,16 @@ export const generateSettingsMenu = (ref: string, project?: ProjectBase): Produc }, ] : []), + ...(isVaultEnabled + ? [ + { + name: 'Vault', + key: 'vault', + url: isProjectBuilding ? buildingUrl : `/project/${ref}/settings/vault/secrets`, + items: [], + }, + ] + : []), ], }, ...(IS_PLATFORM diff --git a/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx b/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx index a6cc5be9196..8816485b1a9 100644 --- a/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx +++ b/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx @@ -29,7 +29,7 @@ const TableEditorLayout: FC = ({ onDuplicateTable = () => {}, children, }) => { - const { meta, ui } = useStore() + const { vault, meta, ui } = useStore() const { isInitialized, isLoading, error } = meta.tables const [loaded, setLoaded] = useState(isInitialized) @@ -42,6 +42,8 @@ const TableEditorLayout: FC = ({ meta.types.load() meta.policies.load() meta.publications.load() + meta.extensions.load() + vault.load() } }, [ui.selectedProject?.ref]) diff --git a/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index 23ef339fd5c..2c846abf96a 100644 --- a/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -2,9 +2,8 @@ import { FC, useEffect, useState } from 'react' import { useRouter } from 'next/router' import Link from 'next/link' import { partition } from 'lodash' -// import { Button } from 'ui' +import { observer } from 'mobx-react-lite' import { - Alert, Button, Dropdown, IconChevronDown, @@ -60,7 +59,6 @@ const TableEditorMenu: FC = ({ const canCreateTables = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') const [searchText, setSearchText] = useState('') - const [schemaViews, setSchemaViews] = useState([]) const [isRefreshing, setIsRefreshing] = useState(false) // We may need to shift this to the schema store and do something like meta.schema.loadViews() @@ -68,12 +66,10 @@ const TableEditorMenu: FC = ({ useEffect(() => { let cancel = false const fetchViews = async (selectedSchema: string) => { - const views: SchemaView[] = await meta.schemas.getViews(selectedSchema) - if (!cancel) setSchemaViews(views) - } - if (selectedSchema) { - fetchViews(selectedSchema) + await meta.schemas.loadViews(selectedSchema) } + if (selectedSchema) fetchViews(selectedSchema) + return () => { cancel = true } @@ -82,6 +78,7 @@ const TableEditorMenu: FC = ({ const refreshTables = async () => { setIsRefreshing(true) await meta.tables.load() + if (selectedSchema) await meta.schemas.loadViews(selectedSchema) setIsRefreshing(false) } @@ -93,8 +90,8 @@ const TableEditorMenu: FC = ({ const filteredSchemaViews = searchText.length === 0 - ? schemaViews - : schemaViews.filter((view) => view.name.includes(searchText)) + ? meta.schemas.views || [] + : (meta.schemas.views || []).filter((view) => view.name.includes(searchText)) const [protectedSchemas, openSchemas] = partition(schemas, (schema) => meta.excludedSchemas.includes(schema?.name ?? '') @@ -117,7 +114,6 @@ const TableEditorMenu: FC = ({ // @ts-ignore onChange={(name: string) => { setSearchText('') - setSchemaViews([]) onSelectSchema(name) }} > @@ -296,24 +292,24 @@ const TableEditorMenu: FC = ({ -
- All views -
- +
+ All views +
} /> - {schemaViews.map((view: SchemaView) => { + {meta.schemas.views.map((view: SchemaView) => { const viewId = Base64.encode(JSON.stringify(view)) const isActive = id === viewId return ( - -
-

{view.name}

-
-
+ + +
+

{view.name}

+
+
+
) })} @@ -339,4 +335,4 @@ const TableEditorMenu: FC = ({ ) } -export default TableEditorMenu +export default observer(TableEditorMenu) diff --git a/studio/components/to-be-cleaned/forms/SchemaFormPanel.js b/studio/components/to-be-cleaned/forms/SchemaFormPanel.js index 6f86522c66a..c92731055e1 100644 --- a/studio/components/to-be-cleaned/forms/SchemaFormPanel.js +++ b/studio/components/to-be-cleaned/forms/SchemaFormPanel.js @@ -32,7 +32,7 @@ export default function SchemaFormPanel({ setSubmitButtonLoading(true) }) .catch((error) => { - console.log('Error on submitting', error) + console.error('Error on submitting', error) }) } diff --git a/studio/components/ui/ConfirmationModal.tsx b/studio/components/ui/ConfirmationModal.tsx index 3bc7905aa0b..9d9e799a6ae 100644 --- a/studio/components/ui/ConfirmationModal.tsx +++ b/studio/components/ui/ConfirmationModal.tsx @@ -52,10 +52,16 @@ const ConfirmationModal: FC = ({ onCancel={onSelectCancel} customFooter={
- -
diff --git a/studio/components/ui/DatePicker/TimeSplitInput.tsx b/studio/components/ui/DatePicker/TimeSplitInput.tsx index 7c437936e9a..ea93dbbe9c4 100644 --- a/studio/components/ui/DatePicker/TimeSplitInput.tsx +++ b/studio/components/ui/DatePicker/TimeSplitInput.tsx @@ -38,13 +38,10 @@ const TimeSplitInput = ({ // Only run time conflicts if // startDate and endDate are the same date - // console.log(startDate) - // console.log(endDate) if (format(new Date(startDate), 'dd/mm/yyyy') == format(new Date(endDate), 'dd/mm/yyyy')) { // checks if start time is ahead of end time if (type === 'start') { - // console.log('HH in start switch') if (_time.HH && Number(_time.HH) > Number(endTime.HH)) { endTimePayload.HH = _time.HH endTimeChanges = true @@ -79,8 +76,6 @@ const TimeSplitInput = ({ } if (type === 'end') { - // console.log('HH in start switch') - if (_time.HH && Number(_time.HH) < Number(startTime.HH)) { startTimePayload.HH = _time.HH startTimeChanges = true @@ -128,8 +123,6 @@ const TimeSplitInput = ({ } function handleOnChange(value: string, valueType: TimeType) { - // console.log('handleOnChange') - const payload = { HH: time.HH, mm: time.mm, @@ -151,14 +144,8 @@ const TimeSplitInput = ({ break } - // console.log('got here') - payload[valueType] = value setTime({ ...payload }) - - // if (endTimeChanges) { - // setEndTime(endTimePayload) - // } } const handleFocus = (event: React.FocusEvent) => { diff --git a/studio/components/ui/Forms/FormSection.tsx b/studio/components/ui/Forms/FormSection.tsx index 1d1f0fec835..7d98c189c5c 100644 --- a/studio/components/ui/Forms/FormSection.tsx +++ b/studio/components/ui/Forms/FormSection.tsx @@ -28,8 +28,18 @@ const FormSection = ({ ) } -const FormSectionLabel = ({ children }: { children: React.ReactNode | string }) => { - return +const FormSectionLabel = ({ + className = '', + children, +}: { + className?: string + children: React.ReactNode | string +}) => { + return ( + + ) } const Shimmer = () => ( diff --git a/studio/components/ui/InformationBox.tsx b/studio/components/ui/InformationBox.tsx index d5e824a1e86..3d560569866 100644 --- a/studio/components/ui/InformationBox.tsx +++ b/studio/components/ui/InformationBox.tsx @@ -1,5 +1,6 @@ +import Link from 'next/link' import { FC, ReactNode, useState } from 'react' -import { IconMaximize2, IconMinimize2 } from 'ui' +import { Button, IconExternalLink, IconMaximize2, IconMinimize2 } from 'ui' interface Props { icon?: ReactNode @@ -64,13 +65,13 @@ const InformationBox: FC = ({
{description}
{url && ( - - {urlLabel} - + + + + + )} {button &&
{button}
} diff --git a/studio/jest.config.js b/studio/jest.config.js index cb7f3c95b01..03b07573f70 100644 --- a/studio/jest.config.js +++ b/studio/jest.config.js @@ -3,6 +3,7 @@ module.exports = { moduleDirectories: ['', 'node_modules'], setupFiles: ['jest-canvas-mock', './tests/setup/radix'], testEnvironment: 'jsdom', + testTimeout: 20000, testRegex: '(.*\\.test.(js|jsx|ts|tsx)$)', transform: { '^.+\\.(t|j)sx?$': 'ts-jest' }, globals: { diff --git a/studio/lib/gotrue.ts b/studio/lib/gotrue.ts index 4bd03f121ab..e4c6383a150 100644 --- a/studio/lib/gotrue.ts +++ b/studio/lib/gotrue.ts @@ -18,7 +18,7 @@ export const getAuthUser = async (token: String): Promise => { return { user, error: null } } catch (err) { - console.log(err) + console.error(err) return { user: null, error: err } } } diff --git a/studio/pages/project/[ref]/editor/[id].tsx b/studio/pages/project/[ref]/editor/[id].tsx index 6dc45feb8fb..a93f9815ef7 100644 --- a/studio/pages/project/[ref]/editor/[id].tsx +++ b/studio/pages/project/[ref]/editor/[id].tsx @@ -3,7 +3,7 @@ import { NextPage } from 'next' import { useRouter } from 'next/router' import { observer } from 'mobx-react-lite' import { isUndefined, isNaN } from 'lodash' -import { Modal } from 'ui' +import { Checkbox, Modal } from 'ui' import { PostgresTable, PostgresColumn } from '@supabase/postgres-meta' import Base64 from 'lib/base64' @@ -24,6 +24,8 @@ const TableEditorPage: NextPage = () => { const [isDeleting, setIsDeleting] = useState(false) const [isDuplicating, setIsDuplicating] = useState(false) + const [isDeleteWithCascade, setIsDeleteWithCascade] = useState(false) + const [selectedColumnToDelete, setSelectedColumnToDelete] = useState() const [selectedTableToDelete, setSelectedTableToDelete] = useState() @@ -70,6 +72,7 @@ const TableEditorPage: NextPage = () => { const onDeleteColumn = (column: PostgresColumn) => { setIsDeleting(true) setSelectedColumnToDelete(column) + setIsDeleteWithCascade(false) } const onAddTable = () => { @@ -87,6 +90,7 @@ const TableEditorPage: NextPage = () => { const onDeleteTable = (table: PostgresTable) => { setIsDeleting(true) setSelectedTableToDelete(table) + setIsDeleteWithCascade(false) } const onDuplicateTable = (table: PostgresTable) => { @@ -141,7 +145,7 @@ const TableEditorPage: NextPage = () => { try { if (isUndefined(selectedTableToDelete)) return - const response: any = await meta.tables.del(selectedTableToDelete.id) + const response: any = await meta.tables.del(selectedTableToDelete.id, isDeleteWithCascade) if (response.error) throw response.error const tables = meta.tables.list((table: PostgresTable) => table.schema === selectedSchema) @@ -156,6 +160,7 @@ const TableEditorPage: NextPage = () => { category: 'success', message: `Successfully deleted ${selectedTableToDelete.name}`, }) + if (selectedSchema) await meta.schemas.loadViews(selectedSchema) } catch (error: any) { ui.setNotification({ error, @@ -217,9 +222,17 @@ const TableEditorPage: NextPage = () => { } children={ -

- Are you sure you want to delete the selected table? This action cannot be undone. -

+
+

+ Are you sure you want to delete the selected table? This action cannot be undone. +

+ setIsDeleteWithCascade(!isDeleteWithCascade)} + /> +
} buttonLabel="Delete" diff --git a/studio/pages/project/[ref]/editor/index.tsx b/studio/pages/project/[ref]/editor/index.tsx index 481c332e059..7532dd7fc3c 100644 --- a/studio/pages/project/[ref]/editor/index.tsx +++ b/studio/pages/project/[ref]/editor/index.tsx @@ -55,6 +55,7 @@ const Editor: NextPage = () => { category: 'success', message: `Successfully deleted ${selectedTableToDelete!.name}`, }) + await meta.schemas.loadViews(selectedSchema) } catch (error: any) { ui.setNotification({ category: 'error', diff --git a/studio/pages/project/[ref]/settings/api.tsx b/studio/pages/project/[ref]/settings/api.tsx index 63af221d05f..e3e6b2e887e 100644 --- a/studio/pages/project/[ref]/settings/api.tsx +++ b/studio/pages/project/[ref]/settings/api.tsx @@ -37,7 +37,7 @@ const ApiSettings: NextPageWithLayout = () => { return ( -
+
diff --git a/studio/pages/project/[ref]/settings/auth.tsx b/studio/pages/project/[ref]/settings/auth.tsx index 4ac0f372b80..cd090a2af79 100644 --- a/studio/pages/project/[ref]/settings/auth.tsx +++ b/studio/pages/project/[ref]/settings/auth.tsx @@ -4,11 +4,9 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { NextPageWithLayout } from 'types' import { checkPermissions, useStore } from 'hooks' -import AutoSchemaForm from 'components/interfaces/Auth/AutoSchemaForm' import { SettingsLayout } from 'components/layouts' -import { FormsContainer } from 'components/ui/Forms' import NoPermission from 'components/ui/NoPermission' -import { SmtpForm } from 'components/interfaces' +import { SmtpForm, AutoSchemaForm } from 'components/interfaces/Auth' const PageLayout: NextPageWithLayout = () => { const { ui, authConfig } = useStore() @@ -23,7 +21,7 @@ const PageLayout: NextPageWithLayout = () => { return } else if (authConfig) { return ( -
+
diff --git a/studio/pages/project/[ref]/settings/billing/update/pro.tsx b/studio/pages/project/[ref]/settings/billing/update/pro.tsx index 180e4f2ddc8..fb6db5a4c29 100644 --- a/studio/pages/project/[ref]/settings/billing/update/pro.tsx +++ b/studio/pages/project/[ref]/settings/billing/update/pro.tsx @@ -12,7 +12,7 @@ import Connecting from 'components/ui/Loading/Loading' import { StripeSubscription } from 'components/interfaces/Billing' import { ProUpgrade } from 'components/interfaces/Billing' import { PaymentMethod } from 'components/interfaces/Billing/Billing.types' -import { DatabaseAddon } from 'components/interfaces/Billing/AddOns/AddOns.types' +import { SubscriptionAddon } from 'components/interfaces/Billing/AddOns/AddOns.types' const BillingUpdatePro: NextPageWithLayout = () => { const { ui } = useStore() @@ -24,7 +24,7 @@ const BillingUpdatePro: NextPageWithLayout = () => { const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') const [subscription, setSubscription] = useState() - const [products, setProducts] = useState<{ tiers: any[]; addons: DatabaseAddon[] }>() + const [products, setProducts] = useState<{ tiers: any[]; addons: SubscriptionAddon[] }>() const [paymentMethods, setPaymentMethods] = useState() const [isLoadingPaymentMethods, setIsLoadingPaymentMethods] = useState(false) diff --git a/studio/pages/project/[ref]/settings/vault/keys.tsx b/studio/pages/project/[ref]/settings/vault/keys.tsx new file mode 100644 index 00000000000..3fed0798173 --- /dev/null +++ b/studio/pages/project/[ref]/settings/vault/keys.tsx @@ -0,0 +1,49 @@ +import { useEffect } from 'react' +import { useRouter } from 'next/router' +import { observer } from 'mobx-react-lite' +import { Tabs } from 'ui' + +import { useParams, useStore } from 'hooks' +import { NextPageWithLayout } from 'types' +import { SettingsLayout } from 'components/layouts' +import { VaultToggle, EncryptionKeysManagement } from 'components/interfaces/Settings/Vault' +import { FormHeader } from 'components/ui/Forms' + +const VaultSettingsSecrets: NextPageWithLayout = () => { + const router = useRouter() + const { meta, vault } = useStore() + const { ref } = useParams() + + const vaultExtension = meta.extensions.byId('supabase_vault') + const isEnabled = vaultExtension !== undefined && vaultExtension?.installed_version !== null + + useEffect(() => { + vault.load() + }, []) + + return ( +
+ + {!isEnabled ? ( + + ) : ( + { + if (id === 'secrets') router.push(`/project/${ref}/settings/vault/secrets`) + }} + > + + + + + + )} +
+ ) +} + +VaultSettingsSecrets.getLayout = (page) => {page} +export default observer(VaultSettingsSecrets) diff --git a/studio/pages/project/[ref]/settings/vault/secrets.tsx b/studio/pages/project/[ref]/settings/vault/secrets.tsx new file mode 100644 index 00000000000..995cdd0b9de --- /dev/null +++ b/studio/pages/project/[ref]/settings/vault/secrets.tsx @@ -0,0 +1,49 @@ +import { useEffect } from 'react' +import { useRouter } from 'next/router' +import { observer } from 'mobx-react-lite' +import { Tabs } from 'ui' + +import { useParams, useStore } from 'hooks' +import { NextPageWithLayout } from 'types' +import { SettingsLayout } from 'components/layouts' +import { VaultToggle, SecretsManagement } from 'components/interfaces/Settings/Vault' +import { FormHeader } from 'components/ui/Forms' + +const VaultSettingsSecrets: NextPageWithLayout = () => { + const router = useRouter() + const { meta, vault } = useStore() + const { ref } = useParams() + + const vaultExtension = meta.extensions.byId('supabase_vault') + const isEnabled = vaultExtension !== undefined && vaultExtension?.installed_version !== null + + useEffect(() => { + vault.load() + }, []) + + return ( +
+ + {!isEnabled ? ( + + ) : ( + { + if (id === 'keys') router.push(`/project/${ref}/settings/vault/keys`) + }} + > + + + + + + )} +
+ ) +} + +VaultSettingsSecrets.getLayout = (page) => {page} +export default observer(VaultSettingsSecrets) diff --git a/studio/public/img/vault-dark.png b/studio/public/img/vault-dark.png new file mode 100644 index 00000000000..9b7c9e3e0c1 Binary files /dev/null and b/studio/public/img/vault-dark.png differ diff --git a/studio/public/img/vault-light.png b/studio/public/img/vault-light.png new file mode 100644 index 00000000000..54f0c9c8eae Binary files /dev/null and b/studio/public/img/vault-light.png differ diff --git a/studio/public/img/vault.png b/studio/public/img/vault.png new file mode 100644 index 00000000000..9a3f456952d Binary files /dev/null and b/studio/public/img/vault.png differ diff --git a/studio/stores/RootStore.ts b/studio/stores/RootStore.ts index eadb3754d51..6520c228b2e 100644 --- a/studio/stores/RootStore.ts +++ b/studio/stores/RootStore.ts @@ -1,4 +1,4 @@ -import { configure, reaction } from 'mobx' +import { configure } from 'mobx' import { Project } from 'types' import AppStore, { IAppStore } from './app/AppStore' import MetaStore, { IMetaStore } from './pgmeta/MetaStore' @@ -9,6 +9,7 @@ import ProjectBackupsStore, { IProjectBackupsStore } from './project/ProjectBack import ProjectAuthConfigStore, { IProjectAuthConfigStore, } from './authConfig/ProjectAuthConfigStore' +import VaultStore, { IVaultStore } from './project/VaultStore' // Temporary disable mobx warnings // TODO: need to remove this after refactoring old stores. @@ -24,6 +25,7 @@ export interface IRootStore { functions: IProjectFunctionsStore backups: IProjectBackupsStore authConfig: IProjectAuthConfigStore + vault: IVaultStore selectedProjectRef?: string @@ -38,6 +40,7 @@ export class RootStore implements IRootStore { functions: IProjectFunctionsStore backups: IProjectBackupsStore authConfig: IProjectAuthConfigStore + vault: IVaultStore selectedProjectRef: string | undefined @@ -50,6 +53,7 @@ export class RootStore implements IRootStore { this.functions = new ProjectFunctionsStore(this, { projectRef: '' }) this.backups = new ProjectBackupsStore(this, { projectRef: '' }) this.authConfig = new ProjectAuthConfigStore(this, { projectRef: '' }) + this.vault = new VaultStore(this) } /** diff --git a/studio/stores/common/PostgresMetaInterface.ts b/studio/stores/common/PostgresMetaInterface.ts index 674946802e1..5345022f277 100644 --- a/studio/stores/common/PostgresMetaInterface.ts +++ b/studio/stores/common/PostgresMetaInterface.ts @@ -17,7 +17,7 @@ export interface IPostgresMetaInterface { load: () => void create: (payload: any) => Promise update: (id: number | string, updates: any) => Promise - del: (id: number | string) => Promise + del: (id: number | string, cascade?: boolean) => Promise list: (filter?: any) => T[] find: (filter?: any) => T | undefined byId: (id: number | string) => T | undefined @@ -187,10 +187,10 @@ export default class PostgresMetaInterface implements IPostgresMetaInterface< } } - async del(id: number | string) { + async del(id: number | string, cascade: boolean = false) { try { const headers = { 'Content-Type': 'application/json', ...this.headers } - const url = `${this.url}?id=${id}` + const url = cascade ? `${this.url}?id=${id}&cascade=${cascade}` : `${this.url}?id=${id}` const response = await delete_(url, {}, { headers }) if (response.error) throw response.error diff --git a/studio/stores/pgmeta/MetaStore.ts b/studio/stores/pgmeta/MetaStore.ts index 0e3e1bc3057..1f3cac332bb 100644 --- a/studio/stores/pgmeta/MetaStore.ts +++ b/studio/stores/pgmeta/MetaStore.ts @@ -1,6 +1,6 @@ import Papa from 'papaparse' import { makeObservable, observable } from 'mobx' -import { find, isUndefined, isEqual, isEmpty, chunk } from 'lodash' +import { find, isUndefined, isEqual, isEmpty, chunk, values } from 'lodash' import { Query } from 'components/grid/query/Query' import { @@ -87,7 +87,8 @@ export interface IMetaStore { createColumn: ( payload: CreateColumnPayload, selectedTable: PostgresTable, - foreignKey?: Partial + foreignKey?: Partial, + securityConfiguration?: { isEncrypted: boolean; keyId?: string; keyName?: string } ) => any updateColumn: ( id: string, @@ -157,6 +158,7 @@ export default class MetaStore implements IMetaStore { 'realtime', 'storage', 'supabase_functions', + 'vault', 'graphql', 'graphql_public', ] @@ -299,7 +301,7 @@ export default class MetaStore implements IMetaStore { publish_insert: true, publish_update: true, publish_delete: true, - tables: [] + tables: [], }) if (publicationCreateError) throw publicationCreateError realtimePublication = publication @@ -315,7 +317,7 @@ export default class MetaStore implements IMetaStore { : publicTables .filter((t: any) => t.id !== table.id) .map((t: any) => `${t.schema}.${t.name}`) - const { error } = await this.publications.update(id, { tables: realtimeTables }) + const { error } = await this.publications.update(id, { tables: realtimeTables }) publicationUpdateError = error } else { const isAlreadyEnabled = publicationTables.some((x: any) => x.id == table.id) @@ -346,8 +348,13 @@ export default class MetaStore implements IMetaStore { async createColumn( payload: CreateColumnPayload, selectedTable: PostgresTable, - foreignKey?: Partial + foreignKey?: Partial, + securityConfiguration?: { isEncrypted: boolean; keyId?: string; keyName?: string } ) { + const toastId = this.rootStore.ui.setNotification({ + category: 'loading', + message: `Creating column "${payload.name}"...`, + }) try { // Once pg-meta supports composite keys, we can remove this logic const { isPrimaryKey, ...formattedPayload } = payload @@ -357,6 +364,11 @@ export default class MetaStore implements IMetaStore { // Firing createColumn in createTable will bypass this block if (isPrimaryKey) { + this.rootStore.ui.setNotification({ + id: toastId, + category: 'loading', + message: 'Assigning primary key to column...', + }) // Same logic in createTable: Remove any primary key constraints first (we'll add it back later) // @ts-ignore const existingPrimaryKeys = selectedTable.primary_keys.map((x) => x.name) @@ -372,10 +384,46 @@ export default class MetaStore implements IMetaStore { } if (!isUndefined(foreignKey)) { + this.rootStore.ui.setNotification({ + id: toastId, + category: 'loading', + message: 'Adding foreign key to column...', + }) const relation: any = await this.addForeignKey(foreignKey) if (relation.error) throw relation.error } + + const { isEncrypted, keyId, keyName } = securityConfiguration || {} + if (isEncrypted) { + this.rootStore.ui.setNotification({ + id: toastId, + category: 'loading', + message: 'Encrypting column...', + }) + let encryptionKey = keyId + if (keyId === 'create-new') { + const addKeyRes = await this.rootStore.vault.addKey(keyName) + if (addKeyRes.error) throw addKeyRes.error + else encryptionKey = addKeyRes[0].id + } + if (encryptionKey !== undefined) { + const encryptColumnRes = await this.rootStore.vault.encryptColumn(column, encryptionKey) + if (encryptColumnRes.error) throw encryptColumnRes.error + } + } + this.rootStore.ui.setNotification({ + id: toastId, + category: 'success', + message: `Successfully created column "${column.name}"`, + }) } catch (error: any) { + this.rootStore.ui.setNotification({ + id: toastId, + category: 'error', + message: `An error occured while creating the column "${payload.name}"`, + }) + const query = `alter table "${selectedTable.name}" drop column if exists "${payload.name}";` + await this.rootStore.meta.query(query) return { error } } } diff --git a/studio/stores/pgmeta/SchemaStore.ts b/studio/stores/pgmeta/SchemaStore.ts index b8762efec64..7a0082db225 100644 --- a/studio/stores/pgmeta/SchemaStore.ts +++ b/studio/stores/pgmeta/SchemaStore.ts @@ -1,13 +1,17 @@ +import { observable, makeObservable } from 'mobx' import { PostgresSchema } from '@supabase/postgres-meta' import PostgresMetaInterface, { IPostgresMetaInterface } from '../common/PostgresMetaInterface' import { IRootStore } from '../RootStore' -import { ResponseError } from 'types' +import { SchemaView, ResponseError } from 'types' export interface ISchemaStore extends IPostgresMetaInterface { - getViews: (schema: string) => Promise + views: SchemaView[] + loadViews: (schema: string) => Promise } export default class SchemaStore extends PostgresMetaInterface { + views: SchemaView[] = [] + constructor( rootStore: IRootStore, dataUrl: string, @@ -17,14 +21,20 @@ export default class SchemaStore extends PostgresMetaInterface { options?: { identifier: string } ) { super(rootStore, dataUrl, headers, options) + makeObservable(this, { + views: observable, + }) + this.views = [] } - async getViews(schema: string) { + async loadViews(schema: string) { const query = ` select schemaname as schema, viewname as name from pg_catalog.pg_views where schemaname = '${schema}' order by schemaname, viewname; ` - return await this.rootStore.meta.query(query) + const views = await this.rootStore.meta.query(query) + this.views = views + return views } } diff --git a/studio/stores/project/VaultStore.ts b/studio/stores/project/VaultStore.ts new file mode 100644 index 00000000000..a8c6f220171 --- /dev/null +++ b/studio/stores/project/VaultStore.ts @@ -0,0 +1,229 @@ +import { PostgresColumn } from '@supabase/postgres-meta' +import { Query } from 'components/grid' +import { makeAutoObservable } from 'mobx' +import { VaultSecret } from 'types' +import { IRootStore } from '../RootStore' + +export interface IVaultStore { + isLoading: boolean + isInitialized: boolean + isLoaded: boolean + error: any + + load: () => void + listKeys: (filter?: any) => EncryptionKey[] + addKey: (name?: string) => any + deleteKey: (id: string) => any + encryptColumn: (column: PostgresColumn, keyId: string) => any + + listSecrets: (filter?: any) => VaultSecret[] + addSecret: (secret: Partial) => any + updateSecret: (id: string, payload: Partial) => any + deleteSecret: (id: string) => any + fetchSecretValue: (id: string) => any + listEncryptedColumns: (table: string) => any +} + +interface EncryptionKey { + id: string + key_id: number + name: string + comment: string + created: string + status: string +} + +export default class VaultStore implements IVaultStore { + rootStore: IRootStore + + STATES = { + INITIAL: 'initial', + LOADING: 'loading', + ERROR: 'error', + LOADED: 'loaded', + } + + data: { keys: EncryptionKey[]; secrets: VaultSecret[] } = { keys: [], secrets: [] } + state = this.STATES.INITIAL + error = null + + constructor(rootStore: IRootStore) { + this.rootStore = rootStore + makeAutoObservable(this) + } + + get isLoading() { + return this.state === this.STATES.INITIAL || this.state === this.STATES.LOADING + } + + get isInitialized() { + return this.state !== this.STATES.INITIAL + } + + get isLoaded() { + return this.state === this.STATES.LOADED + } + + async fetchData() { + const vault = { keys: [], secrets: [] } + + const keysQuery = new Query() + .from('key', 'pgsodium') + .select('id,key_id,name,comment,created,status') + .toSql() + const keys = await this.rootStore.meta.query(keysQuery) + if (!keys.error) vault.keys = keys + + const secretsQuery = new Query() + .from('secrets', 'vault') + .select('id,name,description,secret,key_id,created_at,updated_at') + .toSql() + const secrets = await this.rootStore.meta.query(secretsQuery) + if (!secrets.error) vault.secrets = secrets + + this.data = vault + return vault + } + + async load() { + let { LOADING, ERROR, LOADED } = this.STATES + try { + this.error = null + if (this.state !== LOADED) this.state = LOADING + await this.fetchData() + this.state = LOADED + return this.data + } catch (e: any) { + console.error('Failed to load vault data', e.message) + this.error = e + this.state = ERROR + } + } + + listKeys(filter?: any) { + const arr = this.data.keys.slice() + + if (!!filter) { + return arr + .filter(filter) + .sort((a: any, b: any) => Number(new Date(a.created)) - Number(new Date(b.created))) + } else { + return arr.sort((a: any, b: any) => Number(new Date(a.created)) - Number(new Date(b.created))) + } + } + + async addKey(name?: string) { + const res = + name !== undefined + ? await this.rootStore.meta.query(`select * from pgsodium.create_key(name := '${name}')`) + : await this.rootStore.meta.query(`select * from pgsodium.create_key()`) + if (!res.error) { + this.data.keys = this.data.keys.concat(res) + } + return res + } + + async deleteKey(id: string) { + const query = new Query().from('key', 'pgsodium').delete().match({ id }).toSql() + const res = await this.rootStore.meta.query(query) + if (!res.error) { + this.data.keys = this.data.keys.filter((key) => key.id !== id) + } + return res + } + + async encryptColumn(column: PostgresColumn, keyId: string) { + const query = `security label for pgsodium on column "${column.table}"."${column.name}" is 'ENCRYPT WITH KEY ID ${keyId}';` + return await this.rootStore.meta.query(query) + } + + listSecrets(filter?: any) { + const arr = this.data.secrets.slice() + + if (!!filter) { + return arr + .filter(filter) + .sort((a: any, b: any) => Number(new Date(a.created_at)) - Number(new Date(b.created_at))) + } else { + return arr.sort( + (a: any, b: any) => Number(new Date(a.created_at)) - Number(new Date(b.created_at)) + ) + } + } + + async addSecret(newSecret: Partial) { + const { name, description, secret, key_id } = newSecret + const query = new Query() + .from('secrets', 'vault') + .insert([{ name, description, secret, key_id }], { returning: true }) + .toSql() + const res = await this.rootStore.meta.query(query) + if (!res.error) { + this.data.secrets = this.data.secrets.concat(res) + } + return res + } + + async updateSecret(id: string, payload: Partial) { + const query = new Query() + .from('decrypted_secrets', 'vault') + .update({ ...payload, updated_at: new Date().toISOString() }, { returning: true }) + .match({ id }) + .toSql() + const res = await this.rootStore.meta.query(query) + if (!res.error) { + this.data.secrets = this.data.secrets.map((secret) => { + if (secret.id === res[0].id) return res[0] + else return secret + }) + } + return res + } + + async deleteSecret(id: string) { + const query = new Query().from('secrets', 'vault').delete().match({ id }).toSql() + const res = await this.rootStore.meta.query(query) + if (!res.error) { + this.data.secrets = this.data.secrets.filter((secret) => secret.id !== id) + } + return res + } + + async fetchSecretValue(id: string) { + const query = new Query() + .from('decrypted_secrets', 'vault') + .select('decrypted_secret') + .match({ id }) + .toSql() + const res = await this.rootStore.meta.query(query) + if (!res.error) { + this.data.secrets = this.data.secrets.map((secret) => { + if (secret.id === id) { + return { ...secret, decryptedSecret: res[0].decrypted_secret } + } else { + return secret + } + }) + } + return res[0].decrypted_secret + } + + async listEncryptedColumns(table: string) { + if (!table) return [] + + const decryptedView = this.rootStore.meta.schemas.views.find( + (view) => view.name === `decrypted_${table}` + ) + if (!decryptedView) return [] + + const encryptedColumns = await this.rootStore.meta.query( + `SELECT column_name as name FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'decrypted_${table}' and column_name like 'decrypted_%'` + ) + if (!encryptedColumns.error) { + return encryptedColumns.map((column: any) => column.name.split('decrypted_')[1]) + } else { + console.error('Error fetching encrypted columns', encryptedColumns.error) + return [] + } + } +} diff --git a/studio/styles/main.scss b/studio/styles/main.scss index fff104ca410..b1ba9f4d06d 100644 --- a/studio/styles/main.scss +++ b/studio/styles/main.scss @@ -218,4 +218,10 @@ div.fixed.inset-0 { .capitalize-sentence:first-letter { text-transform: capitalize; +} + +.input-clear { + input { + @apply pr-7; + } } \ No newline at end of file diff --git a/studio/tests/pages/projects/LogsPreviewer.test.js b/studio/tests/pages/projects/LogsPreviewer.test.js index d6a6147ec6a..0b8420e9c14 100644 --- a/studio/tests/pages/projects/LogsPreviewer.test.js +++ b/studio/tests/pages/projects/LogsPreviewer.test.js @@ -391,10 +391,11 @@ test('filters alter generated query', async () => { await waitFor(() => { // counts are adjusted - expect(get).not.toHaveBeenCalledWith(expect.stringMatching(/count.+\*.+as.count.+where.+500.+599/)) + expect(get).not.toHaveBeenCalledWith( + expect.stringMatching(/count.+\*.+as.count.+where.+500.+599/) + ) expect(get).toHaveBeenCalledWith(expect.stringMatching(/count.+\*.+as.count.+where.+400.+499/)) - expect(get).not.toHaveBeenCalledWith(expect.stringContaining('500')) expect(get).not.toHaveBeenCalledWith(expect.stringContaining('599')) expect(get).not.toHaveBeenCalledWith(expect.stringContaining('200')) diff --git a/studio/types/ui.ts b/studio/types/ui.ts index e14c8948e7e..b3e5b4befb9 100644 --- a/studio/types/ui.ts +++ b/studio/types/ui.ts @@ -19,3 +19,19 @@ export interface ChartIntervals { startUnit: 'hour' | 'day' format: 'MMM D, h:mma' | 'MMM D, ha' | 'MMM D' } + +export interface VaultSecret { + id: string + name: string + description: string + secret: string + decryptedSecret?: string + key_id: string + created_at: string + updated_at: string +} + +export interface SchemaView { + name: string + schema: string +}