diff --git a/.turbo-cookie b/.turbo-cookie deleted file mode 100755 index 9e5d1817106..00000000000 --- a/.turbo-cookie +++ /dev/null @@ -1 +0,0 @@ -cookie \ No newline at end of file diff --git a/apps/docs/pages/guides/auth/auth-helpers/nextjs-server-components.mdx b/apps/docs/pages/guides/auth/auth-helpers/nextjs-server-components.mdx index e9862fe85f6..346c8d7f3bc 100644 --- a/apps/docs/pages/guides/auth/auth-helpers/nextjs-server-components.mdx +++ b/apps/docs/pages/guides/auth/auth-helpers/nextjs-server-components.mdx @@ -210,7 +210,7 @@ Create a `/components/supabase-listener.jsx` file and add the following: import { useRouter } from 'next/navigation' import { useEffect } from 'react' -import supabase from '../utils/supabase' +import supabase from '../utils/supabase-browser' export default function SupabaseListener({ accessToken }) { const router = useRouter() diff --git a/apps/docs/pages/guides/auth/auth-mfa.mdx b/apps/docs/pages/guides/auth/auth-mfa.mdx index 286916d29de..0591e3af52b 100644 --- a/apps/docs/pages/guides/auth/auth-mfa.mdx +++ b/apps/docs/pages/guides/auth/auth-mfa.mdx @@ -7,13 +7,6 @@ export const meta = { 'Add an additional layer of security to your apps with Supabase Auth multi-factor authentication.', } - - -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/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-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..7f90355c3d7 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

-
- -
+ {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 fa70af0078c..208693a2a20 100644 --- a/apps/www/public/rss.xml +++ b/apps/www/public/rss.xml @@ -5,945 +5,966 @@ https://supabase.com Latest news from Supabase en - Wed, 30 Nov 2022 03:03:00 +0000 + Thu, 08 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/supabase-beta-november-2022 + Supabase Beta November 2022 + https://supabase.com/blog/supabase-beta-november-2022 + We are preparing everything for Launch Week 6, but we still had time to ship some goodies this month! + Tue, 06 Dec 2022 16:04:00 +0000 + + + https://supabase.com/blog/the-supabase-content-storm + The Supabase Content Storm + https://supabase.com/blog/the-supabase-content-storm + We worked with +30 content creators to drop a mountain of content simultaneously. + Mon, 05 Dec 2022 16:04:00 +0000 + + https://supabase.com/blog/transparent-column-encryption-with-postgres Transparent Column Encryption with Postgres https://supabase.com/blog/transparent-column-encryption-with-postgres Using pgsodium's Transparent Column Encryption to encrypt data and provide your users with row-level encryption. - Wed, 30 Nov 2022 03:03:00 +0000 + Wed, 30 Nov 2022 16:04:00 +0000 https://supabase.com/blog/sql-or-nosql-both-with-postgresql SQL or NoSQL? Why not use both (with PostgreSQL)? https://supabase.com/blog/sql-or-nosql-both-with-postgresql How to turn Postgres into an easy-to-use NoSQL database that retains all the power of SQL - Wed, 23 Nov 2022 03:03:00 +0000 + Wed, 23 Nov 2022 16:04:00 +0000 https://supabase.com/blog/flutter-authentication-and-authorization-with-rls Flutter Authentication and Authorization with RLS https://supabase.com/blog/flutter-authentication-and-authorization-with-rls Learn how you can secure your Flutter app using Supabase Row Level Security. - Mon, 21 Nov 2022 03:03:00 +0000 + Mon, 21 Nov 2022 16:04:00 +0000 https://supabase.com/blog/fetching-and-caching-supabase-data-in-next-js-server-components Fetching and caching Supabase data in Next.js 13 Server Components https://supabase.com/blog/fetching-and-caching-supabase-data-in-next-js-server-components Next.js 13 introduces new data fetching and caching methods to enable React Server Components and Suspense. - Wed, 16 Nov 2022 03:03:00 +0000 + Wed, 16 Nov 2022 16:04:00 +0000 https://supabase.com/blog/authentication-in-ionic-angular Authentication in Ionic Angular with Supabase https://supabase.com/blog/authentication-in-ionic-angular Learn how to build an Ionic Angular app with authentication, Row Level Security, and Magic Link auth. - Mon, 07 Nov 2022 03:03:00 +0000 + Mon, 07 Nov 2022 16:04:00 +0000 https://supabase.com/blog/supabase-beta-update-october-2022 Supabase Beta October 2022 https://supabase.com/blog/supabase-beta-update-october-2022 New SDKs, quickstarts, Functions tricks, and more. But, more importantly, Launch Week 6️ has a date! - Tue, 01 Nov 2022 03:03:00 +0000 + Tue, 01 Nov 2022 16:04:00 +0000 https://supabase.com/blog/postgresql-commitfest What is PostgreSQL commitfest and how to contribute https://supabase.com/blog/postgresql-commitfest A time-tested method for contributing to the core Postgres code - Wed, 26 Oct 2022 03:03:00 +0000 + Wed, 26 Oct 2022 16:04:00 +0000 https://supabase.com/blog/supabase-flutter-sdk-v1-released supabase-flutter v1 Released https://supabase.com/blog/supabase-flutter-sdk-v1-released We've released supabase-flutter v1. More intuitive way of accessing Supabase from your Flutter application. - Thu, 20 Oct 2022 03:03:00 +0000 + Thu, 20 Oct 2022 16:04:00 +0000 https://supabase.com/blog/supabase-js-v2-released supabase-js v2 Released https://supabase.com/blog/supabase-js-v2-released We've released supabase-js v2. Updated examples, quickstarts, and an improved experience. - Wed, 19 Oct 2022 03:03:00 +0000 + Wed, 19 Oct 2022 16:04:00 +0000 https://supabase.com/blog/postgres-full-text-search-vs-the-rest Postgres Full Text Search vs the rest https://supabase.com/blog/postgres-full-text-search-vs-the-rest Comparing one of the most popular Postgres features against alternatives - Thu, 13 Oct 2022 03:03:00 +0000 + Thu, 13 Oct 2022 16:04:00 +0000 https://supabase.com/blog/supabase-beta-update-september-2022 Supabase Beta September 2022 https://supabase.com/blog/supabase-beta-update-september-2022 We were too focused on clearing out the backlog so we didn't ship anything new last month... or did we?! - Tue, 04 Oct 2022 03:03:00 +0000 + Tue, 04 Oct 2022 16:04:00 +0000 https://supabase.com/blog/postgres-wasm Postgres WASM by Snaplet and Supabase https://supabase.com/blog/postgres-wasm We're open sourcing postgres-wasm, a PostgresQL server that runs inside a browser, with our friends at Snaplet. - Sun, 02 Oct 2022 03:03:00 +0000 + Sun, 02 Oct 2022 16:04:00 +0000 https://supabase.com/blog/choosing-a-postgres-primary-key Choosing a Postgres Primary Key https://supabase.com/blog/choosing-a-postgres-primary-key Turns out the question of which identifier to use as a Primary Key is complicated -- we're going to dive into some of the complexity and inherent trade-offs, and figure things out - Wed, 07 Sep 2022 03:03:00 +0000 + Wed, 07 Sep 2022 16:04:00 +0000 https://supabase.com/blog/supabase-beta-update-august-2022 Supabase Beta August 2022 https://supabase.com/blog/supabase-beta-update-august-2022 Launch Week Special. See everything we shipped, plus winners of the Hackathon and the extended Community Highlights - Tue, 06 Sep 2022 03:03:00 +0000 + Tue, 06 Sep 2022 16:04:00 +0000 https://supabase.com/blog/launch-week-5-hackathon-winners Launch Week 5 Hackathon Winners https://supabase.com/blog/launch-week-5-hackathon-winners Announcing the winners of the Launch Week 5 Hackathon! - Wed, 24 Aug 2022 03:03:00 +0000 + Wed, 24 Aug 2022 16:04:00 +0000 https://supabase.com/blog/building-a-realtime-trello-board-with-supabase-and-angular Building a Realtime Trello Board with Supabase and Angular https://supabase.com/blog/building-a-realtime-trello-board-with-supabase-and-angular Go beyond the hello world example with this real world project. - Tue, 23 Aug 2022 03:03:00 +0000 + Tue, 23 Aug 2022 16:04:00 +0000 https://supabase.com/blog/supabase-vault Supabase Vault https://supabase.com/blog/supabase-vault Today we're announcing Vault, a Postgres extension for managing secrets and encryption inside your database. - Thu, 18 Aug 2022 03:03:00 +0000 + Thu, 18 Aug 2022 16:04:00 +0000 https://supabase.com/blog/postgrest-v10 PostgREST v10: EXPLAIN and Improved Relationship Detection https://supabase.com/blog/postgrest-v10 Today, PostgREST 10 was released. Let's take a look at some of the new features that go hand in hand with supabase-js v2. - Thu, 18 Aug 2022 03:03:00 +0000 + Thu, 18 Aug 2022 16:04:00 +0000 https://supabase.com/blog/pg-jsonschema-a-postgres-extension-for-json-validation pg_jsonschema: JSON Schema support for Postgres https://supabase.com/blog/pg-jsonschema-a-postgres-extension-for-json-validation Today we're releasing pg_jsonschema, a Postgres extension for JSON validation. - Thu, 18 Aug 2022 03:03:00 +0000 + Thu, 18 Aug 2022 16:04:00 +0000 https://supabase.com/blog/launch-week-5-one-more-thing One more thing https://supabase.com/blog/launch-week-5-one-more-thing Let's be honest, it's never just one more thing. - Thu, 18 Aug 2022 03:03:00 +0000 + Thu, 18 Aug 2022 16:04:00 +0000 https://supabase.com/blog/launch-week-5-community-day Community Day https://supabase.com/blog/launch-week-5-community-day Wrapping up Launch Week 5 with contributors, partners, and friends. - Thu, 18 Aug 2022 03:03:00 +0000 + Thu, 18 Aug 2022 16:04:00 +0000 https://supabase.com/blog/supabase-realtime-multiplayer-general-availability Realtime: Multiplayer Edition https://supabase.com/blog/supabase-realtime-multiplayer-general-availability Announcing the general availability of Realtime's Broadcast and Presence. - Wed, 17 Aug 2022 03:03:00 +0000 + Wed, 17 Aug 2022 16:04:00 +0000 https://supabase.com/blog/supabase-soc2 Supabase is SOC2 compliant https://supabase.com/blog/supabase-soc2 Supabase is now SOC2 compliant. Learn how we got here and what it means for our customers. - Tue, 16 Aug 2022 03:03:00 +0000 + Tue, 16 Aug 2022 16:04:00 +0000 https://supabase.com/blog/supabase-js-v2 supabase-js v2 https://supabase.com/blog/supabase-js-v2 A look at supabase-js v2, which brings type support and focuses on quality-of-life improvements for developers. - Mon, 15 Aug 2022 03:03:00 +0000 + Mon, 15 Aug 2022 16:04:00 +0000 https://supabase.com/blog/supabase-cli-v1-and-admin-api-beta Supabase CLI v1 and Management API Beta https://supabase.com/blog/supabase-cli-v1-and-admin-api-beta We are moving Supabase CLI v1 out of beta, and releasing Management API beta. - Sun, 14 Aug 2022 03:03:00 +0000 + Sun, 14 Aug 2022 16:04:00 +0000 https://supabase.com/blog/supabase-series-b Supabase Series B https://supabase.com/blog/supabase-series-b Supabase raised $80M in May, bringing our total funding to $116M. - Thu, 11 Aug 2022 03:03:00 +0000 + Thu, 11 Aug 2022 16:04:00 +0000 https://supabase.com/blog/launch-week-5-hackathon Launch Week 5 Hackathon https://supabase.com/blog/launch-week-5-hackathon Build to win $1500 - Friday 12th to Monday 21st August 2022 - Tue, 09 Aug 2022 03:03:00 +0000 + Tue, 09 Aug 2022 16:04:00 +0000 https://supabase.com/blog/slack-consolidate-slackbot-to-consolidate-messages Slack Consolidate: a slackbot built with Python and Supabase https://supabase.com/blog/slack-consolidate-slackbot-to-consolidate-messages A slackbot to consolidate messages from different channels using Supabase, Slack SDK and Python - Mon, 08 Aug 2022 03:03:00 +0000 + Mon, 08 Aug 2022 16:04:00 +0000 https://supabase.com/blog/supabase-beta-update-july-2022 Supabase Beta July 2022 https://supabase.com/blog/supabase-beta-update-july-2022 Launch Week Golden Tickets, Flutter SDK 1.0 Developer Preview and more... - Tue, 02 Aug 2022 03:03:00 +0000 + Tue, 02 Aug 2022 16:04:00 +0000 https://supabase.com/blog/supabase-flutter-sdk-1-developer-preview Supabase Flutter SDK 1.0 Developer Preview https://supabase.com/blog/supabase-flutter-sdk-1-developer-preview Supabase Flutter SDK is getting a major update and we need your help making it better. - Mon, 01 Aug 2022 03:03:00 +0000 + Mon, 01 Aug 2022 16:04:00 +0000 https://supabase.com/blog/seen-by-in-postgresql Implementing "seen by" functionality with Postgres https://supabase.com/blog/seen-by-in-postgresql Different approaches for tracking visitor counts with PostgreSQL. - Sun, 17 Jul 2022 03:03:00 +0000 + Sun, 17 Jul 2022 16:04:00 +0000 https://supabase.com/blog/supabase-auth-helpers-with-sveltekit-support Revamped Auth Helpers for Supabase (with SvelteKit support) https://supabase.com/blog/supabase-auth-helpers-with-sveltekit-support Supabase Auth Helpers now have improved developer experience, Sveltekit support, and more. - Tue, 12 Jul 2022 03:03:00 +0000 + Tue, 12 Jul 2022 16:04:00 +0000 https://supabase.com/blog/beta-update-june-2022 Supabase Beta June 2022 https://supabase.com/blog/beta-update-june-2022 Auth Helpers, Unlimited Free Projects, CLI and more... - Tue, 05 Jul 2022 03:03:00 +0000 + Tue, 05 Jul 2022 16:04:00 +0000 https://supabase.com/blog/flutter-tutorial-building-a-chat-app Flutter Tutorial: building a Flutter chat app https://supabase.com/blog/flutter-tutorial-building-a-chat-app Learn how to build a Flutter chat app with open source and scalable backend (inc. auth, realtime, database, and more). - Wed, 29 Jun 2022 03:03:00 +0000 + Wed, 29 Jun 2022 16:04:00 +0000 https://supabase.com/blog/visualizing-supabase-data-using-metabase Visualizing Supabase Data using Metabase https://supabase.com/blog/visualizing-supabase-data-using-metabase How to create different kinds of charts out of data stored in Supabase using Metabase. - Tue, 28 Jun 2022 03:03:00 +0000 + Tue, 28 Jun 2022 16:04:00 +0000 https://supabase.com/blog/partial-postgresql-data-dumps-with-rls Partial data dumps using Postgres Row Level Security https://supabase.com/blog/partial-postgresql-data-dumps-with-rls Using RLS to create seed files for local PostgreSQL testing. - Mon, 27 Jun 2022 03:03:00 +0000 + Mon, 27 Jun 2022 16:04:00 +0000 https://supabase.com/blog/loading-data-supabase-python Python data loading with Supabase https://supabase.com/blog/loading-data-supabase-python An example of how to load data into Supabase using supabase-py - Thu, 16 Jun 2022 03:03:00 +0000 + Thu, 16 Jun 2022 16:04:00 +0000 https://supabase.com/blog/beta-update-may-2022 Supabase Beta May 2022 https://supabase.com/blog/beta-update-may-2022 Product and community updates including wildcard auth redirects, edge functions with webhooks, and Prometheus endpoints for everybody. - Tue, 31 May 2022 03:03:00 +0000 + Tue, 31 May 2022 16:04:00 +0000 https://supabase.com/blog/how-supabase-accelerates-development-of-all-pull-together How Mike Lyndon is using Supabase to accelerate development of AllPullTogether https://supabase.com/blog/how-supabase-accelerates-development-of-all-pull-together Mike Lyndon is learning web development as he builds AllPullTogether, and Supabase is helping him accelerate what he can accomplish. - Wed, 25 May 2022 03:03:00 +0000 + Wed, 25 May 2022 16:04:00 +0000 https://supabase.com/blog/partner-gallery-works-with-supabase Works With Supabase - announcing our Partner Gallery https://supabase.com/blog/partner-gallery-works-with-supabase Introducing our Partner Gallery - open source and made with Supabase. - Tue, 19 Apr 2022 03:03:00 +0000 + Tue, 19 Apr 2022 16:04:00 +0000 https://supabase.com/blog/bring-the-func-hackathon-winners Bring the Func Hackathon Winners 2022 https://supabase.com/blog/bring-the-func-hackathon-winners Celebrating many amazing OSS Hackathon projects using GraphQL and Edge Functions. - Sun, 17 Apr 2022 03:03:00 +0000 + Sun, 17 Apr 2022 16:04:00 +0000 https://supabase.com/blog/beta-update-march-2022 Supabase Beta March 2022 https://supabase.com/blog/beta-update-march-2022 Functions, GraphQL, and much more. - Thu, 14 Apr 2022 03:03:00 +0000 + Thu, 14 Apr 2022 16:04:00 +0000 https://supabase.com/blog/supabrew Supabrew - Never Code Thirsty https://supabase.com/blog/supabrew A light and refreshing non-alcoholic beer for devs. - Fri, 01 Apr 2022 03:03:00 +0000 + Thu, 31 Mar 2022 16:04:00 +0000 https://supabase.com/blog/supabase-realtime-with-multiplayer-features Supabase Realtime, with Multiplayer Features https://supabase.com/blog/supabase-realtime-with-multiplayer-features Today we're announced Realtime, with multiplayer features. Realtime enables broadcast, presence, and listening to database changes delivered over WebSockets. - Fri, 01 Apr 2022 03:03:00 +0000 + Thu, 31 Mar 2022 16:04:00 +0000 https://supabase.com/blog/hackathon-bring-the-func Hackathon: Bring the Func(🕺) https://supabase.com/blog/hackathon-bring-the-func Build open-source projects with our latest features, win limited edition swag and plant a tree! - Fri, 01 Apr 2022 03:03:00 +0000 + Thu, 31 Mar 2022 16:04:00 +0000 https://supabase.com/blog/supabase-edge-functions Edge Functions are now available in Supabase https://supabase.com/blog/supabase-edge-functions Today we're launching Edge Functions. Edge Functions let you execute Typescript code close to your users, no matter where they're located. - Wed, 30 Mar 2022 03:03:00 +0000 + Wed, 30 Mar 2022 16:04:00 +0000 https://supabase.com/blog/supabase-enterprise Introducing Supabase Enterprise https://supabase.com/blog/supabase-enterprise Today we are releasing Supabase Enterprise, a suite of features to scale your project. - Tue, 29 Mar 2022 03:03:00 +0000 + Tue, 29 Mar 2022 16:04:00 +0000 https://supabase.com/blog/graphql-now-available GraphQL is now available in Supabase https://supabase.com/blog/graphql-now-available GraphQL support is now in general availability on the Supabase platform via our open source PostgreSQL extension, pg_graphql (beta). - Mon, 28 Mar 2022 03:03:00 +0000 + Mon, 28 Mar 2022 16:04:00 +0000 https://supabase.com/blog/community-day-lw4 Community Day https://supabase.com/blog/community-day-lw4 Kicking off Launch Week 4 with contributors, partners, and friends. - Sun, 27 Mar 2022 03:03:00 +0000 + Sun, 27 Mar 2022 16:04:00 +0000 https://supabase.com/blog/supabase-launch-week-four Supabase Launch Week 4 https://supabase.com/blog/supabase-launch-week-four Launch Week 4: One new feature every day for an entire week. Starting Monday 28th March. - Thu, 24 Mar 2022 03:03:00 +0000 + Thu, 24 Mar 2022 16:04:00 +0000 https://supabase.com/blog/should-i-open-source-my-company Should I Open Source my Company? https://supabase.com/blog/should-i-open-source-my-company The unexpected upsides of building in public - Thu, 24 Mar 2022 03:03:00 +0000 + Thu, 24 Mar 2022 16:04:00 +0000 https://supabase.com/blog/audit Postgres Auditing in 150 lines of SQL https://supabase.com/blog/audit PostgreSQL has a robust set of features which we can leverage to create a generic auditing solution in 150 lines of SQL. - Mon, 07 Mar 2022 03:03:00 +0000 + Mon, 07 Mar 2022 16:04:00 +0000 https://supabase.com/blog/supabase-beta-january-2022 Supabase Beta January 2022 https://supabase.com/blog/supabase-beta-january-2022 New auth providers, SMS providers, and new videos. - Mon, 21 Feb 2022 03:03:00 +0000 + Mon, 21 Feb 2022 16:04:00 +0000 https://supabase.com/blog/supabase-beta-december-2021 Supabase Beta December 2021 https://supabase.com/blog/supabase-beta-december-2021 New crypto extension, Postgres videos, and a bunch of cool integrations. - Wed, 19 Jan 2022 03:03:00 +0000 + Wed, 19 Jan 2022 16:04:00 +0000 https://supabase.com/blog/product-hunt-golden-kitty-awards-2021 Golden Kitty Awards Ceremony Watch Party with Supabase https://supabase.com/blog/product-hunt-golden-kitty-awards-2021 Hang out with us while watching the Product Hunt Golden Kitty Awards Ceremony - Wed, 19 Jan 2022 03:03:00 +0000 + Wed, 19 Jan 2022 16:04:00 +0000 https://supabase.com/blog/holiday-hackdays-winners-2021 Holiday Hackdays Winners 2021 https://supabase.com/blog/holiday-hackdays-winners-2021 Celebrating many amazing projects submitted to our Holiday Hackdays Hackathon. - Thu, 16 Dec 2021 03:03:00 +0000 + Thu, 16 Dec 2021 16:04:00 +0000 https://supabase.com/blog/beta-november-2021-launch-week-recap Supabase Beta November 2021: Launch Week Recap https://supabase.com/blog/beta-november-2021-launch-week-recap We wrapped up November with Supabase's third Launch Week. Here's all the awesome stuff that got shipped ... - Tue, 14 Dec 2021 03:03:00 +0000 + Tue, 14 Dec 2021 16:04:00 +0000 https://supabase.com/blog/supabase-holiday-hackdays-hackathon Kicking off the Holiday Hackdays https://supabase.com/blog/supabase-holiday-hackdays-hackathon Build cool stuff and celebrate open-source software with us during the Holiday Hackdays! - Thu, 02 Dec 2021 03:03:00 +0000 + Thu, 02 Dec 2021 16:04:00 +0000 https://supabase.com/blog/pg-graphql pg_graphql: A GraphQL extension for PostgreSQL https://supabase.com/blog/pg-graphql GraphQL support is in development for PostgreSQL + Supabase. - Thu, 02 Dec 2021 03:03:00 +0000 + Thu, 02 Dec 2021 16:04:00 +0000 https://supabase.com/blog/launch-week-three-friday-five-more-things Five more things https://supabase.com/blog/launch-week-three-friday-five-more-things It's never just one more thing! - Thu, 02 Dec 2021 03:03:00 +0000 + Thu, 02 Dec 2021 16:04:00 +0000 https://supabase.com/blog/supabase-acquires-logflare Supabase acquires Logflare https://supabase.com/blog/supabase-acquires-logflare Today, we're ecstatic to announce that Logflare is joining Supabase. - Wed, 01 Dec 2021 03:03:00 +0000 + Wed, 01 Dec 2021 16:04:00 +0000 https://supabase.com/blog/realtime-row-level-security-in-postgresql Realtime Postgres RLS now available on Supabase https://supabase.com/blog/realtime-row-level-security-in-postgresql Realtime database changes are now broadcast to authenticated users, respecting the same PostgreSQL policies that you use for Row Level Security. - Tue, 30 Nov 2021 03:03:00 +0000 + Tue, 30 Nov 2021 16:04:00 +0000 https://supabase.com/blog/supabase-studio Supabase Studio https://supabase.com/blog/supabase-studio The same Dashboard that you're using on our Platform is now available for local development and Self-Hosting. - Mon, 29 Nov 2021 03:03:00 +0000 + Mon, 29 Nov 2021 16:04:00 +0000 https://supabase.com/blog/community-day-lw3 Community Day https://supabase.com/blog/community-day-lw3 Kicking off launch week by highlighting the communities around Supabase. - Sun, 28 Nov 2021 03:03:00 +0000 + Sun, 28 Nov 2021 16:04:00 +0000 https://supabase.com/blog/whats-new-in-postgres-14 New in PostgreSQL 14: What every developer should know https://supabase.com/blog/whats-new-in-postgres-14 A quick look at some new features and functionality in PostgreSQL 14. - Sat, 27 Nov 2021 03:03:00 +0000 + Sat, 27 Nov 2021 16:04:00 +0000 https://supabase.com/blog/postgrest-9 PostgREST 9 https://supabase.com/blog/postgrest-9 New features and updates in PostgREST version 9. - Fri, 26 Nov 2021 03:03:00 +0000 + Fri, 26 Nov 2021 16:04:00 +0000 https://supabase.com/blog/supabase-launch-week-the-trilogy Supabase Launch Week III: Holiday Special https://supabase.com/blog/supabase-launch-week-the-trilogy Tis the season to be shipping. - Thu, 25 Nov 2021 03:03:00 +0000 + Thu, 25 Nov 2021 16:04:00 +0000 https://supabase.com/blog/supabase-how-we-launch How we launch at Supabase https://supabase.com/blog/supabase-how-we-launch The history and methodology of Supabase Launch Week. - Thu, 25 Nov 2021 03:03:00 +0000 + Thu, 25 Nov 2021 16:04:00 +0000 https://supabase.com/blog/supabase-beta-october-2021 Supabase Beta October 2021 https://supabase.com/blog/supabase-beta-october-2021 Three new Auth providers, multi-schema support, and we're gearing up for another Launch Week. - Sat, 06 Nov 2021 03:03:00 +0000 + Sat, 06 Nov 2021 16:04:00 +0000 https://supabase.com/blog/supabase-series-a Supabase $30m Series A https://supabase.com/blog/supabase-series-a Supabase just raised $30M, bringing our total funding to $36M. - Wed, 27 Oct 2021 03:03:00 +0000 + Wed, 27 Oct 2021 16:04:00 +0000 https://supabase.com/blog/replenysh-time-to-value-in-less-than-24-hours Replenysh uses Supabase to implement OTP in less than 24-hours https://supabase.com/blog/replenysh-time-to-value-in-less-than-24-hours Learn how Replenysh uses Supabase to power the circular economy, redefining how brands interact with their customers and products. - Mon, 18 Oct 2021 03:03:00 +0000 + Mon, 18 Oct 2021 16:04:00 +0000 https://supabase.com/blog/hacktoberfest-hackathon-winners-2021 Hacktoberfest Hackathon Winners 2021 https://supabase.com/blog/hacktoberfest-hackathon-winners-2021 Celebrating many amazing projects submitted to our Hacktoberfest Hackathon. - Wed, 13 Oct 2021 03:03:00 +0000 + Wed, 13 Oct 2021 16:04:00 +0000 https://supabase.com/blog/supabase-beta-sept-2021 Supabase Beta Sept 2021 https://supabase.com/blog/supabase-beta-sept-2021 Hackathon, Aborting request, UI updates, and now Hiring. - Sun, 03 Oct 2021 03:03:00 +0000 + Sun, 03 Oct 2021 16:04:00 +0000 https://supabase.com/blog/supabase-hacktoberfest-hackathon-2021 Supabase Hacktoberfest Hackathon 2021 https://supabase.com/blog/supabase-hacktoberfest-hackathon-2021 We're running another Supabase Hackathon during Hacktoberfest! - Mon, 27 Sep 2021 03:03:00 +0000 + Mon, 27 Sep 2021 16:04:00 +0000 https://supabase.com/blog/supabase-beta-august-2021 Supabase Beta August 2021 https://supabase.com/blog/supabase-beta-august-2021 Fundraising, Realtime Security, custom SMS templates, and deployments in South Korea. - Thu, 09 Sep 2021 03:03:00 +0000 + Thu, 09 Sep 2021 16:04:00 +0000 https://supabase.com/blog/supabase-beta-july-2021 Supabase Beta July 2021 https://supabase.com/blog/supabase-beta-july-2021 Discord Logins, Vercel Integration, Full text search, and OAuth guides. - Wed, 11 Aug 2021 03:03:00 +0000 + Wed, 11 Aug 2021 16:04:00 +0000 https://supabase.com/blog/hackathon-winners Open Source Hackathon Winners https://supabase.com/blog/hackathon-winners Let the medal ceremony begin for the best projects submitted during the Supabase Hackathon. - Sun, 08 Aug 2021 03:03:00 +0000 + Sun, 08 Aug 2021 16:04:00 +0000 https://supabase.com/blog/supabase-swag-store Supabase Swag Store https://supabase.com/blog/supabase-swag-store Today we are officially launching the Supabase Swag Store. - Thu, 29 Jul 2021 03:03:00 +0000 + Thu, 29 Jul 2021 16:04:00 +0000 https://supabase.com/blog/supabase-functions-updates Updates for Supabase Functions https://supabase.com/blog/supabase-functions-updates The question on everyone's mind - are we launching Supabase Functions? Well, it's complicated. - Thu, 29 Jul 2021 03:03:00 +0000 + Thu, 29 Jul 2021 16:04:00 +0000 https://supabase.com/blog/1-the-supabase-hackathon The Supabase Hackathon https://supabase.com/blog/1-the-supabase-hackathon A whole week of Hacking for Fun and Prizes. - Thu, 29 Jul 2021 03:03:00 +0000 + Thu, 29 Jul 2021 16:04:00 +0000 https://supabase.com/blog/supabase-reports-and-metrics Supabase Reports and Metrics https://supabase.com/blog/supabase-reports-and-metrics We're exposing a full set of metrics in your projects, so that you can build better (and faster) products for your users. - Wed, 28 Jul 2021 03:03:00 +0000 + Wed, 28 Jul 2021 16:04:00 +0000 https://supabase.com/blog/supabase-auth-passwordless-sms-login Supabase Auth v2: Phone Auth now available https://supabase.com/blog/supabase-auth-passwordless-sms-login Phone Auth is available today on all new and existing Supabase projects. - Tue, 27 Jul 2021 03:03:00 +0000 + Tue, 27 Jul 2021 16:04:00 +0000 https://supabase.com/blog/mobbin-supabase-200000-users Mobbin uses Supabase to authenticate 200,000 users https://supabase.com/blog/mobbin-supabase-200000-users Learn how Mobbin migrated 200,000 users from Firebase for a better authentication experience. - Tue, 27 Jul 2021 03:03:00 +0000 + Tue, 27 Jul 2021 16:04:00 +0000 https://supabase.com/blog/storage-beta Supabase Storage now in Beta https://supabase.com/blog/storage-beta Supabase Storage moves into Beta. - Mon, 26 Jul 2021 03:03:00 +0000 + Mon, 26 Jul 2021 16:04:00 +0000 https://supabase.com/blog/spot-flutter-with-postgres Spot: a video sharing app built with Flutter https://supabase.com/blog/spot-flutter-with-postgres Spot is a geolocation-based video-sharing app with some social networking features. - Mon, 26 Jul 2021 03:03:00 +0000 + Mon, 26 Jul 2021 16:04:00 +0000 https://supabase.com/blog/supabase-postgres-13 Supabase is now on Postgres 13.3 https://supabase.com/blog/supabase-postgres-13 From today, new Supabase projects will be on a version of Supabase Postgres that runs on Postgres 13.3. - Sun, 25 Jul 2021 03:03:00 +0000 + Sun, 25 Jul 2021 16:04:00 +0000 https://supabase.com/blog/supabase-community-day Supabase Community Day https://supabase.com/blog/supabase-community-day Community Day - Sun, 25 Jul 2021 03:03:00 +0000 + Sun, 25 Jul 2021 16:04:00 +0000 https://supabase.com/blog/epsilon3-self-hosting Epsilon3 Self-Host Supabase To Revolutionize Space Operations https://supabase.com/blog/epsilon3-self-hosting Learn how the team at Epsilon3 use Supabase to help teams execute secure and reliable operations in an industry that project spend runs into the billions. - Sun, 25 Jul 2021 03:03:00 +0000 + Sun, 25 Jul 2021 16:04:00 +0000 https://supabase.com/blog/supabase-launch-week-sql Supabase Launch Week II: The SQL https://supabase.com/blog/supabase-launch-week-sql Five days of Supabase. Again. - Wed, 21 Jul 2021 03:03:00 +0000 + Wed, 21 Jul 2021 16:04:00 +0000 https://supabase.com/blog/roles-postgres-hooks Protecting reserved roles with PostgreSQL Hooks https://supabase.com/blog/roles-postgres-hooks Using Postgres Hooks to protect functionality in your Postgres database. - Thu, 01 Jul 2021 03:03:00 +0000 + Thu, 01 Jul 2021 16:04:00 +0000 https://supabase.com/blog/supabase-beta-june-2021 Supabase Beta June 2021 https://supabase.com/blog/supabase-beta-june-2021 Discord Logins, Vercel Integration, Full text search, and OAuth guides. - Tue, 01 Jun 2021 03:03:00 +0000 + Tue, 01 Jun 2021 16:04:00 +0000 https://supabase.com/blog/supabase-beta-may-2021 Supabase Beta May 2021 https://supabase.com/blog/supabase-beta-may-2021 Apple & Twitter Logins, Supabase Grid, Go & Swift Libraries. - Tue, 01 Jun 2021 03:03:00 +0000 + Tue, 01 Jun 2021 16:04:00 +0000 https://supabase.com/blog/supabase-beta-april-2021 Supabase Beta April 2021 https://supabase.com/blog/supabase-beta-april-2021 Supabase "gardening" - stability, security, and community support. - Tue, 04 May 2021 03:03:00 +0000 + Tue, 04 May 2021 16:04:00 +0000 https://supabase.com/blog/supabase-beta-march-2021 Supabase Beta March 2021 https://supabase.com/blog/supabase-beta-march-2021 Launch week, Storage, Supabase CLI, Connection Pooling, Supabase UI, and Pricing. - Mon, 05 Apr 2021 03:03:00 +0000 + Mon, 05 Apr 2021 16:04:00 +0000 https://supabase.com/blog/supabase-workflows Workflows are coming to Supabase https://supabase.com/blog/supabase-workflows Functions are great, but you know what's better? - Thu, 01 Apr 2021 03:03:00 +0000 + Thu, 01 Apr 2021 16:04:00 +0000 https://supabase.com/blog/supabase-pgbouncer PgBouncer is now available in Supabase https://supabase.com/blog/supabase-pgbouncer Better support for Serverless and Postgres. - Thu, 01 Apr 2021 03:03:00 +0000 + Thu, 01 Apr 2021 16:04:00 +0000 https://supabase.com/blog/supabase-dot-com Supabase Dot Com https://supabase.com/blog/supabase-dot-com The Supabase Domain name is changing. - Thu, 01 Apr 2021 03:03:00 +0000 + Thu, 01 Apr 2021 16:04:00 +0000 https://supabase.com/blog/supabase-nft-marketplace Supabase Launches NFT Marketplace https://supabase.com/blog/supabase-nft-marketplace A fully encrypted NFT platform to protect and transact your digital assets - Thu, 01 Apr 2021 03:03:00 +0000 + Wed, 31 Mar 2021 16:04:00 +0000 https://supabase.com/blog/supabase-cli Supabase CLI https://supabase.com/blog/supabase-cli Local development, database migrations, and self-hosting. - Wed, 31 Mar 2021 03:03:00 +0000 + Tue, 30 Mar 2021 16:04:00 +0000 https://supabase.com/blog/supabase-storage Storage is now available in Supabase https://supabase.com/blog/supabase-storage Launching Supabase Storage and how you can use it in your apps - Tue, 30 Mar 2021 03:03:00 +0000 + Mon, 29 Mar 2021 16:04:00 +0000 https://supabase.com/blog/pricing Supabase Beta Pricing https://supabase.com/blog/pricing Supabase launches Beta pricing structure - Mon, 29 Mar 2021 03:03:00 +0000 + Sun, 28 Mar 2021 16:04:00 +0000 https://supabase.com/blog/launch-week Launch week https://supabase.com/blog/launch-week Five days of Supabase. - Wed, 24 Mar 2021 03:03:00 +0000 + Wed, 24 Mar 2021 16:04:00 +0000 https://supabase.com/blog/angels-of-supabase Angels of Supabase https://supabase.com/blog/angels-of-supabase Meet the investors of Supabase. - Wed, 24 Mar 2021 03:03:00 +0000 + Wed, 24 Mar 2021 16:04:00 +0000 https://supabase.com/blog/In-The-Loop Developers stay up to date with intheloop.dev https://supabase.com/blog/In-The-Loop Learn why Kevin is building intheloop.dev with Supabase - Mon, 22 Mar 2021 03:03:00 +0000 + Sun, 21 Mar 2021 16:04:00 +0000 https://supabase.com/blog/using-supabase-replit Using Supabase in Replit https://supabase.com/blog/using-supabase-replit Free hosted relational database from within your node.js repl - Thu, 11 Mar 2021 03:03:00 +0000 + Wed, 10 Mar 2021 16:04:00 +0000 https://supabase.com/blog/toad-a-link-shortener-with-simple-apis-for-low-coders Toad, a link shortener with simple APIs for low-coders https://supabase.com/blog/toad-a-link-shortener-with-simple-apis-for-low-coders An easy-to-use link shortening tool with simple APIs - Mon, 08 Mar 2021 03:03:00 +0000 + Sun, 07 Mar 2021 16:04:00 +0000 https://supabase.com/blog/postgres-as-a-cron-server Postgres as a CRON Server https://supabase.com/blog/postgres-as-a-cron-server Running repetitive tasks with your Postgres database. - Fri, 05 Mar 2021 03:03:00 +0000 + Thu, 04 Mar 2021 16:04:00 +0000 https://supabase.com/blog/supabase-beta-february-2021 Supabase Beta February 2021 https://supabase.com/blog/supabase-beta-february-2021 One year of building. - Tue, 02 Mar 2021 03:03:00 +0000 + Mon, 01 Mar 2021 16:04:00 +0000 https://supabase.com/blog/cracking-postgres-interview Cracking PostgreSQL Interview Questions https://supabase.com/blog/cracking-postgres-interview Understand the top PostgreSQL Interview Questions - Sat, 27 Feb 2021 03:03:00 +0000 + Fri, 26 Feb 2021 16:04:00 +0000 https://supabase.com/blog/case-study-roboflow Roboflow.com choose Supabase to power Paint.wtf leaderboard https://supabase.com/blog/case-study-roboflow Learn how Roboflow.com used Supabase to build their Paint.wtf leaderboard - Tue, 09 Feb 2021 03:03:00 +0000 + Mon, 08 Feb 2021 16:04:00 +0000 https://supabase.com/blog/supabase-beta-january-2021 Supabase Beta January 2021 https://supabase.com/blog/supabase-beta-january-2021 Eleven months of building. - Tue, 02 Feb 2021 03:03:00 +0000 + Mon, 01 Feb 2021 16:04:00 +0000 https://supabase.com/blog/supabase-beta-december-2020 Supabase Beta December 2020 https://supabase.com/blog/supabase-beta-december-2020 Ten months of building. - Sat, 02 Jan 2021 03:03:00 +0000 + Fri, 01 Jan 2021 16:04:00 +0000 https://supabase.com/blog/supabase-dashboard-performance Making the Supabase Dashboard Supa-fast https://supabase.com/blog/supabase-dashboard-performance Improving the performance of the Supabase dashboard - Sun, 13 Dec 2020 03:03:00 +0000 + Sat, 12 Dec 2020 16:04:00 +0000 https://supabase.com/blog/supabase-striveschool Supabase Partners With Strive School To Help Teach Open Source https://supabase.com/blog/supabase-striveschool Supabase Partners With Strive School To Help Teach Open Source To The Next Generation Of Developers - Wed, 02 Dec 2020 03:03:00 +0000 + Tue, 01 Dec 2020 16:04:00 +0000 https://supabase.com/blog/case-study-xendit Xendit Built a Counter-Fraud Watchlist for the Fintech Industry https://supabase.com/blog/case-study-xendit See how Xendit use Supabase to build a full-text search engine. - Wed, 02 Dec 2020 03:03:00 +0000 + Tue, 01 Dec 2020 16:04:00 +0000 https://supabase.com/blog/case-study-tayfa TAYFA Built a No-Code Website Builder in Seven Days https://supabase.com/blog/case-study-tayfa See how Tayfa went from idea to paying customer in less than 30 days. - Wed, 02 Dec 2020 03:03:00 +0000 + Tue, 01 Dec 2020 16:04:00 +0000 https://supabase.com/blog/case-study-monitoro Monitoro Built a Web Crawler Handling Millions of API Requests https://supabase.com/blog/case-study-monitoro See how Monitoro built an automated scraping platform using Supabase. - Wed, 02 Dec 2020 03:03:00 +0000 + Tue, 01 Dec 2020 16:04:00 +0000 https://supabase.com/blog/supabase-alpha-november-2020 Supabase Alpha November 2020 https://supabase.com/blog/supabase-alpha-november-2020 Nine months of building. - Tue, 01 Dec 2020 03:03:00 +0000 + Mon, 30 Nov 2020 16:04:00 +0000 https://supabase.com/blog/postgresql-views Postgres Views https://supabase.com/blog/postgresql-views Creating and using a view in PostgreSQL. - Wed, 18 Nov 2020 03:03:00 +0000 + Tue, 17 Nov 2020 16:04:00 +0000 https://supabase.com/blog/supabase-alpha-october-2020 Supabase Alpha October 2020 https://supabase.com/blog/supabase-alpha-october-2020 Eight months of building. - Mon, 02 Nov 2020 03:03:00 +0000 + Sun, 01 Nov 2020 16:04:00 +0000 https://supabase.com/blog/improved-dx Supabase.js 1.0 https://supabase.com/blog/improved-dx We're releasing a new version of our Supabase client with some awesome new improvements. - Fri, 30 Oct 2020 03:03:00 +0000 + Thu, 29 Oct 2020 16:04:00 +0000 https://supabase.com/blog/supabase-alpha-september-2020 Supabase Alpha September 2020 https://supabase.com/blog/supabase-alpha-september-2020 Seven months of building. - Sat, 03 Oct 2020 03:03:00 +0000 + Fri, 02 Oct 2020 16:04:00 +0000 https://supabase.com/blog/supabase-hacktoberfest-2020 Supabase Hacktoberfest 2020 https://supabase.com/blog/supabase-hacktoberfest-2020 Join us for a celebration of open source software and learn how to contribute to Supabase. - Fri, 11 Sep 2020 03:03:00 +0000 + Thu, 10 Sep 2020 16:04:00 +0000 https://supabase.com/blog/supabase-alpha-august-2020 Supabase Alpha August 2020 https://supabase.com/blog/supabase-alpha-august-2020 Six months of building - Thu, 03 Sep 2020 03:03:00 +0000 + Wed, 02 Sep 2020 16:04:00 +0000 https://supabase.com/blog/supabase-auth Supabase Auth https://supabase.com/blog/supabase-auth Authenticate and authorize your users with Supabase Auth - Wed, 05 Aug 2020 03:03:00 +0000 + Tue, 04 Aug 2020 16:04:00 +0000 https://supabase.com/blog/supabase-alpha-july-2020 Supabase Alpha July 2020 https://supabase.com/blog/supabase-alpha-july-2020 Five months of building - Sun, 02 Aug 2020 03:03:00 +0000 + Sat, 01 Aug 2020 16:04:00 +0000 https://supabase.com/blog/continuous-postgresql-backup-walg Continuous PostgreSQL Backups using WAL-G https://supabase.com/blog/continuous-postgresql-backup-walg Have you ever wanted to restore your database's state to a particular moment in time? This post explains how, using WAL-G. - Sun, 02 Aug 2020 03:03:00 +0000 + Sat, 01 Aug 2020 16:04:00 +0000 https://supabase.com/blog/alpha-launch-postmortem Alpha Launch Postmortem https://supabase.com/blog/alpha-launch-postmortem Everything that went wrong with Supabase's launch - Fri, 10 Jul 2020 03:03:00 +0000 + Thu, 09 Jul 2020 16:04:00 +0000 https://supabase.com/blog/postgresql-templates What are PostgreSQL Templates? https://supabase.com/blog/postgresql-templates What are PostgreSQL templates and what are they used for? - Thu, 09 Jul 2020 03:03:00 +0000 + Wed, 08 Jul 2020 16:04:00 +0000 https://supabase.com/blog/postgresql-physical-logical-backups Physical vs Logical Backups in PostgreSQL https://supabase.com/blog/postgresql-physical-logical-backups What are physical and logical backups in Postgres? - Tue, 07 Jul 2020 03:03:00 +0000 + Mon, 06 Jul 2020 16:04:00 +0000 https://supabase.com/blog/supabase-alpha-june-2020 Supabase Alpha June 2020 https://supabase.com/blog/supabase-alpha-june-2020 Four months of building - Wed, 01 Jul 2020 03:03:00 +0000 + Tue, 30 Jun 2020 16:04:00 +0000 https://supabase.com/blog/supabase-steve-chavez Steve Chavez has joined Supabase https://supabase.com/blog/supabase-steve-chavez Steve joins Supabase to help build Auth. - Mon, 15 Jun 2020 03:03:00 +0000 + Sun, 14 Jun 2020 16:04:00 +0000 https://supabase.com/blog/supabase-alpha-may-2020 Supabase Alpha May 2020 https://supabase.com/blog/supabase-alpha-may-2020 Three months of building - Mon, 01 Jun 2020 03:03:00 +0000 + Sun, 31 May 2020 16:04:00 +0000 https://supabase.com/blog/supabase-alpha-april-2020 Supabase Alpha April 2020 https://supabase.com/blog/supabase-alpha-april-2020 Two months of building - Mon, 01 Jun 2020 03:03:00 +0000 + Sun, 31 May 2020 16:04:00 +0000 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 4f4cb0c4595..3159fad857a 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx @@ -35,8 +35,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 +}