diff --git a/DEVELOPERS.md b/DEVELOPERS.md index 1a4e1a1e27c..26618c6f400 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -69,11 +69,11 @@ To contribute code to [Supabase](https://supabase.com), you must fork the [Supab Then visit, and edit, any of the following sites: -| Site | Directory | Scope name | Description | Local development server | -| ---------------------------------------------- | ------------ | ---------- | ------------------------------------ | -------------------------- | -| [supabase.com](https://supabase.com) | `/apps/www` | www | The main website | http://localhost:3000 | -| [app.supabase.com](https://app.supabase.com) | `/studio` | studio | Studio dashboard | http://localhost:8082 | -| [supabase.com/docs](https://supabase.com/docs) | `/apps/docs` | docs | Guides and Reference (Next.js based) | http://localhost:3001/docs | +| Site | Directory | Scope name | Description | Local development server | +| -------------------------------------------------------- | ------------ | ---------- | ------------------------------------ | -------------------------- | +| [supabase.com](https://supabase.com) | `/apps/www` | www | The main website | http://localhost:3000 | +| [supabase.com/dashboard](https://supabase.com/dashboard) | `/studio` | studio | Studio dashboard | http://localhost:8082 | +| [supabase.com/docs](https://supabase.com/docs) | `/apps/docs` | docs | Guides and Reference (Next.js based) | http://localhost:3001/docs | #### Running sites individually diff --git a/README.md b/README.md index b71944fad9c..5cd68c3014d 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ - [x] Hosted Postgres Database. [Docs](https://supabase.com/docs/guides/database) - [x] Authentication and Authorization. [Docs](https://supabase.com/docs/guides/auth) - [x] Auto-generated APIs. - - [x] REST. [Docs](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Docs](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Realtime subscriptions. [Docs](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Docs](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Docs](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Realtime subscriptions. [Docs](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Functions. - [x] Database Functions. [Docs](https://supabase.com/docs/guides/database/functions) - [x] Edge Functions [Docs](https://supabase.com/docs/guides/functions) @@ -40,7 +40,7 @@ To see how to Contribute, visit [Getting Started](./DEVELOPERS.md) ## Status - [x] Alpha: We are testing Supabase with a closed set of customers -- [x] Public Alpha: Anyone can sign up over at [app.supabase.com](https://app.supabase.com). But go easy on us, there are a few kinks +- [x] Public Alpha: Anyone can sign up over at [supabase.com/dashboard](https://supabase.com/dashboard). But go easy on us, there are a few kinks - [x] Public Beta: Stable enough for most non-enterprise use-cases - [ ] Public: General Availability [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -56,7 +56,7 @@ Supabase is a combination of open source tools. We’re building the features of **Architecture** -Supabase is a [hosted platform](https://app.supabase.com). You can sign up and start using Supabase without installing anything. +Supabase is a [hosted platform](https://supabase.com/dashboard). You can sign up and start using Supabase without installing anything. You can also [self-host](https://supabase.com/docs/guides/hosting/overview) and [develop locally](https://supabase.com/docs/guides/local-development). ![Architecture](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) @@ -260,4 +260,3 @@ Our approach for client libraries is modular. Each sub-library is a standalone i - [Ukrainian / Українська](/i18n/README.uk.md) - [Vietnamese / Tiếng Việt](/i18n/README.vi-vn.md) - [List of translations](/i18n/languages.md) - diff --git a/apps/docs/components/Frameworks.tsx b/apps/docs/components/Frameworks.tsx index a1b6d0da29b..94464c4c395 100644 --- a/apps/docs/components/Frameworks.tsx +++ b/apps/docs/components/Frameworks.tsx @@ -85,6 +85,14 @@ const Frameworks = () => { }, href: '/guides/with-vue-3', }, + { + name: 'refine', + logo: { + light: '/docs/img/icons/refine-icon.svg', + dark: '/docs/img/icons/refine-icon.svg', + }, + href: '/guides/getting-started/tutorials/with-refine', + }, ] return (
diff --git a/apps/docs/components/HomePageCover.tsx b/apps/docs/components/HomePageCover.tsx index 3bf27c6b2a0..597e64e8174 100644 --- a/apps/docs/components/HomePageCover.tsx +++ b/apps/docs/components/HomePageCover.tsx @@ -48,6 +48,11 @@ const HomePageCover = (props) => { icon: '/docs/img/icons/nuxt-icon', href: '/guides/getting-started/quickstarts/nuxtjs', }, + { + tooltip: 'refine', + icon: '/docs/img/icons/refine-icon', + href: '/guides/getting-started/quickstarts/refine', + }, ] const GettingStarted = () => ( diff --git a/apps/docs/components/MDX/database_setup.mdx b/apps/docs/components/MDX/database_setup.mdx index 2a6e853f9c2..c54b9b32f84 100644 --- a/apps/docs/components/MDX/database_setup.mdx +++ b/apps/docs/components/MDX/database_setup.mdx @@ -12,7 +12,7 @@ Your database will be available in less than a minute. **Finding your credentials:** -You can find your project credentials inside the project [settings](https://app.supabase.com/project/_/settings/), including: +You can find your project credentials inside the project [settings](https://supabase.com/dashboard/project/_/settings/), including: -- [Database credentials](https://app.supabase.com/project/_/settings/database): connection strings and connection pooler details. -- [API credentials](https://app.supabase.com/project/_/settings/database): your serverless API URL and `anon` / `service_role` keys. +- [Database credentials](https://supabase.com/dashboard/project/_/settings/database): connection strings and connection pooler details. +- [API credentials](https://supabase.com/dashboard/project/_/settings/database): your serverless API URL and `anon` / `service_role` keys. diff --git a/apps/docs/components/MDX/project_setup.mdx b/apps/docs/components/MDX/project_setup.mdx index 2003a3a2220..d7b095e1288 100644 --- a/apps/docs/components/MDX/project_setup.mdx +++ b/apps/docs/components/MDX/project_setup.mdx @@ -8,7 +8,7 @@ Before we start building we're going to set up our Database and API. This is as ### Create a project -1. [Create a new project](https://app.supabase.com) in the Supabase Dashboard. +1. [Create a new project](https://supabase.com/dashboard) in the Supabase Dashboard. 1. Enter your project details. 1. Wait for the new database to launch. @@ -24,7 +24,7 @@ Now we are going to set up the database schema. We can use the "User Management > -1. Go to the [SQL Editor](https://app.supabase.com/project/_/sql) page in the Dashboard. +1. Go to the [SQL Editor](https://supabase.com/dashboard/project/_/sql) page in the Dashboard. 2. Click **User Management Starter**. 3. Click **Run**. @@ -41,5 +41,5 @@ Now we are going to set up the database schema. We can use the "User Management Now that you've created some database tables, you are ready to insert data using the auto-generated API. We just need to get the Project URL and `anon` key from the API settings. -1. Go to the [API Settings](https://app.supabase.com/project/_/settings/api) page in the Dashboard. +1. Go to the [API Settings](https://supabase.com/dashboard/project/_/settings/api) page in the Dashboard. 1. Find your Project `URL`, `anon`, and `service_role` keys on this page. diff --git a/apps/docs/components/MDX/social_provider_settings_supabase.mdx b/apps/docs/components/MDX/social_provider_settings_supabase.mdx index 38e0d9e5bfe..394673df620 100644 --- a/apps/docs/components/MDX/social_provider_settings_supabase.mdx +++ b/apps/docs/components/MDX/social_provider_settings_supabase.mdx @@ -1,6 +1,6 @@ -- Go to your [Supabase Project Dashboard](https://app.supabase.com) +- Go to your [Supabase Project Dashboard](https://supabase.com/dashboard) - In the left sidebar, click the `Authentication` icon (near the top) -- Click on [`Providers`](https://app.supabase.com/project/_/auth/providers) under the Configuration section +- Click on [`Providers`](https://supabase.com/dashboard/project/_/auth/providers) under the Configuration section - Click on **{props.provider}** from the accordion list to expand and turn **{props.provider} Enabled** to ON - Enter your **{props.provider} Client ID** and **{props.provider} Client Secret** saved in the previous step - Click `Save` diff --git a/apps/docs/components/MDX/social_provider_setup.mdx b/apps/docs/components/MDX/social_provider_setup.mdx index 4e32c3bf401..e6c1fe7e6dc 100644 --- a/apps/docs/components/MDX/social_provider_setup.mdx +++ b/apps/docs/components/MDX/social_provider_setup.mdx @@ -2,7 +2,7 @@ The next step requires a callback URL, which looks like this: `https://.supabase.co/auth/v1/callback` -- Go to your [Supabase Project Dashboard](https://app.supabase.com) +- Go to your [Supabase Project Dashboard](https://supabase.com/dashboard) - Click on the `Authentication` icon in the left sidebar -- Click on [`Providers`](https://app.supabase.com/project/_/auth/providers) under the Configuration section +- Click on [`Providers`](https://supabase.com/dashboard/project/_/auth/providers) under the Configuration section - Click on **{props.provider}** from the accordion list to expand and you'll find your **Redirect URL**, you can click `Copy` to copy it to the clipboard diff --git a/apps/docs/components/MDX/storage_management.mdx b/apps/docs/components/MDX/storage_management.mdx index e8f0184b88e..ce28a1684c8 100644 --- a/apps/docs/components/MDX/storage_management.mdx +++ b/apps/docs/components/MDX/storage_management.mdx @@ -7,7 +7,7 @@ triggers. Note that it is not sufficient to delete the objects from the `storage.objects` table because that would orphan and leak the actual storage objects in the S3 backend. Instead, invoke the storage API within Postgres via the `http` extension. -Enable the [http extension for the `extensions` schema](https://app.supabase.com/project/_/database/extensions) in the Dashboard. +Enable the [http extension for the `extensions` schema](https://supabase.com/dashboard/project/_/database/extensions) in the Dashboard. Then, define the following SQL functions in the SQL Editor to delete storage objects via the API: diff --git a/apps/docs/components/Navigation/Footer.tsx b/apps/docs/components/Navigation/Footer.tsx index 600f81f40e8..32f1b4f73db 100644 --- a/apps/docs/components/Navigation/Footer.tsx +++ b/apps/docs/components/Navigation/Footer.tsx @@ -1,7 +1,7 @@ import Link from 'next/link' import { Button } from 'ui' import { primaryLinks, secondaryLinks } from '~/data/footer' -import { LayoutMainContent } from '../../layouts/DefaultLayout' +import { LayoutMainContent } from '~/layouts/DefaultLayout' const Footer = () => ( @@ -75,20 +75,18 @@ const Footer = () => (
-

The Kotlin client library is created and maintained by the Supabase community, and is not an official library. Please be tolerant of areas where the library is still being developed, and — as with all the libraries — feel free to contribute wherever you find issues.

+

The Kotlin client library is created and maintained by the Supabase community, and is not an official library. Please be tolerant of areas where the library is still being developed, and — as with all the libraries — feel free to contribute wherever you find issues.

Huge thanks to official maintainer, [jan-tennert](https://github.com/jan-tennert).

diff --git a/apps/docs/docs/reference/api.mdx b/apps/docs/docs/reference/api.mdx index 371041af0bd..70bb75ee9f6 100644 --- a/apps/docs/docs/reference/api.mdx +++ b/apps/docs/docs/reference/api.mdx @@ -15,7 +15,7 @@ The Management API is in `beta`. It is usable in it's current state, but it's li ## Authentication All API requests require a Supabase Personal token to be included in the Authorization header: `Authorization Bearer - - - + + + + + diff --git a/apps/docs/pages/guides/ai/engineering-for-scale.mdx b/apps/docs/pages/guides/ai/engineering-for-scale.mdx index 663550fc4a8..0621207930f 100644 --- a/apps/docs/pages/guides/ai/engineering-for-scale.mdx +++ b/apps/docs/pages/guides/ai/engineering-for-scale.mdx @@ -12,7 +12,7 @@ Content sources for vectors can be extremely large. As you grow you should run y ## Simple workloads -For small workloads it's typical to store your data in a single database. +For small workloads, it's typical to store your data in a single database. If you've used [Vecs](/docs/guides/ai/vecs-python-client) to create 3 different collections, you can expose collections to your web or mobile application using [views](/docs/guides/database/tables#views): @@ -93,9 +93,9 @@ docs.query( ### Accessing external collections using Wrappers -Supabase supports [Foreign Data Wrappers](/blog/postgres-foreign-data-wrappers-rust). Wrappers allow you connect two databases together so that you can query them over the network. +Supabase supports [Foreign Data Wrappers](/blog/postgres-foreign-data-wrappers-rust). Wrappers allow you to connect two databases together so that you can query them over the network. -This involves 2 steps: connecting to your remote database from the primary, and creating a Foreign Table. +This involves 2 steps: connecting to your remote database from the primary and creating a Foreign Table. #### Connecting your remote database @@ -140,7 +140,7 @@ const { data, error } = await supabase ### Enterprise architecture -This diagram provides an example architecture, allowing you to access the collections either with our client libraries or using Vecs. You can add as many secondary databases as you need, in this example we show one only: +This diagram provides an example architecture that allows you to access the collections either with our client libraries or using Vecs. You can add as many secondary databases as you need (in this example we only show one):
`API Settings` > `Exposed schemas` +6. Expose `docs` schema via API in Supabase Dashboard [settings](https://supabase.com/dashboard/project/_/settings/api) > `API Settings` > `Exposed schemas` ### Ingest your documentation diff --git a/apps/docs/pages/guides/ai/examples/huggingface-image-captioning.mdx b/apps/docs/pages/guides/ai/examples/huggingface-image-captioning.mdx index 1f891655d85..163422ee325 100644 --- a/apps/docs/pages/guides/ai/examples/huggingface-image-captioning.mdx +++ b/apps/docs/pages/guides/ai/examples/huggingface-image-captioning.mdx @@ -20,15 +20,15 @@ We can combine Hugging Face with [Supabase Storage](https://supabase.com/storage ## Setup -- Open your Supabase project dashboard or [create a new project](https://app.supabase.com/projects). -- [Create a new bucket](https://app.supabase.com/project/_/storage/buckets) called `images`. +- Open your Supabase project dashboard or [create a new project](https://supabase.com/dashboard/projects). +- [Create a new bucket](https://supabase.com/dashboard/project/_/storage/buckets) called `images`. - Generate TypeScript types from remote Database. - Create a new Database table called `image_caption`. - Create `id` column of type `uuid` which references `storage.objects.id`. - Create a `caption` column of type `text`. - Regenerate TypeScript types to include new `image_caption` table. - Deploy the function to Supabase: `supabase functions deploy huggingface-image-captioning`. -- Create the Database Webhook in the [Supabase Dashboard](https://app.supabase.com/project/_/database/hooks) to trigger the `huggingface-image-captioning` function anytime a record is added to the `storage.objects` table. +- Create the Database Webhook in the [Supabase Dashboard](https://supabase.com/dashboard/project/_/database/hooks) to trigger the `huggingface-image-captioning` function anytime a record is added to the `storage.objects` table. ## Generate TypeScript Types diff --git a/apps/docs/pages/guides/ai/examples/nextjs-vector-search.mdx b/apps/docs/pages/guides/ai/examples/nextjs-vector-search.mdx index b340a6607b6..017eb68ec95 100644 --- a/apps/docs/pages/guides/ai/examples/nextjs-vector-search.mdx +++ b/apps/docs/pages/guides/ai/examples/nextjs-vector-search.mdx @@ -22,13 +22,13 @@ We assume that you have a Next.js project with a collection of `.mdx` files nest ## Create a project -1. [Create a new project](https://app.supabase.com) in the Supabase Dashboard. +1. [Create a new project](https://supabase.com/dashboard) in the Supabase Dashboard. 1. Enter your project details. 1. Wait for the new database to launch. ## Prepare the database -Let's prepare the database schema. We can use the "OpenAI Vector Search" quickstart in the [SQL Editor](https://app.supabase.com/project/_/sql), or you can copy/paste the SQL below and run it yourself. +Let's prepare the database schema. We can use the "OpenAI Vector Search" quickstart in the [SQL Editor](https://supabase.com/dashboard/project/_/sql), or you can copy/paste the SQL below and run it yourself. -1. Go to the [SQL Editor](https://app.supabase.com/project/_/sql) page in the Dashboard. +1. Go to the [SQL Editor](https://supabase.com/dashboard/project/_/sql) page in the Dashboard. 2. Click **OpenAI Vector Search**. 3. Click **Run**. diff --git a/apps/docs/pages/guides/ai/google-colab.mdx b/apps/docs/pages/guides/ai/google-colab.mdx index c3e02029842..d6edee7ba26 100644 --- a/apps/docs/pages/guides/ai/google-colab.mdx +++ b/apps/docs/pages/guides/ai/google-colab.mdx @@ -17,7 +17,7 @@ export const meta = { Google Colab is a hosted Jupyter Notebook service. It provides free access to computing resources, including GPUs and TPUs, and is well-suited to machine learning, data science, and education. We can use Colab to manage collections using [Supabase Vecs](/docs/guides/ai/vecs-python-client). -In this tutorial we'll connect to a database running on the Supabase [platform](https://app.supabase.com/). If you don't already have a database, you can create one here: [database.new](https://database.new). +In this tutorial we'll connect to a database running on the Supabase [platform](https://supabase.com/dashboard/). If you don't already have a database, you can create one here: [database.new](https://database.new). ## Create a new notebook @@ -39,7 +39,7 @@ pip install vecs ## Connect to your database -Find the Postgres connection string for your Supabase project in the [database settings](https://app.supabase.com/_/settings/database) of the dashboard. Copy the "URI" format, which should look something like `postgresql:/postgres:@:5432/postgres` +Find the Postgres connection string for your Supabase project in the [database settings](https://supabase.com/dashboard/_/settings/database) of the dashboard. Copy the "URI" format, which should look something like `postgresql:/postgres:@:5432/postgres` Create a new code block below the install block (`ctrl+m b`) and add the following code using the Postgres URI you copied above: @@ -79,7 +79,7 @@ collection.upsert( ) ``` -This will create a table inside your database within the `vecs` schema, called `colab_collection`. You can view the inserted items in the [Table Editor](https://app.supabase.com/project/_/editor/), by selecting the `vecs` schema from the schema dropdown. +This will create a table inside your database within the `vecs` schema, called `colab_collection`. You can view the inserted items in the [Table Editor](https://supabase.com/dashboard/project/_/editor/), by selecting the `vecs` schema from the schema dropdown. ![Colab documents](/docs/img/ai/google-colab/colab-documents.png) diff --git a/apps/docs/pages/guides/ai/integrations/llamaindex.mdx b/apps/docs/pages/guides/ai/integrations/llamaindex.mdx index 59db32b9cd3..b1aa7f80b2a 100644 --- a/apps/docs/pages/guides/ai/integrations/llamaindex.mdx +++ b/apps/docs/pages/guides/ai/integrations/llamaindex.mdx @@ -52,7 +52,7 @@ Replace the `DB_CONNECTION` with your own connection string for your database, w Now all that's left is to step through the notebook. You can do this by clicking the "execute" button (`ctrl+enter`) at the top left of each code cell. The notebook guides you through the process of creating a collection, adding data to it, and querying it. -You can view the inserted items in the [Table Editor](https://app.supabase.com/project/_/editor/), by selecting the `vecs` schema from the schema dropdown. +You can view the inserted items in the [Table Editor](https://supabase.com/dashboard/project/_/editor/), by selecting the `vecs` schema from the schema dropdown. ![Colab documents](/docs/img/ai/google-colab/colab-documents.png) diff --git a/apps/docs/pages/guides/ai/langchain.mdx b/apps/docs/pages/guides/ai/langchain.mdx index 3f66da36b3e..fa8320819a2 100644 --- a/apps/docs/pages/guides/ai/langchain.mdx +++ b/apps/docs/pages/guides/ai/langchain.mdx @@ -1,4 +1,6 @@ import Layout from '~/layouts/DefaultGuideLayout' +import { Tabs } from 'ui' +export const TabPanel = Tabs.Panel export const meta = { id: 'ai-lang-chain', @@ -14,6 +16,21 @@ export const meta = { Prepare you database with the relevant tables: + + + +1. Go to the [SQL Editor](https://supabase.com/dashboard/project/_/sql) page in the Dashboard. +2. Click **LangChain** in the Quick start section. +3. Click **Run**. + + + + ```sql -- Enable the pgvector extension to work with embedding vectors create extension vector; @@ -55,6 +72,9 @@ end; $$; ``` + + + ## Usage You can now search your documents using any Node.js application. This is intended to be run on a secure server route. diff --git a/apps/docs/pages/guides/ai/managing-collections.mdx b/apps/docs/pages/guides/ai/managing-collections.mdx index 5849f6aa526..58e700e2927 100644 --- a/apps/docs/pages/guides/ai/managing-collections.mdx +++ b/apps/docs/pages/guides/ai/managing-collections.mdx @@ -7,7 +7,7 @@ export const meta = { sidebar_label: 'Managing collections', } -A collection is an group of vector records managed by the `vecs` Python library. Records can be added to or updated in a collection. Collections can be queried at any time, but should be indexed for scalable query performance. +A collection is a group of vector records managed by the `vecs` Python library. Records can be added to or updated in a collection. Collections can be queried at any time, but should be indexed for scalable query performance. Supabase provides a [Python client](/docs/guides/ai/vecs-python-client) called `vecs` for managing unstructured vector stores in Postgres. If you come from a data science background, this unstructured data approach will feel familiar. If you are more interested in a structured data approach, see [Vector columns](/docs/guides/ai/vector-columns) or read our guide on [Structured & Unstructured Embeddings](/docs/guides/ai/structured-unstructured-embeddings). @@ -32,7 +32,7 @@ vx = vecs.create_client(DB_CONNECTION) ### Create collection -You can create a collection to store vectors specifying the collections name and the number of dimensions in the vectors you intend to store. +You can create a collection to store vectors specifying the collection's name and the number of dimensions in the vectors you intend to store. ```python docs = vx.create_collection(name="docs", dimension=3) @@ -75,7 +75,7 @@ docs.upsert( Collections can be queried immediately after being created. However, for good performance, the collection should be indexed after records have been upserted. -Indexes should be created **after** the collection has been populated with records. Building an index on an empty collection will result in significantly reduced recall. Once the index has been created you can still upsert new documents into the collection but you should rebuild the index if the size of the collection more than doubles. +Indexes should be created **after** the collection has been populated with records. Building an index on an empty collection will significantly reduce recall. Once the index has been created you can still upsert new documents into the collection but you should rebuild the index if the size of the collection more than doubles. Only one index may exist per-collection. By default, creating an index will replace any existing index. diff --git a/apps/docs/pages/guides/ai/quickstarts/face-similarity.mdx b/apps/docs/pages/guides/ai/quickstarts/face-similarity.mdx index 9bc0be7c9c2..160ed479b6d 100644 --- a/apps/docs/pages/guides/ai/quickstarts/face-similarity.mdx +++ b/apps/docs/pages/guides/ai/quickstarts/face-similarity.mdx @@ -3,11 +3,11 @@ import Layout from '~/layouts/DefaultGuideLayout' export const meta = { id: 'ai-vecs-python-client', title: 'Face similarity search', - subtitle: 'Identify the celebrities you looks most similar to using Supabase Vecs.', + subtitle: 'Identify the celebrities who look most similar to you using Supabase Vecs.', breadcrumb: 'AI Quickstarts', } -This guide will walk you through a ["Face Similarity Search"](https://github.com/supabase/supabase/blob/master/examples/ai/face_similarity.ipynb) example using Colab and Supabase Vecs. You'll identify the celebrities you (or any other person) looks most similar to. You will: +This guide will walk you through a ["Face Similarity Search"](https://github.com/supabase/supabase/blob/master/examples/ai/face_similarity.ipynb) example using Colab and Supabase Vecs. You will be able to identify the celebrities who look most similar to you (or any other person). You will: 1. Launch a Postgres database that uses pgvector to store embeddings 1. Launch a notebook that connects to your database @@ -49,7 +49,7 @@ Replace the `DB_CONNECTION` with your own connection string for your database, w Now all that's left is to step through the notebook. You can do this by clicking the "execute" button (`ctrl+enter`) at the top left of each code cell. The notebook guides you through the process of creating a collection, adding data to it, and querying it. -You can view the inserted items in the [Table Editor](https://app.supabase.com/project/_/editor/), by selecting the `vecs` schema from the schema dropdown. +You can view the inserted items in the [Table Editor](https://supabase.com/dashboard/project/_/editor/), by selecting the `vecs` schema from the schema dropdown. ![Colab documents](/docs/img/ai/google-colab/colab-documents.png) diff --git a/apps/docs/pages/guides/ai/quickstarts/hello-world.mdx b/apps/docs/pages/guides/ai/quickstarts/hello-world.mdx index 7b678dc1d20..d612ed23d86 100644 --- a/apps/docs/pages/guides/ai/quickstarts/hello-world.mdx +++ b/apps/docs/pages/guides/ai/quickstarts/hello-world.mdx @@ -49,7 +49,7 @@ Replace the `DB_CONNECTION` with your own connection string for your database, w Now all that's left is to step through the notebook. You can do this by clicking the "execute" button (`ctrl+enter`) at the top left of each code cell. The notebook guides you through the process of creating a collection, adding data to it, and querying it. -You can view the inserted items in the [Table Editor](https://app.supabase.com/project/_/editor/), by selecting the `vecs` schema from the schema dropdown. +You can view the inserted items in the [Table Editor](https://supabase.com/dashboard/project/_/editor/), by selecting the `vecs` schema from the schema dropdown. ![Colab documents](/docs/img/ai/google-colab/colab-documents.png) diff --git a/apps/docs/pages/guides/ai/quickstarts/text-deduplication.mdx b/apps/docs/pages/guides/ai/quickstarts/text-deduplication.mdx index 8d20cf9cca4..d15cbbd1289 100644 --- a/apps/docs/pages/guides/ai/quickstarts/text-deduplication.mdx +++ b/apps/docs/pages/guides/ai/quickstarts/text-deduplication.mdx @@ -50,7 +50,7 @@ Replace the `DB_CONNECTION` with your own connection string for your database, w Now all that's left is to step through the notebook. You can do this by clicking the "execute" button (`ctrl+enter`) at the top left of each code cell. The notebook guides you through the process of creating a collection, adding data to it, and querying it. -You can view the inserted items in the [Table Editor](https://app.supabase.com/project/_/editor/), by selecting the `vecs` schema from the schema dropdown. +You can view the inserted items in the [Table Editor](https://supabase.com/dashboard/project/_/editor/), by selecting the `vecs` schema from the schema dropdown. ![Colab documents](/docs/img/ai/google-colab/colab-documents.png) diff --git a/apps/docs/pages/guides/ai/vecs-python-client.mdx b/apps/docs/pages/guides/ai/vecs-python-client.mdx index 0be65c7c744..bdcecb77c43 100644 --- a/apps/docs/pages/guides/ai/vecs-python-client.mdx +++ b/apps/docs/pages/guides/ai/vecs-python-client.mdx @@ -42,7 +42,7 @@ docs = vx.create_collection(name="docs", dimension=3) ### Add embeddings -Now we can insert some embeddings into our "docs" collection using the `usert()` command: +Now we can insert some embeddings into our "docs" collection using the `upsert()` command: ```py import vecs diff --git a/apps/docs/pages/guides/ai/vector-columns.mdx b/apps/docs/pages/guides/ai/vector-columns.mdx index d941c164302..7c768606e0b 100644 --- a/apps/docs/pages/guides/ai/vector-columns.mdx +++ b/apps/docs/pages/guides/ai/vector-columns.mdx @@ -23,7 +23,7 @@ Vectors in Supabase are enabled via [pgvector](https://github.com/pgvector/pgvec > -1. Go to the [Database](https://app.supabase.com/project/_/database/tables) page in the Dashboard. +1. Go to the [Database](https://supabase.com/dashboard/project/_/database/tables) page in the Dashboard. 2. Click on **Extensions** in the sidebar. 3. Search for "vector" and enable the extension. diff --git a/apps/docs/pages/guides/api.mdx b/apps/docs/pages/guides/api.mdx index 7ca9052585d..5ac4bc65ba3 100644 --- a/apps/docs/pages/guides/api.mdx +++ b/apps/docs/pages/guides/api.mdx @@ -50,7 +50,7 @@ Reference: ## GraphQL API [#graphql-api-overview] Supabase uses [pg_graphql](https://supabase.github.io/pg_graphql/) to expose a GraphQL API endpoint at `https://.supabase.co/graphql/v1/`. -You can introspect and query the GraphQL API of an existing Supabase project within Studio [here](https://app.supabase.com/project/_/api/graphiql), +You can introspect and query the GraphQL API of an existing Supabase project within Studio [here](https://supabase.com/dashboard/project/_/api/graphiql), or navigate there manually at `API Docs > GraphQL > GraphiQL`. The GraphQL interface is automatically reflected from your database's schema and supports: @@ -72,11 +72,11 @@ Reference: Supabase provides a Realtime API using [Realtime](https://github.com/supabase/realtime). You can use this to listen to database changes over websockets. Realtime leverages PostgreSQL's built-in logical replication. You can manage your Realtime API simply by managing Postgres publications. -Go to your project's [Replication section](https://app.supabase.com/project/_/database/replication) to get started. +Go to your project's [Replication section](https://supabase.com/dashboard/project/_/database/replication) to get started. ## API URL and Keys -You can find the API URL and Keys in the [Dashboard](https://app.supabase.com/project/_/settings/api). +You can find the API URL and Keys in the [Dashboard](https://supabase.com/dashboard/project/_/settings/api).
diff --git a/apps/docs/pages/learn/auth-deep-dive/auth-deep-dive-jwts.mdx b/apps/docs/pages/learn/auth-deep-dive/auth-deep-dive-jwts.mdx index d33020220ed..d621ec7ade2 100644 --- a/apps/docs/pages/learn/auth-deep-dive/auth-deep-dive-jwts.mdx +++ b/apps/docs/pages/learn/auth-deep-dive/auth-deep-dive-jwts.mdx @@ -191,7 +191,7 @@ Now that you understand what JWTs are and where they're used in Supabase, you ca - [Part Three: Policies](../../learn/auth-deep-dive/auth-policies) - [Part Four: GoTrue](../../learn/auth-deep-dive/auth-gotrue) - [Part Five: Google Oauth](../../learn/auth-deep-dive/auth-google-oauth) -- Sign up for Supabase: [app.supabase.com](https://app.supabase.com) +- Sign up for Supabase: [supabase.com/dashboard](https://supabase.com/dashboard) export const Page = ({ children }) => diff --git a/apps/docs/pages/learn/auth-deep-dive/auth-google-oauth.mdx b/apps/docs/pages/learn/auth-deep-dive/auth-google-oauth.mdx index c2b91673f74..d10f98e5d73 100644 --- a/apps/docs/pages/learn/auth-deep-dive/auth-google-oauth.mdx +++ b/apps/docs/pages/learn/auth-deep-dive/auth-google-oauth.mdx @@ -97,7 +97,7 @@ For any support please get in touch at beta at [supabase.com](https://supabase.c - Watch [Part Three: Policies](../../learn/auth-deep-dive/auth-policies) - Watch [Part Four: GoTrue](../../learn/auth-deep-dive/auth-gotrue) {/* */} -- Sign up for Supabase: [app.supabase.com](https://app.supabase.com) +- Sign up for Supabase: [supabase.com/dashboard](https://supabase.com/dashboard) export const Page = ({ children }) => diff --git a/apps/docs/pages/learn/auth-deep-dive/auth-gotrue.mdx b/apps/docs/pages/learn/auth-deep-dive/auth-gotrue.mdx index ed9dd72dec7..a9531106ed8 100644 --- a/apps/docs/pages/learn/auth-deep-dive/auth-gotrue.mdx +++ b/apps/docs/pages/learn/auth-deep-dive/auth-gotrue.mdx @@ -78,7 +78,7 @@ In the next guide we'll be looking at how to setup external OAuth providers: Wat - Watch [Part Three: Policies](../../learn/auth-deep-dive/auth-policies) {/* */} - Watch [Part Five: Google Oauth](../../learn/auth-deep-dive/auth-google-oauth) -- Sign up for Supabase: [app.supabase.com](https://app.supabase.com) +- Sign up for Supabase: [supabase.com/dashboard](https://supabase.com/dashboard) export const Page = ({ children }) => diff --git a/apps/docs/pages/learn/auth-deep-dive/auth-policies.mdx b/apps/docs/pages/learn/auth-deep-dive/auth-policies.mdx index 94fe1602450..b75e75aa2d0 100644 --- a/apps/docs/pages/learn/auth-deep-dive/auth-policies.mdx +++ b/apps/docs/pages/learn/auth-deep-dive/auth-policies.mdx @@ -44,7 +44,7 @@ http://localhost:3000/#access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQ Let's break this up so that it's easier to read: ```jsx -// the base url - whatever you set in the Auth Settings in app.supabase.com dashboard +// the base url - whatever you set in the Auth Settings in supabase.com/dashboard dashboard http://localhost:3000/ // note we use the '#' (fragment) instead of '?' query param @@ -183,7 +183,7 @@ You can get as creative as you like with these policies. {/* */} - Watch [Part Four: GoTrue](../../learn/auth-deep-dive/auth-gotrue) - Watch [Part Five: Google Oauth](../../learn/auth-deep-dive/auth-google-oauth) -- Sign up for Supabase: [app.supabase.com](https://app.supabase.com) +- Sign up for Supabase: [supabase.com/dashboard](https://supabase.com/dashboard) export const Page = ({ children }) => diff --git a/apps/docs/pages/learn/auth-deep-dive/auth-row-level-security.mdx b/apps/docs/pages/learn/auth-deep-dive/auth-row-level-security.mdx index c75b446e972..6f660a2d733 100644 --- a/apps/docs/pages/learn/auth-deep-dive/auth-row-level-security.mdx +++ b/apps/docs/pages/learn/auth-deep-dive/auth-row-level-security.mdx @@ -128,7 +128,7 @@ In the next guide we will look at using Policies in combination with User Accoun - Watch [Part Three: Policies](../../learn/auth-deep-dive/auth-policies) - Watch [Part Four: GoTrue](../../learn/auth-deep-dive/auth-gotrue) - Watch [Part Five: Google Oauth](../../learn/auth-deep-dive/auth-google-oauth) -- Sign up for Supabase: [app.supabase.com](https://app.supabase.com) +- Sign up for Supabase: [supabase.com/dashboard](https://supabase.com/dashboard) export const Page = ({ children }) => diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 50aea950567..d4668694ba3 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -42,6 +42,7 @@ Margarita Sandomirskaia Marijana Šimag Mark Burggraf Michel Pelletier +Mickael Hebert Monica El Khoury Nikita Kotlyarov Oli R diff --git a/apps/docs/public/img/guides/auth-kakao/kakao-developers-consent-items-set.png b/apps/docs/public/img/guides/auth-kakao/kakao-developers-consent-items-set.png new file mode 100644 index 00000000000..b59c4616984 Binary files /dev/null and b/apps/docs/public/img/guides/auth-kakao/kakao-developers-consent-items-set.png differ diff --git a/apps/docs/public/img/guides/auth-kakao/kakao-developers-page.png b/apps/docs/public/img/guides/auth-kakao/kakao-developers-page.png new file mode 100644 index 00000000000..81d30550eb6 Binary files /dev/null and b/apps/docs/public/img/guides/auth-kakao/kakao-developers-page.png differ diff --git a/apps/docs/public/img/icons/kakao-icon.svg b/apps/docs/public/img/icons/kakao-icon.svg new file mode 100644 index 00000000000..e721ec2b432 --- /dev/null +++ b/apps/docs/public/img/icons/kakao-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/docs/public/img/icons/refine-icon.svg b/apps/docs/public/img/icons/refine-icon.svg new file mode 100644 index 00000000000..7f2e1f8d82c --- /dev/null +++ b/apps/docs/public/img/icons/refine-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/docs/public/img/refine-qs-countries-ui.png b/apps/docs/public/img/refine-qs-countries-ui.png new file mode 100644 index 00000000000..e062c721878 Binary files /dev/null and b/apps/docs/public/img/refine-qs-countries-ui.png differ diff --git a/apps/docs/public/img/refine-qs-welcome-page.png b/apps/docs/public/img/refine-qs-welcome-page.png new file mode 100644 index 00000000000..376740a35f7 Binary files /dev/null and b/apps/docs/public/img/refine-qs-welcome-page.png differ diff --git a/apps/docs/public/img/supabase-refine-demo.png b/apps/docs/public/img/supabase-refine-demo.png new file mode 100644 index 00000000000..6a8f15ff6ed Binary files /dev/null and b/apps/docs/public/img/supabase-refine-demo.png differ diff --git a/apps/www/_alternatives/supabase-vs-firebase.mdx b/apps/www/_alternatives/supabase-vs-firebase.mdx index ac4f8fd444c..2ed8ec1883f 100644 --- a/apps/www/_alternatives/supabase-vs-firebase.mdx +++ b/apps/www/_alternatives/supabase-vs-firebase.mdx @@ -28,7 +28,7 @@ Supabase is an open source firebase alternative, but instead of being built arou At Supabase we’ve always been huge fans of Firebase - so we started adding a few things on top of PostgreSQL in an attempt to reach feature parity, including: -- Auto-generated API - [query your data straight from the client](https://supabase.com/docs/guides/database/api#rest-api-2). +- Auto-generated API - [query your data straight from the client](https://supabase.com/docs/guides/api#rest-api-overview). - Realtime - [changes in your data will be streamed directly to your application](https://supabase.com/docs/reference/dart/subscribe). - Auth - [a simple to integrate auth system and SQL based rules engine](https://supabase.com/auth). - Functions - [javascript and typescript functions that deploy out globally](https://supabase.com/edge-functions). @@ -66,4 +66,4 @@ We also have guides and tools for [migrating Firebase Auth to Supabase Auth](htt These are by far the most complete Firebase to Postgres migration tools available anywhere on the web. -You can [try Supabase for free](https://app.supabase.com). If you require Enterprise level support with your project or migration, please get in touch using our [Enterprise contact form](https://forms.supabase.com/enterprise). +You can [try Supabase for free](https://supabase.com/dashboard). If you require Enterprise level support with your project or migration, please get in touch using our [Enterprise contact form](https://forms.supabase.com/enterprise). diff --git a/apps/www/_alternatives/supabase-vs-heroku-postgres.mdx b/apps/www/_alternatives/supabase-vs-heroku-postgres.mdx index af3994eb188..ac4707720b9 100644 --- a/apps/www/_alternatives/supabase-vs-heroku-postgres.mdx +++ b/apps/www/_alternatives/supabase-vs-heroku-postgres.mdx @@ -16,7 +16,7 @@ Heroku is a cloud application platform that offers managed PostgreSQL as a servi Supabase also offers managed Postgres, the main difference is that with each deployment you also get: -- Auto-generated API - [never write an API again](https://supabase.com/docs/guides/database/api#rest-api-2) +- Auto-generated API - [never write an API again](https://supabase.com/docs/guides/api#rest-api-overview) - Realtime - [subscribe to data changes via websockets](https://supabase.com/docs/reference/dart/subscribe) - Auth - [users can log in and out of your application](https://supabase.com/auth) - Functions - [deploy custom logic to the edge](https://supabase.com/edge-functions) @@ -43,7 +43,7 @@ These are some of the key differences between Heroku Postgres and Supabase in te - Supabase is more than just the raw database, it also comes with: - [Connection pooling](https://supabase.com/docs/guides/database/connecting-to-postgres#connection-pool) so that you won’t run out of connections in a serverless environment. - - [Auto-generated APIs](https://supabase.com/docs/guides/database/api#rest-api-2) based on your schema, so you can communicate with your database directly from the client. + - [Auto-generated APIs](https://supabase.com/docs/guides/api#rest-api-overview) based on your schema, so you can communicate with your database directly from the client. - [Realtime API](https://supabase.com/docs/reference/dart/subscribe) is useful for when you want to subscribe to changes to your database over websockets. - [Auth API](https://supabase.com/auth) can be used to leverage Postgres’s Row Level Security model, and control access to sensitive data on a per user, or per group level. - [Functions](https://supabase.com/edge-functions) can be deployed out to the edge directly from the Supabase CLI, which means you can run sensitive business logic or transformations in a serverless fashion. diff --git a/apps/www/_blog/2020-05-01-supabase-alpha-april-2020.mdx b/apps/www/_blog/2020-05-01-supabase-alpha-april-2020.mdx index 964cfe71934..59816be570d 100644 --- a/apps/www/_blog/2020-05-01-supabase-alpha-april-2020.mdx +++ b/apps/www/_blog/2020-05-01-supabase-alpha-april-2020.mdx @@ -11,7 +11,7 @@ tags: date: '06-01-2020' --- -Now in [Supabase](https://app.supabase.com): +Now in [Supabase](https://supabase.com/dashboard): - Set up Postgres in less than 2 minutes - Auto-generated APIs! (they are a bit flaky, go easy) @@ -58,6 +58,6 @@ Send realtime database changes to other systems, like queues or webhooks (Slack ## Follow us -Start using Supabase today: [app.supabase.com](https://app.supabase.com) +Start using Supabase today: [supabase.com/dashboard](https://supabase.com/dashboard) Make sure to star us on github! [github.com/supabase/supabase](https://github.com/supabase/supabase) diff --git a/apps/www/_blog/2020-06-01-supabase-alpha-may-2020.mdx b/apps/www/_blog/2020-06-01-supabase-alpha-may-2020.mdx index fc9d49b9ba6..b3db04f166c 100644 --- a/apps/www/_blog/2020-06-01-supabase-alpha-may-2020.mdx +++ b/apps/www/_blog/2020-06-01-supabase-alpha-may-2020.mdx @@ -56,6 +56,6 @@ This one is still very unstable, but we wanted to ship it anyway. It's a little ## Follow us -Start using Supabase today: [app.supabase.com](https://app.supabase.com) +Start using Supabase today: [supabase.com/dashboard](https://supabase.com/dashboard) Make sure to star us on github! [github.com/supabase/supabase](https://github.com/supabase/supabase) diff --git a/apps/www/_blog/2020-07-01-supabase-alpha-june-2020.mdx b/apps/www/_blog/2020-07-01-supabase-alpha-june-2020.mdx index 4baf3f31a58..f8a2d6a8e1a 100644 --- a/apps/www/_blog/2020-07-01-supabase-alpha-june-2020.mdx +++ b/apps/www/_blog/2020-07-01-supabase-alpha-june-2020.mdx @@ -93,7 +93,7 @@ We have a number of small improvements: ### Get started -- Start using Supabase today: [app.supabase.com](https://app.supabase.com) +- Start using Supabase today: [supabase.com/dashboard](https://supabase.com/dashboard) - Make sure to [star us on GitHub](https://github.com/supabase/supabase) - Follow us [on Twitter](https://twitter.com/supabase) - Become a [sponsor](https://github.com/sponsors/supabase) diff --git a/apps/www/_blog/2020-07-10-alpha-launch-postmortem.mdx b/apps/www/_blog/2020-07-10-alpha-launch-postmortem.mdx index f0186212be6..50484573981 100644 --- a/apps/www/_blog/2020-07-10-alpha-launch-postmortem.mdx +++ b/apps/www/_blog/2020-07-10-alpha-launch-postmortem.mdx @@ -51,7 +51,7 @@ We have since migrated our middleware to multiple ECS clusters, globally load-ba We serve our marketing site ([supabase.io](/)) from Netlify. It's a static-build [Docusaurus (v2)](https://v2.docusaurus.io/) site, so it had no problems (apart from one developer in Russia who couldn't access the site - it looks like some of Netlify's IP addresses are blocked there). -We serve our app ([app.supabase.com](https://app.supabase.com)) using Vercel, and the login system uses Auth0. These were both rock-solid. Before the launch we noticed that Vercel was extremely slow on their free plan, and once we upgraded to their Pro Plan for multi-region deploys it solved performance issues. It looks like they are changing their plans again so buyer beware. +We serve our app ([supabase.com/dashboard](https://supabase.com/dashboard)) using Vercel, and the login system uses Auth0. These were both rock-solid. Before the launch we noticed that Vercel was extremely slow on their free plan, and once we upgraded to their Pro Plan for multi-region deploys it solved performance issues. It looks like they are changing their plans again so buyer beware. ## The Bad diff --git a/apps/www/_blog/2020-08-02-supabase-alpha-july-2020.mdx b/apps/www/_blog/2020-08-02-supabase-alpha-july-2020.mdx index 3d450ab4765..4dbc736616a 100644 --- a/apps/www/_blog/2020-08-02-supabase-alpha-july-2020.mdx +++ b/apps/www/_blog/2020-08-02-supabase-alpha-july-2020.mdx @@ -79,7 +79,7 @@ We have a number of small improvements: ### Get started -- Start using Supabase today: [app.supabase.com](https://app.supabase.com) +- Start using Supabase today: [supabase.com/dashboard](https://supabase.com/dashboard) - Make sure to [star us on GitHub](https://github.com/supabase/supabase) - Follow us [on Twitter](https://twitter.com/supabase) - Become a [sponsor](https://github.com/sponsors/supabase) diff --git a/apps/www/_blog/2020-08-05-supabase-auth.mdx b/apps/www/_blog/2020-08-05-supabase-auth.mdx index 404b0e1d0de..708b0d4d099 100644 --- a/apps/www/_blog/2020-08-05-supabase-auth.mdx +++ b/apps/www/_blog/2020-08-05-supabase-auth.mdx @@ -95,6 +95,6 @@ Supabase has a culture of shipping early and often. Our Auth release is another ### Get started -Supabase Auth is ready for you to start using today, free of charge: [app.supabase.com](https://app.supabase.com) +Supabase Auth is ready for you to start using today, free of charge: [supabase.com/dashboard](https://supabase.com/dashboard) To see the full power of our auth system, watch [this demo](https://youtu.be/2oqIZW5S-lQ) where I deploy a secure, real-time slack clone to Vercel in less than 3 minutes. diff --git a/apps/www/_blog/2020-09-03-supabase-alpha-august-2020.mdx b/apps/www/_blog/2020-09-03-supabase-alpha-august-2020.mdx index 14678e98687..d33c6c02d6f 100644 --- a/apps/www/_blog/2020-09-03-supabase-alpha-august-2020.mdx +++ b/apps/www/_blog/2020-09-03-supabase-alpha-august-2020.mdx @@ -91,7 +91,7 @@ Our focus now is to move from Alpha to Beta. This involves stabilising our Auth ### Get started -- Start using Supabase today: [app.supabase.com](https://app.supabase.com) +- Start using Supabase today: [supabase.com/dashboard](https://supabase.com/dashboard) - Make sure to [star us on GitHub](https://github.com/supabase/supabase) - Follow us [on Twitter](https://twitter.com/supabase) - Become a [sponsor](https://github.com/sponsors/supabase) diff --git a/apps/www/_blog/2020-10-03-supabase-alpha-september-2020.mdx b/apps/www/_blog/2020-10-03-supabase-alpha-september-2020.mdx index f5af4a662ae..f24e466253c 100644 --- a/apps/www/_blog/2020-10-03-supabase-alpha-september-2020.mdx +++ b/apps/www/_blog/2020-10-03-supabase-alpha-september-2020.mdx @@ -99,7 +99,7 @@ Our focus now is to move from Alpha to Beta and we'll be improving stability, re ### Get started -- Start using Supabase today: [app.supabase.com](https://app.supabase.com/) +- Start using Supabase today: [supabase.com/dashboard](https://supabase.com/dashboard/) - Make sure to [star us on GitHub](https://github.com/supabase/supabase) - Follow us [on Twitter](https://twitter.com/supabase) - Become a [sponsor](https://github.com/sponsors/supabase) diff --git a/apps/www/_blog/2020-10-30-improved-dx.mdx b/apps/www/_blog/2020-10-30-improved-dx.mdx index d13ca01f614..786a45c585d 100644 --- a/apps/www/_blog/2020-10-30-improved-dx.mdx +++ b/apps/www/_blog/2020-10-30-improved-dx.mdx @@ -111,7 +111,7 @@ To summarise the steps: ### Get started -- Start using Supabase today: [app.supabase.com](https://app.supabase.com/) +- Start using Supabase today: [supabase.com/dashboard](https://supabase.com/dashboard/) - Make sure to [star us on GitHub](https://github.com/supabase/supabase) - Follow us [on Twitter](https://twitter.com/supabase) - Become a [sponsor](https://github.com/sponsors/supabase) diff --git a/apps/www/_blog/2020-11-02-supabase-alpha-october-2020.mdx b/apps/www/_blog/2020-11-02-supabase-alpha-october-2020.mdx index 158b81d22d5..e3778d5ba34 100644 --- a/apps/www/_blog/2020-11-02-supabase-alpha-october-2020.mdx +++ b/apps/www/_blog/2020-11-02-supabase-alpha-october-2020.mdx @@ -79,7 +79,7 @@ Our focus is still moving from Alpha to Beta and we'll be improving stability, r ### Get started -- Start using Supabase today: [app.supabase.com](https://app.supabase.com/) +- Start using Supabase today: [supabase.com/dashboard](https://supabase.com/dashboard/) - Make sure to [star us on GitHub](https://github.com/supabase/supabase) - Follow us [on Twitter](https://twitter.com/supabase) - Become a [sponsor](https://github.com/sponsors/supabase) diff --git a/apps/www/_blog/2020-12-01-supabase-alpha-november-2020.mdx b/apps/www/_blog/2020-12-01-supabase-alpha-november-2020.mdx index a4bb7858a82..15fd74a13b0 100644 --- a/apps/www/_blog/2020-12-01-supabase-alpha-november-2020.mdx +++ b/apps/www/_blog/2020-12-01-supabase-alpha-november-2020.mdx @@ -76,7 +76,7 @@ Look out for a big announcement on December 3rd. We'll also be presenting in the ### Get started -- Start using Supabase today: [app.supabase.com](https://app.supabase.com/) +- Start using Supabase today: [supabase.com/dashboard](https://supabase.com/dashboard/) - Make sure to [star us on GitHub](https://github.com/supabase/supabase) - Follow us [on Twitter](https://twitter.com/supabase) - Become a [sponsor](https://github.com/sponsors/supabase) diff --git a/apps/www/_blog/2020-12-02-case-study-monitoro.mdx b/apps/www/_blog/2020-12-02-case-study-monitoro.mdx index 0c628e8c394..df53fac79d7 100644 --- a/apps/www/_blog/2020-12-02-case-study-monitoro.mdx +++ b/apps/www/_blog/2020-12-02-case-study-monitoro.mdx @@ -8,7 +8,6 @@ author_image_url: https://github.com/roryw10.png authorURL: https://github.com/roryw10 image: /images/blog/supabase-monitoro.png tags: - - case-study - no-code date: '12-02-2020' video: https://www.youtube.com/v/8A6_pg41M2s diff --git a/apps/www/_blog/2020-12-02-case-study-tayfa.mdx b/apps/www/_blog/2020-12-02-case-study-tayfa.mdx index a4d03ff3e5e..c494482fc63 100644 --- a/apps/www/_blog/2020-12-02-case-study-tayfa.mdx +++ b/apps/www/_blog/2020-12-02-case-study-tayfa.mdx @@ -8,7 +8,6 @@ author_image_url: https://github.com/roryw10.png authorURL: https://github.com/roryw10 image: /images/blog/supabase-tayfa.png tags: - - case-study - no-code date: '12-02-2020' --- diff --git a/apps/www/_blog/2020-12-02-case-study-xendit.mdx b/apps/www/_blog/2020-12-02-case-study-xendit.mdx index 80d8822fb17..5d61a0c70d4 100644 --- a/apps/www/_blog/2020-12-02-case-study-xendit.mdx +++ b/apps/www/_blog/2020-12-02-case-study-xendit.mdx @@ -9,7 +9,6 @@ authorURL: https://github.com/roryw10 image: supabase-xendit.png thumb: supabase-xendit-thumb.jpg tags: - - case-study - fintech date: '12-02-2020' --- diff --git a/apps/www/_blog/2021-01-02-supabase-beta-december-2020.mdx b/apps/www/_blog/2021-01-02-supabase-beta-december-2020.mdx index 3b5f726509a..c78910380ec 100644 --- a/apps/www/_blog/2021-01-02-supabase-beta-december-2020.mdx +++ b/apps/www/_blog/2021-01-02-supabase-beta-december-2020.mdx @@ -102,7 +102,7 @@ We also have something exciting planned with Vercel and Stripe ... [stay tuned]( ### Get started -- Start using Supabase today: [app.supabase.com](https://app.supabase.com/) +- Start using Supabase today: [supabase.com/dashboard](https://supabase.com/dashboard/) - Make sure to [star us on GitHub](https://github.com/supabase/supabase) - Follow us [on Twitter](https://twitter.com/supabase) - Become a [sponsor](https://github.com/sponsors/supabase) diff --git a/apps/www/_blog/2021-02-02-supabase-beta-january-2021.mdx b/apps/www/_blog/2021-02-02-supabase-beta-january-2021.mdx index 976c96f08b6..7fce0b1eabf 100644 --- a/apps/www/_blog/2021-02-02-supabase-beta-january-2021.mdx +++ b/apps/www/_blog/2021-02-02-supabase-beta-january-2021.mdx @@ -123,7 +123,7 @@ We're ramping up to "Launch week" at the end of Q1, where we will be giving you ### Get started -- Start using Supabase today: [app.supabase.com](https://app.supabase.com/) +- Start using Supabase today: [supabase.com/dashboard](https://supabase.com/dashboard/) - Make sure to [star us on GitHub](https://github.com/supabase/supabase) - Follow us [on Twitter](https://twitter.com/supabase) - Subscribe to our [YouTube channel](https://www.youtube.com/c/supabase) diff --git a/apps/www/_blog/2021-02-09-case-study-roboflow.mdx b/apps/www/_blog/2021-02-09-case-study-roboflow.mdx index 97888890d83..5b33ea3b7d8 100644 --- a/apps/www/_blog/2021-02-09-case-study-roboflow.mdx +++ b/apps/www/_blog/2021-02-09-case-study-roboflow.mdx @@ -9,7 +9,6 @@ authorURL: https://github.com/roryw10 image: roboflow-og.png thumb: roboflow-website.png tags: - - case-study - AI date: '02-09-2021' --- diff --git a/apps/www/_blog/2021-02-27-cracking-postgres-interview.mdx b/apps/www/_blog/2021-02-27-cracking-postgres-interview.mdx index dbdc69fc6ee..f90e44dff41 100644 --- a/apps/www/_blog/2021-02-27-cracking-postgres-interview.mdx +++ b/apps/www/_blog/2021-02-27-cracking-postgres-interview.mdx @@ -155,7 +155,7 @@ where first_name like "S%"; If you can reason about most of the topics in this post then you'll be in a very strong position to impress with your answers to PostgreSQL interview questions. As with all programming topics however, the real learning starts when you put these things into practice. At Supabase we offer a very very fast (the fastest?) way to spin up a PostgreSQL database and start querying it, and our browser based SQL editor is getting more powerful every day. -[Get started on Supabase for free here](https://app.supabase.com) +[Get started on Supabase for free here](https://supabase.com/dashboard) Thanks Steve Chavez for providing all the good bits of this post :) diff --git a/apps/www/_blog/2021-03-02-supabase-beta-february-2021.mdx b/apps/www/_blog/2021-03-02-supabase-beta-february-2021.mdx index 750370d5c7a..1f327482d37 100644 --- a/apps/www/_blog/2021-03-02-supabase-beta-february-2021.mdx +++ b/apps/www/_blog/2021-03-02-supabase-beta-february-2021.mdx @@ -103,7 +103,7 @@ Waiting for Supabase Storage? Here's a sneak peek for the upcoming Launch Week a ### Get started -- Start using Supabase today: [app.supabase.com](https://app.supabase.com/) +- Start using Supabase today: [supabase.com/dashboard](https://supabase.com/dashboard/) - Make sure to [star us on GitHub](https://github.com/supabase/supabase) - Follow us [on Twitter](https://twitter.com/supabase) - Subscribe to our [YouTube channel](https://www.youtube.com/c/supabase) diff --git a/apps/www/_blog/2021-03-05-postgres-as-a-cron-server.mdx b/apps/www/_blog/2021-03-05-postgres-as-a-cron-server.mdx index 307339e3a02..f71abee8d97 100644 --- a/apps/www/_blog/2021-03-05-postgres-as-a-cron-server.mdx +++ b/apps/www/_blog/2021-03-05-postgres-as-a-cron-server.mdx @@ -211,7 +211,7 @@ If you're familiar with `C`, you could also help `@pramsey` to implement async f There are plenty of ways to run cron jobs these days. You can trigger them from your local machine. You can install them on a VPS. You can schedule Serverless functions. You can use a paid service. You can use GitHub Actions. -Is Postgres the best place to put your cron jobs? `¯\_(ツ)_/¯`. Postgres databases are free on Supabase and since it takes only one minute to [get started](https://app.supabase.com/), why not make your next cron server a Postgres database? +Is Postgres the best place to put your cron jobs? `¯\_(ツ)_/¯`. Postgres databases are free on Supabase and since it takes only one minute to [get started](https://supabase.com/dashboard/), why not make your next cron server a Postgres database? ## More Postgres resources diff --git a/apps/www/_blog/2021-03-08-toad-a-link-shortener-with-simple-apis-for-low-coders.mdx b/apps/www/_blog/2021-03-08-toad-a-link-shortener-with-simple-apis-for-low-coders.mdx index a87778aaeb0..c1ddf4af7e2 100644 --- a/apps/www/_blog/2021-03-08-toad-a-link-shortener-with-simple-apis-for-low-coders.mdx +++ b/apps/www/_blog/2021-03-08-toad-a-link-shortener-with-simple-apis-for-low-coders.mdx @@ -10,7 +10,6 @@ image: toadli-og.jpg thumb: toadli-website.jpg tags: - supabase - - case-study date: '03-08-2021' --- diff --git a/apps/www/_blog/2021-03-11-using-supabase-replit.mdx b/apps/www/_blog/2021-03-11-using-supabase-replit.mdx index 49dca993283..f5ef05d1413 100644 --- a/apps/www/_blog/2021-03-11-using-supabase-replit.mdx +++ b/apps/www/_blog/2021-03-11-using-supabase-replit.mdx @@ -133,4 +133,4 @@ We look forward to showing off some more Supabase + Replit examples. You can find my example repl here: [https://repl.it/@awalias/supabase-test#index.js](https://repl.it/@awalias/supabase-test#index.js) -Supabase has a free plan, head over to [https://app.supabase.com](https://app.supabase.com) to get started. +Supabase has a free plan, head over to [https://supabase.com/dashboard](https://supabase.com/dashboard) to get started. diff --git a/apps/www/_blog/2021-03-29-pricing.mdx b/apps/www/_blog/2021-03-29-pricing.mdx index f8300653ca4..1b0ccc104be 100644 --- a/apps/www/_blog/2021-03-29-pricing.mdx +++ b/apps/www/_blog/2021-03-29-pricing.mdx @@ -77,7 +77,7 @@ In the next few days, you will see your usage appear in the dashboard with an up Check back throughout the week to see what else we're launching [here](/blog/launch-week). -Sign up for the Supabase Beta [here](https://app.supabase.com). +Sign up for the Supabase Beta [here](https://supabase.com/dashboard). And check out the full pricing details [here](/pricing). And we'd love to hear your opnions on our Pricing! You can let us know via support@supabase.io. diff --git a/apps/www/_blog/2021-03-30-supabase-storage.mdx b/apps/www/_blog/2021-03-30-supabase-storage.mdx index ad9b96c392d..72921f565af 100644 --- a/apps/www/_blog/2021-03-30-supabase-storage.mdx +++ b/apps/www/_blog/2021-03-30-supabase-storage.mdx @@ -169,4 +169,4 @@ You may be itching to get started tinkering around with our new Supabase storage - A better editor to make authoring policies easier and less error prone. - Reach out to us if you would like to help out with adding storage support in one of the [community maintained client libraries](/docs/reference/javascript/installing). -Take it for a spin on our [dashboard](https://app.supabase.com/) and let us know what you think! +Take it for a spin on our [dashboard](https://supabase.com/dashboard/) and let us know what you think! diff --git a/apps/www/_blog/2021-03-31-supabase-cli.mdx b/apps/www/_blog/2021-03-31-supabase-cli.mdx index ea8d4c94ed8..9b3236d738f 100644 --- a/apps/www/_blog/2021-03-31-supabase-cli.mdx +++ b/apps/www/_blog/2021-03-31-supabase-cli.mdx @@ -224,4 +224,4 @@ Head over to the [Self Hosting Docs](/docs/guides/self-hosting) for a more compl If you require any assistance feel free to reach out in our [github discussions](https://github.com/supabase/supabase/discussions) or at support@supabase.io. -Check out the CLI VI [launched Monday 15th August, 2022](/blog/supabase-cli-v1-and-admin-api-beta), contribute to the [CLI repo](https://github.com/supabase/cli), or go here for the [hosted version](https://app.supabase.com). +Check out the CLI VI [launched Monday 15th August, 2022](/blog/supabase-cli-v1-and-admin-api-beta), contribute to the [CLI repo](https://github.com/supabase/cli), or go here for the [hosted version](https://supabase.com/dashboard). diff --git a/apps/www/_blog/2021-04-02-supabase-pgbouncer.mdx b/apps/www/_blog/2021-04-02-supabase-pgbouncer.mdx index 474d9c485bc..df6aa688c0e 100644 --- a/apps/www/_blog/2021-04-02-supabase-pgbouncer.mdx +++ b/apps/www/_blog/2021-04-02-supabase-pgbouncer.mdx @@ -92,7 +92,7 @@ This is the most granular option. Connections are returned to the pool after eve ## What's next? -Try out connection pooling now with a new project in the [dashboard.](https://app.supabase.com) For now, we do not have any plans to port this over to older projects. +Try out connection pooling now with a new project in the [dashboard.](https://supabase.com/dashboard) For now, we do not have any plans to port this over to older projects. Eventually, we will expose more PgBouncer settings to the UI such as `Pool Size`. At the moment it is set to `15`. diff --git a/apps/www/_blog/2021-04-06-supabase-beta-march-2021.mdx b/apps/www/_blog/2021-04-06-supabase-beta-march-2021.mdx index 607625976b6..1d766dbf8dc 100644 --- a/apps/www/_blog/2021-04-06-supabase-beta-march-2021.mdx +++ b/apps/www/_blog/2021-04-06-supabase-beta-march-2021.mdx @@ -116,7 +116,7 @@ For this we are working on our own [Workflow engine](/blog/supabase-workflows), ### Get started -- Start using Supabase today: [app.supabase.com](https://app.supabase.com/) +- Start using Supabase today: [supabase.com/dashboard](https://supabase.com/dashboard/) - Make sure to [star us on GitHub](https://github.com/supabase/supabase) - Follow us [on Twitter](https://twitter.com/supabase) - Subscribe to our [YouTube channel](https://www.youtube.com/c/supabase) diff --git a/apps/www/_blog/2021-05-03-supabase-beta-april-2021.mdx b/apps/www/_blog/2021-05-03-supabase-beta-april-2021.mdx index 342404f552e..817491cccb4 100644 --- a/apps/www/_blog/2021-05-03-supabase-beta-april-2021.mdx +++ b/apps/www/_blog/2021-05-03-supabase-beta-april-2021.mdx @@ -39,7 +39,7 @@ We're a developer tool, which means that Dark Mode is [extremely popular](https: While Dark mode is great, for some people it's not an option. Dark Mode is difficult to use for developers with astigmatisms, or even just working in brightly-lit environments. -So today we're shipping Light Mode. Access it in the settings of your [Dashboard](https://app.supabase.com). +So today we're shipping Light Mode. Access it in the settings of your [Dashboard](https://supabase.com/dashboard).
diff --git a/apps/www/_blog/2022-08-24-building-a-realtime-trello-board-with-supabase-and-angular.mdx b/apps/www/_blog/2022-08-24-building-a-realtime-trello-board-with-supabase-and-angular.mdx index c63debb9185..f674ba663dc 100644 --- a/apps/www/_blog/2022-08-24-building-a-realtime-trello-board-with-supabase-and-angular.mdx +++ b/apps/www/_blog/2022-08-24-building-a-realtime-trello-board-with-supabase-and-angular.mdx @@ -46,7 +46,7 @@ Ready for a wild adventure? Then let's begin inside our Supabase account! ## Creating the Supabase Project -First of all we need a new Supabase project. If you don't have a Supabase account yet, you can [get started for free](https://app.supabase.com/)! +First of all we need a new Supabase project. If you don't have a Supabase account yet, you can [get started for free](https://supabase.com/dashboard/)! In your dashboard, click "New Project" and leave it to the default settings, but make sure you keep a copy o your Database password! diff --git a/apps/www/_blog/2022-09-07-supabase-beta-update-august-2022.mdx b/apps/www/_blog/2022-09-07-supabase-beta-update-august-2022.mdx index 31a6206daec..13ca18a5f0f 100644 --- a/apps/www/_blog/2022-09-07-supabase-beta-update-august-2022.mdx +++ b/apps/www/_blog/2022-09-07-supabase-beta-update-august-2022.mdx @@ -146,7 +146,7 @@ If you made it this far in the blog post you deserve a treat. [Follow us on Twit ## Get started -- Start using Supabase today: **[app.supabase.com](https://app.supabase.com/)** +- Start using Supabase today: **[supabase.com/dashboard](https://supabase.com/dashboard/)** - Make sure to **[star us on GitHub](https://github.com/supabase/supabase)** - Follow us **[on Twitter](https://twitter.com/supabase)** - Subscribe to our **[YouTube channel](https://www.youtube.com/c/supabase)** diff --git a/apps/www/_blog/2022-10-05-supabase-beta-update-september-2022.mdx b/apps/www/_blog/2022-10-05-supabase-beta-update-september-2022.mdx index 8c418b43f0f..f1b3add99a3 100644 --- a/apps/www/_blog/2022-10-05-supabase-beta-update-september-2022.mdx +++ b/apps/www/_blog/2022-10-05-supabase-beta-update-september-2022.mdx @@ -96,7 +96,7 @@ If you made it this far in the blog post you deserve a treat. [Follow us on Twit ## Get started -- Start using Supabase today: **[app.supabase.com](https://app.supabase.com/)** +- Start using Supabase today: **[supabase.com/dashboard](https://supabase.com/dashboard/)** - Make sure to **[star us on GitHub](https://github.com/supabase/supabase)** - Follow us **[on Twitter](https://twitter.com/supabase)** - Subscribe to our **[YouTube channel](https://www.youtube.com/c/supabase)** diff --git a/apps/www/_blog/2022-11-02-supabase-beta-update-october-2022.mdx b/apps/www/_blog/2022-11-02-supabase-beta-update-october-2022.mdx index 5d43f927826..4c2250b41d4 100644 --- a/apps/www/_blog/2022-11-02-supabase-beta-update-october-2022.mdx +++ b/apps/www/_blog/2022-11-02-supabase-beta-update-october-2022.mdx @@ -116,7 +116,7 @@ If you made it this far in the blog post you deserve a treat. [Follow us on Twit ## Get started -- Start using Supabase today: **[app.supabase.com](https://app.supabase.com/)** +- Start using Supabase today: **[supabase.com/dashboard](https://supabase.com/dashboard/)** - Make sure to **[star us on GitHub](https://github.com/supabase/supabase)** - Follow us **[on Twitter](https://twitter.com/supabase)** - Subscribe to our **[YouTube channel](https://www.youtube.com/c/supabase)** diff --git a/apps/www/_blog/2022-11-17-fetching-and-caching-supabase-data-in-next-js-server-components.mdx b/apps/www/_blog/2022-11-17-fetching-and-caching-supabase-data-in-next-js-server-components.mdx index 01c432a7212..9dac31c3ece 100644 --- a/apps/www/_blog/2022-11-17-fetching-and-caching-supabase-data-in-next-js-server-components.mdx +++ b/apps/www/_blog/2022-11-17-fetching-and-caching-supabase-data-in-next-js-server-components.mdx @@ -52,9 +52,9 @@ Each folder within the `app` directory represents a route in our application. Ea > Learn more about [Page](https://beta.nextjs.org/docs/api-reference/file-conventions/page), [Layout](https://beta.nextjs.org/docs/api-reference/file-conventions/layout), [Loading](https://beta.nextjs.org/docs/api-reference/file-conventions/loading) and [Error](https://beta.nextjs.org/docs/api-reference/file-conventions/error) components in the [Next.js beta docs](https://beta.nextjs.org/docs). -Before we jump into fetching data, we need some data to fetch. Let’s [create a new Supabase project](https://app.supabase.com). +Before we jump into fetching data, we need some data to fetch. Let’s [create a new Supabase project](https://supabase.com/dashboard). -Once your instance is up and running, head over to the [SQL Editor](https://app.supabase.com/project/_/sql), paste in the following snippet and click `RUN`. +Once your instance is up and running, head over to the [SQL Editor](https://supabase.com/dashboard/project/_/sql), paste in the following snippet and click `RUN`. ```sql create table if not exists posts ( @@ -85,7 +85,7 @@ NEXT_PUBLIC_SUPABASE_URL=your-supabase-url NEXT_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key ``` -> The values for these can be found in [your project’s API settings](https://app.supabase.com/project/_/settings/api). +> The values for these can be found in [your project’s API settings](https://supabase.com/dashboard/project/_/settings/api). Lastly, we need to create a Supabase client. Create a file at `utils/supabase.ts` with the following content: @@ -382,7 +382,7 @@ But now we have loading spinners! Yuck! # Realtime -Realtime allows us to subscribe to changes in Supabase — inserted, updated or deleted posts — and update our UI dynamically. In order to receive realtime events, we need to [enable replication](https://app.supabase.com/project/_/database/replication) on the posts table. +Realtime allows us to subscribe to changes in Supabase — inserted, updated or deleted posts — and update our UI dynamically. In order to receive realtime events, we need to [enable replication](https://supabase.com/dashboard/project/_/database/replication) on the posts table. Let’s merge the two previous concepts and fetch the initial state of our posts in a Server Component, and then render a Client Component to do client-y things — like subscribe to changes in the DB and update the UI dynamically: diff --git a/apps/www/_blog/2022-12-16-custom-domain-names.mdx b/apps/www/_blog/2022-12-16-custom-domain-names.mdx index 79ca54ad805..2578158b3c7 100644 --- a/apps/www/_blog/2022-12-16-custom-domain-names.mdx +++ b/apps/www/_blog/2022-12-16-custom-domain-names.mdx @@ -30,7 +30,7 @@ your users instead interact with Today, we're making Custom Domains generally available, and they're now accessible as a paid add-on through the Supabase dashboard. -Get your custom domain in the [Dashboard](https://app.supabase.com/project/_/settings/general) +Get your custom domain in the [Dashboard](https://supabase.com/dashboard/project/_/settings/general) # Vanity Subdomains diff --git a/apps/www/_blog/2022-12-16-launch-week-6-community-day.mdx b/apps/www/_blog/2022-12-16-launch-week-6-community-day.mdx index eb485c2bb11..07d0e9921fb 100644 --- a/apps/www/_blog/2022-12-16-launch-week-6-community-day.mdx +++ b/apps/www/_blog/2022-12-16-launch-week-6-community-day.mdx @@ -87,7 +87,7 @@ In [this community spotlight video](https://youtube.com/EdYQ9fF-hz4), [Thang Huu Over the past few years, we've had a number of people ask if we can add Full Text Search for other languages. PGroonga has been on our radar for a long time now, so when we reached out to them we were surprised to learn that _no_ Postgres platform currently supports them. Today we're fixing this by becoming the first. -PostgreSQL of course features excellent [Full Text Search](https://supabase.com/docs/guides/database/full-text-search) out-the-box, but it can be lacking if you need to search against languages with non-Roman characters such as Japanese, Chinese and so on. For this you can use the PGroonga extension! It's already installed on all new projects, just head to the [Database tab](https://app.supabase.com/project/_/database/extensions) on the dashboard and search or scroll for 'PGroonga' and hit enable to get started. Once enabled you can head to [their excellent documentation](https://pgroonga.github.io/tutorial/) on how to start making queries. +PostgreSQL of course features excellent [Full Text Search](https://supabase.com/docs/guides/database/full-text-search) out-the-box, but it can be lacking if you need to search against languages with non-Roman characters such as Japanese, Chinese and so on. For this you can use the PGroonga extension! It's already installed on all new projects, just head to the [Database tab](https://supabase.com/dashboard/project/_/database/extensions) on the dashboard and search or scroll for 'PGroonga' and hit enable to get started. Once enabled you can head to [their excellent documentation](https://pgroonga.github.io/tutorial/) on how to start making queries. PGroonga is backed by a collection of incredible maintainers, contributors, and a broad and diverse [community](https://pgroonga.github.io/community/). diff --git a/apps/www/_blog/2022-12-16-launch-week-6-wrap-up.mdx b/apps/www/_blog/2022-12-16-launch-week-6-wrap-up.mdx index 0e42f6dcdf8..d14a6253afc 100644 --- a/apps/www/_blog/2022-12-16-launch-week-6-wrap-up.mdx +++ b/apps/www/_blog/2022-12-16-launch-week-6-wrap-up.mdx @@ -86,7 +86,7 @@ Today released pg_graphql 1.0 release and its general availability on our platfo - Read the [announcement](/blog/pg-graphql-v1) - Browse the [GitHub repo](https://github.com/supabase/pg_graphql/) -- View the [docs](/docs/guides/database/api#graphql-api) +- View the [docs](/docs/guides/api#graphql-api-overview) ## Postgres Point-in-Time Recovery diff --git a/apps/www/_blog/2022-12-16-postgres-point-in-time-recovery.mdx b/apps/www/_blog/2022-12-16-postgres-point-in-time-recovery.mdx index dbce9c96b54..a566c13a3e0 100644 --- a/apps/www/_blog/2022-12-16-postgres-point-in-time-recovery.mdx +++ b/apps/www/_blog/2022-12-16-postgres-point-in-time-recovery.mdx @@ -17,7 +17,7 @@ When we announced Point-in-Time a few months ago, it was only available for Ente Pro-plan projects created from August 2022 onwards (excluding Northeast Asia / Seoul region), can now enable Point In Time Recovery (PITR) from the dashboard. Projects must have the "Small" compute add-on to ensure that PITR operates smoothly behind the scenes. -For older projects, don't worry - we're working on making it available for everybody. In the meantime, if you would like to have PITR enabled for your ineligible project, reach out directly [here](https://app.supabase.com/support/new), or stay tuned for further updates. +For older projects, don't worry - we're working on making it available for everybody. In the meantime, if you would like to have PITR enabled for your ineligible project, reach out directly [here](https://supabase.com/dashboard/support/new), or stay tuned for further updates. ## What is Point in Time Recovery? @@ -39,7 +39,7 @@ While all Pro-plan projects and above are backed up on a daily basis, this means ## Getting started -Enabling Point in Time Recovery can be done [in the Dashboard](https://app.supabase.com/project/_/settings/billing/subscription?panel=pitr). Retention for backups used by PITR is set to up to 7 days by default but could be increased to up to 28 days via self-serve. +Enabling Point in Time Recovery can be done [in the Dashboard](https://supabase.com/dashboard/project/_/settings/billing/subscription?panel=pitr). Retention for backups used by PITR is set to up to 7 days by default but could be increased to up to 28 days via self-serve. ## More Launch Week 6 diff --git a/apps/www/_blog/2022-12-16-vault-now-in-beta.mdx b/apps/www/_blog/2022-12-16-vault-now-in-beta.mdx index ef7a62d1640..fcc9bbec397 100644 --- a/apps/www/_blog/2022-12-16-vault-now-in-beta.mdx +++ b/apps/www/_blog/2022-12-16-vault-now-in-beta.mdx @@ -22,7 +22,7 @@ Vault is a new Postgres extension and accompanying Supabase UI that makes it saf ❇️ UPDATE JUNE 2023 ❇️ -Supabase Vault is now available on every Supabase project. [Check it out](https://app.supabase.com/project/_/settings/vault/secrets) +Supabase Vault is now available on every Supabase project. [Check it out](https://supabase.com/dashboard/project/_/settings/vault/secrets) @@ -89,7 +89,7 @@ Privacy is becoming one of the most important features in a modern product. Supa ### Using the Vault -To learn how to use Supabase Vault, check out the [docs](https://supabase.com/docs/guides/database/vault). Supabase Vault is now available on all [Supabase projects](https://app.supabase.com/project/_/settings/vault/secrets) (some projects will have to enable via request). +To learn how to use Supabase Vault, check out the [docs](https://supabase.com/docs/guides/database/vault). Supabase Vault is now available on all [Supabase projects](https://supabase.com/dashboard/project/_/settings/vault/secrets) (some projects will have to enable via request). ## More Launch Week 6 diff --git a/apps/www/_blog/2023-02-03-openai-embeddings-postgres-vector.mdx b/apps/www/_blog/2023-02-03-openai-embeddings-postgres-vector.mdx index 18c1b7a7473..d9899ee5152 100644 --- a/apps/www/_blog/2023-02-03-openai-embeddings-postgres-vector.mdx +++ b/apps/www/_blog/2023-02-03-openai-embeddings-postgres-vector.mdx @@ -12,11 +12,17 @@ date: '2023-02-06' toc_depth: 3 --- -A new PostgreSQL extension is now available in Supabase: [`pgvector`](https://supabase.com/docs/guides/getting-started/openai/vector-search), an open-source vector similarity search. +A new PostgreSQL extension is now available in Supabase: [`pgvector`](https://github.com/pgvector/pgvector), an open-source vector similarity search. The exponential progress of AI functionality over the past year has inspired many new real world applications. One specific challenge has been the ability to store and query _embeddings_ at scale. In this post we'll explain what embeddings are, why we might want to use them, and how we can store and query them in PostgreSQL using `pgvector`. +
+ +🆕 Supabase has now released an open source toolkit for developing AI applications using Postgres and pgvector. Learn more in the [AI & Vectors docs](https://supabase.com/docs/guides/ai). + +
+ ## What are embeddings? Embeddings capture the “relatedness” of text, images, video, or other types of information. This relatedness is most commonly used for: @@ -370,7 +376,7 @@ The OpenAI API supports [completion streaming](https://platform.openai.com/docs/ Storing embeddings in Postgres opens a world of possibilities. You can combine your search function with telemetry functions, add an user-provided feedback (thumbs up/down), and make your search feel more integrated with your products. -The [pgvector extension](https://supabase.com/docs/guides/getting-started/openai/vector-search) is available on all new Supabase projects today. If you want to try it out, launch a new Postgres database today: [database.new](https://database.new) +The [pgvector extension](https://supabase.com/docs/guides/ai/vector-columns) is available on all new Supabase projects today. If you want to try it out, launch a new Postgres database today: [database.new](https://database.new) ## More pgvector and ChatGPT resources diff --git a/apps/www/_blog/2023-02-16-case-study-happyteams.mdx b/apps/www/_blog/2023-02-16-case-study-happyteams.mdx index cd7366094c0..47c90329823 100644 --- a/apps/www/_blog/2023-02-16-case-study-happyteams.mdx +++ b/apps/www/_blog/2023-02-16-case-study-happyteams.mdx @@ -5,7 +5,6 @@ author: rory_wilding image: 2023-02-16-case-study-happyteams/case-study-happyteams.png thumb: 2023-02-16-case-study-happyteams/case-study-happyteams.png tags: - - case-study date: '2023-02-16' toc_depth: 3 --- diff --git a/apps/www/_blog/2023-03-09-supabase-beta-update-february-2023.mdx b/apps/www/_blog/2023-03-09-supabase-beta-update-february-2023.mdx index 6b43011f606..42d8126000c 100644 --- a/apps/www/_blog/2023-03-09-supabase-beta-update-february-2023.mdx +++ b/apps/www/_blog/2023-03-09-supabase-beta-update-february-2023.mdx @@ -18,7 +18,7 @@ There’s something for everybody this month: AI, Auth, Database, Edge Functions The most popular GraphQL IDE/API explorer is now built into the dashboard! You can now explore and query your GraphQL API produced by `pg_graphql`. -[Try it now](https://app.supabase.com/project/_/api/graphiql). +[Try it now](https://supabase.com/dashboard/project/_/api/graphiql). ## Supabase + OpenAI search @@ -52,7 +52,7 @@ We rewrote the Postgres Dockerfile with multi-stage builds so that each extensio We've improved database role management. You can create, update, and delete database roles through the dashboard. Just one small step towards column-level security -[Check it out.](https://app.supabase.com/project/_/editor) +[Check it out.](https://supabase.com/dashboard/project/_/editor) ## API docs in the table editor @@ -60,7 +60,7 @@ We've improved database role management. You can create, update, and delete data API docs got a light touchup and were moved to the table editor. You can now look up API methods and generate & download type files right there ✨ -[Check it out.](https://app.supabase.com/project/_/editor) +[Check it out.](https://supabase.com/dashboard/project/_/editor) ## Quick product updates @@ -70,9 +70,9 @@ API docs got a light touchup and were moved to the table editor. You can now loo - **Database**: supabase-js now infers the response type from your query. If the inferred type is incorrect, you can use `.returns()` to override it. [Doc](https://supabase.com/docs/reference/javascript/db-returns) -- **Dashboard**: Improved database roles management, you can now create, update and delete database roles through the dashboard. [Dashboard](https://app.supabase.com/project/_/database/roles) +- **Dashboard**: Improved database roles management, you can now create, update and delete database roles through the dashboard. [Dashboard](https://supabase.com/dashboard/project/_/database/roles) -- **Dashboard**: We've provided a reference panel showing all available paths that can be queried from each respective source that improves the Logs Explorer experience. [Dashboard](https://app.supabase.com/project/_/logs/explorer) +- **Dashboard**: We've provided a reference panel showing all available paths that can be queried from each respective source that improves the Logs Explorer experience. [Dashboard](https://supabase.com/dashboard/project/_/logs/explorer) - **Edge Functions**: upgraded to Deno 1.30.3, that supports TypeScript 4.9.x and introduces `satisfies`. Thanks to [Benjamin Dobell](https://github.com/Benjamin-Dobell) 🙏. [PR](https://github.com/supabase/functions-relay/pull/30) diff --git a/apps/www/_blog/2023-04-04-infinite-scroll-with-nextjs-framer-motion.mdx b/apps/www/_blog/2023-04-04-infinite-scroll-with-nextjs-framer-motion.mdx index a8c453cf3db..342621c48bc 100644 --- a/apps/www/_blog/2023-04-04-infinite-scroll-with-nextjs-framer-motion.mdx +++ b/apps/www/_blog/2023-04-04-infinite-scroll-with-nextjs-framer-motion.mdx @@ -13,13 +13,23 @@ date: '2023-04-04' toc_depth: 6 --- -Every Launch Week, we strive to approach things differently as part of our Kaizen mindset. For [Launch Week 7](https://www.supabase.com/launch-week), we decided to add some twists to our popular ticket system. While we made several changes, this post will focus on one specific modification (we'll share more about the rest this week!). +Imagine you generated a bunch of tickets for the Supabase [Launch Week](https://www.supabase.com/launch-week) and you wanted to display them all on [one page](https://supabase.com/launch-week/tickets). It might take a while to load them all in one go, so a great solution to this would be to lazy load smaller subsets while the user scrolls, with a technique called infinite scrolling. -Our goal was to feature all the amazing people who generated tickets on a [single page](https://supabase.com/launch-week/tickets). However, displaying them all at once could take a toll on the loading time and negatively affect the user experience. That's where lazy loading smaller subsets via infinite scrolling came in as the solution. +An infinite scroll allows you to scroll through your content endlessly while only loading the data you need as you need it. This improves performance while also creating a smooth and delightful user experience. -With infinite scrolling, users can effortlessly scroll through content while only loading the necessary data as they go. This not only enhances performance but also creates a seamless and enjoyable user experience. +In this post, we'll go through the steps of how to create an infinite scroll using NextJs, Supabase, and Framer Motion. -In this post, we'll detail our process of implementing infinite scrolling using Next.js, Framer Motion, and Supabase. +## What is infinite scroll? + +Infinite scroll is a web design technique that automatically loads and displays new content as a user scrolls down a web page. This creates an endless scrolling experience that eliminates the need for pagination and provides a seamless and uninterrupted browsing experience. + +Infinite scroll can enhance user experience and engagement by providing a fluid and intuitive browsing experience. It also allows for faster content delivery. By dynamically loading and displaying content as needed, infinite scroll can reduce the time and effort required to load pages and deliver content, improving overall performance and user satisfaction. + +Next.js is a great choice for implementing infinite scroll in a web application. It provides server-side rendering and automatic code splitting, which can improve performance and optimize the loading of content as the user scrolls down the page. It also includes built-in support for dynamic imports and lazy loading, which can be useful for fetching and displaying content as needed. + +Plus, incremental static regeneration (ISR) and pre-fetching of data can further improve performance by enabling the server to generate and cache pages in advance, and to pre-fetch data as the user scrolls down the page. + +Now, onto the code! ## Step 0. Load the first batch diff --git a/apps/www/_blog/2023-04-13-supabase-auth-sso-pkce.mdx b/apps/www/_blog/2023-04-13-supabase-auth-sso-pkce.mdx index e8783c245d3..ecf8951c8e1 100644 --- a/apps/www/_blog/2023-04-13-supabase-auth-sso-pkce.mdx +++ b/apps/www/_blog/2023-04-13-supabase-auth-sso-pkce.mdx @@ -39,7 +39,7 @@ Building SSO into your application isn't necessarily hard, but does come with so ### Getting Started with SAML 2.0 -To get started, enable the [“SAML 2.0“](https://app.supabase.com/project/phcnitosaawbzytgyznx/auth/providers) [authentication method in the dashboard](https://app.supabase.com/project/_/auth/providers). We've added new commands to the [Supabase CLI](https://supabase.com/docs/guides/cli) to help with the configuration process: +To get started, enable the [“SAML 2.0“](https://supabase.com/dashboard/project/phcnitosaawbzytgyznx/auth/providers) [authentication method in the dashboard](https://supabase.com/dashboard/project/_/auth/providers). We've added new commands to the [Supabase CLI](https://supabase.com/docs/guides/cli) to help with the configuration process: ```bash $ supabase sso --help diff --git a/apps/www/_blog/2023-06-27-native-mobile-auth.mdx b/apps/www/_blog/2023-06-27-native-mobile-auth.mdx new file mode 100644 index 00000000000..d9a3c7aeb43 --- /dev/null +++ b/apps/www/_blog/2023-06-27-native-mobile-auth.mdx @@ -0,0 +1,133 @@ +--- +title: Native Mobile Auth Support for Google and Apple Sign in +description: Supabase auth adds full support for native mobile sign in with Apple and Google. +author: stojan,tyler_shukert +image: native-mobile-auth/native-mobile-auth.png +thumb: native-mobile-auth/native-mobile-auth.png +tags: + - auth +date: '2023-06-27' +toc_depth: 3 +--- + +Supabase supports OAuth logins with 17 providers including Apple, Google, Microsoft, GitHub, … But for native mobile apps, this meant that developers had to use a web browser to sign in. It’s not an ideal flow for users, who are already used to signing in with the operating system’s native dialogs when possible. Today, we are excited to announce full native support for Sign in with Apple and Google on iOS and Android. But this is not all! Supabase Auth now can now be used with one-tap sign in methods like: Sign in with Apple JS, Sign in with Google for Web or even in Chrome extensions. + +## Native Sign in with Apple and Google + +Developers of native iOS and Android apps (using Flutter or React Native) can now take advantage of OS-provided authentication dialogs for Apple and Google. This is available on iOS, macOS, tvOS and watchOS apps in the Apple ecosystem, and all Android variants in the Google ecosystem. + +In full transparency, this was always sort-of possible but there were some edge cases that were not covered well with Supabase Auth. We’ve since ironed out the developer experience and made this into a fully supported feature. + +Behind the scenes, these native sign in methods use ID tokens. They’re a formalized version of a JWT that is issued by Apple or Google and contain profile information. Supabase Auth now can properly validate the ID tokens and create new or link to existing user accounts based on email similarity. + +### Using Sign in with Google in a Flutter App + +To setup Sign in with Google in your Flutter native app, you need to set up your Google Cloud project for each platform: + +- For iOS apps: [Get started with Google Sign In for iOS and macOS](https://developers.google.com/identity/sign-in/ios/start-integrating#get_an_oauth_client_id) +- For Android apps: [Get started with One Tap sign-in and sign-up for Android](https://developers.google.com/identity/one-tap/android/get-started) + +We’ve revamped the Google provider [configuration screen in the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers). Besides the existing OAuth flow, you can now add additional _Authorized Client IDs_ meant for native sign in. + +The client ID you obtained from the setup instructions above should be added to _Authorized Client IDs._ + +![Supabase dashboard Google provider](/images/blog/native-mobile-auth/google-setting.png) + +It really is as simple as that. + +### Using Sign in with Apple in a Flutter App + +We introduced support for native Sign in with Apple in our [previous launch week](https://supabase.com/blog/supabase-auth-sso-pkce#native-apple-login-on-ios#native-apple-login-on-ios), but today we are adding support for multiple apps on a single Supabase project. + +All you need to do is to register all of the bundle IDs of your apps in the [Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers) under _Authorized Client IDs_ as a comma separated string. + +![Supabase dashboard Apple provider](/images/blog/native-mobile-auth/apple-setting.png) + +## Using Sign in with Apple and Google in React Native Apps + +If you use React Native to build your native apps, you can still benefit. Please check out these resources to see how you can show the native authentication dialog and obtain an ID token from the operating system: + +- https://github.com/invertase/react-native-apple-authentication +- [Expo AppleAuthentication](https://docs.expo.dev/versions/latest/sdk/apple-authentication/) +- https://github.com/react-native-google-signin/google-signin + +You would still need to configure the _Authorized Client IDs_ as shown in the Flutter examples above. + +Finally, once you’ve received a valid ID token from the operating system you can call `supabase.auth.signInWithIdToken()` to complete the sign in with your Supabase project. + +For Sign in with Apple: + +```tsx +await supabase.auth.signInWithIdToken({ + provider: 'apple', + token: '', +}) +``` + +For Sign in with Google: + +```tsx +await supabase.auth.signInWithIdToken({ + provider: 'google', + token: '', +}) +``` + +We also have some sample implementation for Expo apps in our [create-t3-turbo example](https://github.com/supabase-community/create-t3-turbo/blob/main/apps/expo/src/app/profile.tsx#L47). + +## Using Sign in with Apple and Google in Flutter Apps + +Once you have configured your Supabase instance, you can utilize third party libraries like **[sign_in_with_apple](https://pub.dev/packages/sign_in_with_apple) or [google_one_tap_sign_in](https://pub.dev/packages/google_one_tap_sign_in) to perform naive sign in, and pass the ID token to Supabase to complete the sign in.** + +For Sign in with Apple: + +```dart +final credential = await SignInWithApple.getAppleIDCredential( + scopes: [ + AppleIDAuthorizationScopes.email, + AppleIDAuthorizationScopes.fullName, + ], +); + +await supabase.auth.signInWithIdToken( + provider: Provider.apple, + idToken: credential.identityToken!, +); +``` + +And for Google: + +```dart +var result = await GoogleOneTapSignIn.handleSignIn(webClientId: ''); + +supabase.auth.signInWithIdToken( + provider: Provider.google, + idToken: result.data!.idToken!, +); +``` + +You can find more detailed instructions on the [here](https://github.com/supabase/supabase-flutter#authentication). + +## Sign in with Apple JS, Google One Tap and Chrome Extensions + +Although sign in on native platforms was the focus of the team when working on this feature, incidentally we’ve added proper support for Sign in with Apple JS, Google’s One Tap and support for authenticating within Google Chrome extensions. + +You can now take advantage of these web frameworks, most notably Google’s One Tap and Automatic Sign-in support for a frictionless onboarding experience for your users. + +All you need to do is configure the web frameworks and use the `signInWithIdToken()` method to pass the ID token provided by the Google and Apple libraries. + +For example, to use Google One tap you should first [generate an embed code for the Google Sign in Button](https://developers.google.com/identity/gsi/web/tools/configurator). Register this method as the callback that will receive the authentication response from the button: + +```tsx +async function handleSignInWithGoogle(response) { + const { data, error } = await supabase.auth.signInWithIdToken({ + token: response.credential, + nonce: 'NONCE', // must be the same one as provided in data-nonce (if any) + }) +} +``` + +## Resources + +- [Login with Apple Guide](https://supabase.com/docs/guides/auth/social-login/auth-apple) +- [Login with Google Guide](https://supabase.com/docs/guides/auth/social-login/auth-google) diff --git a/apps/www/_customers/mendableai.mdx b/apps/www/_customers/mendableai.mdx index bcaa5e6cf50..44d70141b7e 100644 --- a/apps/www/_customers/mendableai.mdx +++ b/apps/www/_customers/mendableai.mdx @@ -34,8 +34,9 @@ about: Mendable is Chat Powered Search for Documentation. Mendable was experiencing tremendous success, growing Weekly Active Users nearly 300% since March. They needed a tool to store and search through large amounts of vector data to improve the efficiency and accuracy of their similarity search operations. They tried Faiss, Weaviate, and Pinecone, but found them to be expensive and not very intuitive, especially when it came to storing metadata along with the vectors. -Why they chose Supabase: -Mendable discovered that Supabase supports pgvector and found it to be a simple and cost-effective solution. They were impressed with the open source nature of Supabase, as well as its ability to store metadata alongside the vectors. They also appreciated the intuitive interface and ease of use. +## Why they chose Supabase + +Mendable discovered that Supabase supports [pgvector](https://supabase.com/docs/guides/database/extensions/pgvector) and found it to be a simple and cost-effective solution. They were impressed with the open source nature of Supabase, as well as its ability to store metadata alongside the vectors. They also appreciated the intuitive interface and ease of use. We tried other vector databases - we tried Faiss, we tried Weaviate, we tried Pinecone. We found @@ -45,18 +46,18 @@ Mendable discovered that Supabase supports pgvector and found it to be a simple ## What They Built -Using Supabase and pgvector, Mendable was able to build a more efficient and accurate search function for their AI chatbot. By storing vector data alongside metadata in Supabase, Mendable was able to quickly and easily search through their customers documentation to find the most relevant responses to queries. They found that Supabase's solution was just as performant as dedicated vector databases, but without the high cost. +Using [Supabase Vector](https://supabase.com/vector), Mendable was able to build a more efficient and accurate search function for their AI chatbot. By storing vector data alongside metadata in Supabase, Mendable was able to quickly and easily search through their customers documentation to find the most relevant responses to queries. They found that Supabase's solution was just as performant as dedicated vector databases, but without the high cost. ## The Results Thanks to Supabase and pgvector, Mendable was able to significantly improve the efficiency and accuracy of their Chat Powered Search for Documentation. They were able to build faster and more cost-effectively using Supabase’s open source stack. -## Tech stack - -Mendable's tech stack includes React, Next.js, Express, Vercel, and Supabase. - We looked at the alternatives and chose Supabase because it’s open source, it’s simpler, and, for all the ways we need use it, Supabase has been just as performant - if not more performant - than the other vector databases. + +## Tech stack + +Mendable's tech stack includes React, Next.js, Express, Vercel, and Supabase. diff --git a/apps/www/_customers/mobbin.mdx b/apps/www/_customers/mobbin.mdx index 6a8345ed0c7..ae7c0334ee8 100644 --- a/apps/www/_customers/mobbin.mdx +++ b/apps/www/_customers/mobbin.mdx @@ -98,4 +98,4 @@ Migrating to Supabase helped the team at Mobbin instantly improve the end-user e They now add new features with confidence, and they continue to help creators all over the globe find inspiration for their next project. You can [check out Mobbin on their website](https://mobbin.design/browse/ios/apps). -[Sign up](https://app.supabase.com/) for Supabase's free plan and set up a scalable backend in less than 2 minutes. +[Sign up](https://supabase.com/dashboard/) for Supabase's free plan and set up a scalable backend in less than 2 minutes. diff --git a/apps/www/_customers/replenysh.mdx b/apps/www/_customers/replenysh.mdx index ca6aef5898f..4d70d4674b3 100644 --- a/apps/www/_customers/replenysh.mdx +++ b/apps/www/_customers/replenysh.mdx @@ -88,4 +88,4 @@ Replenysh clients include brands with global recognition and millions of custome ## Supabase help Replenysh continue to scale -Supabase turbo-charged Replenysh's development time with a seamless end-user mobile login experience. They are ready to help major brands engage with their users through participation in the circular economy. With Supabase, the team knows they have a slick auth experience, reduced DevOps overhead, and can continue to scale with Postgres. You can test out the Supabase developer experience today by [starting a new project on the free plan!.](https://app.supabase.com) +Supabase turbo-charged Replenysh's development time with a seamless end-user mobile login experience. They are ready to help major brands engage with their users through participation in the circular economy. With Supabase, the team knows they have a slick auth experience, reduced DevOps overhead, and can continue to scale with Postgres. You can test out the Supabase developer experience today by [starting a new project on the free plan!.](https://supabase.com/dashboard) diff --git a/apps/www/components/Globe.tsx b/apps/www/components/Globe.tsx index 49470073455..8cf57e7ff59 100644 --- a/apps/www/components/Globe.tsx +++ b/apps/www/components/Globe.tsx @@ -51,7 +51,10 @@ const Globe = () => { }, }) setTimeout(() => (canvasRef.current.style.opacity = '1')) - return () => cobe.destroy() + return () => { + cobe.destroy() + window.removeEventListener('resize', onResize) + } }, [isDarkMode]) return ( diff --git a/apps/www/components/Hero/Hero.tsx b/apps/www/components/Hero/Hero.tsx index c53be844c4c..a309e4087f1 100644 --- a/apps/www/components/Hero/Hero.tsx +++ b/apps/www/components/Hero/Hero.tsx @@ -44,7 +44,11 @@ const Hero = () => {

- + sendTelemetryEvent(gaEvents['www_hp_hero_startProject'])}> +
+ +
+ +
+ + + ); +}; diff --git a/examples/user-management/refine-user-management/src/components/auth.tsx b/examples/user-management/refine-user-management/src/components/auth.tsx new file mode 100644 index 00000000000..1c043ffcb31 --- /dev/null +++ b/examples/user-management/refine-user-management/src/components/auth.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import { useLogin } from "@refinedev/core"; + +export default function Auth() { + const [email, setEmail] = useState(""); + const { isLoading, mutate: login } = useLogin(); + + const handleLogin = async (event: { preventDefault: () => void }) => { + event.preventDefault(); + login({ email }); + }; + + return ( +
+
+

Supabase + refine

+

Sign in via magic link with your email below

+
+
+ setEmail(e.target.value)} + /> +
+
+ +
+
+
+
+ ); +}; diff --git a/examples/user-management/refine-user-management/src/components/avatar.tsx b/examples/user-management/refine-user-management/src/components/avatar.tsx new file mode 100644 index 00000000000..0feb85b2982 --- /dev/null +++ b/examples/user-management/refine-user-management/src/components/avatar.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from "react"; +import { supabaseClient } from "../utility/supabaseClient"; + +type TAvatarProps = { + url?: string; + size: number; + onUpload: (filePath: string) => void; +}; + +export default function Avatar({ + url, + size, + onUpload, +}: TAvatarProps) { + const [avatarUrl, setAvatarUrl] = useState(""); + const [uploading, setUploading] = useState(false); + + useEffect(() => { + if (url) downloadImage(url); + }, [url]); + + async function downloadImage(path: string) { + try { + const { data, error } = await supabaseClient.storage + .from("avatars") + .download(path); + if (error) { + throw error; + } + const url = URL.createObjectURL(data); + setAvatarUrl(url); + } catch (error: any) { + console.log("Error downloading image: ", error?.message); + } + } + + async function uploadAvatar(event: React.ChangeEvent) { + try { + setUploading(true); + + if (!event.target.files || event.target.files.length === 0) { + throw new Error("You must select an image to upload."); + } + + const file = event.target.files[0]; + const fileExt = file.name.split(".").pop(); + const fileName = `${Math.random()}.${fileExt}`; + const filePath = `${fileName}`; + + const { error: uploadError } = await supabaseClient.storage + .from("avatars") + .upload(filePath, file); + + if (uploadError) { + throw uploadError; + } + onUpload(filePath); + } catch (error: any) { + alert(error.message); + } finally { + setUploading(false); + } + } + + return ( +
+ {avatarUrl ? ( + Avatar + ) : ( +
+ )} +
+ + +
+
+ ); +}; diff --git a/examples/user-management/refine-user-management/src/index.tsx b/examples/user-management/refine-user-management/src/index.tsx new file mode 100644 index 00000000000..8d978ffc882 --- /dev/null +++ b/examples/user-management/refine-user-management/src/index.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; + +import App from "./App"; + +const container = document.getElementById("root") as HTMLElement; +const root = createRoot(container); + +root.render( + + + +); diff --git a/examples/user-management/refine-user-management/src/utility/index.ts b/examples/user-management/refine-user-management/src/utility/index.ts new file mode 100644 index 00000000000..1a21596513d --- /dev/null +++ b/examples/user-management/refine-user-management/src/utility/index.ts @@ -0,0 +1 @@ +export * from "./supabaseClient"; diff --git a/examples/user-management/refine-user-management/src/utility/supabaseClient.ts b/examples/user-management/refine-user-management/src/utility/supabaseClient.ts new file mode 100644 index 00000000000..d58cb40f1e4 --- /dev/null +++ b/examples/user-management/refine-user-management/src/utility/supabaseClient.ts @@ -0,0 +1,14 @@ +import { createClient } from "@refinedev/supabase"; + + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; + +export const supabaseClient = createClient(supabaseUrl, supabaseAnonKey, { + db: { + schema: "public", + }, + auth: { + persistSession: true, + }, +}); diff --git a/examples/user-management/refine-user-management/src/vite-env.d.ts b/examples/user-management/refine-user-management/src/vite-env.d.ts new file mode 100644 index 00000000000..11f02fe2a00 --- /dev/null +++ b/examples/user-management/refine-user-management/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/user-management/refine-user-management/tsconfig.json b/examples/user-management/refine-user-management/tsconfig.json new file mode 100644 index 00000000000..c132c6386c5 --- /dev/null +++ b/examples/user-management/refine-user-management/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src", "vite.config.ts"], + "references": [ + { + "path": "./tsconfig.node.json" + } + ] +} diff --git a/examples/user-management/refine-user-management/tsconfig.node.json b/examples/user-management/refine-user-management/tsconfig.node.json new file mode 100644 index 00000000000..bfa585014fc --- /dev/null +++ b/examples/user-management/refine-user-management/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "node" + }, + "include": ["vite.config.ts"] +} diff --git a/examples/user-management/refine-user-management/vite.config.ts b/examples/user-management/refine-user-management/vite.config.ts new file mode 100644 index 00000000000..58676f788a8 --- /dev/null +++ b/examples/user-management/refine-user-management/vite.config.ts @@ -0,0 +1,6 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/examples/user-management/solid-user-management/README.md b/examples/user-management/solid-user-management/README.md index c9d3f6002db..cf43c79c593 100644 --- a/examples/user-management/solid-user-management/README.md +++ b/examples/user-management/solid-user-management/README.md @@ -31,7 +31,7 @@ Your app is ready to be deployed! ### 1. Create new project -Sign up to Supabase - [https://app.supabase.com](https://app.supabase.com) and create a new project. Wait for your database to start. +Sign up to Supabase - [https://supabase.com/dashboard](https://supabase.com/dashboard) and create a new project. Wait for your database to start. ### 2. Run "User Management" Quickstart @@ -68,41 +68,61 @@ This is a trimmed-down schema, with the policies: ```sql -- Create a table for Public Profiles -create table profiles ( - id uuid references auth.users not null, - updated_at timestamp with time zone, - username text unique, - avatar_url text, - website text, - primary key (id), - unique(username), - constraint username_length check (char_length(username) >= 3) -); -alter table profiles enable row level security; -create policy "Public profiles are viewable by everyone." - on profiles for select - using ( true ); -create policy "Users can insert their own profile." - on profiles for insert - with check ( auth.uid() = id ); -create policy "Users can update own profile." - on profiles for update - using ( auth.uid() = id ); +create table + profiles ( + id uuid references auth.users not null, + updated_at timestamp + with + time zone, + username text unique, + avatar_url text, + website text, + primary key (id), + unique (username), + constraint username_length check (char_length(username) >= 3) + ); + +alter table + profiles enable row level security; + +create policy "Public profiles are viewable by everyone." on profiles for +select + using (true); + +create policy "Users can insert their own profile." on profiles for insert +with + check (auth.uid () = id); + +create policy "Users can update own profile." on profiles for +update + using (auth.uid () = id); + -- Set up Realtime! begin; - drop publication if exists supabase_realtime; - create publication supabase_realtime; + +drop + publication if exists supabase_realtime; + +create publication supabase_realtime; + commit; -alter publication supabase_realtime add table profiles; + +alter + publication supabase_realtime add table profiles; + -- Set up Storage! -insert into storage.buckets (id, name) -values ('avatars', 'avatars'); -create policy "Avatar images are publicly accessible." - on storage.objects for select - using ( bucket_id = 'avatars' ); -create policy "Anyone can upload an avatar." - on storage.objects for insert - with check ( bucket_id = 'avatars' ); +insert into + storage.buckets (id, name) +values + ('avatars', 'avatars'); + +create policy "Avatar images are publicly accessible." on storage.objects for +select + using (bucket_id = 'avatars'); + +create policy "Anyone can upload an avatar." on storage.objects for insert +with + check (bucket_id = 'avatars'); ``` ## Authors diff --git a/examples/user-management/svelte-user-management/README.md b/examples/user-management/svelte-user-management/README.md index d53c5f1667f..62e6038e035 100644 --- a/examples/user-management/svelte-user-management/README.md +++ b/examples/user-management/svelte-user-management/README.md @@ -29,7 +29,7 @@ Your app is ready to be deployed! ### 1. Create new project -Sign up to Supabase - [https://app.supabase.com](https://app.supabase.com) and create a new project. Wait for your database to start. +Sign up to Supabase - [https://supabase.com/dashboard](https://supabase.com/dashboard) and create a new project. Wait for your database to start. ### 2. Run "User Management" Quickstart @@ -66,41 +66,61 @@ This is a trimmed-down schema, with the policies: ```sql -- Create a table for Public Profiles -create table profiles ( - id uuid references auth.users not null, - updated_at timestamp with time zone, - username text unique, - avatar_url text, - website text, - primary key (id), - unique(username), - constraint username_length check (char_length(username) >= 3) -); -alter table profiles enable row level security; -create policy "Public profiles are viewable by everyone." - on profiles for select - using ( true ); -create policy "Users can insert their own profile." - on profiles for insert - with check ( auth.uid() = id ); -create policy "Users can update own profile." - on profiles for update - using ( auth.uid() = id ); +create table + profiles ( + id uuid references auth.users not null, + updated_at timestamp + with + time zone, + username text unique, + avatar_url text, + website text, + primary key (id), + unique (username), + constraint username_length check (char_length(username) >= 3) + ); + +alter table + profiles enable row level security; + +create policy "Public profiles are viewable by everyone." on profiles for +select + using (true); + +create policy "Users can insert their own profile." on profiles for insert +with + check (auth.uid () = id); + +create policy "Users can update own profile." on profiles for +update + using (auth.uid () = id); + -- Set up Realtime! begin; - drop publication if exists supabase_realtime; - create publication supabase_realtime; + +drop + publication if exists supabase_realtime; + +create publication supabase_realtime; + commit; -alter publication supabase_realtime add table profiles; + +alter + publication supabase_realtime add table profiles; + -- Set up Storage! -insert into storage.buckets (id, name) -values ('avatars', 'avatars'); -create policy "Avatar images are publicly accessible." - on storage.objects for select - using ( bucket_id = 'avatars' ); -create policy "Anyone can upload an avatar." - on storage.objects for insert - with check ( bucket_id = 'avatars' ); +insert into + storage.buckets (id, name) +values + ('avatars', 'avatars'); + +create policy "Avatar images are publicly accessible." on storage.objects for +select + using (bucket_id = 'avatars'); + +create policy "Anyone can upload an avatar." on storage.objects for insert +with + check (bucket_id = 'avatars'); ``` ## Authors diff --git a/examples/user-management/sveltekit-user-management/README.md b/examples/user-management/sveltekit-user-management/README.md index 0cb8d23aa43..57ff0a09664 100644 --- a/examples/user-management/sveltekit-user-management/README.md +++ b/examples/user-management/sveltekit-user-management/README.md @@ -4,7 +4,7 @@ ### 1. Create new project -Sign up to Supabase - [https://app.supabase.com](https://app.supabase.com) and create a new project. Wait for your database to start. +Sign up to Supabase - [https://supabase.com/dashboard](https://supabase.com/dashboard) and create a new project. Wait for your database to start. ### 2. Run "User Management" Quickstart diff --git a/i18n/README.ar.md b/i18n/README.ar.md index c527ed5d2c1..207bc4d1e8b 100644 --- a/i18n/README.ar.md +++ b/i18n/README.ar.md @@ -35,7 +35,7 @@ ## الحالة - [x] طور الألفا: نحن نختبر Supabase مع مجموعة مغلقة من العملاء -- [x] طور الألفا العام: يمكن لأي شخص الاشتراك في [app.supabase.com](https://app.supabase.com). لكن تحلى بالصبر ، فهناك بعض العقد الصغيرة. +- [x] طور الألفا العام: يمكن لأي شخص الاشتراك في [supabase.com/dashboard](https://supabase.com/dashboard). لكن تحلى بالصبر ، فهناك بعض العقد الصغيرة. - [x] طور البيتا العام: مستقر بدرجة كافية لمعظم حالات الاستخدام غير المتعلقة بالمؤسسات - [ ] الطور العام: جاهز للإنتاج @@ -49,7 +49,7 @@ Supabase عبارة عن مجموعة من الأدوات مفتوحة المص **الهيكلة الحالية** -(supabase) هي [منصة مستضافة](https://app.supabase.com), يمكنك التسجيل والبدأ باستخدامها دون الحاجة لتثبيت أي شئ. يمكنك أيضا [استضافتها ذاتيا](https://supabase.com/docs/guides/hosting/overview) و [تطويرها داخليا](https://supabase.com/docs/guides/local-development). +(supabase) هي [منصة مستضافة](https://supabase.com/dashboard), يمكنك التسجيل والبدأ باستخدامها دون الحاجة لتثبيت أي شئ. يمكنك أيضا [استضافتها ذاتيا](https://supabase.com/docs/guides/hosting/overview) و [تطويرها داخليا](https://supabase.com/docs/guides/local-development). ![Architecture](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png) diff --git a/i18n/README.bg.md b/i18n/README.bg.md index 37b90e633c1..78a4634659e 100644 --- a/i18n/README.bg.md +++ b/i18n/README.bg.md @@ -12,9 +12,9 @@ - [x] Хоствана база данни Postgres. [Документи](https://supabase.com/docs/guides/database) - [x] Удостоверяване и оторизация. [Документи](https://supabase.com/docs/guides/auth) - [x] Автоматично генерирани API. - - [x] REST. [Документи](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Документи](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Абонаменти в реално време. [Документи](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Документи](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Документи](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Абонаменти в реално време. [Документи](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Функции. - [x] Функции за бази данни. [Docs](https://supabase.com/docs/guides/database/functions) - [x] Крайни функции [Docs](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ ## Статус - [x] Алфа: Тестваме Supabase със затворен набор от клиенти -- [x] Публична алфа: Всеки може да се регистрира на адрес [app.supabase.com](https://app.supabase.com). Но не се притеснявайте от нас, има няколко проблема +- [x] Публична алфа: Всеки може да се регистрира на адрес [supabase.com/dashboard](https://supabase.com/dashboard). Но не се притеснявайте от нас, има няколко проблема - [x] Публична бета версия: Достатъчно стабилна за повечето случаи на използване извън предприятията - [ ] Публична: Обща наличност [[статус](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ Supabase е комбинация от инструменти с отворен **Архитектура** -Supabase е [хоствана платформа](https://app.supabase.com). Можете да се регистрирате и да започнете да използвате Supabase, без да инсталирате нищо. +Supabase е [хоствана платформа](https://supabase.com/dashboard). Можете да се регистрирате и да започнете да използвате Supabase, без да инсталирате нищо. Можете също така да [самостоятелно хоствате](https://supabase.com/docs/guides/hosting/overview) и [да разработвате локално](https://supabase.com/docs/guides/local-development). ![Архитектура](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.bn.md b/i18n/README.bn.md index 298cdd07688..a6eaa0878fb 100644 --- a/i18n/README.bn.md +++ b/i18n/README.bn.md @@ -12,9 +12,9 @@ - [x] হোস্ট করা পোস্টগ্রেস ডাটাবেস. [ডক্স](https://supabase.com/docs/guides/database) - [x] অথেনটিকেশন এবং অথরাইজড . [ডক্স](https://supabase.com/docs/guides/auth) - [x] স্বয়ংক্রিয়ভাবে তৈরি এপিআই. - - [x] রেস্ট. [ডক্স](https://supabase.com/docs/guides/database/api#rest-api) - - [x] রিয়েলটাইম সাবস্ক্রিপশন. [ডক্স](https://supabase.com/docs/guides/database/api#realtime-api) - - [x] গ্রাফকিউএল (বেটা). [ডক্স](https://supabase.com/docs/guides/database/api#graphql-api) + - [x] রেস্ট. [ডক্স](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] রিয়েলটাইম সাবস্ক্রিপশন. [ডক্স](https://supabase.com/docs/guides/api#realtime-api-overview) + - [x] গ্রাফকিউএল (বেটা). [ডক্স](https://supabase.com/docs/guides/api#graphql-api-overview) - [x] ফাংশনস. - [x] ডাটাবেস ফাংশনস. [ডক্স](https://supabase.com/docs/guides/database/functions) - [x] এজ ফাংশনস. [ডক্স](https://supabase.com/docs/guides/functions) @@ -39,11 +39,11 @@ ## স্ট্যাটাস - [x] আলফা: আমরা কাছের গ্রাহকদের সাথে Supabase পরীক্ষা করছি -- [x] পাবলিক আলফা: যে কেউ [app.supabase.com](https://app.supabase.com) এ সাইন আপ করতে পারেন। কিন্তু আমাদের উপর সহজ যান, কয়েক kinks আছে +- [x] পাবলিক আলফা: যে কেউ [supabase.com/dashboard](https://supabase.com/dashboard) এ সাইন আপ করতে পারেন। কিন্তু আমাদের উপর সহজ যান, কয়েক kinks আছে - [x] পাবলিক বেটা: বেশিরভাগ নন-এন্টারপ্রাইজ ব্যবহারের ক্ষেত্রে যথেষ্ট স্থিতিশীল - [ ] পাবলিক: প্রোডাকশন রেডি -আমরা বর্তমানে পাবলিক বেটাতে আছি। বড় আপডেটের বিজ্ঞপ্তি পেতে এই রিপুর "রিলিজ" দেখুন। +আমরা বর্তমানে পাবলিক বিটাতে আছি। বড় আপডেটের বিজ্ঞপ্তি পেতে এই রেপোর "রিলিজ" দেখুন। এই রিপু দেখুন @@ -55,7 +55,7 @@ Supabase হল ওপেন সোর্স টুলের সংমিশ্ **স্থাপত্য** -সুপাবেস হল একটি [হোস্ট করা প্ল্যাটফর্ম](https://app.supabase.com)। আপনি সাইন আপ করে এবং কিছু ইনস্টল না করে সুপাবেস ব্যবহার শুরু করতে পারেন। +সুপাবেস হল একটি [হোস্ট করা প্ল্যাটফর্ম](https://supabase.com/dashboard)। আপনি সাইন আপ করে এবং কিছু ইনস্টল না করে সুপাবেস ব্যবহার শুরু করতে পারেন। এছাড়াও আপনি [স্ব-হোস্ট](https://supabase.com/docs/guides/hosting/overview) এবং [ডেভেলপ লোকালি](https://supabase.com/docs/guides/local-development) করতে পারেন। ![আর্কিটেকচার](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png) diff --git a/i18n/README.ca.md b/i18n/README.ca.md index 86e7fb7d894..09e655f1a76 100644 --- a/i18n/README.ca.md +++ b/i18n/README.ca.md @@ -33,7 +33,7 @@ Per a veure la documentació completa, visita [supabase.com/docs](https://supaba ## Estat - [x] Alfa: Estem provant Supabase amb un cercle tancat de clients. -- [x] Alfa pública: Qualsevol pot registrar-se a [app.supabase.com](https://app.supabase.com). Però sigues flexible amb nosaltres; encara poden existir obstacles. +- [x] Alfa pública: Qualsevol pot registrar-se a [supabase.com/dashboard](https://supabase.com/dashboard). Però sigues flexible amb nosaltres; encara poden existir obstacles. - [x] Beta pública: Prou estable per a la majoria dels casos no empresarials. - [ ] Públic: Llest per a producció. @@ -49,7 +49,7 @@ Supabase és una combinació d’eines de codi obert. Estem construint les funci **Arquitectura actual** -Supabase és una [plataforma allotjada](https://app.supabase.com). Et pots registrar i començar a utilitzar Supabase sense instal·lar res. També podeu tenir una [_host_ pròpia](https://supabase.com/docs/guides/hosting/overview) i [desenvolupar localment](https://supabase.com/docs/guides/local-development). +Supabase és una [plataforma allotjada](https://supabase.com/dashboard). Et pots registrar i començar a utilitzar Supabase sense instal·lar res. També podeu tenir una [_host_ pròpia](https://supabase.com/docs/guides/hosting/overview) i [desenvolupar localment](https://supabase.com/docs/guides/local-development). ![Arquitectura](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png) diff --git a/i18n/README.cs.md b/i18n/README.cs.md index b36f8c2e2b2..5b66bfa1f3c 100644 --- a/i18n/README.cs.md +++ b/i18n/README.cs.md @@ -12,9 +12,9 @@ - [x] hostovaná databáze Postgres. [Dokumenty](https://supabase.com/docs/guides/database) - [x] Ověřování a autorizace. [Dokumenty](https://supabase.com/docs/guides/auth) - [x] Automaticky generované rozhraní API. - - [x] REST. [Dokumenty](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Dokumenty](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Odběry v reálném čase. [Dokumenty](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Dokumenty](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Dokumenty](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Odběry v reálném čase. [Dokumenty](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Funkce. - [x] Databázové funkce. [Docs](https://supabase.com/docs/guides/database/functions) - [x] Okrajové funkce [Docs](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ Chcete-li zjistit, jak přispívat, navštivte stránku [Začínáme](../DEVELOP ## Stav - [x] Alfa: Testujeme Supabase s uzavřenou skupinou zákazníků -- [x] Veřejná alfa: [app.supabase.com](https://app.supabase.com). Ale buďte na nás mírní, je tu několik zádrhelů +- [x] Veřejná alfa: [supabase.com/dashboard](https://supabase.com/dashboard). Ale buďte na nás mírní, je tu několik zádrhelů - [x] Veřejná beta verze: Dostatečně stabilní pro většinu případů použití mimo podniky - [ ] Veřejná: Všeobecná dostupnost [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ Supabase je kombinací nástrojů s otevřeným zdrojovým kódem. Funkce Fireba **Architektura** -Supabase je [hostovaná platforma](https://app.supabase.com). Můžete se zaregistrovat a začít používat Supabase, aniž byste museli cokoli instalovat. +Supabase je [hostovaná platforma](https://supabase.com/dashboard). Můžete se zaregistrovat a začít používat Supabase, aniž byste museli cokoli instalovat. Můžete také [hostovat sami](https://supabase.com/docs/guides/hosting/overview) a [vyvíjet lokálně](https://supabase.com/docs/guides/local-development). ![Architektura](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.da.md b/i18n/README.da.md index 29ad065205f..f0100027ee8 100644 --- a/i18n/README.da.md +++ b/i18n/README.da.md @@ -12,9 +12,9 @@ - [x] Hosted Postgres Database. [Docs](https://supabase.com/docs/guides/database) - [x] Autentifikation og autorisering. [Docs](https://supabase.com/docs/guides/auth) - [x] Automatisk genererede API'er. - - [x] REST. [Docs](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Docs](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Realtidsabonnementer. [Docs](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Docs](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Docs](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Realtidsabonnementer. [Docs](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Funktioner. - [x] Databasefunktioner. [Docs](https://supabase.com/docs/guides/database/functions) - [x] Edge-funktioner [Docs](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ For at se, hvordan man bidrager, besøg [Getting Started](../DEVELOPERS.md) ## Status - [x] Alpha: Vi tester Supabase med et lukket sæt af kunder -- [x] Offentlig Alpha: Alle kan tilmelde sig på [app.supabase.com](https://app.supabase.com). Men vær forsigtig med os, der er et par knuder +- [x] Offentlig Alpha: Alle kan tilmelde sig på [supabase.com/dashboard](https://supabase.com/dashboard). Men vær forsigtig med os, der er et par knuder - [x] Public Beta: Stabil nok til de fleste ikke-virksomhedsrelaterede brugssager - [ ] Public: Public: Generel tilgængelighed [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ Supabase er en kombination af open source-værktøjer. Vi opbygger funktionerne **Arkitektur** -Supabase er en [hosted platform](https://app.supabase.com). Du kan tilmelde dig og begynde at bruge Supabase uden at installere noget. +Supabase er en [hosted platform](https://supabase.com/dashboard). Du kan tilmelde dig og begynde at bruge Supabase uden at installere noget. Du kan også [selv hoste](https://supabase.com/docs/guides/hosting/overview) og [udvikle lokalt](https://supabase.com/docs/guides/local-development). ![Arkitektur](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.de.md b/i18n/README.de.md index 045b0c76e33..fc2a2ec307f 100644 --- a/i18n/README.de.md +++ b/i18n/README.de.md @@ -12,9 +12,9 @@ - [x] Gehostete Postgres-Datenbank. [Docs](https://supabase.com/docs/guides/database) - [x] Authentifizierung und Autorisierung. [Docs](https://supabase.com/docs/guides/auth) - [x] Auto-generierte APIs. - - [x] REST. [Docs](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Docs](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Echtzeit-Abonnements. [Docs](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Docs](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Docs](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Echtzeit-Abonnements. [Docs](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Funktionen. - [x] Datenbank-Funktionen. [Docs](https://supabase.com/docs/guides/database/functions) - [x] Edge-Funktionen [Docs](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ Wie Sie einen Beitrag leisten können, erfahren Sie unter [Erste Schritte](../DE ## Status - [x] Alpha: Wir testen Supabase mit einer geschlossenen Gruppe von Kunden -- [x] Öffentliche Alpha: Jeder kann sich unter [app.supabase.com](https://app.supabase.com) anmelden. Aber seien Sie nachsichtig mit uns, es gibt noch ein paar Macken +- [x] Öffentliche Alpha: Jeder kann sich unter [supabase.com/dashboard](https://supabase.com/dashboard) anmelden. Aber seien Sie nachsichtig mit uns, es gibt noch ein paar Macken - [x] Öffentliche Beta: Stabil genug für die meisten nicht-unternehmerischen Anwendungsfälle - [Öffentlich: Allgemeine Verfügbarkeit [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ Supabase ist eine Kombination aus Open-Source-Tools. Wir entwickeln die Funktion **Architektur** -Supabase ist eine [gehostete Plattform](https://app.supabase.com). Sie können sich anmelden und Supabase verwenden, ohne etwas zu installieren. +Supabase ist eine [gehostete Plattform](https://supabase.com/dashboard). Sie können sich anmelden und Supabase verwenden, ohne etwas zu installieren. Sie können auch [selbst hosten](https://supabase.com/docs/guides/hosting/overview) und [lokal entwickeln](https://supabase.com/docs/guides/local-development). ![Architektur](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.el.md b/i18n/README.el.md index 2989656164d..72825e1a5cc 100644 --- a/i18n/README.el.md +++ b/i18n/README.el.md @@ -12,9 +12,9 @@ - [x] Hosted Postgres Database. [Docs](https://supabase.com/docs/guides/database) - [x] Αυθεντικοποίηση και εξουσιοδότηση. [Έγγραφα](https://supabase.com/docs/guides/auth) - [x] Αυτόματα παραγόμενα APIs. - - [x] REST. [Έγγραφα](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Docs](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Συνδρομές σε πραγματικό χρόνο. [Έγγραφα](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Έγγραφα](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Docs](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Συνδρομές σε πραγματικό χρόνο. [Έγγραφα](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Συναρτήσεις. - [x] Συναρτήσεις βάσης δεδομένων. [Docs](https://supabase.com/docs/guides/database/functions) - [x] Edge Functions [Docs](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ ## Κατάσταση - [x] Alpha: Δοκιμάζουμε το Supabase με ένα κλειστό σύνολο πελατών -- [x] Δημόσια Alpha: [app.supabase.com](https://app.supabase.com). Αλλά να είστε προσεκτικοί μαζί μας, υπάρχουν μερικές ατέλειες +- [x] Δημόσια Alpha: [supabase.com/dashboard](https://supabase.com/dashboard). Αλλά να είστε προσεκτικοί μαζί μας, υπάρχουν μερικές ατέλειες - [x] Δημόσια Beta: Αρκετά σταθερό για τις περισσότερες περιπτώσεις μη επιχειρηματικής χρήσης - [ ] Δημόσια: [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ **Αρχιτεκτονική** -Η Supabase είναι μια [φιλοξενούμενη πλατφόρμα](https://app.supabase.com). Μπορείτε να εγγραφείτε και να αρχίσετε να χρησιμοποιείτε το Supabase χωρίς να εγκαταστήσετε τίποτα. +Η Supabase είναι μια [φιλοξενούμενη πλατφόρμα](https://supabase.com/dashboard). Μπορείτε να εγγραφείτε και να αρχίσετε να χρησιμοποιείτε το Supabase χωρίς να εγκαταστήσετε τίποτα. Μπορείτε επίσης να κάνετε [αυτο-ξενάγηση](https://supabase.com/docs/guides/hosting/overview) και [ανάπτυξη τοπικά](https://supabase.com/docs/guides/local-development). ![Αρχιτεκτονική](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.es.md b/i18n/README.es.md index 658c0a45fa9..8eabbc6b799 100644 --- a/i18n/README.es.md +++ b/i18n/README.es.md @@ -12,9 +12,9 @@ - Base de datos Postgres alojada. [Documentación](https://supabase.com/docs/guides/database) - [x] Autenticación y autorización. [Documentos](https://supabase.com/docs/guides/auth) - [x] API autogeneradas. - - [x] REST. [Docs](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Documentos](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Suscripciones en tiempo real. [Documentos](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Docs](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Documentos](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Suscripciones en tiempo real. [Documentos](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Funciones. - [x] Funciones de base de datos. [Docs](https://supabase.com/docs/guides/database/functions) - [x] Funciones de borde [Docs](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ Para ver cómo contribuir, visite [Getting Started](../DEVELOPERS.md) ## Estado - [x] Alfa: Estamos probando Supabase con un grupo cerrado de clientes -- [x] Alfa público: Cualquiera puede registrarse en [app.supabase.com](https://app.supabase.com). Pero no te pases, hay algunos problemas +- [x] Alfa público: Cualquiera puede registrarse en [supabase.com/dashboard](https://supabase.com/dashboard). Pero no te pases, hay algunos problemas - [Beta pública: Suficientemente estable para la mayoría de los casos de uso no empresariales - [Público: Disponibilidad general [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ Supabase es una combinación de herramientas de código abierto. Estamos constru **Arquitectura** -Supabase es una [plataforma alojada](https://app.supabase.com). Puedes registrarte y empezar a usar Supabase sin instalar nada. +Supabase es una [plataforma alojada](https://supabase.com/dashboard). Puedes registrarte y empezar a usar Supabase sin instalar nada. También puede [autoalojarse](https://supabase.com/docs/guides/hosting/overview) y [desarrollar localmente](https://supabase.com/docs/guides/local-development). arquitectura](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.et.md b/i18n/README.et.md index 88ff040404a..154ab2136cc 100644 --- a/i18n/README.et.md +++ b/i18n/README.et.md @@ -12,9 +12,9 @@ - [x] Hostitud Postgres andmebaas. [Dokumendid](https://supabase.com/docs/guides/database) - [x] Autentimine ja autoriseerimine. [Dokumendid](https://supabase.com/docs/guides/auth) - [x] Automaatselt genereeritud APId. - - [x] REST. [Dokumendid](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Dokumendid](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Reaalajas toimivad tellimused. [Dokumendid](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Dokumendid](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Dokumendid](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Reaalajas toimivad tellimused. [Dokumendid](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Funktsioonid. - [x] Andmebaasi funktsioonid. [Dokumendid](https://supabase.com/docs/guides/database/functions) - [x] Edge Functions [Docs](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ Et näha, kuidas panustada, külastage [Getting Started](../DEVELOPERS.md) ## Staatus - [x] Alpha: Me testime Supabase'i suletud kliendikogumiga -- [x] Avalik Alpha: Igaüks saab registreeruda aadressil [app.supabase.com](https://app.supabase.com). Kuid olge meiega ettevaatlikud, seal on mõned veidrused +- [x] Avalik Alpha: Igaüks saab registreeruda aadressil [supabase.com/dashboard](https://supabase.com/dashboard). Kuid olge meiega ettevaatlikud, seal on mõned veidrused - [x] Avalik beeta: Piisavalt stabiilne enamiku mitte-ettevõtluskasutuse jaoks - [ ] Avalik: Üldine kättesaadavus [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ Supabase on avatud lähtekoodiga tööriistade kombinatsioon. Me ehitame Firebas **Arhitektuur** -Supabase on [hostitud platvorm](https://app.supabase.com). Võite registreeruda ja alustada Supabase'i kasutamist ilma midagi installimata. +Supabase on [hostitud platvorm](https://supabase.com/dashboard). Võite registreeruda ja alustada Supabase'i kasutamist ilma midagi installimata. Võite ka [ise hostida](https://supabase.com/docs/guides/hosting/overview) ja [arendada lokaalselt](https://supabase.com/docs/guides/local-development). ![Arhitektuur](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.fa.md b/i18n/README.fa.md index 631741f2364..87c4adc1f3f 100644 --- a/i18n/README.fa.md +++ b/i18n/README.fa.md @@ -34,7 +34,7 @@ ## وضعیت - [x] ‫آلفا: در حال تست Supabase با گروه محدود از کاربران هستیم. -- [x] ‫آلفای عمومی: همه می‌تواند برای استفاده از طریق [app.supabase.com](https://app.supabase.com) ثبت‌نام کنند. اما سخت نگیرید، ممکن است مشکلات معدودی وجود داشته باشد. +- [x] ‫آلفای عمومی: همه می‌تواند برای استفاده از طریق [supabase.com/dashboard](https://supabase.com/dashboard) ثبت‌نام کنند. اما سخت نگیرید، ممکن است مشکلات معدودی وجود داشته باشد. - [x] بتای عمومی: قابل اتکا برای اکثر استفاده‌های غیر-تجاری می‌باشد. - [ ] عمومی: آماده برای استفاده تجاری. @@ -50,7 +50,7 @@ **معماری فعلی** -‫Supabase یک [پلتفرم میزبانی‌شده](https://app.supabase.com) است. شما می‌توانید بدون نصب چیزی، ثبت‌نام و شروع به استفاده از Supabase کنید. ما هنوز در حال ساختن تجربه‌ی توسعه local هستیم - این تمرکز اصلی فعلی ما علاوه بر اتکاپذیری است. +‫Supabase یک [پلتفرم میزبانی‌شده](https://supabase.com/dashboard) است. شما می‌توانید بدون نصب چیزی، ثبت‌نام و شروع به استفاده از Supabase کنید. ما هنوز در حال ساختن تجربه‌ی توسعه local هستیم - این تمرکز اصلی فعلی ما علاوه بر اتکاپذیری است. ![معماری](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png) diff --git a/i18n/README.fi.md b/i18n/README.fi.md index 57451117237..70bd873e5e1 100644 --- a/i18n/README.fi.md +++ b/i18n/README.fi.md @@ -12,9 +12,9 @@ - [x] Hosted Postgres Database. [Docs](https://supabase.com/docs/guides/database) - [x] Tunnistus ja valtuutus. [Docs](https://supabase.com/docs/guides/auth) - [x] Automaattisesti luodut API:t. - - [x] REST. [Asiakirjat](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Asiakirjat](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Reaaliaikaiset tilaukset. [Asiakirjat](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Asiakirjat](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Asiakirjat](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Reaaliaikaiset tilaukset. [Asiakirjat](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Funktiot. - [x] Tietokantafunktiot. [Docs](https://supabase.com/docs/guides/database/functions) - [x] Reunatoiminnot [Docs](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ Jos haluat nähdä, miten osallistuminen tapahtuu, käy osoitteessa [Getting Sta ## Status - [x] Alpha: Testaamme Supabasea suljetulla asiakasjoukolla -- [x] Julkinen Alpha: Kuka tahansa voi rekisteröityä osoitteessa [app.supabase.com](https://app.supabase.com). Mutta olkaa varovaisia, sillä on vielä muutamia ongelmia +- [x] Julkinen Alpha: Kuka tahansa voi rekisteröityä osoitteessa [supabase.com/dashboard](https://supabase.com/dashboard). Mutta olkaa varovaisia, sillä on vielä muutamia ongelmia - [x] Julkinen beta: Tarpeeksi vakaa useimpiin ei-yrityskäyttötapauksiin - [ ] Julkinen: Yleinen saatavuus [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ Supabase on yhdistelmä avoimen lähdekoodin työkaluja. Rakennamme Firebasen om **Arkkitehtuuri** -Supabase on [hosted platform](https://app.supabase.com). Voit rekisteröityä ja aloittaa Supabasen käytön asentamatta mitään. +Supabase on [hosted platform](https://supabase.com/dashboard). Voit rekisteröityä ja aloittaa Supabasen käytön asentamatta mitään. Voit myös [itse isännöidä](https://supabase.com/docs/guides/hosting/overview) ja [kehittää paikallisesti](https://supabase.com/docs/guides/local-development). ![Arkkitehtuuri](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.fr.md b/i18n/README.fr.md index 40832f97256..1cdbaffcda7 100644 --- a/i18n/README.fr.md +++ b/i18n/README.fr.md @@ -12,9 +12,9 @@ - [x] Base de données Postgres hébergée. [Docs](https://supabase.com/docs/guides/database) - [x] Authentification et autorisation. [Docs](https://supabase.com/docs/guides/auth) - [x] API générées automatiquement. - - [x] REST. [Docs](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Docs](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Abonnements en temps réel. [Docs](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Docs](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Docs](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Abonnements en temps réel. [Docs](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Fonctions. - [x] Fonctions de base de données. [Docs](https://supabase.com/docs/guides/database/functions) - [x] Fonctions Edge [Docs](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ Pour savoir comment contribuer, visitez [Getting Started](../DEVELOPERS.md) ## Statut - [x] Alpha : Nous testons Supabase avec un groupe fermé de clients -- [x] Alpha publique : Tout le monde peut s'inscrire sur [app.supabase.com](https://app.supabase.com). Mais allez-y doucement, il y a quelques problèmes +- [x] Alpha publique : Tout le monde peut s'inscrire sur [supabase.com/dashboard](https://supabase.com/dashboard). Mais allez-y doucement, il y a quelques problèmes - [x] Bêta publique : Suffisamment stable pour la plupart des cas d'utilisation hors entreprise - [ ] Public : Disponibilité générale [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ Supabase est une combinaison d'outils open source. Nous construisons les fonctio **Architecture** -Supabase est une [plateforme hébergée](https://app.supabase.com). Vous pouvez vous inscrire et commencer à utiliser Supabase sans rien installer. +Supabase est une [plateforme hébergée](https://supabase.com/dashboard). Vous pouvez vous inscrire et commencer à utiliser Supabase sans rien installer. Vous pouvez également [auto-héberger](https://supabase.com/docs/guides/hosting/overview) et [développer localement](https://supabase.com/docs/guides/local-development). ![Architecture](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.gr.md b/i18n/README.gr.md index f607515d9ad..a1d41628b10 100644 --- a/i18n/README.gr.md +++ b/i18n/README.gr.md @@ -32,7 +32,7 @@ To [Supabase](https://supabase.com) είναι μια εναλλακτική λ ## Κατάσταση - [x] Alpha: Δοκιμάζουμε το Supabase με ένα κλειστό σύνολο πελατών -- [x] Public Alpha: Όλοι μπορούν να εγγραφούν στο [app.supabase.com](https://app.supabase.com). Αλλά μην μας κρίνετε "σκληρά", υπάρχουν ακόμα μερικά λαθάκια (bugs) εδώ και εκεί +- [x] Public Alpha: Όλοι μπορούν να εγγραφούν στο [supabase.com/dashboard](https://supabase.com/dashboard). Αλλά μην μας κρίνετε "σκληρά", υπάρχουν ακόμα μερικά λαθάκια (bugs) εδώ και εκεί - [x] Public Beta: Αρκετά σταθερό για για τις περισσότερες περιπτώσεις χρήσης μη εταιρικού χαρακτήρα (non-enterprise) - [ ] Public: Έτοιμο για Production @@ -48,7 +48,7 @@ To [Supabase](https://supabase.com) είναι μια εναλλακτική λ **Τρέχουσα αρχιτεκτονική** -Το Supabase είναι ένα [hosted platform](https://app.supabase.com) (φιλοξενούμενη πλατφόρμα). Μπορείτε να εγγραφείτε και να αρχίσετε να χρησιμοποιείτε το Supabase χωρίς να εγκαταστήσετε τίποτα. Εξακολουθούμε να αναπτύσουμε την εμπειρία της τοπικής ανάπτυξης (Local Development) - αυτή είναι τώρα η βασική μας εστίαση, μαζί με τη σταθερότητα της πλατφόρμας. +Το Supabase είναι ένα [hosted platform](https://supabase.com/dashboard) (φιλοξενούμενη πλατφόρμα). Μπορείτε να εγγραφείτε και να αρχίσετε να χρησιμοποιείτε το Supabase χωρίς να εγκαταστήσετε τίποτα. Εξακολουθούμε να αναπτύσουμε την εμπειρία της τοπικής ανάπτυξης (Local Development) - αυτή είναι τώρα η βασική μας εστίαση, μαζί με τη σταθερότητα της πλατφόρμας. ![Architecture](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png) diff --git a/i18n/README.gu.md b/i18n/README.gu.md index 6cdfa48c251..5ab1a6558e8 100644 --- a/i18n/README.gu.md +++ b/i18n/README.gu.md @@ -33,7 +33,7 @@ ## સ્થિતિ - [x] આલ્ફા: અમે ગ્રાહકોના બંધ સમૂહ સાથે સુપાબેઝનું પરીક્ષણ કરી રહ્યા છીએ -- [x] જાહેર આલ્ફા: કોઈ પણ [app.supabase.com](https://app.supabase.com) પર જઈને સાઈન-અપ કરી શકે છે. પણ થોડો સંયમ રાખશો, હજુ અમુક સમસ્યાઓ છે +- [x] જાહેર આલ્ફા: કોઈ પણ [supabase.com/dashboard](https://supabase.com/dashboard) પર જઈને સાઈન-અપ કરી શકે છે. પણ થોડો સંયમ રાખશો, હજુ અમુક સમસ્યાઓ છે - [x] જાહેર બીટા: મોટાભાગના નોન-એન્ટરપ્રાઇઝ યુઝ-કેસેટ્સ માટે અનુરૂપ - [ ] જાહેર: પ્રોડક્શન માટે તૈયાર @@ -49,7 +49,7 @@ **વર્તમાન સ્થાપત્ય** -સુપાબેઝ એક [હોસ્ટેડ પ્લેટફોર્મ](https://app.supabase.com) છે. તમે સાઇન અપ કરી શકો છો અને કંઈપણ ઇન્સ્ટોલ કર્યા વગર સુપાબેઝનો ઉપયોગ શરૂ કરી શકો છો. અમે હજી પણ સ્થાનિક ડેવલપમેન્ટ અનુભવ બનાવી રહ્યા છીએ - પ્લેટફોર્મ સ્થિરતાની સાથે હવે આ અમારું મુખ્ય લક્ષ્ય છે. +સુપાબેઝ એક [હોસ્ટેડ પ્લેટફોર્મ](https://supabase.com/dashboard) છે. તમે સાઇન અપ કરી શકો છો અને કંઈપણ ઇન્સ્ટોલ કર્યા વગર સુપાબેઝનો ઉપયોગ શરૂ કરી શકો છો. અમે હજી પણ સ્થાનિક ડેવલપમેન્ટ અનુભવ બનાવી રહ્યા છીએ - પ્લેટફોર્મ સ્થિરતાની સાથે હવે આ અમારું મુખ્ય લક્ષ્ય છે. ![સ્થાપત્ય](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png) diff --git a/i18n/README.he.md b/i18n/README.he.md index a5f94810b61..984f3784e7f 100644 --- a/i18n/README.he.md +++ b/i18n/README.he.md @@ -34,7 +34,7 @@ ## סטטוס - [x] אלפא: בוחנים את המערכת מול מאגר סגור של לקוחות -- [x] אלפא פומבית: כל אחד יכול להרשם ב[app.supabase.com](https://app.supabase.com). אבל תהיו עדינים, יהיו בעיות. +- [x] אלפא פומבית: כל אחד יכול להרשם ב[supabase.com/dashboard](https://supabase.com/dashboard). אבל תהיו עדינים, יהיו בעיות. - [x] בטא פומבית: יציב מספיק לרוב הלקוחות הלא-ארגוניים. - [ ] יציב: מתאים לשימוש הכלל. @@ -50,7 +50,7 @@ Supabase הוא שילוב של כלי קוד פתוח. אנו בונים את **ארכיטקטורה נוכחית** -Supabase היא [תשתית ענן](https://app.supabase.com) +Supabase היא [תשתית ענן](https://supabase.com/dashboard) . את\ה יכול\ה להירשם ולהתחיל להשתמש ב- Supabase מבלי להתקין שום דבר. אנו עדיין עובדים על לייצר את חוויית הפיתוח המקומית - בזה אנחנו מתמקדים, יחד עם יציבות המערכת. ![Architecture](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png) diff --git a/i18n/README.hi.md b/i18n/README.hi.md index c130554f541..0d609921092 100644 --- a/i18n/README.hi.md +++ b/i18n/README.hi.md @@ -12,9 +12,9 @@ - [x] होस्टेड पोस्टग्रेज डेटाबेस। [प्रलेखन](https://supabase.com/docs/guides/database) - [x] प्रमाणीकरण और प्राधिकरण। [प्रलेखन](https://supabase.com/docs/guides/auth) - [x] उत्पन्न एपीआईस। - - [x] रेस्ट। [प्रलेखन](https://supabase.com/docs/guides/database/api#rest-api) - - [x] रीयलटाइम सदस्यता। [प्रलेखन](https://supabase.com/docs/guides/database/api#realtime-api) - - [x] ग्राफ़क्यूएल (प्रयोगात्मक)। [प्रलेखन](https://supabase.com/docs/guides/database/api#graphql-api) + - [x] रेस्ट। [प्रलेखन](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] रीयलटाइम सदस्यता। [प्रलेखन](https://supabase.com/docs/guides/api#realtime-api-overview) + - [x] ग्राफ़क्यूएल (प्रयोगात्मक)। [प्रलेखन](https://supabase.com/docs/guides/api#graphql-api-overview) - [x] फ़ंक्शंस। - [x] डेटाबेस फ़ंक्शंस। [प्रलेखन](https://supabase.com/docs/guides/database/functions) - [x] एज फ़ंक्शंस। [प्रलेखन](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ ## स्थिति - [x] अल्फा: हम चुनिंदा ग्राहकों के साथ सुपरबास का परीक्षण कर रहे हैं -- [x] सार्वजनिक अल्फा: कोई भी [app.supabase.com](https://app.supabase.com) के जरिए शामिल हो सकता है। लेकिन हम पर आसान हो जाओ, कुछ मोड़ हैं। +- [x] सार्वजनिक अल्फा: कोई भी [supabase.com/dashboard](https://supabase.com/dashboard) के जरिए शामिल हो सकता है। लेकिन हम पर आसान हो जाओ, कुछ मोड़ हैं। - [x] सार्वजनिक बीटा: अधिकांश गैर-एंटरप्राइज़ उपयोग-मामलों के लिए पर्याप्त स्थिर - [ ] सार्वजनिक: उत्पादन-तैयार @@ -55,7 +55,7 @@ **वर्तमान वास्तुकला** -सुपाबसे [होस्टेड प्लेटफार्म](https://app.supabase.com). आप साइन अप कर सकते हैं और कुछ भी स्थापित किए बिना सुपाबसे का उपयोग करना शुरू कर सकते हैं। हम अभी भी स्थानीय विकास का अनुभव पैदा कर रहे हैं - यह अब मंच स्थिरता के साथ-साथ हमारा मुख्य फोकस है। +सुपाबसे [होस्टेड प्लेटफार्म](https://supabase.com/dashboard). आप साइन अप कर सकते हैं और कुछ भी स्थापित किए बिना सुपाबसे का उपयोग करना शुरू कर सकते हैं। हम अभी भी स्थानीय विकास का अनुभव पैदा कर रहे हैं - यह अब मंच स्थिरता के साथ-साथ हमारा मुख्य फोकस है। ![आर्किटेक्चर](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png) diff --git a/i18n/README.hu.md b/i18n/README.hu.md index ebe3ed2f6fe..e45f0426b18 100644 --- a/i18n/README.hu.md +++ b/i18n/README.hu.md @@ -12,9 +12,9 @@ - [x] Hosted Postgres adatbázis. [Docs](https://supabase.com/docs/guides/database) - [x] Hitelesítés és engedélyezés. [Docs](https://supabase.com/docs/guides/auth) - [x] Automatikusan generált API-k. - - [x] REST. [Docs](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Docs](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Valós idejű előfizetések. [Docs](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Docs](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Docs](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Valós idejű előfizetések. [Docs](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Funkciók. - [x] Database Functions. [Docs](https://supabase.com/docs/guides/database/functions) - [x] Edge Functions [Docs](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ A Contribute-hoz való hozzájáruláshoz látogasson el a [Getting Started](../ ## Állapot - [x] Alpha: A Supabase-t egy zárt ügyfélkörrel teszteljük -- [x] Nyilvános alfa: Bárki regisztrálhat az [app.supabase.com](https://app.supabase.com) oldalon. De legyetek óvatosak velünk, van még néhány hiba +- [x] Nyilvános alfa: Bárki regisztrálhat az [supabase.com/dashboard](https://supabase.com/dashboard) oldalon. De legyetek óvatosak velünk, van még néhány hiba - [x] Nyilvános béta: Elég stabil a legtöbb nem vállalati felhasználási esethez - [ ] Nyilvános: Általános elérhetőség [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ A Supabase nyílt forráskódú eszközök kombinációja. A Firebase funkcióit **Architektúra** -A Supabase egy [hosztolt platform](https://app.supabase.com). Regisztrálhat és elkezdheti használni a Supabase-t anélkül, hogy bármit telepítene. +A Supabase egy [hosztolt platform](https://supabase.com/dashboard). Regisztrálhat és elkezdheti használni a Supabase-t anélkül, hogy bármit telepítene. Önállóan is [hosztolhat](https://supabase.com/docs/guides/hosting/overview) és [fejleszthet helyben](https://supabase.com/docs/guides/local-development). ![Architektúra](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.id.md b/i18n/README.id.md index 311312da070..297eddab026 100644 --- a/i18n/README.id.md +++ b/i18n/README.id.md @@ -12,9 +12,9 @@ - [x] Basis Data Postgres yang dihosting. [Dokumen](https://supabase.com/docs/guides/database) - [x] Otentikasi dan Otorisasi. [Dokumen](https://supabase.com/docs/guides/auth) - [x] API yang dibuat secara otomatis. - - [x] REST. [Documents](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Documents](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Langganan realtime. [Documents](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Documents](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Documents](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Langganan realtime. [Documents](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Fungsi-fungsi. - [x] Fungsi-fungsi Basis Data. [Documents](https://supabase.com/docs/guides/database/functions) - [x] Fungsi Tepi [Documents](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ Untuk melihat bagaimana cara berkontribusi, kunjungi [Memulai](../DEVELOPERS.md) ## Status - [x] Alpha: Kami sedang menguji Supabase dengan sekumpulan pelanggan tertutup -- [x] Alpha Publik: Siapa pun dapat mendaftar di [app.supabase.com](https://app.supabase.com). Tapi jangan khawatir, ada beberapa hal yang perlu diperhatikan +- [x] Alpha Publik: Siapa pun dapat mendaftar di [supabase.com/dashboard](https://supabase.com/dashboard). Tapi jangan khawatir, ada beberapa hal yang perlu diperhatikan - [x] Public Beta: Cukup stabil untuk sebagian besar kasus penggunaan non-perusahaan - [Publik: Ketersediaan Umum [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ Supabase adalah kombinasi dari alat sumber terbuka. Kami membangun fitur-fitur F **Arsitektur** -Supabase adalah [platform yang di-host](https://app.supabase.com). Anda bisa mendaftar dan mulai menggunakan Supabase tanpa menginstal apa pun. +Supabase adalah [platform yang di-host](https://supabase.com/dashboard). Anda bisa mendaftar dan mulai menggunakan Supabase tanpa menginstal apa pun. Anda juga bisa [menghosting sendiri](https://supabase.com/docs/guides/hosting/overview) dan [mengembangkan secara lokal](https://supabase.com/docs/guides/local-development). ![Arsitektur](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.it.md b/i18n/README.it.md index 594947a7a28..822cd80bfbe 100644 --- a/i18n/README.it.md +++ b/i18n/README.it.md @@ -12,9 +12,9 @@ - Database Postgres ospitato. [Documenti](https://supabase.com/docs/guides/database) - [x] Autenticazione e autorizzazione. [Documenti](https://supabase.com/docs/guides/auth) - [x] API generate automaticamente. - - [x] REST. [Documenti](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Documenti](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Sottoscrizioni in tempo reale. [Documenti](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Documenti](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Documenti](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Sottoscrizioni in tempo reale. [Documenti](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Funzioni. - [x] Funzioni di database. [Docs](https://supabase.com/docs/guides/database/functions) - [x] Funzioni Edge [Docs](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ Per vedere come contribuire, visitate [Getting Started](../DEVELOPERS.md) ## Stato - [Alfa: Stiamo testando Supabase con un gruppo chiuso di clienti -- [x] Alfa pubblica: Chiunque può iscriversi all'indirizzo [app.supabase.com](https://app.supabase.com). Ma andateci piano, ci sono alcuni problemi +- [x] Alfa pubblica: Chiunque può iscriversi all'indirizzo [supabase.com/dashboard](https://supabase.com/dashboard). Ma andateci piano, ci sono alcuni problemi - [x] Beta pubblica: Abbastanza stabile per la maggior parte degli usi non aziendali - [ ] Pubblico: Disponibilità generale [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ Supabase è una combinazione di strumenti open source. Stiamo costruendo le funz **Architettura** -Supabase è una [piattaforma ospitata](https://app.supabase.com). È possibile registrarsi e iniziare a usare Supabase senza installare nulla. +Supabase è una [piattaforma ospitata](https://supabase.com/dashboard). È possibile registrarsi e iniziare a usare Supabase senza installare nulla. È anche possibile [auto-ospitare](https://supabase.com/docs/guides/hosting/overview) e [sviluppare localmente](https://supabase.com/docs/guides/local-development). ![Architettura](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.ja.md b/i18n/README.ja.md index d10d9c3354e..b3c49bdfd73 100644 --- a/i18n/README.ja.md +++ b/i18n/README.ja.md @@ -12,9 +12,9 @@ - [x] ホスティングされた Postgres データベースです。[ドックス](https://supabase.com/docs/guides/database) - [x] 認証と認可。[ドキュメント](https://supabase.com/docs/guides/auth) - [x] 自動生成される API。 - - [x] REST。[ドキュメント](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL。[Docs](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] リアルタイムサブスクリプション。[ドキュメント](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST。[ドキュメント](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL。[Docs](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] リアルタイムサブスクリプション。[ドキュメント](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] 関数。 - [x] データベースファンクション。[Docs](https://supabase.com/docs/guides/database/functions) - [x] エッジ機能 [Docs](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ ## ステータス - [x] アルファ:Supabase をクローズドな顧客セットでテストしています。 -- [x] Public Alpha:誰でも [app.supabase.com](https://app.supabase.com) でサインアップすることができます。しかし、いくつかの問題がありますので、ご容赦ください。 +- [x] Public Alpha:誰でも [supabase.com/dashboard](https://supabase.com/dashboard) でサインアップすることができます。しかし、いくつかの問題がありますので、ご容赦ください。 - [x] パブリックベータ版:企業以外のほとんどのユースケースで十分に安定している。 - [ ] パブリック:一般公開 [[ステータス](https://supabase.com/docs/guides/getting-started/features#feature-status)]。 @@ -55,7 +55,7 @@ Supabase は、オープンソースツールの組み合わせです。私た **アーキテクチャー** -Supabase は[hosted platform](https://app.supabase.com)です。サインアップすれば、何もインストールすることなく Supabase を使い始めることができます。 +Supabase は[hosted platform](https://supabase.com/dashboard)です。サインアップすれば、何もインストールすることなく Supabase を使い始めることができます。 また、[セルフホスト](https://supabase.com/docs/guides/hosting/overview)や[ローカル開発](https://supabase.com/docs/guides/local-development)も可能です。 ![アーキテクチャ](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.jp.md b/i18n/README.jp.md index dd14a029389..35a5f5150ca 100644 --- a/i18n/README.jp.md +++ b/i18n/README.jp.md @@ -12,9 +12,9 @@ - [x] ホスティングされた Postgres データベース [Docs](https://supabase.com/docs/guides/database) - [x] 認証・認可 [Docs](https://supabase.com/docs/guides/auth) - [x] API を自動生成 - - [x] REST [Docs](https://supabase.com/docs/guides/database/api#rest-api) - - [x] リアルタイムサブスクリプション [Docs](https://supabase.com/docs/guides/database/api#realtime-api) - - [x] GraphQL (Beta) [Docs](https://supabase.com/docs/guides/database/api#graphql-api) + - [x] REST [Docs](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] リアルタイムサブスクリプション [Docs](https://supabase.com/docs/guides/api#realtime-api-overview) + - [x] GraphQL (Beta) [Docs](https://supabase.com/docs/guides/api#graphql-api-overview) - [x] 関数 - [x] データベース関数 [Docs](https://supabase.com/docs/guides/database/functions) - [x] Edge Functions [Docs](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ ## ステータス - [x] Alpha: 限られたユーザーで Supabase をテストしています。 -- [x] Public Alpha: 誰でも[app.supabase.com](https://app.supabase.com)から登録ができます。ただし、バグなどがある可能性がありますので、ご容赦ください。 +- [x] Public Alpha: 誰でも[supabase.com/dashboard](https://supabase.com/dashboard)から登録ができます。ただし、バグなどがある可能性がありますので、ご容赦ください。 - [x] Public Beta: 企業以外のほとんどのユースケースに耐えうる十分な安定性を確保。 - [ ] Public: 実用的な用途に対応 @@ -55,7 +55,7 @@ Supabase は、オープンソースのツールを組み合わせてできて **現在のアーキテクチャ** -Supabase は[ホスティングされたプラットフォーム](https://app.supabase.com)です。登録するだけで、何もインストールせずに使い始めることができます。 +Supabase は[ホスティングされたプラットフォーム](https://supabase.com/dashboard)です。登録するだけで、何もインストールせずに使い始めることができます。 さらに、 [セルフホスティング](https://supabase.com/docs/guides/hosting/overview) や [ローカル開発](https://supabase.com/docs/guides/local-development)も可能です。 ![アーキテクチャー](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png) diff --git a/i18n/README.ko.md b/i18n/README.ko.md index 7777db14f21..89a6e2eea29 100644 --- a/i18n/README.ko.md +++ b/i18n/README.ko.md @@ -12,9 +12,9 @@ - [x] 호스팅된 Postgres 데이터베이스. [문서](https://supabase.com/docs/guides/database) - [x] 인증 및 권한 부여. [문서](https://supabase.com/docs/guides/auth) - [x] 자동 생성 API. - - [x] REST. [문서](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [문서](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] 실시간 구독. [문서](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [문서](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [문서](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] 실시간 구독. [문서](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] 함수. - [x] 데이터베이스 함수. [Docs](https://supabase.com/docs/guides/database/functions) - [x] 엣지 기능 [문서](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ ## 상태 - [x] 알파: 비공개 고객들을 대상으로 Supabase를 테스트 중입니다 -- [x] 공개 알파: 누구나 [app.supabase.com](https://app.supabase.com)에서 가입할 수 있습니다. 하지만 몇 가지 불편한 점이 있습니다 +- [x] 공개 알파: 누구나 [supabase.com/dashboard](https://supabase.com/dashboard)에서 가입할 수 있습니다. 하지만 몇 가지 불편한 점이 있습니다 - [x] 공개 베타: 대부분의 비기업용 사용 사례에 충분히 안정적입니다 - [ ] 공개: 일반 사용 가능 [[상태](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ Supabase는 오픈 소스 도구의 조합입니다. 저희는 엔터프라이 **아키텍처** -Supabase는 [호스팅 플랫폼](https://app.supabase.com)입니다. 가입만 하면 아무것도 설치하지 않고 Supabase를 사용할 수 있습니다. +Supabase는 [호스팅 플랫폼](https://supabase.com/dashboard)입니다. 가입만 하면 아무것도 설치하지 않고 Supabase를 사용할 수 있습니다. 자체 호스팅](https://supabase.com/docs/guides/hosting/overview) 및 [로컬 개발](https://supabase.com/docs/guides/local-development)도 가능합니다. 아키텍처](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.lt.md b/i18n/README.lt.md index f14f8c9e554..d5e1c69d4e9 100644 --- a/i18n/README.lt.md +++ b/i18n/README.lt.md @@ -12,9 +12,9 @@ - [x] Prieglobos Postgres duomenų bazė. [Dokumentai](https://supabase.com/docs/guides/database) - [x] Autentiškumo nustatymas ir autorizavimas. [Dokumentai](https://supabase.com/docs/guides/auth) - [x] Automatiškai generuojamos API. - - [x] REST. [Dokumentai](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Dokumentai](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Realaus laiko prenumeratos. [Dokumentai](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Dokumentai](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Dokumentai](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Realaus laiko prenumeratos. [Dokumentai](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Funkcijos. - [x] Duomenų bazės funkcijos. [Dokumentai](https://supabase.com/docs/guides/database/functions) - [x] Kraštų funkcijos [Docs](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ Norėdami sužinoti, kaip prisidėti, apsilankykite [Getting Started](../DEVELOP ## Statusas - [x] Alfa: Bandome "Supabase" su uždaru klientų rinkiniu -- [x] Viešoji Alfa versija: [app.supabase.com](https://app.supabase.com). Tačiau neskubėkite su mumis, yra keletas trūkumų +- [x] Viešoji Alfa versija: [supabase.com/dashboard](https://supabase.com/dashboard). Tačiau neskubėkite su mumis, yra keletas trūkumų - [x] Viešoji beta versija: Pakankamai stabili daugeliui ne įmonių naudojimo atvejų - [ ] Vieša: Bendras prieinamumas [[statusas](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ Norėdami sužinoti, kaip prisidėti, apsilankykite [Getting Started](../DEVELOP **Architektūra** -Supabase yra [prieglobos platforma](https://app.supabase.com). Galite užsiregistruoti ir pradėti naudotis "Supabase" nieko neįdiegę. +Supabase yra [prieglobos platforma](https://supabase.com/dashboard). Galite užsiregistruoti ir pradėti naudotis "Supabase" nieko neįdiegę. Taip pat galite [savarankiškai talpinti](https://supabase.com/docs/guides/hosting/overview) ir [kurti vietoje](https://supabase.com/docs/guides/local-development). ![Architektūra](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.lv.md b/i18n/README.lv.md index 055040a9986..518bb79b799 100644 --- a/i18n/README.lv.md +++ b/i18n/README.lv.md @@ -12,9 +12,9 @@ - [x] Hosted Postgres datubāze. [Dokumenti](https://supabase.com/docs/guides/database) - [x] Autentifikācija un autorizācija. [Dokumenti](https://supabase.com/docs/guides/auth) - [x] Automātiski ģenerēti API. - - [x] REST. [Dokumenti](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Dokumenti](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Reāllaika abonēšana. [Dokumenti](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Dokumenti](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Dokumenti](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Reāllaika abonēšana. [Dokumenti](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Funkcijas. - [x] Datubāzes funkcijas. [Dokumenti](https://supabase.com/docs/guides/database/functions) - [x] Edge funkcijas [Dokumenti](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ Lai uzzinātu, kā veikt iemaksas, apmeklējiet [Getting Started](../DEVELOPERS. ## Statuss - [x] Alfa: Mēs testējam Supabase ar slēgtu klientu kopu -- [x] Publiskā alfa versija: [app.supabase.com](https://app.supabase.com). Bet nesteidzieties, ir dažas nepilnības +- [x] Publiskā alfa versija: [supabase.com/dashboard](https://supabase.com/dashboard). Bet nesteidzieties, ir dažas nepilnības - [x] Publiskā beta versija: Pietiekami stabila lielākajai daļai ar uzņēmumu nesaistītu lietošanas gadījumu - [ ] Publisks: Vispārēja pieejamība [[statuss](https://supabase.com/docs/guides/getting-started/features#feature-status)]] @@ -55,7 +55,7 @@ Supabase ir atvērtā koda rīku kombinācija. Mēs veidojam Firebase funkcijas, **Arhitektūra** -Supabase ir [izvietota platforma](https://app.supabase.com). Jūs varat reģistrēties un sākt izmantot Supabase, neko neinstalējot. +Supabase ir [izvietota platforma](https://supabase.com/dashboard). Jūs varat reģistrēties un sākt izmantot Supabase, neko neinstalējot. Jūs varat arī [pašmāju resursus](https://supabase.com/docs/guides/hosting/overview) un [izstrādāt lokāli](https://supabase.com/docs/guides/local-development). ![Arhitektūra](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.ms.md b/i18n/README.ms.md index e4657cd9ecd..6aa0aecc787 100644 --- a/i18n/README.ms.md +++ b/i18n/README.ms.md @@ -32,7 +32,7 @@ Untuk dokumentasi lengkap, layari [supabase.com/docs](https://supabase.com/docs) ## Status - [x] Alpha: Kami menguji Supabase dengan sejumlah pelanggan secara tertutup -- [x] Public Alpha: Sesiapa sahaja boleh mendaftar di [app.supabase.com](https://app.supabase.com). Tetapi, mohon bersabar kerana mungkin ada masalah +- [x] Public Alpha: Sesiapa sahaja boleh mendaftar di [supabase.com/dashboard](https://supabase.com/dashboard). Tetapi, mohon bersabar kerana mungkin ada masalah - [x] Public Beta: Cukup stabil untuk kebanyakan kes penggunaan bukan perusahaan - [ ] Public: Bersedia untuk pengeluaran @@ -48,7 +48,7 @@ Supabase adalah gabungan alat sumber terbuka. Kami membina ciri Firebase menggun **Seni bina semasa** -Supabase ialah [platform yang dihoskan](https://app.supabase.com). Anda boleh mendaftar dan mula menggunakan Supabase tanpa memasang apa-apa. +Supabase ialah [platform yang dihoskan](https://supabase.com/dashboard). Anda boleh mendaftar dan mula menggunakan Supabase tanpa memasang apa-apa. Anda juga boleh [host sendiri](https://supabase.com/docs/guides/hosting/overview) dan [lokal](https://supabase.com/docs/guides/local-development). ![Seni bina](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png) diff --git a/i18n/README.nb-no.md b/i18n/README.nb-no.md index aa31d37e222..94c1a44386d 100644 --- a/i18n/README.nb-no.md +++ b/i18n/README.nb-no.md @@ -32,7 +32,7 @@ Besøk [supabase.com/docs](https://supabase.com/docs) for full dokumentasjon. ## Status - [x] Alpha: Vi tester Supabase med en lukket gruppe kunder -- [x] Offentlig Alpha: Hvem som helst kan registrere seg på [app.supabase.com](https://app.supabase.com). Merk at enkelte feil og quirks kan forekomme. +- [x] Offentlig Alpha: Hvem som helst kan registrere seg på [supabase.com/dashboard](https://supabase.com/dashboard). Merk at enkelte feil og quirks kan forekomme. - [x] Offentlig Beta: Stabilt nok for de fleste ikke-enterprise-bruksområder - [ ] Offentlig: Produksjonsklar @@ -48,7 +48,7 @@ Supabase er en samling av open source-verktøy. Vi bygger funksjonaliteten til F **Nåværende arkitektur** -Supabase er en [hostet plattform](https://app.supabase.com). Du kan registrere deg og begynne å bruke Supabase uten å installere noe. Vi holder fortsatt på å bygge verktøy for lokalt bruk – dette er nå vårt kjernefokus, i tillegg til plattformstabilitet. +Supabase er en [hostet plattform](https://supabase.com/dashboard). Du kan registrere deg og begynne å bruke Supabase uten å installere noe. Vi holder fortsatt på å bygge verktøy for lokalt bruk – dette er nå vårt kjernefokus, i tillegg til plattformstabilitet. ![Arkitektur](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png) diff --git a/i18n/README.nb.md b/i18n/README.nb.md index 4cade662e60..3ec01daad17 100644 --- a/i18n/README.nb.md +++ b/i18n/README.nb.md @@ -12,9 +12,9 @@ - [x] Hostet Postgres-database. [Dokumenter](https://supabase.com/docs/guides/database) - [x] Autentisering og autorisasjon. [Dokumenter](https://supabase.com/docs/guides/auth) - [x] Autogenererte API-er. - - [x] REST. [Dokumenter](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Dokumenter](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Sanntidsabonnementer. [Dokumenter](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Dokumenter](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Dokumenter](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Sanntidsabonnementer. [Dokumenter](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Funksjoner. - [x] Databasefunksjoner. [Dokumenter](https://supabase.com/docs/guides/database/functions) - [x] Edge-funksjoner [Dokumenter](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ For å se hvordan du kan bidra, gå til [Getting Started](../DEVELOPERS.md) ## Status - [x] Alpha: Vi tester Supabase med en lukket gruppe kunder -- [x] Offentlig Alpha: Alle kan registrere seg på [app.supabase.com](https://app.supabase.com). Men vær snill med oss, det er noen små problemer +- [x] Offentlig Alpha: Alle kan registrere seg på [supabase.com/dashboard](https://supabase.com/dashboard). Men vær snill med oss, det er noen små problemer - [x] Offentlig beta: Stabilt nok for de fleste brukstilfeller som ikke er for bedrifter - [ ] Offentlig: Generell tilgjengelighet [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ Supabase er en kombinasjon av verktøy med åpen kildekode. Vi bygger funksjonen \*\*Arkitektur -Supabase er en [vertsplattform](https://app.supabase.com). Du kan registrere deg og begynne å bruke Supabase uten å installere noe. +Supabase er en [vertsplattform](https://supabase.com/dashboard). Du kan registrere deg og begynne å bruke Supabase uten å installere noe. Du kan også [selv være vert](https://supabase.com/docs/guides/hosting/overview) og [utvikle lokalt](https://supabase.com/docs/guides/local-development). arkitektur](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.ne.md b/i18n/README.ne.md index 65fae41f714..38a20bbb8fb 100644 --- a/i18n/README.ne.md +++ b/i18n/README.ne.md @@ -32,7 +32,7 @@ ## स्थिति - [x] अल्फा: हामी ग्राहकहरु को एक सानो समूह संग सुपाबेस परीक्षण गर्दैछौं। -- [x] सार्वजनिक अल्फा: सबैले [app.supabase.com](https://app.supabase.com) मा साइनअप गर्न सक्दछन्। तर तपाईं केहि अवरोधहरू प्राप्त गर्न सक्नुहुन्छ। +- [x] सार्वजनिक अल्फा: सबैले [supabase.com/dashboard](https://supabase.com/dashboard) मा साइनअप गर्न सक्दछन्। तर तपाईं केहि अवरोधहरू प्राप्त गर्न सक्नुहुन्छ। - [x] सार्वजनिक बीटा: अधिकतर गैर-उद्यम प्रयोगका मामलाहरुका लागि पर्याप्त स्थिर। - [ ] सार्वजनिक: उत्पादन प्रयोगको लागि तयार। @@ -48,7 +48,7 @@ **वर्तमान योजना** -सुपाबेस एक [होस्ट गरिएको प्लेटफर्म](https://app.supabase.com) हो। तपाई आफै साइन अप गर्न सक्नुहुन्छ र कुनै स्थापना बिना सुपाबेस प्रयोग गर्न सक्नुहुन्छ। हामी अझै स्थानीय विकासको अनुभव सिर्जना गर्दैछौं। यो प्लेटफर्म स्थिरताको साथ हाम्रो मूल फोकस हो। +सुपाबेस एक [होस्ट गरिएको प्लेटफर्म](https://supabase.com/dashboard) हो। तपाई आफै साइन अप गर्न सक्नुहुन्छ र कुनै स्थापना बिना सुपाबेस प्रयोग गर्न सक्नुहुन्छ। हामी अझै स्थानीय विकासको अनुभव सिर्जना गर्दैछौं। यो प्लेटफर्म स्थिरताको साथ हाम्रो मूल फोकस हो। ![आर्किटेक्चर](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png) diff --git a/i18n/README.nl.md b/i18n/README.nl.md index e450d973b4f..6a6bdae073f 100644 --- a/i18n/README.nl.md +++ b/i18n/README.nl.md @@ -12,9 +12,9 @@ - [x]Gehoste Postgres Database. [Docs](https://supabase.com/docs/guides/database) - [x] Authenticatie en Autorisatie. [Docs](https://supabase.com/docs/guides/auth) - [x] Automatisch gegenereerde API's. - - [x] REST. [Docs](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Docs](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Realtime abonnementen. [Docs](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Docs](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Docs](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Realtime abonnementen. [Docs](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Functies. - [x] Database functies. [Docs](https://supabase.com/docs/guides/database/functions) - [x] Randfuncties. [Docs](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ Om te zien hoe u kunt bijdragen, bezoek [Aan de slag](../DEVELOPERS.md) ## Status - [Alpha: We testen Supabase met een gesloten groep klanten -- [x] Publieke Alpha: Iedereen kan zich aanmelden op [app.supabase.com](https://app.supabase.com). Maar doe het rustig aan, er zijn een paar kinken +- [x] Publieke Alpha: Iedereen kan zich aanmelden op [supabase.com/dashboard](https://supabase.com/dashboard). Maar doe het rustig aan, er zijn een paar kinken - [Publieke Beta: Stabiel genoeg voor de meeste niet-bedrijfsmatige toepassingen - [Publiek: General Availability [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ Supabase is een combinatie van open source tools. We bouwen de functies van Fire **Architectuur** -Supabase is een [gehost platform](https://app.supabase.com). U kunt zich aanmelden en Supabase gebruiken zonder iets te installeren. +Supabase is een [gehost platform](https://supabase.com/dashboard). U kunt zich aanmelden en Supabase gebruiken zonder iets te installeren. U kunt ook [zelf hosten](https://supabase.com/docs/guides/hosting/overview) en [lokaal ontwikkelen](https://supabase.com/docs/guides/local-development). architectuur](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.pl.md b/i18n/README.pl.md index 774f0e4b84f..7ec47d0a226 100644 --- a/i18n/README.pl.md +++ b/i18n/README.pl.md @@ -12,9 +12,9 @@ - [x] hostowana baza danych Postgres [x]. [Dokumenty](https://supabase.com/docs/guides/database) - [x] uwierzytelnianie i autoryzacja [x]. [Dokumenty](https://supabase.com/docs/guides/auth) - [x] Automatycznie generowane interfejsy API. - - [x] REST. [Docs](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Docs](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Subskrypcje w czasie rzeczywistym. [Docs](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Docs](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Docs](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Subskrypcje w czasie rzeczywistym. [Docs](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Funkcje. - [x] Funkcje bazy danych. [Docs](https://supabase.com/docs/guides/database/functions) - [x] Funkcje brzegowe [Docs](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ Aby zobaczyć, jak wnieść swój wkład, odwiedź [Getting Started](../DEVELOPE ## Status - [x] Alpha: Testujemy Supabase z zamkniętą grupą klientów -- [x] Publiczna wersja alfa: Każdy może zarejestrować się na stronie [app.supabase.com](https://app.supabase.com). Ale spokojnie, jest kilka niedociągnięć +- [x] Publiczna wersja alfa: Każdy może zarejestrować się na stronie [supabase.com/dashboard](https://supabase.com/dashboard). Ale spokojnie, jest kilka niedociągnięć - [x] Publiczna beta: Wystarczająco stabilna dla większości przypadków użycia innych niż korporacyjne - [] Public: Ogólna dostępność [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ Supabase to połączenie narzędzi open source. Tworzymy funkcje Firebase przy u **Architektura** -Supabase jest [platformą hostowaną](https://app.supabase.com). Możesz zarejestrować się i zacząć korzystać z Supabase bez instalowania czegokolwiek. +Supabase jest [platformą hostowaną](https://supabase.com/dashboard). Możesz zarejestrować się i zacząć korzystać z Supabase bez instalowania czegokolwiek. Możesz także [self-host](https://supabase.com/docs/guides/hosting/overview) i [rozwijać lokalnie](https://supabase.com/docs/guides/local-development). ![Architektura](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.pt-PT.md b/i18n/README.pt-PT.md index 873ea251662..7d8d4781204 100644 --- a/i18n/README.pt-PT.md +++ b/i18n/README.pt-PT.md @@ -12,9 +12,9 @@ - [x] Base de dados Postgres alojada. [Docs](https://supabase.com/docs/guides/database) - [x] Autenticação e autorização. [Docs](https://supabase.com/docs/guides/auth) - [x] APIs geradas automaticamente. - - [x] REST. [Docs](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Docs](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Assinaturas em tempo real. [Docs](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Docs](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Docs](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Assinaturas em tempo real. [Docs](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Funções. - [x] Funções de base de dados. [Docs](https://supabase.com/docs/guides/database/functions) - [x] Funções de Borda [Docs](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ Para ver como contribuir, visite [Getting Started](../DEVELOPERS.md) ## Status - [x] Alfa: Estamos a testar o Supabase com um conjunto fechado de clientes -- [x] Alfa Público: Qualquer pessoa pode inscrever-se em [app.supabase.com](https://app.supabase.com). Mas vá com calma, ainda há alguns problemas +- [x] Alfa Público: Qualquer pessoa pode inscrever-se em [supabase.com/dashboard](https://supabase.com/dashboard). Mas vá com calma, ainda há alguns problemas - [x] Beta Público: Estável o suficiente para a maioria dos casos de uso não empresarial - [ ] Público: Disponibilidade geral [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ O Supabase é uma combinação de ferramentas de código aberto. Estamos a const **Arquitectura** -Supabase é uma [plataforma alojada](https://app.supabase.com). Pode registar-se e começar a usar o Supabase sem instalar nada. +Supabase é uma [plataforma alojada](https://supabase.com/dashboard). Pode registar-se e começar a usar o Supabase sem instalar nada. Pode também [auto-hospedar](https://supabase.com/docs/guides/hosting/overview) e [desenvolver localmente](https://supabase.com/docs/guides/local-development). ![Arquitectura](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.pt-br.md b/i18n/README.pt-br.md index 2feecb44a37..8b51680401d 100644 --- a/i18n/README.pt-br.md +++ b/i18n/README.pt-br.md @@ -12,9 +12,9 @@ - [x] Banco de dados Postgres hospedado. [Docs](https://supabase.com/docs/guides/database) - [x] Autenticação e autorização. [Docs](https://supabase.com/docs/guides/auth) - [x] APIs geradas automaticamente. - - [x] REST. [Docs](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Docs](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Assinaturas em tempo real. [Docs](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Docs](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Docs](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Assinaturas em tempo real. [Docs](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Funções. - [x] Funções de banco de dados. [Docs](https://supabase.com/docs/guides/database/functions) - [x] Funções de borda [Docs](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ Para ver como contribuir, visite [Getting Started](../DEVELOPERS.md) ## Status - [x] Alfa: Estamos testando o Supabase com um conjunto fechado de clientes -- [x] Alfa público: Qualquer pessoa pode se registrar em [app.supabase.com](https://app.supabase.com). Mas vá com calma, pois há alguns problemas +- [x] Alfa público: Qualquer pessoa pode se registrar em [supabase.com/dashboard](https://supabase.com/dashboard). Mas vá com calma, pois há alguns problemas - [x] Beta público: Estável o suficiente para a maioria dos casos de uso não corporativos - [ ] Público: Disponibilidade geral [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ Supabase é uma combinação de ferramentas de código aberto. Estamos desenvolv **Arquitetura** -Supabase é uma [plataforma hospedada](https://app.supabase.com). Você pode se registrar e começar a usar o Supabase sem instalar nada. +Supabase é uma [plataforma hospedada](https://supabase.com/dashboard). Você pode se registrar e começar a usar o Supabase sem instalar nada. Você também pode [auto-hospedar](https://supabase.com/docs/guides/hosting/overview) e [desenvolver localmente](https://supabase.com/docs/guides/local-development). ![Arquitetura](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.pt.md b/i18n/README.pt.md index 13090596cc4..e282d2847a1 100644 --- a/i18n/README.pt.md +++ b/i18n/README.pt.md @@ -32,7 +32,7 @@ Para ver a documentação completa, visite [supabase.com/docs](https://supabase. ## Status - [x] Alpha: estamos a testar o Supabase com um grupo fechado de clientes -- [x] Alpha Público: Qualquer pessoa pode se inscrever em [app.supabase.com](https://app.supabase.com). Mas vai com calma, ainda existem alguns problemas. +- [x] Alpha Público: Qualquer pessoa pode se inscrever em [supabase.com/dashboard](https://supabase.com/dashboard). Mas vai com calma, ainda existem alguns problemas. - [x] Beta público: estável o suficiente para a maioria dos casos de uso não empresarial - [ ] Público: pronto para produção @@ -48,7 +48,7 @@ Supabase é uma combinação de ferramentas de código aberto. Estamos a constru **Arquitetura atual** -Supabase é uma [plataforma hospedada](https://app.supabase.com). Podes-te inscrever e começar a usar o Supabase sem instalar nada. Ainda estamos a criar a experiência de desenvolvimento local - esse é nosso foco atual, juntamente com a estabilidade da plataforma. +Supabase é uma [plataforma hospedada](https://supabase.com/dashboard). Podes-te inscrever e começar a usar o Supabase sem instalar nada. Ainda estamos a criar a experiência de desenvolvimento local - esse é nosso foco atual, juntamente com a estabilidade da plataforma. ![Arquitetura](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png) diff --git a/i18n/README.ro.md b/i18n/README.ro.md index bea1096dffa..f48e674704f 100644 --- a/i18n/README.ro.md +++ b/i18n/README.ro.md @@ -12,9 +12,9 @@ - [x] Baza de date Postgres găzduită. [Docs](https://supabase.com/docs/guides/database) - [x] Autentificare și autorizare. [Docs](https://supabase.com/docs/guides/auth) - [x] API-uri generate automat. - - [x] REST. [Docs](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Docs](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Abonamente în timp real. [Docs](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Docs](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Docs](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Abonamente în timp real. [Docs](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Funcții. - [x] Funcții de baze de date. [Docs](https://supabase.com/docs/guides/database/functions) - [x] Funcții de margine. [Docs](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ Pentru a vedea cum să contribui, vizitați [Getting Started](../DEVELOPERS.md) ## Status - [x] Alpha: Testăm Supabase cu un set închis de clienți -- [x] Public Alpha: Oricine se poate înscrie la [app.supabase.com](https://app.supabase.com). Dar fiți blânzi cu noi, există câteva probleme +- [x] Public Alpha: Oricine se poate înscrie la [supabase.com/dashboard](https://supabase.com/dashboard). Dar fiți blânzi cu noi, există câteva probleme - [x] Public Beta: Suficient de stabilă pentru majoritatea cazurilor de utilizare non-întreprindere - [ ] Public: Disponibilitate generală [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ Supabase este o combinație de instrumente open source. Construim caracteristici **Arhitectură** -Supabase este o [platformă găzduită](https://app.supabase.com). Vă puteți înscrie și puteți începe să utilizați Supabase fără a instala nimic. +Supabase este o [platformă găzduită](https://supabase.com/dashboard). Vă puteți înscrie și puteți începe să utilizați Supabase fără a instala nimic. Puteți, de asemenea, [auto-hosting](https://supabase.com/docs/guides/hosting/overview) și [dezvolta local](https://supabase.com/docs/guides/local-development). ![Arhitectură](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.ru.md b/i18n/README.ru.md index 138157fbfb5..142ce343965 100644 --- a/i18n/README.ru.md +++ b/i18n/README.ru.md @@ -12,9 +12,9 @@ - [x] Хостируемая база данных Postgres. [Docs](https://supabase.com/docs/guides/database) - [x] Аутентификация и авторизация. [Docs](https://supabase.com/docs/guides/auth) - [x] Автоматически генерируемые API. - - [x] REST. [Docs](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Docs](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Подписки в реальном времени. [Docs](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Docs](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Docs](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Подписки в реальном времени. [Docs](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Функции. - [x] Функции базы данных. [Docs](https://supabase.com/docs/guides/database/functions) - [x] Edge Functions [Docs](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ ## Статус - [x] Альфа: Мы тестируем Supabase с закрытым набором клиентов -- [x] Публичная Альфа: Любой желающий может зарегистрироваться на [app.supabase.com](https://app.supabase.com). Но будьте с нами помягче, есть несколько недоработок +- [x] Публичная Альфа: Любой желающий может зарегистрироваться на [supabase.com/dashboard](https://supabase.com/dashboard). Но будьте с нами помягче, есть несколько недоработок - [x] Публичная бета-версия: Достаточно стабильна для большинства случаев использования не на предприятиях - [ ] Public: Общая доступность [[статус](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ Supabase - это комбинация инструментов с открыт \*\*Архитектура -Supabase - это [размещенная платформа](https://app.supabase.com). Вы можете зарегистрироваться и начать использовать Supabase, ничего не устанавливая. +Supabase - это [размещенная платформа](https://supabase.com/dashboard). Вы можете зарегистрироваться и начать использовать Supabase, ничего не устанавливая. Вы также можете [самостоятельно размещать](https://supabase.com/docs/guides/hosting/overview) и [разрабатывать локально](https://supabase.com/docs/guides/local-development). ![Архитектура](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.si.md b/i18n/README.si.md index 3790a19a124..ed1b2f45b74 100644 --- a/i18n/README.si.md +++ b/i18n/README.si.md @@ -32,7 +32,7 @@ ## තත්ත්වය - [x] Alpha: අප දැනට Supabase විවෘත නොවන පාරිභෝගිකයන් සමඟ පරීක්ෂා කරනවා -- [x] Public Alpha: ඕනෑම කෙනෙකුට [app.supabase.com](https://app.supabase.com) මඟින් සම්බන්ධ විය හැකිය.නමුත් හෙමිහිට පරීක්ෂා කරන්න, දැනට kinks කිහිපයක් පමණයි ඇත්තේ +- [x] Public Alpha: ඕනෑම කෙනෙකුට [supabase.com/dashboard](https://supabase.com/dashboard) මඟින් සම්බන්ධ විය හැකිය.නමුත් හෙමිහිට පරීක්ෂා කරන්න, දැනට kinks කිහිපයක් පමණයි ඇත්තේ - [x] Public Beta: non-enterprise භාවිතයන් ගොඩකට ස්ථාවරයි - [ ] Public: භාවිතා කිරීම සඳහා සූදානම් @@ -48,7 +48,7 @@ Supabase යනු විවෘත පරිශීලක උපාංග කි **Current architecture** -Supabase යනු [hosted platform](https://app.supabase.com) එකකි.ඔබට දැන් සම්බන්ධ වී කිසිම දෙයක් install නොකර Supabase භාවිත කල හැකිය.අප තවමත් local development අත්දැකීම සාදමින් සිටියි- මෙය තමයි අපේ දැනට ප්‍රධාන අරමුණ,platform එකේ ස්ථාවර බව සමඟ. +Supabase යනු [hosted platform](https://supabase.com/dashboard) එකකි.ඔබට දැන් සම්බන්ධ වී කිසිම දෙයක් install නොකර Supabase භාවිත කල හැකිය.අප තවමත් local development අත්දැකීම සාදමින් සිටියි- මෙය තමයි අපේ දැනට ප්‍රධාන අරමුණ,platform එකේ ස්ථාවර බව සමඟ. ![Architecture](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png) diff --git a/i18n/README.sk.md b/i18n/README.sk.md index 4d6675b4ed3..b3c6d49d1a1 100644 --- a/i18n/README.sk.md +++ b/i18n/README.sk.md @@ -12,9 +12,9 @@ - [x] hostovaná databáza Postgres. [Dokumenty](https://supabase.com/docs/guides/database) - [x] Autentifikácia a autorizácia. [Dokumenty](https://supabase.com/docs/guides/auth) - [x] Automaticky generované rozhrania API. - - [x] REST. [Dokumenty](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Dokumenty](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Odbery v reálnom čase. [Docs](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Dokumenty](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Dokumenty](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Odbery v reálnom čase. [Docs](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Funkcie. - [x] Databázové funkcie. [Docs](https://supabase.com/docs/guides/database/functions) - [x] Okrajové funkcie [Docs](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ Ak chcete zistiť, ako prispievať, navštívte stránku [Začíname](../DEVELOP ## Stav - [x] Alfa: Testujeme Supabase s uzavretým súborom zákazníkov -- [x] Verejná alfa: [app.supabase.com](https://app.supabase.com). Ale buďte na nás mierni, je tu niekoľko zádrheľov +- [x] Verejná alfa: [supabase.com/dashboard](https://supabase.com/dashboard). Ale buďte na nás mierni, je tu niekoľko zádrheľov - [x] Verejná beta verzia: Dostatočne stabilná pre väčšinu prípadov použitia, ktoré nie sú určené pre podniky - [ ] Verejná: Všeobecná dostupnosť [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ Supabase je kombináciou nástrojov s otvoreným zdrojovým kódom. Funkcie Fire **Architektúra** -Supabase je [hostovaná platforma](https://app.supabase.com). Môžete sa zaregistrovať a začať používať Supabase bez toho, aby ste čokoľvek inštalovali. +Supabase je [hostovaná platforma](https://supabase.com/dashboard). Môžete sa zaregistrovať a začať používať Supabase bez toho, aby ste čokoľvek inštalovali. Môžete tiež [samostatne hosťovať](https://supabase.com/docs/guides/hosting/overview) a [vyvíjať lokálne](https://supabase.com/docs/guides/local-development). ![Architektúra](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.sl.md b/i18n/README.sl.md index a4aa4b8a3a3..b29bd185386 100644 --- a/i18n/README.sl.md +++ b/i18n/README.sl.md @@ -12,9 +12,9 @@ - [x] Gostovana podatkovna baza Postgres. [Dokumenti](https://supabase.com/docs/guides/database) - [x] Avtentikacija in avtorizacija. [Dokumenti](https://supabase.com/docs/guides/auth) - [x] Samodejno generirani API-ji. - - [x] REST. [Dokumenti](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Dokumenti](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Naročnine v realnem času. [Dokumenti](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Dokumenti](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Dokumenti](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Naročnine v realnem času. [Dokumenti](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Funkcije. - [x] Funkcije podatkovne zbirke. [Dokumenti](https://supabase.com/docs/guides/database/functions) - [x] Funkcije robov [Dokumenti](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ Za celotno dokumentacijo obiščite [supabase.com/docs](https://supabase.com/doc ## Status - [x] Alfa: Testiramo bazo Supabase z zaprtim naborom strank -- [x] Javna alfa: [app.supabase.com](https://app.supabase.com). Vendar nas ne obremenjujte, saj je še nekaj pomanjkljivosti +- [x] Javna alfa: [supabase.com/dashboard](https://supabase.com/dashboard). Vendar nas ne obremenjujte, saj je še nekaj pomanjkljivosti - [x] Javna beta različica: Dovolj stabilna za večino primerov uporabe, ki niso povezani s podjetji - [ ] Javna: Splošna razpoložljivost [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ Supabaza je kombinacija odprtokodnih orodij. Funkcije Firebase gradimo z odprtok **Arhitektura** -Supabase je [gostovana platforma](https://app.supabase.com). Prijavite se lahko in začnete uporabljati bazo Supabase, ne da bi kar koli namestili. +Supabase je [gostovana platforma](https://supabase.com/dashboard). Prijavite se lahko in začnete uporabljati bazo Supabase, ne da bi kar koli namestili. Lahko tudi [gostujete sami](https://supabase.com/docs/guides/hosting/overview) in [razvijate lokalno](https://supabase.com/docs/guides/local-development). ![Arhitektura](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.sq.md b/i18n/README.sq.md index c80feffbf47..efa85dc895a 100644 --- a/i18n/README.sq.md +++ b/i18n/README.sq.md @@ -32,7 +32,7 @@ Për dokumentimin e plotë, vizitoni [supabase.com/docs](https://supabase.com/do ## Statusi - [x] Alfa: Ne po testojmë Supabase me një grup të mbyllur klientësh -- [x] Publike Alpha: Gjithkush mund të regjistrohet në [app.supabase.com](https://app.supabase.com). Por shkoni lehtë për ne, ka disa ngërçe +- [x] Publike Alpha: Gjithkush mund të regjistrohet në [supabase.com/dashboard](https://supabase.com/dashboard). Por shkoni lehtë për ne, ka disa ngërçe - [x] Publike Beta: Mjaft e qëndrueshme për shumicën e rasteve të përdorimit për jo ndërmarrjet - [ ] Publike: Gati për Prodhim / Publikim @@ -48,7 +48,7 @@ Supabase është një kombinim i mjeteve që janë projekte të hapura (open sou **Arkitektura Aktuale** -Supabase është një [platform hostimi](https://app.supabase.com). Ti mundesh të regjistrohesh dhe të fillosh ta përdorish Supabase duke mos instaluar asgjë. Poashtu ne jemi duke punuar për ta ndërtuar eksperiencën për zhvillim lokal, por kjo nuk është fokusi ynë kryesorë dhe thelbësor, së bashku me stabilitetin e platformës. +Supabase është një [platform hostimi](https://supabase.com/dashboard). Ti mundesh të regjistrohesh dhe të fillosh ta përdorish Supabase duke mos instaluar asgjë. Poashtu ne jemi duke punuar për ta ndërtuar eksperiencën për zhvillim lokal, por kjo nuk është fokusi ynë kryesorë dhe thelbësor, së bashku me stabilitetin e platformës. ![Arkitektura](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png) diff --git a/i18n/README.sr.md b/i18n/README.sr.md index 27817cb4662..c331c8feb2a 100644 --- a/i18n/README.sr.md +++ b/i18n/README.sr.md @@ -12,9 +12,9 @@ - [x] Hostovana Postgres Baza. [Dokumentacija](https://supabase.com/docs/guides/database) - [x] Autentifikacija i Autorizacija. [Dokumentacija](https://supabase.com/docs/guides/auth) - [x] Auto-generisani API-ji. - - [x] REST. [Dokumentacija](https://supabase.com/docs/guides/database/api#rest-api) - - [x] Subskripcije u realnom vremenu. [Dokumentacija](https://supabase.com/docs/guides/database/api#realtime-api) - - [x] GraphQL (Beta). [Dokumentacija](https://supabase.com/docs/guides/database/api#graphql-api) + - [x] REST. [Dokumentacija](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] Subskripcije u realnom vremenu. [Dokumentacija](https://supabase.com/docs/guides/api#realtime-api-overview) + - [x] GraphQL (Beta). [Dokumentacija](https://supabase.com/docs/guides/api#graphql-api-overview) - [x] Funkcije. - [x] Funkcije u bazi. [Dokumentacija](https://supabase.com/docs/guides/database/functions) - [x] Edge Funkcije [Dokumentacija](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ Da vidite kako možete doprineti razvoju, posetite [Getting Started](../DEVELOPE ## Status - [x] Alfa: Testiramo Supabase sa zatvorenim setom klijenata -- [x] Javna Alfa: Bilo ko može da se prijavi na [app.supabase.com](https://app.supabase.com). Ali ne budite grubi, biće nekih poteškoća +- [x] Javna Alfa: Bilo ko može da se prijavi na [supabase.com/dashboard](https://supabase.com/dashboard). Ali ne budite grubi, biće nekih poteškoća - [x] Javna Beta: Dovoljno stabilno za većinu ne-enterprise projekata - [ ] Objavljeno: spremno za produkciju @@ -55,7 +55,7 @@ Supabase je kombinacija open source alata. Razvijamo funkcionalnosti Firebase-a **Arhitektura** -Supabase je [hostovana platforma](https://app.supabase.com). Možete se prijaviti i početi sa korišćenjem Supabase-a bez instaliranja bilo čega. +Supabase je [hostovana platforma](https://supabase.com/dashboard). Možete se prijaviti i početi sa korišćenjem Supabase-a bez instaliranja bilo čega. Takođe možete da [hostujete sami](https://supabase.com/docs/guides/hosting/overview) i da [razvijate u lokalu](https://supabase.com/docs/guides/local-development). ![Arhitektura](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png) diff --git a/i18n/README.sv.md b/i18n/README.sv.md index 32f3399207f..e7378520d20 100644 --- a/i18n/README.sv.md +++ b/i18n/README.sv.md @@ -12,9 +12,9 @@ - [x] Hosted Postgres-databas. [Docs](https://supabase.com/docs/guides/database) - [x] Autentisering och auktorisering. [Docs](https://supabase.com/docs/guides/auth) - [x] Automatiskt genererade API:er. - - [x] REST. [Docs](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Docs](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Prenumerationer i realtid. [Dokument](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Docs](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Docs](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Prenumerationer i realtid. [Dokument](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Funktioner. - [x] Databasfunktioner. [Dokument](https://supabase.com/docs/guides/database/functions) - [x] Edge-funktioner [Docs](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ För att se hur man bidrar, besök [Getting Started](../DEVELOPERS.md) ## Status - [Alpha: Vi testar Supabase med en sluten grupp av kunder -- [x] Public Alpha: Vem som helst kan registrera sig på [app.supabase.com](https://app.supabase.com). Men var försiktig med oss, det finns några problem +- [x] Public Alpha: Vem som helst kan registrera sig på [supabase.com/dashboard](https://supabase.com/dashboard). Men var försiktig med oss, det finns några problem - [x] Public Beta: Stabil nog för de flesta användningsområden som inte är företag - [ ] Public: Allmän tillgänglighet [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)]] @@ -55,7 +55,7 @@ Supabase är en kombination av verktyg med öppen källkod. Vi bygger funktioner **Arkitektur** -Supabase är en [värdplattform](https://app.supabase.com). Du kan registrera dig och börja använda Supabase utan att installera något. +Supabase är en [värdplattform](https://supabase.com/dashboard). Du kan registrera dig och börja använda Supabase utan att installera något. Du kan också [självhosta](https://supabase.com/docs/guides/hosting/overview) och [utveckla lokalt](https://supabase.com/docs/guides/local-development). ![Arkitektur](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.ta.md b/i18n/README.ta.md index 89cab1c35b2..e24ed892e8e 100644 --- a/i18n/README.ta.md +++ b/i18n/README.ta.md @@ -12,9 +12,9 @@ - [x] ஹோஸ்ட் செய்யப்பட்ட பூஸ்ட்ஜீஆர்இஎஸ் தரவுத்தளம். [ஆவணம்](https://supabase.com/docs/guides/database) - [x] அங்கீகாரம் மற்றும் அங்கீகாரம்.. [ஆவணம்](https://supabase.com/docs/guides/auth) - [x] தானாக உருவாக்கப்பட்ட ஏபிஐ கள். - - [x] ராஸ்ட். [ஆவணம்](https://supabase.com/docs/guides/database/api#rest-api) - - [x] நிகழ்நேர சந்தாக்கள். [ஆவணம்](https://supabase.com/docs/guides/database/api#realtime-api) - - [x] கிராப்க்யூஎல் (பீட்டா). [ஆவணம்](https://supabase.com/docs/guides/database/api#graphql-api) + - [x] ராஸ்ட். [ஆவணம்](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] நிகழ்நேர சந்தாக்கள். [ஆவணம்](https://supabase.com/docs/guides/api#realtime-api-overview) + - [x] கிராப்க்யூஎல் (பீட்டா). [ஆவணம்](https://supabase.com/docs/guides/api#graphql-api-overview) - [x] செயல்பாடுகள். - [x] தரவுத்தளம் செயல்பாடுகள். [ஆவணம்](https://supabase.com/docs/guides/database/functions) - [x] விளிம்பு செயல்பாடுகள் [ஆவணம்](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ ## நிலை - [x] ஆல்ஃபா: நாங்கள் வாடிக்கையாளர்களின் மூடிய தொகுப்புடன் சுபாபேஸை சோதிக்கிறோம் -- [x] பொது ஆல்ஃபா: எவரும் [app.supabase.com](https://app.supabase.com) இல் பதிவு செய்யலாம், ஆனால் எங்களுக்கு எளிதாக செல்லுங்கள், ஒரு சில கின்க்ஸ் உள்ளன +- [x] பொது ஆல்ஃபா: எவரும் [supabase.com/dashboard](https://supabase.com/dashboard) இல் பதிவு செய்யலாம், ஆனால் எங்களுக்கு எளிதாக செல்லுங்கள், ஒரு சில கின்க்ஸ் உள்ளன - [x] பொது பீட்டா: பெரும்பாலான அல்லாத நிறுவன பயன்பாட்டு-வழக்குகளுக்கு போதுமான நிலையானது - [ ] பொது: உற்பத்தி-தயார் @@ -55,7 +55,7 @@ **கட்டிடக்கலை** -சுபாபேஸ் ஒரு [ஹோஸ்ட் செய்யப்பட்ட தளம்](https://app.supabase.com). எதையும் நிறுவாமல் நீங்கள் பதிவுசெய்து Supabase ஐப் பயன்படுத்தத் தொடங்கலாம். +சுபாபேஸ் ஒரு [ஹோஸ்ட் செய்யப்பட்ட தளம்](https://supabase.com/dashboard). எதையும் நிறுவாமல் நீங்கள் பதிவுசெய்து Supabase ஐப் பயன்படுத்தத் தொடங்கலாம். நீங்கள் [சுய ஹோஸ்ட்](https://supabase.com/docs/guides/hosting/overview) மற்றும் [உள்நாட்டில் உருவாக்க](https://supabase.com/docs/guides/local-development) ஆகியவற்றையும் செய்யலாம். ![கட்டிடக்கலை](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png) diff --git a/i18n/README.th.md b/i18n/README.th.md index 6c92a9fe38f..d9d092c349f 100644 --- a/i18n/README.th.md +++ b/i18n/README.th.md @@ -32,7 +32,7 @@ ## Status - [x] Alpha: เรากำลังทดสอบ Supabase ในวงผู้ใช้ที่จำกัด -- [x] Public Alpha: ทุกคนสามารถลงชื่อเข้าใช้ได้ที่ [app.supabase.com](https://app.supabase.com) แต่อย่าพึ่งไว้ใจมากนะ ระบบยังไม่สมบูรณ์ +- [x] Public Alpha: ทุกคนสามารถลงชื่อเข้าใช้ได้ที่ [supabase.com/dashboard](https://supabase.com/dashboard) แต่อย่าพึ่งไว้ใจมากนะ ระบบยังไม่สมบูรณ์ - [x] Public Beta: เสถียรมากพอสำหรับการใช้งานแบบทั่วไป (ไม่ใช่องค์กร) - [ ] Public: พร้อมสำหรับการใช้งาน @@ -48,7 +48,7 @@ Supabase สร้างขึ้นมาจากการรวมกัน **สถาปัตยกรรม** -Supabase เป็น [hosted platform](https://app.supabase.com). คุณสามารถลงทะเบียนและเริ่มใช้งาน Supabase ได้เลยโดยไม่ต้องติดตั้งอะไรเพิ่มเติม นอกเหนือไปจากนั้นเรากำลังพัฒนาระบบเพื่อการพัฒนาเว็บบน local (local development experience) ที่เป็นจุดมุ่งหมายของเราในตอนนี้ รวมถึงความมั่นคงของระบบ +Supabase เป็น [hosted platform](https://supabase.com/dashboard). คุณสามารถลงทะเบียนและเริ่มใช้งาน Supabase ได้เลยโดยไม่ต้องติดตั้งอะไรเพิ่มเติม นอกเหนือไปจากนั้นเรากำลังพัฒนาระบบเพื่อการพัฒนาเว็บบน local (local development experience) ที่เป็นจุดมุ่งหมายของเราในตอนนี้ รวมถึงความมั่นคงของระบบ ![สถาปัตยกรรม](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png) diff --git a/i18n/README.tr.md b/i18n/README.tr.md index aa13dbe847d..f09c2c2607d 100644 --- a/i18n/README.tr.md +++ b/i18n/README.tr.md @@ -12,9 +12,9 @@ - [x] Barındırılan Postgres Veritabanı. [Dokümanlar](https://supabase.com/docs/guides/database) - [x] Kimlik Doğrulama ve Yetkilendirme. [Dokümanlar](https://supabase.com/docs/guides/auth) - [x] Otomatik oluşturulan API'ler. - - [x] REST. [Dokümanlar](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Docs](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Gerçek zamanlı abonelikler. [Dokümanlar](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Dokümanlar](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Docs](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Gerçek zamanlı abonelikler. [Dokümanlar](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Fonksiyonlar. - [x] Veritabanı Fonksiyonları. [Dokümanlar](https://supabase.com/docs/guides/database/functions) - [x] Kenar İşlevleri [Dokümanlar](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ Nasıl Katkıda bulunacağınızı görmek için [Başlarken](../DEVELOPERS.md) ## Durum - [Alfa: Supabase'i kapalı bir müşteri grubuyla test ediyoruz -- [Halka Açık Alfa: Herkes [app.supabase.com](https://app.supabase.com) adresinden kaydolabilir. Ama bize karşı sakin olun, birkaç karışıklık var +- [Halka Açık Alfa: Herkes [supabase.com/dashboard](https://supabase.com/dashboard) adresinden kaydolabilir. Ama bize karşı sakin olun, birkaç karışıklık var - [x] Genel Beta: Kurumsal olmayan kullanım durumlarının çoğu için yeterince kararlı - [ ] Genel: Genel Kullanılabilirlik [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ Supabase açık kaynak araçlarının bir birleşimidir. Firebase'in özellikler **Mimari** -Supabase [barındırılan bir platformdur](https://app.supabase.com). Supabase'e kaydolabilir ve hiçbir şey yüklemeden kullanmaya başlayabilirsiniz. +Supabase [barındırılan bir platformdur](https://supabase.com/dashboard). Supabase'e kaydolabilir ve hiçbir şey yüklemeden kullanmaya başlayabilirsiniz. Ayrıca [self-host](https://supabase.com/docs/guides/hosting/overview) ve [yerel olarak geliştirebilirsiniz](https://supabase.com/docs/guides/local-development). ![Mimarlık](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.uk.md b/i18n/README.uk.md index 844b85c98f7..d6a2407eb6c 100644 --- a/i18n/README.uk.md +++ b/i18n/README.uk.md @@ -12,9 +12,9 @@ - [x] Розміщена база даних Postgres. [Документи](https://supabase.com/docs/guides/database) - [x] Аутентифікація та авторизація. [Документи](https://supabase.com/docs/guides/auth) - [x] Автоматично згенеровані API. - - [x] REST. [Документи](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL. [Документи](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] Підписки в режимі реального часу. [Документи](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST. [Документи](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL. [Документи](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] Підписки в режимі реального часу. [Документи](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] Функції. - [x] Функції бази даних. [Документи](https://supabase.com/docs/guides/database/functions) - [x] Граничні функції [Документи](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ ## Статус - [x] Альфа: Ми тестуємо Supabase із закритим набором клієнтів -- [x] Публічна альфа-версія: Будь-хто може зареєструватися на [app.supabase.com](https://app.supabase.com). Але будьте обережні з нами, є кілька нюансів +- [x] Публічна альфа-версія: Будь-хто може зареєструватися на [supabase.com/dashboard](https://supabase.com/dashboard). Але будьте обережні з нами, є кілька нюансів - [x] Публічна бета-версія: Достатньо стабільна для більшості непідприємницьких випадків використання - [ ] Загальнодоступна: Загальна доступність [[статус](https://supabase.com/docs/guides/getting-started/features#feature-status)] @@ -55,7 +55,7 @@ Supabase - це комбінація інструментів з відкрит **Архітектура** -Supabase - це [хостингова платформа](https://app.supabase.com). Ви можете зареєструватися і почати використовувати Supabase без встановлення. +Supabase - це [хостингова платформа](https://supabase.com/dashboard). Ви можете зареєструватися і почати використовувати Supabase без встановлення. Ви також можете [самостійно хостити](https://supabase.com/docs/guides/hosting/overview) і [розробляти локально](https://supabase.com/docs/guides/local-development). ![Архітектура](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/i18n/README.vi-vn.md b/i18n/README.vi-vn.md index ff362a85a74..70b55bc9841 100644 --- a/i18n/README.vi-vn.md +++ b/i18n/README.vi-vn.md @@ -12,9 +12,9 @@ - [x] Cơ sở dữ liệu Postgres tự quản lý. [Tài liệu](https://supabase.com/docs/guides/database) - [x] Xác thực và uỷ quyền. [Tài liệu](https://supabase.com/docs/guides/auth) - [x] APIs tự động khởi tạo. - - [x] REST. [Tài liệu](https://supabase.com/docs/guides/database/api#rest-api) - - [x] Lắng nghe thay đổi thời gian thực. [Tài liệu](https://supabase.com/docs/guides/database/api#realtime-api) - - [x] GraphQL (Thử nghiệm). [Tài liệu](https://supabase.com/docs/guides/database/api#graphql-api) + - [x] REST. [Tài liệu](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] Lắng nghe thay đổi thời gian thực. [Tài liệu](https://supabase.com/docs/guides/api#realtime-api-overview) + - [x] GraphQL (Thử nghiệm). [Tài liệu](https://supabase.com/docs/guides/api#graphql-api-overview) - [x] Chức năng hàm. - [x] Hàm dành cho cơ sở dữ liệu (Database functions). [Tài liệu](https://supabase.com/docs/guides/database/functions) - [x] Hàm được phân phối trên toàn cầu (Edge Functions). [Tài liệu](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ ## Trạng thái - [x] Alpha: Chúng tôi đang thử nghiệm Supabase với một nhóm người dùng kín -- [x] Public Alpha: Bất kỳ ai cũng có thể đăng ký tại [app.supabase.com](https://app.supabase.com). Nhưng hãy bình tĩnh với chúng tôi, sẽ có một vài lỗi nhỏ. +- [x] Public Alpha: Bất kỳ ai cũng có thể đăng ký tại [supabase.com/dashboard](https://supabase.com/dashboard). Nhưng hãy bình tĩnh với chúng tôi, sẽ có một vài lỗi nhỏ. - [x] Public Beta: Đủ ổn định cho hầu hết các trường hợp sử dụng không dành cho môi trường doanh nghiệp (production) - [ ] Public: Sẵn sàng cho môi trường doanh nghiệp (production) @@ -55,7 +55,7 @@ Supabase là sự kết hợp của các công cụ mã nguồn mở. Các tính **Kiến trúc** -Supabase là một [nền tảng lưu trữ cơ sở dữ liệu](https://app.supabase.com). Bạn có thể đăng ký và bắt đầu sử dụng Supabase mà không cần cài đặt bất kỳ thứ gì. +Supabase là một [nền tảng lưu trữ cơ sở dữ liệu](https://supabase.com/dashboard). Bạn có thể đăng ký và bắt đầu sử dụng Supabase mà không cần cài đặt bất kỳ thứ gì. Bạn cũng có thể [tự quản lý](https://supabase.com/docs/guides/hosting/overview) và [phát triển cục bộ](https://supabase.com/docs/guides/local-development). ![Kiến trúc](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png) diff --git a/i18n/README.zh-cn.md b/i18n/README.zh-cn.md index 86acbcd758a..9980e8afb3a 100644 --- a/i18n/README.zh-cn.md +++ b/i18n/README.zh-cn.md @@ -12,9 +12,9 @@ - [x] Postgres 数据库托管。[文档](https://supabase.com/docs/guides/database) - [x] 身份验证和授权。[文档](https://supabase.com/docs/guides/auth) - [x] 自动生成的 API。 - - [x] REST。[文档](https://supabase.com/docs/guides/database/api#rest-api) - - [x] 实时订阅。[文档](https://supabase.com/docs/guides/database/api#realtime-api) - - [x] GraphQL(测试版)。[文档](https://supabase.com/docs/guides/database/api#graphql-api) + - [x] REST。[文档](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] 实时订阅。[文档](https://supabase.com/docs/guides/api#realtime-api-overview) + - [x] GraphQL(测试版)。[文档](https://supabase.com/docs/guides/api#graphql-api-overview) - [x] 函数。 - [x] 数据库函数。[文档](https://supabase.com/docs/guides/database/functions) - [x] 边缘函数。[文档](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ ## 状态 - [x] Alpha:我们正在与一组封闭的客户测试 Supabase -- [x] Public Alpha:任何人都可以在 [app.supabase.com](https://app.supabase.com)上注册。只是务必手下留情,还有一些纠结的地方。 +- [x] Public Alpha:任何人都可以在 [supabase.com/dashboard](https://supabase.com/dashboard)上注册。只是务必手下留情,还有一些纠结的地方。 - [x] Public Beta:足够稳定,适合大多数非企业使用场景 - [ ] Public:生产就绪 @@ -55,7 +55,7 @@ Supabase 是一个开源工具的组合。我们正在使用企业级的开源 **当前架构** -Supabase 是一个[托管平台](https://app.supabase.com)。你可以注册并开始使用 Supabase,而无需安装任何软件。 +Supabase 是一个[托管平台](https://supabase.com/dashboard)。你可以注册并开始使用 Supabase,而无需安装任何软件。 你也可以[自托管](https://supabase.com/docs/guides/hosting/overview)和[本地开发](https://supabase.com/docs/guides/local-development)。 ![架构](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png) diff --git a/i18n/README.zh-tw.md b/i18n/README.zh-tw.md index 4dbe8985113..15da9bfc47b 100644 --- a/i18n/README.zh-tw.md +++ b/i18n/README.zh-tw.md @@ -12,9 +12,9 @@ - [x] 托管的 Postgres 数据库。[文档](https://supabase.com/docs/guides/database) - [x] 认证和授权。[文档](https://supabase.com/docs/guides/auth) - [x] 自动生成的 API。 - - [x] REST.[文档](https://supabase.com/docs/guides/database/api#rest-api) - - [x] GraphQL。[文件](https://supabase.com/docs/guides/database/api#graphql-api) - - [x] 实时订阅。[文档](https://supabase.com/docs/guides/database/api#realtime-api) + - [x] REST.[文档](https://supabase.com/docs/guides/api#rest-api-overview) + - [x] GraphQL。[文件](https://supabase.com/docs/guides/api#graphql-api-overview) + - [x] 实时订阅。[文档](https://supabase.com/docs/guides/api#realtime-api-overview) - [x] 函数。 - [x] 数据库函数。[文件](https://supabase.com/docs/guides/database/functions) - [x] 边缘功能 [文档](https://supabase.com/docs/guides/functions) @@ -39,7 +39,7 @@ ## 状态 - [x] 阿尔法:我们正在与一组封闭的客户测试 Supabase。 -- [x] 公开阿尔法:任何人都可以在[app.supabase.com](https://app.supabase.com)上注册。但请对我们宽容一些,有一些小问题。 +- [x] 公开阿尔法:任何人都可以在[supabase.com/dashboard](https://supabase.com/dashboard)上注册。但请对我们宽容一些,有一些小问题。 - [x] 公开测试版:足够稳定,适合大多数非企业使用的情况。 - [] 公开:普遍可用 [状态](https://supabase.com/docs/guides/getting-started/features#feature-status) @@ -55,7 +55,7 @@ Supabase 是一个开源工具的组合。我们正在使用企业级的开源 **架构** -Supabase 是一个[托管平台](https://app.supabase.com)。你可以注册并开始使用 Supabase,无需安装任何东西。 +Supabase 是一个[托管平台](https://supabase.com/dashboard)。你可以注册并开始使用 Supabase,无需安装任何东西。 你也可以[自我托管](https://supabase.com/docs/guides/hosting/overview)和[本地开发](https://supabase.com/docs/guides/local-development)。 ![架构](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) diff --git a/package-lock.json b/package-lock.json index 2291fa1b4f8..320a08ec69f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "workspaces": [ "apps/www", "apps/docs", - "apps/new-docs", "studio", "tests", "packages/*" @@ -42805,7 +42804,7 @@ "license": "MIT", "dependencies": { "@headlessui/react": "^1.7.14", - "@supabase/gotrue-js": "^2.31.0", + "@supabase/gotrue-js": "^2.35.0", "@supabase/ui": "^0.37.0-alpha.50", "react-use": "^17.4.0" }, @@ -42818,9 +42817,9 @@ } }, "packages/common/node_modules/@supabase/gotrue-js": { - "version": "2.31.0", - "resolved": "https://registry.npmjs.org/@supabase/gotrue-js/-/gotrue-js-2.31.0.tgz", - "integrity": "sha512-YcwlbbNfedlue/HVIXtYBb4fuOrs29gNOTl6AmyxPp4zryRxzFvslVN9kmLDBRUAVU9fnPJh2bgOR3chRjJX5w==", + "version": "2.35.0", + "resolved": "https://registry.npmjs.org/@supabase/gotrue-js/-/gotrue-js-2.35.0.tgz", + "integrity": "sha512-bLNIrBzFNRR61qUN8VZ4iV7q+fPilWyYQi0CZlxuHBiPtVcNPTul02DbNvgvw76hNz+mB5maay/Y17lSHnSRmw==", "dependencies": { "cross-fetch": "^3.1.5" } @@ -42860,6 +42859,7 @@ "eslint-config-next": "^13.3.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-react": "7.28.0", + "mini-svg-data-uri": "^1.4.3", "tailwindcss-radix": "^2.0.0" } }, diff --git a/package.json b/package.json index 2f5284b8fa1..b1c3c271fff 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,6 @@ "workspaces": [ "apps/www", "apps/docs", - "apps/new-docs", "studio", "tests", "packages/*" diff --git a/packages/common/gotrue.ts b/packages/common/gotrue.ts index 9df248b6197..2ef1061dfe7 100644 --- a/packages/common/gotrue.ts +++ b/packages/common/gotrue.ts @@ -1,9 +1,18 @@ import { GoTrueClient } from '@supabase/gotrue-js' export const STORAGE_KEY = process.env.NEXT_PUBLIC_STORAGE_KEY || 'supabase.dashboard.auth.token' +export const AUTH_DEBUG_KEY = + process.env.NEXT_PUBLIC_AUTH_DEBUG_KEY || 'supabase.dashboard.auth.debug' + +const debug = + process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' && + globalThis && + globalThis.localStorage && + globalThis.localStorage.getItem(AUTH_DEBUG_KEY) === 'true' export const gotrueClient = new GoTrueClient({ url: process.env.NEXT_PUBLIC_GOTRUE_URL, storageKey: STORAGE_KEY, detectSessionInUrl: true, + debug, }) diff --git a/packages/common/hooks/useParams.ts b/packages/common/hooks/useParams.ts index cac30143bd9..879d3f4e50e 100644 --- a/packages/common/hooks/useParams.ts +++ b/packages/common/hooks/useParams.ts @@ -1,15 +1,46 @@ import { useRouter } from 'next/router' import { useMemo } from 'react' +/** + * Helper to convert kebab case to camel case + */ +function convertToCamelCase(key: string): string { + if (!key.includes('-')) { + return key + } + + const parts = key.split('-') + const capitalizedParts = parts.map((part, index) => { + if (index === 0) { + return part + } + return part.charAt(0).toUpperCase() + part.slice(1) + }) + return capitalizedParts.join('') +} + export function useParams(): { [k: string]: string | undefined } { const { query } = useRouter() + const modifiedQuery = { + ...query, + } + + // Convert kebab case keys to camel case + Object.keys(modifiedQuery).forEach((key) => { + const modifiedKey = convertToCamelCase(key) + if (modifiedKey !== key) { + modifiedQuery[modifiedKey] = modifiedQuery[key] + delete modifiedQuery[key] + } + }) + return useMemo( () => Object.fromEntries( - Object.entries(query).map(([key, value]) => { + Object.entries(modifiedQuery).map(([key, value]) => { if (Array.isArray(value)) { return [key, value[0]] } else { diff --git a/packages/common/package.json b/packages/common/package.json index eba24a4faeb..f41a62d834e 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -6,7 +6,7 @@ "license": "MIT", "dependencies": { "@headlessui/react": "^1.7.14", - "@supabase/gotrue-js": "^2.31.0", + "@supabase/gotrue-js": "^2.35.0", "@supabase/ui": "^0.37.0-alpha.50", "react-use": "^17.4.0" }, diff --git a/packages/config/package.json b/packages/config/package.json index 45901f19411..b9fb4a06dab 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -15,6 +15,7 @@ "eslint-config-prettier": "^8.3.0", "eslint-plugin-react": "7.28.0", "tailwindcss-radix": "^2.0.0", - "@tailwindcss/typography": "^0.5.9" + "@tailwindcss/typography": "^0.5.9", + "mini-svg-data-uri": "^1.4.3" } } diff --git a/packages/config/ui.config.js b/packages/config/ui.config.js index 96cf6ae71a7..856f8ff436a 100644 --- a/packages/config/ui.config.js +++ b/packages/config/ui.config.js @@ -3,6 +3,7 @@ const forms = require('@tailwindcss/forms') const plugin = require('tailwindcss/plugin') const radixUiColors = require('@radix-ui/colors') const brandColors = require('./default-colors') +const svgToDataUri = require('mini-svg-data-uri') const { default: flattenColorPalette } = require('tailwindcss/lib/util/flattenColorPalette') @@ -371,18 +372,28 @@ const uiConfig = { highlight: (value) => ({ boxShadow: `inset 0 1px 0 0 ${value}` }), }, { values: flattenColorPalette(theme('backgroundColor')), type: 'color' } - ), - matchUtilities( - { - subhighlight: (value) => ({ - boxShadow: `inset 0 -1px 0 0 ${value}`, - }), - }, - { - values: flattenColorPalette(theme('backgroundColor')), - type: 'color', - } - ) + ) + matchUtilities( + { + subhighlight: (value) => ({ + boxShadow: `inset 0 -1px 0 0 ${value}`, + }), + }, + { + values: flattenColorPalette(theme('backgroundColor')), + type: 'color', + } + ) + matchUtilities( + { + 'bg-grid': (value) => ({ + backgroundImage: `url("${svgToDataUri( + `` + )}")`, + }), + }, + { values: flattenColorPalette(theme('backgroundColor')), type: 'color' } + ) }, require('tailwindcss-radix')(), forms, diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx index 30d9ad7d52e..f5009a16d8d 100644 --- a/packages/ui/index.tsx +++ b/packages/ui/index.tsx @@ -63,6 +63,7 @@ export * from './src/components/Form' export * from './src/components/Command' // layout +export * from './src/components/LoadingLine' // banners export * from './src/layout/banners' diff --git a/packages/ui/src/components/Checkbox/Checkbox.tsx b/packages/ui/src/components/Checkbox/Checkbox.tsx index 13707b536a9..0f325e4a18a 100644 --- a/packages/ui/src/components/Checkbox/Checkbox.tsx +++ b/packages/ui/src/components/Checkbox/Checkbox.tsx @@ -84,6 +84,7 @@ function Group({ checked={option.checked} name={option.name} description={option.description} + defaultChecked={option.defaultChecked} /> ) }) diff --git a/packages/ui/src/components/Command/Command.utils.tsx b/packages/ui/src/components/Command/Command.utils.tsx index ee020395eb9..a7a44b0cc8b 100644 --- a/packages/ui/src/components/Command/Command.utils.tsx +++ b/packages/ui/src/components/Command/Command.utils.tsx @@ -7,7 +7,7 @@ import { DetailedHTMLProps, HTMLAttributes, KeyboardEventHandler } from 'react' import { Modal } from '../Modal' import { ModalProps } from '../Modal/Modal' import { useCommandMenu } from './CommandMenuProvider' -import { LoadingLine } from './LoadingLine' +import { LoadingLine } from '../LoadingLine/LoadingLine' type CommandPrimitiveElement = React.ElementRef type CommandPrimitiveProps = React.ComponentPropsWithoutRef diff --git a/packages/ui/src/components/Command/LoadingLine.tsx b/packages/ui/src/components/LoadingLine/LoadingLine.tsx similarity index 93% rename from packages/ui/src/components/Command/LoadingLine.tsx rename to packages/ui/src/components/LoadingLine/LoadingLine.tsx index 1e590e092b1..afb8522b653 100644 --- a/packages/ui/src/components/Command/LoadingLine.tsx +++ b/packages/ui/src/components/LoadingLine/LoadingLine.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { cn } from './../../lib/utils' +import { cn } from '../../lib/utils' export const LoadingLine = ({ loading }: { loading: boolean }) => { return ( diff --git a/packages/ui/src/components/LoadingLine/index.tsx b/packages/ui/src/components/LoadingLine/index.tsx new file mode 100644 index 00000000000..16a23c1edfe --- /dev/null +++ b/packages/ui/src/components/LoadingLine/index.tsx @@ -0,0 +1 @@ +export * from './LoadingLine' diff --git a/packages/ui/src/components/SidePanel/SidePanel.tsx b/packages/ui/src/components/SidePanel/SidePanel.tsx index f5dd4fe552c..13622bbce5d 100644 --- a/packages/ui/src/components/SidePanel/SidePanel.tsx +++ b/packages/ui/src/components/SidePanel/SidePanel.tsx @@ -61,10 +61,6 @@ const SidePanel = ({ }: SidePanelProps) => { const __styles = styleHandler('sidepanel') - // function stopPropagation(e: React.MouseEvent) { - // e.stopPropagation() - // } - const footerContent = customFooter ? ( customFooter ) : ( @@ -74,35 +70,37 @@ const SidePanel = ({ {cancelText}
- - -
- -
-
- {tooltip !== undefined && ( - - - -
+ +
+
- - - )} - + {confirmText} + +
+ + {tooltip !== undefined && ( + + + +
+ {tooltip} +
+
+
+ )} +
+ )} ) diff --git a/packages/ui/src/components/Tabs/Tabs.tsx b/packages/ui/src/components/Tabs/Tabs.tsx index aba66a6dfea..3466dc5d262 100644 --- a/packages/ui/src/components/Tabs/Tabs.tsx +++ b/packages/ui/src/components/Tabs/Tabs.tsx @@ -1,13 +1,8 @@ -import * as React from 'react' -import { TabsContext } from './TabsContext' - import * as TabsPrimitive from '@radix-ui/react-tabs' import { useRouter } from 'next/router' - -// @ts-ignore -// import TabsStyles from './Tabs.module.css' - +import * as React from 'react' import styleHandler from '../../lib/theme/styleHandler' +import { useTabGroup } from './TabsProvider' interface TabsProps { type?: 'pills' | 'underlined' | 'cards' | 'rounded-pills' @@ -42,15 +37,16 @@ const Tabs: React.FC & TabsSubComponents = ({ addOnBefore, addOnAfter, listClassNames, - children, + children: _children, }) => { + // toArray is used here to filter out invalid children + // another method would be to use React.Children.map + const children = React.Children.toArray(_children) as PanelPropsProps[] + const [activeTab, setActiveTab] = React.useState( - defaultActiveId - ? defaultActiveId - : // if no defaultActiveId is set use the first panel - children && children[0].props - ? children[0].props.id - : '' + defaultActiveId ?? + // if no defaultActiveId is set use the first panel + children?.[0]?.props?.id ) const router = useRouter() @@ -58,40 +54,35 @@ const Tabs: React.FC & TabsSubComponents = ({ let __styles = styleHandler('tabs') - // activeId state can be overriden externally with `active` - // defaults to the first panelif we have one or url hash if not - const active = activeId ? activeId : activeTab ? activeTab : hash + const tabIds = children.map((tab) => tab.props.id) + + const { groupActiveId, setGroupActiveId } = useTabGroup(tabIds) + + const active = activeId ?? groupActiveId ?? activeTab ?? hash function onTabClick(id: string) { - const newTabSelected = id !== active setActiveTab(id) - if (onClick) onClick(id) - if (onChange && newTabSelected) onChange(id) - } + setGroupActiveId?.(id) - // for styling the tabs for underline style - const underlined = type === 'underlined' - // for styling the tabs for cards style + onClick?.(id) + if (id !== active) { + onChange?.(id) + } + } const listClasses = [__styles[type].list] if (scrollable) listClasses.push(__styles.scrollable) if (listClassNames) listClasses.push(listClassNames) - // if only 1 react child, it needs to be converted to a list/array - // this is so 1 tab can be displayed - if (children && !Array.isArray(children)) { - children = [children] - } - return ( - + {addOnBefore} {children.map((tab) => { - const activeMatch = active === tab.props.id + const isActive = active === tab.props.id const triggerClasses = [__styles[type].base, __styles.size[size]] - if (activeMatch) { + if (isActive) { triggerClasses.push(__styles[type].active) } else { triggerClasses.push(__styles[type].inactive) @@ -118,10 +109,9 @@ const Tabs: React.FC & TabsSubComponents = ({ ) })} - {/* */} {addOnAfter} - {children} + {children} ) } @@ -142,16 +132,9 @@ export const Panel: React.FC = ({ children, id, className }) => { let __styles = styleHandler('tabs') return ( - - {({ activeId }) => { - const active = activeId === id - return ( - - {children} - - ) - }} - + + {children} + ) } diff --git a/packages/ui/src/components/Tabs/TabsContext.tsx b/packages/ui/src/components/Tabs/TabsContext.tsx deleted file mode 100644 index b4a3471f16b..00000000000 --- a/packages/ui/src/components/Tabs/TabsContext.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { createContext } from 'react' - -// Make sure the shape of the default value passed to -// createContext matches the shape that the consumers expect! -export const TabsContext = createContext({ - activeId: '', -}) diff --git a/packages/ui/src/components/Tabs/TabsProvider.tsx b/packages/ui/src/components/Tabs/TabsProvider.tsx new file mode 100644 index 00000000000..feb6a74941b --- /dev/null +++ b/packages/ui/src/components/Tabs/TabsProvider.tsx @@ -0,0 +1,103 @@ +import { xor } from 'lodash' +import { + Dispatch, + PropsWithChildren, + SetStateAction, + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react' + +export interface TabGroup { + tabIds: string[] + activeId: string +} + +interface TabsContextValue { + tabGroups: TabGroup[] + setTabGroups: Dispatch> +} + +const TabsContext = createContext({ + tabGroups: [], + setTabGroups: () => {}, +}) + +/** + * Tracks active Tab IDs across the site so that tabs + * with the same ID stay in sync (eg. JS vs TS tabs). + */ +const TabsProvider = ({ children }: PropsWithChildren<{}>) => { + const [tabGroups, setTabGroups] = useState([]) + + return {children} +} + +export interface UseTabGroupValue { + /** + * The active tab ID for the tab group. + * This value is shared with all matching tab + * groups within the context. + */ + groupActiveId?: string + + /** + * Set the active tab ID for the tab group. + * Value will be shared with all matching tab + * groups within the context. + */ + setGroupActiveId?(id: string): void +} + +/** + * Hook to retrieve and set the active tab ID for + * the current tab group. + * The value will be shared with all matching tab + * groups within the context. + * + * Silently fails if no `TabsProvider` is set. + */ +export const useTabGroup = (tabIds: string[]): UseTabGroupValue => { + const tabsContext = useContext(TabsContext) + + if (!tabsContext) { + return {} + } + + const { tabGroups, setTabGroups } = tabsContext + + const groupActiveId = useMemo( + () => tabGroups.find((group) => xor(group.tabIds, tabIds).length === 0)?.activeId, + [tabGroups] + ) + + const setGroupActiveId = useCallback((id: string) => { + setTabGroups((groups) => { + // Clone the array + const newGroups = groups.concat() + + const existingGroupIndex = groups.findIndex((group) => xor(group.tabIds, tabIds).length === 0) + + const tabGroup: TabGroup = { + tabIds, + activeId: id, + } + + // If this group already exists, replace it + // Otherwise add to the end + if (existingGroupIndex !== -1) { + newGroups.splice(existingGroupIndex, 1, tabGroup) + } else { + newGroups.push(tabGroup) + } + + return newGroups + }) + }, []) + + return { groupActiveId, setGroupActiveId } +} + +export default TabsProvider diff --git a/packages/ui/src/components/Tabs/index.tsx b/packages/ui/src/components/Tabs/index.tsx index 91e13405f93..14400819be6 100644 --- a/packages/ui/src/components/Tabs/index.tsx +++ b/packages/ui/src/components/Tabs/index.tsx @@ -1 +1,2 @@ export { default as Tabs } from './Tabs' +export { default as TabsProvider } from './TabsProvider' diff --git a/packages/ui/src/lib/Layout/FormLayout/FormLayout.tsx b/packages/ui/src/lib/Layout/FormLayout/FormLayout.tsx index 9715d795b1d..7a8fe25e9ae 100644 --- a/packages/ui/src/lib/Layout/FormLayout/FormLayout.tsx +++ b/packages/ui/src/lib/Layout/FormLayout/FormLayout.tsx @@ -125,12 +125,12 @@ export function FormLayout({ ) const renderDescription = descriptionText && ( -

{descriptionText} -

+ ) return ( diff --git a/spec/cli_v1_commands.yaml b/spec/cli_v1_commands.yaml index c66b66318bb..c7f5c6d7e07 100644 --- a/spec/cli_v1_commands.yaml +++ b/spec/cli_v1_commands.yaml @@ -1,7 +1,7 @@ clispec: '001' info: id: cli - version: 1.62.3 + version: 1.75.2 title: Supabase CLI language: sh source: https://github.com/supabase/cli @@ -45,253 +45,6 @@ flags: description: path to a Supabase project directory default_value: '' commands: - - id: supabase-sso - title: supabase sso - summary: Manage SSO authentication for your project - description: |- - Manage SSO authentication for Supabase projects. - tags: - - management-api - links: [] - subcommands: - - supabase-sso-list - - supabase-sso-show - - supabase-sso-add - - supabase-sso-remove - - supabase-sso-update - - supabase-sso-info - flags: - - id: project-ref - name: --project-ref - description: Project ref of the Supabase project. - default_value: '' - type: string - required: true - inherit: true - - id: output - name: --output [ pretty | json | yaml | toml ] - description: Output format of the command, useful when using the CLI in scripts or CI workflows. - default_value: pretty - type: '[ pretty | json | yaml | toml ]' - inherit: true - accepted_values: - - name: pretty - value: pretty - type: '[ pretty | json | yaml | toml ]' - - name: json - value: json - type: '[ pretty | json | yaml | toml ]' - - name: yaml - value: yaml - type: '[ pretty | json | yaml | toml ]' - - name: toml - value: toml - type: '[ pretty | json | yaml | toml ]' - description: |- - Use only with certain commands to output the raw SAML 2.0 Metadata XML stored in your project's authentication configuration. - - id: supabase-sso-info - title: supabase sso info - summary: Show important SAML 2.0 information about your project - description: |- - Returns all of the important SSO information necessary for your project to be registered with a SAML 2.0 compatible identity provider. - tags: - - management-api - flags: [] - links: [] - examples: - - id: basic-usage - name: Show project information - code: |- - supabase sso info --project-ref abcdefghijklmnopqrst - response: |- - Information about your project's SAML 2.0 configuration. - - id: supabase-sso-add - title: supabase sso add - summary: Add a connection to a SSO identity provider - description: |- - Add and configure a new connection to a SSO identity provider to your Supabase project. - tags: - - management-api - examples: - - id: basic-usage - name: Add with Metadata URL - code: |- - supabase sso add \ - --project-ref abcdefgijklmnopqrst \ - --type saml \ - --metadata-url 'https://...' \ - --domains company.com - response: |- - Information about the added identity provider. You can use - company.com as the domain name on the frontend side to initiate a SSO - request to the identity provider. - - id: with-xml - name: Add with Metadata File - code: |- - supabase sso add \ - --project-ref abcdefgijklmnopqrst \ - --type saml \ - --metadata-file /path/to/metadata/file.xml \ - --domains company.com - response: |- - Information about the added identity provider. You can use - company.com as the domain name on the frontend side to initiate a SSO - request to the identity provider. - flags: - - id: type - name: --type saml - description: |- - Type of identity provider being added. Only SAML 2.0 supported at this time. - type: string - accepted_values: - - name: saml - type: string - required: true - - id: metadata-url - name: --metadata-url - description: |- - A URL (typically HTTPS) that points to a SAML 2.0 Metadata XML document describing the SAML features supported by the identity provider being added. - Mutually exclusive with --metadata-file. - type: string - required: true - - id: metadata-file - name: --metadata-file - description: | - A path to a file containing a SAML 2.0 Metadata XML document describing the SAML features supported by the identity provider being added. Prefer using --metadata-url if possible. - - Mutually exclusive with --metadata-url. - type: string - required: true - - id: domains - name: domains - description: |- - A comma separated list of email domains to associate with this identity provider. Used to quickly sign users in on the frontend by identifying the correct identity provider based on the email address of the user. - type: string - - id: attribute-mapping-file - name: attribute-mapping-file - type: string - description: |- - A file containing a JSON document describing the attribute mappings attached to this identity provider. User information contained in the SAML assertion will be mapped according to the rules laid out in this document. - - id: supabase-sso-remove - title: supabase sso remove - summary: Remove a connection to a SSO identity provider - description: |- - Remove a connection to an already added SSO identity provider. Removing the provider will prevent existing users from logging in. Please treat this command with care. - tags: - - management-api - links: [] - flags: [] - examples: - - id: basic-usage - name: Remove a provider - code: |- - supabase sso remove 6df4d73f-bf21-405f-a084-b11adf19fea5 \ - --project-ref abcdefghijklmnopqrst - response: |- - Information about the removed identity provider. It's a good idea to - save this in case you need it later on. - - id: supabase-sso-update - title: supabase sso update - summary: Update the configuration of an identity provider - description: |- - Update the configuration settings of a already added SSO identity provider. - tags: - - management-api - links: [] - examples: - - id: basic-usage - name: Replace domains - code: |- - supabase sso update 6df4d73f-bf21-405f-a084-b11adf19fea5 \ - --project-ref abcdefghijklmnopqrst \ - --domains new-company.com,new-company.net - response: |- - Information about the updated provider. - - id: add-domains - name: Add an additional domain - code: |- - supabase sso update 6df4d73f-bf21-405f-a084-b11adf19fea5 \ - --project-ref abcdefghijklmnopqrst \ - --add-domains company.net - response: |- - Information about the updated provider. - - id: remove-domains - name: Remove a domain - code: |- - supabase sso update 6df4d73f-bf21-405f-a084-b11adf19fea5 \ - --project-ref abcdefghijklmnopqrst \ - --remove-domains company.org - response: |- - Information about the updated provider. - flags: - - id: metadata-url - name: --metadata-url - description: |- - A URL (typically HTTPS) that points to a SAML 2.0 Metadata XML document describing the SAML features supported by the identity provider being added. - Mutually exclusive with --metadata-file. - type: string - - id: metadata-file - name: --metadata-file - description: | - A path to a file containing a SAML 2.0 Metadata XML document describing the SAML features supported by the identity provider being added. Prefer using --metadata-url if possible. - - Mutually exclusive with --metadata-url. - type: string - - id: domains - name: domains - description: |- - A comma separated list of email domains to associate with this identity provider. Used to quickly sign users in on the frontend by identifying the correct identity provider based on the email address of the user. When provided all of the domains already attached to the identity provider will be replaced with this list. - type: string - - id: add-domains - name: add-domains - description: |- - Associates these additional email domains to the existing set of domains. See --domains for a detailed explanation. - - id: remove-domains - name: remove-domains - description: |- - Removes these email domains from the existing set of domains. See --domains for a detailed explanation. - - id: attribute-mapping-file - name: attribute-mapping-file - type: string - description: |- - A file containing a JSON document describing the attribute mappings attached to this identity provider. User information contained in the SAML assertion will be mapped according to the rules laid out in this document. - - id: supabase-sso-list - title: supabase sso list - summary: Lists all connections to identity providers - description: |- - List all connections to a SSO identity provider to your Supabase project. - tags: - - management-api - links: [] - flags: [] - - id: supabase-sso-show - title: supabase sso show - summary: Show information about an identity provider. - description: |- - Provides the information about an established connection to an identity provider. You can use --metadata to obtain the raw SAML 2.0 Metadata XML document stored in your project's configuration. - tags: - - management-api - examples: - - id: basic-usage - name: Show information - code: |- - supabase sso show 6df4d73f-bf21-405f-a084-b11adf19fea5 \ - --project-ref abcdefghijklmnopqrst - response: |- - Information about the identity provider in pretty output. - - id: metadata-output - name: Get raw SAML 2.0 Metadata XML - code: |- - supabase sso show 6df4d73f-bf21-405f-a084-b11adf19fea5 \ - --project-ref abcdefghijklmnopqrst \ - --metadata - response: |- - Raw SAML 2.0 XML assigned to this identity provider. This is the - version used in the authentication project, and if using a SAML 2.0 - Metadata URL it may change depending on the caching information - contained within the metadata. - links: [] - flags: [] - id: supabase-vanity-subdomains title: supabase vanity-subdomains summary: Manage vanity subdomains for Supabase projects @@ -455,7 +208,7 @@ commands: Requires `supabase/config.toml` to be created in your current working directory by running `supabase init`. - All Docker resources are cleaned up by default. Use `--backup` flag to persist your local development data between restarts. + All Docker resources are maintained across restarts. Use `--no-backup` flag to reset your local development data between restarts. examples: - id: basic-usage name: Basic usage @@ -607,6 +360,359 @@ commands: name: --ignore-health-check description: Ignore unhealthy services and exit 0 default_value: 'false' + - id: supabase-sso + title: supabase sso + summary: Manage Single Sign-On (SSO) authentication for projects + tags: + - management-api + links: [] + subcommands: + - supabase-sso-add + - supabase-sso-info + - supabase-sso-list + - supabase-sso-remove + - supabase-sso-show + - supabase-sso-update + flags: [] + - id: supabase-sso-update + title: supabase sso update + summary: Update information about an SSO identity provider + description: | + Update the configuration settings of a already added SSO identity provider. + examples: + - id: basic-usage + name: Replace domains + code: |- + supabase sso update 6df4d73f-bf21-405f-a084-b11adf19fea5 \ + --project-ref abcdefghijklmnopqrst \ + --domains new-company.com,new-company.net + response: Information about the updated provider. + - id: add-domains + name: Add an additional domain + code: |- + supabase sso update 6df4d73f-bf21-405f-a084-b11adf19fea5 \ + --project-ref abcdefghijklmnopqrst \ + --add-domains company.net + response: Information about the updated provider. + - id: remove-domains + name: Remove a domain + code: |- + supabase sso update 6df4d73f-bf21-405f-a084-b11adf19fea5 \ + --project-ref abcdefghijklmnopqrst \ + --remove-domains company.org + response: Information about the updated provider. + tags: [] + links: [] + usage: supabase sso update [flags] + subcommands: [] + flags: + - id: add-domains + name: --add-domains + description: | + Add this comma separated list of email domains to the identity provider. + default_value: '[]' + - id: attribute-mapping-file + name: --attribute-mapping-file + description: | + File containing a JSON mapping between SAML attributes to custom JWT claims. + default_value: '' + - id: domains + name: --domains + description: | + Replace domains with this comma separated list of email domains. + default_value: '[]' + - id: metadata-file + name: --metadata-file + description: | + File containing a SAML 2.0 Metadata XML document describing the identity provider. + default_value: '' + - id: metadata-url + name: --metadata-url + description: | + URL pointing to a SAML 2.0 Metadata XML document describing the identity provider. + default_value: '' + - id: remove-domains + name: --remove-domains + description: | + Remove this comma separated list of email domains from the identity provider. + default_value: '[]' + - id: skip-url-validation + name: --skip-url-validation + description: | + Whether local validation of the SAML 2.0 Metadata URL should not be performed. + default_value: 'false' + - id: output + name: -o, --output <[ pretty | json | toml | yaml ]> + description: Output format + default_value: pretty + accepted_values: + - id: pretty + name: pretty + type: '[ pretty | json | toml | yaml ]' + - id: json + name: json + type: '[ pretty | json | toml | yaml ]' + - id: toml + name: toml + type: '[ pretty | json | toml | yaml ]' + - id: yaml + name: yaml + type: '[ pretty | json | toml | yaml ]' + - id: project-ref + name: --project-ref + description: Project ref of the Supabase project. + default_value: '' + - id: supabase-sso-show + title: supabase sso show + summary: Show information about an SSO identity provider + description: | + Provides the information about an established connection to an identity provider. You can use --metadata to obtain the raw SAML 2.0 Metadata XML document stored in your project's configuration. + examples: + - id: basic-usage + name: Show information + code: |- + supabase sso show 6df4d73f-bf21-405f-a084-b11adf19fea5 \ + --project-ref abcdefghijklmnopqrst + response: Information about the identity provider in pretty output. + - id: metadata-output + name: Get raw SAML 2.0 Metadata XML + code: |- + supabase sso show 6df4d73f-bf21-405f-a084-b11adf19fea5 \ + --project-ref abcdefghijklmnopqrst \ + --metadata + response: |- + Raw SAML 2.0 XML assigned to this identity provider. This is the + version used in the authentication project, and if using a SAML 2.0 + Metadata URL it may change depending on the caching information + contained within the metadata. + tags: [] + links: [] + usage: supabase sso show [flags] + subcommands: [] + flags: + - id: metadata + name: --metadata + description: Show SAML 2.0 XML Metadata only + default_value: 'false' + - id: output + name: -o, --output <[ pretty | json | toml | yaml ]> + description: Output format + default_value: pretty + accepted_values: + - id: pretty + name: pretty + type: '[ pretty | json | toml | yaml ]' + - id: json + name: json + type: '[ pretty | json | toml | yaml ]' + - id: toml + name: toml + type: '[ pretty | json | toml | yaml ]' + - id: yaml + name: yaml + type: '[ pretty | json | toml | yaml ]' + - id: project-ref + name: --project-ref + description: Project ref of the Supabase project. + default_value: '' + - id: supabase-sso-remove + title: supabase sso remove + summary: Remove an existing SSO identity provider + description: | + Remove a connection to an already added SSO identity provider. Removing the provider will prevent existing users from logging in. Please treat this command with care. + examples: + - id: basic-usage + name: Remove a provider + code: |- + supabase sso remove 6df4d73f-bf21-405f-a084-b11adf19fea5 \ + --project-ref abcdefghijklmnopqrst + response: |- + Information about the removed identity provider. It's a good idea to + save this in case you need it later on. + tags: [] + links: [] + usage: supabase sso remove + subcommands: [] + flags: + - id: output + name: -o, --output <[ pretty | json | toml | yaml ]> + description: Output format + default_value: pretty + accepted_values: + - id: pretty + name: pretty + type: '[ pretty | json | toml | yaml ]' + - id: json + name: json + type: '[ pretty | json | toml | yaml ]' + - id: toml + name: toml + type: '[ pretty | json | toml | yaml ]' + - id: yaml + name: yaml + type: '[ pretty | json | toml | yaml ]' + - id: project-ref + name: --project-ref + description: Project ref of the Supabase project. + default_value: '' + - id: supabase-sso-list + title: supabase sso list + summary: List all SSO identity providers for a project + description: | + List all connections to a SSO identity provider to your Supabase project. + tags: [] + links: [] + usage: supabase sso list + subcommands: [] + flags: + - id: output + name: -o, --output <[ pretty | json | toml | yaml ]> + description: Output format + default_value: pretty + accepted_values: + - id: pretty + name: pretty + type: '[ pretty | json | toml | yaml ]' + - id: json + name: json + type: '[ pretty | json | toml | yaml ]' + - id: toml + name: toml + type: '[ pretty | json | toml | yaml ]' + - id: yaml + name: yaml + type: '[ pretty | json | toml | yaml ]' + - id: project-ref + name: --project-ref + description: Project ref of the Supabase project. + default_value: '' + - id: supabase-sso-info + title: supabase sso info + summary: | + Returns the SAML SSO settings required for the identity provider + description: | + Returns all of the important SSO information necessary for your project to be registered with a SAML 2.0 compatible identity provider. + examples: + - id: basic-usage + name: Show project information + code: supabase sso info --project-ref abcdefghijklmnopqrst + response: Information about your project's SAML 2.0 configuration. + tags: [] + links: [] + usage: supabase sso info + subcommands: [] + flags: + - id: output + name: -o, --output <[ pretty | json | toml | yaml ]> + description: Output format + default_value: pretty + accepted_values: + - id: pretty + name: pretty + type: '[ pretty | json | toml | yaml ]' + - id: json + name: json + type: '[ pretty | json | toml | yaml ]' + - id: toml + name: toml + type: '[ pretty | json | toml | yaml ]' + - id: yaml + name: yaml + type: '[ pretty | json | toml | yaml ]' + - id: project-ref + name: --project-ref + description: Project ref of the Supabase project. + default_value: '' + - id: supabase-sso-add + title: supabase sso add + summary: Add a new SSO identity provider + description: | + Add and configure a new connection to a SSO identity provider to your Supabase project. + examples: + - id: basic-usage + name: Add with Metadata URL + code: |- + supabase sso add \ + --project-ref abcdefgijklmnopqrst \ + --type saml \ + --metadata-url 'https://...' \ + --domains company.com + response: |- + Information about the added identity provider. You can use + company.com as the domain name on the frontend side to initiate a SSO + request to the identity provider. + - id: with-xml + name: Add with Metadata File + code: |- + supabase sso add \ + --project-ref abcdefgijklmnopqrst \ + --type saml \ + --metadata-file /path/to/metadata/file.xml \ + --domains company.com + response: |- + Information about the added identity provider. You can use + company.com as the domain name on the frontend side to initiate a SSO + request to the identity provider. + tags: [] + links: [] + usage: supabase sso add [flags] + subcommands: [] + flags: + - id: attribute-mapping-file + name: --attribute-mapping-file + description: | + File containing a JSON mapping between SAML attributes to custom JWT claims. + default_value: '' + - id: domains + name: --domains + description: | + Comma separated list of email domains to associate with the added identity provider. + default_value: '[]' + - id: metadata-file + name: --metadata-file + description: | + File containing a SAML 2.0 Metadata XML document describing the identity provider. + default_value: '' + - id: metadata-url + name: --metadata-url + description: | + URL pointing to a SAML 2.0 Metadata XML document describing the identity provider. + default_value: '' + - id: skip-url-validation + name: --skip-url-validation + description: | + Whether local validation of the SAML 2.0 Metadata URL should not be performed. + default_value: 'false' + - id: type + name: -t, --type <[ saml ]> + description: Type of identity provider (according to supported protocol). + required: true + default_value: '' + accepted_values: + - id: saml + name: saml + type: '[ saml ]' + - id: output + name: -o, --output <[ pretty | json | toml | yaml ]> + description: Output format + default_value: pretty + accepted_values: + - id: pretty + name: pretty + type: '[ pretty | json | toml | yaml ]' + - id: json + name: json + type: '[ pretty | json | toml | yaml ]' + - id: toml + name: toml + type: '[ pretty | json | toml | yaml ]' + - id: yaml + name: yaml + type: '[ pretty | json | toml | yaml ]' + - id: project-ref + name: --project-ref + description: Project ref of the Supabase project. + default_value: '' - id: supabase-ssl-enforcement title: supabase ssl-enforcement summary: Manage SSL enforcement configuration @@ -722,6 +828,7 @@ commands: - management-api links: [] subcommands: + - supabase-projects-api-keys - supabase-projects-create - supabase-projects-list flags: [] @@ -812,6 +919,74 @@ commands: - id: us-west-2 name: us-west-2 type: string + - id: supabase-projects-api-keys + title: supabase projects api-keys + summary: List all API keys for a Supabase project + tags: [] + links: [] + usage: supabase projects api-keys [flags] + subcommands: [] + flags: + - id: project-ref + name: --project-ref + description: Project ref of the Supabase project. + default_value: '' + - id: supabase-postgres-config + title: supabase postgres-config + summary: Manage Postgres database config + tags: + - management-api + links: [] + subcommands: + - supabase-postgres-config-get + - supabase-postgres-config-update + flags: [] + - id: supabase-postgres-config-update + title: supabase postgres-config update + summary: Update Postgres database config + description: |- + Overriding the default Postgres config could result in unstable database behavior. + Custom configuration also overrides the optimizations generated based on the compute add-ons in use. + tags: [] + links: [] + usage: supabase postgres-config update [flags] + subcommands: [] + flags: + - id: config + name: --config + description: Config overrides specified as a 'key=value' pair + default_value: '[]' + - id: replace-existing-overrides + name: --replace-existing-overrides + description: | + If true, replaces all existing overrides with the ones provided. If false (default), merges existing overrides with the ones provided. + default_value: 'false' + - id: experimental + name: --experimental + description: enable experimental features + required: true + default_value: 'false' + - id: project-ref + name: --project-ref + description: Project ref of the Supabase project. + default_value: '' + - id: supabase-postgres-config-get + title: supabase postgres-config get + summary: Get the current Postgres database config overrides + tags: [] + links: [] + usage: supabase postgres-config get + subcommands: [] + flags: + - id: experimental + name: --experimental + description: enable experimental features + required: true + default_value: 'false' + - id: project-ref + name: --project-ref + description: Project ref of the Supabase project. + default_value: '' - id: supabase-orgs title: supabase orgs summary: Manage Supabase organizations @@ -819,6 +994,7 @@ commands: - management-api links: [] subcommands: + - supabase-orgs-create - supabase-orgs-list flags: [] - id: supabase-orgs-list @@ -830,6 +1006,15 @@ commands: usage: supabase orgs list subcommands: [] flags: [] + - id: supabase-orgs-create + title: supabase orgs create + summary: Create an organization + description: Create an organization for the logged-in user. + tags: [] + links: [] + usage: supabase orgs create + subcommands: [] + flags: [] - id: supabase-network-restrictions title: supabase network-restrictions summary: Manage network restrictions @@ -944,6 +1129,7 @@ commands: - supabase-migration-list - supabase-migration-new - supabase-migration-repair + - supabase-migration-squash - supabase-migration-up flags: [] - id: supabase-migration-up @@ -954,6 +1140,30 @@ commands: usage: supabase migration up subcommands: [] flags: [] + - id: supabase-migration-squash + title: supabase migration squash + summary: Squash migrations to a single file + tags: [] + links: [] + usage: supabase migration squash [flags] + subcommands: [] + flags: + - id: db-url + name: --db-url + description: connect using the specified database url + default_value: '' + - id: linked + name: --linked + description: Update migration history of the linked project. + default_value: 'false' + - id: password + name: -p, --password + description: Password to your remote Postgres database. + default_value: '' + - id: version + name: --version + description: Squash up to the specified version. + default_value: '' - id: supabase-migration-repair title: supabase migration repair summary: Repair the migration history table @@ -1176,6 +1386,53 @@ commands: description: Project ref of the Supabase project. required: true default_value: '' + - id: supabase-inspect + title: supabase inspect + summary: Tools to inspect your Supabase Database + tags: + - local-dev + links: [] + subcommands: + - supabase-inspect-db:cache-hit + - supabase-inspect-db:index-usage + - supabase-inspect-db:replication-slots + flags: [] + - id: supabase-inspect-db:replication-slots + title: supabase inspect db:replication-slots + summary: Shows information about replication slots on the database + tags: [] + links: [] + usage: supabase inspect db:replication-slots + subcommands: [] + flags: + - id: db-url + name: --db-url + description: connect using the specified database url + default_value: '' + - id: supabase-inspect-db:index-usage + title: supabase inspect db:index-usage + summary: Shows information about the efficiency of indexes + tags: [] + links: [] + usage: supabase inspect db:index-usage + subcommands: [] + flags: + - id: db-url + name: --db-url + description: connect using the specified database url + default_value: '' + - id: supabase-inspect-db:cache-hit + title: supabase inspect db:cache-hit + summary: Shows cache hit rates for tables and indices + tags: [] + links: [] + usage: supabase inspect db:cache-hit + subcommands: [] + flags: + - id: db-url + name: --db-url + description: connect using the specified database url + default_value: '' - id: supabase-init title: supabase init summary: Initialize a local project @@ -1200,9 +1457,13 @@ commands: tags: - local-dev links: [] - usage: supabase init + usage: supabase init [flags] subcommands: [] - flags: [] + flags: + - id: with-vscode-workspace + name: --with-vscode-workspace + description: Generate VS Code workspace. + default_value: 'false' - id: supabase-gen title: supabase gen summary: Run code generation tools @@ -1299,6 +1560,7 @@ commands: - supabase-functions-delete - supabase-functions-deploy - supabase-functions-download + - supabase-functions-list - supabase-functions-new - supabase-functions-serve flags: [] @@ -1307,7 +1569,7 @@ commands: summary: Serve all Functions locally tags: [] links: [] - usage: supabase functions serve [flags] + usage: supabase functions serve [flags] subcommands: [] flags: - id: env-file @@ -1331,6 +1593,19 @@ commands: usage: supabase functions new subcommands: [] flags: [] + - id: supabase-functions-list + title: supabase functions list + summary: List all Functions in Supabase + description: List all Functions in the linked Supabase project. + tags: [] + links: [] + usage: supabase functions list [flags] + subcommands: [] + flags: + - id: project-ref + name: --project-ref + description: Project ref of the Supabase project. + default_value: '' - id: supabase-functions-download title: supabase functions download summary: Download a Function from Supabase @@ -1762,6 +2037,10 @@ commands: name: --role-only description: Dumps only cluster roles. default_value: 'false' + - id: use-copy + name: --use-copy + description: Uses copy statements in place of inserts. + default_value: 'false' - id: db-url name: --db-url description: connect using the specified database url diff --git a/spec/common-cli-sections.json b/spec/common-cli-sections.json index a1dd8615255..ba3c23a319f 100644 --- a/spec/common-cli-sections.json +++ b/spec/common-cli-sections.json @@ -191,6 +191,12 @@ "slug": "supabase-migration-repair", "type": "cli-command" }, + { + "id": "supabase-migration-squash", + "title": "Squash migrations to a single file", + "slug": "supabase-migration-squash", + "type": "cli-command" + }, { "id": "supabase-migration-up", "title": "Apply pending migrations locally", @@ -199,6 +205,36 @@ } ] }, + { + "type": "category", + "title": "Inspect", + "items": [ + { + "id": "supabase-inspect", + "title": "Inspect database statistics", + "slug": "supabase-inspect", + "type": "cli-command" + }, + { + "id": "supabase-inspect-db:cache-hit", + "title": "Show cache hit rates for tables and indexes", + "slug": "supabase-inspect-db:cache-hit", + "type": "cli-command" + }, + { + "id": "supabase-inspect-db:index-usage", + "title": "Show usage efficiency of indexes", + "slug": "supabase-inspect-db:index-usage", + "type": "cli-command" + }, + { + "id": "supabase-inspect-db:replication-slots", + "title": "Show information about replication slots", + "slug": "supabase-inspect-db:replication-slots", + "type": "cli-command" + } + ] + }, { "type": "category", "title": "Projects", @@ -220,6 +256,12 @@ "title": "List all projects", "slug": "supabase-projects-list", "type": "cli-command" + }, + { + "id": "supabase-projects-api-keys", + "title": "Show API keys for a Supabase project", + "slug": "supabase-projects-api-keys", + "type": "cli-command" } ] }, @@ -257,6 +299,12 @@ "slug": "supabase-functions-new", "type": "cli-command" }, + { + "id": "supabase-functions-list", + "title": "List all functions", + "slug": "supabase-functions-list", + "type": "cli-command" + }, { "id": "supabase-functions-download", "title": "Download a function", @@ -323,6 +371,12 @@ "slug": "supabase-sso", "type": "cli-command" }, + { + "id": "supabase-sso-add", + "title": "Add an identity provider", + "slug": "supabase-sso-add", + "type": "cli-command" + }, { "id": "supabase-sso-list", "title": "List all identity providers", @@ -331,14 +385,14 @@ }, { "id": "supabase-sso-show", - "title": "Show information for an identity providers", + "title": "Show information for an identity provider", "slug": "supabase-sso-show", "type": "cli-command" }, { - "id": "supabase-sso-add", - "title": "Add an identity provider", - "slug": "supabase-sso-add", + "id": "supabase-sso-info", + "title": "See your project's SSO information", + "slug": "supabase-sso-info", "type": "cli-command" }, { @@ -352,12 +406,6 @@ "title": "Remove an identity provider", "slug": "supabase-sso-remove", "type": "cli-command" - }, - { - "id": "supabase-sso-info", - "title": "See your project's SSO information", - "slug": "supabase-sso-info", - "type": "cli-command" } ] }, @@ -493,24 +541,48 @@ "items": [ { "id": "supabase-ssl-enforcement", - "title": "Manage SSL enforcement configuration", + "title": "Manage SSL enforcement configurations", "slug": "supabase-ssl-enforcement", "type": "cli-command" }, { "id": "supabase-ssl-enforcement-get", - "title": "Get the current SSL enforcement configuration", + "title": "Get the current SSL enforcement configurations", "slug": "supabase-ssl-enforcement-get", "type": "cli-command" }, { "id": "supabase-ssl-enforcement-update", - "title": "Update SSL enforcement configuration", + "title": "Update SSL enforcement configurations", "slug": "supabase-ssl-enforcement-update", "type": "cli-command" } ] }, + { + "type": "category", + "title": "Postgres Config", + "items": [ + { + "id": "supabase-postgres-config", + "title": "Manage Postgres configurations", + "slug": "supabase-ssl-enforcement", + "type": "cli-command" + }, + { + "id": "supabase-postgres-config-get", + "title": "Get the current Postgres config overrides", + "slug": "supabase-postgres-config-get", + "type": "cli-command" + }, + { + "id": "supabase-postgres-config-update", + "title": "Update Postgres configurations", + "slug": "supabase-postgres-config-update", + "type": "cli-command" + } + ] + }, { "type": "category", "title": "Autocompletion Scripts", diff --git a/spec/common-client-libs-sections.json b/spec/common-client-libs-sections.json index d3b7525192c..a9dc70aee0a 100644 --- a/spec/common-client-libs-sections.json +++ b/spec/common-client-libs-sections.json @@ -343,7 +343,7 @@ }, { "id": "single", - "title": "Retrieve the query as one row", + "title": "Retrieve one row of data", "slug": "single", "product": "database", "parent": "modifiers", @@ -351,7 +351,7 @@ }, { "id": "maybe-single", - "title": "Retrieve the query as 0-1 rows", + "title": "Retrieve zero or one row of data", "slug": "maybesingle", "product": "database", "parent": "modifiers", @@ -359,7 +359,7 @@ }, { "id": "csv", - "title": "Retrieve the query as a CSV string", + "title": "Retrieve as a CSV", "slug": "db-csv", "product": "database", "parent": "modifiers", @@ -715,11 +715,11 @@ }, { "type": "category", - "title": "Functions", + "title": "Edge Functions", "items": [ { "id": "invoke", - "title": "Invoke a function", + "title": "Invokes a Supabase Edge Function.", "slug": "functions-invoke", "product": "functions", "type": "function" diff --git a/spec/examples/examples.yml b/spec/examples/examples.yml index 4960cd5d206..bd034b94223 100644 --- a/spec/examples/examples.yml +++ b/spec/examples/examples.yml @@ -240,7 +240,7 @@ functions: js: | ```js const { data, error } = await supabase.auth.signInWithOAuth({ - provider: 'github' + provider: 'github', options: { redirectTo: 'https://example.com/welcome' } @@ -255,7 +255,7 @@ functions: js: | ```js const { data, error } = await supabase.auth.signInWithOAuth({ - provider: 'github' + provider: 'github', options: { scopes: 'repo gist notifications' } @@ -5406,7 +5406,7 @@ functions: title: 'from.getPublicUrl()' $ref: '@supabase/storage-js.packages/StorageFileApi.default.getPublicUrl' notes: | - - The bucket needs to be set to public, either via [updateBucket()](/docs/reference/javascript/storage-updatebucket) or by going to Storage on [app.supabase.com](https://supabase.com/dashboard), clicking the overflow menu on a bucket and choosing "Make public" + - The bucket needs to be set to public, either via [updateBucket()](/docs/reference/javascript/storage-updatebucket) or by going to Storage on [supabase.com/dashboard](https://supabase.com/dashboard), clicking the overflow menu on a bucket and choosing "Make public" - RLS policy permissions required: - `buckets` table permissions: none - `objects` table permissions: none diff --git a/spec/supabase_csharp_v0.yml b/spec/supabase_csharp_v0.yml index 001beb98c19..51055d4964d 100644 --- a/spec/supabase_csharp_v0.yml +++ b/spec/supabase_csharp_v0.yml @@ -1060,7 +1060,7 @@ functions: Retrieve URLs for assets in public buckets title: 'from.getPublicUrl()' notes: | - - The bucket needs to be set to public, either via [UpdateBucket()](/docs/reference/csharp/storage-updatebucket) or by going to Storage on [app.supabase.com](https://supabase.com/dashboard), clicking the overflow menu on a bucket and choosing "Make public" + - The bucket needs to be set to public, either via [UpdateBucket()](/docs/reference/csharp/storage-updatebucket) or by going to Storage on [supabase.com/dashboard](https://supabase.com/dashboard), clicking the overflow menu on a bucket and choosing "Make public" - Policy permissions required: - `buckets` permissions: none - `objects` permissions: none diff --git a/spec/supabase_dart_v0.yml b/spec/supabase_dart_v0.yml index 5cba1f8eb6e..d44745e7711 100644 --- a/spec/supabase_dart_v0.yml +++ b/spec/supabase_dart_v0.yml @@ -78,10 +78,10 @@ functions: description: | Creates a new user. notes: | - - By default, the user will need to verify their email address before logging in. If you would like to change this, you can disable "Email Confirmations" by going to Authentication -> Settings on [app.supabase.com](https://supabase.com/dashboard) + - By default, the user will need to verify their email address before logging in. If you would like to change this, you can disable "Email Confirmations" by going to Authentication -> Settings on [supabase.com/dashboard](https://supabase.com/dashboard) - If "Email Confirmations" is turned on, a user is returned but session will be null - If "Email Confirmations" is turned off, both a `user` and a `session` will be returned - - When the user confirms their email address, they will be redirected to localhost:3000 by default. To change this, you can go to Authentication -> Settings on [app.supabase.com](https://supabase.com/dashboard) + - When the user confirms their email address, they will be redirected to localhost:3000 by default. To change this, you can go to Authentication -> Settings on [supabase.com/dashboard](https://supabase.com/dashboard) examples: - id: sign-up name: Sign up. @@ -106,7 +106,7 @@ functions: notes: | - A user can sign up via email, phone number. - If you provide `email` without a `password`, the user will be sent a magic link. - - The magic link's destination URL is determined by the SITE_URL config variable. To change this, you can go to Authentication -> Settings on [app.supabase.com](https://supabase.com/dashboard) + - The magic link's destination URL is determined by the SITE_URL config variable. To change this, you can go to Authentication -> Settings on [supabase.com/dashboard](https://supabase.com/dashboard) - Similarly, if you provide `phone` without a `password`, the user will be sent a one time password. - If you are looking to sign users in with OAuth in Flutter apps, go to [`signInWithProvider()`](/docs/reference/dart/v0/auth-signinwithprovider). examples: @@ -1073,7 +1073,7 @@ functions: Retrieve URLs for assets in public buckets title: 'from.getPublicUrl()' notes: | - - The bucket needs to be set to public, either via [updateBucket()](/docs/reference/javascript/storage-updatebucket) or by going to Storage on [app.supabase.com](https://supabase.com/dashboard), clicking the overflow menu on a bucket and choosing "Make public" + - The bucket needs to be set to public, either via [updateBucket()](/docs/reference/javascript/storage-updatebucket) or by going to Storage on [supabase.com/dashboard](https://supabase.com/dashboard), clicking the overflow menu on a bucket and choosing "Make public" - Policy permissions required: - `buckets` permissions: none - `objects` permissions: none diff --git a/spec/supabase_dart_v1.yml b/spec/supabase_dart_v1.yml index 5b1cfcece67..a79cf7c84b5 100644 --- a/spec/supabase_dart_v1.yml +++ b/spec/supabase_dart_v1.yml @@ -1216,7 +1216,7 @@ functions: Retrieve URLs for assets in public buckets title: 'from.getPublicUrl()' notes: | - - The bucket needs to be set to public, either via [updateBucket()](/docs/reference/javascript/storage-updatebucket) or by going to Storage on [app.supabase.com](https://supabase.com/dashboard), clicking the overflow menu on a bucket and choosing "Make public" + - The bucket needs to be set to public, either via [updateBucket()](/docs/reference/javascript/storage-updatebucket) or by going to Storage on [supabase.com/dashboard](https://supabase.com/dashboard), clicking the overflow menu on a bucket and choosing "Make public" - Policy permissions required: - `buckets` permissions: none - `objects` permissions: none diff --git a/spec/supabase_js_v1.yml b/spec/supabase_js_v1.yml index 702c9661638..610839264df 100644 --- a/spec/supabase_js_v1.yml +++ b/spec/supabase_js_v1.yml @@ -82,10 +82,10 @@ functions: title: 'signUp()' $ref: '@supabase/gotrue-js.GoTrueClient.signUp' notes: | - - By default, the user will need to verify their email address before logging in. If you would like to change this, you can disable "Email Confirmations" by going to Authentication -> Settings on [app.supabase.com](https://supabase.com/dashboard) + - By default, the user will need to verify their email address before logging in. If you would like to change this, you can disable "Email Confirmations" by going to Authentication -> Settings on [supabase.com/dashboard](https://supabase.com/dashboard) - If "Email Confirmations" is turned on, a `user` is returned but `session` will be null - If "Email Confirmations" is turned off, both a `user` and a `session` will be returned - - When the user confirms their email address, they will be redirected to localhost:3000 by default. To change this, you can go to Authentication -> Settings on [app.supabase.com](https://supabase.com/dashboard) + - When the user confirms their email address, they will be redirected to localhost:3000 by default. To change this, you can go to Authentication -> Settings on [supabase.com/dashboard](https://supabase.com/dashboard) - If signUp() is called for an existing confirmed user: - If "Enable email confirmations" is enabled on the "Authentication" -> "Settings" page, an obfuscated / fake user object will be returned. - If "Enable email confirmations" is disabled, an error with a message "User already registered" will be returned. @@ -147,7 +147,7 @@ functions: notes: | - A user can sign up either via email or OAuth. - If you provide `email` without a `password`, the user will be sent a magic link. - - The magic link's destination URL is determined by the SITE_URL config variable. To change this, you can go to Authentication -> Settings on [app.supabase.com](https://supabase.com/dashboard) + - The magic link's destination URL is determined by the SITE_URL config variable. To change this, you can go to Authentication -> Settings on [supabase.com/dashboard](https://supabase.com/dashboard) - Specifying a `provider` will open the browser to the relevant login page. examples: - id: sign-in-with-email-and-password @@ -707,7 +707,7 @@ functions: - id: invoke title: 'invoke()' description: | - Invokes a Supabase Function. + Invokes a Supabase Edge Function. $ref: '@supabase/functions-js.FunctionsClient.invoke' notes: | - Requires an Authorization header. @@ -736,7 +736,7 @@ functions: - id: passing-custom-headers name: Passing custom headers. description: | - You can pass custom headers to your function. Note: supabase-js automatically passes the `Authorization` header with the signed in user's JWT. + You can pass custom headers to your Edge Function. Note: supabase-js automatically passes the `Authorization` header with the signed in user's JWT. isSpotlight: true code: | ```js @@ -1521,7 +1521,7 @@ functions: title: 'from.getPublicUrl()' $ref: '@supabase/storage-js."lib/StorageFileApi".StorageFileApi.getPublicUrl' notes: | - - The bucket needs to be set to public, either via [updateBucket()](/docs/reference/javascript/storage-updatebucket) or by going to Storage on [app.supabase.com](https://supabase.com/dashboard), clicking the overflow menu on a bucket and choosing "Make public" + - The bucket needs to be set to public, either via [updateBucket()](/docs/reference/javascript/storage-updatebucket) or by going to Storage on [supabase.com/dashboard](https://supabase.com/dashboard), clicking the overflow menu on a bucket and choosing "Make public" - Policy permissions required: - `buckets` permissions: none - `objects` permissions: none diff --git a/spec/supabase_js_v2.yml b/spec/supabase_js_v2.yml index 81d284826f8..7c05933ffeb 100644 --- a/spec/supabase_js_v2.yml +++ b/spec/supabase_js_v2.yml @@ -297,7 +297,7 @@ functions: code: | ```js const { data, error } = await supabase.auth.signInWithOAuth({ - provider: 'github' + provider: 'github', options: { redirectTo: 'https://example.com/welcome' } @@ -312,7 +312,7 @@ functions: code: | ```js const { data, error } = await supabase.auth.signInWithOAuth({ - provider: 'github' + provider: 'github', options: { scopes: 'repo gist notifications' } @@ -4273,7 +4273,7 @@ functions: - id: invoke title: invoke() description: | - Invoke a Supabase Function. + Invoke a Supabase Edge Function. $ref: '@supabase/functions-js.FunctionsClient.invoke' notes: | - Requires an Authorization header. @@ -4329,13 +4329,42 @@ functions: body: { foo: 'bar' } }) ``` + - id: calling-with-delete-verb + name: Calling with DELETE HTTP verb + description: | + You can also set the HTTP verb to `DELETE` when calling your Edge Function. + isSpotlight: true + code: | + ```js + const { data, error } = await supabase.functions.invoke('hello', { + headers: { + "my-custom-header": 'my-custom-header-value' + }, + body: { foo: 'bar' }, + method: 'DELETE' + }) + ``` + - id: calling-with-get-verb + name: Calling with GET HTTP verb + description: | + You can also set the HTTP verb to `GET` when calling your Edge Function. + isSpotlight: true + code: | + ```js + const { data, error } = await supabase.functions.invoke('hello', { + headers: { + "my-custom-header": 'my-custom-header-value' + }, + method: 'GET' + }) + ``` - id: subscribe title: on().subscribe() $ref: '@supabase/realtime-js.RealtimeChannel.on' notes: | - By default, Broadcast and Presence are enabled for all projects. - - By default, listening to database changes is disabled for new projects due to database performance and security concerns. You can turn it on by managing Realtime's [replication](/docs/guides/database/api#realtime-api-overview). + - By default, listening to database changes is disabled for new projects due to database performance and security concerns. You can turn it on by managing Realtime's [replication](/docs/guides/api#realtime-api-overview). - You can receive the "previous" data for updates and deletes by setting the table's `REPLICA IDENTITY` to `FULL` (e.g., `ALTER TABLE your_table REPLICA IDENTITY FULL;`). - Row level security is not applied to delete statements. When RLS is enabled and replica identity is set to full, only the primary key is sent to clients. examples: @@ -4890,7 +4919,7 @@ functions: title: from.getPublicUrl() $ref: '@supabase/storage-js.packages/StorageFileApi.default.getPublicUrl' notes: | - - The bucket needs to be set to public, either via [updateBucket()](/docs/reference/javascript/storage-updatebucket) or by going to Storage on [app.supabase.com](https://supabase.com/dashboard), clicking the overflow menu on a bucket and choosing "Make public" + - The bucket needs to be set to public, either via [updateBucket()](/docs/reference/javascript/storage-updatebucket) or by going to Storage on [supabase.com/dashboard](https://supabase.com/dashboard), clicking the overflow menu on a bucket and choosing "Make public" - RLS policy permissions required: - `buckets` table permissions: none - `objects` table permissions: none diff --git a/spec/supabase_kt_v0.yml b/spec/supabase_kt_v0.yml index ebac308c3fb..e3e0b032dd4 100644 --- a/spec/supabase_kt_v0.yml +++ b/spec/supabase_kt_v0.yml @@ -18,12 +18,53 @@ functions: - id: initializing title: 'Initializing' description: | + ### Create Supabase Client + Independently of which Supabase module you are using, you will need to initialize the main client first and install the module. To create a new client, you can use the `createSupabaseClient` function. When installing a module, you can pass a block to configure it. + ### OAuth and OTP link verification + + [supabase-kt](https://github.com/supabase-community/supabase-kt) provides several platform implementations for OAuth and OTP link verification. \ + **On JVM**, it uses a HTTP Callback Server to receive the session data from a successful OAuth login. + + *Note: OTP link verification such as sign ups are not supported on JVM. You may have to send a verification token rather than a url in your E-Mail. To send the token, rather than a redirect url, you have to change `{{ .ConfirmationURL }}` in your sign up email to `{{ .Token }}`* + + **On Android, IOS & MacOS**, it uses deeplinks. Refer to the guide below on how to setup deeplinks. + **On JS**, it uses the website origin as the callback url. Session importing gets handled automatically. + **Windows & Linux** currently have no default implementation. Feel free to create a PR. + + You always make your own implementation and use `gotrue.parseFragmentAndImportSession(fragment)` to let [supabase-kt](https://github.com/supabase-community/supabase-kt) handle the parsing & importing of the session after receiving a callback. + + ### Configure deeplink callbacks for Authentication + + Deeplinks are supported on Android, IOS and MacOS. + 1. **Setup a deeplink for you app** \ + On Android you may setup a [deeplink](https://developer.android.com/training/app-links/deep-linking) in your Android manifest. \ + On IOS & MacOS you may setup a [url scheme](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app). + 2. **Configure the GoTrue plugin** + You have to set the `host` and the `scheme` in the GoTrue config: + ```kotlin + install(GoTrue) { + host = "deeplink host" + scheme = "deeplink scheme" + } + ``` + 3. **Call platform specific function on startup** \ + On Android: `client.handleDeeplinks(intent)` \ + On IOS/MacOS: `client.handleDeeplinks(url)` + + ### PKCE Authentication flow + suapbase-kt supports the [PKCE authentication flow](https://supabase.com/blog/supabase-auth-sso-pkce). + To use it, you just have to change the `flowType` in the GoTrue configuration: + ```kotlin + install(GoTrue) { + flowType = FlowType.PKCE + } + ``` examples: - id: initialize-client name: Initialize Client @@ -133,7 +174,7 @@ functions: description: | `transferTimeout` - the timeout for uploading and downloading files. Default: `120.seconds` - `resumable.cache` - Interface for storing resumable upload urls. Default: `ResumableCache.Disk` + `resumable.cache` - Interface for storing resumable upload urls. Default: `SettingsResumableCache` `resumable.defaultChunkSize` - The default chunk size for resumable uploads. Supabase currently only supports a chunk size of 6MB, so be careful when changing this value. Default: `6MB` @@ -216,7 +257,7 @@ functions: title: 'Fetch data: select()' notes: | Perform a SELECT query on the table or view. - - When calling a `decode` method, you have to provide a [serializable class](/docs/reference/kotlin/installing#add-kotlinx-serialization-required-for-postgrest-and-realtime-serialization) as the type parameter. + - When calling a `decode` method, you have to provide a [serializable class](/docs/reference/kotlin/installing#serialization) as the type parameter. - You can provide a `Columns` object to select specific columns. - You can provide a [filter](/docs/reference/kotlin/using-filters) block to filter the results examples: @@ -312,7 +353,7 @@ functions: title: 'Create data: insert()' $ref: '@supabase/postgrest-js."lib/PostgrestQueryBuilder".PostgrestQueryBuilder.insert' notes: | - - When calling an `insert` method, you have to provide a [serializable value](/docs/reference/kotlin/installing#add-kotlinx-serialization-required-for-postgrest-and-realtime-serialization). + - When calling an `insert` method, you have to provide a [serializable value](/docs/reference/kotlin/installing#serialization). - By default, every time you run `insert()`, the client library will make a `select` to return the full record. This is convenient, but it can also cause problems if your policies are not configured to allow the `select` operation. If you are using Row Level Security and you are encountering problems, try setting the `returning` param to `Returning.MINIMAL`. @@ -349,7 +390,7 @@ functions: title: 'Modify data: update()' notes: | - `update()` should always be combined with a [filter](/docs/reference/kotlin/using-filters) block to avoid updating all records. - - When calling `insert` or `update`, you have to provide a [serializable value](/docs/reference/kotlin/installing#add-kotlinx-serialization-required-for-postgrest-and-realtime-serialization) in the function parameter. + - When calling `insert` or `update`, you have to provide a [serializable value](/docs/reference/kotlin/installing#serialization) in the function parameter. examples: - id: updating-your-data name: Updating your data @@ -437,7 +478,7 @@ functions: - Primary keys should be included in the data payload in order for an update to work correctly. - Primary keys must be natural, not surrogate. There are however, [workarounds](https://github.com/PostgREST/postgrest/issues/1118) for surrogate primary keys. - If you need to insert new data and update existing data at the same time, use [Postgres triggers](https://github.com/supabase/postgrest-js/issues/173#issuecomment-825124550). - - When calling `insert` or `update`, you have to provide a [serializable value](/docs/reference/kotlin/installing#add-kotlinx-serialization-required-for-postgrest-and-realtime-serialization) in the function parameter. + - When calling `insert` or `update`, you have to provide a [serializable value](/docs/reference/kotlin/installing#serialization) in the function parameter. examples: - id: upsert-your-data name: Upsert your data @@ -508,7 +549,7 @@ functions: That's a fancy way of saying that you can put some logic into your database then call it from anywhere. It's especially useful when the logic rarely changes - like password resets and updates. - - When calling `rpc` with parameters, you have to provide a [serializable value](/docs/reference/kotlin/installing#add-kotlinx-serialization-required-for-postgrest-and-realtime-serialization) in the function parameter. + - When calling `rpc` with parameters, you have to provide a [serializable value](/docs/reference/kotlin/installing#serialization) in the function parameter. examples: - id: call-a-stored-procedure name: Call a stored procedure @@ -2047,12 +2088,7 @@ functions: - If **Confirm email** is enabled, the return value is null and you will be logged in instead. - If **Confirm email** is disabled, the return value is the user and you won't be logged in automatically. - When the user confirms their email address, they are redirected to the [`SITE_URL`](https://supabase.com/docs/reference/auth/config#site_url) by default. You can modify your `SITE_URL` or add additional redirect URLs in [your project](https://supabase.com/dashboard/project/_/auth/url-configuration). - - [supabase-kt](https://github.com/supabase-community/supabase-kt/) has default platform implementations: - - On JVM, you can currently only verify a sign up through the token. To send the token, rather than a redirect url, you have to change `{{ .ConfirmationURL }}` in your sign up email to `{{ .Token }}` - - [deep links](https://developer.android.com/training/app-links/deep-linking) (Android), - - [url schemes](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app) (IOS) - - To enable deeplinking/url scheme support on Android/IOS set the `host` and the `scheme` in the GoTrue Config and call `client.handleDeeplinks(intent)` (Android) and `client.handleDeeplinks(url)` (IOS) on startup - - the website origin (JS) + - To learn how to handle OTP links & OAuth refer to [initializing](/docs/reference/kotlin/initializing) - If signUpWith() is called for an existing confirmed user: - If **Confirm email** is enabled in [your project](https://supabase.com/dashboard/project/_/auth/providers), an obfuscated/fake user object is returned. - If **Confirm email** is disabled, the error message, `User already registered` is returned. @@ -2157,12 +2193,7 @@ functions: - If you're using phone, you can configure whether you want the user to receive a OTP. - The magic link's destination URL is determined by the [`SITE_URL`](/docs/reference/auth/config#site_url). - See [redirect URLs and wildcards](/docs/guides/auth/overview#redirect-urls-and-wildcards) to add additional redirect URLs to your project. - - [supabase-kt](https://github.com/supabase-community/supabase-kt/) has default platform implementations: - - On JVM, you can currently only verify a OTP through the token. To send the token, rather than a redirect url, you have to change `{{ .ConfirmationURL }}` in your OTP email to `{{ .Token }}` - - [deep links](https://developer.android.com/training/app-links/deep-linking) (Android), - - [url schemes](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app) (IOS) - - To enable deeplinking/url scheme support on Android/IOS set the `host` and the `scheme` in the GoTrue Config and call `client.handleDeeplinks(intent)` (Android) and `client.handleDeeplinks(url)` (IOS) on startup - - the website origin (JS) + - To learn how to handle OTP links & OAuth refer to [initializing](/docs/reference/kotlin/initializing) - Magic links and OTPs share the same implementation. To send users a one-time code instead of a magic link, [modify the magic link email template](https://supabase.com/dashboard/project/_/auth/templates) to include `{{ .Token }}` instead of `{{ .ConfirmationURL }}`. examples: - id: sign-in-with-email @@ -2191,12 +2222,7 @@ functions: notes: | - This method is used for signing in using a third-party provider. - Supabase supports many different [third-party providers](https://supabase.com/docs/guides/auth#providers). - - [supabase-kt](https://github.com/supabase-community/supabase-kt/) has default platform implementations: - - HTTP Callback Server (JVM), - - [deep links](https://developer.android.com/training/app-links/deep-linking) (Android), - - [url schemes](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app) (IOS) - - To enable deeplinking/url scheme support on Android/IOS set the `host` and the `scheme` in the GoTrue Config and call `client.handleDeeplinks(intent)` (Android) and `client.handleDeeplinks(url)` (IOS) on startup - - the website origin (JS) + - To learn how to handle OTP links & OAuth refer to [initializing](/docs/reference/kotlin/initializing) examples: - id: sign-in-using-a-third-party-provider name: Sign in using a third-party provider @@ -2249,12 +2275,7 @@ functions: - In case you need to use a different way to start the authentication flow with an identity provider, you can use the `SSO.withProviderId` function. For example: - Mapping specific user email addresses with an identity provider. - Using different hints to identity the identity provider to be used by the user, like a company-specific page, IP address or other tracking information. - - [supabase-kt](https://github.com/supabase-community/supabase-kt/) has default platform implementations: - - HTTP Callback Server (JVM), - - [deep links](https://developer.android.com/training/app-links/deep-linking) (Android), - - [url schemes](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app) (IOS) - - To enable deeplinking/url scheme support on Android/IOS set the `host` and the `scheme` in the GoTrue Config and call `client.handleDeeplinks(intent)` (Android) and `client.handleDeeplinks(url)` (IOS) on startup - - the website origin (JS) + - To learn how to handle OTP links & OAuth refer to [initializing](/docs/reference/kotlin/initializing) examples: - id: sign-in-with-domain @@ -2560,7 +2581,7 @@ functions: - id: auth-mfa-api title: 'Overview' notes: | - This section contains methods commonly used for Multi-Factor Authentication (MFA) and are invoked behind the `supabase.gotrue.mfa` namespace. + This section contains methods commonly used for Multi-Factor Authentication (MFA) and are invoked behind the `client.gotrue.mfa` namespace. Currently, we only support time-based one-time password (TOTP) as the 2nd factor. We don't support recovery codes but we allow users to enroll more than 1 TOTP factor, with an upper limit of 10. @@ -3011,7 +3032,7 @@ functions: title: 'invoke()' description: | Invokes a Supabase Function. See the [guide](/docs/guides/functions) for details on writing Functions. - - When invoking a function with parameters, you have to provide a [serializable value](/docs/reference/kotlin/installing#add-kotlinx-serialization-required-for-postgrest-and-realtime-serialization) in the function parameter. + - When invoking a function with parameters, you have to provide a [serializable value](/docs/reference/kotlin/installing#serialization) in the function parameter. notes: | - Requires an Authorization header. examples: @@ -3066,7 +3087,7 @@ functions: notes: | - Realtime is disabled by default for new Projects for better database performance and security. You can turn it on by [managing replication](/docs/guides/database/api#managing-realtime). - If you want to receive the "previous" data for updates and deletes, you will need to set `REPLICA IDENTITY` to `FULL`, like this: `ALTER TABLE your_table REPLICA IDENTITY FULL;` - - When using a method with a generic type like `track`, `broadcast` or `broadcastFlow`, you have to provide a [serializable class](/docs/reference/kotlin/installing#add-kotlinx-serialization-required-for-postgrest-and-realtime-serialization) as the type parameter. + - When using a method with a generic type like `track`, `broadcast` or `broadcastFlow`, you have to provide a [serializable class](/docs/reference/kotlin/installing#serialization) as the type parameter. examples: - id: connect-to-realtime-client name: Connect to Realtime @@ -3288,15 +3309,29 @@ functions: title: removeAllChannels() $ref: '@supabase/supabase-js.index.SupabaseClient.removeAllChannels' notes: | + Unsubscribes and removes all Realtime channels from Realtime client. - Removing channels is a great way to maintain the performance of your project's Realtime service as well as your database if you're listening to Postgres changes. Supabase will automatically handle cleanup 30 seconds after a client is disconnected, but unused channels may cause degradation as more clients are simultaneously subscribed. examples: - id: remove-all-channels name: Remove all channels isSpotlight: true code: | - ```js + ```kotlin client.realtime.removeAllChannels() ``` + - id: get-channels + title: getChannels() + $ref: '@supabase/supabase-js.index.SupabaseClient.getChannels' + notes: | + Returns all Realtime channels. + examples: + - id: get-all-channels + name: Get all channels + isSpotlight: true + code: | + ```kotlin + val channels = client.realtime.subscriptions.entries + ``` - id: list-buckets title: listBuckets() $ref: '@supabase/storage-js.packages/StorageBucketApi.default.listBuckets' @@ -3643,7 +3678,7 @@ functions: title: from.getPublicUrl() $ref: '@supabase/storage-js.packages/StorageFileApi.default.getPublicUrl' notes: | - - The bucket needs to be set to public, either via [updateBucket()](/docs/reference/kotlin/storage-updatebucket) or by going to Storage on [app.supabase.com](https://supabase.com/dashboard), clicking the overflow menu on a bucket and choosing "Make public" + - The bucket needs to be set to public, either via [updateBucket()](/docs/reference/kotlin/storage-updatebucket) or by going to Storage on [supabase.com/dashboard](https://supabase.com/dashboard), clicking the overflow menu on a bucket and choosing "Make public" - RLS policy permissions required: - `buckets` table permissions: none - `objects` table permissions: none diff --git a/spec/supabase_py_v2.yml b/spec/supabase_py_v2.yml index 5e3cf4277bb..e99bb5128e2 100644 --- a/spec/supabase_py_v2.yml +++ b/spec/supabase_py_v2.yml @@ -782,7 +782,7 @@ functions: - id: from-get-public-url title: 'from_.get_public_url()' notes: | - - The bucket needs to be set to public, either via [updateBucket()](/docs/reference/javascript/storage-updatebucket) or by going to Storage on [app.supabase.com](https://supabase.com/dashboard), clicking the overflow menu on a bucket and choosing "Make public" + - The bucket needs to be set to public, either via [updateBucket()](/docs/reference/javascript/storage-updatebucket) or by going to Storage on [supabase.com/dashboard](https://supabase.com/dashboard), clicking the overflow menu on a bucket and choosing "Make public" - RLS policy permissions required: - `buckets` table permissions: none - `objects` table permissions: none diff --git a/studio/__mocks__/hooks.js b/studio/__mocks__/hooks.js index b65063f7371..e5fbfee3462 100644 --- a/studio/__mocks__/hooks.js +++ b/studio/__mocks__/hooks.js @@ -1,5 +1,5 @@ export const useFlag = jest.fn().mockReturnValue(true) -export const checkPermissions = jest.fn().mockReturnValue(true) +export const useCheckPermissions = jest.fn().mockReturnValue(true) const { useStore: _useStore, StoreProvider: _StoreProvider } = jest.requireActual('hooks') export const useStore = _useStore diff --git a/studio/components/grid/components/header/Header.tsx b/studio/components/grid/components/header/Header.tsx index 86aad589319..db30a9924d1 100644 --- a/studio/components/grid/components/header/Header.tsx +++ b/studio/components/grid/components/header/Header.tsx @@ -13,7 +13,7 @@ import { } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions, useStore, useUrlState } from 'hooks' +import { useCheckPermissions, useStore, useUrlState } from 'hooks' import FilterDropdown from './filter' import SortPopover from './sort' import RefreshButton from './RefreshButton' @@ -106,7 +106,7 @@ const DefaultHeader = ({ const canAddNew = onAddRow !== undefined || onAddColumn !== undefined // [Joshen] Using this logic to block both column and row creation/update/delete - const canCreateColumns = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns') + const canCreateColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns') const [{ filter: filters, sort: sorts }, setParams] = useUrlState({ arrayKeys: ['sort', 'filter'], diff --git a/studio/components/interfaces/App/CommandMenuWrapper.tsx b/studio/components/interfaces/App/CommandMenuWrapper.tsx index c45cae8447f..e94fe6797d1 100644 --- a/studio/components/interfaces/App/CommandMenuWrapper.tsx +++ b/studio/components/interfaces/App/CommandMenuWrapper.tsx @@ -1,33 +1,33 @@ -import remarkGfm from 'remark-gfm' -import ReactMarkdown from 'react-markdown' -import { PropsWithChildren, useMemo } from 'react' -import { useParams } from 'common' -import { CommandMenuProvider, markdownComponents } from 'ui' -import { observer } from 'mobx-react-lite' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useParams } from 'common' +import { PropsWithChildren, useMemo } from 'react' +import { CommandMenuProvider } from 'ui' -import { uuidv4 } from 'lib/helpers' -import { checkPermissions, useFlag, useStore } from 'hooks' -import { createSqlSnippetSkeleton } from '../SQLEditor/SQLEditor.utils' -import { useSqlEditorStateSnapshot } from 'state/sql-editor' import { codeBlock } from 'common-tags' -import { SqlSnippet } from 'data/content/sql-snippets-query' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useProjectApiQuery } from 'data/config/project-api-query' +import { SqlSnippet } from 'data/content/sql-snippets-query' import { useEntityDefinitionsQuery } from 'data/database/entity-definitions-query' -import { useProfileQuery } from 'data/profile/profile-query' +import { useCheckPermissions, useFlag, useSelectedOrganization, useStore } from 'hooks' +import { uuidv4 } from 'lib/helpers' +import { useProfile } from 'lib/profile' +import { useSqlEditorStateSnapshot } from 'state/sql-editor' +import { createSqlSnippetSkeleton } from '../SQLEditor/SQLEditor.utils' -const CommandMenuWrapper = observer(({ children }: PropsWithChildren<{}>) => { +const CommandMenuWrapper = ({ children }: PropsWithChildren<{}>) => { const { ref } = useParams() const { ui } = useStore() - const { opt_in_tags } = ui.selectedOrganization ?? {} + const selectedOrganization = useSelectedOrganization() + const { project: selectedProject } = useProjectContext() + const opt_in_tags = selectedOrganization?.opt_in_tags const snap = useSqlEditorStateSnapshot() const allowCMDKDataOptIn = useFlag('dashboardCmdkDataOptIn') const isOptedInToAI = opt_in_tags?.includes('AI_SQL_GENERATOR_OPT_IN') ?? false - const { data: profile } = useProfileQuery() + const { profile } = useProfile() const { data: settings } = useProjectApiQuery({ projectRef: ref }) - const canCreateSQLSnippet = checkPermissions(PermissionAction.CREATE, 'user_content', { + const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', { resource: { type: 'sql', owner_id: profile?.id }, subject: { id: profile?.id }, }) @@ -39,8 +39,8 @@ const CommandMenuWrapper = observer(({ children }: PropsWithChildren<{}>) => { const { data } = useEntityDefinitionsQuery( { - projectRef: ui.selectedProject?.ref, - connectionString: ui.selectedProject?.connectionString, + projectRef: selectedProject?.ref, + connectionString: selectedProject?.connectionString, }, { enabled: isOptedInToAI } ) @@ -105,6 +105,6 @@ const CommandMenuWrapper = observer(({ children }: PropsWithChildren<{}>) => { {children} ) -}) +} export default CommandMenuWrapper diff --git a/studio/components/interfaces/App/RouteValidationWrapper.tsx b/studio/components/interfaces/App/RouteValidationWrapper.tsx index f6ca98bf22d..aacae694dc3 100644 --- a/studio/components/interfaces/App/RouteValidationWrapper.tsx +++ b/studio/components/interfaces/App/RouteValidationWrapper.tsx @@ -1,15 +1,15 @@ -import { FC, useEffect } from 'react' import { observer } from 'mobx-react-lite' import { useRouter } from 'next/router' +import { PropsWithChildren, useEffect } from 'react' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useProjectsQuery } from 'data/projects/projects-query' import { useStore } from 'hooks' -import { Organization, Project } from 'types' +import useLatest from 'hooks/misc/useLatest' // Ideally these could all be within a _middleware when we use Next 12 -const RouteValidationWrapper: FC = ({ children }) => { - const { ui, app } = useStore() - const orgsInitialized = app.organizations.isInitialized - const projectsInitialized = app.projects.isInitialized +const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => { + const { ui } = useStore() const router = useRouter() const projectRef = router.query.ref @@ -36,14 +36,17 @@ const RouteValidationWrapper: FC = ({ children }) => { return excemptUrls.includes(router?.pathname) } + const { data: organizations, isSuccess: orgsInitialized } = useOrganizationsQuery() + const organizationsRef = useLatest(organizations) + useEffect(() => { // check if current route is excempted from route validation check if (isExceptUrl()) return if (orgsInitialized && orgSlug) { // Check validity of organization that user is trying to access - const organizations = app.organizations.list() - const isValidOrg = organizations.some((org: Organization) => org.slug === orgSlug) + const organizations = organizationsRef.current ?? [] + const isValidOrg = organizations.some((org) => org.slug === orgSlug) if (!isValidOrg) { ui.setNotification({ category: 'error', message: 'This organization does not exist' }) @@ -53,14 +56,17 @@ const RouteValidationWrapper: FC = ({ children }) => { } }, [orgsInitialized]) + const { data: projects, isSuccess: projectsInitialized } = useProjectsQuery() + const projectsRef = useLatest(projects) + useEffect(() => { // check if current route is excempted from route validation check if (isExceptUrl()) return if (projectsInitialized && projectRef) { // Check validity of project that the user is trying to access - const projects = app.projects.list() - const isValidProject = projects.some((project: Project) => project.ref === projectRef) + const projects = projectsRef.current ?? [] + const isValidProject = projects.some((project) => project.ref === projectRef) if (!isValidProject) { ui.setNotification({ category: 'error', message: 'This project does not exist' }) diff --git a/studio/components/interfaces/Auth/Auth.types.ts b/studio/components/interfaces/Auth/Auth.types.ts index 3c4118ec83b..55b756c31bd 100644 --- a/studio/components/interfaces/Auth/Auth.types.ts +++ b/studio/components/interfaces/Auth/Auth.types.ts @@ -137,6 +137,9 @@ export interface AuthConfig { SMS_TWILIO_ACCOUNT_SID: string SMS_TWILIO_AUTH_TOKEN: string SMS_TWILIO_MESSAGE_SERVICE_SID: string + SMS_TWILIO_VERIFY_ACCOUNT_SID: string + SMS_TWILIO_VERIFY_AUTH_TOKEN: string + SMS_TWILIO_VERIFY_MESSAGE_SERVICE_SID: string SMS_VONAGE_API_KEY: string SMS_VONAGE_API_SECRET: string SMS_VONAGE_FROM: string diff --git a/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx b/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx index ddb160631fb..a9665541e76 100644 --- a/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx +++ b/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx @@ -14,7 +14,8 @@ interface Props { const FormField: FC = ({ name, properties, formValues, disabled = false }) => { const [hidden, setHidden] = useState(!!properties.isSecret) - if (properties.show && formValues[properties.show.key] !== properties.show.matches) return null + if (properties.show && !properties.show.matches.includes(formValues[properties.show.key])) + return null switch (properties.type) { case 'string': diff --git a/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx b/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx index 673fdaebd80..0851c3e397f 100644 --- a/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx +++ b/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx @@ -1,27 +1,29 @@ -import { FC, useState } from 'react' -import ReactMarkdown from 'react-markdown' import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useState } from 'react' +import ReactMarkdown from 'react-markdown' import { Alert, Button, Collapsible, Form, IconCheck, IconChevronUp, Input } from 'ui' -import { useStore, checkPermissions } from 'hooks' -import { BASE_PATH } from 'lib/constants' -import { Provider } from './AuthProvidersForm.types' -import { ProviderCollapsibleClasses } from './AuthProvidersForm.constants' -import FormField from './FormField' -import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useParams } from 'common' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' +import { useCheckPermissions, useStore } from 'hooks' +import { BASE_PATH } from 'lib/constants' +import { ProviderCollapsibleClasses } from './AuthProvidersForm.constants' +import { Provider } from './AuthProvidersForm.types' +import FormField from './FormField' -interface Props { +export interface ProviderFormProps { provider: Provider } -const ProviderForm: FC = ({ provider }) => { +const ProviderForm = ({ provider }: ProviderFormProps) => { const [open, setOpen] = useState(false) const { authConfig, ui } = useStore() + const { project: selectedProject } = useProjectContext() const { ref } = useParams() const doubleNegativeKeys = ['MAILER_AUTOCONFIRM', 'SMS_AUTOCONFIRM'] - const canUpdateConfig = checkPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') + const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') const { data: customDomainData } = useCustomDomainsQuery({ projectRef: ref }) @@ -149,18 +151,20 @@ const ProviderForm: FC = ({ provider }) => { )} {provider.misc.requiresRedirect && ( <> - - {provider.misc.helper} - + {provider.misc.helper} + } /> diff --git a/studio/components/interfaces/Auth/AutoSchemaForm.tsx b/studio/components/interfaces/Auth/AutoSchemaForm.tsx index 046f2b88e5c..72722a74e46 100644 --- a/studio/components/interfaces/Auth/AutoSchemaForm.tsx +++ b/studio/components/interfaces/Auth/AutoSchemaForm.tsx @@ -4,7 +4,7 @@ import { boolean, number, object, string } from 'yup' import { PermissionAction } from '@supabase/shared-types/out/constants' import { Button, Form, Input, IconEye, IconEyeOff, InputNumber, Toggle, Radio } from 'ui' -import { useStore, checkPermissions } from 'hooks' +import { useStore, useCheckPermissions } from 'hooks' import { FormActions, FormHeader, @@ -20,7 +20,7 @@ const AutoSchemaForm = observer(() => { const formId = 'auth-config-general-form' const [hidden, setHidden] = useState(true) - const canUpdateConfig = checkPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') + const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') const INITIAL_VALUES = { DISABLE_SIGNUP: !authConfig.config.DISABLE_SIGNUP, @@ -31,7 +31,7 @@ const AutoSchemaForm = observer(() => { SECURITY_CAPTCHA_ENABLED: authConfig.config.SECURITY_CAPTCHA_ENABLED || false, SECURITY_CAPTCHA_SECRET: authConfig.config.SECURITY_CAPTCHA_SECRET || '', SECURITY_CAPTCHA_PROVIDER: authConfig.config.SECURITY_CAPTCHA_PROVIDER || 'hcaptcha', - MAX_ENROLLED_FACTORS: authConfig.config.MAX_ENROLLED_FACTORS, + MFA_MAX_ENROLLED_FACTORS: authConfig.config.MFA_MAX_ENROLLED_FACTORS || 10, } const schema = object({ @@ -55,7 +55,7 @@ const AutoSchemaForm = observer(() => { .oneOf(['hcaptcha', 'turnstile']) .required('Captcha provider must be either hcaptcha or turnstile'), }), - MAX_ENROLLED_FACTORS: number() + MFA_MAX_ENROLLED_FACTORS: number() .min(0, 'Must be be a value more than 0') .max(30, 'Must be a value less than 30'), }) @@ -215,7 +215,7 @@ const AutoSchemaForm = observer(() => { > = ({ template }) => { const formId = `auth-config-email-templates-${id}` const INITIAL_VALUES: { [x: string]: string } = {} - const canUpdateConfig = checkPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') + const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') useEffect(() => { if (isLoaded) { diff --git a/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx b/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx index 41518f2742e..aa9dcb9cc88 100644 --- a/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx +++ b/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx @@ -4,7 +4,7 @@ import { Button, Dropdown, IconEdit, IconTrash, IconMoreVertical } from 'ui' import type { PostgresPolicy } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions } from 'hooks' +import { useCheckPermissions } from 'hooks' import Panel from 'components/ui/Panel' interface Props { @@ -18,7 +18,7 @@ const PolicyRow: FC = ({ onSelectEditPolicy = () => {}, onSelectDeletePolicy = () => {}, }) => { - const canUpdatePolicies = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'policies') + const canUpdatePolicies = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'policies') return ( = ({ }) => { const router = useRouter() const { ref } = router.query - const canToggleRLS = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') - const canCreatePolicies = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'policies') + const canToggleRLS = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') + const canCreatePolicies = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'policies') return (
diff --git a/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx b/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx index 178ad63bd38..19ed2369767 100644 --- a/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx +++ b/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx @@ -5,7 +5,7 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { Button, Form, Input, Modal } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' import { FormHeader } from 'components/ui/Forms' import { urlRegex } from '../Auth.constants' import RedirectUrlList from './RedirectUrlList' @@ -21,7 +21,7 @@ const RedirectUrls = () => { const [isDeleting, setIsDeleting] = useState(false) const [selectedUrlToDelete, setSelectedUrlToDelete] = useState() - const canUpdateConfig = checkPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') + const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') const newUrlSchema = object({ url: string().matches(urlRegex, 'URL is not valid').required(), diff --git a/studio/components/interfaces/Auth/SiteUrl/SiteUrl.tsx b/studio/components/interfaces/Auth/SiteUrl/SiteUrl.tsx index 1ad738c80be..da893178403 100644 --- a/studio/components/interfaces/Auth/SiteUrl/SiteUrl.tsx +++ b/studio/components/interfaces/Auth/SiteUrl/SiteUrl.tsx @@ -12,7 +12,7 @@ import { FormSectionContent, FormSectionLabel, } from 'components/ui/Forms' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' const SiteUrl = observer(() => { const { authConfig, ui } = useStore() @@ -21,7 +21,7 @@ const SiteUrl = observer(() => { const formId = 'auth-config-general-form' const [hidden, setHidden] = useState(true) - const canUpdateConfig = checkPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') + const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') const INITIAL_VALUES = { DISABLE_SIGNUP: !authConfig.config.DISABLE_SIGNUP, diff --git a/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx b/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx index 8d658333d9b..26cba0e4739 100644 --- a/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx +++ b/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx @@ -12,7 +12,7 @@ import { FormSectionContent, FormSectionLabel, } from 'components/ui/Forms' -import { useStore, checkPermissions } from 'hooks' +import { useStore, useCheckPermissions } from 'hooks' import { urlRegex } from './../Auth.constants' import { defaultDisabledSmtpFormValues } from './SmtpForm.constants' import { generateFormValues, isSmtpEnabled } from './SmtpForm.utils' @@ -26,7 +26,7 @@ const SmtpForm = () => { const formId = 'auth-config-smtp-form' const initialValues = generateFormValues(authConfig.config) - const canUpdateConfig = checkPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') + const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') useEffect(() => { if (isLoaded && isSmtpEnabled(config)) { diff --git a/studio/components/interfaces/Auth/Users/AddUserDropdown.tsx b/studio/components/interfaces/Auth/Users/AddUserDropdown.tsx index 3cacf12808d..6cbdd654104 100644 --- a/studio/components/interfaces/Auth/Users/AddUserDropdown.tsx +++ b/studio/components/interfaces/Auth/Users/AddUserDropdown.tsx @@ -1,6 +1,6 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions } from 'hooks' +import { useCheckPermissions } from 'hooks' import { IS_PLATFORM } from 'lib/constants' import { useState } from 'react' import semver from 'semver' @@ -21,8 +21,8 @@ const AddUserDropdown = ({ projectKpsVersion }: AddUserDropdownProps) => { ) : true - const canInviteUsers = checkPermissions(PermissionAction.AUTH_EXECUTE, 'invite_user') - const canCreateUsers = checkPermissions(PermissionAction.AUTH_EXECUTE, 'create_user') + const canInviteUsers = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'invite_user') + const canCreateUsers = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'create_user') const [inviteVisible, setInviteVisible] = useState(false) const [createVisible, setCreateVisible] = useState(false) diff --git a/studio/components/interfaces/Auth/Users/CreateUserModal.tsx b/studio/components/interfaces/Auth/Users/CreateUserModal.tsx index 94698aca740..527b09d4f16 100644 --- a/studio/components/interfaces/Auth/Users/CreateUserModal.tsx +++ b/studio/components/interfaces/Auth/Users/CreateUserModal.tsx @@ -5,7 +5,7 @@ import { Button, Checkbox, Form, IconLock, IconMail, Input, Loading, Modal } fro import { useUserCreateMutation } from 'data/auth/user-create-mutation' import { useProjectApiQuery } from 'data/config/project-api-query' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' import { PageContext } from 'pages/project/[ref]/auth/users' export type CreateUserModalProps = { @@ -21,7 +21,7 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => { const { data, isLoading, isSuccess } = useProjectApiQuery({ projectRef }, { enabled: visible }) const handleToggle = () => setVisible(!visible) - const canCreateUsers = checkPermissions(PermissionAction.AUTH_EXECUTE, 'create_user') + const canCreateUsers = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'create_user') const validate = (values: any) => { const errors: any = {} diff --git a/studio/components/interfaces/Auth/Users/InviteUserModal.tsx b/studio/components/interfaces/Auth/Users/InviteUserModal.tsx index 2ec628eb448..f64c1723ccb 100644 --- a/studio/components/interfaces/Auth/Users/InviteUserModal.tsx +++ b/studio/components/interfaces/Auth/Users/InviteUserModal.tsx @@ -3,7 +3,7 @@ import { observer } from 'mobx-react-lite' import { useContext } from 'react' import { Button, Form, IconMail, Input, Modal } from 'ui' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' import { post } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { PageContext } from 'pages/project/[ref]/auth/users' @@ -18,7 +18,7 @@ const InviteUserModal = ({ visible, setVisible }: InviteUserModalProps) => { const PageState: any = useContext(PageContext) const handleToggle = () => setVisible(!visible) - const canInviteUsers = checkPermissions(PermissionAction.AUTH_EXECUTE, 'invite_user') + const canInviteUsers = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'invite_user') const validate = (values: any) => { const errors: any = {} diff --git a/studio/components/interfaces/Auth/Users/UsersList.tsx b/studio/components/interfaces/Auth/Users/UsersList.tsx index cd583dd76c3..652858cb5f1 100644 --- a/studio/components/interfaces/Auth/Users/UsersList.tsx +++ b/studio/components/interfaces/Auth/Users/UsersList.tsx @@ -6,15 +6,15 @@ import { PageContext } from 'pages/project/[ref]/auth/users' import Table from 'components/to-be-cleaned/Table' import UserListItem from './UsersListItem' import UsersPagination from './UsersPagination' -import { checkPermissions } from 'hooks' +import { useCheckPermissions } from 'hooks' import { PermissionAction } from '@supabase/shared-types/out/constants' const UsersList = ({}) => { const PageState: any = useContext(PageContext) // Check once on the top level, rather than checking for every row - const canRemoveUser = checkPermissions(PermissionAction.TENANT_SQL_DELETE, 'auth.users') - const canRemoveMFAFactors = checkPermissions( + const canRemoveUser = useCheckPermissions(PermissionAction.TENANT_SQL_DELETE, 'auth.users') + const canRemoveMFAFactors = useCheckPermissions( PermissionAction.TENANT_SQL_DELETE, 'auth.mfa_factors' ) diff --git a/studio/components/interfaces/Billing/AddNewPaymentMethodModal/AddNewPaymentMethodModal.tsx b/studio/components/interfaces/Billing/AddNewPaymentMethodModal/AddNewPaymentMethodModal.tsx index d8f6c0335f0..60d8715ba37 100644 --- a/studio/components/interfaces/Billing/AddNewPaymentMethodModal/AddNewPaymentMethodModal.tsx +++ b/studio/components/interfaces/Billing/AddNewPaymentMethodModal/AddNewPaymentMethodModal.tsx @@ -1,15 +1,15 @@ import HCaptcha from '@hcaptcha/react-hcaptcha' -import { loadStripe } from '@stripe/stripe-js' import { Elements } from '@stripe/react-stripe-js' +import { loadStripe } from '@stripe/stripe-js' import { FC, useCallback, useEffect, useState } from 'react' -import { Modal } from 'ui' -import { useStore } from 'hooks' -import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store' +import { useTheme } from 'common' +import { useSelectedOrganization, useStore } from 'hooks' import { post } from 'lib/common/fetch' import { API_URL, STRIPE_PUBLIC_KEY } from 'lib/constants' +import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store' +import { Modal } from 'ui' import AddPaymentMethodForm from './AddPaymentMethodForm' -import { useTheme } from 'common' interface Props { visible: boolean @@ -60,10 +60,12 @@ const AddNewPaymentMethodModal: FC = ({ visible, returnUrl, onCancel, onC captchaRef?.resetCaptcha() } + const selectedOrganization = useSelectedOrganization() + const setupIntent = async (hcaptchaToken: string | undefined) => { setIntent(undefined) - const orgSlug = ui.selectedOrganization?.slug ?? '' + const orgSlug = selectedOrganization?.slug ?? '' const intent = await post(`${API_URL}/organizations/${orgSlug}/payments/setup-intent`, { hcaptchaToken, }) diff --git a/studio/components/interfaces/BillingV2/Subscription/AddOns/AddOns.tsx b/studio/components/interfaces/BillingV2/Subscription/AddOns/AddOns.tsx index 037943496ac..ca737136495 100644 --- a/studio/components/interfaces/BillingV2/Subscription/AddOns/AddOns.tsx +++ b/studio/components/interfaces/BillingV2/Subscription/AddOns/AddOns.tsx @@ -38,16 +38,8 @@ const AddOns = ({}: AddOnsProps) => { const { data: addons, isLoading } = useProjectAddonsQuery({ projectRef }) const selectedAddons = addons?.selected_addons ?? [] - const availableAddons = addons?.available_addons ?? [] const { computeInstance, pitr, customDomain } = getAddons(selectedAddons) - const computeInstanceSpecs = - computeInstance !== undefined - ? availableAddons - .find((addon) => addon.type === 'compute_instance') - ?.variants.find((variant) => variant.identifier === computeInstance.variant.identifier) - ?.meta - : undefined return ( <> @@ -157,8 +149,9 @@ const AddOns = ({}: AddOnsProps) => { >

Your workload is currently running at the baseline disk IO bandwidth at{' '} - {computeInstanceSpecs?.baseline_disk_io_mbs?.toLocaleString() ?? 87} Mbps - and may suffer degradation in performance. + {computeInstance?.variant?.meta?.baseline_disk_io_mbs?.toLocaleString() ?? + 87}{' '} + Mbps and may suffer degradation in performance.

Consider upgrading to a larger compute instance for a higher baseline @@ -175,8 +168,9 @@ const AddOns = ({}: AddOnsProps) => {

If the disk IO budget drops to zero, your workload will run at the baseline disk IO bandwidth at{' '} - {computeInstanceSpecs?.baseline_disk_io_mbs?.toLocaleString() ?? 87} Mbps - and may suffer degradation in performance. + {computeInstance?.variant?.meta?.baseline_disk_io_mbs?.toLocaleString() ?? + 87}{' '} + Mbps and may suffer degradation in performance.

Consider upgrading to a larger compute instance for a higher baseline @@ -200,7 +194,7 @@ const AddOns = ({}: AddOnsProps) => {

-

{computeInstanceSpecs?.memory_gb ?? 1} GB

+

{computeInstance?.variant?.meta?.memory_gb ?? 1} GB

@@ -218,17 +212,21 @@ const AddOns = ({}: AddOnsProps) => {

- {computeInstanceSpecs?.cpu_cores ?? 2}-core ARM{' '} - {computeInstanceSpecs?.cpu_dedicated ? '(Dedicated)' : '(Shared)'} + {computeInstance?.variant?.meta?.cpu_cores ?? 2}-core ARM{' '} + {computeInstance?.variant?.meta?.cpu_dedicated ? '(Dedicated)' : '(Shared)'}

No. of direct connections

-

{computeInstanceSpecs?.connections_direct ?? 60}

+

+ {computeInstance?.variant?.meta?.connections_direct ?? 60} +

No. of pooler connections

-

{computeInstanceSpecs?.connections_pooler ?? 200}

+

+ {computeInstance?.variant?.meta?.connections_pooler ?? 200} +

@@ -246,7 +244,8 @@ const AddOns = ({}: AddOnsProps) => {

- {computeInstanceSpecs?.max_disk_io_mbs?.toLocaleString() ?? '2,085'} Mbps + {computeInstance?.variant?.meta?.max_disk_io_mbs?.toLocaleString() ?? '2,085'}{' '} + Mbps

@@ -265,7 +264,8 @@ const AddOns = ({}: AddOnsProps) => {

- {computeInstanceSpecs?.baseline_disk_io_mbs?.toLocaleString() ?? 87} Mbps + {computeInstance?.variant?.meta?.baseline_disk_io_mbs?.toLocaleString() ?? 87}{' '} + Mbps

@@ -293,9 +293,7 @@ const AddOns = ({}: AddOnsProps) => {

Point in time recovery

{pitr !== undefined - ? `Point in time recovery of ${ - pitr.variant.identifier.split('_')[1] - } days is enabled` + ? `Point in time recovery of ${pitr.variant.meta?.backup_duration_days} days is enabled` : 'Point in time recovery is not enabled'}

diff --git a/studio/components/interfaces/BillingV2/Subscription/AddOns/ComputeInstanceSidePanel.tsx b/studio/components/interfaces/BillingV2/Subscription/AddOns/ComputeInstanceSidePanel.tsx index 43d224f320d..f6bdf2517a6 100644 --- a/studio/components/interfaces/BillingV2/Subscription/AddOns/ComputeInstanceSidePanel.tsx +++ b/studio/components/interfaces/BillingV2/Subscription/AddOns/ComputeInstanceSidePanel.tsx @@ -1,15 +1,20 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' import clsx from 'clsx' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' + import { useParams, useTheme } from 'common' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { setProjectStatus } from 'data/projects/projects-query' import { useProjectAddonRemoveMutation } from 'data/subscriptions/project-addon-remove-mutation' import { useProjectAddonUpdateMutation } from 'data/subscriptions/project-addon-update-mutation' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' import { BASE_PATH, PROJECT_STATUS } from 'lib/constants' -import Link from 'next/link' -import { useRouter } from 'next/router' -import { useEffect, useState } from 'react' +import Telemetry from 'lib/telemetry' import { useSubscriptionPageStateSnapshot } from 'state/subscription-page' import { Alert, Button, IconExternalLink, Modal, Radio, SidePanel } from 'ui' @@ -34,17 +39,22 @@ const COMPUTE_CATEGORY_OPTIONS: { ] const ComputeInstanceSidePanel = () => { - const { ui, app } = useStore() + const queryClient = useQueryClient() + const { ui } = useStore() const router = useRouter() const { ref: projectRef } = useParams() const { isDarkMode } = useTheme() + const { project: selectedProject } = useProjectContext() const [isSubmitting, setIsSubmitting] = useState(false) const [showConfirmationModal, setShowConfirmationModal] = useState(false) const [selectedCategory, setSelectedCategory] = useState<'micro' | 'optimized'>('micro') const [selectedOption, setSelectedOption] = useState('ci_micro') - const canUpdateCompute = checkPermissions(PermissionAction.BILLING_WRITE, 'stripe.subscriptions') + const canUpdateCompute = useCheckPermissions( + PermissionAction.BILLING_WRITE, + 'stripe.subscriptions' + ) const snap = useSubscriptionPageStateSnapshot() const visible = snap.panelKey === 'computeInstance' @@ -55,7 +65,7 @@ const ComputeInstanceSidePanel = () => { const { mutateAsync: updateAddon } = useProjectAddonUpdateMutation() const { mutateAsync: removeAddon } = useProjectAddonRemoveMutation() - const projectId = ui.selectedProject?.id + const projectId = selectedProject?.id const selectedAddons = addons?.selected_addons ?? [] const availableAddons = addons?.available_addons ?? [] @@ -79,6 +89,17 @@ const ComputeInstanceSidePanel = () => { setSelectedCategory('micro') setSelectedOption('ci_micro') } + Telemetry.sendActivity( + { + activity: 'Side Panel Viewed', + source: 'Dashboard', + data: { + title: 'Change project compute size', + section: 'Add ons', + }, + }, + router + ) } }, [visible, isLoading]) @@ -98,9 +119,11 @@ const ComputeInstanceSidePanel = () => { ui.setNotification({ duration: 8000, category: 'success', - message: `Successfully updated compute instance to ${selectedCompute?.name}. Your project is currently being restarted to update its instance`, + message: `Successfully updated compute instance to ${ + selectedCompute?.name || 'Micro' + }. Your project is currently being restarted to update its instance`, }) - app.onProjectStatusUpdated(projectId, PROJECT_STATUS.RESTORING) + setProjectStatus(queryClient, projectRef, PROJECT_STATUS.RESTORING) onClose() router.push(`/project/${projectRef}`) } catch (error: any) { @@ -167,6 +190,18 @@ const ComputeInstanceSidePanel = () => { onClick={() => { setSelectedCategory(option.id) if (option.id === 'micro') setSelectedOption('ci_micro') + Telemetry.sendActivity( + { + activity: 'Option Selected', + source: 'Dashboard', + data: { + title: 'Change project compute size', + section: 'Add ons', + option: option.name, + }, + }, + router + ) }} > { const { ui } = useStore() + const router = useRouter() const { ref: projectRef } = useParams() const [isSubmitting, setIsSubmitting] = useState(false) const [selectedOption, setSelectedOption] = useState('cd_none') - const canUpdateCustomDomain = checkPermissions( + const canUpdateCustomDomain = useCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.subscriptions' ) @@ -51,6 +54,17 @@ const CustomDomainSidePanel = () => { } else { setSelectedOption('cd_none') } + Telemetry.sendActivity( + { + activity: 'Side Panel Viewed', + source: 'Dashboard', + data: { + title: 'Custom domains', + section: 'Add ons', + }, + }, + router + ) } }, [visible, isLoading]) @@ -128,7 +142,21 @@ const CustomDomainSidePanel = () => { type="large-cards" size="tiny" id="custom-domain" - onChange={(event: any) => setSelectedOption(event.target.value)} + onChange={(event: any) => { + setSelectedOption(event.target.value) + Telemetry.sendActivity( + { + activity: 'Option Selected', + source: 'Dashboard', + data: { + title: 'Custom domains', + section: 'Add ons', + option: event.target.label, + }, + }, + router + ) + }} > { const { ui } = useStore() + const router = useRouter() const { ref: projectRef } = useParams() const { isDarkMode } = useTheme() @@ -41,7 +44,7 @@ const PITRSidePanel = () => { const [selectedCategory, setSelectedCategory] = useState<'on' | 'off'>('off') const [selectedOption, setSelectedOption] = useState('pitr_0') - const canUpdatePitr = checkPermissions(PermissionAction.BILLING_WRITE, 'stripe.subscriptions') + const canUpdatePitr = useCheckPermissions(PermissionAction.BILLING_WRITE, 'stripe.subscriptions') const snap = useSubscriptionPageStateSnapshot() const visible = snap.panelKey === 'pitr' @@ -72,6 +75,17 @@ const PITRSidePanel = () => { setSelectedCategory('off') setSelectedOption('pitr_0') } + Telemetry.sendActivity( + { + activity: 'Side Panel Viewed', + source: 'Dashboard', + data: { + title: 'Point in Time Recovery', + section: 'Add ons', + }, + }, + router + ) } }, [visible, isLoading]) @@ -152,6 +166,18 @@ const PITRSidePanel = () => { onClick={() => { setSelectedCategory(option.id) if (option.id === 'off') setSelectedOption('pitr_0') + Telemetry.sendActivity( + { + activity: 'Option Selected', + source: 'Dashboard', + data: { + title: 'Point in Time Recovery', + section: 'Add ons', + option: option.name, + }, + }, + router + ) }} > { {isUsageBillingEnabled ? (

Your plan includes a limited amount of usage. If the usage on your project exceeds - these quotas, your subscription will be charged for the overage. It may take up to 24 + these quotas, your subscription will be charged for the overages. It may take up to 24 hours for usage stats to update.

) : (

Your plan includes a limited amount of usage. If the usage on your project exceeds these quotas, you may experience restrictions, as you are currently not billed for - overage. It may take up to 24 hours for usage stats to update. + overages. It may take up to 24 hours for usage stats to update.

)} diff --git a/studio/components/interfaces/BillingV2/Subscription/CostControl/CostControl.tsx b/studio/components/interfaces/BillingV2/Subscription/CostControl/CostControl.tsx index 9d00bf180c6..34ce550a355 100644 --- a/studio/components/interfaces/BillingV2/Subscription/CostControl/CostControl.tsx +++ b/studio/components/interfaces/BillingV2/Subscription/CostControl/CostControl.tsx @@ -34,7 +34,7 @@ const CostControl = ({}: CostControlProps) => {

Cost Control

- Control whether to usage beyond your plans included quota + Control whether to use beyond your plans included quota

diff --git a/studio/components/interfaces/BillingV2/Subscription/CostControl/SpendCapSidePanel.tsx b/studio/components/interfaces/BillingV2/Subscription/CostControl/SpendCapSidePanel.tsx index 74fccb42c8d..8e150368483 100644 --- a/studio/components/interfaces/BillingV2/Subscription/CostControl/SpendCapSidePanel.tsx +++ b/studio/components/interfaces/BillingV2/Subscription/CostControl/SpendCapSidePanel.tsx @@ -3,7 +3,7 @@ import { useParams, useTheme } from 'common' import Table from 'components/to-be-cleaned/Table' import { useProjectSubscriptionUpdateMutation } from 'data/subscriptions/project-subscription-update-mutation' import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' import { BASE_PATH, PRICING_TIER_PRODUCT_IDS } from 'lib/constants' import Link from 'next/link' import { useEffect, useState } from 'react' @@ -11,6 +11,8 @@ import { useSubscriptionPageStateSnapshot } from 'state/subscription-page' import { Alert, Button, Collapsible, IconChevronRight, IconExternalLink, SidePanel } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' import { pricing } from 'shared-data/pricing' +import Telemetry from 'lib/telemetry' +import { useRouter } from 'next/router' const SPEND_CAP_OPTIONS: { name: string @@ -34,6 +36,7 @@ const SPEND_CAP_OPTIONS: { const SpendCapSidePanel = () => { const { ui } = useStore() + const router = useRouter() const { ref: projectRef } = useParams() const { isDarkMode } = useTheme() @@ -41,7 +44,10 @@ const SpendCapSidePanel = () => { const [isSubmitting, setIsSubmitting] = useState(false) const [selectedOption, setSelectedOption] = useState<'on' | 'off'>() - const canUpdateSpendCap = checkPermissions(PermissionAction.BILLING_WRITE, 'stripe.subscriptions') + const canUpdateSpendCap = useCheckPermissions( + PermissionAction.BILLING_WRITE, + 'stripe.subscriptions' + ) const snap = useSubscriptionPageStateSnapshot() const visible = snap.panelKey === 'costControl' @@ -58,6 +64,17 @@ const SpendCapSidePanel = () => { useEffect(() => { if (visible && subscription !== undefined) { setSelectedOption(isSpendCapOn ? 'on' : 'off') + Telemetry.sendActivity( + { + activity: 'Side Panel Viewed', + source: 'Dashboard', + data: { + title: 'Spend cap', + section: 'Cost Control', + }, + }, + router + ) } }, [visible, isLoading, subscription, isSpendCapOn]) @@ -206,7 +223,21 @@ const SpendCapSidePanel = () => {
!isFreePlan && setSelectedOption(option.value)} + onClick={() => { + !isFreePlan && setSelectedOption(option.value) + Telemetry.sendActivity( + { + activity: 'Option Selected', + source: 'Dashboard', + data: { + title: 'Spend cap', + section: 'Cost Control', + option: option.name, + }, + }, + router + ) + }} > Spend Cap { setIntent(undefined) - const orgSlug = ui.selectedOrganization?.slug ?? '' + const orgSlug = selectedOrganization?.slug ?? '' const intent = await post(`${API_URL}/organizations/${orgSlug}/payments/setup-intent`, { hcaptchaToken, }) diff --git a/studio/components/interfaces/BillingV2/Subscription/Tier/ExitSurveyModal.tsx b/studio/components/interfaces/BillingV2/Subscription/Tier/ExitSurveyModal.tsx index 404045ec10d..2a9e5c84af7 100644 --- a/studio/components/interfaces/BillingV2/Subscription/Tier/ExitSurveyModal.tsx +++ b/studio/components/interfaces/BillingV2/Subscription/Tier/ExitSurveyModal.tsx @@ -1,17 +1,19 @@ import HCaptcha from '@hcaptcha/react-hcaptcha' -import * as Tooltip from '@radix-ui/react-tooltip' +import { useQueryClient } from '@tanstack/react-query' +import { includes, without } from 'lodash' +import { useRouter } from 'next/router' +import { useReducer, useRef, useState } from 'react' + import { useParams } from 'common' +import { setProjectStatus } from 'data/projects/projects-query' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { useProjectSubscriptionUpdateMutation } from 'data/subscriptions/project-subscription-update-mutation' import { useFlag, useStore } from 'hooks' import { post } from 'lib/common/fetch' import { API_URL, PROJECT_STATUS } from 'lib/constants' -import { includes, without } from 'lodash' -import { useRouter } from 'next/router' -import { useReducer, useRef, useState } from 'react' import { Button, Input, Modal } from 'ui' -import ProjectUpdateDisabledTooltip from '../../ProjectUpdateDisabledTooltip' import { CANCELLATION_REASONS } from '../../Billing.constants' +import ProjectUpdateDisabledTooltip from '../../ProjectUpdateDisabledTooltip' export interface ExitSurveyModalProps { visible: boolean @@ -21,7 +23,8 @@ export interface ExitSurveyModalProps { // [Joshen] For context - Exit survey is only when going to free plan from a paid plan const ExitSurveyModal = ({ visible, onClose }: ExitSurveyModalProps) => { - const { ui, app } = useStore() + const queryClient = useQueryClient() + const { ui } = useStore() const { ref: projectRef } = useParams() const captchaRef = useRef(null) const router = useRouter() @@ -35,7 +38,6 @@ const ExitSurveyModal = ({ visible, onClose }: ExitSurveyModalProps) => { const { data: addons } = useProjectAddonsQuery({ projectRef }) const { mutateAsync: updateSubscriptionTier } = useProjectSubscriptionUpdateMutation() - const projectId = ui.selectedProject?.id ?? -1 const subscriptionAddons = addons?.selected_addons ?? [] const hasComputeInstance = subscriptionAddons.find((addon) => addon.type === 'compute_instance') @@ -118,7 +120,7 @@ const ExitSurveyModal = ({ visible, onClose }: ExitSurveyModalProps) => { : 'Successfully downgraded project to the free plan', }) if (hasComputeInstance) { - app.onProjectStatusUpdated(projectId, PROJECT_STATUS.RESTORING) + setProjectStatus(queryClient, projectRef, PROJECT_STATUS.RESTORING) router.push(`/project/${projectRef}`) } onClose(true) diff --git a/studio/components/interfaces/BillingV2/Subscription/Tier/MembersExceedLimitModal.tsx b/studio/components/interfaces/BillingV2/Subscription/Tier/MembersExceedLimitModal.tsx index 82d3c770205..501e9259135 100644 --- a/studio/components/interfaces/BillingV2/Subscription/Tier/MembersExceedLimitModal.tsx +++ b/studio/components/interfaces/BillingV2/Subscription/Tier/MembersExceedLimitModal.tsx @@ -1,5 +1,5 @@ import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' -import { useStore } from 'hooks' +import { useSelectedOrganization } from 'hooks' import { Button, Modal } from 'ui' export interface MembersExceedLimitModalProps { @@ -8,8 +8,8 @@ export interface MembersExceedLimitModalProps { } const MembersExceedLimitModal = ({ visible, onClose }: MembersExceedLimitModalProps) => { - const { ui } = useStore() - const slug = ui.selectedOrganization?.slug + const selectedOrganization = useSelectedOrganization() + const slug = selectedOrganization?.slug const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery({ slug }) return ( diff --git a/studio/components/interfaces/BillingV2/Subscription/Tier/PaymentMethodSelection.tsx b/studio/components/interfaces/BillingV2/Subscription/Tier/PaymentMethodSelection.tsx index 6c499a3b6c5..877d1cc9f94 100644 --- a/studio/components/interfaces/BillingV2/Subscription/Tier/PaymentMethodSelection.tsx +++ b/studio/components/interfaces/BillingV2/Subscription/Tier/PaymentMethodSelection.tsx @@ -1,12 +1,13 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useEffect, useMemo, useState } from 'react' + import { useParams } from 'common' import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query' import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organization-payment-methods-query' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' import { BASE_PATH } from 'lib/constants' import { getURL } from 'lib/helpers' -import { useEffect, useMemo, useState } from 'react' import { Button, IconAlertCircle, IconCreditCard, IconLoader, IconPlus, Listbox } from 'ui' import AddNewPaymentMethodModal from './AddNewPaymentMethodModal' @@ -21,7 +22,8 @@ const PaymentMethodSelection = ({ }: PaymentMethodSelectionProps) => { const { ui } = useStore() const { ref: projectRef } = useParams() - const slug = ui.selectedOrganization?.slug + const selectedOrganization = useSelectedOrganization() + const slug = selectedOrganization?.slug const [showAddNewPaymentMethodModal, setShowAddNewPaymentMethodModal] = useState(false) const { @@ -35,7 +37,7 @@ const PaymentMethodSelection = ({ const { data: customerProfile, isSuccess: loadedCustomerProfile } = useOrganizationCustomerProfileQuery({ slug }) - const canUpdatePaymentMethods = checkPermissions( + const canUpdatePaymentMethods = useCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.payment_methods' ) diff --git a/studio/components/interfaces/BillingV2/Subscription/Tier/SubscriptionPaymentMethod.tsx b/studio/components/interfaces/BillingV2/Subscription/Tier/SubscriptionPaymentMethod.tsx index 1b1a26c910f..ae705f20c2a 100644 --- a/studio/components/interfaces/BillingV2/Subscription/Tier/SubscriptionPaymentMethod.tsx +++ b/studio/components/interfaces/BillingV2/Subscription/Tier/SubscriptionPaymentMethod.tsx @@ -1,18 +1,19 @@ -import Panel from 'components/ui/Panel' -import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query' -import { Button, IconCreditCard, Modal } from 'ui' import * as Tooltip from '@radix-ui/react-tooltip' +import { PermissionAction } from '@supabase/shared-types/out/constants' import { useState } from 'react' -import PaymentMethodSelection from './PaymentMethodSelection' + import { useParams } from 'common' +import Panel from 'components/ui/Panel' import { SubscriptionTier, updateSubscriptionTier, } from 'data/subscriptions/project-subscription-update-mutation' -import { checkPermissions, useStore } from 'hooks' +import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query' +import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' import { BASE_PATH } from 'lib/constants' -import { PermissionAction } from '@supabase/shared-types/out/constants' import Link from 'next/link' +import { Button, IconCreditCard, Modal } from 'ui' +import PaymentMethodSelection from './PaymentMethodSelection' export interface SubscriptionTierProps { subscription: ProjectSubscriptionResponse @@ -26,9 +27,10 @@ const SubscriptionPaymentMethod = ({ const { ref: projectRef } = useParams() const { ui } = useStore() - const currentOrgSlug = ui.selectedOrganization?.slug + const selectedOrganization = useSelectedOrganization() + const currentOrgSlug = selectedOrganization?.slug - const canUpdatePaymentMethod = checkPermissions( + const canUpdatePaymentMethod = useCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.subscriptions' ) diff --git a/studio/components/interfaces/BillingV2/Subscription/Tier/SubscriptionTier.tsx b/studio/components/interfaces/BillingV2/Subscription/Tier/SubscriptionTier.tsx index a26e3eec82b..663096404ed 100644 --- a/studio/components/interfaces/BillingV2/Subscription/Tier/SubscriptionTier.tsx +++ b/studio/components/interfaces/BillingV2/Subscription/Tier/SubscriptionTier.tsx @@ -1,22 +1,23 @@ +import dayjs from 'dayjs' +import Link from 'next/link' + import { useParams } from 'common' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import SparkBar from 'components/ui/SparkBar' import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query' -import dayjs from 'dayjs' +import { useFlag, useSelectedOrganization } from 'hooks' import { useSubscriptionPageStateSnapshot } from 'state/subscription-page' import { Alert, Button, IconExternalLink } from 'ui' -import TierUpdateSidePanel from './TierUpdateSidePanel' -import Link from 'next/link' -import { useFlag, useStore } from 'hooks' import ProjectUpdateDisabledTooltip from '../../ProjectUpdateDisabledTooltip' import SubscriptionPaymentMethod from './SubscriptionPaymentMethod' +import TierUpdateSidePanel from './TierUpdateSidePanel' export interface SubscriptionTierProps {} const SubscriptionTier = ({}: SubscriptionTierProps) => { const { ref: projectRef } = useParams() - const { ui } = useStore() - const orgSlug = ui.selectedOrganization?.slug ?? '' + const selectedOrganization = useSelectedOrganization() + const orgSlug = selectedOrganization?.slug ?? '' const snap = useSubscriptionPageStateSnapshot() const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') const { data: subscription, isLoading, refetch } = useProjectSubscriptionV2Query({ projectRef }) @@ -121,7 +122,7 @@ const SubscriptionTier = ({}: SubscriptionTierProps) => { actions={ currentPlan?.id === 'free' ? ( ) : ( diff --git a/studio/components/interfaces/BillingV2/Usage/Infrastructure.tsx b/studio/components/interfaces/BillingV2/Usage/Infrastructure.tsx index 90298f7e9fe..b46e60bd771 100644 --- a/studio/components/interfaces/BillingV2/Usage/Infrastructure.tsx +++ b/studio/components/interfaces/BillingV2/Usage/Infrastructure.tsx @@ -6,11 +6,13 @@ import Link from 'next/link' import { Alert, Button, IconBarChart2 } from 'ui' import SectionContent from './SectionContent' import SectionHeader from './SectionHeader' -import { COMPUTE_INSTANCE_SPECS, USAGE_CATEGORIES } from './Usage.constants' +import { USAGE_CATEGORIES } from './Usage.constants' import { getUpgradeUrl } from './Usage.utils' import UsageBarChart from './UsageBarChart' import Panel from 'components/ui/Panel' import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query' +import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' +import { getAddons } from '../Subscription/Subscription.utils' export interface InfrastructureProps { projectRef: string @@ -31,11 +33,18 @@ const Infrastructure = ({ const upgradeUrl = getUpgradeUrl(projectRef, subscription) const isFreeTier = subscription?.plan?.id === 'free' - const currentComputeInstance = subscription?.addons.find((addon) => - addon.supabase_prod_id.includes('_instance_') - ) - const currentComputeInstanceSpecs = - COMPUTE_INSTANCE_SPECS[currentComputeInstance?.supabase_prod_id ?? 'addon_instance_micro'] + + const { data: addons, isLoading } = useProjectAddonsQuery({ projectRef }) + const selectedAddons = addons?.selected_addons ?? [] + + const { computeInstance } = getAddons(selectedAddons) + const currentComputeInstanceSpecs = computeInstance?.variant?.meta ?? { + baseline_disk_io_mbs: 87, + max_disk_io_mbs: 2085, + cpu_cores: 2, + cpu_dedicated: true, + memory_gb: 1, + } // Switch to hourly interval, if the timeframe is <48 hours let interval: '1d' | '1h' = '1d' @@ -51,7 +60,7 @@ const Infrastructure = ({ const { data: cpuUsageData, isLoading: isLoadingCpuUsageData } = useInfraMonitoringQuery({ projectRef, - attribute: 'cpu_usage', + attribute: 'max_cpu_usage', interval, startDate, endDate, @@ -82,7 +91,7 @@ const Infrastructure = ({ ) const chartMeta: { [key: string]: { data: DataPoint[]; isLoading: boolean } } = { - cpu_usage: { + max_cpu_usage: { isLoading: isLoadingCpuUsageData, data: cpuUsageData?.data ?? [], }, @@ -144,18 +153,18 @@ const Infrastructure = ({

Disk IO Bandwidth

- {currentComputeInstanceSpecs.maxBandwidth === - currentComputeInstanceSpecs.baseBandwidth ? ( + {currentComputeInstanceSpecs.baseline_disk_io_mbs === + currentComputeInstanceSpecs.max_disk_io_mbs ? (

- Your current compute can has a baseline and maximum disk throughput of - {currentComputeInstanceSpecs.maxBandwidth.toLocaleString()} Mbps. + Your current compute can has a baseline and maximum disk throughput of{' '} + {currentComputeInstanceSpecs.max_disk_io_mbs?.toLocaleString()} Mbps.

) : (

Your current compute can burst up to{' '} - {currentComputeInstanceSpecs.maxBandwidth.toLocaleString()} Mbps for 30 + {currentComputeInstanceSpecs.max_disk_io_mbs?.toLocaleString()} Mbps for 30 minutes a day and reverts to the baseline performance of{' '} - {currentComputeInstanceSpecs.baseBandwidth.toLocaleString()} Mbps. + {currentComputeInstanceSpecs.baseline_disk_io_mbs?.toLocaleString()} Mbps.

)}
@@ -163,22 +172,22 @@ const Infrastructure = ({

Overview

Current compute instance

-

{currentComputeInstance?.name ?? 'Micro'}

+

{computeInstance?.variant?.name ?? 'Micro'}

Maximum IO Bandwidth (burst limit)

- {currentComputeInstanceSpecs.maxBandwidth.toLocaleString()} Mbps + {currentComputeInstanceSpecs.max_disk_io_mbs?.toLocaleString()} Mbps

Baseline IO Bandwidth

- {currentComputeInstanceSpecs.baseBandwidth.toLocaleString()} Mbps + {currentComputeInstanceSpecs.baseline_disk_io_mbs?.toLocaleString()} Mbps

- {currentComputeInstanceSpecs.maxBandwidth !== - currentComputeInstanceSpecs.baseBandwidth && ( + {currentComputeInstanceSpecs.max_disk_io_mbs !== + currentComputeInstanceSpecs?.baseline_disk_io_mbs && (

Daily burst time limit

30 mins

@@ -205,9 +214,10 @@ const Infrastructure = ({ {attribute.key === 'ram_usage' && (

- Your compute instance has {currentComputeInstanceSpecs.memoryGb} GB of memory. + Your compute instance has {currentComputeInstanceSpecs.memory_gb} GB of + memory.

- {currentComputeInstanceSpecs.memoryGb === 1 && ( + {currentComputeInstanceSpecs.memory_gb === 1 && (

As your project is running on the smallest compute instance, it is not unusual for your project to have a base memory usage of ~50%. @@ -216,9 +226,9 @@ const Infrastructure = ({

)} - {attribute.key === 'cpu_usage' && ( + {attribute.key === 'max_cpu_usage' && (

- Your compute instance has {currentComputeInstanceSpecs.cpuCores} CPU cores. + Your compute instance has {currentComputeInstanceSpecs.cpu_cores} CPU cores.

)} diff --git a/studio/components/interfaces/BillingV2/Usage/Usage.constants.tsx b/studio/components/interfaces/BillingV2/Usage/Usage.constants.tsx index 35fad4af14b..abb971d2db1 100644 --- a/studio/components/interfaces/BillingV2/Usage/Usage.constants.tsx +++ b/studio/components/interfaces/BillingV2/Usage/Usage.constants.tsx @@ -49,11 +49,11 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ attributes: [ { anchor: 'cpu', - key: 'cpu_usage', - attribute: 'cpu_usage', + key: 'max_cpu_usage', + attribute: 'max_cpu_usage', name: 'CPU', unit: 'percentage', - description: 'CPU usage of your server', + description: 'Max CPU usage of your server', chartDescription: '', links: [ { @@ -362,69 +362,3 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ ], }, ] - -// [Joshen] Ideally live in common package for www as well -export const COMPUTE_INSTANCE_SPECS: { - [key: string]: { maxBandwidth: number; baseBandwidth: number; memoryGb: number; cpuCores: number } -} = { - addon_instance_micro: { - memoryGb: 1, - cpuCores: 2, - maxBandwidth: 2085, - baseBandwidth: 87, - }, - addon_instance_small: { - memoryGb: 2, - cpuCores: 2, - maxBandwidth: 2085, - baseBandwidth: 174, - }, - addon_instance_medium: { - memoryGb: 4, - cpuCores: 2, - maxBandwidth: 2085, - baseBandwidth: 347, - }, - addon_instance_large: { - memoryGb: 8, - cpuCores: 2, - maxBandwidth: 4750, - baseBandwidth: 630, - }, - addon_instance_xlarge: { - memoryGb: 16, - cpuCores: 4, - maxBandwidth: 4750, - baseBandwidth: 1188, - }, - addon_instance_xxlarge: { - memoryGb: 32, - cpuCores: 8, - maxBandwidth: 4750, - baseBandwidth: 2375, - }, - addon_instance_4xlarge: { - memoryGb: 64, - cpuCores: 16, - maxBandwidth: 4750, - baseBandwidth: 4750, - }, - addon_instance_8xlarge: { - memoryGb: 128, - cpuCores: 32, - maxBandwidth: 9500, - baseBandwidth: 9500, - }, - addon_instance_12xlarge: { - memoryGb: 192, - cpuCores: 48, - maxBandwidth: 14250, - baseBandwidth: 14250, - }, - addon_instance_16xlarge: { - memoryGb: 256, - cpuCores: 64, - maxBandwidth: 19000, - baseBandwidth: 19000, - }, -} diff --git a/studio/components/interfaces/Database/Backups/BackupItem.tsx b/studio/components/interfaces/Database/Backups/BackupItem.tsx index e17c909e7e6..473f4b837cb 100644 --- a/studio/components/interfaces/Database/Backups/BackupItem.tsx +++ b/studio/components/interfaces/Database/Backups/BackupItem.tsx @@ -1,14 +1,15 @@ -import dayjs from 'dayjs' -import { FC, useState } from 'react' -import { useRouter } from 'next/router' -import { observer } from 'mobx-react-lite' -import { Badge, Button, IconDownload } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' +import dayjs from 'dayjs' +import { useRouter } from 'next/router' +import { FC, useState } from 'react' -import { useStore, checkPermissions } from 'hooks' -import { API_URL, PROJECT_STATUS } from 'lib/constants' -import { post } from 'lib/common/fetch' import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal' +import { setProjectStatus } from 'data/projects/projects-query' +import { useCheckPermissions, useStore } from 'hooks' +import { post } from 'lib/common/fetch' +import { API_URL, PROJECT_STATUS } from 'lib/constants' +import { Badge, Button, IconDownload } from 'ui' interface Props { projectRef: string @@ -17,14 +18,14 @@ interface Props { } const BackupItem: FC = ({ projectRef, backup, index }) => { + const queryClient = useQueryClient() const router = useRouter() - const { app, ui } = useStore() + const { ui } = useStore() const [isDownloading, setDownloading] = useState(false) const [isRestoring, setRestoring] = useState(false) - const projectId = ui.selectedProject?.id ?? -1 - const canTriggerScheduledBackups = checkPermissions( + const canTriggerScheduledBackups = useCheckPermissions( PermissionAction.INFRA_EXECUTE, 'queue_job.restore.prepare' ) @@ -34,7 +35,7 @@ const BackupItem: FC = ({ projectRef, backup, index }) => { try { post(`${API_URL}/database/${projectRef}/backups/restore`, backup).then(() => { setTimeout(() => { - app.onProjectStatusUpdated(projectId, PROJECT_STATUS.RESTORING) + setProjectStatus(queryClient, projectRef, PROJECT_STATUS.RESTORING) ui.setNotification({ category: 'success', message: `Restoring database back to ${dayjs(backup.inserted_at).format( @@ -132,4 +133,4 @@ const BackupItem: FC = ({ projectRef, backup, index }) => { ) } -export default observer(BackupItem) +export default BackupItem diff --git a/studio/components/interfaces/Database/Backups/BackupsList.tsx b/studio/components/interfaces/Database/Backups/BackupsList.tsx index 59e94d83ed1..fd379d00293 100644 --- a/studio/components/interfaces/Database/Backups/BackupsList.tsx +++ b/studio/components/interfaces/Database/Backups/BackupsList.tsx @@ -1,24 +1,22 @@ -import { FC } from 'react' +import { PermissionAction } from '@supabase/shared-types/out/constants' import { observer } from 'mobx-react-lite' -import { useStore, checkPermissions } from 'hooks' -import Loading from 'components/ui/Loading' -import UpgradeToPro from 'components/ui/UpgradeToPro' -import Panel from 'components/ui/Panel' - -import BackupItem from './BackupItem' -import BackupsError from './BackupsError' -import BackupsEmpty from './BackupsEmpty' -import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import InformationBox from 'components/ui/InformationBox' +import Loading from 'components/ui/Loading' +import Panel from 'components/ui/Panel' +import UpgradeToPro from 'components/ui/UpgradeToPro' +import { useCheckPermissions, useStore } from 'hooks' import { IconAlertCircle, IconClock } from 'ui' +import BackupItem from './BackupItem' +import BackupsEmpty from './BackupsEmpty' +import BackupsError from './BackupsError' -interface Props {} - -const BackupsList: FC = ({}) => { - const { ui, backups } = useStore() - const projectRef = ui.selectedProject?.ref || 'default' - const canTriggerScheduledBackups = checkPermissions( +const BackupsList = () => { + const { project: selectedProject } = useProjectContext() + const { backups } = useStore() + const projectRef = selectedProject?.ref || 'default' + const canTriggerScheduledBackups = useCheckPermissions( PermissionAction.INFRA_EXECUTE, 'queue_job.restore.prepare' ) diff --git a/studio/components/interfaces/Database/Backups/PITR/PITRNotice.tsx b/studio/components/interfaces/Database/Backups/PITR/PITRNotice.tsx index 88e43844ae3..9a1e23206e7 100644 --- a/studio/components/interfaces/Database/Backups/PITR/PITRNotice.tsx +++ b/studio/components/interfaces/Database/Backups/PITR/PITRNotice.tsx @@ -2,7 +2,7 @@ import Link from 'next/link' import { Button, IconCalendar } from 'ui' import { FormPanel } from 'components/ui/Forms' import * as Tooltip from '@radix-ui/react-tooltip' -import { checkPermissions } from 'hooks' +import { useCheckPermissions } from 'hooks' import { useParams } from 'common/hooks' import { getPITRRetentionDuration } from './PITR.utils' import { PermissionAction } from '@supabase/shared-types/out/constants' @@ -13,7 +13,7 @@ const PITRNotice = ({}) => { const { data: addonsResponse } = useProjectAddonsQuery({ projectRef }) const retentionPeriod = getPITRRetentionDuration(addonsResponse?.selected_addons ?? []) - const canUpdateSubscription = checkPermissions( + const canUpdateSubscription = useCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.subscriptions' ) @@ -28,7 +28,10 @@ const PITRNotice = ({}) => { - + diff --git a/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx b/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx index eb1ecceb2f0..85ba4eed382 100644 --- a/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx +++ b/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx @@ -1,35 +1,37 @@ -import dayjs from 'dayjs' +import * as Tooltip from '@radix-ui/react-tooltip' +import { useQueryClient } from '@tanstack/react-query' import { format } from 'date-fns' +import dayjs from 'dayjs' +import { useRouter } from 'next/router' import { useState } from 'react' import DatePicker from 'react-datepicker' -import { Button, Modal, IconChevronLeft, IconChevronRight, IconHelpCircle, Alert } from 'ui' -import * as Tooltip from '@radix-ui/react-tooltip' +import { Alert, Button, IconChevronLeft, IconChevronRight, IconHelpCircle, Modal } from 'ui' import { useParams } from 'common' +import { FormHeader, FormPanel } from 'components/ui/Forms' +import InformationBox from 'components/ui/InformationBox' +import { setProjectStatus } from 'data/projects/projects-query' import { useStore } from 'hooks' import { post } from 'lib/common/fetch' import { API_URL, PROJECT_STATUS } from 'lib/constants' -import { FormHeader, FormPanel } from 'components/ui/Forms' -import InformationBox from 'components/ui/InformationBox' -import TimeInput from './TimeInput' -import TimezoneSelection from './TimezoneSelection' +import BackupsEmpty from '../BackupsEmpty' import { Timezone } from './PITR.types' -import PITRStatus from './PITRStatus' import { constrainDateToRange, formatNumberToTwoDigits, getClientTimezone, getDatesBetweenRange, } from './PITR.utils' -import { useRouter } from 'next/router' -import BackupsEmpty from '../BackupsEmpty' +import PITRStatus from './PITRStatus' +import TimeInput from './TimeInput' +import TimezoneSelection from './TimezoneSelection' -const PITRSelection = ({}) => { +const PITRSelection = () => { + const queryClient = useQueryClient() const router = useRouter() const { ref } = useParams() - const { app, ui, backups } = useStore() - const projectId = ui.selectedProject?.id ?? -1 + const { ui, backups } = useStore() const [selectedTimezone, setSelectedTimezone] = useState(getClientTimezone()) @@ -114,7 +116,7 @@ const PITRSelection = ({}) => { } else { setTimeout(() => { setShowConfirmation(false) - app.onProjectStatusUpdated(projectId, PROJECT_STATUS.RESTORING) + setProjectStatus(queryClient, projectRef, PROJECT_STATUS.RESTORING) router.push(`/project/${projectRef}`) }, 3000) } @@ -147,7 +149,6 @@ const PITRSelection = ({}) => { + + +
+
+ + {isLoading && ( +
+ + + +
+ )} + +
+ {isError && ( + + + + + , + ]} + > +

+ Try refreshing your browser, but if the issue persists, please reach out to us via + support. +

+

Error: {(error as any)?.message ?? 'Unknown'}

+
+ )} + {isSuccess && ( +
+ {data.result.length <= 0 && } + + {data.result.length > 0 && ( + <> +
+ setSearch(e.target.value)} + icon={} + /> +
+ Version, + , + ]} + body={ + migrations.length > 0 ? ( + migrations.map((migration) => ( + + {migration.version} + + + + + )) + ) : ( + + +

No results found

+

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

+
+
+ ) + } + /> + + )} + + )} + + + + setSelectedMigration(undefined)} + customFooter={ +
+ +
+ } + > +
+
+ +
+
+
+ + ) +} + +export default observer(Wrappers) diff --git a/studio/components/interfaces/Database/Migrations/MigrationsEmptyState.tsx b/studio/components/interfaces/Database/Migrations/MigrationsEmptyState.tsx new file mode 100644 index 00000000000..b4f2153339c --- /dev/null +++ b/studio/components/interfaces/Database/Migrations/MigrationsEmptyState.tsx @@ -0,0 +1,103 @@ +import clsx from 'clsx' +import { useState } from 'react' +import { Collapsible, IconMaximize2, IconMinimize2, IconTerminal } from 'ui' + +import { useParams } from 'common' +import CommandRender from 'components/interfaces/Functions/CommandRender' + +const MigrationsEmptyState = () => { + const { ref } = useParams() + const [showInstructions, setShowInstructions] = useState(false) + + const commands = [ + { + comment: 'Link your project', + command: `supabase link --project-ref ${ref}`, + jsx: () => { + return ( + <> + supabase link --project-ref {ref} + + ) + }, + }, + { + comment: 'Create a new migration called "new-migration"', + command: `supabase migration new new-migration`, + jsx: () => { + return ( + <> + supabase migration new new-migration + + ) + }, + }, + { + comment: 'Run all migrations against this project', + command: `supabase db push`, + jsx: () => { + return ( + <> + supabase db push + + ) + }, + }, + ] + + return ( +
+
+
+

Database migrations

+

+ Database changes are managed through "migrations" and they are a common way of tracking + changes to your database over time. You can run migrations against your project's + database via the Supabase CLI. +

+
+ + + + + + +
+ +
+
+
+
+
+ ) +} + +export default MigrationsEmptyState diff --git a/studio/components/interfaces/Database/Publications/PublicationsList.tsx b/studio/components/interfaces/Database/Publications/PublicationsList.tsx index ce030e980f3..385235c3f96 100644 --- a/studio/components/interfaces/Database/Publications/PublicationsList.tsx +++ b/studio/components/interfaces/Database/Publications/PublicationsList.tsx @@ -3,7 +3,7 @@ import { observer } from 'mobx-react-lite' import { PermissionAction } from '@supabase/shared-types/out/constants' import { Button, Input, Toggle, IconSearch, IconAlertCircle } from 'ui' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal' import Table from 'components/to-be-cleaned/Table' import NoSearchResults from 'components/ui/NoSearchResults' @@ -17,7 +17,7 @@ const PublicationsList: FC = ({ onSelectPublication = () => {} }) => { const { ui, meta } = useStore() const [filterString, setFilterString] = useState('') - const canUpdatePublications = checkPermissions( + const canUpdatePublications = useCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'publications' ) diff --git a/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx b/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx index 5c30d1f31cc..1a0c0ef867c 100644 --- a/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx +++ b/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx @@ -4,7 +4,7 @@ import { Badge, Toggle } from 'ui' import type { PostgresPublication, PostgresTable } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' import Table from 'components/to-be-cleaned/Table' interface Props { @@ -21,7 +21,7 @@ const PublicationsTableItem: FC = ({ table, selectedPublication }) => { selectedPublication.tables?.find((x: any) => x.id == table.id) != undefined ) - const canUpdatePublications = checkPermissions( + const canUpdatePublications = useCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'publications' ) diff --git a/studio/components/interfaces/Database/Publications/PublicationsTables.tsx b/studio/components/interfaces/Database/Publications/PublicationsTables.tsx index 73d2f67bbd5..a24f038b8a9 100644 --- a/studio/components/interfaces/Database/Publications/PublicationsTables.tsx +++ b/studio/components/interfaces/Database/Publications/PublicationsTables.tsx @@ -4,7 +4,7 @@ import { Button, Input, IconChevronLeft, IconSearch, IconAlertCircle } from 'ui' import type { PostgresPublication } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' import PublicationsTableItem from './PublicationsTableItem' import Table from 'components/to-be-cleaned/Table' import NoSearchResults from 'components/to-be-cleaned/NoSearchResults' @@ -19,7 +19,7 @@ const PublicationsTables: FC = ({ selectedPublication, onSelectBack }) => const { meta } = useStore() const [filterString, setFilterString] = useState('') - const canUpdatePublications = checkPermissions( + const canUpdatePublications = useCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'publications' ) diff --git a/studio/components/interfaces/Database/Roles/RolesList.tsx b/studio/components/interfaces/Database/Roles/RolesList.tsx index 09b4d1d24d2..85c2cd4ec62 100644 --- a/studio/components/interfaces/Database/Roles/RolesList.tsx +++ b/studio/components/interfaces/Database/Roles/RolesList.tsx @@ -6,7 +6,7 @@ import { PostgresRole } from '@supabase/postgres-meta' import { Button, Input, IconPlus, IconSearch, IconX, Badge } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' import SparkBar from 'components/ui/SparkBar' import { FormHeader } from 'components/ui/Forms' import RoleRow from './RoleRow' @@ -24,7 +24,7 @@ const RolesList = ({}) => { const [isCreatingRole, setIsCreatingRole] = useState(false) const [selectedRoleToDelete, setSelectedRoleToDelete] = useState() - const canUpdateRoles = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'roles') + const canUpdateRoles = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'roles') useEffect(() => { const getMaxConnectionLimit = async () => { diff --git a/studio/components/interfaces/Database/Tables/ColumnList.tsx b/studio/components/interfaces/Database/Tables/ColumnList.tsx index 447e4a82750..03b9a1ff1c4 100644 --- a/studio/components/interfaces/Database/Tables/ColumnList.tsx +++ b/studio/components/interfaces/Database/Tables/ColumnList.tsx @@ -5,7 +5,7 @@ import { Input, Button, IconSearch, IconPlus, IconChevronLeft, IconEdit3, IconTr import type { PostgresTable } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useStore, checkPermissions } from 'hooks' +import { useStore, useCheckPermissions } from 'hooks' import Table from 'components/to-be-cleaned/Table' import NoSearchResults from 'components/to-be-cleaned/NoSearchResults' @@ -32,7 +32,7 @@ const ColumnList: FC = ({ : selectedTable.columns?.filter((column: any) => column.name.includes(filterString))) ?? [] const isLocked = meta.excludedSchemas.includes(selectedTable.schema ?? '') - const canUpdateColumns = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns') + const canUpdateColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns') return ( <> diff --git a/studio/components/interfaces/Database/Tables/TableList.tsx b/studio/components/interfaces/Database/Tables/TableList.tsx index ecf88deec88..075c2873ecd 100644 --- a/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/studio/components/interfaces/Database/Tables/TableList.tsx @@ -16,7 +16,7 @@ import { partition } from 'lodash' import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useStore, checkPermissions } from 'hooks' +import { useStore, useCheckPermissions } from 'hooks' import Table from 'components/to-be-cleaned/Table' import NoSearchResults from 'components/to-be-cleaned/NoSearchResults' import type { PostgresTable, PostgresSchema } from '@supabase/postgres-meta' @@ -42,7 +42,7 @@ const TableList: FC = ({ const { meta } = useStore() const { isLoading } = meta.tables const [filterString, setFilterString] = useState('') - const canUpdateTables = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') + const canUpdateTables = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') const schemas: PostgresSchema[] = meta.schemas.list() const [protectedSchemas, openSchemas] = partition(schemas, (schema) => diff --git a/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx b/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx index 659ff35ba08..51212f9b822 100644 --- a/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx +++ b/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx @@ -5,7 +5,7 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' import { Badge, Button, Dropdown, IconMoreVertical, IconTrash, IconEdit3 } from 'ui' -import { useStore, checkPermissions } from 'hooks' +import { useStore, useCheckPermissions } from 'hooks' import Table from 'components/to-be-cleaned/Table' interface Props { @@ -23,7 +23,7 @@ const TriggerList: FC = ({ filterString, schema, editTrigger, deleteTrigg ) const _triggers = filteredTriggers.filter((x: any) => x.schema == schema) - const canUpdateTriggers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers') + const canUpdateTriggers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers') function onEdit(trigger: any) { editTrigger(trigger) diff --git a/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx b/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx index 75fa2be96dc..1f39f35c974 100644 --- a/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx +++ b/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx @@ -5,7 +5,7 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { Button, Input, IconSearch, IconLoader } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' import SchemaTable from './SchemaTable' import AlphaPreview from 'components/to-be-cleaned/AlphaPreview' import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' @@ -23,7 +23,7 @@ const TriggersList: FC = ({ includes(x.name.toLowerCase(), filterString.toLowerCase()) ) const filteredTriggerSchemas = lodashMap(uniqBy(filteredTriggers, 'schema'), 'schema') - const canCreateTriggers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers') + const canCreateTriggers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers') if (meta.triggers.isLoading) { return ( diff --git a/studio/components/interfaces/Database/Wrappers/CreateWrapper.tsx b/studio/components/interfaces/Database/Wrappers/CreateWrapper.tsx index b2693a6ee2b..84e100f16e9 100644 --- a/studio/components/interfaces/Database/Wrappers/CreateWrapper.tsx +++ b/studio/components/interfaces/Database/Wrappers/CreateWrapper.tsx @@ -6,7 +6,7 @@ import { observer } from 'mobx-react-lite' import { Button, Form, Input, IconArrowLeft, IconExternalLink, IconEdit, IconTrash } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' import { useParams } from 'common/hooks' import { FormPanel, @@ -37,7 +37,7 @@ const CreateWrapper = () => { const [selectedTableToEdit, setSelectedTableToEdit] = useState() const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({}) - const canCreateWrapper = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers') + const canCreateWrapper = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers') const wrapperMeta = WRAPPERS.find((wrapper) => wrapper.name === type) const initialValues = diff --git a/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx b/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx index bb82a567cdf..8c26b54fe79 100644 --- a/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx +++ b/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx @@ -16,7 +16,7 @@ import { import { PermissionAction } from '@supabase/shared-types/out/constants' import { VaultSecret } from 'types' -import { checkPermissions, useImmutableValue, useStore } from 'hooks' +import { useCheckPermissions, useImmutableValue, useStore } from 'hooks' import { useParams } from 'common/hooks' import { useFDWsQuery } from 'data/fdw/fdws-query' import { useFDWUpdateMutation } from 'data/fdw/fdw-update-mutation' @@ -65,7 +65,7 @@ const EditWrapper = () => { const [selectedTableToEdit, setSelectedTableToEdit] = useState() const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({}) - const canUpdateWrapper = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers') + const canUpdateWrapper = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers') const initialValues = wrapperMeta !== undefined diff --git a/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx b/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx index d22d91b727f..411c23e0ac3 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx @@ -6,7 +6,7 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' import { Collapsible, IconChevronUp, Button, IconExternalLink, IconTrash, IconEdit } from 'ui' -import { useStore, checkPermissions } from 'hooks' +import { useStore, useCheckPermissions } from 'hooks' import { useParams } from 'common/hooks' import { WrapperMeta } from './Wrappers.types' import { FDW } from 'data/fdw/fdws-query' @@ -27,7 +27,7 @@ const WrapperRow: FC = ({ wrappers = [], wrapperMeta, isOpen, onOpen }) = const { project } = useProjectContext() const { mutateAsync: deleteFDW } = useFDWDeleteMutation() - const canManageWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers') + const canManageWrappers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers') const onDeleteWrapper = (wrapper: any) => { confirmAlert({ diff --git a/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx b/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx index f21bf7bbe6b..7a74cdb78ec 100644 --- a/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx @@ -5,7 +5,7 @@ import { observer } from 'mobx-react-lite' import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' import { useParams } from 'common/hooks' import { BASE_PATH } from 'lib/constants' import { useTheme } from 'common' @@ -19,7 +19,10 @@ const WrappersDisabledState = () => { const isNotAvailable = wrappersExtension === undefined || vaultExtension === undefined const [isEnabling, setIsEnabling] = useState(false) - const canToggleWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'extensions') + const canToggleWrappers = useCheckPermissions( + PermissionAction.TENANT_SQL_ADMIN_WRITE, + 'extensions' + ) const onEnableWrappers = async () => { if (wrappersExtension === undefined || vaultExtension === undefined) return diff --git a/studio/components/interfaces/Database/Wrappers/WrappersDropdown.tsx b/studio/components/interfaces/Database/Wrappers/WrappersDropdown.tsx index 33b5cd693ed..5fdab17e076 100644 --- a/studio/components/interfaces/Database/Wrappers/WrappersDropdown.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrappersDropdown.tsx @@ -6,7 +6,7 @@ import { Button, Dropdown, IconPlus } from 'ui' import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions } from 'hooks' +import { useCheckPermissions } from 'hooks' import { useParams } from 'common/hooks' import { WRAPPERS } from './Wrappers.constants' @@ -17,7 +17,7 @@ interface Props { const WrapperDropdown: FC = ({ buttonText = 'Add wrapper', align = 'end' }) => { const { ref } = useParams() - const canManageWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers') + const canManageWrappers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers') if (!canManageWrappers) { return ( diff --git a/studio/components/interfaces/Docs/LangSelector.tsx b/studio/components/interfaces/Docs/LangSelector.tsx index a0e05272874..e9bf2158ef0 100644 --- a/studio/components/interfaces/Docs/LangSelector.tsx +++ b/studio/components/interfaces/Docs/LangSelector.tsx @@ -1,6 +1,6 @@ import { FC } from 'react' import { Button, Dropdown, IconKey } from 'ui' -import { checkPermissions } from 'hooks' +import { useCheckPermissions } from 'hooks' import { PermissionAction } from '@supabase/shared-types/out/constants' import { showApiKey } from 'components/interfaces/Docs/Docs.types' @@ -24,7 +24,7 @@ const LangSelector: FC = ({ apiKey, autoApiService, }) => { - const canReadServiceKey = checkPermissions( + const canReadServiceKey = useCheckPermissions( PermissionAction.READ, 'service_api_keys.service_role_key' ) diff --git a/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx b/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx index a3d970c4fa1..60a433db257 100644 --- a/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx +++ b/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx @@ -17,7 +17,7 @@ import { IconExternalLink, } from 'ui' -import { useStore, checkPermissions } from 'hooks' +import { useStore, useCheckPermissions } from 'hooks' import { useParams } from 'common/hooks' import Panel from 'components/ui/Panel' import CommandRender from '../CommandRender' @@ -51,7 +51,7 @@ const EdgeFunctionDetails: FC = () => { const { mutateAsync: deleteEdgeFunction, isLoading: isDeleting } = useEdgeFunctionDeleteMutation() const formId = 'edge-function-update-form' - const canUpdateEdgeFunction = checkPermissions(PermissionAction.FUNCTIONS_WRITE, '*') + const canUpdateEdgeFunction = useCheckPermissions(PermissionAction.FUNCTIONS_WRITE, '*') // Get the API service const apiService = settings?.autoApiService diff --git a/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx b/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx index cea9b1e2fb2..4237781074c 100644 --- a/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx +++ b/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx @@ -1,14 +1,13 @@ -import dayjs from 'dayjs' -import { FC, useState } from 'react' -import { useRouter } from 'next/router' -import { observer } from 'mobx-react-lite' -import { IconCheck, IconClipboard } from 'ui' import * as Tooltip from '@radix-ui/react-tooltip' +import dayjs from 'dayjs' +import { useRouter } from 'next/router' +import { FC, useState } from 'react' -import { useStore } from 'hooks' import { useParams } from 'common/hooks' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import Table from 'components/to-be-cleaned/Table' import { EdgeFunctionsResponse } from 'data/edge-functions/edge-functions-query' +import { IconCheck, IconClipboard } from 'ui' interface Props { function: EdgeFunctionsResponse @@ -16,12 +15,12 @@ interface Props { const EdgeFunctionsListItem: FC = ({ function: item }) => { const router = useRouter() - const { ui } = useStore() const { ref } = useParams() const [isCopied, setIsCopied] = useState(false) + const { project } = useProjectContext() // get the .co or .net TLD from the restUrl - const restUrl = ui.selectedProject?.restUrl + const restUrl = project?.restUrl const restUrlTld = new URL(restUrl as string).hostname.split('.').pop() const functionUrl = `https://${ref}.supabase.${restUrlTld}/functions/v1/${item.slug}` @@ -103,4 +102,4 @@ const EdgeFunctionsListItem: FC = ({ function: item }) => { ) } -export default observer(EdgeFunctionsListItem) +export default EdgeFunctionsListItem diff --git a/studio/components/interfaces/Home/ClientLibrary.tsx b/studio/components/interfaces/Home/ClientLibrary.tsx index fdabae5cadb..ff78b50643a 100644 --- a/studio/components/interfaces/Home/ClientLibrary.tsx +++ b/studio/components/interfaces/Home/ClientLibrary.tsx @@ -5,29 +5,26 @@ import { BASE_PATH } from 'lib/constants' interface Props { language: string officialSupport?: boolean - releaseState?: string docsUrl?: string gitUrl: string + altIconName?: string } -const ClientLibrary: FC = ({ language, releaseState, officialSupport, docsUrl, gitUrl }) => { +const ClientLibrary: FC = ({ language, officialSupport, docsUrl, gitUrl, altIconName }) => { return (
{`${language}
- {language} {releaseState && {`Public ${releaseState}`}} + {language} {!officialSupport && Community}
-

- {officialSupport - ? 'This library is officially supported' - : 'This library is community supported'} -

{docsUrl && ( diff --git a/studio/components/interfaces/Home/Home.constants.ts b/studio/components/interfaces/Home/Home.constants.ts index 319aa46eb5d..52787f999a3 100644 --- a/studio/components/interfaces/Home/Home.constants.ts +++ b/studio/components/interfaces/Home/Home.constants.ts @@ -6,13 +6,6 @@ export const CLIENT_LIBRARIES = [ docsUrl: 'https://supabase.com/docs/reference/javascript/installing', gitUrl: 'https://github.com/supabase/supabase-js', }, - { - language: 'Python', - officialSupport: false, - releaseState: 'Alpha', - docsUrl: 'https://supabase.com/docs/reference/python/introduction', - gitUrl: 'https://github.com/supabase/supabase-py', - }, { language: 'Flutter', officialSupport: true, @@ -20,6 +13,35 @@ export const CLIENT_LIBRARIES = [ docsUrl: 'https://supabase.com/docs/reference/dart/installing', gitUrl: 'https://github.com/supabase/supabase-flutter', }, + { + language: 'Python', + officialSupport: false, + releaseState: 'Alpha', + docsUrl: 'https://supabase.com/docs/reference/python/initializing', + gitUrl: 'https://github.com/supabase/supabase-py', + }, + { + language: 'C#', + officialSupport: false, + releaseState: undefined, + docsUrl: 'https://supabase.com/docs/reference/csharp/installing', + gitUrl: 'https://github.com/supabase-community/supabase-csharp', + altIconName: 'c-sharp', + }, + { + language: 'Swift', + officialSupport: false, + releaseState: undefined, + docsUrl: 'https://supabase.com/docs/reference/swift/initializing', + gitUrl: 'https://github.com/supabase-community/supabase-swift', + }, + { + language: 'Kotlin', + officialSupport: false, + releaseState: undefined, + docsUrl: 'https://supabase.com/docs/reference/kotlin/installing', + gitUrl: 'https://github.com/supabase-community/supabase-kt', + }, ] export const EXAMPLE_PROJECTS = [ diff --git a/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx b/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx index 42b39dbac4b..815ac8cec05 100644 --- a/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx +++ b/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx @@ -6,7 +6,7 @@ import { IconAlertCircle, IconLoader, Input } from 'ui' import { useProjectApiQuery } from 'data/config/project-api-query' import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' -import { checkPermissions } from 'hooks' +import { useCheckPermissions } from 'hooks' import { useParams } from 'common/hooks' import Snippets from 'components/to-be-cleaned/Docs/Snippets' import Panel from 'components/ui/Panel' @@ -36,7 +36,7 @@ const APIKeys = () => { } = useJwtSecretUpdatingStatusQuery({ projectRef }) const jwtSecretUpdateStatus = data?.jwtSecretUpdateStatus - const canReadAPIKeys = checkPermissions(PermissionAction.READ, 'service_api_keys') + const canReadAPIKeys = useCheckPermissions(PermissionAction.READ, 'service_api_keys') // Get the API service const apiService = settings?.autoApiService diff --git a/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx b/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx index dff7b3a46bd..02126a1cf98 100644 --- a/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx +++ b/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx @@ -5,7 +5,7 @@ import { useProjectReadOnlyStatusQuery } from 'data/projects/project-readonly-st import { PROJECT_STATUS } from 'lib/constants' import { Project } from 'types' -interface ProjectCardProps { +export interface ProjectCardProps { project: Project rewriteHref?: string } @@ -16,9 +16,10 @@ const ProjectCard = ({ project, rewriteHref }: ProjectCardProps) => { const { data: readonlyStatus } = useProjectReadOnlyStatusQuery({ projectRef }) - // Project status should supercede is read only status + // Project status should supersede is read only status const isHealthy = project.status === PROJECT_STATUS.ACTIVE_HEALTHY - const isPausing = project.status === PROJECT_STATUS.GOING_DOWN + const isPausing = + project.status === PROJECT_STATUS.GOING_DOWN || project.status === PROJECT_STATUS.PAUSING const isPaused = project.status === PROJECT_STATUS.INACTIVE const isRestoring = project.status === PROJECT_STATUS.RESTORING const isReadonly = readonlyStatus?.enabled ?? false diff --git a/studio/components/interfaces/Home/ProjectList/ProjectList.tsx b/studio/components/interfaces/Home/ProjectList/ProjectList.tsx index 4f26eeb077b..e4ee9880f3e 100644 --- a/studio/components/interfaces/Home/ProjectList/ProjectList.tsx +++ b/studio/components/interfaces/Home/ProjectList/ProjectList.tsx @@ -1,102 +1,64 @@ -import Link from 'next/link' -import { FC } from 'react' -import { Badge, Button, IconPlus } from 'ui' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { groupBy } from 'lodash' import { observer } from 'mobx-react-lite' +import Link from 'next/link' +import { Badge, Button, IconPlus } from 'ui' +import AlertError from 'components/ui/AlertError' +import { + OverdueInvoicesResponse, + useOverdueInvoicesQuery, +} from 'data/invoices/invoices-overdue-query' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { usePermissionsQuery } from 'data/permissions/permissions-query' +import { useProjectsQuery } from 'data/projects/projects-query' +import { useCheckPermissions } from 'hooks' import { IS_PLATFORM } from 'lib/constants' -import { checkPermissions, useStore } from 'hooks' -import { Organization, Project } from 'types' import { makeRandomString } from 'lib/helpers' +import { Organization, Project, ResponseError } from 'types' import ProjectCard from './ProjectCard' import ShimmeringCard from './ShimmeringCard' -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useOverdueInvoicesQuery } from 'data/invoices/invoices-overdue-query' -interface Props { +export interface ProjectListProps { rewriteHref?: (projectRef: string) => string } -const ProjectList: FC = ({ rewriteHref }) => { - const { app, ui } = useStore() - const { organizations, projects } = app - const { isLoading: isLoadingProjects } = projects - +const ProjectList = ({ rewriteHref }: ProjectListProps) => { + const { data: organizations } = useOrganizationsQuery() + const { + data: allProjects, + isLoading: isLoadingProjects, + isError: isErrorProjects, + error: projectsError, + } = useProjectsQuery() + const { + isLoading: _isLoadingPermissions, + isError: isErrorPermissions, + error: permissionsError, + } = usePermissionsQuery() const { data: allOverdueInvoices } = useOverdueInvoicesQuery({ enabled: IS_PLATFORM }) - - const isLoadingPermissions = IS_PLATFORM ? (ui?.permissions ?? []).length === 0 : false + const projectsByOrg = groupBy(allProjects, 'organization_id') + const isLoadingPermissions = IS_PLATFORM ? _isLoadingPermissions : false return ( <> - {organizations.list().map((org: Organization) => { - const { id, name, slug, subscription_id } = org - const sortedProjects = projects.list( - ({ organization_id }: Project) => organization_id == id - ) - const isEmpty = sortedProjects?.length == 0 - const canReadProjects = checkPermissions(PermissionAction.READ, 'projects', undefined, id) - - const overdueInvoices = (allOverdueInvoices || []).filter((it) => it.organization_id === id) - + {organizations?.map((organization) => { return ( -
-
-

{name}{subscription_id ? V2 : <>}

- - {!!overdueInvoices.length && ( - - )} -
- - {isLoadingPermissions || isLoadingProjects ? ( -
    - - -
- ) : ( -
    - {!canReadProjects ? ( -
    -
    -

    You need additional permissions to view projects from this organization

    -

    - Contact your organization owner or adminstrator for assistance. -

    -
    -
    - ) : isEmpty ? ( -
    -
    -

    No projects

    -

    - Get started by creating a new project. -

    -
    - -
    - ) : ( - sortedProjects?.map((project: Project) => ( - - )) - )} -
+ it.organization_id === organization.id )} -
+ rewriteHref={rewriteHref} + isLoadingPermissions={isLoadingPermissions} + isErrorPermissions={isErrorPermissions} + permissionsError={permissionsError} + isLoadingProjects={isLoadingProjects} + isErrorProjects={isErrorProjects} + projectsError={projectsError} + /> ) })} @@ -104,3 +66,109 @@ const ProjectList: FC = ({ rewriteHref }) => { } export default observer(ProjectList) + +type OrganizationProjectsProps = { + organization: Organization + projects: Project[] + overdueInvoices: OverdueInvoicesResponse[] + isLoadingPermissions: boolean + isErrorPermissions: boolean + permissionsError: ResponseError | null + isLoadingProjects: boolean + isErrorProjects: boolean + projectsError: ResponseError | null + rewriteHref?: (projectRef: string) => string +} + +const OrganizationProjects = ({ + organization: { id, name, slug, subscription_id }, + projects, + overdueInvoices, + isLoadingPermissions, + isErrorPermissions, + permissionsError, + isLoadingProjects, + isErrorProjects, + projectsError, + rewriteHref, +}: OrganizationProjectsProps) => { + const isEmpty = !projects || projects.length === 0 + const canReadProjects = useCheckPermissions(PermissionAction.READ, 'projects', undefined, id) + + return ( +
+
+

+ {name} + {subscription_id ? V2 : <>} +

+ + {!!overdueInvoices.length && ( + + )} +
+ + {isLoadingPermissions || isLoadingProjects ? ( +
    + + +
+ ) : ( +
    + {isErrorPermissions ? ( +
    + +
    + ) : isErrorProjects ? ( +
    + +
    + ) : !canReadProjects ? ( +
    +
    +

    You need additional permissions to view projects from this organization

    +

    + Contact your organization owner or administrator for assistance. +

    +
    +
    + ) : isEmpty ? ( +
    +
    +

    No projects

    +

    Get started by creating a new project.

    +
    + +
    + ) : ( + projects?.map((project) => ( + + )) + )} +
+ )} +
+ ) +} diff --git a/studio/components/interfaces/Integrations/IntegrationPanels.tsx b/studio/components/interfaces/Integrations/IntegrationPanels.tsx new file mode 100644 index 00000000000..e4e6d36cb9f --- /dev/null +++ b/studio/components/interfaces/Integrations/IntegrationPanels.tsx @@ -0,0 +1,277 @@ +import dayjs from 'dayjs' +import Image from 'next/image' +import React from 'react' + +import { Markdown } from 'components/interfaces/Markdown' +import { Integration, IntegrationProjectConnection } from 'data/integrations/integrations.types' +import { useProjectsQuery } from 'data/projects/projects-query' +import { BASE_PATH } from 'lib/constants' +import { getVercelConfigurationUrl } from 'lib/integration-utils' +import { Badge, Button, IconArrowRight, IconExternalLink, IconGitHub, IconSquare, cn } from 'ui' + +const ICON_STROKE_WIDTH = 2 +const ICON_SIZE = 14 + +export interface IntegrationInstallationProps extends React.RefAttributes { + title: string + integration: Integration +} + +type HandleIconType = Integration['integration']['name'] | 'Supabase' + +const HandleIcon = ({ type, className }: { type: HandleIconType; className?: string }) => { + switch (type) { + case 'GitHub': + return + break + case 'Netlify': + return + break + case 'Vercel': + return ( + + + + ) + + break + case 'Supabase': + return Supabase + break + + default: + return <> + break + } +} + +const Avatar = ({ src }: { src: string | undefined }) => { + return ( +
+ +
+ ) +} + +const IntegrationInstallation = React.forwardRef( + ({ title, integration, ...props }, ref) => { + const IntegrationIconBlock = () => { + return ( +
+ +
+ ) + } + + return ( +
  • +
    +
    +
    + + +
    +
    +
    +
    + + {/* {title} integration connection •{' '} */} + {integration.metadata?.account.name || integration.metadata?.gitHubConnectionOwner} + + + + {integration.metadata?.account.type} + +
    +
    + + Created {dayjs(integration.inserted_at).fromNow()} + + + Added by {integration?.added_by?.primary_email} + +
    +
    +
    + + +
  • + ) + } +) + +export interface IntegrationConnectionProps extends React.HTMLAttributes { + connection: IntegrationProjectConnection + type: Integration['integration']['name'] + actions?: React.ReactNode +} + +const IntegrationConnection = React.forwardRef( + ({ className, connection, type, actions, ...props }, ref) => { + const { data: projects } = useProjectsQuery() + const project = projects?.find((project) => project.ref === connection.supabase_project_ref) + + return ( +
  • +
    +
    +
    +
    + + {project?.name} + + {!connection?.metadata?.framework ? ( +
    + +
    + ) : ( + {`icon`} + )} + {connection.metadata?.name} +
    + +
    + + Connected {dayjs(connection?.inserted_at).fromNow()} + + + Added by {connection?.added_by?.primary_email} + +
    +
    + +
    {actions}
    +
    +
  • + ) + } +) + +const IntegrationConnectionOption = React.forwardRef( + ({ className, connection, type, ...props }, ref) => { + const { data: projects } = useProjectsQuery() + const project = projects?.find((project) => project.ref === connection.supabase_project_ref) + + return ( +
  • +
    +
    + + {project?.name} + + + {connection.metadata.name} +
    + + + Connected {dayjs(connection.inserted_at).fromNow()} + +
    + + +
  • + ) + } +) + +const EmptyIntegrationConnection = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + return ( +
    +
    +
    + +
    +
    + ) +}) + +interface IntegrationConnectionHeader extends React.HTMLAttributes { + name?: string + markdown?: string +} + +const IntegrationConnectionHeader = React.forwardRef( + ({ className, name, markdown = '', ...props }, ref) => { + return ( +
    + +
    + ) + } +) + +IntegrationInstallation.displayName = 'IntegrationInstallation' +IntegrationConnection.displayName = 'IntegrationConnection' +IntegrationConnectionHeader.displayName = 'IntegrationConnectionHeader' +EmptyIntegrationConnection.displayName = 'EmptyIntegrationConnection' +IntegrationConnectionOption.displayName = 'IntegrationConnectionOption' + +export { + EmptyIntegrationConnection, + IntegrationConnection, + IntegrationConnectionHeader, + IntegrationConnectionOption, + IntegrationInstallation, +} diff --git a/studio/components/interfaces/Integrations/Integrations-Vercel.constants.ts b/studio/components/interfaces/Integrations/Integrations-Vercel.constants.ts new file mode 100644 index 00000000000..4513c7a4aed --- /dev/null +++ b/studio/components/interfaces/Integrations/Integrations-Vercel.constants.ts @@ -0,0 +1,52 @@ +export const ENV_VAR_KEYS = { + POSTGRES_URL: { + key: 'POSTGRES_URL', + type: 'encrypted', + }, + POSTGRES_PRISMA_URL: { + key: 'POSTGRES_PRISMA_URL', + type: 'encrypted', + }, + POSTGRES_URL_NON_POOLING: { + key: 'POSTGRES_URL_NON_POOLING', + type: 'encrypted', + }, + POSTGRES_USER: { + key: 'POSTGRES_USER', + type: 'encrypted', + }, + POSTGRES_HOST: { + key: 'POSTGRES_HOST', + type: 'encrypted', + }, + POSTGRES_PASSWORD: { + key: 'POSTGRES_PASSWORD', + type: 'encrypted', + }, + POSTGRES_DATABASE: { + key: 'POSTGRES_DATABASE', + type: 'encrypted', + }, + SUPABASE_ANON_KEY: { + key: 'SUPABASE_ANON_KEY', + type: 'encrypted', + }, + SUPABASE_URL: { + key: 'SUPABASE_URL', + type: 'encrypted', + }, + SUPABASE_SERVICE_ROLE_KEY: { + key: 'SUPABASE_SERVICE_ROLE_KEY', + type: 'encrypted', + }, + NEXT_PUBLIC_SUPABASE_ANON_KEY: { + key: 'NEXT_PUBLIC_SUPABASE_ANON_KEY', + type: 'encrypted', + }, + NEXT_PUBLIC_SUPABASE_URL: { + key: 'NEXT_PUBLIC_SUPABASE_URL', + type: 'encrypted', + }, +} as const + +export const ENV_VAR_RAW_KEYS = Object.values(ENV_VAR_KEYS).map((x) => x.key) diff --git a/studio/components/interfaces/Integrations/ProjectLinker.tsx b/studio/components/interfaces/Integrations/ProjectLinker.tsx new file mode 100644 index 00000000000..0be4ac9d5d4 --- /dev/null +++ b/studio/components/interfaces/Integrations/ProjectLinker.tsx @@ -0,0 +1,228 @@ +import { useState } from 'react' + +import { ENV_VAR_RAW_KEYS } from 'components/interfaces/Integrations/Integrations-Vercel.constants' +import { Markdown } from 'components/interfaces/Markdown' +import { vercelIcon } from 'components/to-be-cleaned/ListIcons' +import { useIntegrationConnectionsCreateMutation } from 'data/integrations/integration-connections-create-mutation' +import { VercelProjectsResponse } from 'data/integrations/integrations-vercel-projects-query' +import { IntegrationProjectConnection } from 'data/integrations/integrations.types' +import { useSelectedOrganization } from 'hooks' +import { BASE_PATH } from 'lib/constants' +import { Button, Listbox, cn } from 'ui' + +interface Project { + id: string + name: string + ref: string +} + +export interface ProjectLinkerProps { + organizationIntegrationId: string | undefined + foreignProjects: VercelProjectsResponse[] + supabaseProjects: Project[] + onCreateConnections?: () => void + installedConnections: IntegrationProjectConnection[] | undefined + setLoading?: (x: boolean) => void +} + +const UNDEFINED_SELECT_VALUE = 'undefined' + +const ProjectLinker = ({ + organizationIntegrationId, + foreignProjects, + supabaseProjects, + onCreateConnections: _onCreateConnections, + installedConnections = [], + setLoading, +}: ProjectLinkerProps) => { + const selectedOrganization = useSelectedOrganization() + + const [supabaseProjectRef, setSupabaseProjectRef] = useState(UNDEFINED_SELECT_VALUE) + const [vercelProjectId, setVercelProjectId] = useState(UNDEFINED_SELECT_VALUE) + + const { mutate: createConnections, isLoading } = useIntegrationConnectionsCreateMutation({ + onSuccess() { + _onCreateConnections?.() + }, + + onSettled() { + if (setLoading) setLoading(false) + }, + }) + + function onCreateConnections() { + const projectDetails = foreignProjects.filter((x) => x.id === vercelProjectId)[0] + + if (!organizationIntegrationId) { + console.error('No integration ID set') + return + } + + if (setLoading) setLoading(true) + + createConnections({ + organizationIntegrationId, + connection: { + foreign_project_id: vercelProjectId, + supabase_project_ref: supabaseProjectRef, + metadata: { + ...projectDetails, + supabaseConfig: { + projectEnvVars: { + write: true, + }, + }, + }, + }, + orgSlug: selectedOrganization?.slug, + }) + } + + const Panel = ({ children, className, ...props }: React.HTMLAttributes) => { + return ( +
    + {children} +
    + ) + } + + // create a flat array of foreign project ids. ie, ["prj_MlkO6AiLG5ofS9ojKrkS3PhhlY3f", ..] + const flatInstalledConnectionsIds = new Set(installedConnections.map((x) => x.foreign_project_id)) + + // check that vercel project is not already installed + const filteredForeignProjects: VercelProjectsResponse[] = foreignProjects.filter( + (foreignProject) => { + return !flatInstalledConnectionsIds.has(foreignProject.id) + } + ) + + return ( +
    +
    +
    +
    + +
    + Supabase +
    + setSupabaseProjectRef(e)} + > + + Choose project + + {supabaseProjects.map((project) => ( + { + return ( + <> +
    + Supabase +
    + + ) + }} + > + {project.name} +
    + ))} +
    +
    +
    + +
    + {/* integration icon */} + + + +
    + setVercelProjectId(e)} + > + + Choose Vercel project + + {filteredForeignProjects.map((project) => { + return ( + { + return ( + <> + {!project?.framework ? ( + vercelIcon + ) : ( + {`icon`} + )} + + ) + }} + > + {project.name} + + ) + })} + +
    +
    +
    +
    + +
    + { + return ` + \n + - \`${x}\` +` +})} +`} + /> +
    + ) +} + +export default ProjectLinker diff --git a/studio/components/interfaces/Markdown.tsx b/studio/components/interfaces/Markdown.tsx new file mode 100644 index 00000000000..1755f86c843 --- /dev/null +++ b/studio/components/interfaces/Markdown.tsx @@ -0,0 +1,18 @@ +import { ReactMarkdownProps } from 'react-markdown/lib/complex-types' +import { ReactMarkdown } from 'react-markdown/lib/react-markdown' +import { cn } from 'ui' + +interface Props extends Omit { + className?: string + content: string +} + +const Markdown = ({ className, content, ...props }: Props) => { + return ( + + {content} + + ) +} + +export { Markdown } diff --git a/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx b/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx new file mode 100644 index 00000000000..c463ce98397 --- /dev/null +++ b/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx @@ -0,0 +1,346 @@ +import * as Tooltip from '@radix-ui/react-tooltip' +import dayjs from 'dayjs' +import Image from 'next/image' +import { useState } from 'react' + +import { useParams } from 'common' +import Table from 'components/to-be-cleaned/Table' +import AlertError from 'components/ui/AlertError' +import { DatePicker } from 'components/ui/DatePicker' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { + OrganizationAuditLog, + useOrganizationAuditLogsQuery, +} from 'data/organizations/organization-audit-logs-query' +import { useOrganizationDetailQuery } from 'data/organizations/organization-detail-query' +import { useOrganizationRolesQuery } from 'data/organizations/organization-roles-query' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useProjectsQuery } from 'data/projects/projects-query' +import { Alert, Button, IconArrowDown, IconArrowUp, IconRefreshCw, IconUser } from 'ui' +import FilterPopover from './FilterPopover' +import LogDetailsPanel from './LogDetailsPanel' +import { ScaffoldContainerLegacy } from 'components/layouts/Scaffold' + +// [Joshen considerations] +// - Maybe fix the height of the table to the remaining height of the viewport, so that the search input is always visible +// - We'll need pagination as well if the audit logs get too large, but that needs to be implemented on the API side first if possible +// - I've hidden time input in the date picker for now cause the time support in the component is a bit iffy, need to investigate +// - Maybe a rule to follow from here is just everytime we call dayjs, use UTC(), one TZ to rule them all + +const AuditLogs = () => { + const { slug } = useParams() + const currentTime = dayjs().utc().set('millisecond', 0) + const [dateSortDesc, setDateSortDesc] = useState(true) + const [dateRange, setDateRange] = useState({ + from: currentTime.subtract(1, 'day').toISOString(), + to: currentTime.toISOString(), + }) + const [selectedLog, setSelectedLog] = useState() + const [filters, setFilters] = useState<{ users: string[]; projects: string[] }>({ + users: [], // gotrue_id[] + projects: [], // project_ref[] + }) + + const { data: projects } = useProjectsQuery() + const { data: organizations } = useOrganizationsQuery() + const { data: detailData } = useOrganizationDetailQuery({ slug }) + const { data: rolesData } = useOrganizationRolesQuery({ slug }) + const { data, error, isLoading, isSuccess, isError, isRefetching, refetch } = + useOrganizationAuditLogsQuery({ + slug, + iso_timestamp_start: dateRange.from, + iso_timestamp_end: dateRange.to, + }) + + const members = detailData?.members ?? [] + const roles = rolesData?.roles ?? [] + + const retentionPeriod = data?.retention_period ?? 0 + const logs = data?.result ?? [] + const sortedLogs = logs + ?.sort((a, b) => + dateSortDesc + ? Number(new Date(b.occurred_at)) - Number(new Date(a.occurred_at)) + : Number(new Date(a.occurred_at)) - Number(new Date(b.occurred_at)) + ) + ?.filter((log) => { + if (filters.users.length > 0) { + return filters.users.includes(log.actor.id) + } else { + return log + } + }) + ?.filter((log) => { + if (filters.projects.length > 0) { + return filters.projects.includes(log.target.metadata.project_ref || '') + } else { + return log + } + }) + + return ( + <> + +
    +
    +
    +

    Filter by

    + setFilters({ ...filters, users: values })} + /> + setFilters({ ...filters, projects: values })} + /> + { + if (value.from !== null && value.to !== null) { + const current = dayjs().utc() + const from = dayjs(value.from) + .utc() + .hour(current.hour()) + .minute(current.minute()) + .second(current.second()) + .toISOString() + const to = dayjs(value.to) + .utc() + .hour(current.hour()) + .minute(current.minute()) + .second(current.second()) + .toISOString() + setDateRange({ from, to }) + } + }} + renderFooter={() => { + return ( + + Your organization has a log retention period of{' '} + + {retentionPeriod} day + {retentionPeriod > 1 ? 's' : ''} + + . You may only view logs from{' '} + {dayjs().subtract(retentionPeriod, 'days').format('DD MMM YYYY')} as the + earliest date. + + ) + }} + /> + {isSuccess && ( + <> +
    +

    Viewing {sortedLogs.length} logs in total

    + + )} +
    + +
    + + {isLoading && ( +
    + + + +
    + )} + + {isError && } + + {isSuccess && ( + <> + {logs.length === 0 ? ( +
    +

    + Your organization does not have any audit logs available yet +

    +
    + ) : logs.length > 0 && sortedLogs.length === 0 ? ( +
    +

    No audit logs found based on the filters applied

    +
    + ) : ( +
    + User + , + + Action + , + + Target + , + +
    +

    Date

    + + + +
    +
    , + , + ]} + body={ + sortedLogs?.map((log) => { + const user = members.find((member) => member.gotrue_id === log.actor.id) + const role = roles.find((role) => user?.role_ids?.[0] === role.id) + const project = projects?.find( + (project) => project.ref === log.target.metadata.project_ref + ) + const organization = organizations?.find( + (org) => org.slug === log.target.metadata.org_slug + ) + + const hasStatusCode = log.action.metadata[0]?.status !== undefined + const userIcon = + user === undefined ? ( +
    +

    ?

    +
    + ) : user?.invited_id || user?.username === user?.primary_email ? ( +
    + +
    + ) : ( + + ) + + return ( + setSelectedLog(log)} + className="cursor-pointer hover:!bg-scale-100 transition duration-100" + > + +
    +
    {userIcon}
    +
    +

    {user?.username ?? 'Unknown'}

    + {role && ( +

    {role?.name}

    + )} +
    +
    +
    + +
    + {hasStatusCode && ( +

    + {log.action.metadata[0].status} +

    + )} +

    + {log.action.name} +

    +
    +
    + +

    + {project?.name + ? 'Project: ' + : organization?.name + ? 'Organization: ' + : null} + {project?.name ?? organization?.name ?? 'Unknown'} +

    +

    + {log.target.metadata.project_ref + ? 'Ref: ' + : log.target.metadata.org_slug + ? 'Slug: ' + : null} + {log.target.metadata.project_ref ?? log.target.metadata.org_slug} +

    +
    + + {dayjs(log.occurred_at).format('DD MMM YYYY, HH:mm:ss')} + + + + +
    + ) + }) ?? [] + } + /> + )} + + )} + + + + setSelectedLog(undefined)} /> + + ) +} + +export default AuditLogs diff --git a/studio/components/interfaces/Organization/AuditLogs/FilterPopover.tsx b/studio/components/interfaces/Organization/AuditLogs/FilterPopover.tsx new file mode 100644 index 00000000000..c38c96736e3 --- /dev/null +++ b/studio/components/interfaces/Organization/AuditLogs/FilterPopover.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react' +import { Button, Checkbox, Popover } from 'ui' + +interface FilterPopoverProps { + options: any[] + activeOptions: any[] + valueKey: string + labelKey: string + name: string + onSaveFilters: (options: string[]) => void +} + +// [Joshen] Form + Checkbox.Group doesn't seem to work properly RE form state +// hence why the manual implementation here as a workaround + +const FilterPopover = ({ + options = [], + activeOptions = [], + valueKey, + labelKey, + name, + onSaveFilters, +}: FilterPopoverProps) => { + const [open, setOpen] = useState(false) + const [selectedOptions, setSelectedOptions] = useState([]) + + const formattedOptions = activeOptions.map((option) => { + const base = options.find((x) => x[valueKey] === option) + return base[labelKey] + }) + + return ( + setOpen(!open)} + header={
    Select {name.toLowerCase()}
    } + overlay={ + <> +
    + { + const value = event.target.value + if (selectedOptions.includes(value)) { + setSelectedOptions(selectedOptions.filter((x) => x !== value)) + } else { + setSelectedOptions(selectedOptions.concat(value)) + } + }} + options={options.map((option) => { + return { + value: option[valueKey], + label: option[labelKey], + defaultChecked: activeOptions.includes(option[valueKey]), + } + })} + > +
    +
    + + +
    + + } + > + +
    + ) +} + +export default FilterPopover diff --git a/studio/components/interfaces/Organization/AuditLogs/LogDetailsPanel.tsx b/studio/components/interfaces/Organization/AuditLogs/LogDetailsPanel.tsx new file mode 100644 index 00000000000..0f374319b15 --- /dev/null +++ b/studio/components/interfaces/Organization/AuditLogs/LogDetailsPanel.tsx @@ -0,0 +1,97 @@ +import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms' +import { OrganizationAuditLog } from 'data/organizations/organization-audit-logs-query' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useProjectsQuery } from 'data/projects/projects-query' +import dayjs from 'dayjs' +import { Input, SidePanel } from 'ui' + +export interface LogDetailsPanelProps { + selectedLog?: OrganizationAuditLog + onClose: () => void +} + +const LogDetailsPanel = ({ selectedLog, onClose }: LogDetailsPanelProps) => { + const { data: projects } = useProjectsQuery() + const { data: organizations } = useOrganizationsQuery() + + const project = projects?.find( + (project) => project.ref === selectedLog?.target.metadata.project_ref + ) + const organization = organizations?.find( + (org) => org.slug === selectedLog?.target.metadata.org_slug + ) + + return ( + + General}> + + + + + + + + Actor}> + + + + + + + + + Action}> + + + + + + + + + Target}> + + + + + + + ) +} + +export default LogDetailsPanel diff --git a/studio/components/interfaces/Organization/BillingSettings/BillingAddress/BillingAddress.tsx b/studio/components/interfaces/Organization/BillingSettings/BillingAddress/BillingAddress.tsx index 010510676a2..510117e4e2c 100644 --- a/studio/components/interfaces/Organization/BillingSettings/BillingAddress/BillingAddress.tsx +++ b/studio/components/interfaces/Organization/BillingSettings/BillingAddress/BillingAddress.tsx @@ -1,29 +1,32 @@ -import { FC, useState } from 'react' -import { isEqual } from 'lodash' -import { Dictionary } from 'components/grid' -import { Form, Input, Button, Listbox } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { isEqual } from 'lodash' +import { useState } from 'react' +import { Button, Form, Input, Listbox } from 'ui' -import { checkPermissions, useStore } from 'hooks' +import NoPermission from 'components/ui/NoPermission' +import Panel from 'components/ui/Panel' +import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' import { patch } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { COUNTRIES } from './BillingAddress.constants' -import Panel from 'components/ui/Panel' -import NoPermission from 'components/ui/NoPermission' -interface Props { +export interface BillingAddressProps { loading: boolean - address: Dictionary + address: { [key: string]: any } onAddressUpdated: (address: any) => void } -const BillingAddress: FC = ({ loading, address, onAddressUpdated }) => { +const BillingAddress = ({ loading, address, onAddressUpdated }: BillingAddressProps) => { const { ui } = useStore() - const orgSlug = ui.selectedOrganization?.slug ?? '' + const selectedOrganization = useSelectedOrganization() + const orgSlug = selectedOrganization?.slug ?? '' const { city, country, line1, line2, postal_code, state } = address - const canReadBillingAddress = checkPermissions(PermissionAction.BILLING_READ, 'stripe.customer') - const canUpdateBillingAddress = checkPermissions( + const canReadBillingAddress = useCheckPermissions( + PermissionAction.BILLING_READ, + 'stripe.customer' + ) + const canUpdateBillingAddress = useCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.customer' ) diff --git a/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx b/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx index b5279fd1753..98438a7f4df 100644 --- a/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx +++ b/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx @@ -1,24 +1,29 @@ -import { useEffect } from 'react' -import { Form, Input } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useStore, checkPermissions } from 'hooks' +import { useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' + import { useParams } from 'common/hooks' -import { API_URL } from 'lib/constants' -import { patch } from 'lib/common/fetch' import { FormActions, FormPanel, FormSection, FormSectionContent } from 'components/ui/Forms' +import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query' +import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' +import { patch } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { Form, Input } from 'ui' const BillingEmail = () => { - const { app, ui } = useStore() + const queryClient = useQueryClient() + const { ui } = useStore() const { slug } = useParams() - const { name, billing_email } = ui.selectedOrganization ?? {} + const selectedOrganization = useSelectedOrganization() + const { name, billing_email } = selectedOrganization ?? {} const formId = 'org-billing-email' const initialValues = { billing_email: billing_email ?? '', } - const canUpdateOrganization = checkPermissions(PermissionAction.UPDATE, 'organizations') - const canReadBillingEmail = checkPermissions(PermissionAction.READ, 'organizations') + const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') + const canReadBillingEmail = useCheckPermissions(PermissionAction.READ, 'organizations') const onUpdateOrganization = async (values: any, { setSubmitting, resetForm }: any) => { if (!canUpdateOrganization) { @@ -45,7 +50,7 @@ const BillingEmail = () => { initialValues: { billing_email }, }) - app.onOrgUpdated(response) + invalidateOrganizationsQuery(queryClient) ui.setNotification({ category: 'success', message: 'Successfully saved settings', @@ -70,6 +75,7 @@ const BillingEmail = () => { useEffect(() => { const values = { billing_email: billing_email ?? '' } resetForm({ values, initialValues: values }) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [slug]) return ( diff --git a/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx b/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx index 0e700116ef2..c18d3297dfd 100644 --- a/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx +++ b/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx @@ -1,25 +1,28 @@ -import { useState, useEffect } from 'react' -import { observer } from 'mobx-react-lite' +import { useEffect, useState } from 'react' -import { Project } from 'types' -import { useStore } from 'hooks' import { useParams } from 'common/hooks' -import { API_URL } from 'lib/constants' +import { useProjectsQuery } from 'data/projects/projects-query' +import { useCheckPermissions, useFlag, useSelectedOrganization, useStore } from 'hooks' +import { PermissionAction } from '@supabase/shared-types/out/constants' import { get } from 'lib/common/fetch' - -import ProjectsSummary from './ProjectsSummary' +import { API_URL } from 'lib/constants' +import BillingAddress from './BillingAddress/BillingAddress' +import BillingEmail from './BillingEmail' import CreditBalance from './CreditBalance' import PaymentMethods from './PaymentMethods' -import BillingAddress from './BillingAddress/BillingAddress' +import ProjectsSummary from './ProjectsSummary' import TaxID from './TaxID/TaxID' -import BillingEmail from './BillingEmail' +import OrganizationBillingMigrationPanel from '../GeneralSettings/OrganizationBillingMigrationPanel' +import { ScaffoldContainer, ScaffoldContainerLegacy } from 'components/layouts/Scaffold' const BillingSettings = () => { - const { app, ui } = useStore() + const { ui } = useStore() const { slug } = useParams() - const organization = ui.selectedOrganization - const projects = app.projects.list((x: Project) => x.organization_id == organization?.id) || [] + const organization = useSelectedOrganization() + const { data: allProjects } = useProjectsQuery() + const projects = + allProjects?.filter((project) => project.organization_id === organization?.id) ?? [] const [customer, setCustomer] = useState(null) const [isLoadingCustomer, setIsLoadingCustomer] = useState(false) @@ -39,11 +42,10 @@ const BillingSettings = () => { ? customerBalance.toString().replace('-', '') : customerBalance - useEffect(() => { - getCustomerProfile() - getPaymentMethods() - getTaxIds() - }, [slug]) + const orgBillingMigrationEnabled = useFlag('orgBillingMigration') + const canMigrateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') + const selectedOrganization = useSelectedOrganization() + const { subscription_id } = selectedOrganization ?? {} const getCustomerProfile = async () => { try { @@ -93,35 +95,42 @@ const BillingSettings = () => { } } + useEffect(() => { + getCustomerProfile() + getPaymentMethods() + getTaxIds() + }, [slug]) + return ( -
    -
    - - - getPaymentMethods()} - onPaymentMethodAdded={() => getPaymentMethods()} - /> + + {orgBillingMigrationEnabled && canMigrateOrganization && !subscription_id && ( + + )} + + + getPaymentMethods()} + onPaymentMethodAdded={() => getPaymentMethods()} + /> - + - setCustomer({ ...customer, address })} - /> - setTaxIds(ids)} - /> -
    -
    + setCustomer({ ...customer, address })} + /> + setTaxIds(ids)} + /> + ) } -export default observer(BillingSettings) +export default BillingSettings diff --git a/studio/components/interfaces/Organization/BillingSettings/PaymentMethods.tsx b/studio/components/interfaces/Organization/BillingSettings/PaymentMethods.tsx index c77d8b7fa7f..d0aeeeb3416 100644 --- a/studio/components/interfaces/Organization/BillingSettings/PaymentMethods.tsx +++ b/studio/components/interfaces/Organization/BillingSettings/PaymentMethods.tsx @@ -1,28 +1,28 @@ -import { FC, useState } from 'react' -import { - Dropdown, - IconPlus, - IconCreditCard, - Button, - Input, - Badge, - Modal, - Alert, - IconMoreHorizontal, - IconX, -} from 'ui' import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useState } from 'react' +import { + Alert, + Badge, + Button, + Dropdown, + IconCreditCard, + IconMoreHorizontal, + IconPlus, + IconX, + Input, + Modal, +} from 'ui' -import { checkPermissions, useStore } from 'hooks' -import { delete_, patch } from 'lib/common/fetch' -import { getURL } from 'lib/helpers' -import { API_URL, BASE_PATH } from 'lib/constants' -import Panel from 'components/ui/Panel' import { AddNewPaymentMethodModal } from 'components/interfaces/Billing' import NoPermission from 'components/ui/NoPermission' +import Panel from 'components/ui/Panel' +import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' +import { delete_, patch } from 'lib/common/fetch' +import { API_URL, BASE_PATH } from 'lib/constants' +import { getURL } from 'lib/helpers' -interface Props { +export interface PaymentMethodsProps { loading: boolean defaultPaymentMethod: string paymentMethods: any[] @@ -31,27 +31,28 @@ interface Props { onPaymentMethodAdded: () => void } -const PaymentMethods: FC = ({ +const PaymentMethods = ({ loading, defaultPaymentMethod, paymentMethods, onDefaultMethodUpdated, onPaymentMethodsDeleted, onPaymentMethodAdded, -}) => { +}: PaymentMethodsProps) => { const { ui } = useStore() - const orgSlug = ui.selectedOrganization?.slug ?? '' + const selectedOrganization = useSelectedOrganization() + const orgSlug = selectedOrganization?.slug ?? '' const [selectedMethodForDefault, setSelectedMethodForDefault] = useState() const [selectedMethodToDelete, setSelectedMethodToDelete] = useState() const [showAddPaymentMethodModal, setShowAddPaymentMethodModal] = useState(false) const [isUpdatingPaymentMethod, setIsUpdatingPaymentMethod] = useState(false) - const canReadPaymentMethods = checkPermissions( + const canReadPaymentMethods = useCheckPermissions( PermissionAction.BILLING_READ, 'stripe.payment_methods' ) - const canUpdatePaymentMethods = checkPermissions( + const canUpdatePaymentMethods = useCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.payment_methods' ) diff --git a/studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx b/studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx new file mode 100644 index 00000000000..873a8144f68 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx @@ -0,0 +1,36 @@ +import * as Tooltip from '@radix-ui/react-tooltip' +import { PropsWithChildren } from 'react' + +export interface ProjectUpdateDisabledTooltipProps { + projectUpdateDisabled: boolean +} + +const ProjectUpdateDisabledTooltip = ({ + projectUpdateDisabled, + children, +}: PropsWithChildren) => { + return ( + + {children} + {projectUpdateDisabled ? ( + + + +
    + + Subscription changes are currently disabled. Our engineers are working on a fix. + +
    +
    +
    + ) : null} +
    + ) +} + +export default ProjectUpdateDisabledTooltip diff --git a/studio/components/interfaces/Organization/BillingSettings/TaxID/TaxID.tsx b/studio/components/interfaces/Organization/BillingSettings/TaxID/TaxID.tsx index 02a33a6333e..3566af3da31 100644 --- a/studio/components/interfaces/Organization/BillingSettings/TaxID/TaxID.tsx +++ b/studio/components/interfaces/Organization/BillingSettings/TaxID/TaxID.tsx @@ -1,18 +1,18 @@ -import { FC, useEffect, useState } from 'react' -import { isEqual } from 'lodash' -import { Input, Button, IconPlus, IconX, Listbox } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { isEqual } from 'lodash' +import { useEffect, useState } from 'react' +import { Button, IconPlus, IconX, Input, Listbox } from 'ui' -import { checkPermissions, useFlag, useStore } from 'hooks' -import { uuidv4 } from 'lib/helpers' -import { post, delete_ } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' -import Panel from 'components/ui/Panel' -import { StripeTaxId, TAX_IDS } from './TaxID.constants' import NoPermission from 'components/ui/NoPermission' +import Panel from 'components/ui/Panel' +import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' +import { delete_, post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { uuidv4 } from 'lib/helpers' +import { StripeTaxId, TAX_IDS } from './TaxID.constants' import { sanitizeTaxID } from './TaxID.utils' -interface Props { +export interface TaxIDProps { loading: boolean taxIds: any[] onTaxIdsUpdated: (taxIds: any) => void @@ -21,9 +21,10 @@ interface Props { // Stripe recommends to delete tax ids and create new ones to update // https://stripe.com/docs/billing/customer/tax-ids -const TaxID: FC = ({ loading, taxIds, onTaxIdsUpdated }) => { +const TaxID = ({ loading, taxIds, onTaxIdsUpdated }: TaxIDProps) => { const { ui } = useStore() - const slug = ui.selectedOrganization?.slug ?? '' + const selectedOrganization = useSelectedOrganization() + const slug = selectedOrganization?.slug ?? '' const [isSaving, setIsSaving] = useState(false) const [errors, setErrors] = useState([]) @@ -50,8 +51,8 @@ const TaxID: FC = ({ loading, taxIds, onTaxIdsUpdated }) => { }, [taxIds]) const hasChanges = !isEqual(taxIdValues, formattedTaxIds) - const canReadTaxIds = checkPermissions(PermissionAction.BILLING_READ, 'stripe.tax_ids') - const canUpdateTaxIds = checkPermissions(PermissionAction.BILLING_WRITE, 'stripe.tax_ids') + const canReadTaxIds = useCheckPermissions(PermissionAction.BILLING_READ, 'stripe.tax_ids') + const canUpdateTaxIds = useCheckPermissions(PermissionAction.BILLING_WRITE, 'stripe.tax_ids') const onUpdateTaxId = (id: string, key: string, value: string) => { const updatedTaxIds = taxIdValues.map((taxId: any) => { diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/BillingAddress/BillingAddress.constants.ts b/studio/components/interfaces/Organization/BillingSettingsV2/BillingAddress/BillingAddress.constants.ts new file mode 100644 index 00000000000..728b4ecef37 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/BillingAddress/BillingAddress.constants.ts @@ -0,0 +1,248 @@ +export const COUNTRIES = [ + { code: 'AF', name: 'Afghanistan' }, + { code: 'AL', name: 'Albania' }, + { code: 'DZ', name: 'Algeria' }, + { code: 'AS', name: 'American Samoa' }, + { code: 'AD', name: 'Andorra' }, + { code: 'AO', name: 'Angola' }, + { code: 'AI', name: 'Anguilla' }, + { code: 'AQ', name: 'Antarctica' }, + { code: 'AG', name: 'Antigua and Barbuda' }, + { code: 'AR', name: 'Argentina' }, + { code: 'AM', name: 'Armenia' }, + { code: 'AW', name: 'Aruba' }, + { code: 'AU', name: 'Australia' }, + { code: 'AT', name: 'Austria' }, + { code: 'AZ', name: 'Azerbaijan' }, + { code: 'BS', name: 'Bahamas' }, + { code: 'BH', name: 'Bahrain' }, + { code: 'BD', name: 'Bangladesh' }, + { code: 'BB', name: 'Barbados' }, + { code: 'BY', name: 'Belarus' }, + { code: 'BE', name: 'Belgium' }, + { code: 'BZ', name: 'Belize' }, + { code: 'BJ', name: 'Benin' }, + { code: 'BM', name: 'Bermuda' }, + { code: 'BT', name: 'Bhutan' }, + { code: 'BO', name: 'Bolivia' }, + { code: 'BA', name: 'Bosnia and Herzegovina' }, + { code: 'BW', name: 'Botswana' }, + { code: 'BV', name: 'Bouvet Island' }, + { code: 'BR', name: 'Brazil' }, + { code: 'IO', name: 'British Indian Ocean Territory' }, + { code: 'VG', name: 'British Virgin Islands' }, + { code: 'BN', name: 'Brunei Darussalam' }, + { code: 'BG', name: 'Bulgaria' }, + { code: 'BF', name: 'Burkina Faso' }, + { code: 'BI', name: 'Burundi' }, + { code: 'KH', name: 'Cambodia' }, + { code: 'CM', name: 'Cameroon' }, + { code: 'CA', name: 'Canada' }, + { code: 'CV', name: 'Cape Verde' }, + { code: 'KY', name: 'Cayman Islands' }, + { code: 'CF', name: 'Central African Republic' }, + { code: 'TD', name: 'Chad' }, + { code: 'CL', name: 'Chile' }, + { code: 'CN', name: 'China' }, + { code: 'CX', name: 'Christmas Island' }, + { code: 'CC', name: 'Cocos (Keeling) Islands' }, + { code: 'CO', name: 'Colombia' }, + { code: 'KM', name: 'Comoros' }, + { code: 'CG', name: 'Congo' }, + { code: 'CK', name: 'Cook Islands' }, + { code: 'CR', name: 'Costa Rica' }, + { code: 'HR', name: 'Croatia' }, + { code: 'CU', name: 'Cuba' }, + { code: 'CW', name: 'Curaçao' }, + { code: 'CY', name: 'Cyprus' }, + { code: 'CZ', name: 'Czech Republic' }, + { code: 'CI', name: "Côte d'Ivoire" }, + { code: 'DK', name: 'Denmark' }, + { code: 'DJ', name: 'Djibouti' }, + { code: 'DM', name: 'Dominica' }, + { code: 'DO', name: 'Dominican Republic' }, + { code: 'EC', name: 'Ecuador' }, + { code: 'EG', name: 'Egypt' }, + { code: 'SV', name: 'El Salvador' }, + { code: 'GQ', name: 'Equatorial Guinea' }, + { code: 'ER', name: 'Eritrea' }, + { code: 'EE', name: 'Estonia' }, + { code: 'SZ', name: 'Eswatini' }, + { code: 'ET', name: 'Ethiopia' }, + { code: 'FK', name: 'Falkland Islands' }, + { code: 'FO', name: 'Faroe Islands' }, + { code: 'FJ', name: 'Fiji' }, + { code: 'FI', name: 'Finland' }, + { code: 'FR', name: 'France' }, + { code: 'GF', name: 'French Guiana' }, + { code: 'PF', name: 'French Polynesia' }, + { code: 'TF', name: 'French Southern Territories' }, + { code: 'GA', name: 'Gabon' }, + { code: 'GM', name: 'Gambia' }, + { code: 'GE', name: 'Georgia' }, + { code: 'DE', name: 'Germany' }, + { code: 'GH', name: 'Ghana' }, + { code: 'GI', name: 'Gibraltar' }, + { code: 'GR', name: 'Greece' }, + { code: 'GL', name: 'Greenland' }, + { code: 'GD', name: 'Grenada' }, + { code: 'GP', name: 'Guadeloupe' }, + { code: 'GU', name: 'Guam' }, + { code: 'GT', name: 'Guatemala' }, + { code: 'GG', name: 'Guernsey' }, + { code: 'GN', name: 'Guinea' }, + { code: 'GW', name: 'Guinea-Bissau' }, + { code: 'GY', name: 'Guyana' }, + { code: 'HT', name: 'Haiti' }, + { code: 'HM', name: 'Heard & McDonald Islands' }, + { code: 'HN', name: 'Honduras' }, + { code: 'HK', name: 'Hong Kong' }, + { code: 'HU', name: 'Hungary' }, + { code: 'IS', name: 'Iceland' }, + { code: 'IN', name: 'India' }, + { code: 'ID', name: 'Indonesia' }, + { code: 'IR', name: 'Iran' }, + { code: 'IQ', name: 'Iraq' }, + { code: 'IE', name: 'Ireland' }, + { code: 'IM', name: 'Isle of Man' }, + { code: 'IL', name: 'Israel' }, + { code: 'IT', name: 'Italy' }, + { code: 'JM', name: 'Jamaica' }, + { code: 'JP', name: 'Japan' }, + { code: 'JE', name: 'Jersey' }, + { code: 'JO', name: 'Jordan' }, + { code: 'KZ', name: 'Kazakhstan' }, + { code: 'KE', name: 'Kenya' }, + { code: 'KI', name: 'Kiribati' }, + { code: 'KW', name: 'Kuwait' }, + { code: 'KG', name: 'Kyrgyzstan' }, + { code: 'LA', name: 'Laos' }, + { code: 'LV', name: 'Latvia' }, + { code: 'LB', name: 'Lebanon' }, + { code: 'LS', name: 'Lesotho' }, + { code: 'LR', name: 'Liberia' }, + { code: 'LY', name: 'Libya' }, + { code: 'LI', name: 'Liechtenstein' }, + { code: 'LT', name: 'Lithuania' }, + { code: 'LU', name: 'Luxembourg' }, + { code: 'MO', name: 'Macao' }, + { code: 'MG', name: 'Madagascar' }, + { code: 'MW', name: 'Malawi' }, + { code: 'MY', name: 'Malaysia' }, + { code: 'MV', name: 'Maldives' }, + { code: 'ML', name: 'Mali' }, + { code: 'MT', name: 'Malta' }, + { code: 'MH', name: 'Marshall Islands' }, + { code: 'MQ', name: 'Martinique' }, + { code: 'MR', name: 'Mauritania' }, + { code: 'MU', name: 'Mauritius' }, + { code: 'YT', name: 'Mayotte' }, + { code: 'MX', name: 'Mexico' }, + { code: 'FM', name: 'Micronesia' }, + { code: 'MD', name: 'Moldova' }, + { code: 'MC', name: 'Monaco' }, + { code: 'MN', name: 'Mongolia' }, + { code: 'ME', name: 'Montenegro' }, + { code: 'MS', name: 'Montserrat' }, + { code: 'MA', name: 'Morocco' }, + { code: 'MZ', name: 'Mozambique' }, + { code: 'MM', name: 'Myanmar' }, + { code: 'NA', name: 'Namibia' }, + { code: 'NR', name: 'Nauru' }, + { code: 'NP', name: 'Nepal' }, + { code: 'NL', name: 'Netherlands' }, + { code: 'NC', name: 'New Caledonia' }, + { code: 'NZ', name: 'New Zealand' }, + { code: 'NI', name: 'Nicaragua' }, + { code: 'NE', name: 'Niger' }, + { code: 'NG', name: 'Nigeria' }, + { code: 'NU', name: 'Niue' }, + { code: 'NF', name: 'Norfolk Island' }, + { code: 'KP', name: 'North Korea' }, + { code: 'MK', name: 'North Macedonia' }, + { code: 'MP', name: 'Northern Mariana Islands' }, + { code: 'NO', name: 'Norway' }, + { code: 'OM', name: 'Oman' }, + { code: 'PK', name: 'Pakistan' }, + { code: 'PW', name: 'Palau' }, + { code: 'PS', name: 'Palestinian Territories' }, + { code: 'PA', name: 'Panama' }, + { code: 'PG', name: 'Papua New Guinea' }, + { code: 'PY', name: 'Paraguay' }, + { code: 'PE', name: 'Peru' }, + { code: 'PH', name: 'Philippines' }, + { code: 'PN', name: 'Pitcairn Islands' }, + { code: 'PL', name: 'Poland' }, + { code: 'PT', name: 'Portugal' }, + { code: 'PR', name: 'Puerto Rico' }, + { code: 'QA', name: 'Qatar' }, + { code: 'RO', name: 'Romania' }, + { code: 'RU', name: 'Russia' }, + { code: 'RW', name: 'Rwanda' }, + { code: 'RE', name: 'Réunion' }, + { code: 'BL', name: 'Saint Barthélemy' }, + { code: 'WS', name: 'Samoa' }, + { code: 'SM', name: 'San Marino' }, + { code: 'ST', name: 'Sao Tome and Principe' }, + { code: 'SA', name: 'Saudi Arabia' }, + { code: 'SN', name: 'Senegal' }, + { code: 'RS', name: 'Serbia' }, + { code: 'SC', name: 'Seychelles' }, + { code: 'SL', name: 'Sierra Leone' }, + { code: 'SG', name: 'Singapore' }, + { code: 'SX', name: 'Sint Maarten' }, + { code: 'SK', name: 'Slovakia' }, + { code: 'SI', name: 'Slovenia' }, + { code: 'SB', name: 'Solomon Islands' }, + { code: 'SO', name: 'Somalia' }, + { code: 'ZA', name: 'South Africa' }, + { code: 'GS', name: 'South Georgia and the South Sandwich Islands' }, + { code: 'KR', name: 'South Korea' }, + { code: 'SS', name: 'South Sudan' }, + { code: 'ES', name: 'Spain' }, + { code: 'LK', name: 'Sri Lanka' }, + { code: 'SH', name: 'St Helena' }, + { code: 'KN', name: 'St Kitts and Nevis' }, + { code: 'LC', name: 'St Lucia' }, + { code: 'MF', name: 'St Martin' }, + { code: 'PM', name: 'St Pierre and Miquelon' }, + { code: 'VC', name: 'St Vincent and the Grenadines' }, + { code: 'SD', name: 'Sudan' }, + { code: 'SR', name: 'Suriname' }, + { code: 'SJ', name: 'Svalbard & Jan Mayen' }, + { code: 'SE', name: 'Sweden' }, + { code: 'CH', name: 'Switzerland' }, + { code: 'SY', name: 'Syria' }, + { code: 'TW', name: 'Taiwan' }, + { code: 'TJ', name: 'Tajikistan' }, + { code: 'TZ', name: 'Tanzania' }, + { code: 'TH', name: 'Thailand' }, + { code: 'TL', name: 'Timor-Leste' }, + { code: 'TG', name: 'Togo' }, + { code: 'TK', name: 'Tokelau' }, + { code: 'TO', name: 'Tonga' }, + { code: 'TT', name: 'Trinidad & Tobago' }, + { code: 'TN', name: 'Tunisia' }, + { code: 'TR', name: 'Turkey' }, + { code: 'TM', name: 'Turkmenistan' }, + { code: 'TC', name: 'Turks & Caicos Islands' }, + { code: 'TV', name: 'Tuvalu' }, + { code: 'VI', name: 'US Virgin Islands' }, + { code: 'UG', name: 'Uganda' }, + { code: 'UA', name: 'Ukraine' }, + { code: 'AE', name: 'United Arab Emirates' }, + { code: 'GB', name: 'United Kingdom' }, + { code: 'US', name: 'United States of America' }, + { code: 'UY', name: 'Uruguay' }, + { code: 'UZ', name: 'Uzbekistan' }, + { code: 'VU', name: 'Vanuatu' }, + { code: 'VA', name: 'Vatican City' }, + { code: 'VE', name: 'Venezuela' }, + { code: 'VN', name: 'Vietnam' }, + { code: 'WF', name: 'Wallis & Futuna' }, + { code: 'EH', name: 'Western Sahara' }, + { code: 'YE', name: 'Yemen' }, + { code: 'ZM', name: 'Zambia' }, + { code: 'ZW', name: 'Zimbabwe' }, + { code: 'AX', name: 'Åland Islands' }, +] diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/BillingAddress/BillingAddress.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/BillingAddress/BillingAddress.tsx new file mode 100644 index 00000000000..d1bf4e11422 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/BillingAddress/BillingAddress.tsx @@ -0,0 +1,199 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useEffect } from 'react' + +import { useParams } from 'common' +import { + ScaffoldSection, + ScaffoldSectionContent, + ScaffoldSectionDetail, +} from 'components/layouts/Scaffold' +import AlertError from 'components/ui/AlertError' +import { FormActions, FormPanel, FormSection, FormSectionContent } from 'components/ui/Forms' +import NoPermission from 'components/ui/NoPermission' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query' +import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation' +import { useCheckPermissions, useStore } from 'hooks' +import { Form, Input, Listbox } from 'ui' +import { COUNTRIES } from './BillingAddress.constants' + +const BillingAddress = () => { + const { ui } = useStore() + const { slug } = useParams() + const { data, error, isLoading, isSuccess, isError } = useOrganizationCustomerProfileQuery({ + slug, + }) + const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation() + + const formId = 'billing-address-form' + const { city, country, line1, line2, postal_code, state } = data?.address ?? {} + + const canReadBillingAddress = useCheckPermissions( + PermissionAction.BILLING_READ, + 'stripe.customer' + ) + const canUpdateBillingAddress = useCheckPermissions( + PermissionAction.BILLING_WRITE, + 'stripe.customer' + ) + + const initialValues = { city, country, line1, line2, postal_code, state } + + const onSubmit = async (values: any, { setSubmitting, resetForm }: any) => { + if (!slug) return console.error('Slug is required') + + try { + setSubmitting(true) + await updateCustomerProfile({ slug, address: values }) + ui.setNotification({ + category: 'success', + message: 'Successfully updated billing address', + }) + resetForm({ values, initialValues: values }) + } catch (error: any) { + ui.setNotification({ + category: 'error', + message: `Failed to update billing address: ${error.message}`, + }) + } finally { + setSubmitting(false) + } + } + + return ( + + +
    +

    Billing Address

    +

    This will be reflected in every invoice

    +
    +
    + + {!canReadBillingAddress ? ( + + ) : ( + <> + {isLoading && ( +
    + + + +
    + )} + + {isError && ( + + )} + + {isSuccess && ( +
    + {({ isSubmitting, values, initialValues, handleReset, resetForm }: any) => { + const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues) + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + if (isSuccess && data !== undefined) { + const { city, country, line1, line2, postal_code, state } = + data?.address ?? {} + const values = { city, country, line1, line2, postal_code, state } + resetForm({ values, initialValues: values }) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSuccess]) + + return ( + + + + } + > + + + + +
    + + + --- + + {COUNTRIES.map((country) => ( + + {country.name} + + ))} + + +
    +
    + + +
    +
    +
    +
    + ) + }} + + )} + + )} +
    +
    + ) +} + +export default BillingAddress diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/BillingEmail.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/BillingEmail.tsx new file mode 100644 index 00000000000..dbac307c3af --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/BillingEmail.tsx @@ -0,0 +1,126 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' + +import { useParams } from 'common' +import { + ScaffoldSection, + ScaffoldSectionContent, + ScaffoldSectionDetail, +} from 'components/layouts/Scaffold' +import { FormActions, FormPanel, FormSection, FormSectionContent } from 'components/ui/Forms' +import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query' +import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' +import { patch } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { Form, Input } from 'ui' + +const BillingEmail = () => { + const queryClient = useQueryClient() + const { ui } = useStore() + const { slug } = useParams() + const selectedOrganization = useSelectedOrganization() + const { name, billing_email } = selectedOrganization ?? {} + + const formId = 'org-billing-email' + const initialValues = { billing_email: billing_email ?? '' } + + const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') + const canReadBillingEmail = useCheckPermissions(PermissionAction.READ, 'organizations') + + const onUpdateOrganizationEmail = async (values: any, { setSubmitting, resetForm }: any) => { + if (!canUpdateOrganization) { + return ui.setNotification({ + category: 'error', + message: 'You do not have the required permissions to update this organization', + }) + } + + setSubmitting(true) + const response = await patch(`${API_URL}/organizations/${slug}`, { ...values, name }) + if (response.error) { + ui.setNotification({ + category: 'error', + message: `Failed to update organization: ${response.error.message}`, + }) + } else { + const { billing_email } = response + resetForm({ + values: { billing_email }, + initialValues: { billing_email }, + }) + + invalidateOrganizationsQuery(queryClient) + ui.setNotification({ + category: 'success', + message: 'Successfully saved settings', + }) + } + setSubmitting(false) + } + + return ( + + +
    +
    +

    Email Recipient

    +

    + All billing correspondence will go to this email +

    +
    +
    +
    + +
    + {({ isSubmitting, handleReset, values, initialValues, resetForm }: any) => { + const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues) + + // [Alaister] although this "technically" is breaking the rules of React hooks + // it won't error because the hooks are always rendered in the same order + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + const values = { billing_email: billing_email ?? '' } + resetForm({ values, initialValues: values }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [slug]) + + return ( + + + + } + > + + + + + + + ) + }} + +
    +
    + ) +} + +export default BillingEmail diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/BillingSettings.constants.ts b/studio/components/interfaces/Organization/BillingSettingsV2/BillingSettings.constants.ts new file mode 100644 index 00000000000..3e16e7b2138 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/BillingSettings.constants.ts @@ -0,0 +1,16 @@ +export const CANCELLATION_REASONS = [ + 'Pricing', + "My project isn't getting traction", + 'Poor customer service', + 'Missing feature', + "I didn't see the value", + "Supabase didn't meet my needs", + 'Dashboard is too complicated', + 'Postgres is too complicated', + 'Problem not solved', + 'Bug issues', + 'I decided to use something else', + 'My work has finished/discontinued', + 'I’m migrating to/starting a new project', + 'None of the above', +] diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/BillingSettings.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/BillingSettings.tsx new file mode 100644 index 00000000000..ec6f077faa4 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/BillingSettings.tsx @@ -0,0 +1,56 @@ +import { ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold' +import BillingAddress from './BillingAddress/BillingAddress' +import BillingEmail from './BillingEmail' +import CostControl from './CostControl/CostControl' +import CreditBalance from './CreditBalance' +import PaymentMethods from './PaymentMethods/PaymentMethods' +import Subscription from './Subscription/Subscription' +import TaxID from './TaxID/TaxID' + +const BillingSettings = () => { + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +export default BillingSettings diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/CostControl/CostControl.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/CostControl/CostControl.tsx new file mode 100644 index 00000000000..8926d07bde4 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/CostControl/CostControl.tsx @@ -0,0 +1,168 @@ +import Link from 'next/link' + +import { useParams, useTheme } from 'common' +import { + ScaffoldSection, + ScaffoldSectionContent, + ScaffoldSectionDetail, +} from 'components/layouts/Scaffold' +import AlertError from 'components/ui/AlertError' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import { useFlag } from 'hooks' +import { BASE_PATH } from 'lib/constants' +import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' +import { Alert, Button, IconExternalLink } from 'ui' +import ProjectUpdateDisabledTooltip from '../../BillingSettings/ProjectUpdateDisabledTooltip' +import SpendCapSidePanel from './SpendCapSidePanel' + +export interface CostControlProps {} + +const CostControl = ({}: CostControlProps) => { + const { slug } = useParams() + const { isDarkMode } = useTheme() + + const snap = useOrgSettingsPageStateSnapshot() + const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') + const { + data: subscription, + error, + isLoading, + isSuccess, + isError, + } = useOrgSubscriptionQuery({ orgSlug: slug }) + + const currentPlan = subscription?.plan + const isUsageBillingEnabled = subscription?.usage_billing_enabled ?? false + + const canChangeTier = + !projectUpdateDisabled && !['team', 'enterprise'].includes(currentPlan?.id || '') + + return ( + <> + + +
    +
    +

    Cost Control

    +

    + Control whether to use beyond your plans included quota +

    +
    +
    +

    More information

    + +
    +
    +
    + + {isLoading && ( +
    + + + +
    + )} + + {isError && } + + {isSuccess && ( +
    + {['team', 'enterprise'].includes(currentPlan?.id || '') ? ( + + {currentPlan?.name || ''} plan requires you to have spend cap off at all times. + Your projects will never become unresponsive. Only when your{' '} + + + included usage + + {' '} + is exceeded will you be charged for any additional usage. + + ) : ( +

    + You can control whether your organization is charged for additional usage beyond + the{' '} + + + included quota + + {' '} + of your subscription plan. If you need to go beyond the included quota, simply + switch off your spend cap to pay for additional usage. +

    + )} + +
    +
    +
    + Spend Cap +
    +
    +
    +

    + Spend cap is {isUsageBillingEnabled ? 'disabled' : 'enabled'} +

    +

    + {isUsageBillingEnabled ? ( + You will be charged for any usage above the included quota. + ) : ( + + You won't be charged any extra for usage. However, your projects could + become unresponsive or enter read only mode if you exceed the included + quota. + + )} +

    + {isUsageBillingEnabled && ( +

    + Your projects will never become unresponsive. Only when your usage reaches the + quota limit will you be charged for any excess usage. +

    + )} + + + +
    +
    +
    + )} +
    +
    + + + ) +} + +export default CostControl diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/CostControl/SpendCapSidePanel.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/CostControl/SpendCapSidePanel.tsx new file mode 100644 index 00000000000..3013c81b829 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/CostControl/SpendCapSidePanel.tsx @@ -0,0 +1,308 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import clsx from 'clsx' +import { useParams, useTheme } from 'common' +import Table from 'components/to-be-cleaned/Table' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation' +import { useCheckPermissions, useStore } from 'hooks' +import { BASE_PATH, PRICING_TIER_PRODUCT_IDS } from 'lib/constants' +import Telemetry from 'lib/telemetry' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import { pricing } from 'shared-data/pricing' +import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' +import { Alert, Button, Collapsible, IconChevronRight, IconExternalLink, SidePanel } from 'ui' + +const SPEND_CAP_OPTIONS: { + name: string + value: 'on' | 'off' + imageUrl: string + imageUrlLight: string +}[] = [ + { + name: 'Spend cap enabled', + value: 'on', + imageUrl: `${BASE_PATH}/img/spend-cap-on.png`, + imageUrlLight: `${BASE_PATH}/img/spend-cap-on--light.png`, + }, + { + name: 'Spend cap disabled', + value: 'off', + imageUrl: `${BASE_PATH}/img/spend-cap-off.png`, + imageUrlLight: `${BASE_PATH}/img/spend-cap-off--light.png`, + }, +] + +const SpendCapSidePanel = () => { + const { ui } = useStore() + const router = useRouter() + const { slug } = useParams() + const { isDarkMode } = useTheme() + + const [showUsageCosts, setShowUsageCosts] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [selectedOption, setSelectedOption] = useState<'on' | 'off'>() + + const canUpdateSpendCap = useCheckPermissions( + PermissionAction.BILLING_WRITE, + 'stripe.subscriptions' + ) + + const snap = useOrgSettingsPageStateSnapshot() + const visible = snap.panelKey === 'costControl' + const onClose = () => snap.setPanelKey(undefined) + + const { data: subscription, isLoading } = useOrgSubscriptionQuery({ orgSlug: slug }) + const { mutateAsync: updateOrgSubscription } = useOrgSubscriptionUpdateMutation() + + const isFreePlan = subscription?.plan?.id === 'free' + const isSpendCapOn = !subscription?.usage_billing_enabled + const isTurningOnCap = !isSpendCapOn && selectedOption === 'on' + const hasChanges = selectedOption !== (isSpendCapOn ? 'on' : 'off') + + useEffect(() => { + if (visible && subscription !== undefined) { + setSelectedOption(isSpendCapOn ? 'on' : 'off') + Telemetry.sendActivity( + { + activity: 'Side Panel Viewed', + source: 'Dashboard', + data: { + title: 'Spend cap', + section: 'Cost Control', + }, + }, + router + ) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible, isLoading, subscription, isSpendCapOn]) + + const onConfirm = async () => { + if (!slug) return console.error('Org slug is required') + + try { + const tier = ( + selectedOption === 'on' ? PRICING_TIER_PRODUCT_IDS.PRO : PRICING_TIER_PRODUCT_IDS.PAYG + ) as 'tier_pro' | 'tier_payg' + setIsSubmitting(true) + await updateOrgSubscription({ slug, tier }) + ui.setNotification({ + category: 'success', + message: `Successfully ${isTurningOnCap ? 'enabled' : 'disabled'} spend cap`, + }) + onClose() + } catch (error: any) { + ui.setNotification({ + error, + category: 'error', + message: `Unable to toggle spend cap: ${error.message}`, + }) + } finally { + setIsSubmitting(false) + } + } + + const billingMetricCategories: (keyof typeof pricing)[] = [ + 'database', + 'auth', + 'storage', + 'realtime', + 'edge_functions', + ] + + return ( + +

    Spend cap

    + + + + + + + } + tooltip={!canUpdateSpendCap ? 'You do not have permission to update spend cap' : undefined} + > + +
    +

    + Use the spend cap to manage project usage and costs, and control whether the project can + exceed the included quota allowance of any billed line item in a billing cycle +

    + + + +
    + +

    + How are each resource charged after exceeding the included quota? +

    +
    +
    + +
    + +

    Item

    +
    + +

    Rate

    +
    + + } + body={billingMetricCategories.map((categoryId) => { + const category = pricing[categoryId] + const usageItems = category.features.filter((it: any) => it.usage_based) + + return ( + <> + + +

    {category.title}

    +
    + {null} +
    + {usageItems.map((item: any) => { + return ( + + +

    {item.title}

    +
    + +

    + {item.plans[subscription?.plan?.id || 'pro']} +

    +
    +
    + ) + })} + + ) + })} + /> + + + + {isFreePlan && ( + snap.setPanelKey('subscriptionPlan')}> + View available plans + + } + > + Upgrade your plan to disable the spend cap + + )} + +
    +
    + {SPEND_CAP_OPTIONS.map((option) => { + const isSelected = selectedOption === option.value + + return ( +
    { + !isFreePlan && setSelectedOption(option.value) + Telemetry.sendActivity( + { + activity: 'Option Selected', + source: 'Dashboard', + data: { + title: 'Spend cap', + section: 'Cost Control', + option: option.name, + }, + }, + router + ) + }} + > + Spend Cap + +

    + {option.name} +

    +
    + ) + })} +
    +
    + + {selectedOption === 'on' ? ( + + Exceeding the included quota allowance with spend cap enabled can cause your projects + to become unresponsive or enter read only mode. + + ) : ( + + Your projects will always remain responsive and active, and charges only apply when + exceeding the included quota limit. + + )} + + {hasChanges && ( + <> +

    + {selectedOption === 'on' + ? 'Upon clicking confirm, spend cap will be enabled for your organization and you will no longer be charged any extra for usage.' + : 'Upon clicking confirm, spend cap will be disabled for your organization and you will be charged for any usage beyong the included quota.'} +

    + + )} + + + + ) +} + +export default SpendCapSidePanel diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/CreditBalance.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/CreditBalance.tsx new file mode 100644 index 00000000000..4b0b69c3bbb --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/CreditBalance.tsx @@ -0,0 +1,66 @@ +import { useParams } from 'common' +import { + ScaffoldSection, + ScaffoldSectionContent, + ScaffoldSectionDetail, +} from 'components/layouts/Scaffold' +import AlertError from 'components/ui/AlertError' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query' +import { Badge } from 'ui' + +const CreditBalance = () => { + const { slug } = useParams() + const { data, error, isLoading, isError, isSuccess } = useOrganizationCustomerProfileQuery({ + slug, + }) + + const customerBalance = (data?.balance ?? 0) / 100 + const isCredit = customerBalance < 0 + const isDebt = customerBalance > 0 + const balance = + isCredit && customerBalance !== 0 + ? customerBalance.toFixed(2).toString().replace('-', '') + : customerBalance.toFixed(2) + + return ( + + +
    +
    +

    Credit balance

    + {isCredit && You have credits available} + {isDebt && Outstanding payments} +
    +

    + Charges will be deducted from your balance first +

    +
    +
    + + {isLoading && ( +
    + + + +
    + )} + + {isError && ( + + )} + + {isSuccess && ( +
    + {isDebt &&

    -

    } +

    $

    +

    {balance}

    + {isCredit &&

    /credits

    } +
    + )} +
    +
    + ) +} + +export default CreditBalance diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/ChangePaymentMethodModal.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/ChangePaymentMethodModal.tsx new file mode 100644 index 00000000000..8abb06b9d87 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/ChangePaymentMethodModal.tsx @@ -0,0 +1,89 @@ +import { useParams } from 'common' +import { OrganizationPaymentMethod } from 'data/organizations/organization-payment-methods-query' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import { + SubscriptionTier, + useOrgSubscriptionUpdateMutation, +} from 'data/subscriptions/org-subscription-update-mutation' +import { useStore } from 'hooks' +import { Button, Modal } from 'ui' + +export interface ChangePaymentMethodModalProps { + selectedPaymentMethod?: OrganizationPaymentMethod + onClose: () => void +} + +const ChangePaymentMethodModal = ({ + selectedPaymentMethod, + onClose, +}: ChangePaymentMethodModalProps) => { + const { ui } = useStore() + const { slug } = useParams() + const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: slug }) + const { mutateAsync: updateOrgSubscription, isLoading: isUpdating } = + useOrgSubscriptionUpdateMutation() + + const onConfirmUpdate = async () => { + if (!slug) return console.error('Slug is required') + if (!subscription) return console.error('Subscription is required') + if (!selectedPaymentMethod) return console.error('Card ID is required') + + const selectedTier = + subscription.plan.id === 'pro' && subscription.usage_billing_enabled + ? 'tier_payg' + : `tier_${subscription.plan.id}` + + try { + await updateOrgSubscription({ + slug, + tier: selectedTier as SubscriptionTier, + paymentMethod: selectedPaymentMethod.id, + }) + ui.setNotification({ + category: 'success', + message: `Successfully removed payment method ending with ${selectedPaymentMethod.card.last4}`, + }) + onClose() + } catch (error: any) { + ui.setNotification({ + category: 'error', + message: `Failed to change payment method: ${error.message}`, + }) + } + } + + return ( + onClose()} + customFooter={ +
    + + +
    + } + > +
    + +

    + Upon clicking confirm, all future charges will be deducted from the card ending with{' '} + {selectedPaymentMethod?.card.last4}. There are no immediate charges. +

    +
    +
    +
    + ) +} + +export default ChangePaymentMethodModal diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/DeletePaymentMethodModal.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/DeletePaymentMethodModal.tsx new file mode 100644 index 00000000000..66fb26260b5 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/DeletePaymentMethodModal.tsx @@ -0,0 +1,80 @@ +import { useParams } from 'common' +import { useOrganizationPaymentMethodDeleteMutation } from 'data/organizations/organization-payment-method-delete-mutation' +import { OrganizationPaymentMethod } from 'data/organizations/organization-payment-methods-query' +import { useStore } from 'hooks' +import { Alert, Button, Modal } from 'ui' + +export interface DeletePaymentMethodModalProps { + selectedPaymentMethod?: OrganizationPaymentMethod + onClose: () => void +} + +const DeletePaymentMethodModal = ({ + selectedPaymentMethod, + onClose, +}: DeletePaymentMethodModalProps) => { + const { ui } = useStore() + const { slug } = useParams() + const { mutateAsync: deletePayment, isLoading: isDeleting } = + useOrganizationPaymentMethodDeleteMutation() + + const onConfirmDelete = async () => { + if (!slug) return console.error('Slug is required') + if (!selectedPaymentMethod) return console.error('Card ID is required') + + try { + await deletePayment({ slug, cardId: selectedPaymentMethod.id }) + ui.setNotification({ + category: 'success', + message: `Successfully removed payment method ending with ${selectedPaymentMethod.card.last4}`, + }) + onClose() + } catch (error: any) { + ui.setNotification({ + category: 'error', + message: `Failed to delete payment method: ${error.message}`, + }) + } + } + + return ( + onClose()} + customFooter={ +
    + + +
    + } + > +
    + + +

    + Any subscription currently charging this payment method will start charging your + default payment method. +

    +
    +
    +
    +
    + ) +} + +export default DeletePaymentMethodModal diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/PaymentMethods.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/PaymentMethods.tsx new file mode 100644 index 00000000000..04b69f49827 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/PaymentMethods/PaymentMethods.tsx @@ -0,0 +1,209 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' + +import { useParams } from 'common' +import { AddNewPaymentMethodModal } from 'components/interfaces/Billing' +import { + ScaffoldSection, + ScaffoldSectionContent, + ScaffoldSectionDetail, +} from 'components/layouts/Scaffold' +import AlertError from 'components/ui/AlertError' +import { FormPanel, FormSection, FormSectionContent } from 'components/ui/Forms' +import NoPermission from 'components/ui/NoPermission' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { organizationKeys } from 'data/organizations/keys' +import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organization-payment-methods-query' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import { useCheckPermissions, useStore } from 'hooks' +import { BASE_PATH } from 'lib/constants' +import { getURL } from 'lib/helpers' +import { Badge, Button, Dropdown, IconCreditCard, IconMoreHorizontal, IconPlus } from 'ui' +import ChangePaymentMethodModal from './ChangePaymentMethodModal' +import DeletePaymentMethodModal from './DeletePaymentMethodModal' + +const PaymentMethods = () => { + const { ui } = useStore() + const { slug } = useParams() + const queryClient = useQueryClient() + const [selectedMethodForUse, setSelectedMethodForUse] = useState() + const [selectedMethodToDelete, setSelectedMethodToDelete] = useState() + const [showAddPaymentMethodModal, setShowAddPaymentMethodModal] = useState(false) + + const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: slug }) + const { + data: paymentMethods, + error, + isLoading, + isError, + isSuccess, + } = useOrganizationPaymentMethodsQuery({ slug }) + const sortedPaymentMethods = + paymentMethods?.sort((a, b) => (a.id === subscription?.payment_method_id ? -1 : 0)) ?? [] + + const canReadPaymentMethods = useCheckPermissions( + PermissionAction.BILLING_READ, + 'stripe.payment_methods' + ) + const canUpdatePaymentMethods = useCheckPermissions( + PermissionAction.BILLING_WRITE, + 'stripe.payment_methods' + ) + + return ( + <> + + +
    +
    +

    Payment methods

    +

    + This copy might need to change, when adding a new payment method, either remove the + old one or go to your projects' subscription to explicitly update the payment + method. +

    +
    +
    +
    + + {!canReadPaymentMethods ? ( + + ) : ( + <> + {isLoading && ( +
    + + + +
    + )} + + {isError && ( + + )} + + {isSuccess && ( + + {!canUpdatePaymentMethods ? ( +

    + You need additional permissions to manage payment methods +

    + ) : ( +
    + )} + +
    + } + > + + + {(paymentMethods?.length ?? 0) === 0 ? ( +
    + +

    No payment methods

    +
    + ) : ( +
    + {sortedPaymentMethods?.map((paymentMethod) => { + const isActive = subscription?.payment_method_id === paymentMethod.id + return ( +
    +
    + Credit card brand +

    + **** **** **** {paymentMethod.card.last4} +

    +

    + Expires: {paymentMethod.card.exp_month}/ + {paymentMethod.card.exp_year} +

    +
    + {isActive && Active} + {canUpdatePaymentMethods && !isActive ? ( + setSelectedMethodForUse(paymentMethod)} + > + Use this card + , + , + setSelectedMethodToDelete(paymentMethod)} + > + Delete card + , + ]} + > +
    + ) + })} +
    + )} +
    +
    +
    + )} + + )} +
    +
    + + setShowAddPaymentMethodModal(false)} + onConfirm={async () => { + setShowAddPaymentMethodModal(false) + ui.setNotification({ + category: 'success', + message: 'Successfully added new payment method', + }) + await queryClient.invalidateQueries(organizationKeys.paymentMethods(slug)) + }} + /> + + setSelectedMethodForUse(undefined)} + /> + + setSelectedMethodToDelete(undefined)} + /> + + ) +} + +export default PaymentMethods diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/EnterpriseCard.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/EnterpriseCard.tsx new file mode 100644 index 00000000000..af9608f0dd0 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/EnterpriseCard.tsx @@ -0,0 +1,62 @@ +import clsx from 'clsx' +import { Button, IconCheck } from 'ui' +import { PricingInformation } from 'shared-data' + +export interface EnterpriseCardProps { + plan: PricingInformation + isCurrentPlan: boolean +} + +const EnterpriseCard = ({ plan, isCurrentPlan }: EnterpriseCardProps) => { + return ( +
    +
    +
    +

    {plan.name}

    + {isCurrentPlan ? ( +
    + Current plan +
    + ) : plan.nameBadge ? ( +
    + {plan.nameBadge} +
    + ) : null} +
    + +

    {plan.description}

    + + + + +
    + +
    +
      + {plan.features.map((feature) => ( +
    • +
    • + ))} +
    + + + + +
    +
    + ) +} + +export default EnterpriseCard diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/ExitSurveyModal.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/ExitSurveyModal.tsx new file mode 100644 index 00000000000..ff7dd6b1552 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/ExitSurveyModal.tsx @@ -0,0 +1,235 @@ +import HCaptcha from '@hcaptcha/react-hcaptcha' +import { useQueryClient } from '@tanstack/react-query' +import { includes, without } from 'lodash' +import { useRouter } from 'next/router' +import { useReducer, useRef, useState } from 'react' + +import { useParams } from 'common' +import { setProjectStatus } from 'data/projects/projects-query' +import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' +import { useProjectSubscriptionUpdateMutation } from 'data/subscriptions/project-subscription-update-mutation' +import { useFlag, useStore } from 'hooks' +import { post } from 'lib/common/fetch' +import { API_URL, PROJECT_STATUS } from 'lib/constants' +import { Button, Input, Modal } from 'ui' +import { CANCELLATION_REASONS } from '../BillingSettings.constants' +import ProjectUpdateDisabledTooltip from '../../BillingSettings/ProjectUpdateDisabledTooltip' + +export interface ExitSurveyModalProps { + visible: boolean + onClose: (success?: boolean) => void +} + +// [Joshen] For context - Exit survey is only when going to free plan from a paid plan +// [Joshen TODO] Remove all contexts of projects - i'm presuming we still want the exit survey + +const ExitSurveyModal = ({ visible, onClose }: ExitSurveyModalProps) => { + const queryClient = useQueryClient() + const { ui } = useStore() + const { ref: projectRef } = useParams() + const captchaRef = useRef(null) + const router = useRouter() + + const [isSubmitting, setIsSubmitting] = useState(false) + const [message, setMessage] = useState('') + const [captchaToken, setCaptchaToken] = useState(null) + const [selectedReasons, dispatchSelectedReasons] = useReducer(reducer, []) + + const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') + const { data: addons } = useProjectAddonsQuery({ projectRef }) + const { mutateAsync: updateSubscriptionTier } = useProjectSubscriptionUpdateMutation() + + const subscriptionAddons = addons?.selected_addons ?? [] + const hasComputeInstance = subscriptionAddons.find((addon) => addon.type === 'compute_instance') + + function reducer(state: any, action: any) { + if (includes(state, action.target.value)) { + return without(state, action.target.value) + } else { + return [...state, action.target.value] + } + } + + const resetCaptcha = () => { + setCaptchaToken(null) + captchaRef.current?.resetCaptcha() + } + + const onSubmit = async () => { + if (selectedReasons.length === 0) { + return ui.setNotification({ + category: 'error', + message: 'Please select at least one reason for canceling your subscription', + }) + } + + setIsSubmitting(true) + let token = captchaToken + + try { + if (!token) { + const captchaResponse = await captchaRef.current?.execute({ async: true }) + token = captchaResponse?.response ?? null + await downgradeProject() + } + } catch (error: any) { + ui.setNotification({ + category: 'error', + message: `Failed to downgrade project: ${error.message}`, + }) + } finally { + setIsSubmitting(false) + } + } + + const downgradeProject = async (values?: any) => { + // Update the subscription first, followed by posting the exit survey if successful + // If compute instance is present within the existing subscription, then a restart will be triggered + if (!projectRef) return console.error('Project ref is required') + + try { + await updateSubscriptionTier({ projectRef, tier: 'tier_free' }) + resetCaptcha() + } catch (error: any) { + return ui.setNotification({ + error, + category: 'error', + message: `Failed to cancel subscription: ${error.message}`, + }) + } + + try { + const feedbackRes = await post(`${API_URL}/feedback/downgrade`, { + projectRef, + reasons: selectedReasons.reduce((a, b) => `${a}- ${b}\n`, ''), + additionalFeedback: message, + exitAction: 'downgrade', + }) + if (feedbackRes.error) throw feedbackRes.error + } catch (error: any) { + return ui.setNotification({ + error, + category: 'info', + message: `Failed to submit exit survey: ${error.message}`, + }) + } finally { + ui.setNotification({ + category: 'success', + duration: hasComputeInstance ? 8000 : 4000, + message: hasComputeInstance + ? 'Your project has been downgraded and is currently restarting to update its instance size' + : 'Successfully downgraded project to the free plan', + }) + if (hasComputeInstance) { + setProjectStatus(queryClient, projectRef, PROJECT_STATUS.RESTORING) + router.push(`/project/${projectRef}`) + } + onClose(true) + window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }) + } + } + + return ( + <> +
    + { + setCaptchaToken(token) + if (document !== undefined) document.body.classList.remove('!pointer-events-auto') + }} + onExpire={() => setCaptchaToken(null)} + onOpen={() => { + // [Joshen] This is to ensure that hCaptcha popup remains clickable + if (document !== undefined) document.body.classList.add('!pointer-events-auto') + }} + onClose={() => { + if (document !== undefined) document.body.classList.remove('!pointer-events-auto') + }} + /> +
    + + + +
    +

    + We always strive to improve Supabase as much as we can. Please let us know the reasons + you are canceling your subscription so that we can improve in the future. +

    +
    +
    + {CANCELLATION_REASONS.map((option) => { + const active = selectedReasons.find((x) => x === option) + return ( + + ) + })} +
    +
    + setMessage(event.target.value)} + label="Anything else that we can improve on?" + /> +
    +
    +
    +
    + +
    +

    + The unused amount for the remaining of your billing cycle will be refunded as credits +

    +
    + + + + +
    +
    +
    + + ) +} + +export default ExitSurveyModal diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/MembersExceedLimitModal.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/MembersExceedLimitModal.tsx new file mode 100644 index 00000000000..7f64080e394 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/MembersExceedLimitModal.tsx @@ -0,0 +1,59 @@ +import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' +import { useSelectedOrganization } from 'hooks' +import { Button, Modal } from 'ui' + +// [Joshen TODO] Check if we still need this? + +export interface MembersExceedLimitModalProps { + visible: boolean + onClose: () => void +} + +const MembersExceedLimitModal = ({ visible, onClose }: MembersExceedLimitModalProps) => { + const selectedOrganization = useSelectedOrganization() + const slug = selectedOrganization?.slug + const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery({ slug }) + + return ( + +
    + +
    +

    + The following members have reached their maximum limits for the number of active free + plan projects within organizations where they are an administrator or owner: +

    +
      + {(membersExceededLimit || []).map((member, idx: number) => ( +
    • + {member.username || member.primary_email} (Limit: {member.free_project_limit} free + projects) +
    • + ))} +
    +

    + These members will need to either delete, pause, or upgrade one or more of these + projects before you're able to downgrade this project. +

    +
    +
    + + +
    + +
    +
    +
    +
    + ) +} + +export default MembersExceedLimitModal diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/PaymentMethodSelection.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/PaymentMethodSelection.tsx new file mode 100644 index 00000000000..5c955148e91 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/PaymentMethodSelection.tsx @@ -0,0 +1,181 @@ +import * as Tooltip from '@radix-ui/react-tooltip' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useEffect, useMemo, useState } from 'react' + +import { useParams } from 'common' +import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query' +import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organization-payment-methods-query' +import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' +import { BASE_PATH } from 'lib/constants' +import { getURL } from 'lib/helpers' +import { Button, IconAlertCircle, IconCreditCard, IconLoader, IconPlus, Listbox } from 'ui' +import { AddNewPaymentMethodModal } from 'components/interfaces/Billing' + +export interface PaymentMethodSelectionProps { + selectedPaymentMethod?: string + onSelectPaymentMethod: (id: string) => void +} + +const PaymentMethodSelection = ({ + selectedPaymentMethod, + onSelectPaymentMethod, +}: PaymentMethodSelectionProps) => { + const { ui } = useStore() + const { ref: projectRef } = useParams() + const selectedOrganization = useSelectedOrganization() + const slug = selectedOrganization?.slug + const [showAddNewPaymentMethodModal, setShowAddNewPaymentMethodModal] = useState(false) + + const { + data, + isLoading, + isSuccess: loadedPaymentMethods, + refetch: refetchPaymentMethods, + } = useOrganizationPaymentMethodsQuery({ slug }) + const paymentMethods = useMemo(() => data ?? [], [data]) + + const { data: customerProfile, isSuccess: loadedCustomerProfile } = + useOrganizationCustomerProfileQuery({ slug }) + + const canUpdatePaymentMethods = useCheckPermissions( + PermissionAction.BILLING_WRITE, + 'stripe.payment_methods' + ) + + useEffect(() => { + if (loadedPaymentMethods && loadedCustomerProfile && paymentMethods.length > 0) { + const selectedPaymentMethodExists = paymentMethods.some( + (it) => it.id === selectedPaymentMethod + ) + + if (!selectedPaymentMethod || !selectedPaymentMethodExists) { + const defaultPaymentMethod = paymentMethods.find( + (method) => method.id === customerProfile.invoice_settings.default_payment_method + ) + if (defaultPaymentMethod !== undefined) { + onSelectPaymentMethod(defaultPaymentMethod.id) + } else { + onSelectPaymentMethod(paymentMethods[0].id) + } + } + } + }, [ + loadedPaymentMethods, + loadedCustomerProfile, + selectedPaymentMethod, + customerProfile, + paymentMethods, + onSelectPaymentMethod, + ]) + + return ( + <> +
    +

    Select payment method

    + {isLoading ? ( +
    + +

    Retrieving payment methods

    +
    + ) : paymentMethods.length === 0 ? ( +
    +
    + +

    No saved payment methods

    +
    + + + + + + {!canUpdatePaymentMethods && ( + + + +
    + + You need additional permissions to add new payment methods to this + organization + +
    +
    +
    + )} +
    +
    + ) : ( + + {paymentMethods.map((method: any) => { + const label = `•••• •••• •••• ${method.card.last4}` + return ( + { + return ( + Credit Card Brand + ) + }} + > +
    {label}
    +
    + ) + })} +
    setShowAddNewPaymentMethodModal(true)} + > + +

    + Add new payment method +

    +
    +
    + )} +
    + + setShowAddNewPaymentMethodModal(false)} + onConfirm={async () => { + setShowAddNewPaymentMethodModal(false) + ui.setNotification({ + category: 'success', + message: 'Successfully added new payment method', + }) + const { data } = await refetchPaymentMethods() + if (data?.length) { + // Preselect the card that was just added + const mostRecentPaymentMethod = data.reduce( + (prev, current) => (prev.created > current.created ? prev : current), + data[0] + ) + onSelectPaymentMethod(mostRecentPaymentMethod.id) + } + }} + /> + + ) +} + +export default PaymentMethodSelection diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/PlanUpdateSidePanel.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/PlanUpdateSidePanel.tsx new file mode 100644 index 00000000000..0d9ffda0a02 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/PlanUpdateSidePanel.tsx @@ -0,0 +1,381 @@ +import * as Tooltip from '@radix-ui/react-tooltip' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import clsx from 'clsx' +import Link from 'next/link' +import { useEffect, useState } from 'react' + +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { useOrgPlansQuery } from 'data/subscriptions/org-plans-query' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation' +import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' +import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants' +import Telemetry from 'lib/telemetry' +import { useRouter } from 'next/router' +import { plans as subscriptionsPlans } from 'shared-data/plans' +import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' +import { Alert, Button, IconCheck, IconExternalLink, Modal, SidePanel } from 'ui' +import EnterpriseCard from './EnterpriseCard' +import ExitSurveyModal from './ExitSurveyModal' +import MembersExceedLimitModal from './MembersExceedLimitModal' +import PaymentMethodSelection from './PaymentMethodSelection' + +// [Joshen TODO] Need to remove all contexts of "projects" + +const PlanUpdateSidePanel = () => { + const { ui } = useStore() + const router = useRouter() + const selectedOrganization = useSelectedOrganization() + const slug = selectedOrganization?.slug + + const [isSubmitting, setIsSubmitting] = useState(false) + const [showExitSurvey, setShowExitSurvey] = useState(false) + const [selectedPaymentMethod, setSelectedPaymentMethod] = useState() + const [showDowngradeError, setShowDowngradeError] = useState(false) + const [selectedTier, setSelectedTier] = useState<'tier_free' | 'tier_pro' | 'tier_team'>() + + const canUpdateSubscription = useCheckPermissions( + PermissionAction.BILLING_WRITE, + 'stripe.subscriptions' + ) + + const snap = useOrgSettingsPageStateSnapshot() + const visible = snap.panelKey === 'subscriptionPlan' + const onClose = () => snap.setPanelKey(undefined) + + const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: slug }) + const { data: plans, isLoading: isLoadingPlans } = useOrgPlansQuery({ orgSlug: slug }) + const { mutateAsync: updateOrgSubscription } = useOrgSubscriptionUpdateMutation() + // const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery({ slug }) // [Joshen] Still need? + + const availablePlans = plans ?? [] + const subscriptionAddons: any[] = [] // [Joshen] Still need? + + // const hasMembersExceedingFreeTierLimit = (membersExceededLimit || []).length > 0 + const hasMembersExceedingFreeTierLimit = false // [Joshen] Still need? + const selectedTierMeta = subscriptionsPlans.find((tier) => tier.id === selectedTier) + const selectedPlanMeta = availablePlans.find( + (plan) => plan.id === selectedTier?.split('tier_')[1] + ) + + useEffect(() => { + if (visible) { + setSelectedTier(undefined) + Telemetry.sendActivity( + { + activity: 'Side Panel Viewed', + source: 'Dashboard', + data: { + title: 'Change Subscription Plan', + section: 'Subscription plan', + }, + }, + router + ) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible]) + + const onConfirmDowngrade = () => { + setSelectedTier(undefined) + if (hasMembersExceedingFreeTierLimit) { + setShowDowngradeError(true) + } else { + setShowExitSurvey(true) + } + } + + const onUpdateSubscription = async () => { + if (!slug) return console.error('org slug is required') + if (!selectedTier) return console.error('Selected plan is required') + if (!selectedPaymentMethod) { + return ui.setNotification({ category: 'error', message: 'Please select a payment method' }) + } + + try { + setIsSubmitting(true) + await updateOrgSubscription({ + slug, + tier: selectedTier, + paymentMethod: selectedPaymentMethod, + }) + ui.setNotification({ + category: 'success', + message: `Successfully updated subscription to ${selectedTierMeta?.name}!`, + }) + setSelectedTier(undefined) + onClose() + window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }) + } catch (error: any) { + ui.setNotification({ + error, + category: 'error', + message: `Unable to update subscription: ${error.message}`, + }) + } finally { + setIsSubmitting(false) + } + } + + return ( + <> + onClose()} + header={ +
    +

    Change subscription plan

    + + + + + +
    + } + > + +
    + {subscriptionsPlans.map((plan) => { + const planMeta = availablePlans.find((p) => p.id === plan.id.split('tier_')[1]) + const tierMeta = subscriptionsPlans.find((it) => it.id === plan.id) + const price = planMeta?.price ?? 0 + const isDowngradeOption = planMeta?.change_type === 'downgrade' + const isCurrentPlan = planMeta?.id === subscription?.plan?.id + + if (plan.id === 'tier_enterprise') { + return + } + + return ( +
    +
    +
    +

    {plan.name}

    + {isCurrentPlan ? ( +
    + Current plan +
    + ) : plan.nameBadge ? ( +
    + {plan.nameBadge} +
    + ) : ( + <> + )} +
    +
    + {(price ?? 0) > 0 &&

    From

    } + {isLoadingPlans ? ( +
    + +
    + ) : ( +

    ${price}

    + )} +

    {tierMeta?.costUnit}

    +
    +
    +
    + {tierMeta?.warning} +
    +
    + {isCurrentPlan ? ( + + ) : plan.id !== PRICING_TIER_PRODUCT_IDS.TEAM ? ( + + +
    + +
    +
    + {!canUpdateSubscription ? ( + + + +
    + + You do not have permission to change the subscription plan. + +
    +
    +
    + ) : null} +
    + ) : ( + + + + + + )} + +
    + +
      + {plan.features.map((feature) => ( +
    • +
      +
      +

      {feature}

      +
    • + ))} +
    +
    + + {plan.footer && ( +
    +

    {plan.footer}

    +
    + )} +
    + ) + })} +
    + + + + setSelectedTier(undefined)} + onConfirm={onConfirmDowngrade} + header={`Confirm to downgrade to ${selectedTierMeta?.name}`} + > + {/* [JOSHEN] We could make this better by only showing a danger warning if the project is already above the free plan limits */} + +
    + +

    + If you're already past the limits of the free plan, your project could become + unresponsive or enter read only mode. +

    + {subscriptionAddons.length > 0 && ( + <> +

    + Your project's add ons will also be removed, which includes: +

    +
      + {subscriptionAddons.map((addon) => ( +
    • + {addon.variant.name}{' '} + {addon.type === 'compute_instance' + ? 'compute instance' + : addon.type === 'pitr' + ? 'PITR' + : ''} +
    • + ))} +
    + + )} +
    +
    +
    +
    + + setSelectedTier(undefined)} + onConfirm={onUpdateSubscription} + overlayClassName="pointer-events-none" + header={`Confirm to upgrade to ${selectedTierMeta?.name}`} + > + +
    +

    + Upon clicking confirm, the amount of ${selectedPlanMeta?.price ?? 'Unknown'} will be + added to your monthly invoice and your credit card will be charged immediately. + Changing the plan resets your billing cycle and may result in a prorated charge for + previous usage. +

    +

    + You will also be able to change your project's add-ons after upgrading your project's + plan. +

    +
    + +
    +
    +
    +
    + + setShowDowngradeError(false)} + /> + + { + setShowExitSurvey(false) + if (success) onClose() + }} + /> + + ) +} + +export default PlanUpdateSidePanel diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/Subscription.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/Subscription.tsx new file mode 100644 index 00000000000..8051efb8697 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/Subscription/Subscription.tsx @@ -0,0 +1,183 @@ +import dayjs from 'dayjs' +import Link from 'next/link' + +import { useParams } from 'common' +import { + ScaffoldSection, + ScaffoldSectionContent, + ScaffoldSectionDetail, +} from 'components/layouts/Scaffold' +import AlertError from 'components/ui/AlertError' +import SparkBar from 'components/ui/SparkBar' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import { useFlag } from 'hooks' +import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' +import { Alert, Button, IconExternalLink } from 'ui' +import ProjectUpdateDisabledTooltip from '../../BillingSettings/ProjectUpdateDisabledTooltip' +import PlanUpdateSidePanel from './PlanUpdateSidePanel' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' + +const Subscription = () => { + const { slug } = useParams() + const snap = useOrgSettingsPageStateSnapshot() + const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') + + const { + data: subscription, + error, + isLoading, + isError, + isSuccess, + } = useOrgSubscriptionQuery({ orgSlug: slug }) + + const currentPlan = subscription?.plan + const planName = currentPlan?.name ?? 'Unknown' + const billingCycleStart = dayjs.unix(subscription?.current_period_start ?? 0).utc() + const billingCycleEnd = dayjs.unix(subscription?.current_period_end ?? 0).utc() + const daysToCycleEnd = billingCycleEnd.diff(dayjs(), 'days') + const daysWithinCycle = billingCycleEnd.diff(billingCycleStart, 'days') + + const canChangeTier = + !projectUpdateDisabled && !['team', 'enterprise'].includes(currentPlan?.id ?? '') + + return ( + <> + + +
    +

    Subscription Plan

    +
    +

    More information

    + +
    +
    +
    + + {isLoading && ( +
    + + + +
    + )} + + {isError && } + + {isSuccess && ( +
    +
    +

    This organization is currently on the plan:

    +

    + {currentPlan?.name ?? 'Unknown'} +

    +
    + +
    + + + + {!canChangeTier && + (projectUpdateDisabled ? ( + + We have temporarily disabled project and subscription changes - our engineers + are working on a fix. + + ) : ( + + + + + + +
    , + ]} + > + Please contact us if you'd like to change your plan. + + ))} +
    + + {!subscription?.usage_billing_enabled && ( + snap.setPanelKey('subscriptionPlan')}> + Upgrade Plan + + ) : ( + + ) + } + > +

    + When this organization exceeds its{' '} + + + included usage quotas + + + , its projects may become unresponsive.{' '} + {currentPlan?.id === 'free' ? ( +

    + If you wish to exceed the included usage, you should upgrade to a paid plan. +

    + ) : ( +

    + You currently have Spend Cap enabled - when you exceed your plan's limit, + you will experience restrictions. To scale seamlessly and pay for + over-usage, you can adjust your Cost Control settings. +

    + )} +

    +
    + )} + + +
    + )} + + + + + ) +} + +export default Subscription diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.constants.ts b/studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.constants.ts new file mode 100644 index 00000000000..9595d935efb --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.constants.ts @@ -0,0 +1,459 @@ +// List of available Tax IDs as reflected in Stripe's web portal +// This was manually ported over so there may be a chance of mistakes +// Last updated as of 29th March 2022. +// The code may not necessarily match with the name (ref SE_VAT) +// https://stripe.com/docs/api/customer_tax_ids/create +export interface TaxId { + name: string + code: string + country: string + placeholder: string + vatPrefix?: string +} + +export const TAX_IDS: TaxId[] = [ + { + name: 'AE TRN', + code: 'ae_trn', + country: 'United Arab Emirates', + placeholder: '123456789012345', + }, + { + name: 'AT VAT', + code: 'eu_vat', + country: 'Austria', + placeholder: 'ATU12345678', + vatPrefix: 'ATU', + }, + { + name: 'AU ABN', + code: 'au_abn', + country: 'Australia', + placeholder: '12345678912', + }, + { + name: 'AU ARN', + code: 'au_arn', + country: 'Australia', + placeholder: '123456789123', + }, + { + name: 'BE VAT', + code: 'eu_vat', + country: 'Belgium', + placeholder: 'BE0123456789', + vatPrefix: 'BE', + }, + { + name: 'BG VAT', + code: 'eu_vat', + country: 'Bulgaria', + placeholder: 'BG0123456789', + vatPrefix: 'BG', + }, + { + name: 'BR CNPJ', + code: 'br_cnpj', + country: 'Brazil', + placeholder: '01.234.456/5432-10', + }, + { + name: 'BR CPF', + code: 'br_cpf', + country: 'Brazil', + placeholder: '123.456.789-87', + }, + { + name: 'CA BN', + code: 'ca_bn', + country: 'Canada', + placeholder: '123456789', + }, + { + name: 'CA GST/HST', + code: 'ca_gst_hst', + country: 'Canada', + placeholder: '123456789RT0002', + }, + { + name: 'CA PST-BC', + code: 'ca_pst_bc', + country: 'Canada', + placeholder: 'PST-1234-5678', + }, + { + name: 'CA PST-MB', + code: 'ca_pst_mb', + country: 'Canada', + placeholder: '123456-7', + }, + { + name: 'CA PST-SK', + code: 'ca_pst_mb', + country: 'Canada', + placeholder: '1234567', + }, + { + name: 'CA QST', + code: 'ca_qst', + country: 'Canada', + placeholder: '1234567890TQ1234', + }, + { + name: 'CH VAT', + code: 'ch_vat', + country: 'Switzerland', + placeholder: 'CHE-123.456.789 MWST', + vatPrefix: 'CHE', + }, + { + name: 'CL TIN', + code: 'cl_tin', + country: 'Chile', + placeholder: '12.345.678-K', + }, + { + name: 'CY VAT', + code: 'eu_vat', + country: 'Cyprus', + placeholder: 'CY12345678Z', + vatPrefix: 'CY', + }, + { + name: 'CZ VAT', + code: 'eu_vat', + country: 'Czech Republic', + placeholder: 'CZ1234567890', + vatPrefix: 'CZ', + }, + { + name: 'DE VAT', + code: 'eu_vat', + country: 'Germany', + placeholder: 'DE123456789', + vatPrefix: 'DE', + }, + { + name: 'DK VAT', + code: 'eu_vat', + country: 'Denmark', + placeholder: 'DK12345678', + vatPrefix: 'DK', + }, + { + name: 'EE VAT', + code: 'eu_vat', + country: 'Estonia', + placeholder: 'EE123456789', + vatPrefix: 'EE', + }, + { + name: 'ES CIF', + code: 'es_cif', + country: 'Spain', + placeholder: 'A12345678', + }, + { + name: 'ES VAT', + code: 'eu_vat', + country: 'Spain', + placeholder: 'ESX1234567Z', + vatPrefix: 'ES', + }, + { + name: 'FI VAT', + code: 'eu_vat', + country: 'Finland', + placeholder: 'FI12345678', + vatPrefix: 'FI', + }, + { + name: 'FR VAT', + code: 'eu_vat', + country: 'France', + placeholder: 'FRAB123456789', + vatPrefix: 'FR', + }, + { + name: 'GB VAT', + code: 'eu_vat', + country: 'United Kingdom', + placeholder: 'GB123456789', + vatPrefix: 'GB', + }, + { + name: 'GE VAT', + code: 'ge_vat', + country: 'Georgia', + placeholder: '123456789', + }, + { + name: 'EL VAT', + code: 'eu_vat', + country: 'Greece', + placeholder: 'EL123456789', + vatPrefix: 'EL', + }, + { + name: 'HK BR', + code: 'hk_br', + country: 'Hong Kong SAR China', + placeholder: '12345678', + }, + { + name: 'HR VAT', + code: 'eu_vat', + country: 'Croatia', + placeholder: 'HR12345678912', + vatPrefix: 'HR', + }, + { + name: 'HU VAT', + code: 'eu_vat', + country: 'Hungary', + placeholder: 'HU12345678912', + vatPrefix: 'HU', + }, + { + name: 'ID NPWP', + code: 'id_npwp', + country: 'Indonesia', + placeholder: '12.345.678.9-012.345', + }, + { + name: 'IE VAT', + code: 'eu_vat', + country: 'Ireland', + placeholder: 'IE1234567AB', + vatPrefix: 'IE', + }, + { + name: 'IL VAT', + code: 'il_vat', + country: 'Israel', + placeholder: '000012345', + }, + { + name: 'IN GST', + code: 'in_gst', + country: 'India', + placeholder: '12ABCDE3456FGZH', + }, + { + name: 'IS VAT', + code: 'is_vat', + country: 'Iceland', + placeholder: '123456', + }, + { + name: 'IT VAT', + code: 'eu_vat', + country: 'Italy', + placeholder: 'IT12345678912', + vatPrefix: 'IT', + }, + { + name: 'JP CN', + code: 'jp_cn', + country: 'Japan', + placeholder: '1234567891234', + }, + { + name: 'JP RN', + code: 'jp_rn', + country: 'Japan', + placeholder: '12345', + }, + { + name: 'KR BRN', + code: 'kr_brn', + country: 'Korea', + placeholder: '123-45-67890', + }, + { + name: 'LI UID', + code: 'li_uid', + country: 'Liechtenstein', + placeholder: 'CHE123456789', + }, + { + name: 'LT VAT', + code: 'eu_vat', + country: 'Lithuania', + placeholder: 'LT123456789123', + vatPrefix: 'LT', + }, + { + name: 'LU VAT', + code: 'eu_vat', + country: 'Luxembourg', + placeholder: 'LU12345678', + vatPrefix: 'LU', + }, + { + name: 'LV VAT', + code: 'eu_vat', + country: 'Latvia', + placeholder: 'LV12345678912', + vatPrefix: 'LV', + }, + { + name: 'MT VAT', + code: 'eu_vat', + country: 'Malta', + placeholder: 'MT12345678', + vatPrefix: 'MT', + }, + { + name: 'MX RFC', + code: 'mx_rfc', + country: 'Mexico', + placeholder: 'ABC010203AB9', + }, + { + name: 'MY FRP', + code: 'my_frp', + country: 'Malaysia', + placeholder: '12345678', + }, + { + name: 'MY ITN', + code: 'my_itn', + country: 'Malaysia', + placeholder: 'C 1234567890', + }, + { + name: 'MY SST', + code: 'my_sst', + country: 'Malaysia', + placeholder: 'A12-3456-78912345', + }, + { + name: 'NL VAT', + code: 'eu_vat', + country: 'Netherlands', + placeholder: 'NL123456789B12', + vatPrefix: 'NL', + }, + { + name: 'NO VAT', + code: 'no_vat', + country: 'Norway', + placeholder: '123456789MVA', + }, + { + name: 'NZ GST', + code: 'nz_gst', + country: 'New Zealand', + placeholder: '123456789', + }, + { + name: 'PL VAT', + code: 'eu_vat', + country: 'Poland', + placeholder: 'PL1234567890', + vatPrefix: 'PL', + }, + { + name: 'PT VAT', + code: 'eu_vat', + country: 'Portugal', + placeholder: 'PT123456789', + vatPrefix: 'PT', + }, + { + name: 'RO VAT', + code: 'eu_vat', + country: 'Romania', + placeholder: 'RO1234567891', + vatPrefix: 'RO', + }, + { + name: 'RU INN', + code: 'ru_inn', + country: 'Russia', + placeholder: '1234567891', + }, + { + name: 'RU KPP', + code: 'ru_kpp', + country: 'Russia', + placeholder: '123456789', + }, + { + name: 'SA VAT', + code: 'sa_vat', + country: 'Saudi Arabia', + placeholder: '123456789012345', + }, + { + name: 'SE VAT', + code: 'eu_vat', + country: 'Sweden', + placeholder: 'SE123456789123', + vatPrefix: 'SE', + }, + { + name: 'SG GST', + code: 'sg_gst', + country: 'Singapore', + placeholder: 'M12345678X', + }, + { + name: 'SG UEN', + code: 'sg_uen', + country: 'Singapore', + placeholder: '123456789F', + }, + { + name: 'SI VAT', + code: 'eu_vat', + country: 'Slovenia', + placeholder: 'SI12345678', + vatPrefix: 'SI', + }, + { + name: 'SK VAT', + code: 'eu_vat', + country: 'Slovakia', + placeholder: 'SK1234567891', + vatPrefix: 'SK', + }, + { + name: 'TH VAT', + code: 'th_vat', + country: 'Thailand', + placeholder: '1234567891234', + }, + { + name: 'TW VAT', + code: 'tw_vat', + country: 'Taiwan', + placeholder: '12345678', + }, + { + name: 'UA VAT', + code: 'ua_vat', + country: 'Ukraine', + placeholder: '123456789', + }, + { + name: 'US EIN', + code: 'us_ein', + country: 'United States', + placeholder: '12-3456789', + }, + { + name: 'XI VAT', + code: 'eu_vat', + country: 'United Kingdom (Northern Ireland)', + placeholder: 'XI123456789', + vatPrefix: 'XI', + }, + { + name: 'ZA VAT', + code: 'za_vat', + country: 'South Africa', + placeholder: '4123456789', + }, +] diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.tsx new file mode 100644 index 00000000000..c0c81f6d1c1 --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.tsx @@ -0,0 +1,254 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useEffect, useState } from 'react' + +import { useParams } from 'common' +import { + ScaffoldSection, + ScaffoldSectionContent, + ScaffoldSectionDetail, +} from 'components/layouts/Scaffold' +import AlertError from 'components/ui/AlertError' +import { FormActions, FormPanel, FormSection, FormSectionContent } from 'components/ui/Forms' +import NoPermission from 'components/ui/NoPermission' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { TaxId, useOrganizationTaxIDsQuery } from 'data/organizations/organization-tax-ids-query' +import { + TaxIdValue, + useOrganizationTaxIDsUpdateMutation, +} from 'data/organizations/organization-tax-ids-update-mutation' +import { useCheckPermissions, useStore } from 'hooks' +import { uuidv4 } from 'lib/helpers' +import { isEqual } from 'lodash' +import { Button, Form, IconPlus, IconX, Input, Listbox } from 'ui' +import { TAX_IDS } from './TaxID.constants' +import { sanitizeTaxID } from './TaxID.utilts' + +const TaxID = () => { + const { ui } = useStore() + const { slug } = useParams() + const { + data: taxIds, + error, + isLoading, + isSuccess, + isError, + } = useOrganizationTaxIDsQuery({ slug }) + const { mutateAsync: updateTaxIDs } = useOrganizationTaxIDsUpdateMutation() + + const [errors, setErrors] = useState([]) + const [taxIdValues, setTaxIdValues] = useState([]) + + const formattedTaxIds = + taxIds?.map((taxId: TaxId) => { + return { + id: taxId.id, + type: taxId.type, + value: taxId.value, + name: + taxId.type === 'eu_vat' + ? `${taxId.country} VAT` + : TAX_IDS.find((option) => option.code === taxId.type)?.name ?? '', + } + }) ?? [] + + const formId = 'tax-id-form' + const hasChanges = !isEqual(taxIdValues, formattedTaxIds) + const canReadTaxIds = useCheckPermissions(PermissionAction.BILLING_READ, 'stripe.tax_ids') + const canUpdateTaxIds = useCheckPermissions(PermissionAction.BILLING_WRITE, 'stripe.tax_ids') + + useEffect(() => { + if (isSuccess) { + if (taxIdValues.length === 0) { + setTaxIdValues(formattedTaxIds) + } else { + const erroredTaxIds = taxIdValues.filter((taxId: any) => errors.includes(taxId.id)) + setTaxIdValues(formattedTaxIds.concat(erroredTaxIds)) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSuccess]) + + const onUpdateTaxId = (id: string, key: string, value: string) => { + const updatedTaxIds = taxIdValues.map((taxId: any) => { + if (taxId.id === id) { + if (key === 'name') { + const selectedTaxIdOption = TAX_IDS.find((option) => option.name === value) + return { ...taxId, [key]: value, type: selectedTaxIdOption?.code } + } else { + return { ...taxId, [key]: value } + } + } + return taxId + }) + setTaxIdValues(updatedTaxIds) + } + + const onAddNewTaxId = () => { + const newTaxId = { id: uuidv4(), type: TAX_IDS[0].code, value: '', name: TAX_IDS[0].name } + const updatedTaxIds = taxIdValues.concat([newTaxId]) + setTaxIdValues(updatedTaxIds) + } + + const onRemoveTaxId = (id: string) => { + const updatedTaxIds = taxIdValues.filter((taxId) => taxId.id !== id) + setTaxIdValues(updatedTaxIds) + } + + const onResetTaxIds = () => { + setTaxIdValues(formattedTaxIds) + } + + const onSaveTaxIds = async (values: any, { setSubmitting }: any) => { + if (!slug) return console.error('Slug is required') + if (taxIds === undefined) return console.error('Tax IDs are required') + + try { + setSubmitting(true) + const newIds = taxIdValues.map((x) => sanitizeTaxID(x)) + const { errors } = await updateTaxIDs({ slug, existingIds: taxIds, newIds }) + setErrors(errors?.map((taxId) => taxId.id) ?? []) + + if (errors !== undefined && errors.length > 0) { + errors.forEach((taxId: any) => { + ui.setNotification({ category: 'error', message: taxId.result.error.message }) + }) + } else { + ui.setNotification({ category: 'success', message: 'Successfully updated tax IDs' }) + } + } catch (error: any) { + ui.setNotification({ category: 'error', message: `Failed to save tax IDs: ${error.message}` }) + } finally { + setSubmitting(false) + } + } + + return ( + + +
    +
    +

    Tax ID

    +

    + If you would like to include specific tax ID(s) to your invoices. +

    +

    + Make sure the tax ID looks exactly like the placeholder text. +

    +
    +
    +
    + + {!canReadTaxIds ? ( + + ) : ( + <> + {isLoading && ( +
    + + + +
    + )} + + {isError && ( + + )} + + {isSuccess && ( +
    + {({ isSubmitting }: { isSubmitting: boolean }) => ( + + onResetTaxIds()} + helper={ + !canUpdateTaxIds + ? "You need additional permissions to manage this organization's tax IDs" + : undefined + } + /> + + } + > + + + {taxIdValues.length >= 1 ? ( +
    + {taxIdValues.map((taxId, idx: number) => { + const selectedTaxId = TAX_IDS.find( + (option) => option.code === taxId.type + ) + return ( +
    + onUpdateTaxId(taxId.id, 'name', e)} + disabled={!canUpdateTaxIds} + className="w-[200px]" + > + {TAX_IDS.sort((a, b) => a.country.localeCompare(b.country)).map( + (option) => ( + + {option.country} - {option.name} + + ) + )} + + + onUpdateTaxId(taxId.id, 'value', e.target.value) + } + disabled={!canUpdateTaxIds} + /> + {canUpdateTaxIds && ( +
    + ) + })} +
    + ) : ( +
    +

    + No tax IDs +

    +
    + )} + {canUpdateTaxIds && ( +
    onAddNewTaxId()} + > + +

    Add another ID

    +
    + )} +
    +
    +
    + )} + + )} + + )} +
    +
    + ) +} + +export default TaxID diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.utilts.ts b/studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.utilts.ts new file mode 100644 index 00000000000..e44856a58be --- /dev/null +++ b/studio/components/interfaces/Organization/BillingSettingsV2/TaxID/TaxID.utilts.ts @@ -0,0 +1,24 @@ +import { TaxId } from 'data/organizations/organization-tax-ids-query' +import { TAX_IDS } from './TaxID.constants' +import { TaxIdValue } from 'data/organizations/organization-tax-ids-update-mutation' + +/** + * Sanitize EU VAT ids so they get prepended with the country code + * + * Ex: Belgium's VAT ID should be BE0123456789 not 0123456789 + * Only EU VAT ids are affected + */ +export const sanitizeTaxID = (taxId: TaxIdValue) => { + let newID = taxId + + const selectedTaxId = TAX_IDS.find((option) => option.code === taxId.type) + + const vatIdPrefix = selectedTaxId?.vatPrefix + + // if the value doesn't start with the prefix, prepend them + if (vatIdPrefix && !taxId.value.startsWith(vatIdPrefix)) { + newID.value = `${vatIdPrefix}${taxId.value}` + } + + return newID +} diff --git a/studio/components/interfaces/Organization/GeneralSettings/DeleteOrganizationButton.tsx b/studio/components/interfaces/Organization/GeneralSettings/DeleteOrganizationButton.tsx index c166fad4e7e..55f3c6ed20f 100644 --- a/studio/components/interfaces/Organization/GeneralSettings/DeleteOrganizationButton.tsx +++ b/studio/components/interfaces/Organization/GeneralSettings/DeleteOrganizationButton.tsx @@ -1,23 +1,26 @@ -import { useState, useContext } from 'react' -import { useRouter } from 'next/router' -import { observer } from 'mobx-react-lite' -import { Button, Form, Modal, Input } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' +import { useRouter } from 'next/router' +import { useState } from 'react' +import { Button, Form, Input, Modal } from 'ui' -import { useStore, checkPermissions } from 'hooks' +import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query' +import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' import { delete_ } from 'lib/common/fetch' import { API_URL } from 'lib/constants' -const DeleteOrganizationButton = observer(() => { +const DeleteOrganizationButton = () => { const router = useRouter() - const { app, ui } = useStore() + const queryClient = useQueryClient() + const { ui } = useStore() - const { slug: orgSlug, name: orgName } = ui.selectedOrganization || {} + const selectedOrganization = useSelectedOrganization() + const { slug: orgSlug, name: orgName } = selectedOrganization ?? {} const [isOpen, setIsOpen] = useState(false) const [value, setValue] = useState('') - const canDeleteOrganization = checkPermissions(PermissionAction.UPDATE, 'organizations') + const canDeleteOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') const onValidate = (values: any) => { const errors: any = {} @@ -47,7 +50,7 @@ const DeleteOrganizationButton = observer(() => { }) setSubmitting(false) } else { - app.onOrgDeleted(ui.selectedOrganization) + invalidateOrganizationsQuery(queryClient) setSubmitting(false) router.push('/') ui.setNotification({ @@ -119,6 +122,6 @@ const DeleteOrganizationButton = observer(() => { ) -}) +} export default DeleteOrganizationButton diff --git a/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx b/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx index 6849fe6a8b8..435adc38493 100644 --- a/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx +++ b/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx @@ -1,16 +1,12 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' import clsx from 'clsx' +import { observer } from 'mobx-react-lite' import Link from 'next/link' import { useEffect, useState } from 'react' -import { observer } from 'mobx-react-lite' -import { Collapsible, Form, IconChevronRight, IconHelpCircle, Input, Toggle } from 'ui' -import { PermissionAction } from '@supabase/shared-types/out/constants' +import { Collapsible, Form, IconChevronRight, Input, Toggle } from 'ui' -import { useStore, checkPermissions, useFlag } from 'hooks' import { useParams } from 'common/hooks' -import { API_URL } from 'lib/constants' -import { patch } from 'lib/common/fetch' - -import OrganizationDeletePanel from './OrganizationDeletePanel' import { FormActions, FormPanel, @@ -18,12 +14,20 @@ import { FormSectionContent, FormSectionLabel, } from 'components/ui/Forms' +import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query' +import { useCheckPermissions, useFlag, useSelectedOrganization, useStore } from 'hooks' +import { patch } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import OrganizationDeletePanel from './OrganizationDeletePanel' +import { ScaffoldContainerLegacy } from 'components/layouts/Scaffold' const GeneralSettings = () => { - const { app, ui } = useStore() + const queryClient = useQueryClient() + const { ui } = useStore() const { slug } = useParams() const [open, setOpen] = useState(false) - const { name, opt_in_tags } = ui.selectedOrganization ?? {} + const selectedOrganization = useSelectedOrganization() + const { name, opt_in_tags } = selectedOrganization ?? {} const formId = 'org-general-settings' const isOptedIntoAi = opt_in_tags?.includes('AI_SQL_GENERATOR_OPT_IN') @@ -32,9 +36,8 @@ const GeneralSettings = () => { const showCMDK = useFlag('dashboardCmdk') const allowCMDKDataOptIn = useFlag('dashboardCmdkDataOptIn') - const canUpdateOrganization = checkPermissions(PermissionAction.UPDATE, 'organizations') - const canDeleteOrganization = checkPermissions(PermissionAction.UPDATE, 'organizations') - + const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') + const canDeleteOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') const onUpdateOrganization = async (values: any, { setSubmitting, resetForm }: any) => { if (!canUpdateOrganization) { return ui.setNotification({ @@ -49,7 +52,7 @@ const GeneralSettings = () => { const optInTags = values.isOptedIntoAi ? ['AI_SQL_GENERATOR_OPT_IN'] : [] const response = await patch(`${API_URL}/organizations/${slug}`, { name: values.name, - billing_email: ui.selectedOrganization?.billing_email ?? '', + billing_email: selectedOrganization?.billing_email ?? '', ...(allowCMDKDataOptIn && { opt_in_tags: optInTags }), }) @@ -60,14 +63,14 @@ const GeneralSettings = () => { }) } else { resetForm({ values, initialValues: values }) - app.onOrgUpdated(response) + invalidateOrganizationsQuery(queryClient) ui.setNotification({ category: 'success', message: 'Successfully saved settings' }) } setSubmitting(false) } return ( -
    +
    {({ isSubmitting, handleReset, values, initialValues, resetForm }: any) => { const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues) @@ -78,7 +81,7 @@ const GeneralSettings = () => { useEffect(() => { const values = { name: name ?? '', isOptedIntoAi } resetForm({ values, initialValues: values }) - }, [ui.selectedOrganization?.slug]) + }, [selectedOrganization?.slug]) return ( { {canDeleteOrganization && } -
    + ) } diff --git a/studio/components/interfaces/Organization/GeneralSettings/MigrateOrganizationBillingButton.tsx b/studio/components/interfaces/Organization/GeneralSettings/MigrateOrganizationBillingButton.tsx new file mode 100644 index 00000000000..f54d883d63b --- /dev/null +++ b/studio/components/interfaces/Organization/GeneralSettings/MigrateOrganizationBillingButton.tsx @@ -0,0 +1,289 @@ +import { useState, useEffect, useMemo } from 'react' +import { observer } from 'mobx-react-lite' +import { Button, Form, Modal, Listbox, Loading, Alert, IconHelpCircle, Toggle } from 'ui' +import { PermissionAction } from '@supabase/shared-types/out/constants' + +import { useStore, useCheckPermissions, useSelectedOrganization } from 'hooks' +import { PRICING_TIER_LABELS_ORG } from 'lib/constants' +import { useOrganizationBillingMigrationMutation } from 'data/organizations/organization-migrate-billing-mutation' +import { useOrganizationBillingMigrationPreview } from 'data/organizations/organization-migrate-billing-preview-query' +import Link from 'next/link' +import SpendCapModal from 'components/interfaces/Billing/SpendCapModal' +import { useRouter } from 'next/router' +import InformationBox from 'components/ui/InformationBox' + +const MigrateOrganizationBillingButton = observer(() => { + const { ui } = useStore() + const router = useRouter() + + const organization = useSelectedOrganization() + + const [isOpen, setIsOpen] = useState(false) + const [tier, setTier] = useState('') + const [showSpendCapHelperModal, setShowSpendCapHelperModal] = useState(false) + const [isSpendCapEnabled, setIsSpendCapEnabled] = useState(true) + + const dbTier = useMemo(() => { + if (tier === '') return '' + if (tier === 'PRO' && !isSpendCapEnabled) { + return `tier_payg` + } else { + return `tier_` + tier.toLocaleLowerCase() + } + }, [tier, isSpendCapEnabled]) + + const { error: migrationError, mutateAsync: migrateBilling } = + useOrganizationBillingMigrationMutation() + + const { + data: migrationPreviewData, + error: migrationPreviewError, + isLoading: migrationPreviewIsLoading, + remove, + refetch: previewMigration, + } = useOrganizationBillingMigrationPreview( + { tier: dbTier, organizationSlug: organization?.slug }, + { enabled: false, refetchOnWindowFocus: false } + ) + + useEffect(() => { + if (dbTier) { + previewMigration() + } + }, [dbTier]) + + useEffect(() => { + if (isOpen) { + // reset state + setTier('') + } else { + // Invalidate cache + remove() + } + }, [isOpen]) + + const canMigrateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') + + const toggle = () => { + setIsOpen(!isOpen) + } + + const onValidate = (values: any) => { + const errors: any = {} + if (!values.tier) { + errors.tier = 'Please select a plan.' + } + return errors + } + + const onConfirmMigrate = async (values: any, { setSubmitting }: any) => { + if (!canMigrateOrganization) { + return ui.setNotification({ + category: 'error', + message: 'You do not have the required permissions to migrate this organization', + }) + } + + setSubmitting(true) + + try { + await migrateBilling({ organizationSlug: organization?.slug, tier: dbTier }) + ui.setNotification({ + message: 'Successfully migrated to organization-level billing', + category: 'success', + duration: 5000, + }) + router.push('/projects') + setIsOpen(false) + } catch { + } finally { + setSubmitting(false) + } + } + + return ( + <> +
    + +
    + +
    Migrate organization
    + + } + > +
    + {({ isSubmitting }: { isSubmitting: boolean }) => ( +
    + +
    + +
    +

    + Migrating to new organization-level billing combines subscriptions for all + projects in the organization into a single subscription. This cannot be + reversed.{' '} +

    + +

    + For a detailed breakdown of changes, see{' '} + + + Billing Migration Docs + + + . To transfer projects to a different organization, visit{' '} + + + General settings + + + . +

    +
    +
    +
    +
    + + + + + Select Plan + + {Object.entries(PRICING_TIER_LABELS_ORG).map(([k, v]) => { + return ( + + {v} + + ) + })} + + +

    + The pricing plan, along with included usage limits will apply to your entire + organization. See{' '} + + Pricing + {' '} + for more details. Please contact support if you are an Enterprise customer. +

    + + {tier !== '' && tier !== 'FREE' && ( +
    +

    + Paid plans come with one compute instance included. Additional projects will + at least cost the compute instance hours used (min $7/month). See{' '} + + + Compute Instance Usage Billing + + {' '} + for more details. +

    +
    + )} + + {tier === 'PRO' && ( +
    +
    +

    + Spend Cap{' '} + setShowSpendCapHelperModal(true)} + /> +

    +

    + When enabled, usage is limited to the plan's quota, with restrictions when + limits are exceeded.{' '} +

    +

    + To scale beyond Pro limits without restrictions, disable the spend cap and + pay for over-usage beyond the quota. +

    +
    + +
    + + Enable Spend Cap +
    + } + checked={isSpendCapEnabled} + onChange={() => setIsSpendCapEnabled(!isSpendCapEnabled)} + /> +
    + + setShowSpendCapHelperModal(false)} + /> +
    + )} + + + + {migrationPreviewError && ( + + {migrationPreviewError.message} + + )} + + + {migrationError && ( + + {migrationError.message} + + )} + + + + + + )} + +
    + + ) +}) + +export default MigrateOrganizationBillingButton diff --git a/studio/components/interfaces/Organization/GeneralSettings/OrganizationBillingMigrationPanel.tsx b/studio/components/interfaces/Organization/GeneralSettings/OrganizationBillingMigrationPanel.tsx new file mode 100644 index 00000000000..48013e406a3 --- /dev/null +++ b/studio/components/interfaces/Organization/GeneralSettings/OrganizationBillingMigrationPanel.tsx @@ -0,0 +1,49 @@ +import { observer } from 'mobx-react-lite' +import Link from 'next/link' +import { Badge, Button, IconExternalLink, IconTruck } from 'ui' + +import Panel from 'components/ui/Panel' +import MigrateOrganizationBillingButton from './MigrateOrganizationBillingButton' + +const OrganizationBillingMigrationPanel = observer(() => { + return ( +
    +
    +

    Organization-level billing

    + + + New + + +
    +

    Migrate this project to organization-level billing

    + + +
    +
    +

    + You will have a single subscription for all projects inside the organization, rather + than individual subscriptions per project. +

    +

    + Migrating your organization billing is not reversible. +

    +
    + +
    +
    +
    +
    + ) +}) + +export default OrganizationBillingMigrationPanel diff --git a/studio/components/interfaces/Organization/IntegrationSettings/Integration.tsx b/studio/components/interfaces/Organization/IntegrationSettings/Integration.tsx new file mode 100644 index 00000000000..c0a351aff61 --- /dev/null +++ b/studio/components/interfaces/Organization/IntegrationSettings/Integration.tsx @@ -0,0 +1,160 @@ +import { + EmptyIntegrationConnection, + IntegrationConnection, + IntegrationConnectionHeader, + IntegrationInstallation, +} from 'components/interfaces/Integrations/IntegrationPanels' +import { Markdown } from 'components/interfaces/Markdown' +import { + ScaffoldContainer, + ScaffoldSection, + ScaffoldSectionContent, + ScaffoldSectionDetail, +} from 'components/layouts/Scaffold' +import { useIntegrationsVercelInstalledConnectionDeleteMutation } from 'data/integrations/integrations-vercel-installed-connection-delete-mutation' +import { + IntegrationProjectConnection, + Integration as TIntegration, +} from 'data/integrations/integrations.types' +import { useSelectedOrganization } from 'hooks' +import { BASE_PATH } from 'lib/constants' +import { pluralize } from 'lib/helpers' +import { EMPTY_ARR } from 'lib/void' +import { useGithubConnectionConfigPanelSnapshot } from 'state/github-connection-config-panel' +import { Button, Dropdown, IconChevronDown, IconTrash } from 'ui' + +export interface IntegrationProps { + title: string + orgName?: string + description?: string + note?: string + detail?: string + integrations?: TIntegration[] +} + +const Integration = ({ + title, + orgName, + description, + note, + detail, + integrations = EMPTY_ARR, +}: IntegrationProps) => { + const snapshot = useGithubConnectionConfigPanelSnapshot() + + const selectedOrganization = useSelectedOrganization() + + const { mutate: deleteMutate, isLoading: isDeleteLoading } = + useIntegrationsVercelInstalledConnectionDeleteMutation() + + const ConnectionHeading = ({ integration }: { integration: TIntegration }) => { + return ( + + ) + } + + // Call the mutation when needed + const handleDelete = (organizationIntegrationId: string, projectIntegrationId: string) => { + const variables = { + organization_integration_id: organizationIntegrationId, + id: projectIntegrationId, + orgSlug: selectedOrganization?.slug, + } + + deleteMutate(variables) + } + + return ( + <> + + + + {detail && } + cover + + + +
    + {integrations.length > 0 && + integrations.map((integration, i) => { + return ( +
    + + {integration.connections.length > 0 ? ( + <> + + +
      + {integration.connections.map( + (connection: IntegrationProjectConnection, i) => ( + + } + onSelect={() => + handleDelete(integration.id, connection.id) + } + > + Delete + + + } + > + + + } + /> + ) + )} +
    + + ) : ( + + )} + { + snapshot.setOrganizationIntegrationId(integration.id) + snapshot.setOpen(true) + }} + > + Add new project connection + +
    + ) + })} +
    + {note !== undefined && } +
    +
    +
    + + ) +} + +export default Integration diff --git a/studio/components/interfaces/Organization/IntegrationSettings/IntegrationSettings.tsx b/studio/components/interfaces/Organization/IntegrationSettings/IntegrationSettings.tsx new file mode 100644 index 00000000000..b80cd93b40a --- /dev/null +++ b/studio/components/interfaces/Organization/IntegrationSettings/IntegrationSettings.tsx @@ -0,0 +1,96 @@ +import { ScaffoldDivider } from 'components/layouts/Scaffold' +import { useOrgIntegrationsQuery } from 'data/integrations/integrations-query-org-only' +import { useVercelProjectsQuery } from 'data/integrations/integrations-vercel-projects-query' +import { useSelectedOrganization } from 'hooks' +import { getVercelConfigurationUrl } from 'lib/integration-utils' +import Integration from './Integration' +import SidePanelVercelProjectLinker from './SidePanelVercelProjectLinker' + +const IntegrationSettings = () => { + const org = useSelectedOrganization() + const { data } = useOrgIntegrationsQuery({ orgSlug: org?.slug }) + + const vercelIntegrations = data + ?.filter((integration) => integration.integration.name === 'Vercel') + .map((integration) => { + if (integration.metadata) { + const avatarSrc = + !integration.metadata.account.avatar && integration.metadata.account.type === 'Team' + ? `https://vercel.com/api/www/avatar?teamId=${integration.metadata.account.team_id}&s=48` + : `https://vercel.com/api/www/avatar/${integration.metadata.account.avatar}?s=48` + + integration['metadata']['account']['avatar'] = avatarSrc + } + + return integration + }) + const githubIntegrations = data?.filter( + (integration) => integration.integration.name === 'GitHub' + ) + + // We're only supporting one Vercel integration per org for now + // this will need to be updated when we support multiple integrations + const vercelIntegration = vercelIntegrations?.[0] + const { data: vercelProjectsData } = useVercelProjectsQuery( + { + organization_integration_id: vercelIntegration?.id, + }, + { enabled: vercelIntegration?.id !== undefined } + ) + const vercelProjectCount = vercelProjectsData?.length ?? 0 + + return ( + <> + 0 && vercelIntegration !== undefined + ? ` +Your Vercel connection has access to ${vercelProjectCount} Vercel Projects. +You can change the scope of the access for Supabase by configuring [here](${getVercelConfigurationUrl( + vercelIntegration + )}). +` + : undefined + } + integrations={vercelIntegrations} + /> + + + + + ) +} + +export default IntegrationSettings diff --git a/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoSelection.tsx b/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoSelection.tsx new file mode 100644 index 00000000000..4b978dd0ae2 --- /dev/null +++ b/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoSelection.tsx @@ -0,0 +1,58 @@ +import { useParams } from 'common' +import { Markdown } from 'components/interfaces/Markdown' +// import { useIntegrationsQuery } from 'data/integrations/integrations-query-org-only' +import { useStore } from 'hooks' +import { observer } from 'mobx-react-lite' +import { useGithubConnectionConfigPanelSnapshot } from 'state/github-connection-config-panel' +import { IconGitHub, IconSearch, Input, Select, SidePanel } from 'ui' + +// TO DO + +const GitHubRepoSelection = () => { + const { ui } = useStore() + const { slug } = useParams() + // const { data } = useIntegrationsQuery({ orgSlug: slug }) + + const githubConnectionConfigPanelSnapshot = useGithubConnectionConfigPanelSnapshot() + + return ( + githubConnectionConfigPanelSnapshot.setVisible(false)} + > +
    + + + + + +
    + + } className="w-full" placeholder="Search..." /> +
    +
    + +
      +
    • + {/* */} +
    • +
    +
    +
    +
    + ) +} + +export default observer(GitHubRepoSelection) diff --git a/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx b/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx new file mode 100644 index 00000000000..6beed7de861 --- /dev/null +++ b/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx @@ -0,0 +1,103 @@ +import { useMemo } from 'react' + +import ProjectLinker from 'components/interfaces/Integrations/ProjectLinker' +import { Markdown } from 'components/interfaces/Markdown' +import { useOrgIntegrationsQuery } from 'data/integrations/integrations-query-org-only' +import { useVercelProjectsQuery } from 'data/integrations/integrations-vercel-projects-query' +import { useProjectsQuery } from 'data/projects/projects-query' +import { useSelectedOrganization } from 'hooks' +import { EMPTY_ARR } from 'lib/void' +import { useGithubConnectionConfigPanelSnapshot } from 'state/github-connection-config-panel' +import { SidePanel } from 'ui' + +const SidePanelVercelProjectLinker = () => { + const selectedOrganization = useSelectedOrganization() + + const { data: integrationData } = useOrgIntegrationsQuery({ + orgSlug: selectedOrganization?.slug, + }) + const vercelIntegrations = integrationData?.filter( + (integration) => integration.integration.name === 'Vercel' + ) // vercel + + const snapshot = useGithubConnectionConfigPanelSnapshot() + + /** + * Find the right integration + * + * we use the snapshot.organizationIntegrationId which should be set whenever this sidepanel is opened + */ + const selectedIntegration = vercelIntegrations?.find( + (x) => x.id === snapshot.organizationIntegrationId + ) + + const organizationIntegrationId = snapshot.organizationIntegrationId + const open = snapshot.open + + /** + * Supabase projects available + */ + const { data: supabaseProjectsData } = useProjectsQuery({ + enabled: organizationIntegrationId !== undefined, + }) + + const supabaseProjects = useMemo( + () => + supabaseProjectsData + ?.filter((project) => project.organization_id === selectedOrganization?.id) + .map((project) => ({ id: project.id.toString(), name: project.name, ref: project.ref })) ?? + EMPTY_ARR, + [selectedOrganization?.id, supabaseProjectsData] + ) + + const { data: vercelProjectsData } = useVercelProjectsQuery( + { + organization_integration_id: organizationIntegrationId, + }, + { enabled: organizationIntegrationId !== undefined } + ) + + const vercelProjects = useMemo(() => vercelProjectsData ?? EMPTY_ARR, [vercelProjectsData]) + + return ( + snapshot.setOpen(false)} + > +
    + + + + + { + snapshot.setOpen(false) + }} + installedConnections={selectedIntegration?.connections} + /> + + +
      +
    • + {/* */} +
    • +
    +
    +
    +
    + ) +} + +export default SidePanelVercelProjectLinker diff --git a/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx b/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx index 1fa2fed1edd..904d335208b 100644 --- a/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx +++ b/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx @@ -1,15 +1,16 @@ -import { useState, useEffect } from 'react' -import { Button, Loading, IconFileText, IconDownload, IconChevronLeft, IconChevronRight } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' import Link from 'next/link' +import { useEffect, useState } from 'react' -import { checkPermissions, useStore } from 'hooks' -import { API_URL } from 'lib/constants' -import { get, head } from 'lib/common/fetch' -import Table from 'components/to-be-cleaned/Table' -import NoPermission from 'components/ui/NoPermission' import InvoiceStatusBadge from 'components/interfaces/Billing/InvoiceStatusBadge' import { Invoice, InvoiceStatus } from 'components/interfaces/Billing/Invoices.types' +import Table from 'components/to-be-cleaned/Table' +import NoPermission from 'components/ui/NoPermission' +import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' +import { get, head } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { Button, IconChevronLeft, IconChevronRight, IconDownload, IconFileText, Loading } from 'ui' +import { ScaffoldContainerLegacy } from 'components/layouts/Scaffold' const PAGE_LIMIT = 10 @@ -23,10 +24,11 @@ const InvoicesSettings = () => { const [count, setCount] = useState(0) const [invoices, setInvoices] = useState([]) - const { stripe_customer_id, slug } = ui.selectedOrganization ?? {} + const selectedOrganization = useSelectedOrganization() + const { stripe_customer_id, slug } = selectedOrganization ?? {} const offset = (page - 1) * PAGE_LIMIT - const canReadInvoices = checkPermissions(PermissionAction.READ, 'invoices') + const canReadInvoices = useCheckPermissions(PermissionAction.READ, 'invoices') useEffect(() => { if (!canReadInvoices || !stripe_customer_id || !slug) return @@ -92,7 +94,7 @@ const InvoicesSettings = () => { } return ( -
    +
    { } /> - + ) } diff --git a/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx index 12bcbc13e2a..184eb20c7d2 100644 --- a/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx +++ b/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx @@ -1,15 +1,17 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useRouter } from 'next/router' import { useState } from 'react' import { Button, IconEdit2, IconInfo, Input, Listbox } from 'ui' -import { useRouter } from 'next/router' -import { API_URL, BASE_PATH, PRICING_TIER_LABELS_ORG } from 'lib/constants' +import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js' +import type { PaymentMethod } from '@stripe/stripe-js' +import InformationBox from 'components/ui/InformationBox' +import Panel from 'components/ui/Panel' +import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query' import { useStore } from 'hooks' import { post } from 'lib/common/fetch' -import Panel from 'components/ui/Panel' -import { useElements, useStripe, PaymentElement } from '@stripe/react-stripe-js' -import type { PaymentMethod } from '@stripe/stripe-js' +import { API_URL, BASE_PATH, PRICING_TIER_LABELS_ORG } from 'lib/constants' import { getURL } from 'lib/helpers' -import InformationBox from 'components/ui/InformationBox' const ORG_KIND_TYPES = { PERSONAL: 'Personal', @@ -38,7 +40,8 @@ interface NewOrgFormProps { * No org selected yet, create a new one */ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => { - const { ui, app } = useStore() + const queryClient = useQueryClient() + const { ui } = useStore() const router = useRouter() const stripe = useStripe() const elements = useElements() @@ -49,7 +52,7 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => { const [newOrgLoading, setNewOrgLoading] = useState(false) const [paymentMethod, setPaymentMethod] = useState() - const [dbPricingTierKey, setDbPricingTierKey] = useState('PRO') + const [dbPricingTierKey, setDbPricingTierKey] = useState('FREE') function validateOrgName(name: any) { const value = name ? name.trim() : '' @@ -72,7 +75,7 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => { setDbPricingTierKey(value) } - async function createOrg(paymentMethodId: string) { + async function createOrg(paymentMethodId?: string) { const response = await post( `${API_URL}/organizations`, { @@ -97,7 +100,7 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => { }) } else { const org = response - app.onOrgAdded(org) + await invalidateOrganizationsQuery(queryClient) router.push(`/new/${org.slug}`) } setNewOrgLoading(false) @@ -118,7 +121,9 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => { } setNewOrgLoading(true) - if (!paymentMethod) { + if (dbPricingTierKey === 'FREE') { + await createOrg() + } else if (!paymentMethod) { const { error, setupIntent } = await stripe.confirmSetup({ elements, redirect: 'if_required', @@ -169,7 +174,6 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {

    You can rename your organization later

    -
    -
    - - ) : ( - - )} - + ) : ( + + )} + + )} diff --git a/studio/components/interfaces/Organization/NewProject/EmptyPaymentMethodWarning.tsx b/studio/components/interfaces/Organization/NewProject/EmptyPaymentMethodWarning.tsx index 7a94402fe18..74ae3d7735f 100644 --- a/studio/components/interfaces/Organization/NewProject/EmptyPaymentMethodWarning.tsx +++ b/studio/components/interfaces/Organization/NewProject/EmptyPaymentMethodWarning.tsx @@ -1,53 +1,53 @@ import { useState } from 'react' -import { observer } from 'mobx-react-lite' + +import { AddNewPaymentMethodModal } from 'components/interfaces/Billing' +import InformationBox from 'components/ui/InformationBox' +import { useSelectedOrganization } from 'hooks' +import { getURL } from 'lib/helpers' import { Button, IconAlertCircle } from 'ui' -import { useStore } from 'hooks' -import { getURL } from 'lib/helpers' -import InformationBox from 'components/ui/InformationBox' -import { AddNewPaymentMethodModal } from 'components/interfaces/Billing' +const EmptyPaymentMethodWarning = ({ + onPaymentMethodAdded, +}: { + onPaymentMethodAdded: () => void +}) => { + const selectedOrganization = useSelectedOrganization() + const slug = selectedOrganization?.slug -const EmptyPaymentMethodWarning = observer( - ({ onPaymentMethodAdded }: { onPaymentMethodAdded: () => void }) => { - const { ui } = useStore() - const slug = ui.selectedOrganization?.slug + const [showAddPaymentMethodModal, setShowAddPaymentMethodModal] = useState(false) - const [showAddPaymentMethodModal, setShowAddPaymentMethodModal] = useState(false) + const onLocalPaymentMethodAdded = () => { + setShowAddPaymentMethodModal(false) - const onLocalPaymentMethodAdded = () => { - setShowAddPaymentMethodModal(false) - - return onPaymentMethodAdded() - } - - return ( -
    - } - defaultVisibility={true} - hideCollapse - title="Your organization has no payment methods" - description={ -
    -

    - You need to add a payment method for your organization before creating a paid - project. -

    - -
    - } - /> - setShowAddPaymentMethodModal(false)} - onConfirm={() => onLocalPaymentMethodAdded()} - /> -
    - ) + return onPaymentMethodAdded() } -) + + return ( +
    + } + defaultVisibility={true} + hideCollapse + title="Your organization has no payment methods" + description={ +
    +

    + You need to add a payment method for your organization before creating a paid project. +

    + +
    + } + /> + setShowAddPaymentMethodModal(false)} + onConfirm={() => onLocalPaymentMethodAdded()} + /> +
    + ) +} export default EmptyPaymentMethodWarning diff --git a/studio/components/interfaces/Organization/NewProject/NotOrganizationOwnerWarning.tsx b/studio/components/interfaces/Organization/NewProject/NotOrganizationOwnerWarning.tsx index a80724fb25c..61bc8a39cff 100644 --- a/studio/components/interfaces/Organization/NewProject/NotOrganizationOwnerWarning.tsx +++ b/studio/components/interfaces/Organization/NewProject/NotOrganizationOwnerWarning.tsx @@ -13,7 +13,7 @@ const NotOrganizationOwnerWarning = () => { description={

    - Contact your organization owner or adminstrator to create a new project. + Contact your organization owner or administrator to create a new project.

    } diff --git a/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx b/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx new file mode 100644 index 00000000000..16d4edac3f7 --- /dev/null +++ b/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx @@ -0,0 +1,33 @@ +export interface AuthorizeRequesterDetailsProps { + icon: string | null + name: string + domain: string +} + +const AuthorizeRequesterDetails = ({ icon, name, domain }: AuthorizeRequesterDetailsProps) => { + return ( +
    +
    +
    +
    + {icon === null &&

    {name[0]}

    } +
    +
    +
    +

    + {name} ({domain}) is requesting API access to an organization. The application will be able + to{' '} + + read and write the organization's settings and all of its projects. + +

    +
    + ) +} + +export default AuthorizeRequesterDetails diff --git a/studio/components/interfaces/Organization/OAuthApps/AuthorizedAppRow.tsx b/studio/components/interfaces/Organization/OAuthApps/AuthorizedAppRow.tsx new file mode 100644 index 00000000000..d8b4360ef66 --- /dev/null +++ b/studio/components/interfaces/Organization/OAuthApps/AuthorizedAppRow.tsx @@ -0,0 +1,57 @@ +import Table from 'components/to-be-cleaned/Table' +import { AuthorizedApp } from 'data/oauth/authorized-apps-query' +import dayjs from 'dayjs' +import { copyToClipboard } from 'lib/helpers' +import { useState } from 'react' +import { Button, IconCheck, IconClipboard, IconTrash } from 'ui' + +export interface AuthorizedAppRowProps { + app: AuthorizedApp + onSelectRevoke: () => void +} + +const AuthorizedAppRow = ({ app, onSelectRevoke }: AuthorizedAppRowProps) => { + const [isCopied, setIsCopied] = useState(false) + + return ( + + +
    + {!!app.icon ? '' : `${app.name[0]}`} +
    +
    + {app.name} + + {app.id} + + + + {isLoadingPublishedApps && ( +
    + + + +
    + )} + + {isErrorPublishedApps && ( + + )} + + {createdApp !== undefined && ( + +
    +
    +
    +

    + Ensure that you store the client secret securely - you will not be able to see it + again. +

    +
    + {}} + /> + {}} + /> +
    +
    +
    + )} + + {isSuccessPublishedApps && ( + <> + {(publishedApps?.length ?? 0) === 0 ? ( +
    +

    You do not have any published applications yet

    +
    + ) : ( +
    , + Name, + Client ID, + Client Secret, + Created at, + , + ]} + body={ + sortedPublishedApps?.map((app) => ( + { + setShowPublishModal(true) + setSelectedAppToUpdate(app) + }} + onSelectDelete={() => setSelectedAppToDelete(app)} + /> + )) ?? [] + } + /> + )} + + )} + + +
    +

    Authorized Apps

    +

    + Applications that have access to your organization's settings and projects +

    + +
    + {isLoadingAuthorizedApps && ( +
    + + + +
    + )} + + {isErrorAuthorizedApps && } + + {isSuccessAuthorizedApps && ( + <> + {(authorizedApps.length ?? 0) === 0 ? ( +
    +

    You do not have any authorized applications yet

    +
    + ) : ( +
    , + Name, + ID, + Authorized at, + , + ]} + body={ + sortedAuthorizedApps?.map((app) => ( + setSelectedAppToRevoke(app)} + /> + )) ?? [] + } + /> + )} + + )} + + + + + { + setSelectedAppToUpdate(undefined) + setShowPublishModal(false) + }} + onCreateSuccess={setCreatedApp} + /> + setSelectedAppToDelete(undefined)} + /> + setSelectedAppToRevoke(undefined)} + /> + + ) +} + +export default OAuthApps diff --git a/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel.tsx b/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel.tsx new file mode 100644 index 00000000000..43eaa6e9044 --- /dev/null +++ b/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel.tsx @@ -0,0 +1,397 @@ +import clsx from 'clsx' +import { ChangeEvent, useEffect, useRef, useState } from 'react' + +import { useParams } from 'common' +import { + OAuthAppCreateResponse, + useOAuthAppCreateMutation, +} from 'data/oauth/oauth-app-create-mutation' +import { useOAuthAppUpdateMutation } from 'data/oauth/oauth-app-update-mutation' +import { OAuthApp } from 'data/oauth/oauth-apps-query' +import { useStore } from 'hooks' +import { isValidHttpUrl, uuidv4 } from 'lib/helpers' +import { Badge, Button, Dropdown, Form, IconEdit, IconUpload, Input, Modal, SidePanel } from 'ui' +import { uploadAttachment } from 'lib/upload' +import AuthorizeRequesterDetails from './AuthorizeRequesterDetails' + +export interface PublishAppModalProps { + visible: boolean + selectedApp?: OAuthApp + onClose: () => void + onCreateSuccess: (app: OAuthAppCreateResponse) => void +} + +const PublishAppModal = ({ + visible, + selectedApp, + onClose, + onCreateSuccess, +}: PublishAppModalProps) => { + const { ui } = useStore() + const { slug } = useParams() + const uploadButtonRef = useRef() + const { mutateAsync: createOAuthApp } = useOAuthAppCreateMutation() + const { mutateAsync: updateOAuthApp } = useOAuthAppUpdateMutation() + + const [showPreview, setShowPreview] = useState(false) + const [iconFile, setIconFile] = useState() + const [iconUrl, setIconUrl] = useState() + const [errors, setErrors] = useState<{ [key: string]: string }>({}) + const [urls, setUrls] = useState<{ id: string; value: string }[]>([{ id: uuidv4(), value: '' }]) + + useEffect(() => { + if (visible) { + setErrors({}) + setIconFile(undefined) + + if (selectedApp !== undefined) { + setUrls( + selectedApp.redirect_uris.map((url) => { + return { id: uuidv4(), value: url } + }) + ) + setIconUrl(selectedApp.icon === null ? undefined : selectedApp.icon) + } else { + setUrls([{ id: uuidv4(), value: '' }]) + setIconUrl(undefined) + } + } + }, [visible, selectedApp]) + + const onUpdateUrl = (id: string, value: string) => { + const updatedUrls = urls.map((url) => { + if (url.id === id) return { id, value } + else return url + }) + setUrls(updatedUrls) + setErrors({}) + } + + const removeUrl = (id: string) => { + const updatedUrls = urls.filter((url) => url.id !== id) + setUrls(updatedUrls) + } + + const onFileUpload = async (event: ChangeEvent) => { + event.persist() + const [file] = event.target.files || (event as any).dataTransfer.items + setIconFile(file) + setIconUrl(URL.createObjectURL(file)) + event.target.value = '' + } + + const validate = (values: any) => { + const errors: any = {} + if (!values.name) errors.name = 'Please provide a name for your application' + if (!values.website) errors.website = 'Please provide a URL for your site' + if (!isValidHttpUrl(values.website)) errors.website = 'Please provide a valid URL for your site' + return errors + } + + const onSubmit = async (values: any, { setSubmitting }: any) => { + if (!slug) return console.error('Slug is required') + + const redirect_uris = urls.filter((url) => url.value.length > 0).map((url) => url.value) + if (redirect_uris.length === 0) { + setSubmitting(false) + setErrors({ urls: 'Please provide at least one URL' }) + return + } else { + setErrors({}) + } + + const { name, website } = values + const uploadedIconUrl = + iconFile !== undefined + ? await uploadAttachment('oauth-app-icons', `${slug}/${uuidv4()}.png`, iconFile) + : iconUrl + + if (selectedApp === undefined) { + // Create application + try { + const res = await createOAuthApp({ + slug, + name, + website, + redirect_uris, + icon: uploadedIconUrl, + }) + ui.setNotification({ + category: 'success', + message: `Successfully created OAuth app "${name}"!`, + }) + onClose() + onCreateSuccess(res) + } catch (error: any) { + setSubmitting(false) + ui.setNotification({ + category: 'error', + message: `Failed to create OAuth app: ${error.message}`, + }) + } + } else { + // Update application + try { + await updateOAuthApp({ + id: selectedApp.id, + slug, + name, + website, + redirect_uris, + icon: uploadedIconUrl === undefined ? null : uploadedIconUrl, + }) + ui.setNotification({ + category: 'success', + message: `Successfully updated OAuth app "${name}"!`, + }) + onClose() + } catch (error: any) { + setSubmitting(false) + ui.setNotification({ + category: 'error', + message: `Failed to update OAuth app: ${error.message}`, + }) + } + } + } + + return ( + <> + onClose()} + > +
    + {({ + isSubmitting, + resetForm, + values, + }: { + isSubmitting: boolean + resetForm: any + values: any + }) => { + // [Joshen] although this "technically" is breaking the rules of React hooks + // it won't error because the hooks are always rendered in the same order + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + if (visible && selectedApp !== undefined) { + const values = { name: selectedApp.name, website: selectedApp.website } + resetForm({ values, initialValues: values }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible, selectedApp]) + + return ( + <> +
    +
    + +
    +
    + + +
    +
    + {iconUrl !== undefined ? ( +
    +
    + { + if (uploadButtonRef.current) + (uploadButtonRef.current as any).click() + }} + > + Upload image + , + { + setIconFile(undefined) + setIconUrl(undefined) + }} + > + Remove image + , + ]} + > +
    +
    + ) : ( +
    { + if (uploadButtonRef.current) + (uploadButtonRef.current as any).click() + }} + > + +

    Upload logo

    +
    + )} + +
    +
    +
    + + + + +
    +
    +

    Authorization callback URLs

    +

    + All URLs must use HTTPS, except for localhost +

    +
    + +
    +
    + {urls.map((url) => ( + onUpdateUrl(url.id, event.target.value)} + placeholder="e.g https://my-website.com" + actions={[ + urls.length > 1 ? ( + + ) : null, + ]} + /> + ))} + {errors.urls &&

    {errors.urls}

    } +
    +
    + +
    + + + + +
    + +
    + + +
    +
    +
    +
    + + setShowPreview(false)} + > + +
    +

    Authorize API access for {values.name}

    + Preview +
    +
    + + +
    + +
    +

    + Select an organization to grant API access to +

    +
    + Organizations that you have access to will be listed here +
    +
    +
    +
    + + +
    +

    + This is what your users will see when authorizing with your app +

    + +
    +
    +
    + + ) + }} + +
    + + ) +} + +export default PublishAppModal diff --git a/studio/components/interfaces/Organization/OAuthApps/RevokeAppModal.tsx b/studio/components/interfaces/Organization/OAuthApps/RevokeAppModal.tsx new file mode 100644 index 00000000000..7d49e8d54c0 --- /dev/null +++ b/studio/components/interfaces/Organization/OAuthApps/RevokeAppModal.tsx @@ -0,0 +1,73 @@ +import { useParams } from 'common' +import { useAuthorizedAppRevokeMutation } from 'data/oauth/authorized-app-revoke-mutation' +import { AuthorizedApp } from 'data/oauth/authorized-apps-query' +import { useStore } from 'hooks' +import { Alert, IconAlertOctagon, IconLock, Modal } from 'ui' + +export interface RevokeAppModalProps { + selectedApp?: AuthorizedApp + onClose: () => void +} + +const RevokeAppModal = ({ selectedApp, onClose }: RevokeAppModalProps) => { + const { ui } = useStore() + const { slug } = useParams() + const { mutateAsync: revokeAuthorizedApp, isLoading: isDeleting } = + useAuthorizedAppRevokeMutation() + + const onConfirmDelete = async () => { + if (!slug) return console.error('Slug is required') + if (!selectedApp?.id) return console.error('App ID is required') + + try { + await revokeAuthorizedApp({ slug, id: selectedApp?.id }) + ui.setNotification({ + category: 'success', + message: `Successfully revoked the app "${selectedApp?.name}"`, + }) + onClose() + } catch (error) { + ui.setNotification({ + category: 'error', + message: `Failed to revoke app: ${(error as any).message}`, + }) + } + } + + return ( + + +
    + + {selectedApp?.name} application will no longer have access to your organization's + settings and projects. + +
      +
    • + +
      + Before you remove this app, consider: +
        +
      • + No users are currently using this application. The application will no longer + have access to your organization after being revoked. +
      • +
      +
      +
    • +
    +
    +
    +
    + ) +} + +export default RevokeAppModal diff --git a/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index 4ac9ce6acbe..137f9f6c81d 100644 --- a/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -1,16 +1,17 @@ +import * as Tooltip from '@radix-ui/react-tooltip' import { isNil } from 'lodash' import { useEffect, useState } from 'react' import { object, string } from 'yup' -import * as Tooltip from '@radix-ui/react-tooltip' + +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useParams } from 'common/hooks' +import { useOrganizationMemberInviteCreateMutation } from 'data/organizations/organization-member-invite-create-mutation' +import { doPermissionsCheck, useGetPermissions, useStore } from 'hooks' +import { Member, Role } from 'types' import { Button, Form, IconMail, Input, Listbox, Modal } from 'ui' -import { Member, Role } from 'types' -import { checkPermissions, useStore } from 'hooks' -import { useParams } from 'common/hooks' -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useOrganizationMemberInviteCreateMutation } from 'data/organizations/organization-member-invite-create-mutation' - export interface InviteMemberButtonProps { + orgId: number userId: number members: Member[] roles: Role[] @@ -18,6 +19,7 @@ export interface InviteMemberButtonProps { } const InviteMemberButton = ({ + orgId, userId, members = [], roles = [], @@ -28,8 +30,18 @@ const InviteMemberButton = ({ const [isOpen, setIsOpen] = useState(false) + const { permissions: allPermissions } = useGetPermissions() + const canInviteMembers = roles.some(({ id: role_id }) => - checkPermissions(PermissionAction.CREATE, 'user_invites', { resource: { role_id } }) + doPermissionsCheck( + allPermissions, + PermissionAction.CREATE, + 'user_invites', + { + resource: { role_id }, + }, + orgId + ) ) const initialValues = { email: '', role: '' } diff --git a/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx b/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx index 099999fe0a6..d49a715f289 100644 --- a/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx +++ b/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx @@ -2,18 +2,18 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' import { observer } from 'mobx-react-lite' import { FC } from 'react' -import { Button, Dropdown, IconMoreHorizontal, IconTrash } from 'ui' -import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal' -import { checkPermissions, useStore } from 'hooks' import { useParams } from 'common/hooks' -import { Member, Role } from 'types' - +import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal' import { useOrganizationMemberDeleteMutation } from 'data/organizations/organization-member-delete-mutation' import { useOrganizationMemberInviteCreateMutation } from 'data/organizations/organization-member-invite-create-mutation' import { useOrganizationMemberInviteDeleteMutation } from 'data/organizations/organization-member-invite-delete-mutation' +import { usePermissionsQuery } from 'data/permissions/permissions-query' +import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' +import { Member, Role } from 'types' +import { Button, Dropdown, IconMoreHorizontal, IconTrash } from 'ui' import { isInviteExpired } from '../Organization.utils' -import { getRolesManagementPermissions } from './TeamSettings.utils' +import { useGetRolesManagementPermissions } from './TeamSettings.utils' interface Props { member: Member @@ -23,17 +23,23 @@ interface Props { const MemberActions: FC = ({ member, roles }) => { const { ui } = useStore() const { slug } = useParams() - const { rolesRemovable } = getRolesManagementPermissions(roles) + const selectedOrganization = useSelectedOrganization() + const { data: permissions } = usePermissionsQuery() + const { rolesRemovable } = useGetRolesManagementPermissions( + selectedOrganization?.id, + roles, + permissions ?? [] + ) const isExpired = isInviteExpired(member?.invited_at ?? '') const isPendingInviteAcceptance = member.invited_id const roleId = member.role_ids?.[0] ?? -1 const canRemoveMember = rolesRemovable.includes((member?.role_ids ?? [-1])[0]) - const canResendInvite = checkPermissions(PermissionAction.CREATE, 'user_invites', { + const canResendInvite = useCheckPermissions(PermissionAction.CREATE, 'user_invites', { resource: { role_id: roleId }, }) - const canRevokeInvite = checkPermissions(PermissionAction.DELETE, 'user_invites', { + const canRevokeInvite = useCheckPermissions(PermissionAction.DELETE, 'user_invites', { resource: { role_id: roleId }, }) diff --git a/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx b/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx index 1727cb59eaa..1a15465fb53 100644 --- a/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx +++ b/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx @@ -1,23 +1,23 @@ -import Image from 'next/image' import * as Tooltip from '@radix-ui/react-tooltip' -import { useState, Fragment } from 'react' import { observer } from 'mobx-react-lite' -import { Badge, Button, Loading, Listbox, IconUser, Modal, IconAlertCircle, IconLoader } from 'ui' +import Image from 'next/image' +import { Fragment, useState } from 'react' -import { Member } from 'types' -import { useStore } from 'hooks' import { useParams } from 'common/hooks' - -import ShimmeringLoader from 'components/ui/ShimmeringLoader' import Table from 'components/to-be-cleaned/Table' -import MemberActions from './MemberActions' -import RolesHelperModal from './RolesHelperModal/RolesHelperModal' -import { getRolesManagementPermissions } from './TeamSettings.utils' -import { isInviteExpired, getUserDisplayName } from '../Organization.utils' -import { useOrganizationRolesQuery } from 'data/organizations/organization-roles-query' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useOrganizationDetailQuery } from 'data/organizations/organization-detail-query' import { useOrganizationMemberUpdateMutation } from 'data/organizations/organization-member-update-mutation' -import { useProfileQuery } from 'data/profile/profile-query' +import { useOrganizationRolesQuery } from 'data/organizations/organization-roles-query' +import { usePermissionsQuery } from 'data/permissions/permissions-query' +import { useSelectedOrganization, useStore } from 'hooks' +import { useProfile } from 'lib/profile' +import { Member } from 'types' +import { Badge, Button, IconAlertCircle, IconLoader, IconUser, Listbox, Loading, Modal } from 'ui' +import { getUserDisplayName, isInviteExpired } from '../Organization.utils' +import MemberActions from './MemberActions' +import RolesHelperModal from './RolesHelperModal/RolesHelperModal' +import { useGetRolesManagementPermissions } from './TeamSettings.utils' interface SelectedMember extends Member { oldRoleId: number @@ -31,8 +31,10 @@ export interface MembersViewProps { const MembersView = ({ searchString }: MembersViewProps) => { const { ui } = useStore() const { slug } = useParams() + const selectedOrganization = useSelectedOrganization() - const { data: profile } = useProfileQuery() + const { profile } = useProfile() + const { data: permissions } = usePermissionsQuery() const { data: detailData, isLoading: isLoadingOrgDetails } = useOrganizationDetailQuery({ slug }) const { data: rolesData, isLoading: isLoadingRoles } = useOrganizationRolesQuery({ slug }) const { mutate: updateOrganizationMember, isLoading } = useOrganizationMemberUpdateMutation({ @@ -52,7 +54,11 @@ const MembersView = ({ searchString }: MembersViewProps) => { const roles = rolesData?.roles ?? [] const members = detailData?.members ?? [] - const { rolesAddable, rolesRemovable } = getRolesManagementPermissions(roles) + const { rolesAddable, rolesRemovable } = useGetRolesManagementPermissions( + selectedOrganization?.id, + roles, + permissions ?? [] + ) const [selectedMember, setSelectedMember] = useState() const [userRoleChangeModalVisible, setUserRoleChangeModalVisible] = useState(false) @@ -105,7 +111,7 @@ const MembersView = ({ searchString }: MembersViewProps) => { return ( <> -
    +
    { const { ui } = useStore() const { slug } = useParams() - const { data: profile } = useProfileQuery() - const isOwner = ui.selectedOrganization?.is_owner + const { profile } = useProfile() + const selectedOrganization = useSelectedOrganization() + const isOwner = selectedOrganization?.is_owner + const { data: permissions } = usePermissionsQuery() const { data: detailData } = useOrganizationDetailQuery({ slug }) const { data: rolesData } = useOrganizationRolesQuery({ slug }) const members = detailData?.members ?? [] const roles = rolesData?.roles ?? [] - const { rolesAddable } = getRolesManagementPermissions(roles) + const { rolesAddable } = useGetRolesManagementPermissions( + selectedOrganization?.id, + roles, + permissions ?? [] + ) const [isLeaving, setIsLeaving] = useState(false) const [searchString, setSearchString] = useState('') @@ -64,9 +76,9 @@ const TeamSettings = () => { } return ( - <> -
    -
    + + + } size="small" @@ -76,10 +88,11 @@ const TeamSettings = () => { id="email" placeholder="Filter members" /> -
    - {canAddMembers && profile !== undefined && ( + + {canAddMembers && profile !== undefined && selectedOrganization !== undefined && (
    { )}
    -
    -
    -
    -
    + + -
    - + + ) } -export default observer(TeamSettings) +export default TeamSettings diff --git a/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.ts b/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.ts index 9651dacc86c..a245ffacce2 100644 --- a/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.ts +++ b/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.ts @@ -1,23 +1,43 @@ -import { Member, Role } from 'types' -import { checkPermissions } from 'hooks' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { doPermissionsCheck, useGetPermissions } from 'hooks' +import { Member, Permission, Role } from 'types' -export const getRolesManagementPermissions = ( - roles: Role[] +export const useGetRolesManagementPermissions = ( + orgId?: number, + roles?: Role[], + permissions?: Permission[] ): { rolesAddable: Number[]; rolesRemovable: Number[] } => { + const { permissions: allPermissions, organizationId } = useGetPermissions( + permissions, + orgId, + permissions !== undefined && orgId !== undefined + ) + const rolesAddable: Number[] = [] const rolesRemovable: Number[] = [] - if (!roles) return { rolesAddable, rolesRemovable } + if (!roles || !orgId) return { rolesAddable, rolesRemovable } roles.forEach((role: Role) => { - const canAdd = checkPermissions(PermissionAction.CREATE, 'auth.subject_roles', { - resource: { role_id: role.id }, - }) + const canAdd = doPermissionsCheck( + allPermissions, + PermissionAction.CREATE, + 'auth.subject_roles', + { + resource: { role_id: role.id }, + }, + organizationId + ) if (canAdd) rolesAddable.push(role.id) - const canRemove = checkPermissions(PermissionAction.DELETE, 'auth.subject_roles', { - resource: { role_id: role.id }, - }) + const canRemove = doPermissionsCheck( + allPermissions, + PermissionAction.DELETE, + 'auth.subject_roles', + { + resource: { role_id: role.id }, + }, + organizationId + ) if (canRemove) rolesRemovable.push(role.id) }) diff --git a/studio/components/interfaces/Organization/Usage/Activity.tsx b/studio/components/interfaces/Organization/Usage/Activity.tsx new file mode 100644 index 00000000000..29fff89ff43 --- /dev/null +++ b/studio/components/interfaces/Organization/Usage/Activity.tsx @@ -0,0 +1,126 @@ +import { useDailyStatsQuery } from 'data/analytics/daily-stats-query' +import { DataPoint } from 'data/analytics/constants' +import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query' +import UsageSection from './UsageSection' + +export interface ActivityProps { + projectRef: string + startDate: string | undefined + endDate: string | undefined + subscription: ProjectSubscriptionResponse | undefined + currentBillingCycleSelected: boolean +} + +const Activity = ({ + projectRef, + subscription, + startDate, + endDate, + currentBillingCycleSelected, +}: ActivityProps) => { + const { data: mauData, isLoading: isLoadingMauData } = useDailyStatsQuery({ + projectRef, + attribute: 'total_auth_billing_period_mau', + interval: '1d', + startDate, + endDate, + }) + + const { data: mauSSOData, isLoading: isLoadingMauSSOData } = useDailyStatsQuery({ + projectRef, + attribute: 'total_auth_billing_period_sso_mau', + interval: '1d', + startDate, + endDate, + }) + + const { data: assetTransformationsData, isLoading: isLoadingAssetTransformationsData } = + useDailyStatsQuery({ + projectRef, + attribute: 'total_storage_image_render_count', + interval: '1d', + startDate, + endDate, + }) + + const { data: funcInvocationsData, isLoading: isLoadingFuncInvocationsData } = useDailyStatsQuery( + { + projectRef, + attribute: 'total_func_invocations', + interval: '1d', + startDate, + endDate, + } + ) + + const { data: realtimeMessagesData, isLoading: isLoadingRealtimeMessagesData } = + useDailyStatsQuery({ + projectRef, + attribute: 'total_realtime_message_count', + interval: '1d', + startDate, + endDate, + }) + + const { data: realtimeConnectionsData, isLoading: isLoadingRealtimeConnectionsData } = + useDailyStatsQuery({ + projectRef, + attribute: 'total_realtime_peak_connection', + interval: '1d', + startDate, + endDate, + }) + + const chartMeta: { + [key: string]: { data: DataPoint[]; margin: number; isLoading: boolean; hasNoData: boolean } + } = { + monthly_active_users: { + data: mauData?.data ?? [], + margin: 18, + isLoading: isLoadingMauData, + hasNoData: mauData?.hasNoData ?? false, + }, + monthly_active_sso_users: { + data: mauSSOData?.data ?? [], + margin: 20, + isLoading: isLoadingMauSSOData, + hasNoData: mauSSOData?.hasNoData ?? false, + }, + storage_image_render_count: { + data: assetTransformationsData?.data ?? [], + margin: 0, + isLoading: isLoadingMauSSOData, + hasNoData: assetTransformationsData?.hasNoData ?? false, + }, + func_invocations: { + data: funcInvocationsData?.data ?? [], + margin: 26, + isLoading: isLoadingFuncInvocationsData, + hasNoData: funcInvocationsData?.hasNoData ?? false, + }, + realtime_message_count: { + data: realtimeMessagesData?.data ?? [], + margin: 38, + isLoading: isLoadingRealtimeMessagesData, + hasNoData: realtimeMessagesData?.hasNoData ?? false, + }, + realtime_peak_connection: { + data: realtimeConnectionsData?.data ?? [], + margin: 0, + isLoading: isLoadingRealtimeConnectionsData, + hasNoData: realtimeConnectionsData?.hasNoData ?? false, + }, + } + + return ( + + ) +} + +export default Activity diff --git a/studio/components/interfaces/Organization/Usage/Bandwidth.tsx b/studio/components/interfaces/Organization/Usage/Bandwidth.tsx new file mode 100644 index 00000000000..36635299f93 --- /dev/null +++ b/studio/components/interfaces/Organization/Usage/Bandwidth.tsx @@ -0,0 +1,136 @@ +import { DataPoint } from 'data/analytics/constants' +import { useDailyStatsQuery } from 'data/analytics/daily-stats-query' +import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query' +import UsageSection from './UsageSection' + +const GB = 1073741824 +const MOCK_DATA = [ + { + id: '1', + loopId: '1', + period_start: '2023-06-29T07:05:12Z', + periodStartFormatted: '29 Jun 2023', + total_storage_egress: (1.53 * GB).toString(), + total_egress_modified: (1.12 * GB).toString(), + }, + { + id: '2', + loopId: '2', + period_start: '2023-06-30T07:05:12Z', + periodStartFormatted: '30 Jun 2023', + total_storage_egress: (1 * GB).toString(), + total_egress_modified: (1.1 * GB).toString(), + }, + { + id: '3', + loopId: '3', + period_start: '2023-07-01T07:05:12Z', + periodStartFormatted: '01 Jul 2023', + total_storage_egress: (1 * GB).toString(), + total_egress_modified: (1.2 * GB).toString(), + }, + { + id: '4', + loopId: '4', + period_start: '2023-07-02T07:05:12Z', + periodStartFormatted: '02 Jul 2023', + total_storage_egress: (1 * GB).toString(), + total_egress_modified: (1.5 * GB).toString(), + }, + { + id: '5', + loopId: '5', + period_start: '2023-07-03T07:05:12Z', + periodStartFormatted: '03 Jul 2023', + total_storage_egress: (1 * GB).toString(), + total_egress_modified: (2 * GB).toString(), + }, + { + id: '6', + loopId: '6', + period_start: '2023-07-04T07:05:12Z', + periodStartFormatted: '04 Jul 2023', + total_storage_egress: (1 * GB).toString(), + total_egress_modified: (3 * GB).toString(), + }, + { + id: '7', + loopId: '7', + period_start: '2023-07-05T07:05:12Z', + periodStartFormatted: '05 Jul 2023', + total_storage_egress: (1 * GB).toString(), + total_egress_modified: (4.5 * GB).toString(), + }, + { + id: '8', + loopId: '8', + period_start: '2023-07-06T07:05:12Z', + periodStartFormatted: '06 Jul 2023', + total_storage_egress: (1 * GB).toString(), + total_egress_modified: (4 * GB).toString(), + }, + { + id: '9', + loopId: '9', + period_start: '2023-07-07T07:05:12Z', + periodStartFormatted: '07 Jul 2023', + total_storage_egress: (1 * GB).toString(), + total_egress_modified: (3.8 * GB).toString(), + }, + { + id: '10', + loopId: '10', + period_start: '2023-07-08T07:05:12Z', + periodStartFormatted: '08 Jul 2023', + total_storage_egress: (1 * GB).toString(), + total_egress_modified: (3.5 * GB).toString(), + }, +] + +export interface BandwidthProps { + projectRef: string + startDate: string | undefined + endDate: string | undefined + subscription: ProjectSubscriptionResponse | undefined + currentBillingCycleSelected: boolean +} + +// [Joshen TODO] Needs to take in org slug and eventually use daily stats org query +const Bandwidth = ({ + projectRef, + subscription, + startDate, + endDate, + currentBillingCycleSelected, +}: BandwidthProps) => { + // const { data: dbEgressData, isLoading: isLoadingDbEgressData } = useDailyStatsQuery({ + // projectRef, + // attribute: 'total_egress_modified', + // interval: '1d', + // startDate, + // endDate, + // }) + + const chartMeta: { + [key: string]: { data: DataPoint[]; margin: number; isLoading: boolean; hasNoData: boolean } + } = { + db_egress: { + data: MOCK_DATA, // dbEgressData?.data ?? [], + margin: 16, + isLoading: false, // isLoadingDbEgressData, + hasNoData: false, // dbEgressData?.hasNoData ?? false, + }, + } + + return ( + + ) +} + +export default Bandwidth diff --git a/studio/components/interfaces/Organization/Usage/SectionContent.tsx b/studio/components/interfaces/Organization/Usage/SectionContent.tsx new file mode 100644 index 00000000000..a316df9e863 --- /dev/null +++ b/studio/components/interfaces/Organization/Usage/SectionContent.tsx @@ -0,0 +1,69 @@ +import { PropsWithChildren } from 'react' +import { Badge, IconExternalLink } from 'ui' +import { CategoryAttribute } from './Usage.constants' +import Link from 'next/link' +import { ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold' + +export interface SectionContent { + section: CategoryAttribute + includedInPlan?: boolean +} + +const SectionContent = ({ + section, + includedInPlan, + children, +}: PropsWithChildren) => { + const { name, description, links } = section + + return ( + <> + +
    +
    +
    +
    +
    +
    +

    {name}

    + {includedInPlan === false && Not included} +
    +
    + {description.split('\n').map((value, idx) => ( +

    + {value} +

    + ))} +
    +
    + {links && links.length && ( +
    +

    + More information +

    + {links.map((link) => ( + + ))} +
    + )} +
    +
    +
    {children}
    +
    +
    +
    + + + ) +} + +export default SectionContent diff --git a/studio/components/interfaces/Organization/Usage/SectionHeader.tsx b/studio/components/interfaces/Organization/Usage/SectionHeader.tsx new file mode 100644 index 00000000000..3f453ece07d --- /dev/null +++ b/studio/components/interfaces/Organization/Usage/SectionHeader.tsx @@ -0,0 +1,17 @@ +export interface SectionHeaderProps { + title: string + description: string +} + +const SectionHeader = ({ title, description }: SectionHeaderProps) => { + return ( +
    +
    +

    {title}

    +

    {description}

    +
    +
    + ) +} + +export default SectionHeader diff --git a/studio/components/interfaces/Organization/Usage/SizeAndCounts.tsx b/studio/components/interfaces/Organization/Usage/SizeAndCounts.tsx new file mode 100644 index 00000000000..6527a636ee7 --- /dev/null +++ b/studio/components/interfaces/Organization/Usage/SizeAndCounts.tsx @@ -0,0 +1,79 @@ +import { useDailyStatsQuery } from 'data/analytics/daily-stats-query' +import { DataPoint } from 'data/analytics/constants' +import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query' +import UsageSection from './UsageSection' + +export interface SizeAndCountsProps { + projectRef: string + startDate: string | undefined + endDate: string | undefined + subscription: ProjectSubscriptionResponse | undefined + currentBillingCycleSelected: boolean +} + +const SizeAndCounts = ({ + projectRef, + startDate, + endDate, + subscription, + currentBillingCycleSelected, +}: SizeAndCountsProps) => { + const { data: dbSizeData, isLoading: isLoadingDbSizeData } = useDailyStatsQuery({ + projectRef, + attribute: 'total_db_size_bytes', + interval: '1d', + startDate, + endDate, + }) + + const { data: storageSizeData, isLoading: isLoadingStorageSizeData } = useDailyStatsQuery({ + projectRef, + attribute: 'total_storage_size_bytes', + interval: '1d', + startDate, + endDate, + }) + + const { data: functionCountData, isLoading: isLoadingFunctionCountData } = useDailyStatsQuery({ + projectRef, + attribute: 'total_func_count', + interval: '1d', + startDate, + endDate, + }) + + const chartMeta: { + [key: string]: { data: DataPoint[]; margin: number; isLoading: boolean; hasNoData: boolean } + } = { + db_size: { + isLoading: isLoadingDbSizeData, + margin: 14, + data: dbSizeData?.data ?? [], + hasNoData: dbSizeData?.hasNoData ?? false, + }, + storage_size: { + isLoading: isLoadingStorageSizeData, + margin: 14, + data: storageSizeData?.data ?? [], + hasNoData: storageSizeData?.hasNoData ?? false, + }, + func_count: { + isLoading: isLoadingFunctionCountData, + margin: 0, + data: functionCountData?.data ?? [], + hasNoData: functionCountData?.hasNoData ?? false, + }, + } + + return ( + + ) +} + +export default SizeAndCounts diff --git a/studio/components/interfaces/Organization/Usage/Usage.constants.tsx b/studio/components/interfaces/Organization/Usage/Usage.constants.tsx new file mode 100644 index 00000000000..533dc73cffc --- /dev/null +++ b/studio/components/interfaces/Organization/Usage/Usage.constants.tsx @@ -0,0 +1,302 @@ +import Link from 'next/link' + +import { USAGE_APPROACHING_THRESHOLD } from 'components/interfaces/BillingV2/Billing.constants' +import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query' +import { ProjectUsageData } from 'data/usage/project-usage-query' +import { Alert, Badge, Button, IconExternalLink } from 'ui' + +export const COLOR_MAP = { + white: { bar: 'fill-scale-1200', marker: 'bg-scale-1200' }, + green: { bar: 'fill-green-1000', marker: 'bg-green-1000' }, + blue: { bar: 'fill-blue-1000', marker: 'bg-blue-1000' }, +} + +export const Y_DOMAIN_CEILING_MULTIPLIER = 4 / 3 + +export const USAGE_STATUS = { + NORMAL: 'NORMAL', + APPROACHING: 'APPROACHING', + EXCEEDED: 'EXCEEDED', +} + +export interface Attribute { + key: string + name?: string + color: 'white' | 'blue' | 'green' +} +export interface CategoryAttribute { + anchor: string + key: string // Property from project usage + attributes: Attribute[] // For querying against stats-daily / infra-monitoring + name: string + chartPrefix?: string + unit: 'bytes' | 'absolute' | 'percentage' + links?: { + name: string + url: string + }[] + description: string + chartDescription: string + additionalInfo?: ( + ref: string, + subscription?: ProjectSubscriptionResponse, + usage?: ProjectUsageData + ) => JSX.Element | null +} + +export type CategoryMetaKey = 'bandwidth' | 'sizeCount' | 'activity' + +export interface CategoryMeta { + key: 'bandwidth' | 'sizeCount' | 'activity' + name: string + description: string + attributes: CategoryAttribute[] +} + +// [Joshen TODO] We need to update this +export const USAGE_CATEGORIES: CategoryMeta[] = [ + { + key: 'bandwidth', + name: 'Bandwidth', + description: 'Amount of data transmitted over all network connections', + attributes: [ + { + anchor: 'dbEgress', + key: 'db_egress', // This needs to be updated based on the new org usage endpoint + attributes: [ + { key: 'total_egress_modified', name: 'DB Egress', color: 'green' }, + { key: 'total_storage_egress', name: 'Storage Egress', color: 'blue' }, + ], + name: 'Total Egress', + unit: 'bytes', + description: + 'Contains any outgoing traffic (egress) from your database.\nBilling is based on the total sum of egress in GB throughout your billing period.', + chartDescription: 'The data refreshes every 24 hours.', + }, + ], + }, + { + key: 'sizeCount', + name: 'Size & Counts', + description: 'Amount of resources your project is consuming', + attributes: [ + { + anchor: 'dbSize', + key: 'db_size', + attributes: [{ key: 'total_db_size_bytes', color: 'white' }], + name: 'Database size', + chartPrefix: 'Average ', + unit: 'bytes', + description: + 'Database size refers to the monthly average storage usage, as reported by Postgres. Paid plans use auto-scaling disks.\nBilling is based on the average daily database size used in GB throughout the billing period. Billing is independent of the provisioned disk size.', + links: [ + { + name: 'Documentation', + url: 'https://supabase.com/docs/guides/platform/database-size', + }, + ], + chartDescription: 'The data refreshes every 24 hours.', + additionalInfo: ( + ref: string, + subscription?: ProjectSubscriptionResponse, + usage?: ProjectUsageData + ) => { + const usageMeta = usage?.['db_size'] ?? undefined + const usageRatio = + typeof usageMeta !== 'number' ? (usageMeta?.usage ?? 0) / (usageMeta?.limit ?? 0) : 0 + const hasLimit = usageMeta && usageMeta.limit > 0 + + const isApproachingLimit = hasLimit && usageRatio >= USAGE_APPROACHING_THRESHOLD + const isExceededLimit = hasLimit && usageRatio >= 1 + + return subscription?.plan?.id !== 'free' ? ( +
    +
    +
    +

    Disk size:

    +

    {usage?.disk_volume_size_gb} GB

    + + Auto-scaling + +
    + + + + + +
    +

    + If you reach 95% of the disk space, your project will enter read-only mode. Your + disk storage expands automatically when the database reaches 90% of the disk size. + The disk is expanded to be 50% larger. Auto-scaling can only take place once every 6 + hours. You can also{' '} + + preprovision + {' '} + disk for loading larger amounts of data. +

    +
    + ) : ( +
    + {(isApproachingLimit || isExceededLimit) && ( + +
    +
    + When you reach your database size limit, your project can go into read-only + mode. Please upgrade your plan. +
    +
    +
    + )} +
    + ) + }, + }, + { + anchor: 'storageSize', + key: 'storage_size', + attributes: [{ key: 'total_storage_size_bytes', color: 'white' }], + name: 'Storage Size', + chartPrefix: 'Max ', + unit: 'bytes', + description: + 'Sum of all objects in your storage buckets.\nBilling is based on the average daily size in GB throughout your billing period.', + chartDescription: 'The data refreshes every 24 hours.', + links: [ + { + name: 'Storage', + url: 'https://supabase.com/docs/guides/storage', + }, + ], + }, + { + anchor: 'funcCount', + key: 'func_count', + attributes: [{ key: 'total_func_count', color: 'white' }], + name: 'Edge Function Count', + chartPrefix: 'Max ', + unit: 'absolute', + description: + 'Number of serverless functions in your project.\nBilling is based on the maximum amount of functions at any point in time throughout your billing period.', + chartDescription: 'The data refreshes every 24 hours.', + }, + ], + }, + { + key: 'activity', + name: 'Activity', + description: 'Usage statistics that reflect the activity of your project', + attributes: [ + { + anchor: 'mau', + key: 'monthly_active_users', + attributes: [{ key: 'total_auth_billing_period_mau', color: 'white' }], + name: 'Monthly Active Users', + unit: 'absolute', + description: + 'Users who log in or refresh their token count towards MAU.\nBilling is based on the sum of distinct users requesting your API throughout the billing period. Resets every billing cycle.', + chartDescription: + 'The data is refreshed over a period of 24 hours and resets at the beginning of every billing period.\nThe data points are relative to the beginning of your billing period.', + links: [ + { + name: 'Auth', + url: 'https://supabase.com/docs/guides/auth', + }, + ], + }, + { + anchor: 'mauSso', + key: 'monthly_active_sso_users', + attributes: [{ key: 'total_auth_billing_period_sso_mau', color: 'white' }], + name: 'Monthly Active SSO Users', + unit: 'absolute', + description: + 'SSO users who log in or refresh their token count towards SSO MAU.\nBilling is based on the sum of distinct Single Sign-On users requesting your API throughout the billing period. Resets every billing cycle.', + chartDescription: + 'The data refreshes over a period of 24 hours and resets at the beginning of every billing period.\nThe data points are relative to the beginning of your billing period.', + links: [ + { + name: 'SSO with SAML 2.0', + url: 'https://supabase.com/docs/guides/auth/sso/auth-sso-saml', + }, + ], + }, + { + anchor: 'storageImageTransformations', + key: 'storage_image_render_count', + attributes: [{ key: 'total_storage_image_render_count', color: 'white' }], + name: 'Storage Image Transformations', + unit: 'absolute', + description: + 'We count all images that were transformed in the billing period, ignoring any transformations.\nUsage example: You transform one image with four different size transformations and another image with just a single transformation. It counts as two, as only two images were transformed.\nBilling is based on the count of (origin) images that used transformations throughout the billing period. Resets every billing cycle.', + chartDescription: + 'The data refreshes every 24 hours.\nThe data points are relative to the beginning of your billing period.', + links: [ + { + name: 'Documentation', + url: 'https://supabase.com/docs/guides/storage/image-transformations', + }, + ], + }, + { + anchor: 'functionInvocations', + key: 'func_invocations', + attributes: [{ key: 'total_func_invocations', color: 'white' }], + name: 'Edge Function Invocations', + unit: 'absolute', + description: + 'Every serverless function invocation independent of response status is counted.\nBilling is based on the sum of all invocations throughout your billing period.', + chartDescription: 'The data refreshes every 24 hours.', + links: [ + { + name: 'Edge Functions', + url: 'https://supabase.com/docs/guides/functions', + }, + ], + }, + { + anchor: 'realtimeMessageCount', + key: 'realtime_message_count', + attributes: [{ key: 'total_realtime_message_count', color: 'white' }], + name: 'Realtime Message Count', + unit: 'absolute', + description: + "Count of messages going through Realtime.\nUsage example: If you do a database change and 5 clients listen to that change via Realtime, that's 5 messages. If you broadcast a message and 4 clients listen to that, that's 5 messages (1 message sent, 4 received).\nBilling is based on the total amount of messages throughout your billing period.", + chartDescription: 'The data refreshes every 24 hours.', + links: [ + { + name: 'Realtime Quotas', + url: 'https://supabase.com/docs/guides/realtime/quotas', + }, + ], + }, + { + anchor: 'realtimePeakConnection', + key: 'realtime_peak_connection', + attributes: [{ key: 'total_realtime_peak_connection', color: 'white' }], + name: 'Realtime Peak Connections', + chartPrefix: 'Max ', + unit: 'absolute', + description: + 'Total number of successful connections. Connections attempts are not counted towards usage.\nBilling is based on the maximum amount of concurrent peak connections throughout your billing period.', + chartDescription: 'The data refreshes every 24 hours.', + links: [ + { + name: 'Realtime Quotas', + url: 'https://supabase.com/docs/guides/realtime/quotas', + }, + ], + }, + ], + }, +] diff --git a/studio/components/interfaces/Organization/Usage/Usage.tsx b/studio/components/interfaces/Organization/Usage/Usage.tsx new file mode 100644 index 00000000000..087e221556c --- /dev/null +++ b/studio/components/interfaces/Organization/Usage/Usage.tsx @@ -0,0 +1,127 @@ +import clsx from 'clsx' +import dayjs from 'dayjs' +import { useMemo, useState } from 'react' + +import { useParams } from 'common' +import Bandwidth from './Bandwidth' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import DateRangePicker from 'components/to-be-cleaned/DateRangePicker' +import { TIME_PERIODS_BILLING, TIME_PERIODS_REPORTS } from 'lib/constants' +import SizeAndCounts from './SizeAndCounts' +import Activity from './Activity' +import { IconLoader } from 'ui' +import { ScaffoldContainer, ScaffoldDivider, ScaffoldSection } from 'components/layouts/Scaffold' + +const Usage = () => { + const { slug } = useParams() + const [dateRange, setDateRange] = useState() + + const { + data: subscription, + isLoading: isLoadingSubscription, + isError, + isSuccess, + } = useOrgSubscriptionQuery({ orgSlug: slug }) + + const selectedProjectRef = 'kojplftaeiavuneadgix' + const billingCycleStart = dayjs.unix(subscription?.current_period_start ?? 0).utc() + const billingCycleEnd = dayjs.unix(subscription?.current_period_end ?? 0).utc() + + const currentBillingCycleSelected = useMemo(() => { + // Selected by default + if (!dateRange?.period_start || !dateRange?.period_end || !subscription) return true + + const { current_period_start, current_period_end } = subscription + + return ( + dayjs(dateRange.period_start.date).isSame(new Date(current_period_start * 1000)) && + dayjs(dateRange.period_end.date).isSame(new Date(current_period_end * 1000)) + ) + }, [dateRange, subscription]) + + const startDate = useMemo(() => { + // If end date is in future, set end date to now + if (!dateRange?.period_start?.date) { + return undefined + } else { + // LF seems to have an issue with the milliseconds, causes infinite loading sometimes + return new Date(dateRange?.period_start?.date).toISOString().slice(0, -5) + 'Z' + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dateRange, subscription]) + + const endDate = useMemo(() => { + // If end date is in future, set end date to end of current day + if (dateRange?.period_end?.date && dayjs(dateRange.period_end.date).isAfter(dayjs())) { + // LF seems to have an issue with the milliseconds, causes infinite loading sometimes + // In order to have full days from Prometheus metrics when using 1d interval, + // the time needs to be greater or equal than the time of the start date + return dayjs().endOf('day').toISOString().slice(0, -5) + 'Z' + } else if (dateRange?.period_end?.date) { + // LF seems to have an issue with the milliseconds, causes infinite loading sometimes + return new Date(dateRange.period_end.date).toISOString().slice(0, -5) + 'Z' + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dateRange, subscription]) + + return ( + <> + +
    + {!isLoadingSubscription && ( + + )} + + {isLoadingSubscription ? ( + + ) : subscription !== undefined ? ( +
    +

    + Organization is on the {subscription.plan.name} plan +

    +

    + {billingCycleStart.format('DD MMM YYYY')} - {billingCycleEnd.format('DD MMM YYYY')} +

    +
    + ) : null} +
    +
    + + + + + + + + + ) +} + +export default Usage diff --git a/studio/components/interfaces/Organization/Usage/Usage.utils.ts b/studio/components/interfaces/Organization/Usage/Usage.utils.ts new file mode 100644 index 00000000000..6bc5eb09712 --- /dev/null +++ b/studio/components/interfaces/Organization/Usage/Usage.utils.ts @@ -0,0 +1,111 @@ +import { DataPoint } from 'data/analytics/constants' +import { ProjectUsageResponse } from 'data/usage/project-usage-query' +import { USAGE_APPROACHING_THRESHOLD } from 'components/interfaces/BillingV2/Billing.constants' +import { CategoryAttribute, USAGE_STATUS } from './Usage.constants' +import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query' + +// [Joshen] This is just for development to generate some test data for chart rendering +export const generateUsageData = (attribute: string, days: number): DataPoint[] => { + const tempArray = new Array(days).fill(0) + return tempArray.map((x, idx) => { + return { + loopId: (idx + 1).toString(), + period_start: `${idx + 1}`, + [attribute]: Math.floor(Math.random() * 100).toString(), + } + }) +} + +export const getUsageStatus = (attributes: CategoryAttribute[], usage?: ProjectUsageResponse) => { + if (!usage) return USAGE_STATUS.NORMAL + + const attributeStatuses = attributes.map((attribute) => { + const usageMeta = usage?.[attribute.key as keyof ProjectUsageResponse] + const usageRatio = + typeof usageMeta !== 'number' ? (usageMeta?.usage ?? 0) / (usageMeta?.limit ?? 0) : 0 + if (usageRatio >= 1) return USAGE_STATUS.EXCEEDED + else if (usageRatio >= USAGE_APPROACHING_THRESHOLD) return USAGE_STATUS.APPROACHING + else return USAGE_STATUS.NORMAL + }) + + if (attributeStatuses.find((x) => x === USAGE_STATUS.EXCEEDED)) return USAGE_STATUS.EXCEEDED + else if (attributeStatuses.find((x) => x === USAGE_STATUS.APPROACHING)) + return USAGE_STATUS.APPROACHING + else return USAGE_STATUS.NORMAL +} + +export const getUpgradeUrl = (projectRef: string, subscription?: ProjectSubscriptionResponse) => { + if (!subscription) { + return `/project/${projectRef}/settings/billing/subscription` + } + + return subscription?.plan?.id === 'pro' && subscription?.usage_billing_enabled === false + ? `/project/${projectRef}/settings/billing/subscription#cost-control` + : `/project/${projectRef}/settings/billing/subscription?panel=subscriptionPlan` +} + +const compactNumberFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + compactDisplay: 'short', +}) + +/** + * For the y-axis, we don't need to be as precise, to avoid showing 58.597MB. + */ +export const ChartYFormatterCompactNumber = (number: number | string, unit: string) => { + if (typeof number === 'string') return number + + if (unit === 'bytes') { + const formattedBytes = formatBytesCompact(number).replace(/\s/g, '') + + return formattedBytes === '0bytes' ? '0' : formattedBytes + } else { + return compactNumberFormatter.format(number) + } +} + +/** + * For the chart tooltip, we want to be more precise and show more decimals. + */ +export const ChartTooltipValueFormatter = (number: number | string, unit: string) => { + if (typeof number === 'string') return number + + if (unit === 'bytes') { + const formattedBytes = formatBytesPrecision(number).replace(/\s/g, '') + + return formattedBytes === '0bytes' ? '0' : formattedBytes + } else { + return compactNumberFormatter.format(number) + } +} + +const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + +export const formatBytesCompact = (bytes: number) => { + if (bytes === 0 || bytes === undefined) return '0 bytes' + + const k = 1024 + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + const unit = sizes[i] + + let dm = 2 + if (['bytes', 'KB', 'MB'].includes(unit)) { + dm = 0 + } else if (['GB'].includes(unit)) { + dm = 1 + } + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + unit +} + +export const formatBytesPrecision = (bytes: any) => { + if (bytes === 0 || bytes === undefined) return '0 bytes' + + const k = 1024 + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + const unit = sizes[i] + + return parseFloat((bytes / Math.pow(k, i)).toFixed(3)) + ' ' + unit +} diff --git a/studio/components/interfaces/Organization/Usage/UsageBarChart.tsx b/studio/components/interfaces/Organization/Usage/UsageBarChart.tsx new file mode 100644 index 00000000000..337f43e6edd --- /dev/null +++ b/studio/components/interfaces/Organization/Usage/UsageBarChart.tsx @@ -0,0 +1,107 @@ +import { DataPoint } from 'data/analytics/constants' +import { + Bar, + CartesianGrid, + Cell, + ComposedChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import dayjs from 'dayjs' +import clsx from 'clsx' +import { COLOR_MAP, Attribute } from './Usage.constants' +import { MultiAttributeTooltipContent, SingleAttributeTooltipContent } from './UsageChartTooltips' + +// [Joshen] This BarChart is specifically for usage, hence not a reusable component, and not +// replacing the existing BarChart in ui/Charts + +export interface UsageBarChartProps { + data: DataPoint[] + name: string // Used within the tooltip + attributes: Attribute[] + unit: 'bytes' | 'absolute' | 'percentage' + yLimit?: number + yLeftMargin?: number + yFormatter?: (value: number | string) => string + tooltipFormatter?: (value: number | string) => string +} + +const UsageBarChart = ({ + data, + name, + attributes, + unit, + yLimit, + yLeftMargin = 10, + yFormatter, + tooltipFormatter, +}: UsageBarChartProps) => { + const yMin = 0 // We can consider passing this as a prop if there's a use case in the future + const yDomain = [yMin, yLimit ?? 0] + + return ( +
    + + + + + + { + const { active, payload } = props + if (active && payload && payload.length) { + const dataPeriod = dayjs(payload[0].payload.period_start) + const isAfterToday = dataPeriod.startOf('day').isAfter(dayjs().startOf('day')) + return ( +
    1 && !isAfterToday ? 'w-[250px]' : 'w-[170px]' + )} + > + {attributes.length > 1 ? ( + + ) : ( + + )} +

    + {dataPeriod.format('DD MMM YYYY')} +

    +
    + ) + } else return null + }} + /> + {attributes?.map((attr) => ( + + {data.map((entry) => { + return + })} + + ))} +
    +
    +
    + ) +} + +export default UsageBarChart diff --git a/studio/components/interfaces/Organization/Usage/UsageChartTooltips.tsx b/studio/components/interfaces/Organization/Usage/UsageChartTooltips.tsx new file mode 100644 index 00000000000..3bddb5f962e --- /dev/null +++ b/studio/components/interfaces/Organization/Usage/UsageChartTooltips.tsx @@ -0,0 +1,80 @@ +import clsx from 'clsx' +import { Payload, ValueType } from 'recharts/types/component/DefaultTooltipContent' +import { Attribute, COLOR_MAP } from './Usage.constants' + +export interface SingleAttributeTooltipContentProps { + name: string + unit: 'bytes' | 'percentage' | 'absolute' + value: any + isAfterToday: boolean + tooltipFormatter?: (value: any) => any +} + +export const SingleAttributeTooltipContent = ({ + name, + unit, + value, + isAfterToday, + tooltipFormatter, +}: SingleAttributeTooltipContentProps) => { + const formattedValue = unit === 'percentage' ? Number(value).toFixed(2) : Number(value) + return ( + <> +

    {name}

    + {isAfterToday ? ( +

    No data yet

    + ) : ( +

    + {tooltipFormatter !== undefined ? tooltipFormatter(formattedValue) : formattedValue} +

    + )} + + ) +} + +export interface MultiAttributeTooltipContentProps { + attributes: Attribute[] + values: Payload[] + isAfterToday: boolean + tooltipFormatter?: (value: any) => any +} + +export const MultiAttributeTooltipContent = ({ + attributes, + values, + isAfterToday, + tooltipFormatter, +}: MultiAttributeTooltipContentProps) => { + return ( + <> + {isAfterToday ? ( +

    No data yet

    + ) : ( +
    + {attributes.map((attr) => { + const attrMeta = values.find((x) => x.dataKey === attr.key) + const attrValue = Number(attrMeta?.value ?? 0) + const sumValue = values.reduce((a, b) => a + Number(b.value), 0) + const percentageContribution = ((attrValue / sumValue) * 100).toFixed(1) + + return ( +
    +
    +
    +

    + {attr.name} ({percentageContribution}%):{' '} +

    +
    +

    + {tooltipFormatter !== undefined ? tooltipFormatter(attrValue) : attrValue} +

    +
    + ) + })} +
    + )} + + ) +} diff --git a/studio/components/interfaces/Organization/Usage/UsageSection.tsx b/studio/components/interfaces/Organization/Usage/UsageSection.tsx new file mode 100644 index 00000000000..50ac24685e5 --- /dev/null +++ b/studio/components/interfaces/Organization/Usage/UsageSection.tsx @@ -0,0 +1,293 @@ +import * as Tooltip from '@radix-ui/react-tooltip' +import clsx from 'clsx' +import Link from 'next/link' + +import { USAGE_APPROACHING_THRESHOLD } from 'components/interfaces/BillingV2/Billing.constants' +import Panel from 'components/ui/Panel' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import SparkBar from 'components/ui/SparkBar' +import { DataPoint } from 'data/analytics/constants' +import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query' +import { + ProjectUsageResponse, + UsageMetric, + useProjectUsageQuery, +} from 'data/usage/project-usage-query' +import { formatBytes } from 'lib/helpers' +import { Button, IconAlertTriangle, IconBarChart2 } from 'ui' +import SectionContent from './SectionContent' +import SectionHeader from './SectionHeader' +import { CategoryMetaKey, USAGE_CATEGORIES } from './Usage.constants' +import { + ChartTooltipValueFormatter, + ChartYFormatterCompactNumber, + getUpgradeUrl, +} from './Usage.utils' +import UsageBarChart from './UsageBarChart' +import { ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold' + +interface UsageSectionProps { + projectRef: string + categoryKey: CategoryMetaKey + subscription: ProjectSubscriptionResponse | undefined + chartMeta: { + [key: string]: { data: DataPoint[]; margin: number; isLoading: boolean; hasNoData: boolean } + } + currentBillingCycleSelected: boolean +} + +// [Joshen TODO] Needs to change to use org slug, and eventually call useOrganizationUsageQuery +const UsageSection = ({ + projectRef, + categoryKey, + chartMeta, + subscription, + currentBillingCycleSelected, +}: UsageSectionProps) => { + const { data: usage } = useProjectUsageQuery({ projectRef }) + const categoryMeta = USAGE_CATEGORIES.find((category) => category.key === categoryKey) + + if (!categoryMeta) return null + + const usageBasedBilling = subscription?.usage_billing_enabled + const exceededLimitStyle = !usageBasedBilling ? 'text-red-900' : 'text-amber-900' + + const upgradeUrl = getUpgradeUrl(projectRef, subscription) + + return ( + <> + + + + + + + {categoryMeta.attributes.map((attribute) => { + const usageMeta = usage?.[attribute.key as keyof ProjectUsageResponse] as UsageMetric + const usageRatio = + typeof usageMeta !== 'number' ? (usageMeta?.usage ?? 0) / (usageMeta?.limit ?? 0) : 0 + const usageExcess = (usageMeta?.usage ?? 0) - (usageMeta?.limit ?? 0) + + const chartData = chartMeta[attribute.key]?.data ?? [] + + const notAllValuesZero = + attribute.attributes + ?.map((attr) => { + return chartData.some((dataPoint) => Number(dataPoint[attr.key]) !== 0) + }) + .some((x) => !!x) ?? false + + return ( +
    + + {usageMeta?.available_in_plan ? ( + <> +
    +
    +
    +

    {attribute.name} usage

    + {currentBillingCycleSelected && + usageBasedBilling === false && + usageRatio >= USAGE_APPROACHING_THRESHOLD && ( + + + {!usageBasedBilling && usageRatio >= 1 ? ( +
    + +

    + Exceeded limit +

    +
    + ) : ( + !usageBasedBilling && + usageRatio >= USAGE_APPROACHING_THRESHOLD && ( +
    + +

    Approaching limit

    +
    + ) + )} +
    + + + +
    +

    + Exceeding your plans included usage will lead to restrictions + to your project. +

    +

    + Upgrade to a usage-based plan or disable the spend cap to + avoid restrictions. +

    +
    +
    +
    +
    + )} +
    + + {currentBillingCycleSelected && + !usageBasedBilling && + usageRatio >= USAGE_APPROACHING_THRESHOLD && ( + + + + + + )} +
    + {currentBillingCycleSelected && usageMeta?.limit > 0 && ( + = 1 + ? usageBasedBilling + ? 'bg-scale-1100' + : 'bg-red-900' + : usageBasedBilling === false && + usageRatio >= USAGE_APPROACHING_THRESHOLD + ? 'bg-amber-900' + : 'bg-scale-1100' + )} + bgClass="bg-gray-300 dark:bg-gray-600" + value={usageMeta?.usage ?? 0} + max={usageMeta?.limit || 1} + /> + )} +
    +
    +

    + Included in {subscription?.plan?.name.toLowerCase()} plan +

    + {usageMeta?.limit === -1 ? ( +

    None

    + ) : usageMeta?.limit === 0 ? ( +

    Unlimited

    + ) : ( +

    + {attribute.unit === 'bytes' + ? formatBytes(usageMeta?.limit ?? 0) + : (usageMeta?.limit ?? 0).toLocaleString()} +

    + )} +
    + {currentBillingCycleSelected && ( +
    +

    + {attribute.chartPrefix || 'Used '}in period +

    +

    + {attribute.unit === 'bytes' + ? formatBytes(usageMeta?.usage ?? 0) + : (usageMeta?.usage ?? 0).toLocaleString()} +

    +
    + )} + {currentBillingCycleSelected && usageMeta?.limit > 0 && ( +
    +

    Overage in period

    +

    + {(usageMeta?.limit ?? 0) === -1 || usageExcess < 0 + ? 0 + : attribute.unit === 'bytes' + ? formatBytes(usageExcess) + : usageExcess.toLocaleString()} +

    +
    + )} +
    +
    + + {attribute.additionalInfo?.(projectRef, subscription, usage)} + +
    +

    + {attribute.chartPrefix || ''} + {attribute.name} per day +

    + {attribute.chartDescription.split('\n').map((paragraph, idx) => ( +

    + {paragraph} +

    + ))} +
    + {chartMeta[attribute.key].isLoading ? ( +
    + + + +
    + ) : chartData.length > 0 && notAllValuesZero ? ( + ChartYFormatterCompactNumber(value, attribute.unit)} + tooltipFormatter={(value) => + ChartTooltipValueFormatter(value, attribute.unit) + } + /> + ) : ( + + +
    + +

    No data in period

    +

    May take up to 24 hours to show

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

    Not included in plan

    +
    +

    + You need to be on a higher plan in order to use this feature. +

    +
    +
    + + + + + +
    +
    +
    + )} +
    +
    + ) + })} + + ) +} + +export default UsageSection diff --git a/studio/components/interfaces/Organization/Usage/UsageStackedBarChart.tsx b/studio/components/interfaces/Organization/Usage/UsageStackedBarChart.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/studio/components/interfaces/Organization/index.ts b/studio/components/interfaces/Organization/index.ts index beee07cbc43..c86093a5382 100644 --- a/studio/components/interfaces/Organization/index.ts +++ b/studio/components/interfaces/Organization/index.ts @@ -1,7 +1,23 @@ -import GeneralSettings from './GeneralSettings/GeneralSettings' -import TeamSettings from './TeamSettings/TeamSettings' import BillingSettings from './BillingSettings/BillingSettings' +import BillingSettingsV2 from './BillingSettingsV2/BillingSettings' +import GeneralSettings from './GeneralSettings/GeneralSettings' +import IntegrationSettings from './IntegrationSettings/IntegrationSettings' import InvoicesSettings from './InvoicesSettings/InvoicesSettings' import NewOrgForm from './NewOrg/NewOrgForm' +import Usage from './Usage/Usage' +import AuditLogs from './AuditLogs/AuditLogs' +import OAuthApps from './OAuthApps/OAuthApps' +import TeamSettings from './TeamSettings/TeamSettings' -export { GeneralSettings, TeamSettings, BillingSettings, InvoicesSettings, NewOrgForm } +export { + BillingSettings, + BillingSettingsV2, + GeneralSettings, + IntegrationSettings, + InvoicesSettings, + NewOrgForm, + OAuthApps, + AuditLogs, + TeamSettings, + Usage, +} diff --git a/studio/components/interfaces/Reports/Reports.constants.ts b/studio/components/interfaces/Reports/Reports.constants.ts index 914c8a068ae..71b5f65324f 100644 --- a/studio/components/interfaces/Reports/Reports.constants.ts +++ b/studio/components/interfaces/Reports/Reports.constants.ts @@ -33,7 +33,7 @@ export const DEFAULT_QUERY_PARAMS = { iso_timestamp_end: REPORTS_DATEPICKER_HELPERS[0].calcTo(), } -const generateRexepWhere = (filters: ReportFilterItem[], prepend = true) => { +const generateRegexpWhere = (filters: ReportFilterItem[], prepend = true) => { if (filters.length === 0) return '' const conditions = filters .map((filter) => { @@ -68,15 +68,15 @@ export const PRESET_CONFIG: Record = { cross join unnest(m.response) as response cross join unnest(m.request) as request cross join unnest(request.headers) as headers - ${generateRexepWhere(filters)} + ${generateRegexpWhere(filters)} GROUP BY timestamp ORDER BY timestamp ASC`, }, topRoutes: { - queryType: "logs", - sql: (filters)=> ` + queryType: 'logs', + sql: (filters) => ` select request.path as path, request.method as method, @@ -88,14 +88,14 @@ export const PRESET_CONFIG: Record = { cross join unnest(m.response) as response cross join unnest(m.request) as request cross join unnest(request.headers) as headers - ${generateRexepWhere(filters)} + ${generateRegexpWhere(filters)} group by request.path, request.method, request.search, response.status_code order by count desc limit 3 - ` + `, }, errorCounts: { queryType: 'logs', @@ -110,7 +110,7 @@ export const PRESET_CONFIG: Record = { cross join unnest(request.headers) as headers WHERE response.status_code >= 400 - ${generateRexepWhere(filters, false)} + ${generateRegexpWhere(filters, false)} GROUP BY timestamp ORDER BY @@ -118,8 +118,8 @@ export const PRESET_CONFIG: Record = { `, }, topErrorRoutes: { - queryType: "logs", - sql: (filters)=> ` + queryType: 'logs', + sql: (filters) => ` select request.path as path, request.method as method, @@ -133,14 +133,14 @@ export const PRESET_CONFIG: Record = { cross join unnest(request.headers) as headers where response.status_code >= 400 - ${generateRexepWhere(filters, false)} + ${generateRegexpWhere(filters, false)} group by request.path, request.method, request.search, response.status_code order by count desc limit 3 - ` + `, }, responseSpeed: { queryType: 'logs', @@ -154,7 +154,7 @@ export const PRESET_CONFIG: Record = { cross join unnest(m.response) as response cross join unnest(m.request) as request cross join unnest(request.headers) as headers - ${generateRexepWhere(filters)} + ${generateRegexpWhere(filters)} GROUP BY timestamp ORDER BY @@ -162,8 +162,8 @@ export const PRESET_CONFIG: Record = { `, }, topSlowRoutes: { - queryType: "logs", - sql: (filters)=> ` + queryType: 'logs', + sql: (filters) => ` select request.path as path, request.method as method, @@ -176,14 +176,51 @@ export const PRESET_CONFIG: Record = { cross join unnest(m.response) as response cross join unnest(m.request) as request cross join unnest(request.headers) as headers - ${generateRexepWhere(filters)} + ${generateRegexpWhere(filters)} group by request.path, request.method, request.search, response.status_code order by avg desc limit 3 - ` + `, + }, + networkTraffic: { + queryType: 'logs', + sql: (filters) => ` + select + cast(timestamp_trunc(t.timestamp, hour) as datetime) as timestamp, + coalesce( + safe_divide( + sum( + cast(coalesce(headers.content_length, "0") as int64) + ), + 1000000 + ), + 0 + ) as ingress_mb, + coalesce( + safe_divide( + sum( + cast(coalesce(resp_headers.content_length, "0") as int64) + ), + 1000000 + ), + 0 + ) as egress_mb, + FROM + edge_logs t + cross join unnest(metadata) as m + cross join unnest(m.response) as response + cross join unnest(m.request) as request + cross join unnest(request.headers) as headers + cross join unnest(response.headers) as resp_headers + ${generateRegexpWhere(filters)} + GROUP BY + timestamp + ORDER BY + timestamp ASC + `, }, }, }, diff --git a/studio/components/interfaces/Reports/Reports.tsx b/studio/components/interfaces/Reports/Reports.tsx index 5ab137f59b8..f08e517394c 100644 --- a/studio/components/interfaces/Reports/Reports.tsx +++ b/studio/components/interfaces/Reports/Reports.tsx @@ -1,8 +1,9 @@ -import { useEffect, useState } from 'react' import * as Tooltip from '@radix-ui/react-tooltip' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import dayjs from 'dayjs' import { groupBy, isNull } from 'lodash' import { toJS } from 'mobx' -import dayjs from 'dayjs' +import { useEffect, useState } from 'react' import { Badge, Button, @@ -14,26 +15,25 @@ import { IconSave, IconSettings, } from 'ui' -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions } from 'hooks' import { useParams } from 'common/hooks' -import { uuidv4 } from 'lib/helpers' -import { METRIC_CATEGORIES, METRICS, TIME_PERIODS_REPORTS } from 'lib/constants' -import { useProjectContentStore } from 'stores/projectContentStore' -import Loading from 'components/ui/Loading' import DateRangePicker from 'components/to-be-cleaned/DateRangePicker' +import Loading from 'components/ui/Loading' import NoPermission from 'components/ui/NoPermission' +import { useCheckPermissions } from 'hooks' +import { METRIC_CATEGORIES, METRICS, TIME_PERIODS_REPORTS } from 'lib/constants' +import { uuidv4 } from 'lib/helpers' +import { useProfile } from 'lib/profile' +import { useProjectContentStore } from 'stores/projectContentStore' import GridResize from './GridResize' import { LAYOUT_COLUMN_COUNT } from './Reports.constants' -import { useProfileQuery } from 'data/profile/profile-query' const DEFAULT_CHART_COLUMN_COUNT = 12 const DEFAULT_CHART_ROW_COUNT = 4 const Reports = () => { const { id, ref } = useParams() - const { data: profile } = useProfileQuery() + const { profile } = useProfile() const [report, setReport] = useState() @@ -46,7 +46,7 @@ const Reports = () => { const [endDate, setEndDate] = useState(null) const contentStore = useProjectContentStore(ref) - const canReadReport = checkPermissions(PermissionAction.READ, 'user_content', { + const canReadReport = useCheckPermissions(PermissionAction.READ, 'user_content', { resource: { type: 'report', visibility: report?.visibility, @@ -54,7 +54,7 @@ const Reports = () => { }, subject: { id: profile?.id }, }) - const canUpdateReport = checkPermissions(PermissionAction.UPDATE, 'user_content', { + const canUpdateReport = useCheckPermissions(PermissionAction.UPDATE, 'user_content', { resource: { type: 'report', visibility: report?.visibility, diff --git a/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx b/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx index e7643bb70e7..8d72a882657 100644 --- a/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx +++ b/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx @@ -9,7 +9,59 @@ import { Button, Collapsible, IconChevronRight } from 'ui' import { queryParamsToObject } from '../Reports.utils' import { Fragment } from 'react' import useFillTimeseriesSorted from 'hooks/analytics/useFillTimeseriesSorted' +import sumBy from 'lodash/sumBy' +export const NetworkTrafficRenderer = ( + props: ReportWidgetProps<{ + timestamp: string + ingress: number + egress: number + }> +) => { + const data = useFillTimeseriesSorted( + props.data, + 'timestamp', + ['ingress_mb', 'egress_mb'], + 0, + props.params?.iso_timestamp_start, + props.params?.iso_timestamp_end + ) + const totalIngress = sumBy(props.data, 'ingress_mb') + const totalEgress = sumBy(props.data, 'egress_mb') + + function determinePrecision(valueInMb: number) { + return valueInMb < 0.001 ? 7 : totalIngress > 1 ? 2 : 4 + } + return ( +
    + + + +
    + ) +} export const TotalRequestsChartRenderer = ( props: ReportWidgetProps<{ timestamp: string diff --git a/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts b/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts index 77ebefd4d13..b9e54497146 100644 --- a/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts +++ b/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts @@ -1169,7 +1169,7 @@ $$; `.trim(), }, { - id: 21, + id: 22, type: 'template', title: 'Replication status report', description: 'See the status of your replication slots and replication lag.', @@ -1185,6 +1185,52 @@ FROM pg_control_checkpoint(), pg_replication_slots s LEFT JOIN pg_stat_replication r ON (r.pid = s.active_pid); `, }, + { + id: 23, + type: 'quickstart', + title: 'LangChain', + description: 'LangChain is a popular framework for working with AI, Vectors, and embeddings.', + sql: ` +-- Enable the pgvector extension to work with embedding vectors +create extension vector; + +-- Create a table to store your documents +create table documents ( + id bigserial primary key, + content text, -- corresponds to Document.pageContent + metadata jsonb, -- corresponds to Document.metadata + embedding vector(1536) -- 1536 works for OpenAI embeddings, change if needed +); + +-- Create a function to search for documents +create function match_documents ( + query_embedding vector(1536), + match_count int default null, + filter jsonb DEFAULT '{}' +) returns table ( + id bigint, + content text, + metadata jsonb, + similarity float +) +language plpgsql +as $$ +#variable_conflict use_column +begin + return query + select + id, + content, + metadata, + 1 - (documents.embedding <=> query_embedding) as similarity + from documents + where metadata @> filter + order by documents.embedding <=> query_embedding + limit match_count; +end; +$$; +`.trim(), + }, ] export const SQL_SNIPPET_SCHEMA_VERSION = '1.0' diff --git a/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx b/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx index 1aa386bdac5..3c729c690d2 100644 --- a/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx +++ b/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx @@ -1,29 +1,29 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { partition } from 'lodash' import { observer } from 'mobx-react-lite' -import { PermissionAction } from '@supabase/shared-types/out/constants' -import Telemetry from 'lib/telemetry' -import { checkPermissions, useStore } from 'hooks' +import { useParams, useTelemetryProps } from 'common' import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.constants' -import SQLCard from './SQLCard' -import { createSqlSnippetSkeleton } from '../SQLEditor.utils' -import { useTelemetryProps, useParams } from 'common' -import { useRouter } from 'next/router' import { SqlSnippet } from 'data/content/sql-snippets-query' -import { useProfileQuery } from 'data/profile/profile-query' -import { useSqlEditorStateSnapshot } from 'state/sql-editor' +import { useCheckPermissions, useStore } from 'hooks' import { uuidv4 } from 'lib/helpers' +import { useProfile } from 'lib/profile' +import Telemetry from 'lib/telemetry' +import { useRouter } from 'next/router' +import { useSqlEditorStateSnapshot } from 'state/sql-editor' +import { createSqlSnippetSkeleton } from '../SQLEditor.utils' +import SQLCard from './SQLCard' const SQLTemplates = observer(() => { const { ui } = useStore() const { ref } = useParams() const router = useRouter() - const { data: profile } = useProfileQuery() + const { profile } = useProfile() const [sql, quickStart] = partition(SQL_TEMPLATES, { type: 'template' }) const telemetryProps = useTelemetryProps() const snap = useSqlEditorStateSnapshot() - const canCreateSQLSnippet = checkPermissions(PermissionAction.CREATE, 'user_content', { + const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', { resource: { type: 'sql', owner_id: profile?.id }, subject: { id: profile?.id }, }) diff --git a/studio/components/interfaces/Settings/API/JWTSettings.tsx b/studio/components/interfaces/Settings/API/JWTSettings.tsx index d9853359b33..e93d4e12f74 100644 --- a/studio/components/interfaces/Settings/API/JWTSettings.tsx +++ b/studio/components/interfaces/Settings/API/JWTSettings.tsx @@ -23,7 +23,7 @@ import { import { PermissionAction } from '@supabase/shared-types/out/constants' import { uuidv4 } from 'lib/helpers' -import { useStore, checkPermissions } from 'hooks' +import { useStore, useCheckPermissions } from 'hooks' import { useParams } from 'common/hooks' import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' import { useJwtSecretUpdateMutation } from 'data/config/jwt-secret-update-mutation' @@ -48,8 +48,8 @@ const JWTSettings: FC = ({}) => { const [isSubmittingJwtSecretUpdateRequest, setIsSubmittingJwtSecretUpdateRequest] = useState(false) - const canReadJWTSecret = checkPermissions(PermissionAction.READ, 'field.jwt_secret') - const canGenerateNewJWTSecret = checkPermissions( + const canReadJWTSecret = useCheckPermissions(PermissionAction.READ, 'field.jwt_secret') + const canGenerateNewJWTSecret = useCheckPermissions( PermissionAction.INFRA_EXECUTE, 'queue_job.projects.update_jwt' ) diff --git a/studio/components/interfaces/Settings/API/PostgrestConfig.tsx b/studio/components/interfaces/Settings/API/PostgrestConfig.tsx index f52a9da6473..1328b337b0f 100644 --- a/studio/components/interfaces/Settings/API/PostgrestConfig.tsx +++ b/studio/components/interfaces/Settings/API/PostgrestConfig.tsx @@ -4,7 +4,7 @@ import { observer } from 'mobx-react-lite' import { Input, Form, IconAlertCircle, InputNumber } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' import { useParams } from 'common/hooks' import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' import { useProjectPostgrestConfigUpdateMutation } from 'data/config/project-postgrest-config-update-mutation' @@ -37,7 +37,7 @@ const PostgrestConfig: FC = ({}) => { db_extra_search_path: '', } - const canUpdatePostgrestConfig = checkPermissions( + const canUpdatePostgrestConfig = useCheckPermissions( PermissionAction.UPDATE, 'custom_config_postgrest' ) diff --git a/studio/components/interfaces/Settings/Database/ConnectionPooling.tsx b/studio/components/interfaces/Settings/Database/ConnectionPooling.tsx index b9d6b89725c..95c8ba477a4 100644 --- a/studio/components/interfaces/Settings/Database/ConnectionPooling.tsx +++ b/studio/components/interfaces/Settings/Database/ConnectionPooling.tsx @@ -1,24 +1,22 @@ -import { FC, useState, useEffect, Fragment } from 'react' -import { observer } from 'mobx-react-lite' -import { checkPermissions, useStore } from 'hooks' -import { pluckObjectFields } from 'lib/helpers' -import Loading from 'components/ui/Loading' -import Panel from 'components/ui/Panel' -import Divider from 'components/ui/Divider' -import { AutoField } from 'uniforms-bootstrap4' -import { API_URL } from 'lib/constants' -import { patch } from 'lib/common/fetch' -import ToggleField from 'components/to-be-cleaned/forms/ToggleField' -import SchemaFormPanel from 'components/to-be-cleaned/forms/SchemaFormPanel' -import { Input } from 'ui' -import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { FC, Fragment, useEffect, useState } from 'react' +import { AutoField } from 'uniforms-bootstrap4' -interface Props {} +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import SchemaFormPanel from 'components/to-be-cleaned/forms/SchemaFormPanel' +import Divider from 'components/ui/Divider' +import Panel from 'components/ui/Panel' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { useCheckPermissions, useStore } from 'hooks' +import { patch } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { pluckObjectFields } from 'lib/helpers' +import { Input } from 'ui' -const ConnectionPooling: FC = () => { - const { ui, app } = useStore() - const projectRef = ui.selectedProject?.ref ?? 'default' +const ConnectionPooling = () => { + const { app } = useStore() + const { project } = useProjectContext() + const projectRef = project?.ref ?? 'default' const [isLoading, setIsLoading] = useState(true) const [poolingConfiguration, setPoolingConfiguration] = useState() @@ -103,7 +101,7 @@ const ConnectionPooling: FC = () => { ) } -export default observer(ConnectionPooling) +export default ConnectionPooling interface ConfigProps { projectRef: string @@ -122,139 +120,137 @@ interface ConfigProps { } } -export const PgbouncerConfig: FC = observer( - ({ projectRef, bouncerInfo, connectionInfo }) => { - const { ui } = useStore() +export const PgbouncerConfig: FC = ({ projectRef, bouncerInfo, connectionInfo }) => { + const { ui } = useStore() - const canUpdateConnectionPoolingConfiguration = checkPermissions( - PermissionAction.UPDATE, - 'projects' - ) + const canUpdateConnectionPoolingConfiguration = useCheckPermissions( + PermissionAction.UPDATE, + 'projects' + ) - const [updates, setUpdates] = useState({ - pool_mode: bouncerInfo.pool_mode || 'transaction', - default_pool_size: bouncerInfo.default_pool_size || '', - ignore_startup_parameters: bouncerInfo.ignore_startup_parameters || '', - pgbouncer_enabled: bouncerInfo.pgbouncer_enabled, - }) + const [updates, setUpdates] = useState({ + pool_mode: bouncerInfo.pool_mode || 'transaction', + default_pool_size: bouncerInfo.default_pool_size || '', + ignore_startup_parameters: bouncerInfo.ignore_startup_parameters || '', + pgbouncer_enabled: bouncerInfo.pgbouncer_enabled, + }) - const updateConfig = async (updatedConfig: any) => { - try { - const response = await patch(`${API_URL}/projects/${projectRef}/config/pgbouncer`, { - pgbouncer_enabled: updatedConfig.pgbouncer_enabled, - default_pool_size: updatedConfig.default_pool_size, - ignore_startup_parameters: updatedConfig.ignore_startup_parameters, - pool_mode: updatedConfig.pool_mode, - max_client_conn: updatedConfig.max_client_conn, - }) - if (response.error) { - throw response.error - } else { - setUpdates({ ...response }) - ui.setNotification({ category: 'success', message: 'Successfully saved settings' }) - } - } catch (error: any) { - ui.setNotification({ - category: 'error', - message: `Failed to update config: ${error.message}`, - }) + const updateConfig = async (updatedConfig: any) => { + try { + const response = await patch(`${API_URL}/projects/${projectRef}/config/pgbouncer`, { + pgbouncer_enabled: updatedConfig.pgbouncer_enabled, + default_pool_size: updatedConfig.default_pool_size, + ignore_startup_parameters: updatedConfig.ignore_startup_parameters, + pool_mode: updatedConfig.pool_mode, + max_client_conn: updatedConfig.max_client_conn, + }) + if (response.error) { + throw response.error + } else { + setUpdates({ ...response }) + ui.setNotification({ category: 'success', message: 'Successfully saved settings' }) } + } catch (error: any) { + ui.setNotification({ + category: 'error', + message: `Failed to update config: ${error.message}`, + }) } - - const formSchema = { - properties: { - pool_mode: { - title: 'Pool Mode', - type: 'string', - options: [ - { - label: 'Transaction', - value: 'transaction', - }, - { - label: 'Session', - value: 'session', - }, - ], - }, - ignore_startup_parameters: { - title: 'Ignore Startup Parameters', - type: 'string', - help: 'Defaults are either blank or "extra_float_digits"', - }, - }, - required: ['pool_mode'], - type: 'object', - } - - return ( -
    - setUpdates(model)} - onSubmit={(model: any) => updateConfig(model)} - onReset={() => setUpdates(bouncerInfo)} - disabled={!canUpdateConnectionPoolingConfiguration} - disabledMessage="You need additional permissions to update connection pooling settings" - > -
    - {bouncerInfo.pgbouncer_enabled && ( - <> - -
    -

    - Specify when a connection can be returned to the pool. To find out the most - suitable mode for your use case,{' '} - - click here - - . -

    -
    - - - - )} - - - - -
    -
    -
    - ) } -) + + const formSchema = { + properties: { + pool_mode: { + title: 'Pool Mode', + type: 'string', + options: [ + { + label: 'Transaction', + value: 'transaction', + }, + { + label: 'Session', + value: 'session', + }, + ], + }, + ignore_startup_parameters: { + title: 'Ignore Startup Parameters', + type: 'string', + help: 'Defaults are either blank or "extra_float_digits"', + }, + }, + required: ['pool_mode'], + type: 'object', + } + + return ( +
    + setUpdates(model)} + onSubmit={(model: any) => updateConfig(model)} + onReset={() => setUpdates(bouncerInfo)} + disabled={!canUpdateConnectionPoolingConfiguration} + disabledMessage="You need additional permissions to update connection pooling settings" + > +
    + {bouncerInfo.pgbouncer_enabled && ( + <> + +
    +

    + Specify when a connection can be returned to the pool. To find out the most + suitable mode for your use case,{' '} + + click here + + . +

    +
    + + + + )} + + + + +
    +
    +
    + ) +} diff --git a/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx b/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx index c3d88b9915f..73b3de9eec1 100644 --- a/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx +++ b/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx @@ -5,7 +5,7 @@ import generator from 'generate-password' import { Input, Button, Modal } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' import { useParams } from 'common/hooks' import { patch } from 'lib/common/fetch' import { passwordStrength } from 'lib/helpers' @@ -13,12 +13,13 @@ import { API_URL, DEFAULT_MINIMUM_PASSWORD_STRENGTH } from 'lib/constants' import Panel from 'components/ui/Panel' import PasswordStrengthBar from 'components/ui/PasswordStrengthBar' +import { getProjectDetail } from 'data/projects/project-detail-query' const ResetDbPassword: FC = ({ disabled = false }) => { const { ui, app, meta } = useStore() const { ref } = useParams() - const canResetDbPassword = checkPermissions(PermissionAction.UPDATE, 'projects') + const canResetDbPassword = useCheckPermissions(PermissionAction.UPDATE, 'projects') const [showResetDbPass, setShowResetDbPass] = useState(false) const [isUpdatingPassword, setIsUpdatingPassword] = useState(false) @@ -65,7 +66,11 @@ const ResetDbPassword: FC = ({ disabled = false }) => { setIsUpdatingPassword(true) const res = await patch(`${API_URL}/projects/${ref}/db-password`, { password }) if (!res.error) { - await app.projects.fetchDetail(ref, (project) => meta.setProjectDetails(project)) + const project = await getProjectDetail({ ref }) + if (project) { + meta.setProjectDetails(project) + } + ui.setNotification({ category: 'success', message: res.message }) setShowResetDbPass(false) } else { diff --git a/studio/components/interfaces/Settings/Database/DiskSizeConfiguration.tsx b/studio/components/interfaces/Settings/Database/DiskSizeConfiguration.tsx index e99285afbc8..b7c73169bd3 100644 --- a/studio/components/interfaces/Settings/Database/DiskSizeConfiguration.tsx +++ b/studio/components/interfaces/Settings/Database/DiskSizeConfiguration.tsx @@ -6,7 +6,7 @@ import Panel from 'components/ui/Panel' import { useProjectDiskResizeMutation } from 'data/config/project-disk-resize-mutation' import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query' import { useProjectUsageQuery } from 'data/usage/project-usage-query' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' import Link from 'next/link' import { useEffect, useState } from 'react' import { Alert, Button, Form, InputNumber, Modal } from 'ui' @@ -20,7 +20,7 @@ const DiskSizeConfiguration = ({ disabled = false }: DiskSizeConfigurationProps) const { ui } = useStore() const { ref: projectRef } = useParams() - const canUpdateDiskSizeConfig = checkPermissions(PermissionAction.UPDATE, 'projects') + const canUpdateDiskSizeConfig = useCheckPermissions(PermissionAction.UPDATE, 'projects') const [showResetDbPass, setShowResetDbPass] = useState(false) const [isUpdatingDiskSize, setIsUpdatingDiskSize] = useState(false) diff --git a/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx b/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx index b0391bea62f..16f5414552e 100644 --- a/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx +++ b/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx @@ -4,7 +4,7 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { Button, IconAlertCircle, IconExternalLink, IconGlobe, IconLock } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions } from 'hooks' +import { useCheckPermissions } from 'hooks' import { useParams } from 'common/hooks' import Panel from 'components/ui/Panel' import { FormPanel, FormHeader } from 'components/ui/Forms' @@ -84,7 +84,7 @@ const NetworkRestrictions = ({}) => { const [selectedRestrictionToRemove, setSelectedRestrictionToRemove] = useState() const { data, isLoading } = useNetworkRestrictionsQuery({ projectRef: ref }) - const canUpdateNetworkRestrictions = checkPermissions(PermissionAction.UPDATE, 'projects') + const canUpdateNetworkRestrictions = useCheckPermissions(PermissionAction.UPDATE, 'projects') const hasAccessToRestrictions = data?.entitlement === 'allowed' const restrictedIps = data?.config?.dbAllowedCidrs ?? [] diff --git a/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx b/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx index d43cee3453e..d6dfc2bd7b5 100644 --- a/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx +++ b/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx @@ -1,10 +1,10 @@ import Link from 'next/link' import { useState, useEffect } from 'react' import * as Tooltip from '@radix-ui/react-tooltip' -import { Button, IconDownload, Toggle, IconLoader, Alert } from 'ui' +import { Button, IconDownload, Toggle, IconLoader, Alert, IconExternalLink } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions, useStore, useFlag } from 'hooks' +import { useCheckPermissions, useStore, useFlag } from 'hooks' import { useParams } from 'common/hooks' import { FormHeader, @@ -25,12 +25,16 @@ const SSLConfiguration = () => { const sslEnforcement = useFlag('sslEnforcement') const { data: projectSettings } = useProjectSettingsQuery({ projectRef: ref }) - const { data: sslEnforcementConfiguration, isLoading } = useSSLEnforcementQuery({ + const { + data: sslEnforcementConfiguration, + isLoading, + isSuccess, + } = useSSLEnforcementQuery({ projectRef: ref, }) const { mutateAsync: updateSSLEnforcement } = useSSLEnforcementUpdateMutation() - const canUpdateSSLEnforcement = checkPermissions(PermissionAction.UPDATE, 'projects') + const canUpdateSSLEnforcement = useCheckPermissions(PermissionAction.UPDATE, 'projects') const hasAccessToSSLEnforcement = !sslEnforcementConfiguration?.isNotAllowed const env = process.env.NEXT_PUBLIC_ENVIRONMENT === 'prod' ? 'prod' : 'staging' @@ -73,7 +77,7 @@ const SSLConfiguration = () => {
    - {sslEnforcement && hasAccessToSSLEnforcement && ( + {sslEnforcement && ( {

    Reject non-SSL connections to your database

    - {!sslEnforcementConfiguration?.appliedSuccessfully && ( + {isSuccess && !sslEnforcementConfiguration?.appliedSuccessfully && ( { {(isLoading || isSubmitting) && ( )} - + + + + + {(!canUpdateSSLEnforcement || !hasAccessToSSLEnforcement) && ( + + + +
    + + {!canUpdateSSLEnforcement + ? 'You need additional permissions to update SSL enforcement for your project' + : !hasAccessToSSLEnforcement + ? 'Your project does not have access to SSL enforcement' + : ''} + +
    +
    +
    + )} +
    diff --git a/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx b/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx index e4c254536a5..0f5bbc5360f 100644 --- a/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx +++ b/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx @@ -1,29 +1,25 @@ import Link from 'next/link' -import { observer } from 'mobx-react-lite' -import { IconAlertCircle } from 'ui' -import { useStore } from 'hooks' import { useParams } from 'common/hooks' -import { useProjectApiQuery } from 'data/config/project-api-query' -import { - CustomDomainResponse, - useCustomDomainsQuery, -} from 'data/custom-domains/custom-domains-query' -import Panel from 'components/ui/Panel' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { FormHeader } from 'components/ui/Forms' +import Panel from 'components/ui/Panel' import UpgradeToPro from 'components/ui/UpgradeToPro' +import { useProjectApiQuery } from 'data/config/project-api-query' +import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' +import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants' +import { IconAlertCircle } from 'ui' +import CustomDomainActivate from './CustomDomainActivate' import CustomDomainDelete from './CustomDomainDelete' import CustomDomainVerify from './CustomDomainVerify' -import CustomDomainActivate from './CustomDomainActivate' import CustomDomainsConfigureHostname from './CustomDomainsConfigureHostname' import CustomDomainsShimmerLoader from './CustomDomainsShimmerLoader' -import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants' const CustomDomainConfig = () => { - const { ui } = useStore() + const { project } = useProjectContext() const { ref } = useParams() - const tier = ui.selectedProject?.subscription_tier + const tier = project?.subscription_tier const { isLoading: isSettingsLoading, data: settings } = useProjectApiQuery({ projectRef: ref, }) @@ -103,4 +99,4 @@ const CustomDomainConfig = () => { ) } -export default observer(CustomDomainConfig) +export default CustomDomainConfig diff --git a/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainsConfigureHostname.tsx b/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainsConfigureHostname.tsx index 4a3349638cc..75c54228551 100644 --- a/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainsConfigureHostname.tsx +++ b/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainsConfigureHostname.tsx @@ -4,7 +4,7 @@ import { observer } from 'mobx-react-lite' import { Button, Form, IconExternalLink, Input } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' import { useParams } from 'common/hooks' import { FormActions, @@ -28,7 +28,7 @@ const CustomDomainsConfigureHostname = () => { const FORM_ID = 'custom-domains-form' const endpoint = settings?.autoApiService.endpoint - const canConfigureCustomDomain = checkPermissions(PermissionAction.UPDATE, 'projects') + const canConfigureCustomDomain = useCheckPermissions(PermissionAction.UPDATE, 'projects') const verifyCNAME = async (domain: string): Promise => { const res = await fetch(`https://1.1.1.1/dns-query?name=${domain}`, { diff --git a/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx b/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx index b1fe6d9553f..915c12b0dc5 100644 --- a/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx +++ b/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx @@ -1,24 +1,28 @@ -import { FC, useEffect, useState } from 'react' -import { useRouter } from 'next/router' -import { Button, Input } from 'ui' import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import { Button, Input } from 'ui' -import { useStore, checkPermissions } from 'hooks' -import { API_URL, PRICING_TIER_PRODUCT_IDS } from 'lib/constants' -import { delete_, post } from 'lib/common/fetch' -import TextConfirmModal from 'components/ui/Modals/TextConfirmModal' import { CANCELLATION_REASONS } from 'components/interfaces/BillingV2/Billing.constants' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import TextConfirmModal from 'components/ui/Modals/TextConfirmModal' +import { invalidateProjectsQuery } from 'data/projects/projects-query' +import { useCheckPermissions, useStore } from 'hooks' +import { delete_, post } from 'lib/common/fetch' +import { API_URL, PRICING_TIER_PRODUCT_IDS } from 'lib/constants' -interface Props { +export interface DeleteProjectButtonProps { type?: 'danger' | 'default' } -const DeleteProjectButton: FC = ({ type = 'danger' }) => { +const DeleteProjectButton = ({ type = 'danger' }: DeleteProjectButtonProps) => { + const queryClient = useQueryClient() const router = useRouter() - const { app, ui } = useStore() + const { ui } = useStore() - const project = ui.selectedProject + const { project } = useProjectContext() const projectRef = project?.ref const projectTier = project?.subscription_tier ?? PRICING_TIER_PRODUCT_IDS.FREE const isFree = projectTier === PRICING_TIER_PRODUCT_IDS.FREE @@ -35,7 +39,7 @@ const DeleteProjectButton: FC = ({ type = 'danger' }) => { } }, [isOpen]) - const canDeleteProject = checkPermissions(PermissionAction.UPDATE, 'projects') + const canDeleteProject = useCheckPermissions(PermissionAction.UPDATE, 'projects') const toggle = () => { if (loading) return @@ -66,7 +70,7 @@ const DeleteProjectButton: FC = ({ type = 'danger' }) => { try { const response = await delete_(`${API_URL}/projects/${projectRef}`) if (response.error) throw response.error - app.onProjectDeleted(response) + await invalidateProjectsQuery(queryClient) ui.setNotification({ category: 'success', message: `Successfully deleted ${project.name}` }) router.push(`/projects`) } catch (error: any) { diff --git a/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectPanel.tsx b/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectPanel.tsx index 4c8c7267fdb..b4afefdac56 100644 --- a/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectPanel.tsx +++ b/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectPanel.tsx @@ -1,19 +1,13 @@ -import { FC } from 'react' -import { Alert } from 'ui' - -import { useStore } from 'hooks' -import Panel from 'components/ui/Panel' - -import DeleteProjectButton from './DeleteProjectButton' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { FormHeader } from 'components/ui/Forms' +import Panel from 'components/ui/Panel' +import { Alert } from 'ui' +import DeleteProjectButton from './DeleteProjectButton' -interface Props {} +const DeleteProjectPanel = () => { + const { project } = useProjectContext() -const DeleteProjectPanel: FC = ({}) => { - const { ui } = useStore() - const project = ui.selectedProject - - if (project === undefined) return <> + if (project === undefined) return null return (
    diff --git a/studio/components/interfaces/Settings/General/General.tsx b/studio/components/interfaces/Settings/General/General.tsx index 2fce9ce289b..9fcbdac5795 100644 --- a/studio/components/interfaces/Settings/General/General.tsx +++ b/studio/components/interfaces/Settings/General/General.tsx @@ -1,28 +1,29 @@ -import { FC } from 'react' -import { Form, Input } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' +import { Form, Input } from 'ui' -import { useStore, checkPermissions } from 'hooks' -import { API_URL } from 'lib/constants' -import { patch } from 'lib/common/fetch' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { + FormActions, FormHeader, FormPanel, - FormActions, FormSection, - FormSectionLabel, FormSectionContent, + FormSectionLabel, } from 'components/ui/Forms' +import { invalidateProjectsQuery } from 'data/projects/projects-query' +import { useCheckPermissions, useStore } from 'hooks' +import { patch } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' -interface Props {} - -const General: FC = ({}) => { - const { app, ui } = useStore() - const project = ui.selectedProject +const General = () => { + const queryClient = useQueryClient() + const { ui } = useStore() + const { project } = useProjectContext() const formId = 'project-general-settings' const initialValues = { name: project?.name ?? '', ref: project?.ref ?? '' } - const canUpdateProject = checkPermissions(PermissionAction.UPDATE, 'projects') + const canUpdateProject = useCheckPermissions(PermissionAction.UPDATE, 'projects') const onSubmit = async (values: any, { resetForm }: any) => { const response = await patch(`${API_URL}/projects/${project?.ref}`, { @@ -37,7 +38,7 @@ const General: FC = ({}) => { const { name } = response resetForm({ values: { name }, initialValues: { name } }) - app.onProjectUpdated(response) + await invalidateProjectsQuery(queryClient) ui.setNotification({ category: 'success', message: 'Successfully saved settings', diff --git a/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx b/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx index 449c6f1fb13..fdd25f07510 100644 --- a/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx +++ b/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx @@ -1,14 +1,17 @@ -import { FC, useState } from 'react' -import { useRouter } from 'next/router' -import { observer } from 'mobx-react-lite' import * as Tooltip from '@radix-ui/react-tooltip' -import { Button, IconPause } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' +import { observer } from 'mobx-react-lite' +import { useRouter } from 'next/router' +import { FC, useState } from 'react' -import { checkPermissions, useStore } from 'hooks' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog' +import { setProjectStatus } from 'data/projects/projects-query' +import { useCheckPermissions, useStore } from 'hooks' import { post } from 'lib/common/fetch' import { API_URL, PROJECT_STATUS } from 'lib/constants' -import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog' +import { Button, IconPause } from 'ui' interface Props { projectId: number @@ -16,7 +19,9 @@ interface Props { } const PauseProjectButton: FC = observer(({ projectRef, projectId }) => { - const { ui, app } = useStore() + const queryClient = useQueryClient() + const { ui } = useStore() + const { project } = useProjectContext() const router = useRouter() const [loading, setLoading] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false) @@ -24,8 +29,8 @@ const PauseProjectButton: FC = observer(({ projectRef, projectId }) => { const openModal = () => setIsModalOpen(true) const closeModal = () => setIsModalOpen(false) - const isPaused = ui?.selectedProject?.status === PROJECT_STATUS.INACTIVE - const canPauseProject = checkPermissions( + const isPaused = project?.status === PROJECT_STATUS.INACTIVE + const canPauseProject = useCheckPermissions( PermissionAction.INFRA_EXECUTE, 'queue_jobs.projects.pause' ) @@ -49,8 +54,7 @@ const PauseProjectButton: FC = observer(({ projectRef, projectId }) => { }) setLoading(false) } else { - app.onProjectPaused(projectId) - app.onProjectStatusUpdated(projectId, PROJECT_STATUS.GOING_DOWN) + setProjectStatus(queryClient, projectRef, PROJECT_STATUS.PAUSING) ui.setNotification({ category: 'success', message: 'Pausing project' }) router.push(`/project/${projectRef}`) diff --git a/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx b/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx index 0af657cae0e..b4ba127f6c1 100644 --- a/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx +++ b/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx @@ -1,30 +1,31 @@ +import { useQueryClient } from '@tanstack/react-query' import Link from 'next/link' import { useRouter } from 'next/router' -import { FC, useState } from 'react' +import { useState } from 'react' import { Alert, Button, Form, - Modal, - IconPackage, - Listbox, IconAlertCircle, IconExternalLink, + IconPackage, + Listbox, + Modal, } from 'ui' -import { useStore } from 'hooks' import { useParams } from 'common/hooks' +import InformationBox from 'components/ui/InformationBox' +import { useProjectUpgradeEligibilityQuery } from 'data/config/project-upgrade-eligibility-query' +import { setProjectStatus } from 'data/projects/projects-query' +import { useStore } from 'hooks' import { post } from 'lib/common/fetch' import { API_ADMIN_URL, PROJECT_STATUS } from 'lib/constants' -import InformationBox from 'components/ui/InformationBox' import { BREAKING_CHANGES } from './ProjectUpgradeAlert.constants' -import { useProjectUpgradeEligibilityQuery } from 'data/config/project-upgrade-eligibility-query' -interface Props {} - -const ProjectUpgradeAlert: FC = ({}) => { +const ProjectUpgradeAlert = () => { + const queryClient = useQueryClient() const router = useRouter() - const { app, ui } = useStore() + const { ui } = useStore() const { ref } = useParams() const [showUpgradeModal, setShowUpgradeModal] = useState(false) @@ -38,6 +39,14 @@ const ProjectUpgradeAlert: FC = ({}) => { const onConfirmUpgrade = async (values: any, { setSubmitting }: any) => { setSubmitting(true) + if (!ref) { + ui.setNotification({ + category: 'error', + message: 'Project ref not found', + }) + return + } + const res = await post(`${API_ADMIN_URL}/projects/${ref}/upgrade`, { target_version: values.version, }) @@ -49,12 +58,9 @@ const ProjectUpgradeAlert: FC = ({}) => { }) setSubmitting(false) } else { - const projectId = ui.selectedProject?.id - if (projectId !== undefined) { - app.onProjectStatusUpdated(projectId, PROJECT_STATUS.UPGRADING) - ui.setNotification({ category: 'success', message: 'Upgrading project' }) - router.push(`/project/${ref}?upgradeInitiated=true`) - } + setProjectStatus(queryClient, ref, PROJECT_STATUS.UPGRADING) + ui.setNotification({ category: 'success', message: 'Upgrading project' }) + router.push(`/project/${ref}?upgradeInitiated=true`) } } diff --git a/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx b/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx index 8099afc7b63..2435d800eea 100644 --- a/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx +++ b/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx @@ -1,31 +1,33 @@ -import { FC, useState } from 'react' -import { useRouter } from 'next/router' -import { observer } from 'mobx-react-lite' import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' +import { observer } from 'mobx-react-lite' +import { useRouter } from 'next/router' +import { useState } from 'react' import { Button, Dropdown, IconChevronDown } from 'ui' -import { Project } from 'types' -import { useStore, checkPermissions } from 'hooks' +import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog' +import { setProjectPostgrestStatus } from 'data/projects/projects-query' +import { useCheckPermissions, useStore } from 'hooks' import { post } from 'lib/common/fetch' import { API_URL } from 'lib/constants' -import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog' +import { Project } from 'types' -interface Props { +export interface RestartServerButtonProps { project: Project } -const RestartServerButton: FC = ({ project }) => { - const { ui, app } = useStore() +const RestartServerButton = ({ project }: RestartServerButtonProps) => { + const queryClient = useQueryClient() + const { ui } = useStore() const router = useRouter() const [loading, setLoading] = useState(false) const [serviceToRestart, setServiceToRestart] = useState<'project' | 'database'>() - const projectId = project.id const projectRef = project.ref const projectRegion = project.region - const canRestartProject = checkPermissions(PermissionAction.INFRA_EXECUTE, 'reboot') + const canRestartProject = useCheckPermissions(PermissionAction.INFRA_EXECUTE, 'reboot') const requestProjectRestart = async () => { if (!canRestartProject) { @@ -69,7 +71,7 @@ const RestartServerButton: FC = ({ project }) => { } const onRestartSuccess = () => { - app.onProjectPostgrestStatusUpdated(projectId, 'OFFLINE') + setProjectPostgrestStatus(queryClient, projectRef, 'OFFLINE') ui.setNotification({ category: 'success', message: 'Restarting server' }) router.push(`/project/${projectRef}`) setLoading(false) diff --git a/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx b/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx new file mode 100644 index 00000000000..073260b00d0 --- /dev/null +++ b/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx @@ -0,0 +1,358 @@ +import { FC, useEffect, useMemo, useState } from 'react' +import { + Alert, + Button, + IconAlertCircle, + IconLoader, + IconShield, + IconTool, + IconUsers, + Listbox, + Loading, + Modal, +} from 'ui' +import * as Tooltip from '@radix-ui/react-tooltip' +import { PermissionAction } from '@supabase/shared-types/out/constants' + +import { useStore, useSelectedProject, useCheckPermissions } from 'hooks' +import { useProjectTransferPreviewQuery } from 'data/projects/project-transfer-preview-query' +import { useProjectTransferMutation } from 'data/projects/project-transfer-mutation' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import Link from 'next/link' + +const TransferProjectButton: FC<{}> = () => { + const { ui } = useStore() + + const project = useSelectedProject() + const projectRef = project?.ref + const projectOrgId = project?.organization_id + const { data: allOrganizations } = useOrganizationsQuery() + + const organizations = (allOrganizations || []) + .filter((it) => it.id !== projectOrgId) + // Only orgs with org-level subscription + .filter((it) => it.subscription_id) + + const [isOpen, setIsOpen] = useState(false) + const [isLoading, setIsLoading] = useState(false) + + const [selectedOrg, setSelectedOrg] = useState() + + const { error: transferError, mutateAsync: transferProject } = useProjectTransferMutation() + + const { + data: transferPreviewData, + error: transferPreviewError, + isLoading: transferPreviewIsLoading, + remove, + } = useProjectTransferPreviewQuery( + { projectRef, targetOrganizationSlug: selectedOrg }, + { enabled: !isLoading && isOpen } + ) + + useEffect(() => { + if (isOpen) { + // reset state + setSelectedOrg(undefined) + setIsLoading(false) + } else { + // Invalidate cache + remove() + } + }, [isOpen]) + + const canTransferProject = useCheckPermissions(PermissionAction.UPDATE, 'projects') + + const toggle = () => { + setIsOpen(!isOpen) + } + + async function handleTransferProject() { + if (project === undefined) return + if (selectedOrg === undefined) return + + setIsLoading(true) + + try { + await transferProject({ projectRef, targetOrganizationSlug: selectedOrg }) + ui.setNotification({ + category: 'success', + + duration: 5000, + message: `Successfully transferred project ${project?.name}.`, + }) + setIsOpen(false) + } catch { + } finally { + setIsLoading(false) + } + } + + return ( + <> + + + + + {!canTransferProject && ( + + + +
    + + You need additional permissions to delete this project + +
    +
    +
    + )} +
    + + toggle()} + visible={isOpen} + loading={isLoading} + size={'xlarge'} + header={`Transfer project ${project?.name}`} + customFooter={ +
    + + +
    + } + > +
    + +

    + To transfer projects, the owner must be a member of both the source and target + organizations. +

    + +

    Transferring considerations:

    + +
      +
    • + + + +
      +

      No downtime

      +

      There is no downtime or restrictions involved when transferring a project.

      +
      +
    • + +
    • + + + +
      +

      Permissions

      +

      + Depending on your role in the target organization, your level of permissions may + change after transfer. +

      +
      +
    • + +
    • + + + +
      +

      Features

      +

      + Moving your project to an organization with a smaller subscription plan may + result in the loss of certain features (i.e. image transformations). +

      +
      +
    • +
    +
    + + {organizations && ( +
    + {organizations.length === 0 ? ( +
    + You do not have any organizations with an organization-level + subscription. +
    + ) : ( + setSelectedOrg(slug)} + placeholder="Select Organization" + > + + Select Organization + + {organizations.map((x: any) => ( + } + > + {x.name} + + ))} + + )} + +

    + The target organization needs to use{' '} + + + organization-level-billing + + + . To migrate an organization to the new billing, head to your{' '} + + + organizations billing settings + + + . +

    +
    + )} +
    + + + +
    + {transferPreviewData && transferPreviewData.valid && ( +
    + {transferPreviewData.source_subscription_plan !== + transferPreviewData.target_subscription_plan ? ( +
    +

    + Your project is currently on the{' '} + {transferPreviewData.source_subscription_plan} plan, whereas the target + organization uses the {transferPreviewData.target_subscription_plan} plan. +

    +
    + ) : ( +
    + Your project and the target organization are both on the{' '} + {transferPreviewData.source_subscription_plan} subscription plan. +
    + )} + +
    + {transferPreviewData.credits_on_source_organization === 0 ? ( + + {' '} + Your current organization won't be granted any prorated credits. + + ) : ( + + {' '} + Your current organization will be granted{' '} + + ${transferPreviewData.credits_on_source_organization} + {' '} + in credits as proration. + + )} + {transferPreviewData.costs_on_target_organization === 0 ? ( + + {' '} + The target organization won't be charged any immediate upfront payment. + + ) : ( + + {' '} + The target organization will be billed{' '} + + ${transferPreviewData.costs_on_target_organization} + {' '} + immediately to prorate for the remainder of the billing period. + + )} +
    +
    + )} + {transferPreviewData && transferPreviewData.warnings.length > 0 && ( + +
    + {transferPreviewData.warnings.map((warning) => ( +

    {warning.message}

    + ))} +
    +
    + )} + {transferPreviewData && transferPreviewData.errors.length > 0 && ( + +
    + {transferPreviewData.errors.map((error) => ( +

    {error.message}

    + ))} +
    + {transferPreviewData.members_exceeding_free_project_limit.length > 0 && ( +
    +

    + These members have reached their maximum limits for the number of active + Free plan projects within organizations where they are an administrator or + owner: +

    +
      + {(transferPreviewData.members_exceeding_free_project_limit || []).map( + (member, idx: number) => ( +
    • + {member.name} (Limit: {member.limit} free projects) +
    • + ) + )} +
    +

    + These members will need to either delete, pause, or upgrade one or more of + their projects before you can downgrade this project. +

    +
    + )} +
    + )} + {transferPreviewError && !transferError && ( + +

    {transferPreviewError.message}

    +
    + )} + {transferError && ( + +

    {transferError.message}

    +
    + )} +
    +
    +
    +
    +
    + + ) +} + +export default TransferProjectButton diff --git a/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectPanel.tsx b/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectPanel.tsx new file mode 100644 index 00000000000..ff8ce40428d --- /dev/null +++ b/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectPanel.tsx @@ -0,0 +1,46 @@ +import { FC } from 'react' + +import { useSelectedProject } from 'hooks' +import Panel from 'components/ui/Panel' + +import TransferProjectButton from './TransferProjectButton' +import { FormHeader } from 'components/ui/Forms' +import { IconTruck } from 'ui' + +interface Props {} + +const TransferProjectPanel: FC = ({}) => { + const project = useSelectedProject() + + if (project === undefined) return <> + + return ( +
    + + + +
    +
    + +
    +

    Transfer project to another organization

    +

    + To transfer projects, the owner must be a member of both the source and target + organizations. +

    +
    +
    +
    + +
    +
    +
    +
    +
    + ) +} + +export default TransferProjectPanel diff --git a/studio/components/interfaces/Settings/General/index.ts b/studio/components/interfaces/Settings/General/index.ts index 8ff5b36ea73..ecd3345f01c 100644 --- a/studio/components/interfaces/Settings/General/index.ts +++ b/studio/components/interfaces/Settings/General/index.ts @@ -3,5 +3,15 @@ import Infrastructure from './Infrastructure/Infrastructure' import CustomDomainConfig from './CustomDomainConfig/CustomDomainConfig' import DeleteProjectPanel from './DeleteProjectPanel/DeleteProjectPanel' import DeleteProjectButton from './DeleteProjectPanel/DeleteProjectButton' +import TransferProjectButton from './TransferProjectPanel/TransferProjectButton' +import TransferProjectPanel from './TransferProjectPanel/TransferProjectPanel' -export { General, Infrastructure, CustomDomainConfig, DeleteProjectPanel, DeleteProjectButton } +export { + General, + Infrastructure, + CustomDomainConfig, + DeleteProjectPanel, + DeleteProjectButton, + TransferProjectPanel, + TransferProjectButton, +} diff --git a/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/studio/components/interfaces/Settings/Logs/Logs.utils.ts index 006f78c3ff9..f8ff4d249a1 100644 --- a/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -234,7 +234,7 @@ export const genChartQuery = ( const where = _genWhereStatement(table, filters) let joins = 'cross join unnest(t.metadata) as metadata' - if (table === LogsTableName.EDGE) { + if (table === LogsTableName.EDGE || table === LogsTableName.FN_EDGE) { joins += ' \n cross join unnest(metadata.request) as request' joins += ' \n cross join unnest(metadata.response) as response' } else if (table === LogsTableName.POSTGRES) { diff --git a/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx b/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx index 3f20bd4a7c5..8687e36e43e 100644 --- a/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx +++ b/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx @@ -1,20 +1,20 @@ -import { Button, Dropdown, IconChevronDown, IconPlay, Badge, Popover, Alert } from 'ui' import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { Alert, Badge, Button, Dropdown, IconChevronDown, IconPlay, Popover } from 'ui' -import { - EXPLORER_DATEPICKER_HELPERS, - LogsTableName, - LogsWarning, - LOGS_SOURCE_DESCRIPTION, - LogTemplate, -} from '.' -import DatePickers from './Logs.DatePickers' +import { useCheckPermissions } from 'hooks' +import { IS_PLATFORM } from 'lib/constants' +import { useProfile } from 'lib/profile' import Link from 'next/link' import React from 'react' -import { checkPermissions } from 'hooks' -import { IS_PLATFORM } from 'lib/constants' -import { useProfileQuery } from 'data/profile/profile-query' +import { + EXPLORER_DATEPICKER_HELPERS, + LOGS_SOURCE_DESCRIPTION, + LogTemplate, + LogsTableName, + LogsWarning, +} from '.' +import DatePickers from './Logs.DatePickers' export interface LogsQueryPanelProps { templates?: LogTemplate[] @@ -45,8 +45,8 @@ const LogsQueryPanel = ({ onDateChange, warnings, }: LogsQueryPanelProps) => { - const { data: profile } = useProfileQuery() - const canCreateLogQuery = checkPermissions(PermissionAction.CREATE, 'user_content', { + const { profile } = useProfile() + const canCreateLogQuery = useCheckPermissions(PermissionAction.CREATE, 'user_content', { resource: { type: 'log_sql', owner_id: profile?.id }, subject: { id: profile?.id }, }) diff --git a/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeysManagement.tsx b/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeysManagement.tsx index 01e4a6d527f..1d743178e1c 100644 --- a/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeysManagement.tsx +++ b/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeysManagement.tsx @@ -18,7 +18,7 @@ import { IconExternalLink, } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useStore, checkPermissions } from 'hooks' +import { useStore, useCheckPermissions } from 'hooks' import { useParams } from 'common/hooks' import Divider from 'components/ui/Divider' @@ -36,7 +36,7 @@ const EncryptionKeysManagement: FC = ({}) => { const [selectedKeyToRemove, setSelectedKeyToRemove] = useState() const [isDeletingKey, setIsDeletingKey] = useState(false) - const canManageKeys = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') + const canManageKeys = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') useEffect(() => { if (id !== undefined) setSearchValue(id) diff --git a/studio/components/interfaces/Settings/Vault/Secrets/SecretRow.tsx b/studio/components/interfaces/Settings/Vault/Secrets/SecretRow.tsx index a4b2cf56e7b..c0a9419c7e8 100644 --- a/studio/components/interfaces/Settings/Vault/Secrets/SecretRow.tsx +++ b/studio/components/interfaces/Settings/Vault/Secrets/SecretRow.tsx @@ -16,7 +16,7 @@ import { import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' import { VaultSecret } from 'types' -import { useStore, checkPermissions } from 'hooks' +import { useStore, useCheckPermissions } from 'hooks' import { useParams } from 'common/hooks' interface Props { @@ -33,7 +33,7 @@ const SecretRow: FC = ({ secret, onSelectEdit, onSelectRemove }) => { const [revealedValue, setRevealedValue] = useState() const name = secret?.name ?? 'No name provided' - const canManageSecrets = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') + const canManageSecrets = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') const revealSecret = async () => { setIsLoading(true) diff --git a/studio/components/interfaces/Settings/Vault/Secrets/SecretsManagement.tsx b/studio/components/interfaces/Settings/Vault/Secrets/SecretsManagement.tsx index e5dfe455fe0..88954ad8984 100644 --- a/studio/components/interfaces/Settings/Vault/Secrets/SecretsManagement.tsx +++ b/studio/components/interfaces/Settings/Vault/Secrets/SecretsManagement.tsx @@ -5,7 +5,7 @@ import { FC, Fragment, useState, useEffect } from 'react' import { IconSearch, Input, Button, Listbox, IconLoader, IconExternalLink, IconX } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useStore, checkPermissions } from 'hooks' +import { useStore, useCheckPermissions } from 'hooks' import { useParams } from 'common/hooks' import SecretRow from './SecretRow' import EditSecretModal from './EditSecretModal' @@ -25,7 +25,7 @@ const SecretsManagement: FC = ({}) => { const [selectedSecretToEdit, setSelectedSecretToEdit] = useState() const [selectedSecretToRemove, setSelectedSecretToRemove] = useState() - const canManageSecrets = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') + const canManageSecrets = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') useEffect(() => { if (search !== undefined) setSearchValue(search) diff --git a/studio/components/interfaces/Settings/Vault/VaultToggle.tsx b/studio/components/interfaces/Settings/Vault/VaultToggle.tsx index f8a187ecc90..e96511cf63c 100644 --- a/studio/components/interfaces/Settings/Vault/VaultToggle.tsx +++ b/studio/components/interfaces/Settings/Vault/VaultToggle.tsx @@ -5,7 +5,7 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { Button, IconExternalLink } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' import { useParams } from 'common/hooks' import { BASE_PATH } from 'lib/constants' import { useTheme } from 'common' @@ -17,7 +17,7 @@ const VaultToggle: FC = () => { const { ref } = useParams() const { isDarkMode } = useTheme() const [isEnabling, setIsEnabling] = useState(false) - const canToggleVault = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'extensions') + const canToggleVault = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'extensions') const vaultExtension = meta.extensions.byId('supabase_vault') const isNotAvailable = vaultExtension === undefined diff --git a/studio/components/interfaces/SignIn/SignInForm.tsx b/studio/components/interfaces/SignIn/SignInForm.tsx index d9adb6e4b6a..cd8be134729 100644 --- a/studio/components/interfaces/SignIn/SignInForm.tsx +++ b/studio/components/interfaces/SignIn/SignInForm.tsx @@ -3,10 +3,12 @@ import { useQueryClient } from '@tanstack/react-query' import { useStore } from 'hooks' import { usePushNext } from 'hooks/misc/useAutoAuthRedirect' import { auth } from 'lib/gotrue' +import { incrementSignInClicks } from 'lib/local-storage' import Link from 'next/link' import { useRef, useState } from 'react' import { Button, Form, Input } from 'ui' import { object, string } from 'yup' +import * as Sentry from '@sentry/nextjs' const signInSchema = object({ email: string().email('Must be a valid email').required('Email is required'), @@ -33,6 +35,11 @@ const SignInForm = () => { token = captchaResponse?.response ?? null } + const signInClicks = incrementSignInClicks() + if (signInClicks > 1) { + Sentry.captureMessage('Sign in without previous sign out detected') + } + const { error } = await auth.signInWithPassword({ email, password, diff --git a/studio/components/interfaces/SignIn/SignInWithGitHub.tsx b/studio/components/interfaces/SignIn/SignInWithGitHub.tsx index 091b2ee750c..d6cad1da894 100644 --- a/studio/components/interfaces/SignIn/SignInWithGitHub.tsx +++ b/studio/components/interfaces/SignIn/SignInWithGitHub.tsx @@ -1,10 +1,22 @@ +import { useState } from 'react' import { BASE_PATH } from 'lib/constants' import { auth, getReturnToPath } from 'lib/gotrue' +import { incrementSignInClicks } from 'lib/local-storage' import { Button, IconGitHub } from 'ui' +import * as Sentry from '@sentry/nextjs' const SignInWithGitHub = () => { + const [loading, setLoading] = useState(false) + async function handleGithubSignIn() { + setLoading(true) + try { + const signInClicks = incrementSignInClicks() + if (signInClicks > 1) { + Sentry.captureMessage('Sign in without previous sign out detected') + } + const { error } = await auth.signInWithOAuth({ provider: 'github', options: { @@ -18,6 +30,8 @@ const SignInWithGitHub = () => { if (error) throw error } catch (error) { console.error(error) + } finally { + setLoading(false) } } @@ -28,6 +42,7 @@ const SignInWithGitHub = () => { icon={} size="large" type="default" + loading={loading} > Continue with GitHub diff --git a/studio/components/interfaces/SignIn/SignUpForm.tsx b/studio/components/interfaces/SignIn/SignUpForm.tsx index b84ae70b19e..b206e80e8a6 100644 --- a/studio/components/interfaces/SignIn/SignUpForm.tsx +++ b/studio/components/interfaces/SignIn/SignUpForm.tsx @@ -7,6 +7,7 @@ import { useStore } from 'hooks' import { post } from 'lib/common/fetch' import { API_URL, BASE_PATH } from 'lib/constants' import { passwordSchema } from 'lib/schemas' +import { resetSignInClicks } from 'lib/local-storage' import PasswordConditionsHelper from './PasswordConditionsHelper' const signUpSchema = passwordSchema.shape({ @@ -33,6 +34,8 @@ const SignUpForm = () => { token = captchaResponse?.response ?? null } + resetSignInClicks() + const response = await post(`${API_URL}/signup`, { email, password, diff --git a/studio/components/interfaces/Support/SupportForm.tsx b/studio/components/interfaces/Support/SupportForm.tsx index 9337a56cc63..3bec0ff1722 100644 --- a/studio/components/interfaces/Support/SupportForm.tsx +++ b/studio/components/interfaces/Support/SupportForm.tsx @@ -1,47 +1,49 @@ -import Link from 'next/link' -import { useRouter } from 'next/router' -import { useEffect, useState, FC, ChangeEvent, useRef } from 'react' +import { CLIENT_LIBRARIES } from 'common/constants' import { observer } from 'mobx-react-lite' +import Link from 'next/link' +import { ChangeEvent, useEffect, useRef, useState } from 'react' import { Button, + Checkbox, + Form, + IconAlertCircle, + IconExternalLink, + IconLoader, IconMail, IconPlus, IconX, Input, Listbox, - Form, - IconLoader, - IconAlertCircle, - IconExternalLink, - Checkbox, } from 'ui' -import { CLIENT_LIBRARIES } from 'common/constants' - -import { Project } from 'types' -import { useStore, useFlag } from 'hooks' -import { post, get } from 'lib/common/fetch' -import { detectBrowser } from 'lib/helpers' -import { API_URL, PRICING_TIER_PRODUCT_IDS } from 'lib/constants' +import { useParams } from 'common' import Divider from 'components/ui/Divider' +import InformationBox from 'components/ui/InformationBox' import Connecting from 'components/ui/Loading' import MultiSelect from 'components/ui/MultiSelect' -import InformationBox from 'components/ui/InformationBox' -import { formatMessage, uploadAttachments } from './SupportForm.utils' -import { CATEGORY_OPTIONS, SEVERITY_OPTIONS, SERVICE_OPTIONS } from './Support.constants' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useProjectsQuery } from 'data/projects/projects-query' +import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query' +import { useFlag, useStore } from 'hooks' +import useLatest from 'hooks/misc/useLatest' +import { get, post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { detectBrowser } from 'lib/helpers' +import { useProfile } from 'lib/profile' +import { Project } from 'types' import DisabledStateForFreeTier from './DisabledStateForFreeTier' -import { useProfileQuery } from 'data/profile/profile-query' +import { CATEGORY_OPTIONS, SERVICE_OPTIONS, SEVERITY_OPTIONS } from './Support.constants' +import { formatMessage, uploadAttachments } from './SupportForm.utils' const MAX_ATTACHMENTS = 5 -interface Props { +export interface SupportFormProps { setSentCategory: (value: string) => void } -const SupportForm: FC = ({ setSentCategory }) => { - const { ui, app } = useStore() - const router = useRouter() - const { ref, subject, category, message } = router.query +const SupportForm = ({ setSentCategory }: SupportFormProps) => { + const { ui } = useStore() + const { ref, subject, category, message } = useParams() const uploadButtonRef = useRef() const enableFreeSupport = useFlag('enableFreeSupport') @@ -49,22 +51,19 @@ const SupportForm: FC = ({ setSentCategory }) => { const [uploadedDataUrls, setUploadedDataUrls] = useState([]) const [selectedServices, setSelectedServices] = useState([]) - // Get all orgs and projects from global store - const sortedOrganizations = app.organizations.list() - const sortedProjects = app.projects.list() + const { data: organizations, isSuccess: isOrganizationsSuccess } = useOrganizationsQuery() + // for use in useEffect + const organizationsRef = useLatest(organizations) - const isInitialized = app.projects.isInitialized + const { data: allProjects, isSuccess: isProjectsSuccess } = useProjectsQuery() + const { data: subscription, isLoading: isLoadingSubscription } = useProjectSubscriptionV2Query({ + projectRef: ref, + }) + + const isInitialized = isOrganizationsSuccess && isProjectsSuccess const projectDefaults: Partial[] = [{ ref: 'no-project', name: 'No specific project' }] - const projects = [...sortedProjects, ...projectDefaults] - - const planNames = { - [PRICING_TIER_PRODUCT_IDS.FREE]: 'Free', - [PRICING_TIER_PRODUCT_IDS.PRO]: 'Pro', - [PRICING_TIER_PRODUCT_IDS.PAYG]: 'Pro', - [PRICING_TIER_PRODUCT_IDS.TEAM]: 'Team', - [PRICING_TIER_PRODUCT_IDS.ENTERPRISE]: 'Enterprise', - } + const projects = [...(allProjects ?? []), ...projectDefaults] useEffect(() => { if (!uploadedFiles) return @@ -76,7 +75,7 @@ const SupportForm: FC = ({ setSentCategory }) => { } }, [uploadedFiles]) - const { data: profile } = useProfileQuery() + const { profile } = useProfile() const respondToEmail = profile?.primary_email ?? 'your email' if (!isInitialized) { @@ -87,7 +86,7 @@ const SupportForm: FC = ({ setSentCategory }) => { ) } - const selectedProject = sortedProjects.find((project) => project.ref === ref) + const selectedProject = projects.find((project) => project.ref === ref) const selectedCategory = CATEGORY_OPTIONS.find((option) => { if (option.value.toLowerCase() === ((category as string) ?? '').toLowerCase()) return option }) @@ -95,16 +94,16 @@ const SupportForm: FC = ({ setSentCategory }) => { const initialProjectRef = selectedProject !== undefined ? selectedProject.ref - : sortedProjects.length > 0 - ? sortedProjects[0].ref + : projects.length > 0 + ? projects[0].ref : 'no-project' const initialOrganizationSlug = initialProjectRef !== 'no-project' - ? sortedOrganizations.find((org) => { - const project = sortedProjects.find((project) => project.ref === initialProjectRef) + ? organizations?.find((org) => { + const project = projects.find((project) => project.ref === initialProjectRef) return org.id === project?.organization_id })?.slug - : sortedOrganizations[0]?.slug + : organizations?.[0]?.slug const initialValues = { category: selectedCategory !== undefined ? selectedCategory.value : CATEGORY_OPTIONS[0].value, severity: 'Low', @@ -209,9 +208,7 @@ const SupportForm: FC = ({ setSentCategory }) => { ) const selectedProject = projects.find((project) => project.ref === values.projectRef) - const isFreeProject = - (selectedProject?.subscription_tier ?? PRICING_TIER_PRODUCT_IDS.FREE) === - PRICING_TIER_PRODUCT_IDS.FREE + const isFreeProject = (subscription?.plan.id ?? 'free') === 'free' const isDisabled = !enableFreeSupport && isFreeProject && @@ -222,13 +219,13 @@ const SupportForm: FC = ({ setSentCategory }) => { // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { if (values.projectRef === 'no-project') { - const updatedValues = { ...values, organizationSlug: sortedOrganizations[0]?.slug } + const updatedValues = { + ...values, + organizationSlug: organizationsRef.current?.[0]?.slug, + } resetForm({ values: updatedValues, initialValues: updatedValues }) } else if (selectedProject) { - if (!selectedProject.subscription_tier) { - app.projects.fetchSubscriptionTier(selectedProject as Project) - } - const organization = sortedOrganizations.find( + const organization = organizationsRef.current?.find( (org) => org.id === selectedProject.organization_id ) if (organization) { @@ -269,9 +266,7 @@ const SupportForm: FC = ({ setSentCategory }) => {
    {projects.map((option) => { - const organization = sortedOrganizations.find( - (x) => x.id === option.organization_id - ) + const organization = organizations?.find((x) => x.id === option.organization_id) return ( = ({ setSentCategory }) => { })}
    - {selectedProject?.subscription_tier ? ( + {subscription ? (

    This project is on the{' '} - - {planNames[selectedProject?.subscription_tier]} plan - + {subscription.plan.name} plan

    - ) : selectedProject?.ref !== 'no-project' ? ( + ) : isLoadingSubscription ? (

    Checking project's plan

    @@ -317,14 +310,14 @@ const SupportForm: FC = ({ setSentCategory }) => { )}
    - {values.projectRef === 'no-project' && sortedOrganizations.length > 0 && ( + {values.projectRef === 'no-project' && (organizations?.length ?? 0) > 0 && (
    - {sortedOrganizations.map((option) => { + {organizations?.map((option) => { return ( = ({ setSentCategory }) => {
    )} - {selectedProject?.subscription_tier === PRICING_TIER_PRODUCT_IDS.FREE && ( + {subscription?.plan.id === 'free' && (
    } @@ -353,7 +346,9 @@ const SupportForm: FC = ({ setSentCategory }) => { Enhanced SLAs for support are available on our Enterprise Plan.

    - + diff --git a/studio/components/interfaces/TableGridEditor/EmptyState.tsx b/studio/components/interfaces/TableGridEditor/EmptyState.tsx index 3667bf1e4ef..ef92fbf19e3 100644 --- a/studio/components/interfaces/TableGridEditor/EmptyState.tsx +++ b/studio/components/interfaces/TableGridEditor/EmptyState.tsx @@ -1,7 +1,7 @@ import { FC } from 'react' import { observer } from 'mobx-react-lite' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions, useLocalStorage, useStore } from 'hooks' +import { useCheckPermissions, useLocalStorage, useStore } from 'hooks' import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query' import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' @@ -15,7 +15,7 @@ const EmptyState: FC = ({ selectedSchema, onAddTable }) => { const { meta } = useStore() const isProtectedSchema = meta.excludedSchemas.includes(selectedSchema) const canCreateTables = - !isProtectedSchema && checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') + useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') && !isProtectedSchema const [sort] = useLocalStorage<'alphabetical' | 'grouped-alphabetical'>( 'table-editor-sort', diff --git a/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx b/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx index 0472136abab..1bfbcfd4ae3 100644 --- a/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx +++ b/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx @@ -1,32 +1,33 @@ -import { FC } from 'react' -import Link from 'next/link' -import { Button, IconAlertCircle, IconCode, IconLock } from 'ui' import * as Tooltip from '@radix-ui/react-tooltip' import type { PostgresPolicy, PostgresTable } from '@supabase/postgres-meta' - -import { useStore, checkPermissions } from 'hooks' import { PermissionAction } from '@supabase/shared-types/out/constants' +import Link from 'next/link' -interface Props { +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useCheckPermissions, useStore } from 'hooks' +import { Button, IconAlertCircle, IconCode, IconLock } from 'ui' + +export interface GridHeaderActionsProps { table: PostgresTable apiPreviewPanelOpen: boolean setApiPreviewPanelOpen: (apiPreviewPanelOpen: boolean) => void refreshDocs: () => void } -const GridHeaderActions: FC = ({ +const GridHeaderActions = ({ table, apiPreviewPanelOpen, setApiPreviewPanelOpen, refreshDocs, -}) => { - const { ui, meta } = useStore() - const projectRef = ui.selectedProject?.ref +}: GridHeaderActionsProps) => { + const { meta } = useStore() + const { project } = useProjectContext() + const projectRef = project?.ref const policies = meta.policies.list((policy: PostgresPolicy) => policy.table_id === table.id) - const isReadOnly = - !checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') && - !checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns') + const canSqlWriteTables = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') + const canSqlWriteColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns') + const isReadOnly = !canSqlWriteTables && !canSqlWriteColumns function handlePreviewToggle() { setApiPreviewPanelOpen(!apiPreviewPanelOpen) diff --git a/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index 56a81b2c554..61fb02e7fd4 100644 --- a/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -7,7 +7,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { QueryKey, useQueryClient } from '@tanstack/react-query' import { SchemaView } from 'types' -import { checkPermissions, useFlag, useStore, useUrlState } from 'hooks' +import { useCheckPermissions, useFlag, useStore, useUrlState } from 'hooks' import useEntityType from 'hooks/misc/useEntityType' import { useParams } from 'common/hooks' import GridHeaderActions from './GridHeaderActions' @@ -116,9 +116,10 @@ const TableGridEditor = ({ } } - const isReadOnly = - !checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') && - !checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns') + const canEditTables = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') + const canEditColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns') + + const isReadOnly = !canEditTables && !canEditColumns const getEncryptedColumns = async (table: any) => { const columns = await vault.listEncryptedColumns(table.schema, table.name) @@ -214,7 +215,6 @@ const TableGridEditor = ({ const isTableSelected = entityType?.type === ENTITY_TYPE.TABLE const isForeignTableSelected = entityType?.type === ENTITY_TYPE.FOREIGN_TABLE const isLocked = meta.excludedSchemas.includes(entityType?.schema ?? '') - const canUpdateTables = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') const canEditViaTableEditor = isTableSelected && !isLocked // [Joshen] We can tweak below to eventually support composite keys as the data @@ -351,7 +351,7 @@ const TableGridEditor = ({ theme={theme} gridProps={{ height: '100%' }} storageRef={projectRef} - editable={!isReadOnly && canUpdateTables && canEditViaTableEditor} + editable={!isReadOnly && canEditTables && canEditViaTableEditor} schema={selectedTable.schema} table={gridTable} refreshDocs={refreshDocs} diff --git a/studio/components/layouts/AccountLayout/AccountLayout.tsx b/studio/components/layouts/AccountLayout/AccountLayout.tsx index b503f308268..f4b7599e8a6 100644 --- a/studio/components/layouts/AccountLayout/AccountLayout.tsx +++ b/studio/components/layouts/AccountLayout/AccountLayout.tsx @@ -1,26 +1,26 @@ import Head from 'next/head' -import { FC, ReactNode } from 'react' import { useRouter } from 'next/router' -import { observer } from 'mobx-react-lite' +import { PropsWithChildren } from 'react' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useFlag, useSelectedOrganization, withAuth } from 'hooks' import { useSignOut } from 'lib/auth' -import { useStore, withAuth, useFlag } from 'hooks' import { IS_PLATFORM } from 'lib/constants' -import WithSidebar from './WithSidebar' import { SidebarSection } from './AccountLayout.types' +import WithSidebar from './WithSidebar' -interface Props { +export interface AccountLayoutProps { title: string breadcrumbs: { key: string label: string }[] - children: ReactNode } -const AccountLayout: FC = ({ children, title, breadcrumbs }) => { +const AccountLayout = ({ children, title, breadcrumbs }: PropsWithChildren) => { const router = useRouter() - const { app, ui } = useStore() + const { data: organizations } = useOrganizationsQuery() + const selectedOrganization = useSelectedOrganization() const ongoingIncident = useFlag('ongoingIncident') const maxHeight = ongoingIncident ? 'calc(100vh - 44px)' : '100vh' @@ -32,11 +32,10 @@ const AccountLayout: FC = ({ children, title, breadcrumbs }) => { await router.push('/sign-in') } - const organizationsLinks = app.organizations - .list() + const organizationsLinks = (organizations ?? []) .map((organization) => ({ isActive: - router.pathname.startsWith('/org/') && ui.selectedOrganization?.slug === organization.slug, + router.pathname.startsWith('/org/') && selectedOrganization?.slug === organization.slug, label: organization.name + (organization.subscription_id ? ' (V2)' : ''), href: `/org/${organization.slug}/general`, key: organization.slug, @@ -147,6 +146,6 @@ const AccountLayout: FC = ({ children, title, breadcrumbs }) => { ) } -export default withAuth(observer(AccountLayout)) +export default withAuth(AccountLayout) -export const AccountLayoutWithoutAuth = observer(AccountLayout) +export const AccountLayoutWithoutAuth = AccountLayout diff --git a/studio/components/layouts/AuthLayout/AuthLayout.tsx b/studio/components/layouts/AuthLayout/AuthLayout.tsx index 8acef13d526..6ffb6073271 100644 --- a/studio/components/layouts/AuthLayout/AuthLayout.tsx +++ b/studio/components/layouts/AuthLayout/AuthLayout.tsx @@ -1,22 +1,22 @@ -import { FC, ReactNode, useState, useEffect } from 'react' import { observer } from 'mobx-react-lite' import { useRouter } from 'next/router' +import { PropsWithChildren, useEffect, useState } from 'react' -import ProjectLayout from '../' -import { useStore, withAuth } from 'hooks' +import { useParams } from 'common' import Error from 'components/ui/Error' import ProductMenu from 'components/ui/ProductMenu' +import { useStore, withAuth } from 'hooks' +import ProjectLayout from '../' import { generateAuthMenu } from './AuthLayout.utils' -interface Props { +export interface AuthLayoutProps { title?: string - children: ReactNode } -const AuthLayout: FC = ({ title, children }) => { +const AuthLayout = ({ title, children }: PropsWithChildren) => { const { ui, meta } = useStore() const { isInitialized, isLoading, error } = meta.tables - const projectRef = ui.selectedProject?.ref ?? 'default' + const { ref: projectRef = 'default' } = useParams() const router = useRouter() const page = router.pathname.split('/')[4] @@ -24,13 +24,13 @@ const AuthLayout: FC = ({ title, children }) => { const [loaded, setLoaded] = useState(isInitialized) useEffect(() => { - if (ui.selectedProject?.ref) { + if (ui.selectedProjectRef) { meta.policies.load() meta.tables.load() meta.roles.load() meta.schemas.load() } - }, [ui.selectedProject?.ref]) + }, [ui.selectedProjectRef]) useEffect(() => { if (!isLoading && !loaded) { diff --git a/studio/components/layouts/BillingLayout.tsx b/studio/components/layouts/BillingLayout.tsx index 402c8b480e2..5404de66c65 100644 --- a/studio/components/layouts/BillingLayout.tsx +++ b/studio/components/layouts/BillingLayout.tsx @@ -1,24 +1,22 @@ import Link from 'next/link' -import { FC, ReactNode } from 'react' -import { useStore, withAuth } from 'hooks' -import { observer } from 'mobx-react-lite' -import { IconX } from 'ui' +import { PropsWithChildren } from 'react' import BaseLayout from 'components/layouts' +import { useSelectedOrganization, useSelectedProject, withAuth } from 'hooks' +import { IconX } from 'ui' -interface Props { - children: ReactNode -} +export interface BillingLayoutProps {} -const BillingLayout: FC = ({ children }) => { - const { ui } = useStore() +const BillingLayout = ({ children }: PropsWithChildren) => { + const selectedOrganization = useSelectedOrganization() + const selectedProject = useSelectedProject() return (
    {/* Header */}
    - + @@ -27,7 +25,7 @@ const BillingLayout: FC = ({ children }) => {

    Customize your plan

    -

    {ui.selectedOrganization?.name}

    +

    {selectedOrganization?.name}

    = ({ children }) => { -

    {ui.selectedProject?.name}

    +

    {selectedProject?.name}

    @@ -55,4 +53,4 @@ const BillingLayout: FC = ({ children }) => { ) } -export default withAuth(observer(BillingLayout)) +export default withAuth(BillingLayout) diff --git a/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx b/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx index 6c721abe778..aed4e7515c6 100644 --- a/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx +++ b/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx @@ -1,26 +1,25 @@ -import { FC, ReactNode, useEffect, useState } from 'react' import { observer } from 'mobx-react-lite' import { useRouter } from 'next/router' +import { PropsWithChildren, useEffect, useState } from 'react' -import { useFlag, useStore, withAuth } from 'hooks' -import ProjectLayout from '../' import Error from 'components/ui/Error' import ProductMenu from 'components/ui/ProductMenu' -import { generateDatabaseMenu } from './DatabaseMenu.utils' +import { useFlag, useSelectedProject, useStore, withAuth } from 'hooks' import { IS_PLATFORM } from 'lib/constants' +import ProjectLayout from '../' +import { generateDatabaseMenu } from './DatabaseMenu.utils' -interface Props { +export interface DatabaseLayoutProps { title?: string - children: ReactNode } -const DatabaseLayout: FC = ({ title, children }) => { - const { meta, ui, vault, backups } = useStore() +const DatabaseLayout = ({ title, children }: PropsWithChildren) => { + const { ui, meta, vault, backups } = useStore() const { isLoading: isSchemasLoading } = meta.schemas const { isLoading: isVaultLoading } = vault const { isInitialized, error } = meta.tables - const project = ui.selectedProject + const project = useSelectedProject() const router = useRouter() const page = router.pathname.split('/')[4] @@ -34,7 +33,7 @@ const DatabaseLayout: FC = ({ title, children }) => { const [loaded, setLoaded] = useState(isInitialized) useEffect(() => { - if (ui.selectedProject?.ref) { + if (ui.selectedProjectRef) { // Eventually should only load the required stores based on the pages meta.schemas.load() meta.tables.load() @@ -48,13 +47,13 @@ const DatabaseLayout: FC = ({ title, children }) => { backups.load() } } - }, [ui.selectedProject?.ref]) + }, [ui.selectedProjectRef]) useEffect(() => { if (isVaultEnabled) { vault.load() } - }, [ui.selectedProject?.ref, isVaultEnabled]) + }, [ui.selectedProjectRef, isVaultEnabled]) // Optimization required: load logic should be at the page level // e.g backups page is waiting for meta.tables to load finish when it doesnt even need that data @@ -77,7 +76,10 @@ const DatabaseLayout: FC = ({ title, children }) => { isLoading={!loaded} product="Database" productMenu={ - + } >
    diff --git a/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts b/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts index 064d1e70c42..bc9fdeda485 100644 --- a/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts +++ b/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts @@ -70,6 +70,12 @@ export const generateDatabaseMenu = ( }, ] : []), + { + name: 'Migrations', + key: 'migrations', + url: `/project/${ref}/database/migrations`, + items: [], + }, ], }, ] diff --git a/studio/components/layouts/DocsLayout/DocsLayout.tsx b/studio/components/layouts/DocsLayout/DocsLayout.tsx index a4923f56077..d14142740f2 100644 --- a/studio/components/layouts/DocsLayout/DocsLayout.tsx +++ b/studio/components/layouts/DocsLayout/DocsLayout.tsx @@ -1,20 +1,21 @@ -import { ReactElement, useEffect } from 'react' import { observer } from 'mobx-react-lite' import { useRouter } from 'next/router' +import { ReactElement, useEffect } from 'react' -import ProjectLayout from '../' -import { useStore, withAuth } from 'hooks' import Error from 'components/ui/Error' import ProductMenu from 'components/ui/ProductMenu' -import { generateDocsMenu } from './DocsLayout.utils' +import { useSelectedProject, useStore, withAuth } from 'hooks' import { PROJECT_STATUS } from 'lib/constants' +import ProjectLayout from '../' +import { generateDocsMenu } from './DocsLayout.utils' function DocsLayout({ title, children }: { title: string; children: ReactElement }) { const router = useRouter() - const { meta, ui } = useStore() + const { meta } = useStore() const { data, isLoading, error } = meta.openApi + const selectedProject = useSelectedProject() - const isPaused = ui.selectedProject?.status === PROJECT_STATUS.INACTIVE + const isPaused = selectedProject?.status === PROJECT_STATUS.INACTIVE const getPage = () => { if (router.pathname.endsWith('graphiql')) return 'graphiql' @@ -25,10 +26,10 @@ function DocsLayout({ title, children }: { title: string; children: ReactElement } useEffect(() => { - if (ui.selectedProject?.ref && !isPaused) { + if (selectedProject?.ref && !isPaused) { meta.openApi.load() } - }, [ui.selectedProject?.ref]) + }, [selectedProject?.ref]) if (error) { return ( @@ -38,7 +39,7 @@ function DocsLayout({ title, children }: { title: string; children: ReactElement ) } - const projectRef = ui.selectedProject?.ref ?? 'default' + const projectRef = selectedProject?.ref ?? 'default' const tableNames = (data?.tables ?? []).map((table: any) => table.name) const functionNames = (data?.functions ?? []).map((fn: any) => fn.name) diff --git a/studio/components/layouts/FunctionsLayout.tsx b/studio/components/layouts/FunctionsLayout.tsx index f8caaf4780a..815aff90c2f 100644 --- a/studio/components/layouts/FunctionsLayout.tsx +++ b/studio/components/layouts/FunctionsLayout.tsx @@ -4,7 +4,7 @@ import { observer } from 'mobx-react-lite' import { Button, IconExternalLink, IconCode, Modal, IconTerminal } from 'ui' import ProjectLayout from './' -import { checkPermissions, withAuth } from 'hooks' +import { useCheckPermissions, withAuth } from 'hooks' import { useParams } from 'common/hooks' import FunctionsNav from '../interfaces/Functions/FunctionsNav' import NoPermission from 'components/ui/NoPermission' @@ -24,7 +24,7 @@ const FunctionsLayout: FC = ({ title, children }) => { const { data: functions, isLoading } = useEdgeFunctionsQuery({ projectRef: ref }) const { data: selectedFunction } = useEdgeFunctionQuery({ projectRef: ref, slug: functionSlug }) - const canReadFunctions = checkPermissions(PermissionAction.FUNCTIONS_READ, '*') + const canReadFunctions = useCheckPermissions(PermissionAction.FUNCTIONS_READ, '*') if (!canReadFunctions) { return ( @@ -63,7 +63,6 @@ const FunctionsLayout: FC = ({ title, children }) => {

    Edge Functions

    -

    Beta

    @@ -97,7 +96,6 @@ const FunctionsLayout: FC = ({ title, children }) => { Edge Functions -

    Beta

    {name && (
    diff --git a/studio/components/layouts/IntegrationWindowLayout.tsx b/studio/components/layouts/IntegrationWindowLayout.tsx new file mode 100644 index 00000000000..123e42f205c --- /dev/null +++ b/studio/components/layouts/IntegrationWindowLayout.tsx @@ -0,0 +1,78 @@ +import { FC } from 'react' +import Link from 'next/link' +import { IconChevronRight, IconX, cn } from 'ui' +import { withAuth, useFlag } from 'hooks' +import { observer } from 'mobx-react-lite' +import { BASE_PATH } from 'lib/constants' +import React from 'react' +import { ScaffoldContainer } from './Scaffold' +import { useParams } from 'common' + +const IntegrationWindowLayout: FC = ({ organization, project, children }) => { + const ongoingIncident = useFlag('ongoingIncident') + const maxHeight = ongoingIncident ? 'calc(100vh - 44px)' : '100vh' + + return ( +
    +
    + {children} +
    + ) +} + +const INTEGRATION_LAYOUT_MAX_WIDTH = '' // 'max-w-[720px]' + +export default withAuth(observer(IntegrationWindowLayout)) + +export const IntegrationWindowLayoutWithoutAuth = observer(IntegrationWindowLayout) + +const Header: FC = ({ organization, project }) => { + let stepNumber = organization ? 1 : project ? 2 : 0 + const { externalId } = useParams() + + const title = externalId + ? 'Supabase + Vercel Deploy Button' + : 'Supabase + Vercel Integration Marketplace Connector' + + return ( +
    + +
    +
    +
    + Supabase +
    + +
    + + + +
    +
    + + {title} + +
    +
    +
    + ) +} + +const maxWidthClasses = 'mx-auto w-full max-w-[1600px]' +const paddingClasses = 'px-6 lg:px-14 xl:px-28 2xl:px-32' + +const IntegrationScaffoldContainer = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + return
    +}) + +IntegrationScaffoldContainer.displayName = 'IntegrationScaffoldContainer' + +export { IntegrationScaffoldContainer } diff --git a/studio/components/layouts/LogsLayout/LogsLayout.tsx b/studio/components/layouts/LogsLayout/LogsLayout.tsx index 1d889b1735f..ff331f16c4c 100644 --- a/studio/components/layouts/LogsLayout/LogsLayout.tsx +++ b/studio/components/layouts/LogsLayout/LogsLayout.tsx @@ -1,26 +1,25 @@ -import { PropsWithChildren } from 'react' -import { observer } from 'mobx-react-lite' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useRouter } from 'next/router' -import { checkPermissions, useStore, withAuth } from 'hooks' -import ProjectLayout from '../' +import { PropsWithChildren } from 'react' + import NoPermission from 'components/ui/NoPermission' -import { generateLogsMenu } from './LogsMenu.utils' import ProductMenu from 'components/ui/ProductMenu' +import { useCheckPermissions, useSelectedProject, withAuth } from 'hooks' +import ProjectLayout from '../' +import { generateLogsMenu } from './LogsMenu.utils' interface LogsLayoutProps { title?: string } const LogsLayout = ({ title, children }: PropsWithChildren) => { - const { ui } = useStore() const router = useRouter() const pathArr = router.pathname.split('/') const page = pathArr[pathArr.length - 1] - const project = ui.selectedProject + const project = useSelectedProject() - const canUseLogsExplorer = checkPermissions(PermissionAction.ANALYTICS_READ, 'logflare') + const canUseLogsExplorer = useCheckPermissions(PermissionAction.ANALYTICS_READ, 'logflare') if (!canUseLogsExplorer) { return ( @@ -45,4 +44,4 @@ const LogsLayout = ({ title, children }: PropsWithChildren) => ) } -export default withAuth(observer(LogsLayout)) +export default withAuth(LogsLayout) diff --git a/studio/components/layouts/OrganizationLayout.tsx b/studio/components/layouts/OrganizationLayout.tsx index 0b323386d03..6dca6aa2d51 100644 --- a/studio/components/layouts/OrganizationLayout.tsx +++ b/studio/components/layouts/OrganizationLayout.tsx @@ -1,23 +1,59 @@ -import { useStore } from 'hooks' -import { observer } from 'mobx-react-lite' -import { FC, ReactNode } from 'react' +import { useParams } from 'common' +import { useFlag, useSelectedOrganization } from 'hooks' +import { useRouter } from 'next/router' +import { PropsWithChildren } from 'react' +import { Tabs } from 'ui' import { AccountLayout } from './' +import { ScaffoldContainer, ScaffoldDivider, ScaffoldHeader, ScaffoldTitle } from './Scaffold' -interface Props { - children: ReactNode -} - -const OrganizationLayout: FC = ({ children }) => { - const { ui } = useStore() +const OrganizationLayout = ({ children }: PropsWithChildren<{}>) => { + const selectedOrganization = useSelectedOrganization() + const router = useRouter() + const { slug } = useParams() + const id = router.asPath.split('/').at(-1) + const showOAuthApps = useFlag('oauthApps') + const showAuditLogs = useFlag('auditLogs') + const showIntegrationsV2 = useFlag('integrationsV2') + const isOrgBilling = !!selectedOrganization?.subscription_id return ( + + + {selectedOrganization?.name ?? 'Organization'} settings + + + + + + {children} ) } -export default observer(OrganizationLayout) +export default OrganizationLayout diff --git a/studio/components/layouts/ProjectLayout/BuildingState.tsx b/studio/components/layouts/ProjectLayout/BuildingState.tsx index 11ec2af1c9e..a41e89137a2 100644 --- a/studio/components/layouts/ProjectLayout/BuildingState.tsx +++ b/studio/components/layouts/ProjectLayout/BuildingState.tsx @@ -1,25 +1,26 @@ +import { useQueryClient } from '@tanstack/react-query' +import ClientLibrary from 'components/interfaces/Home/ClientLibrary' +import ExampleProject from 'components/interfaces/Home/ExampleProject' +import { CLIENT_LIBRARIES, EXAMPLE_PROJECTS } from 'components/interfaces/Home/Home.constants' import Link from 'next/link' import { FC, useEffect, useRef } from 'react' -import { observer } from 'mobx-react-lite' -import { Badge, IconArrowRight, IconLoader, Button } from 'ui' -import ExampleProject from 'components/interfaces/Home/ExampleProject' -import ClientLibrary from 'components/interfaces/Home/ClientLibrary' -import { CLIENT_LIBRARIES, EXAMPLE_PROJECTS } from 'components/interfaces/Home/Home.constants' +import { Badge, Button, IconArrowRight, IconLoader } from 'ui' -import { API_URL, PROJECT_STATUS } from 'lib/constants' -import { useStore } from 'hooks' -import { getWithTimeout } from 'lib/common/fetch' -import { Project } from 'types' import { DisplayApiSettings, DisplayConfigSettings } from 'components/ui/ProjectSettings' +import { invalidateProjectsQuery } from 'data/projects/projects-query' +import { getWithTimeout } from 'lib/common/fetch' +import { API_URL, PROJECT_STATUS } from 'lib/constants' +import { Project } from 'types' -interface Props { +export interface BuildingStateProps { project: Project } -const BuildingState: FC = ({ project }) => { - const { ui, app } = useStore() +const BuildingState = ({ project }: BuildingStateProps) => { + const queryClient = useQueryClient() const checkServerInterval = useRef() + // TODO: move to react-query async function checkServer() { if (!project) return @@ -30,9 +31,8 @@ const BuildingState: FC = ({ project }) => { const { status } = projectStatus if (status === PROJECT_STATUS.ACTIVE_HEALTHY) { clearInterval(checkServerInterval.current) - // re-fetch project detail. - // This will also trigger UI state change to show project building completed - await app.projects.fetchDetail(project.ref) + + await invalidateProjectsQuery(queryClient) } } } @@ -156,7 +156,7 @@ const BuildingState: FC = ({ project }) => {
    ) } -export default observer(BuildingState) +export default BuildingState const ChecklistItem = ({ description }: any) => { return ( diff --git a/studio/components/layouts/ProjectLayout/ConnectingState.tsx b/studio/components/layouts/ProjectLayout/ConnectingState.tsx index 4378343983b..ba2ae591e0b 100644 --- a/studio/components/layouts/ProjectLayout/ConnectingState.tsx +++ b/studio/components/layouts/ProjectLayout/ConnectingState.tsx @@ -1,19 +1,19 @@ +import { useQueryClient } from '@tanstack/react-query' import Link from 'next/link' -import { FC, useEffect, useRef, useState } from 'react' -import { observer } from 'mobx-react-lite' +import { useEffect, useRef, useState } from 'react' import { Badge, Button, IconLoader, IconMonitor, IconServer, Modal } from 'ui' -import { Project } from 'types' -import { useStore } from 'hooks' import ShimmerLine from 'components/ui/ShimmerLine' +import { setProjectPostgrestStatus } from 'data/projects/projects-query' import pingPostgrest from 'lib/pingPostgrest' +import { Project } from 'types' -interface Props { +export interface ConnectingStateProps { project: Project } -const ConnectingState: FC = ({ project }) => { - const { app } = useStore() +const ConnectingState = ({ project }: ConnectingStateProps) => { + const queryClient = useQueryClient() const checkProjectConnectionIntervalRef = useRef() const [showHelperButton, setShowHelperButton] = useState(false) @@ -40,7 +40,7 @@ const ConnectingState: FC = ({ project }) => { const result = await pingPostgrest(project.ref, { kpsVersion: project.kpsVersion }) if (result) { clearInterval(checkProjectConnectionIntervalRef.current) - app.onProjectPostgrestStatusUpdated(project.id, 'ONLINE') + setProjectPostgrestStatus(queryClient, project.ref, 'ONLINE') } } @@ -144,4 +144,4 @@ const ConnectingState: FC = ({ project }) => { ) } -export default observer(ConnectingState) +export default ConnectingState diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx index 4ec5f0064ad..834f8feb806 100644 --- a/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx +++ b/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx @@ -1,39 +1,36 @@ -import Link from 'next/link' -import { observer } from 'mobx-react-lite' import { useParams } from 'common' +import Link from 'next/link' import { Badge } from 'ui' +import { getResourcesExceededLimits } from 'components/ui/OveragesBanner/OveragesBanner.utils' +import { useProjectReadOnlyQuery } from 'data/config/project-read-only-query' +import { useProjectUsageQuery } from 'data/usage/project-usage-query' +import { useFlag, useSelectedOrganization, useSelectedProject } from 'hooks' import { IS_PLATFORM, PRICING_TIER_PRODUCT_IDS } from 'lib/constants' -import { useFlag, useStore } from 'hooks' import BreadcrumbsView from './BreadcrumbsView' -import OrgDropdown from './OrgDropdown' -import ProjectDropdown from './ProjectDropdown' import FeedbackDropdown from './FeedbackDropdown' import HelpPopover from './HelpPopover' import NotificationsPopover from './NotificationsPopover' -import { getResourcesExceededLimits } from 'components/ui/OveragesBanner/OveragesBanner.utils' -import { useProjectUsageQuery } from 'data/usage/project-usage-query' -import { useProjectReadOnlyQuery } from 'data/config/project-read-only-query' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import OrgDropdown from './OrgDropdown' +import ProjectDropdown from './ProjectDropdown' const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder = true }: any) => { - const { ui } = useStore() - const { selectedOrganization, selectedProject } = ui + const selectedOrganization = useSelectedOrganization() + const selectedProject = useSelectedProject() const { ref: projectRef } = useParams() - const { project } = useProjectContext() const { data: isReadOnlyMode } = useProjectReadOnlyQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, + projectRef: selectedProject?.ref, + connectionString: selectedProject?.connectionString, }) const { data: usage } = useProjectUsageQuery({ projectRef }) const resourcesExceededLimits = getResourcesExceededLimits(usage) const projectHasNoLimits = - ui.selectedProject?.subscription_tier === PRICING_TIER_PRODUCT_IDS.PAYG || - ui.selectedProject?.subscription_tier === PRICING_TIER_PRODUCT_IDS.ENTERPRISE || - ui.selectedProject?.subscription_tier === PRICING_TIER_PRODUCT_IDS.TEAM + selectedProject?.subscription_tier === PRICING_TIER_PRODUCT_IDS.PAYG || + selectedProject?.subscription_tier === PRICING_TIER_PRODUCT_IDS.ENTERPRISE || + selectedProject?.subscription_tier === PRICING_TIER_PRODUCT_IDS.TEAM const showOverUsageBadge = useFlag('overusageBadge') && @@ -120,4 +117,4 @@ const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder =
    ) } -export default observer(LayoutHeader) +export default LayoutHeader diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRow.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRow.tsx index a523bd84c4d..54d19628a36 100644 --- a/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRow.tsx +++ b/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRow.tsx @@ -1,19 +1,20 @@ -import dayjs from 'dayjs' -import { FC, useState } from 'react' -import { useQuery, useQueryClient } from '@tanstack/react-query' import * as Tooltip from '@radix-ui/react-tooltip' import { Notification, NotificationStatus } from '@supabase/shared-types/out/notifications' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import dayjs from 'dayjs' +import { useState } from 'react' -import { useStore } from 'hooks' import { notificationKeys } from 'data/notifications/keys' -import { Project } from 'types' -import { formatNotificationCTAText, formatNotificationText } from './NotificationRows.utils' -import NotificationActions from './NotificationActions' -import { get, delete_ } from 'lib/common/fetch' +import { useProjectsQuery } from 'data/projects/projects-query' +import { useStore } from 'hooks' +import { delete_, get } from 'lib/common/fetch' import { API_URL } from 'lib/constants' +import { Project } from 'types' import { Button, IconX } from 'ui' +import NotificationActions from './NotificationActions' +import { formatNotificationCTAText, formatNotificationText } from './NotificationRows.utils' -interface Props { +export interface NotificationRowProps { notification: Notification onSelectRestartProject: (project: Project, notification: Notification) => void onSelectApplyMigration: (project: Project, notification: Notification) => void @@ -21,17 +22,18 @@ interface Props { onSelectFinalizeMigration: (project: Project, notification: Notification) => void } -const NotificationRow: FC = ({ +const NotificationRow = ({ notification, onSelectRestartProject, onSelectApplyMigration, onSelectRollbackMigration, onSelectFinalizeMigration, -}) => { - const { app, ui } = useStore() +}: NotificationRowProps) => { + const { ui } = useStore() const queryClient = useQueryClient() const [dismissing, setDismissing] = useState(false) - const [project] = app.projects.list((project: Project) => project.id === notification.project_id) + const { data: projects } = useProjectsQuery() + const project = projects?.find((project) => project.id === notification.project_id) const insertedAt = dayjs(notification.inserted_at).format('DD MMM YYYY, HH:mma') const changelogLink = (notification.data as any).changelog_link @@ -41,10 +43,11 @@ const NotificationRow: FC = ({ // have been removed, double check with Qiao before removing. // Relevant PR: https://github.com/supabase/supabase/pull/9229 const { data: ownerReassignStatus } = useQuery( - ['projects', project.ref, 'owner-reassign'], - ({ signal }) => get(`${API_URL}/database/${project.ref}/owner-reassign`, { signal }), + ['projects', project?.ref, 'owner-reassign'], + ({ signal }) => get(`${API_URL}/database/${project?.ref}/owner-reassign`, { signal }), { - enabled: (notification.data as any).upgrade_type === 'schema-migration', + enabled: + (notification.data as any).upgrade_type === 'schema-migration' && project !== undefined, } ) @@ -65,6 +68,8 @@ const NotificationRow: FC = ({ setDismissing(false) } + if (!project) return null + return (
    diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/index.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/index.tsx index 129a2568e6c..1ff8530f549 100644 --- a/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/index.tsx +++ b/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/index.tsx @@ -1,27 +1,29 @@ -import dayjs from 'dayjs' -import { FC, Fragment, useState } from 'react' -import { useRouter } from 'next/router' -import { Alert, Button, IconBell, Popover, IconArrowRight } from 'ui' import * as Tooltip from '@radix-ui/react-tooltip' import { + ActionType, Notification, NotificationStatus, - ActionType, } from '@supabase/shared-types/out/notifications' +import { useQueryClient } from '@tanstack/react-query' +import dayjs from 'dayjs' +import { useRouter } from 'next/router' +import { Fragment, useState } from 'react' -import { Project } from 'types' +import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog' +import { useNotificationsQuery } from 'data/notifications/notifications-query' +import { getProjectDetail } from 'data/projects/project-detail-query' +import { invalidateProjectsQuery, setProjectPostgrestStatus } from 'data/projects/projects-query' import { useStore } from 'hooks' import { delete_, patch, post } from 'lib/common/fetch' import { API_URL } from 'lib/constants' +import { Project } from 'types' +import { Alert, Button, IconArrowRight, IconBell, Popover } from 'ui' import NotificationRow from './NotificationRow' -import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog' -import { useNotificationsQuery } from 'data/notifications/notifications-query' -interface Props {} - -const NotificationsPopover: FC = () => { +const NotificationsPopover = () => { + const queryClient = useQueryClient() const router = useRouter() - const { app, meta, ui } = useStore() + const { meta, ui } = useStore() const { data: notifications, refetch } = useNotificationsQuery() const [projectToRestart, setProjectToRestart] = useState() @@ -57,7 +59,7 @@ const NotificationsPopover: FC = () => { const { id } = targetNotification - const { id: projectId, ref, region } = projectToRestart + const { ref, region } = projectToRestart const serviceNamesByActionName: Record = { [ActionType.PgBouncerRestart]: 'pgbouncer', [ActionType.SchedulePostgresRestart]: 'postgresql', @@ -82,7 +84,7 @@ const NotificationsPopover: FC = () => { error, }) } else { - app.onProjectPostgrestStatusUpdated(projectId, 'OFFLINE') + setProjectPostgrestStatus(queryClient, ref, 'OFFLINE') ui.setNotification({ category: 'success', message: `Restarting services` }) router.push(`/project/${ref}`) } @@ -102,9 +104,11 @@ const NotificationsPopover: FC = () => { if (!projectToApplyMigration) return const res = await post(`${API_URL}/database/${projectToApplyMigration.ref}/owner-reassign`, {}) if (!res.error) { - await app.projects.fetchDetail(projectToApplyMigration.ref, (project) => + const project = await getProjectDetail({ ref: projectToApplyMigration.ref }) + if (project) { meta.setProjectDetails(project) - ) + } + ui.setNotification({ category: 'success', message: `Successfully applied migration for project "${projectToApplyMigration.name}"`, @@ -126,9 +130,11 @@ const NotificationsPopover: FC = () => { {} ) if (!res.error) { - await app.projects.fetchDetail(projectToRollbackMigration.ref, (project) => + const project = await getProjectDetail({ ref: projectToRollbackMigration.ref }) + if (project) { meta.setProjectDetails(project) - ) + } + ui.setNotification({ category: 'success', message: `Successfully rolled back migration for project "${projectToRollbackMigration.name}"`, @@ -150,9 +156,11 @@ const NotificationsPopover: FC = () => { {} ) if (!res.error) { - await app.projects.fetchDetail(projectToFinalizeMigration.ref, (project) => + const project = await getProjectDetail({ ref: projectToFinalizeMigration.ref }) + if (project) { meta.setProjectDetails(project) - ) + } + ui.setNotification({ category: 'success', message: `Successfully finalized migration for project "${projectToFinalizeMigration.name}"`, diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/OrgDropdown.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/OrgDropdown.tsx index b2033eace78..5e2bfffa05f 100644 --- a/studio/components/layouts/ProjectLayout/LayoutHeader/OrgDropdown.tsx +++ b/studio/components/layouts/ProjectLayout/LayoutHeader/OrgDropdown.tsx @@ -1,17 +1,17 @@ -import { useRouter } from 'next/router' import { toJS } from 'mobx' -import { observer } from 'mobx-react-lite' -import { Button, Dropdown, IconPlus } from 'ui' +import { useRouter } from 'next/router' -import { useFlag, useStore } from 'hooks' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useFlag, useSelectedOrganization, useStore } from 'hooks' import { IS_PLATFORM } from 'lib/constants' +import { Button, Dropdown, IconPlus } from 'ui' const OrgDropdown = () => { const router = useRouter() - const { app, ui } = useStore() + const { ui } = useStore() - const sortedOrganizations: any[] = app.organizations.list() - const selectedOrganization: any = ui.selectedOrganization + const { data: organizations } = useOrganizationsQuery() + const selectedOrganization = useSelectedOrganization() const orgCreationV2 = useFlag('orgcreationv2') @@ -21,8 +21,8 @@ const OrgDropdown = () => { align="start" overlay={ <> - {sortedOrganizations - .sort((a, b) => a.name.localeCompare(b.name)) + {organizations + ?.sort((a, b) => a.name.localeCompare(b.name)) .map((x) => { const slug = toJS(x.slug) @@ -68,14 +68,14 @@ const OrgDropdown = () => { } > ) : ( ) } -export default observer(OrgDropdown) +export default OrgDropdown diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/ProjectDropdown.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/ProjectDropdown.tsx index 6de692d35f7..b56940ded52 100644 --- a/studio/components/layouts/ProjectLayout/LayoutHeader/ProjectDropdown.tsx +++ b/studio/components/layouts/ProjectLayout/LayoutHeader/ProjectDropdown.tsx @@ -1,10 +1,10 @@ import Link from 'next/link' -import { observer } from 'mobx-react-lite' -import { Button, Dropdown, IconPlus, Popover } from 'ui' import { useRouter } from 'next/router' import { ParsedUrlQuery } from 'querystring' +import { Button, Dropdown, IconPlus, Popover } from 'ui' -import { useStore } from 'hooks' +import { useProjectsQuery } from 'data/projects/projects-query' +import { useSelectedOrganization, useSelectedProject } from 'hooks' import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' // [Fran] the idea is to let users change projects without losing the current page, @@ -31,10 +31,10 @@ export const sanitizeRoute = (route: string, routerQueries: ParsedUrlQuery) => { } const ProjectDropdown = () => { - const { app, ui } = useStore() - const selectedOrganizationProjects = app.projects.list() - const selectedOrganizationSlug = ui.selectedOrganization?.slug - const selectedProject: any = ui.selectedProject + const selectedOrganization = useSelectedOrganization() + const selectedProject = useSelectedProject() + const { data: allProjects } = useProjectsQuery() + const selectedOrganizationSlug = selectedOrganization?.slug const router = useRouter() const sanitizedRoute = sanitizeRoute(router.route, router.query) @@ -44,10 +44,10 @@ const ProjectDropdown = () => { align="start" overlay={ <> - {selectedOrganizationProjects - .filter((x: any) => x.status !== PROJECT_STATUS.INACTIVE) - .sort((a: any, b: any) => a.name.localeCompare(b.name)) - .map((x: any) => ( + {allProjects + ?.filter((x) => x.status !== PROJECT_STATUS.INACTIVE) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((x) => ( { { } > ) : ( ) } -export default observer(ProjectDropdown) +export default ProjectDropdown diff --git a/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx b/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx index 944df1f6c25..50bdff1621b 100644 --- a/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx +++ b/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx @@ -1,46 +1,46 @@ -import Link from 'next/link' -import { FC } from 'react' +import * as Tooltip from '@radix-ui/react-tooltip' import { isUndefined } from 'lodash' -import { observer } from 'mobx-react-lite' +import Link from 'next/link' import { useRouter } from 'next/router' +import { FC } from 'react' + +import { useTheme } from 'common' +import { useParams } from 'common/hooks' +import { useFlag } from 'hooks' +import { IS_PLATFORM } from 'lib/constants' +import { detectOS } from 'lib/helpers' import { Button, Dropdown, + IconCommand, IconHome, + IconSearch, IconSettings, IconUser, - IconSearch, useCommandMenu, - IconCommand, } from 'ui' -import * as Tooltip from '@radix-ui/react-tooltip' -import { useFlag, useStore } from 'hooks' -import { IS_PLATFORM } from 'lib/constants' +import { useProjectContext } from '../ProjectContext' import { generateOtherRoutes, generateProductRoutes, generateToolRoutes, } from './NavigationBar.utils' import NavigationIconButton from './NavigationIconButton' -import { useParams } from 'common/hooks' -import { useTheme } from 'common' -import { detectOS } from 'lib/helpers' interface Props {} const NavigationBar: FC = ({}) => { const router = useRouter() - const { ui } = useStore() const { isDarkMode, toggleTheme } = useTheme() const { ref: projectRef } = useParams() - const projectBaseInfo = ui.selectedProjectBaseInfo + const { project } = useProjectContext() const ongoingIncident = useFlag('ongoingIncident') const activeRoute = router.pathname.split('/')[3] - const toolRoutes = generateToolRoutes(projectRef, projectBaseInfo) - const productRoutes = generateProductRoutes(projectRef, projectBaseInfo) - const otherRoutes = generateOtherRoutes(projectRef, projectBaseInfo) + const toolRoutes = generateToolRoutes(projectRef, project) + const productRoutes = generateProductRoutes(projectRef, project) + const otherRoutes = generateOtherRoutes(projectRef, project) const showCmdkHelper = useFlag('dashboardCmdk') const os = detectOS() const { setIsOpen } = useCommandMenu() @@ -172,4 +172,4 @@ const NavigationBar: FC = ({}) => { ) } -export default observer(NavigationBar) +export default NavigationBar diff --git a/studio/components/layouts/ProjectLayout/PausingState.tsx b/studio/components/layouts/ProjectLayout/PausingState.tsx index 1e19e5cf3b6..ddd078ccf28 100644 --- a/studio/components/layouts/ProjectLayout/PausingState.tsx +++ b/studio/components/layouts/ProjectLayout/PausingState.tsx @@ -1,12 +1,11 @@ -import { FC } from 'react' -import { Badge, IconLoader, IconCircle } from 'ui' import { Project } from 'types' +import { Badge, IconCircle, IconLoader } from 'ui' -interface Props { +export interface PausingStateProps { project: Project } -const PausingState: FC = ({ project }) => { +const PausingState = ({ project }: PausingStateProps) => { return (
    diff --git a/studio/components/layouts/ProjectLayout/ProjectContext.tsx b/studio/components/layouts/ProjectLayout/ProjectContext.tsx index 8fc28f52926..b06457c84fe 100644 --- a/studio/components/layouts/ProjectLayout/ProjectContext.tsx +++ b/studio/components/layouts/ProjectLayout/ProjectContext.tsx @@ -1,8 +1,7 @@ import { useParams } from 'common/hooks' -import { useStore } from 'hooks' +import { useProjectDetailQuery } from 'data/projects/project-detail-query' import { PROJECT_STATUS } from 'lib/constants' -import { observer } from 'mobx-react-lite' -import { createContext, PropsWithChildren, useContext, useMemo } from 'react' +import { PropsWithChildren, createContext, useContext, useMemo } from 'react' import { Project } from 'types' type ProjectContextType = { @@ -23,29 +22,21 @@ type ProjectContextProviderProps = { projectRef: string | undefined } -export const ProjectContextProvider = observer( - ({ projectRef, children }: PropsWithChildren) => { - // TODO(alaister): This will eventually be replaced with a react-query query - // once we remove mobx - const { ui } = useStore() +export const ProjectContextProvider = ({ + projectRef, + children, +}: PropsWithChildren) => { + const { data: selectedProject, isLoading } = useProjectDetailQuery({ ref: projectRef }) - const value = useMemo(() => { - if (ui.selectedProject?.ref === projectRef) { - return { - project: ui.selectedProject, - isLoading: false, - } - } + const value = useMemo(() => { + return { + project: selectedProject, + isLoading: isLoading, + } + }, [selectedProject, isLoading]) - return { - project: undefined, - isLoading: true, - } - }, [ui.selectedProject?.ref, projectRef]) - - return {children} - } -) + return {children} +} export const ProjectContextFromParamsProvider = ({ children }: PropsWithChildren<{}>) => { const { ref: projectRef } = useParams() diff --git a/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index f00bbca7e2a..e2dae22e23c 100644 --- a/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -1,22 +1,21 @@ import Head from 'next/head' -import { ReactNode, Fragment } from 'react' -import { observer } from 'mobx-react-lite' import { useRouter } from 'next/router' -import { useStore, withAuth, useFlag } from 'hooks' -import { useParams } from 'common/hooks' -import { PROJECT_STATUS } from 'lib/constants' +import { Fragment, PropsWithChildren, ReactNode } from 'react' +import { useParams } from 'common/hooks' import Connecting from 'components/ui/Loading' -import NavigationBar from './NavigationBar/NavigationBar' -import ProductMenuBar from './ProductMenuBar' -import LayoutHeader from './LayoutHeader' -import ConnectingState from './ConnectingState' -import PausingState from './PausingState' +import { useFlag, useSelectedOrganization, useSelectedProject, withAuth } from 'hooks' +import { PROJECT_STATUS } from 'lib/constants' import BuildingState from './BuildingState' +import ConnectingState from './ConnectingState' +import LayoutHeader from './LayoutHeader' +import NavigationBar from './NavigationBar/NavigationBar' +import PausingState from './PausingState' +import ProductMenuBar from './ProductMenuBar' import { ProjectContextProvider } from './ProjectContext' +import ProjectPausedState from './ProjectPausedState' import RestoringState from './RestoringState' import UpgradingState from './UpgradingState' -import ProjectPausedState from './ProjectPausedState' export interface ProjectLayoutProps { title?: string @@ -25,7 +24,6 @@ export interface ProjectLayoutProps { productMenu?: ReactNode hideHeader?: boolean hideIconBar?: boolean - children: ReactNode selectedTable?: string } @@ -38,15 +36,16 @@ const ProjectLayout = ({ hideHeader = false, hideIconBar = false, selectedTable, -}: ProjectLayoutProps) => { - const { ui } = useStore() +}: PropsWithChildren) => { const router = useRouter() const { ref: projectRef } = useParams() const ongoingIncident = useFlag('ongoingIncident') - const projectName = ui.selectedProject?.name - const organizationName = ui.selectedOrganization?.name + const selectedOrganization = useSelectedOrganization() + const selectedProject = useSelectedProject() + const projectName = selectedProject?.name + const organizationName = selectedOrganization?.name - const isPaused = ui.selectedProject?.status === PROJECT_STATUS.INACTIVE + const isPaused = selectedProject?.status === PROJECT_STATUS.INACTIVE const ignorePausedState = router.pathname === '/project/[ref]' || router.pathname.includes('/project/[ref]/settings') const showPausedState = isPaused && !ignorePausedState @@ -98,9 +97,9 @@ const ProjectLayout = ({ ) } -export const ProjectLayoutWithAuth = withAuth(observer(ProjectLayout)) +export const ProjectLayoutWithAuth = withAuth(ProjectLayout) -export default observer(ProjectLayout) +export default ProjectLayout interface MenuBarWrapperProps { isLoading: boolean @@ -108,10 +107,10 @@ interface MenuBarWrapperProps { children: ReactNode } -const MenuBarWrapper = observer(({ isLoading, productMenu, children }: MenuBarWrapperProps) => { - const { ui } = useStore() - return <>{!isLoading && productMenu && ui.selectedProject !== undefined ? children : null} -}) +const MenuBarWrapper = ({ isLoading, productMenu, children }: MenuBarWrapperProps) => { + const selectedProject = useSelectedProject() + return <>{!isLoading && productMenu && selectedProject !== undefined ? children : null} +} interface ContentWrapperProps { isLoading: boolean @@ -130,8 +129,8 @@ interface ContentWrapperProps { * * [TODO] Next iteration should scrape long polling and just listen to the project's status */ -const ContentWrapper = observer(({ isLoading, children }: ContentWrapperProps) => { - const { ui } = useStore() +const ContentWrapper = ({ isLoading, children }: ContentWrapperProps) => { + const selectedProject = useSelectedProject() const router = useRouter() const routesToIgnorePostgrestConnection = [ @@ -140,95 +139,95 @@ const ContentWrapper = observer(({ isLoading, children }: ContentWrapperProps) = '/project/[ref]/settings/database', '/project/[ref]/settings/billing/subscription', '/project/[ref]/settings/billing/usage', - '/project/[ref]/settings/billing/invoices' + '/project/[ref]/settings/billing/invoices', ] const requiresDbConnection: boolean = router.pathname !== '/project/[ref]/settings/general' const requiresPostgrestConnection = !routesToIgnorePostgrestConnection.includes(router.pathname) - const isProjectUpgrading = ui.selectedProject?.status === PROJECT_STATUS.UPGRADING - const isProjectRestoring = ui.selectedProject?.status === PROJECT_STATUS.RESTORING - const isProjectBuilding = ui.selectedProject?.status === PROJECT_STATUS.COMING_UP - const isProjectPausing = ui.selectedProject?.status === PROJECT_STATUS.GOING_DOWN - const isProjectOffline = ui.selectedProject?.postgrestStatus === 'OFFLINE' + const isProjectUpgrading = selectedProject?.status === PROJECT_STATUS.UPGRADING + const isProjectRestoring = selectedProject?.status === PROJECT_STATUS.RESTORING + const isProjectBuilding = selectedProject?.status === PROJECT_STATUS.COMING_UP + const isProjectPausing = + selectedProject?.status === PROJECT_STATUS.GOING_DOWN || + selectedProject?.status === PROJECT_STATUS.PAUSING + const isProjectOffline = selectedProject?.postgrestStatus === 'OFFLINE' return ( <> - {isLoading || ui.selectedProject === undefined ? ( + {isLoading || selectedProject === undefined ? ( ) : isProjectUpgrading ? ( ) : isProjectPausing ? ( - + ) : requiresPostgrestConnection && isProjectOffline ? ( - + ) : requiresDbConnection && isProjectRestoring ? ( ) : requiresDbConnection && isProjectBuilding ? ( - + ) : ( - {children} + {children} )} ) -}) +} /** * Shows the children irregardless of whether the selected project has loaded or not * We'll eventually want to use this instead of the current ProjectLayout to prevent * a catch-all spinner on the dashboard */ -export const ProjectLayoutNonBlocking = observer( - ({ - title, - product = '', - productMenu, - children, - hideHeader = false, - hideIconBar = false, - }: ProjectLayoutProps) => { - const { ui } = useStore() - const router = useRouter() - const { ref: projectRef } = useParams() - const ongoingIncident = useFlag('ongoingIncident') - const isPaused = ui.selectedProject?.status === PROJECT_STATUS.INACTIVE - const ignorePausedState = - router.pathname === '/project/[ref]' || router.pathname.includes('/project/[ref]/settings') - const showPausedState = isPaused && !ignorePausedState +export const ProjectLayoutNonBlocking = ({ + title, + product = '', + productMenu, + children, + hideHeader = false, + hideIconBar = false, +}: PropsWithChildren) => { + const selectedProject = useSelectedProject() + const router = useRouter() + const { ref: projectRef } = useParams() + const ongoingIncident = useFlag('ongoingIncident') + const isPaused = selectedProject?.status === PROJECT_STATUS.INACTIVE + const ignorePausedState = + router.pathname === '/project/[ref]' || router.pathname.includes('/project/[ref]/settings') + const showPausedState = isPaused && !ignorePausedState - return ( - - - {title ? `${title} | Supabase` : 'Supabase'} - - - -
    - {/* Left-most navigation side bar to access products */} - {!hideIconBar && } + return ( + + + {title ? `${title} | Supabase` : 'Supabase'} + + + +
    + {/* Left-most navigation side bar to access products */} + {!hideIconBar && } - {/* Product menu bar */} - {productMenu && !showPausedState && ( - {productMenu} - )} + {/* Product menu bar */} + {productMenu && !showPausedState && ( + {productMenu} + )} -
    - {!hideHeader && } - {showPausedState ? ( -
    -
    - -
    +
    + {!hideHeader && } + {showPausedState ? ( +
    +
    +
    - ) : ( - children - )} -
    -
    - - ) - } -) +
    + ) : ( + children + )} + +
    +
    + ) +} diff --git a/studio/components/layouts/ProjectLayout/ProjectPausedState.tsx b/studio/components/layouts/ProjectLayout/ProjectPausedState.tsx index 3bc5d3a4bd4..adadffcaae4 100644 --- a/studio/components/layouts/ProjectLayout/ProjectPausedState.tsx +++ b/studio/components/layouts/ProjectLayout/ProjectPausedState.tsx @@ -1,25 +1,30 @@ +import * as Tooltip from '@radix-ui/react-tooltip' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' import Link from 'next/link' import { useState } from 'react' -import * as Tooltip from '@radix-ui/react-tooltip' -import { Modal, Button, IconPauseCircle } from 'ui' -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions, useStore, useFlag } from 'hooks' -import { post } from 'lib/common/fetch' -import { API_URL, PROJECT_STATUS } from 'lib/constants' +import { useParams } from 'common' import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog' import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' -import { useParams } from 'common' +import { setProjectStatus } from 'data/projects/projects-query' +import { useCheckPermissions, useFlag, useSelectedOrganization, useStore } from 'hooks' +import { post } from 'lib/common/fetch' +import { API_URL, PROJECT_STATUS } from 'lib/constants' +import { Button, IconPauseCircle, Modal } from 'ui' +import { useProjectContext } from './ProjectContext' export interface ProjectPausedStateProps { product?: string } const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { - const { ui, app } = useStore() + const queryClient = useQueryClient() + const { ui } = useStore() const { ref } = useParams() - const project = ui.selectedProject - const orgSlug = ui.selectedOrganization?.slug + const selectedOrganization = useSelectedOrganization() + const { project } = useProjectContext() + const orgSlug = selectedOrganization?.slug const kpsEnabled = useFlag('initWithKps') const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery({ slug: orgSlug }) @@ -28,7 +33,7 @@ const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { const [showConfirmRestore, setShowConfirmRestore] = useState(false) const [showFreeProjectLimitWarning, setShowFreeProjectLimitWarning] = useState(false) - const canResumeProject = checkPermissions( + const canResumeProject = useCheckPermissions( PermissionAction.INFRA_EXECUTE, 'queue_jobs.projects.initialize_or_resume' ) @@ -53,7 +58,7 @@ const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { } await post(`${API_URL}/projects/${project.ref}/restore`, { kps_enabled: kpsEnabled }) - app.onProjectUpdated({ ...project, status: PROJECT_STATUS.RESTORING }) + setProjectStatus(queryClient, project.ref, PROJECT_STATUS.RESTORING) ui.setNotification({ category: 'success', message: 'Restoring project' }) } diff --git a/studio/components/layouts/ProjectLayout/RestoringState.tsx b/studio/components/layouts/ProjectLayout/RestoringState.tsx index ce1e8130d45..12cd01d836a 100644 --- a/studio/components/layouts/ProjectLayout/RestoringState.tsx +++ b/studio/components/layouts/ProjectLayout/RestoringState.tsx @@ -1,16 +1,18 @@ import Link from 'next/link' import { FC, useEffect, useRef, useState } from 'react' -import { Button, IconAlertCircle, IconCheckCircle, IconLoader } from 'ui' +import { getProjectDetail } from 'data/projects/project-detail-query' import { useStore } from 'hooks' -import { API_URL, PROJECT_STATUS } from 'lib/constants' import { getWithTimeout } from 'lib/common/fetch' +import { API_URL, PROJECT_STATUS } from 'lib/constants' +import { Button, IconAlertCircle, IconCheckCircle, IconLoader } from 'ui' +import { useProjectContext } from './ProjectContext' interface Props {} const RestoringState: FC = ({}) => { - const { app, ui, meta } = useStore() - const project = ui.selectedProject + const { meta } = useStore() + const { project } = useProjectContext() const checkServerInterval = useRef() const [loading, setLoading] = useState(false) @@ -41,8 +43,13 @@ const RestoringState: FC = ({}) => { } const onConfirm = async () => { + if (!project) return console.error('Project is required') + setLoading(true) - await app.projects.fetchDetail(project?.ref ?? '', (project) => meta.setProjectDetails(project)) + const projectDetail = await getProjectDetail({ ref: project?.ref }) + if (projectDetail) { + meta.setProjectDetails(projectDetail) + } } return ( diff --git a/studio/components/layouts/ProjectLayout/UpgradingState/UpgradingState.tsx b/studio/components/layouts/ProjectLayout/UpgradingState/UpgradingState.tsx index 0ec3881d623..2f6b15649e5 100644 --- a/studio/components/layouts/ProjectLayout/UpgradingState/UpgradingState.tsx +++ b/studio/components/layouts/ProjectLayout/UpgradingState/UpgradingState.tsx @@ -1,42 +1,44 @@ +import * as Tooltip from '@radix-ui/react-tooltip' +import { DatabaseUpgradeStatus } from '@supabase/shared-types/out/events' import dayjs from 'dayjs' import Link from 'next/link' import { useState } from 'react' -import * as Tooltip from '@radix-ui/react-tooltip' import { Button, - IconCircle, - IconCheckCircle, - IconSettings, IconAlertCircle, - IconLoader, IconCheck, + IconCheckCircle, + IconCircle, + IconLoader, IconMaximize2, IconMinimize2, + IconSettings, } from 'ui' -import { DatabaseUpgradeStatus } from '@supabase/shared-types/out/events' -import { useStore } from 'hooks' import { useParams } from 'common/hooks' -import { IS_PLATFORM } from 'lib/constants' -import { DATABASE_UPGRADE_MESSAGES } from './UpgradingState.constants' import { useProjectUpgradingStatusQuery } from 'data/config/project-upgrade-status-query' +import { getProjectDetail } from 'data/projects/project-detail-query' +import { useStore } from 'hooks' +import { IS_PLATFORM } from 'lib/constants' +import { useProjectContext } from '../ProjectContext' +import { DATABASE_UPGRADE_MESSAGES } from './UpgradingState.constants' const UpgradingState = () => { const { ref } = useParams() - const { app, ui, meta } = useStore() + const { meta } = useStore() + const { project } = useProjectContext() const [loading, setLoading] = useState(false) const [isExpanded, setIsExpanded] = useState(false) const { data } = useProjectUpgradingStatusQuery( { projectRef: ref, - projectStatus: ui.selectedProject?.status, + projectStatus: project?.status, }, { enabled: IS_PLATFORM, } ) - const project = ui.selectedProject const { initiated_at, status, progress, target_version, error } = data?.databaseUpgradeStatus ?? {} const progressStage = Number((progress || '').split('_')[0]) @@ -51,7 +53,11 @@ const UpgradingState = () => { const refetchProjectDetails = async () => { setLoading(true) - await app.projects.fetchDetail(project?.ref ?? '', (project) => meta.setProjectDetails(project)) + + const projectDetail = await getProjectDetail({ ref }) + if (projectDetail) { + meta.setProjectDetails(projectDetail) + } } const subject = 'Upgrade%20failed%20for%20project' diff --git a/studio/components/layouts/ReportsLayout/ReportsLayout.tsx b/studio/components/layouts/ReportsLayout/ReportsLayout.tsx index 6c789413f9e..f1517357776 100644 --- a/studio/components/layouts/ReportsLayout/ReportsLayout.tsx +++ b/studio/components/layouts/ReportsLayout/ReportsLayout.tsx @@ -1,9 +1,9 @@ -import { PropsWithChildren } from 'react' -import { observer } from 'mobx-react-lite' import { useRouter } from 'next/router' -import { useStore, withAuth } from 'hooks' -import ProjectLayout from '../' +import { PropsWithChildren } from 'react' + import ProductMenu from 'components/ui/ProductMenu' +import { useSelectedProject, withAuth } from 'hooks' +import ProjectLayout from '../' import { generateReportsMenu } from './ReportsMenu.utils' interface ReportsLayoutProps { @@ -11,10 +11,9 @@ interface ReportsLayoutProps { } const ReportsLayout = ({ title, children }: PropsWithChildren) => { - const { ui } = useStore() const router = useRouter() - const project = ui.selectedProject + const project = useSelectedProject() const page = router.pathname.split('/')[4] || '' return ( @@ -30,4 +29,4 @@ const ReportsLayout = ({ title, children }: PropsWithChildren { const { ui } = useStore() const { ref, id } = useParams() const router = useRouter() - const { data: profile } = useProfileQuery() + const { profile } = useProfile() const [filterString, setFilterString] = useState('') const { setPages, setIsOpen } = useCommandMenu() const showCmdkHelper = useFlag('dashboardCmdk') @@ -61,7 +50,7 @@ const SideBarContent = observer(() => { filterString.length === 0 ? queries : queries.filter((tab) => tab.name.toLowerCase().includes(filterString.toLowerCase())) - const canCreateSQLSnippet = checkPermissions(PermissionAction.CREATE, 'user_content', { + const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', { resource: { type: 'sql', owner_id: profile?.id }, subject: { id: profile?.id }, }) @@ -94,26 +83,21 @@ const SideBarContent = observer(() => {
    {IS_PLATFORM && ( -
    -
    - - {showCmdkHelper && ( - +
    + handleNewQuery()}> +
    +

    New blank query

    +
    + , +
    , + showCmdkHelper ? ( { @@ -122,22 +106,23 @@ const SideBarContent = observer(() => { }} >
    -

    New AI query

    -

    - Generate a SQL query using Supabase AI -

    +

    New AI query

    -
    , - ]} + + ) : null, + ]} + > + +
    >( + ({ className, ...props }, ref) => { + return ( +
    + ) + } +) + +const ScaffoldTitle = React.forwardRef< + HTMLHeadingElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + return

    +}) + +const ScaffoldContainer = React.forwardRef>( + ({ className, ...props }, ref) => { + return
    + } +) + +const ScaffoldDivider = React.forwardRef>( + ({ className, ...props }, ref) => { + return
    + } +) + +const ScaffoldSection = React.forwardRef>( + ({ className, ...props }, ref) => { + return ( +
    + ) + } +) + +const ScaffoldSectionDetail = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + return
    +}) + +const ScaffoldSectionContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + return ( +
    + ) +}) + +// Table and filters +const ScaffoldFilterAndContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + return
    +}) + +// Actions Group +const ScaffoldActionsContainer = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + return
    +}) + +// Actions Group +const ScaffoldActionsGroup = React.forwardRef>( + ({ className, ...props }, ref) => { + return
    + } +) + +// For older layouts +const ScaffoldContainerLegacy = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + return ( +
    + ) +}) + +ScaffoldHeader.displayName = 'ScaffoldHeader' +ScaffoldTitle.displayName = 'ScaffoldTitle' +ScaffoldContainer.displayName = 'ScaffoldContainer' +ScaffoldDivider.displayName = 'ScaffoldDivider' +ScaffoldSection.displayName = 'ScaffoldSection' +ScaffoldSectionDetail.displayName = 'ScaffoldSectionDetail' +ScaffoldSectionContent.displayName = 'ScaffoldSectionContent' +ScaffoldFilterAndContent.displayName = 'ScaffoldFilterAndContent' +ScaffoldActionsContainer.displayName = 'ScaffoldActionsContainer' +ScaffoldActionsGroup.displayName = 'ScaffoldActionsGroup' +ScaffoldContainerLegacy.displayName = 'ScaffoldContainerLegacy' + +export { + ScaffoldHeader, + ScaffoldTitle, + ScaffoldContainer, + ScaffoldDivider, + ScaffoldSection, + ScaffoldSectionDetail, + ScaffoldSectionContent, + ScaffoldFilterAndContent, + ScaffoldActionsContainer, + ScaffoldActionsGroup, + ScaffoldContainerLegacy, +} diff --git a/studio/components/layouts/SettingsLayout/SettingsLayout.tsx b/studio/components/layouts/SettingsLayout/SettingsLayout.tsx index 983a2c67393..4c4d2425c38 100644 --- a/studio/components/layouts/SettingsLayout/SettingsLayout.tsx +++ b/studio/components/layouts/SettingsLayout/SettingsLayout.tsx @@ -1,7 +1,7 @@ import { FC, ReactNode, useEffect } from 'react' import { observer } from 'mobx-react-lite' import { useRouter } from 'next/router' -import { useFlag, useStore, withAuth } from 'hooks' +import { useFlag, useSelectedProject, useStore, withAuth } from 'hooks' import { generateSettingsMenu } from './SettingsMenu.utils' import ProjectLayout from '../' @@ -14,8 +14,7 @@ interface Props { const SettingsLayout: FC = ({ title, children }) => { const { ui, meta } = useStore() - const projectRef = ui.selectedProjectRef as string - const projectBaseInfo = ui.selectedProjectBaseInfo + const project = useSelectedProject() const router = useRouter() // billing pages live under /billing/invoices and /billing/subscription, etc @@ -25,13 +24,13 @@ const SettingsLayout: FC = ({ title, children }) => { : router.pathname.split('/')[4] const isVaultEnabled = useFlag('vaultExtension') - const menuRoutes = generateSettingsMenu(projectRef, projectBaseInfo, isVaultEnabled) + const menuRoutes = generateSettingsMenu(project?.ref as string, project, isVaultEnabled) useEffect(() => { - if (ui.selectedProject?.ref) { + if (project?.ref) { meta.extensions.load() } - }, [ui.selectedProject?.ref]) + }, [project?.ref]) return ( { - const canUpdateBuckets = checkPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') + const canUpdateBuckets = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') return (
    { + const { ref } = useParams() + + return ( +
    +
    + + + + + , + ]} + > +

    + Please try refreshing your browser, or contact support if the issue persists +

    +

    Error: {(error as any)?.message ?? 'Unknown'}

    +
    +
    +
    + ) +} + +export default StorageBucketsError diff --git a/studio/components/layouts/StorageLayout/StorageLayout.tsx b/studio/components/layouts/StorageLayout/StorageLayout.tsx index 797b327ad60..9592b2f9c6b 100644 --- a/studio/components/layouts/StorageLayout/StorageLayout.tsx +++ b/studio/components/layouts/StorageLayout/StorageLayout.tsx @@ -1,10 +1,9 @@ -import { observer } from 'mobx-react-lite' import { ReactNode, useEffect } from 'react' import { useParams } from 'common/hooks' import DeleteBucketModal from 'components/to-be-cleaned/Storage/DeleteBucketModal' import { AutoApiService, useProjectApiQuery } from 'data/config/project-api-query' -import { useStore, withAuth } from 'hooks' +import { useSelectedProject, useStore, withAuth } from 'hooks' import { PROJECT_STATUS } from 'lib/constants' import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore' import ProjectLayout from '../' @@ -18,6 +17,7 @@ export interface StorageLayoutProps { const StorageLayout = ({ title, children }: StorageLayoutProps) => { const { ui } = useStore() const { ref: projectRef } = useParams() + const project = useSelectedProject() const storageExplorerStore = useStorageStore() const { selectedBucketToEdit, closeDeleteBucketModal, showDeleteBucketModal } = @@ -26,11 +26,11 @@ const StorageLayout = ({ title, children }: StorageLayoutProps) => { const { data: settings, isLoading } = useProjectApiQuery({ projectRef }) const apiService = settings?.autoApiService - const isPaused = ui.selectedProject?.status === PROJECT_STATUS.INACTIVE + const isPaused = project?.status === PROJECT_STATUS.INACTIVE useEffect(() => { if (!isLoading && apiService) initializeStorageStore(apiService) - }, [isLoading]) + }, [isLoading, projectRef]) const initializeStorageStore = async (apiService: AutoApiService) => { if (isPaused) return @@ -64,4 +64,4 @@ const StorageLayout = ({ title, children }: StorageLayoutProps) => { ) } -export default withAuth(observer(StorageLayout)) +export default withAuth(StorageLayout) diff --git a/studio/components/layouts/StorageLayout/StorageMenu.tsx b/studio/components/layouts/StorageLayout/StorageMenu.tsx index 1d85c34ee92..dd66d310e56 100644 --- a/studio/components/layouts/StorageLayout/StorageMenu.tsx +++ b/studio/components/layouts/StorageLayout/StorageMenu.tsx @@ -6,7 +6,7 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { Button, Menu, Alert, IconEdit } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions } from 'hooks' +import { useCheckPermissions } from 'hooks' import { useParams } from 'common/hooks' import BucketRow from './BucketRow' import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore' @@ -21,7 +21,7 @@ const StorageMenu = () => { const { ref, bucketId } = useParams() const [showCreateBucketModal, setShowCreateBucketModal] = useState(false) const [selectedBucketToEdit, setSelectedBucketToEdit] = useState() - const canCreateBuckets = checkPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') + const canCreateBuckets = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') const page = router.pathname.split('/')[4] as | undefined @@ -31,7 +31,7 @@ const StorageMenu = () => { | 'logs' const storageExplorerStore = useStorageStore() - const { data, isLoading } = useBucketsQuery({ projectRef: ref }) + const { data, isLoading, isError, isSuccess } = useBucketsQuery({ projectRef: ref }) const { openDeleteBucketModal } = storageExplorerStore || {} const buckets = data ?? [] @@ -80,13 +80,24 @@ const StorageMenu = () => {
    - {isLoading ? ( + + {isLoading && (
    - ) : ( + )} + + {isError && ( +
    + + Please refresh to try again. + +
    + )} + + {isSuccess && ( <> {buckets.length === 0 && (
    diff --git a/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx b/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx index 60143db8f77..2cf50e4643f 100644 --- a/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx +++ b/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx @@ -1,21 +1,21 @@ -import { PropsWithChildren, useEffect } from 'react' -import { useRouter } from 'next/router' +import { PermissionAction } from '@supabase/shared-types/out/constants' import { noop } from 'lodash' import { observer } from 'mobx-react-lite' -import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useRouter } from 'next/router' +import { PropsWithChildren, useEffect } from 'react' -import { checkPermissions, useStore } from 'hooks' import { useParams } from 'common/hooks' -import { Entity } from 'data/entity-types/entity-type-query' +import Connecting from 'components/ui/Loading/Loading' +import NoPermission from 'components/ui/NoPermission' import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' +import { Entity } from 'data/entity-types/entity-type-query' +import { useCheckPermissions, useSelectedProject, useStore } from 'hooks' +import useEntityType from 'hooks/misc/useEntityType' +import useLatest from 'hooks/misc/useLatest' import { useIsTableLoaded, useTableEditorStateSnapshot } from 'state/table-editor' import ProjectLayout from '../' -import TableEditorMenu from './TableEditorMenu' -import NoPermission from 'components/ui/NoPermission' -import useEntityType from 'hooks/misc/useEntityType' -import Connecting from 'components/ui/Loading/Loading' -import useLatest from 'hooks/misc/useLatest' import useTableRowsPrefetchWrapper from './TableEditorLayout.utils' +import TableEditorMenu from './TableEditorMenu' export interface TableEditorLayoutProps { selectedSchema?: string @@ -37,26 +37,27 @@ const TableEditorLayout = ({ onDuplicateTable = noop, children, }: PropsWithChildren) => { - const { vault, meta, ui } = useStore() + const { ui, vault, meta } = useStore() + const selectedProject = useSelectedProject() const router = useRouter() const { ref, id: _id } = useParams() const id = _id ? Number(_id) : undefined const snap = useTableEditorStateSnapshot() - const canReadTables = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'tables') + const canReadTables = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'tables') const vaultExtension = meta.extensions.byId('supabase_vault') const isVaultEnabled = vaultExtension !== undefined && vaultExtension.installed_version !== null useEffect(() => { - if (ui.selectedProject?.ref) { + if (ui.selectedProjectRef) { meta.schemas.load() meta.types.load() meta.policies.load() meta.publications.load() meta.extensions.load() } - }, [ui.selectedProject?.ref]) + }, [ui.selectedProjectRef]) const isLoaded = useIsTableLoaded(ref, id) @@ -112,7 +113,7 @@ const TableEditorLayout = ({ if (isVaultEnabled) { vault.load() } - }, [ui.selectedProject?.ref, isVaultEnabled]) + }, [selectedProject?.ref, isVaultEnabled]) if (!canReadTables) { return ( diff --git a/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index 4c6e3d1c8b2..1e8f7e114e6 100644 --- a/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -20,7 +20,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import type { PostgresSchema } from '@supabase/postgres-meta' import { useParams } from 'common/hooks' -import { checkPermissions, useStore, useLocalStorage } from 'hooks' +import { useCheckPermissions, useStore, useLocalStorage } from 'hooks' import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query' import { Entity } from 'data/entity-types/entity-type-query' import { useProjectContext } from '../ProjectLayout/ProjectContext' @@ -85,7 +85,7 @@ const TableEditorMenu = ({ const schemas: PostgresSchema[] = meta.schemas.list() const schema = schemas.find((schema) => schema.name === selectedSchema) - const canCreateTables = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') + const canCreateTables = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') const isLoadingTableMetadata = id ? !meta.tables.byId(id) : true diff --git a/studio/components/to-be-cleaned/Docs/Description.js b/studio/components/to-be-cleaned/Docs/Description.js index ec37ba6e9b1..fc942ac6c50 100644 --- a/studio/components/to-be-cleaned/Docs/Description.js +++ b/studio/components/to-be-cleaned/Docs/Description.js @@ -2,7 +2,7 @@ import { useState } from 'react' import { Button, IconLoader } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useStore, checkPermissions } from 'hooks' +import { useStore, useCheckPermissions } from 'hooks' import AutoTextArea from 'components/to-be-cleaned/forms/AutoTextArea' import { timeout } from 'lib/helpers' @@ -32,7 +32,7 @@ export default function Description({ content, metadata, onChange = () => {} }) const hasChanged = value != contentText const animateCss = `transition duration-150` - const canUpdateDescription = checkPermissions(PermissionAction.TENANT_SQL_QUERY, '*') + const canUpdateDescription = useCheckPermissions(PermissionAction.TENANT_SQL_QUERY, '*') const updateDescription = async () => { if (isUpdating || !canUpdateDescription) return false diff --git a/studio/components/to-be-cleaned/Docs/Snippets.js b/studio/components/to-be-cleaned/Docs/Snippets.js index 4e0568b70c9..98d9b8eb026 100644 --- a/studio/components/to-be-cleaned/Docs/Snippets.js +++ b/studio/components/to-be-cleaned/Docs/Snippets.js @@ -47,8 +47,8 @@ supabase: Client = create_client(url, key) dart: { language: 'dart', code: ` -final supabaseUrl = '${endpoint}' -final supabaseKey = String.fromEnvironment('SUPABASE_KEY') +const supabaseUrl = '${endpoint}'; +const supabaseKey = String.fromEnvironment('SUPABASE_KEY'); Future main() async { await Supabase.initialize(url: supabaseUrl, anonKey: supabaseKey); @@ -378,6 +378,7 @@ const { data, error } = await supabase .insert([ { some_column: 'someValue', other_column: 'otherValue' }, ]) + .select() `, }, }), @@ -402,6 +403,7 @@ const { data, error } = await supabase { some_column: 'someValue' }, { some_column: 'otherValue' }, ]) + .select() `, }, }), @@ -423,7 +425,8 @@ curl -X POST '${endpoint}/rest/v1/${resourceId}' \\ code: ` const { data, error } = await supabase .from('${resourceId}') - .upsert({ some_column: 'someValue' }).select() + .upsert({ some_column: 'someValue' }) + .select() `, }, }), @@ -447,6 +450,7 @@ const { data, error } = await supabase .from('${resourceId}') .update({ other_column: 'otherValue' }) .eq('some_column', 'someValue') + .select() `, }, }), @@ -463,7 +467,7 @@ curl -X DELETE '${endpoint}/rest/v1/${resourceId}?some_column=eq.someValue' \\ js: { language: 'js', code: ` -const { data, error } = await supabase +const { error } = await supabase .from('${resourceId}') .delete() .eq('some_column', 'someValue') diff --git a/studio/components/to-be-cleaned/Dropdown/OrganizationDropdown.js b/studio/components/to-be-cleaned/Dropdown/OrganizationDropdown.js index c0d03ac03c9..ddfa2ba75b0 100644 --- a/studio/components/to-be-cleaned/Dropdown/OrganizationDropdown.js +++ b/studio/components/to-be-cleaned/Dropdown/OrganizationDropdown.js @@ -1,17 +1,11 @@ -import React from 'react' import { useRouter } from 'next/router' -import { toJS } from 'mobx' -import { Button, Dropdown, IconPlus } from 'ui' -import { observer } from 'mobx-react-lite' + import { useFlag } from 'hooks' +import { Button, Dropdown, IconPlus } from 'ui' const OrganizationDropdown = ({ organizations }) => { const router = useRouter() - const organizationList = Object.values(toJS(organizations.data)).sort((a, b) => - a.name.localeCompare(b.name) - ) - const orgCreationV2 = useFlag('orgcreationv2') return ( @@ -21,7 +15,7 @@ const OrganizationDropdown = ({ organizations }) => { overlay={ <> Choose organization - {organizationList + {organizations .sort((a, b) => a.name.localeCompare(b.name)) .map((x) => ( { ) } -export default observer(OrganizationDropdown) +export default OrganizationDropdown diff --git a/studio/components/to-be-cleaned/ListIcons.tsx b/studio/components/to-be-cleaned/ListIcons.tsx index 8d38c8feecc..28430ead532 100644 --- a/studio/components/to-be-cleaned/ListIcons.tsx +++ b/studio/components/to-be-cleaned/ListIcons.tsx @@ -2,9 +2,9 @@ import { IconDatabase } from 'ui' import SVG from 'react-inlinesvg' export const vercelIcon = ( -
    +
    { } />

    - Hmm, we couldn't find any results that matches your query. Try another? + Hmm, we couldn't find any results that match your query.

    ) diff --git a/studio/components/to-be-cleaned/Storage/Storage.types.ts b/studio/components/to-be-cleaned/Storage/Storage.types.ts new file mode 100644 index 00000000000..fcb2ac2e405 --- /dev/null +++ b/studio/components/to-be-cleaned/Storage/Storage.types.ts @@ -0,0 +1,30 @@ +export interface StorageColumn { + id: string + name: string + status: string + items: StorageItem[] + hasMoreItems: boolean + isLoadingMoreItems: boolean +} + +export interface StorageItem { + id: string | null + name: string + type: string + status: string + metadata: StorageItemMetadata | null + isCorrupted: boolean + created_at: string | null + updated_at: string | null + last_accessed_at: string | null +} + +export interface StorageItemMetadata { + cacheControl: string + contentLength: number + size: number + httpStatusCode: number + eTag: string + lastModified: string + mimetype: string +} diff --git a/studio/components/to-be-cleaned/Storage/StorageExplorer/CustomExpiryModal.tsx b/studio/components/to-be-cleaned/Storage/StorageExplorer/CustomExpiryModal.tsx index f702ba38bfa..d20d137cdbc 100644 --- a/studio/components/to-be-cleaned/Storage/StorageExplorer/CustomExpiryModal.tsx +++ b/studio/components/to-be-cleaned/Storage/StorageExplorer/CustomExpiryModal.tsx @@ -1,14 +1,14 @@ -import { FC } from 'react' import { Form, Modal, Input, Button } from 'ui' import { observer } from 'mobx-react-lite' import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore' -interface Props {} +export interface CustomExpiryModalProps { + onCopyUrl: (name: string, url: string) => void +} -const CustomExpiryModal: FC = () => { +const CustomExpiryModal = ({ onCopyUrl }: CustomExpiryModalProps) => { const storageExplorerStore = useStorageStore() - const { selectedFileCustomExpiry, setSelectedFileCustomExpiry, copyFileURLToClipboard } = - storageExplorerStore + const { getFileUrl, selectedFileCustomExpiry, setSelectedFileCustomExpiry } = storageExplorerStore const visible = selectedFileCustomExpiry !== undefined const onClose = () => setSelectedFileCustomExpiry(undefined) @@ -28,7 +28,10 @@ const CustomExpiryModal: FC = () => { initialValues={{ expiresIn: '' }} onSubmit={async (values: any, { setSubmitting }: any) => { setSubmitting(true) - await copyFileURLToClipboard(selectedFileCustomExpiry, values.expiresIn) + onCopyUrl( + selectedFileCustomExpiry.name, + await getFileUrl(selectedFileCustomExpiry, values.expiresIn) + ) setSubmitting(false) onClose() }} diff --git a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorer.js b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorer.tsx similarity index 74% rename from studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorer.js rename to studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorer.tsx index 2b41ffda51a..09783f3f591 100644 --- a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorer.js +++ b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorer.tsx @@ -6,6 +6,21 @@ import ItemContextMenu from './ItemContextMenu' import FolderContextMenu from './FolderContextMenu' import ColumnContextMenu from './ColumnContextMenu' import FileExplorerColumn from './FileExplorerColumn' +import { noop } from 'lodash' +import { StorageColumn } from '../Storage.types' + +export interface FileExplorerProps { + view: string + columns: any[] + openedFolders: any[] + selectedItems: any[] + selectedFilePreview: any + onFilesUpload: (event: any, index: number) => void + onSelectAllItemsInColumn: (index: number) => void + onSelectColumnEmptySpace: (index: number) => void + onColumnLoadMore: (index: number, column: StorageColumn) => void + onCopyUrl: (name: string, url: string) => void +} const FileExplorer = ({ view = STORAGE_VIEWS.COLUMNS, @@ -13,12 +28,13 @@ const FileExplorer = ({ openedFolders = [], selectedItems = [], selectedFilePreview = {}, - onFilesUpload = () => {}, - onSelectAllItemsInColumn = () => {}, - onSelectColumnEmptySpace = () => {}, - onColumnLoadMore = () => {}, -}) => { - const fileExplorerRef = useRef(null) + onFilesUpload = noop, + onSelectAllItemsInColumn = noop, + onSelectColumnEmptySpace = noop, + onColumnLoadMore = noop, + onCopyUrl = noop, +}: FileExplorerProps) => { + const fileExplorerRef = useRef(null) useEffect(() => { if (fileExplorerRef) { @@ -35,7 +51,7 @@ const FileExplorer = ({ className="file-explorer flex flex-grow overflow-x-auto justify-between h-full w-full" > - + {view === STORAGE_VIEWS.COLUMNS ? (
    @@ -52,6 +68,7 @@ const FileExplorer = ({ onSelectAllItemsInColumn={onSelectAllItemsInColumn} onSelectColumnEmptySpace={onSelectColumnEmptySpace} onColumnLoadMore={onColumnLoadMore} + onCopyUrl={onCopyUrl} /> ))}
    @@ -69,6 +86,7 @@ const FileExplorer = ({ onSelectAllItemsInColumn={onSelectAllItemsInColumn} onSelectColumnEmptySpace={onSelectColumnEmptySpace} onColumnLoadMore={onColumnLoadMore} + onCopyUrl={onCopyUrl} /> )} diff --git a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerColumn.js b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerColumn.tsx similarity index 88% rename from studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerColumn.js rename to studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerColumn.tsx index 3bf029e8f5f..264deef55a7 100644 --- a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerColumn.js +++ b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerColumn.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react' -import { get, sum } from 'lodash' +import { get, noop, sum } from 'lodash' import { Checkbox, IconUpload } from 'ui' import { Transition } from '@headlessui/react' import { useContextMenu } from 'react-contexify' @@ -15,8 +15,9 @@ import { } from '../Storage.constants' import { formatBytes } from 'lib/helpers' import { BASE_PATH } from 'lib/constants' +import { StorageColumn } from '../Storage.types' -const DragOverOverlay = ({ isOpen, onDragLeave, onDrop, folderIsEmpty }) => { +const DragOverOverlay = ({ isOpen, onDragLeave, onDrop, folderIsEmpty }: any) => { return ( { ) } +export interface FileExplorerColumnProps { + index: number + view: string + column: StorageColumn + fullWidth?: boolean + openedFolders?: any[] + selectedItems: any[] + selectedFilePreview: any + onFilesUpload: (event: any, index: number) => void + onSelectAllItemsInColumn: (index: number) => void + onSelectColumnEmptySpace: (index: number) => void + onColumnLoadMore: (index: number, column: StorageColumn) => void + onCopyUrl: (name: string, url: string) => void +} + const FileExplorerColumn = ({ index = 0, view = STORAGE_VIEWS.COLUMNS, - column = {}, + column, fullWidth = false, openedFolders = [], selectedItems = [], selectedFilePreview = {}, - onFilesUpload = () => {}, - onSelectAllItemsInColumn = () => {}, - onSelectColumnEmptySpace = () => {}, - onColumnLoadMore = () => {}, -}) => { + onFilesUpload = noop, + onSelectAllItemsInColumn = noop, + onSelectColumnEmptySpace = noop, + onColumnLoadMore = noop, + onCopyUrl = noop, +}: FileExplorerColumnProps) => { const [isDraggedOver, setIsDraggedOver] = useState(false) - const fileExplorerColumnRef = useRef(null) + const fileExplorerColumnRef = useRef(null) useEffect(() => { if (fileExplorerColumnRef) { @@ -87,14 +104,14 @@ const FileExplorerColumn = ({ const columnItemsSize = sum(columnItems.map((item) => get(item, ['metadata', 'size'], 0))) const { show } = useContextMenu() - const displayMenu = (event) => { + const displayMenu = (event: any) => { show(event, { id: CONTEXT_MENU_KEYS.STORAGE_COLUMN, props: { index }, }) } - const onDragOver = (event) => { + const onDragOver = (event: any) => { if (event) { event.stopPropagation() event.preventDefault() @@ -104,7 +121,7 @@ const FileExplorerColumn = ({ } } - const onDrop = (event) => { + const onDrop = (event: any) => { onDragOver(event) onFilesUpload(event, index) } @@ -144,7 +161,7 @@ const FileExplorerColumn = ({ {view === STORAGE_VIEWS.COLUMNS && (
    0 ? 'h-10 py-3 opacity-100' : 'h-0 py-0 opacity-0' + haveSelectedItems ? 'h-10 py-3 opacity-100' : 'h-0 py-0 opacity-0' } transition-all duration-200`} onClick={(event) => event.stopPropagation()} > @@ -202,15 +219,7 @@ const FileExplorerColumn = ({ selectedItems, openedFolders, selectedFilePreview, - // onCheckItem, - // onRenameFile, - // onCopyFileURL, - // onDownloadFile, - // onRenameFolder, - // onCreateFolder, - // onSelectItemDelete, - // onSelectItemRename, - // onSelectItemMove, + onCopyUrl, }} ItemComponent={FileExplorerRow} getItemSize={(index) => (index !== 0 && index === columnItems.length ? 85 : 37)} diff --git a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeader.tsx b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeader.tsx index 9e121416c62..bfd8d6a21c4 100644 --- a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeader.tsx +++ b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeader.tsx @@ -21,7 +21,7 @@ import { import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions } from 'hooks' +import { useCheckPermissions } from 'hooks' import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore' import { STORAGE_VIEWS, STORAGE_SORT_BY, STORAGE_SORT_BY_ORDER } from '../Storage.constants' @@ -141,7 +141,7 @@ const FileExplorerHeader: FC = ({ const breadcrumbs = columns.map((column: any) => column.name) const backDisabled = columns.length <= 1 - const canUpdateStorage = checkPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') + const canUpdateStorage = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') useEffect(() => { if (itemSearchString) setSearchString(itemSearchString) diff --git a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx index 95fb39f36ca..ab905d4f326 100644 --- a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx +++ b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx @@ -21,7 +21,7 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { useContextMenu } from 'react-contexify' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions } from 'hooks' +import { useCheckPermissions } from 'hooks' import { STORAGE_VIEWS, STORAGE_ROW_TYPES, @@ -71,7 +71,7 @@ export const RowIcon = ({ view, status, fileType, mimeType }: any) => { return } -interface FileExplorerRowProps { +export interface FileExplorerRowProps { index: number item: any view: string @@ -79,6 +79,7 @@ interface FileExplorerRowProps { selectedItems: any[] openedFolders: any[] selectedFilePreview: any + onCopyUrl: (name: string, url: string) => void } const FileExplorerRow = ({ @@ -89,9 +90,11 @@ const FileExplorerRow = ({ selectedItems = [], openedFolders = [], selectedFilePreview = {}, + onCopyUrl, }: FileExplorerRowProps) => { const storageExplorerStore = useStorageStore() const { + getFileUrl, popColumnAtIndex, pushOpenedFolderAtIndex, popOpenedFoldersAtIndex, @@ -107,7 +110,6 @@ const FileExplorerRow = ({ fetchFolderContents, downloadFile, downloadFolder, - copyFileURLToClipboard, selectRangeItems, } = storageExplorerStore @@ -117,7 +119,7 @@ const FileExplorerRow = ({ const isOpened = openedFolders.length > columnIndex ? isEqual(openedFolders[columnIndex], item) : false const isPreviewed = !isEmpty(selectedFilePreview) && isEqual(selectedFilePreview.id, item.id) - const canUpdateFiles = checkPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') + const canUpdateFiles = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') const { show } = useContextMenu() @@ -188,7 +190,11 @@ const FileExplorerRow = ({ { name: 'Get URL', icon: , - onClick: async () => await copyFileURLToClipboard(itemWithColumnIndex), + onClick: async () => + onCopyUrl( + itemWithColumnIndex.name, + await getFileUrl(itemWithColumnIndex) + ), }, ] : [ @@ -199,25 +205,25 @@ const FileExplorerRow = ({ { name: 'Expire in 1 week', onClick: async () => - await copyFileURLToClipboard( - itemWithColumnIndex, - URL_EXPIRY_DURATION.WEEK + onCopyUrl( + itemWithColumnIndex.name, + await getFileUrl(itemWithColumnIndex, URL_EXPIRY_DURATION.WEEK) ), }, { name: 'Expire in 1 month', onClick: async () => - await copyFileURLToClipboard( - itemWithColumnIndex, - URL_EXPIRY_DURATION.MONTH + onCopyUrl( + itemWithColumnIndex.name, + await getFileUrl(itemWithColumnIndex, URL_EXPIRY_DURATION.MONTH) ), }, { name: 'Expire in 1 year', onClick: async () => - await copyFileURLToClipboard( - itemWithColumnIndex, - URL_EXPIRY_DURATION.YEAR + onCopyUrl( + itemWithColumnIndex.name, + await getFileUrl(itemWithColumnIndex, URL_EXPIRY_DURATION.YEAR) ), }, { diff --git a/studio/components/to-be-cleaned/Storage/StorageExplorer/FolderContextMenu.tsx b/studio/components/to-be-cleaned/Storage/StorageExplorer/FolderContextMenu.tsx index 5dea1dd1b93..541d3437f0c 100644 --- a/studio/components/to-be-cleaned/Storage/StorageExplorer/FolderContextMenu.tsx +++ b/studio/components/to-be-cleaned/Storage/StorageExplorer/FolderContextMenu.tsx @@ -4,7 +4,7 @@ import 'react-contexify/dist/ReactContexify.css' import { IconEdit, IconDownload, IconTrash2 } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions } from 'hooks' +import { useCheckPermissions } from 'hooks' import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore' interface Props { @@ -14,7 +14,7 @@ interface Props { const FolderContextMenu: FC = ({ id = '' }) => { const storageExplorerStore = useStorageStore() const { downloadFolder, setSelectedItemToRename, setSelectedItemsToDelete } = storageExplorerStore - const canUpdateFiles = checkPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') + const canUpdateFiles = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') return ( diff --git a/studio/components/to-be-cleaned/Storage/StorageExplorer/ItemContextMenu.tsx b/studio/components/to-be-cleaned/Storage/StorageExplorer/ItemContextMenu.tsx index bd0ad8fec92..beede269ab7 100644 --- a/studio/components/to-be-cleaned/Storage/StorageExplorer/ItemContextMenu.tsx +++ b/studio/components/to-be-cleaned/Storage/StorageExplorer/ItemContextMenu.tsx @@ -1,38 +1,39 @@ -import { FC } from 'react' import { observer } from 'mobx-react-lite' import { Menu, Item, Separator, Submenu } from 'react-contexify' import 'react-contexify/dist/ReactContexify.css' import { PermissionAction } from '@supabase/shared-types/out/constants' import { IconClipboard, IconEdit, IconMove, IconDownload, IconTrash2, IconChevronRight } from 'ui' -import { checkPermissions } from 'hooks' +import { useCheckPermissions } from 'hooks' import { URL_EXPIRY_DURATION } from '../Storage.constants' import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore' +import { noop } from 'lodash' -interface Props { +interface ItemContextMenuProps { id: string + onCopyUrl: (name: string, url: string) => void } -const ItemContextMenu: FC = ({ id = '' }) => { +const ItemContextMenu = ({ id = '', onCopyUrl = noop }: ItemContextMenuProps) => { const storageExplorerStore = useStorageStore() const { + getFileUrl, downloadFile, selectedBucket, setSelectedItemsToDelete, setSelectedItemToRename, setSelectedItemsToMove, setSelectedFileCustomExpiry, - copyFileURLToClipboard, } = storageExplorerStore const isPublic = selectedBucket.public - const canUpdateFiles = checkPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') + const canUpdateFiles = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') const onHandleClick = async (event: any, item: any, expiresIn?: number) => { if (item.isCorrupted) return switch (event) { case 'copy': if (expiresIn !== undefined && expiresIn < 0) return setSelectedFileCustomExpiry(item) - else return await copyFileURLToClipboard(item, expiresIn) + else return onCopyUrl(item.name, await getFileUrl(item, expiresIn)) case 'rename': return setSelectedItemToRename(item) case 'move': diff --git a/studio/components/to-be-cleaned/Storage/StorageExplorer/PreviewPane.tsx b/studio/components/to-be-cleaned/Storage/StorageExplorer/PreviewPane.tsx index 453825ddeec..a50d04e58f6 100644 --- a/studio/components/to-be-cleaned/Storage/StorageExplorer/PreviewPane.tsx +++ b/studio/components/to-be-cleaned/Storage/StorageExplorer/PreviewPane.tsx @@ -17,7 +17,7 @@ import { Transition } from '@headlessui/react' import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions } from 'hooks' +import { useCheckPermissions } from 'hooks' import { URL_EXPIRY_DURATION } from '../Storage.constants' import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore' @@ -94,13 +94,17 @@ const PreviewFile = ({ mimeType, previewUrl }: { mimeType: string; previewUrl: s ) } -const PreviewPane = () => { +export interface PreviewPaneProps { + onCopyUrl: (name: string, url: string) => void +} + +const PreviewPane = ({ onCopyUrl }: PreviewPaneProps) => { const storageExplorerStore = useStorageStore() const { + getFileUrl, downloadFile, selectedBucket, selectedFilePreview: file, - copyFileURLToClipboard, closeFilePreview, setSelectedItemsToDelete, setSelectedFileCustomExpiry, @@ -112,7 +116,7 @@ const PreviewPane = () => { const mimeType = file.metadata ? file.metadata.mimetype : null const createdAt = file.created_at ? new Date(file.created_at).toLocaleString() : 'Unknown' const updatedAt = file.updated_at ? new Date(file.updated_at).toLocaleString() : 'Unknown' - const canUpdateFiles = checkPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') + const canUpdateFiles = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') return ( <> @@ -194,7 +198,7 @@ const PreviewPane = () => {
    ) }) diff --git a/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.tsx b/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.tsx index 2da515fa150..6fe14d00574 100644 --- a/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.tsx +++ b/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { Button, Form, IconClock, Input, Listbox } from 'ui' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' import { IS_PLATFORM } from 'lib/constants' import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query' import { useProjectStorageConfigUpdateUpdateMutation } from 'data/config/project-storage-config-update-mutation' @@ -44,7 +44,7 @@ const StorageConfig = ({ config, projectRef }: any) => { const [selectedUnit, setSelectedUnit] = useState(unit) let initialValues = { fileSizeLimit: value, unformattedFileSizeLimit: fileSizeLimit } - const canUpdateStorageSettings = checkPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') + const canUpdateStorageSettings = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') const formattedMaxSizeBytes = `${new Intl.NumberFormat('en-US').format( STORAGE_FILE_SIZE_LIMIT_MAX_BYTES diff --git a/studio/components/to-be-cleaned/Table.tsx b/studio/components/to-be-cleaned/Table.tsx index dc3c37f83ce..3b25fbcf4da 100644 --- a/studio/components/to-be-cleaned/Table.tsx +++ b/studio/components/to-be-cleaned/Table.tsx @@ -79,6 +79,7 @@ type TdProps = { colSpan?: number className?: string style?: React.CSSProperties + align?: 'left' | 'center' | 'right' | 'justify' | 'char' | undefined } & React.HTMLProps const Td: React.FC = ({ children, colSpan, className, style, ...rest }) => { return ( diff --git a/studio/components/ui/AlertError.tsx b/studio/components/ui/AlertError.tsx new file mode 100644 index 00000000000..7b6af8e4c98 --- /dev/null +++ b/studio/components/ui/AlertError.tsx @@ -0,0 +1,46 @@ +import Link from 'next/link' +import { ResponseError } from 'types' +import { Alert, Button } from 'ui' + +export interface AlertErrorProps { + ref?: string + subject?: string + error?: ResponseError | null + className?: string +} + +// [Joshen] To standardize the language for all error UIs + +const AlertError = ({ ref, subject, error, className }: AlertErrorProps) => { + const subjectString = subject?.replace(/ /g, '%20') + let href = `/support/new?category=dashboard_bug` + + if (ref) href += `&ref=${ref}` + if (subjectString) href += `&subject=${subjectString}` + if (error) href += `&message=Error:%20${error.message}` + + return ( + + + + + , + ]} + > + {error &&

    Error: {error.message}

    } +

    + Try refreshing your browser, but if the issue persists, please reach out to us via support. +

    +
    + ) +} + +export default AlertError diff --git a/studio/components/ui/Charts/AreaChart.tsx b/studio/components/ui/Charts/AreaChart.tsx index be69b3ad2d0..0c1b4277390 100644 --- a/studio/components/ui/Charts/AreaChart.tsx +++ b/studio/components/ui/Charts/AreaChart.tsx @@ -24,7 +24,7 @@ export interface AreaChartProps extends CommonChartProps { displayDateInUtc?: boolean } -const AreaChart: React.FC = ({ +const AreaChart = ({ data, yAxisKey, xAxisKey, @@ -36,8 +36,9 @@ const AreaChart: React.FC = ({ displayDateInUtc, minimalHeader, className = '', + valuePrecision, size = 'normal', -}) => { +}: AreaChartProps) => { const { Container } = useChartSize(size) const [focusDataIndex, setFocusDataIndex] = useState(null) @@ -62,7 +63,7 @@ const AreaChart: React.FC = ({ customDateFormat={customDateFormat} highlightedValue={ typeof resolvedHighlightedValue === 'number' - ? numberFormatter(resolvedHighlightedValue) + ? numberFormatter(resolvedHighlightedValue, valuePrecision) : resolvedHighlightedValue } highlightedLabel={resolvedHighlightedLabel} diff --git a/studio/components/ui/Charts/BarChart.tsx b/studio/components/ui/Charts/BarChart.tsx index e2f9d135882..bde773eb9f7 100644 --- a/studio/components/ui/Charts/BarChart.tsx +++ b/studio/components/ui/Charts/BarChart.tsx @@ -13,13 +13,12 @@ dayjs.extend(utc) export interface BarChartProps extends CommonChartProps { yAxisKey: string xAxisKey: string - format?: string customDateFormat?: string displayDateInUtc?: boolean onBarClick?: (datum: Datum, tooltipData?: CategoricalChartState) => void } -const BarChart: React.FC = ({ +const BarChart = ({ data, yAxisKey, xAxisKey, @@ -30,10 +29,11 @@ const BarChart: React.FC = ({ highlightedLabel, displayDateInUtc, minimalHeader, + valuePrecision, className = '', size = 'normal', onBarClick, -}) => { +}: BarChartProps) => { const { Container } = useChartSize(size) const [focusDataIndex, setFocusDataIndex] = useState(null) @@ -58,7 +58,7 @@ const BarChart: React.FC = ({ customDateFormat={customDateFormat} highlightedValue={ typeof resolvedHighlightedValue === 'number' - ? numberFormatter(resolvedHighlightedValue) + ? numberFormatter(resolvedHighlightedValue, valuePrecision) : resolvedHighlightedValue } highlightedLabel={resolvedHighlightedLabel} diff --git a/studio/components/ui/Charts/ChartHeader.tsx b/studio/components/ui/Charts/ChartHeader.tsx index ee515259815..93ce980b751 100644 --- a/studio/components/ui/Charts/ChartHeader.tsx +++ b/studio/components/ui/Charts/ChartHeader.tsx @@ -1,8 +1,6 @@ -import { DateTimeFormats } from './Charts.constants' - export interface ChartHeaderProps { title?: string - format?: string + format?: string | ((value: unknown) => string) customDateFormat?: string minimalHeader?: boolean displayDateInUtc?: boolean @@ -15,7 +13,7 @@ const ChartHeader: React.FC = ({ highlightedLabel, title, minimalHeader = false, -}) => { +}: ChartHeaderProps) => { const chartTitle = (

    {title}

    ) @@ -24,8 +22,10 @@ const ChartHeader: React.FC = ({
    - {highlightedValue} - {format} + {highlightedValue !== undefined && String(highlightedValue)} + + {typeof format === 'function' ? format(highlightedValue) : format} +
    ) const label =
    {highlightedLabel}
    @@ -35,7 +35,7 @@ const ChartHeader: React.FC = ({
    {title && chartTitle}
    - {highlightedValue && highlighted} + {highlightedValue !== undefined && highlighted} {label}
    @@ -45,7 +45,7 @@ const ChartHeader: React.FC = ({ return (
    {title && chartTitle} - {highlightedValue && highlighted} + {highlightedValue !== undefined && highlighted} {label}
    ) diff --git a/studio/components/ui/Charts/Charts.types.tsx b/studio/components/ui/Charts/Charts.types.tsx index e02178f9350..bf0d1c0aa97 100644 --- a/studio/components/ui/Charts/Charts.types.tsx +++ b/studio/components/ui/Charts/Charts.types.tsx @@ -13,6 +13,7 @@ export interface CommonChartProps > { title?: string className?: string + valuePrecision?: number size?: 'tiny' | 'small' | 'normal' | 'large' } @@ -27,7 +28,7 @@ export interface StackedChartProps extends CommonChartProps { export type HeaderType = { attribute: string focus: number | null - format?: string + format?: string | ((value: unknown) => string); highlightedValue?: number | string highlightedLabel?: string data: D[] diff --git a/studio/components/ui/CodeEditor/CodeEditor.tsx b/studio/components/ui/CodeEditor/CodeEditor.tsx index 253e2ad4775..7f291e08e3a 100644 --- a/studio/components/ui/CodeEditor/CodeEditor.tsx +++ b/studio/components/ui/CodeEditor/CodeEditor.tsx @@ -4,14 +4,14 @@ import Editor, { EditorProps } from '@monaco-editor/react' import { timeout } from 'lib/helpers' import Connecting from '../Loading' import { alignEditor } from './CodeEditor.utils' -import { merge } from 'lodash' +import { merge, noop } from 'lodash' interface Props { id: string language: 'pgsql' | 'json' | 'html' defaultValue?: string isReadOnly?: boolean - onInputChange: (value?: string) => void + onInputChange?: (value?: string) => void onInputRun?: (value: string) => void hideLineNumbers?: boolean className?: string @@ -26,8 +26,8 @@ const CodeEditor: FC = ({ defaultValue, isReadOnly = false, hideLineNumbers = false, - onInputChange = () => {}, - onInputRun = () => {}, + onInputChange = noop, + onInputRun = noop, className, loading, options, diff --git a/studio/components/ui/DatePicker/DatePicker.tsx b/studio/components/ui/DatePicker/DatePicker.tsx index aca6f5d504d..ba89eeecfe1 100644 --- a/studio/components/ui/DatePicker/DatePicker.tsx +++ b/studio/components/ui/DatePicker/DatePicker.tsx @@ -17,11 +17,15 @@ import { DatePickerToFrom } from 'components/interfaces/Settings/Logs' export interface DatePickerProps { onChange?: (args: DatePickerToFrom) => void - to?: string - from?: string + to?: string // ISO string + from?: string // ISO string triggerButtonType?: ButtonProps['type'] triggerButtonClassName?: string triggerButtonTitle?: string + minDate?: Date + maxDate?: Date + hideTime?: boolean + hideClear?: boolean renderFooter?: (args: DatePickerToFrom) => React.ReactNode | void } @@ -38,6 +42,10 @@ function _DatePicker({ triggerButtonType = 'default', triggerButtonClassName = '', triggerButtonTitle, + minDate, + maxDate, + hideTime = false, + hideClear = false, renderFooter = () => null, }: DatePickerProps) { const [open, setOpen] = useState(false) @@ -64,6 +72,7 @@ function _DatePicker({ setAppliedEndDate(end) setEndDate(end) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [to, from]) function handleDatePickerChange(dates: [from: Date | null, to: Date | null]) { @@ -81,15 +90,15 @@ function _DatePicker({ const payload = { from: dayjs .utc(startDate) - .second(startTime.ss) - .minute(startTime.mm) - .hour(startTime.HH) + .second(Number(startTime.ss)) + .minute(Number(startTime.mm)) + .hour(Number(startTime.HH)) .toISOString(), to: dayjs .utc(endDate || startDate) - .second(endTime.ss) - .minute(endTime.mm) - .hour(endTime.HH) + .second(Number(endTime.ss)) + .minute(Number(endTime.mm)) + .hour(Number(endTime.HH)) .toISOString(), } if (onChange) onChange(payload) @@ -114,52 +123,56 @@ function _DatePicker({ align="center" side="bottom" header={ - <> -
    -
    - -
    -
    +
    +
    + +
    +
    - + > + +
    +
    + +
    -
    - -
    -
    - + + ) } overlay={ <>
    { handleDatePickerChange(dates) @@ -167,8 +180,8 @@ function _DatePicker({ dateFormat="MMMM d, yyyy h:mm aa" startDate={startDate} endDate={endDate} - selectsRange - inline + minDate={minDate} + maxDate={maxDate} dayClassName={() => 'cursor-pointer'} renderCustomHeader={({ date, @@ -213,9 +226,11 @@ function _DatePicker({ })}
    - + {!hideClear && ( + + )}
    diff --git a/studio/components/ui/Forms/FormSection.tsx b/studio/components/ui/Forms/FormSection.tsx index a94b81d44d4..5baa7ca7f56 100644 --- a/studio/components/ui/Forms/FormSection.tsx +++ b/studio/components/ui/Forms/FormSection.tsx @@ -2,12 +2,14 @@ import { Children } from 'react' const FormSection = ({ children, + id, header, disabled, visible = true, className, }: { children: React.ReactNode + id?: string header?: React.ReactNode disabled?: boolean visible?: boolean @@ -21,7 +23,7 @@ const FormSection = ({ ] return ( -
    +
    {header} {children}
    diff --git a/studio/components/ui/OrganizationPicker.tsx b/studio/components/ui/OrganizationPicker.tsx new file mode 100644 index 00000000000..c6308ef4a6b --- /dev/null +++ b/studio/components/ui/OrganizationPicker.tsx @@ -0,0 +1,115 @@ +import { useIntegrationsQuery } from 'data/integrations/integrations-query' +import { IntegrationName } from 'data/integrations/integrations.types' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useState } from 'react' +import { Organization } from 'types' +import { Badge, IconHexagon, Listbox } from 'ui' + +export interface OrganizationPickerProps { + label?: string + onSelectedOrgChange?: (org: Organization) => void + integrationName: IntegrationName +} + +const OrganizationPicker = ({ + label = 'Choose an organization', + onSelectedOrgChange, + integrationName, +}: OrganizationPickerProps) => { + const [selectedOrg, setSelectedOrg] = useState(null) + + /** + * array of integrations installed on all + */ + const { data: integrationData, isLoading: integrationDataLoading } = useIntegrationsQuery() + + /** + * filter integrations to match integrationName + */ + const vercelIntegrationsInstalled = integrationData?.filter( + (integration) => integration.integration.name === 'Vercel' + ) + + const { data, isLoading } = useOrganizationsQuery({ + onSuccess(organizations) { + const firstOrg = organizations?.[0] + if (firstOrg && selectedOrg === null) { + setSelectedOrg(firstOrg) + onSelectedOrgChange?.(firstOrg) + } + }, + }) + + function _onSelectedOrgChange(slug: string) { + const org = data?.find((org) => org.slug === slug) + + if (org) { + setSelectedOrg(org) + onSelectedOrgChange?.(org) + } + } + + if (isLoading || integrationDataLoading) { + return ( + + + Loading... + + + ) + } + + /** + * Organization type with `installationInstalled` added + */ + interface OrganizationsResponseWithInstalledData extends Organization { + installationInstalled?: boolean + } + + /** + * A flat array of org slugs that have integration installed + */ + const flatInstalledConnectionsIds = + integrationData && integrationData.length > 0 + ? integrationData?.map((x) => x.organization.slug) + : [] + + /** + * Organizations with extra `installationInstalled` attribute + * Used to show label/badge and allow/disallow installing + */ + const organizationsWithInstalledData: OrganizationsResponseWithInstalledData[] = data + ? data.map((org) => { + return { + ...org, + installationInstalled: !flatInstalledConnectionsIds.includes(org.slug) ? true : false, + } + }) + : [] + + return ( + + {organizationsWithInstalledData?.map((org) => { + const label = ( +
    + {org.name} + {org.installationInstalled && Integration Installed} +
    + ) + return ( + } + > + {label} + + ) + })} +
    + ) +} + +export default OrganizationPicker diff --git a/studio/components/ui/OveragesBanner/OveragesBanner.tsx b/studio/components/ui/OveragesBanner/OveragesBanner.tsx index 9962c9d5ca2..f07981140d5 100644 --- a/studio/components/ui/OveragesBanner/OveragesBanner.tsx +++ b/studio/components/ui/OveragesBanner/OveragesBanner.tsx @@ -85,7 +85,7 @@ const OveragesBanner: FC = ({ tier, minimal }) => { actions={ minimal ? (
    - + diff --git a/studio/components/ui/PageTelemetry.tsx b/studio/components/ui/PageTelemetry.tsx index 6d9d469ef9a..3fc7ca2bb78 100644 --- a/studio/components/ui/PageTelemetry.tsx +++ b/studio/components/ui/PageTelemetry.tsx @@ -54,6 +54,13 @@ const PageTelemetry: FC = ({ children }) => { language: telemetryProps?.language, }, }) + + post(`${API_URL}/telemetry/pageview`, { + referrer: referrer, + title: document.title, + path: router.route, + location: router.asPath, + }) } } diff --git a/studio/components/ui/PasswordStrengthBar.tsx b/studio/components/ui/PasswordStrengthBar.tsx index 2c472fad53a..4bd35b7b473 100644 --- a/studio/components/ui/PasswordStrengthBar.tsx +++ b/studio/components/ui/PasswordStrengthBar.tsx @@ -1,8 +1,4 @@ -import { - DEFAULT_MINIMUM_PASSWORD_STRENGTH, - PASSWORD_STRENGTH_COLOR, - PASSWORD_STRENGTH_PERCENTAGE, -} from 'lib/constants' +import { PASSWORD_STRENGTH_COLOR, PASSWORD_STRENGTH_PERCENTAGE } from 'lib/constants' interface Props { passwordStrengthScore: number diff --git a/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx b/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx index e1bebe6e641..a19d5f34d48 100644 --- a/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx +++ b/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx @@ -1,12 +1,12 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' -import { IconAlertCircle, IconLoader, Input } from 'ui' +import { Button, IconAlertCircle, IconBookOpen, IconLoader, Input } from 'ui' -import Panel from 'components/ui/Panel' -import { useProjectSettingsQuery } from 'data/config/project-settings-query' -import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' -import { checkPermissions } from 'hooks' import { useParams } from 'common/hooks' +import Panel from 'components/ui/Panel' +import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' +import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import { useCheckPermissions } from 'hooks' import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' const DisplayApiSettings = () => { @@ -24,7 +24,7 @@ const DisplayApiSettings = () => { } = useJwtSecretUpdatingStatusQuery({ projectRef }) const jwtSecretUpdateStatus = data?.jwtSecretUpdateStatus - const canReadAPIKeys = checkPermissions(PermissionAction.READ, 'service_api_keys') + const canReadAPIKeys = useCheckPermissions(PermissionAction.READ, 'service_api_keys') const isNotUpdatingJwtSecret = jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated @@ -44,7 +44,13 @@ const DisplayApiSettings = () => {

    Your API is secured behind an API gateway which requires an API Key for every request.
    - You can use the keys below to use Supabase client libraries. + You can use the keys below in the Supabase client libraries. +
    +
    + +

    } diff --git a/studio/components/ui/UpgradeToPro.tsx b/studio/components/ui/UpgradeToPro.tsx index fb17a75452a..75236f67f88 100644 --- a/studio/components/ui/UpgradeToPro.tsx +++ b/studio/components/ui/UpgradeToPro.tsx @@ -1,13 +1,13 @@ -import { Button } from 'ui' -import { observer } from 'mobx-react-lite' +import * as Tooltip from '@radix-ui/react-tooltip' +import { PermissionAction } from '@supabase/shared-types/out/constants' import Link from 'next/link' import { FC, ReactNode } from 'react' -import * as Tooltip from '@radix-ui/react-tooltip' -import { checkPermissions, useStore, useFlag } from 'hooks' import { useParams } from 'common/hooks' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useCheckPermissions, useFlag } from 'hooks' import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants' -import { PermissionAction } from '@supabase/shared-types/out/constants' +import { Button } from 'ui' interface Props { icon?: ReactNode @@ -17,16 +17,15 @@ interface Props { } const UpgradeToPro: FC = ({ icon, primaryText, projectRef, secondaryText }) => { - const { ui } = useStore() const { ref } = useParams() - const tier = ui.selectedProject?.subscription_tier + const { project } = useProjectContext() + const tier = project?.subscription_tier - const canUpdateSubscription = checkPermissions( + const canUpdateSubscription = useCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.subscriptions' ) const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') - const isEnterprise = tier === PRICING_TIER_PRODUCT_IDS.ENTERPRISE return (
    = ({ icon, primaryText, projectRef, secondaryText @@ -93,4 +94,4 @@ const UpgradeToPro: FC = ({ icon, primaryText, projectRef, secondaryText ) } -export default observer(UpgradeToPro) +export default UpgradeToPro diff --git a/studio/data/analytics/infra-monitoring-query.ts b/studio/data/analytics/infra-monitoring-query.ts index 642baeacfb8..1783de01939 100644 --- a/studio/data/analytics/infra-monitoring-query.ts +++ b/studio/data/analytics/infra-monitoring-query.ts @@ -8,7 +8,13 @@ import { analyticsKeys } from './keys' export type InfraMonitoringVariables = { projectRef?: string - attribute: 'cpu_usage' | 'disk_io_budget' | 'ram_usage' | 'disk_io_consumption' | 'swap_usage' + attribute: + | 'max_cpu_usage' + | 'avg_cpu_usage' + | 'disk_io_budget' + | 'ram_usage' + | 'disk_io_consumption' + | 'swap_usage' startDate?: string endDate?: string interval?: '1m' | '5m' | '10m' | '30m' | '1h' | '1d' diff --git a/studio/data/database/migrations-query.ts b/studio/data/database/migrations-query.ts new file mode 100644 index 00000000000..f9a25136e8b --- /dev/null +++ b/studio/data/database/migrations-query.ts @@ -0,0 +1,67 @@ +import { useCallback } from 'react' +import { UseQueryOptions } from '@tanstack/react-query' +import { ExecuteSqlData, useExecuteSqlPrefetch, useExecuteSqlQuery } from '../sql/execute-sql-query' + +export type DatabaseMigration = { + version: string + statements?: string[] +} + +export const getMigrationsQuery = () => { + const sql = /* SQL */ ` + select + * + from supabase_migrations.schema_migrations sm + order by sm.version desc + `.trim() + + return sql +} + +export type MigrationsVariables = { + projectRef?: string + connectionString?: string +} + +export type MigrationsData = { result: DatabaseMigration[] } +export type MigrationsError = unknown + +export const useMigrationsQuery = ( + { projectRef, connectionString }: MigrationsVariables, + options: UseQueryOptions = {} +) => { + return useExecuteSqlQuery( + { + projectRef, + connectionString, + sql: getMigrationsQuery(), + queryKey: ['migrations'], + handleError: (error: { code: number; message: string; requestId: string }) => { + if ( + error.code === 400 && + error.message.includes('relation "supabase_migrations.schema_migrations" does not exist') + ) { + return { result: [] } + } else { + throw error + } + }, + }, + options + ) +} + +export const useMigrationsPrefetch = () => { + const prefetch = useExecuteSqlPrefetch() + + return useCallback( + ({ projectRef, connectionString }: MigrationsVariables) => + prefetch({ + projectRef, + connectionString, + sql: getMigrationsQuery(), + queryKey: ['migrations'], + }), + [prefetch] + ) +} diff --git a/studio/data/integrations/integration-connections-create-mutation.ts b/studio/data/integrations/integration-connections-create-mutation.ts new file mode 100644 index 00000000000..9431e94aafb --- /dev/null +++ b/studio/data/integrations/integration-connections-create-mutation.ts @@ -0,0 +1,65 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { integrationKeys } from './keys' + +export type IntegrationConnectionsCreateVariables = { + organizationIntegrationId: string + connection: { + foreign_project_id: string + supabase_project_ref: string + metadata: any + } + orgSlug: string | undefined +} + +export async function createIntegrationConnections({ + organizationIntegrationId, + connection, +}: IntegrationConnectionsCreateVariables) { + const response = await post(`${API_URL}/integrations/vercel/connections`, { + organization_integration_id: organizationIntegrationId, + connection, + }) + if (response.error) { + throw response.error + } + + return response +} + +type IntegrationConnectionsCreateData = Awaited> + +export const useIntegrationConnectionsCreateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions< + IntegrationConnectionsCreateData, + unknown, + IntegrationConnectionsCreateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + return useMutation< + IntegrationConnectionsCreateData, + unknown, + IntegrationConnectionsCreateVariables + >((vars) => createIntegrationConnections(vars), { + async onSuccess(data, variables, context) { + await Promise.all([ + queryClient.invalidateQueries(integrationKeys.integrationsList()), + queryClient.invalidateQueries(integrationKeys.integrationsListWithOrg(variables.orgSlug)), + queryClient.invalidateQueries( + integrationKeys.vercelProjectList(variables.organizationIntegrationId) + ), + queryClient.invalidateQueries( + integrationKeys.vercelConnectionsList(variables.organizationIntegrationId) + ), + ]) + await onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/studio/data/integrations/integrations-github-repos-query.ts b/studio/data/integrations/integrations-github-repos-query.ts new file mode 100644 index 00000000000..4b1141df100 --- /dev/null +++ b/studio/data/integrations/integrations-github-repos-query.ts @@ -0,0 +1,137 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { integrationKeys } from './keys' + +// repos fetch +export type IntegrationsVariables = { + orgSlug?: string +} + +export type IntegrationProjectConnection = { + id: string + createdAt: string + from: { + name: string + } + to: { + name: string + } +} + +export type Integration = { + id: string + type: 'VERCEL' | 'NETLIFY' | 'GITHUB' + createdBy: string + createdAt: string + connections: IntegrationProjectConnection[] + metadata?: { + vercelTeam?: string + gitHubConnectionOwner?: string + } +} + +export type IntegrationsResponse = Integration[] + +export async function getIntegrations({ orgSlug }: IntegrationsVariables, signal?: AbortSignal) { + if (!orgSlug) { + throw new Error('orgSlug is required') + } + + const response: IntegrationsResponse = [ + { + id: '1', + type: 'VERCEL', + createdBy: 'alaister@supabase.io', + createdAt: '2023-06-05T06:56:25.565Z', + metadata: { + vercelTeam: 'alaister-team', + }, + connections: [ + { + id: '1.0', + createdAt: '2023-06-05T01:56:25.565Z', + to: { + name: 'www prod', + }, + from: { + name: 'alaister project prod', + }, + }, + { + id: '1.1', + createdAt: '2023-06-14T06:56:25.565Z', + to: { + name: 'www staging', + }, + from: { + name: 'alaister project staging', + }, + }, + { + id: '1.1', + createdAt: '2023-06-02T21:56:25.565Z', + to: { + name: 'bees knees', + }, + from: { + name: 'jonny bee project', + }, + }, + ], + }, + { + id: '2', + type: 'VERCEL', + createdBy: 'alaister@supabase.io', + createdAt: '2023-06-05T06:56:25.565Z', + metadata: { + vercelTeam: 'alaister-team', + }, + connections: [], + }, + { + id: '3', + type: 'GITHUB', + createdBy: 'Alaister', + createdAt: '2023-06-05T06:56:25.565Z', + metadata: { + gitHubConnectionOwner: 'alaister', + }, + connections: [ + { + id: '1.2', + createdAt: '2023-06-05T06:56:25.565Z', + to: { + name: 'supabase/supabase', + }, + from: { + name: 'alaister project', + }, + }, + ], + }, + ] + + // const response = await get(`${API_URL}/projects/${orgSlug}/integrations`, { + // signal, + // }) + // if (response.error) { + // throw response.error + // } + + return response as IntegrationsResponse +} + +export type IntegrationsData = Awaited> +export type IntegrationsError = unknown + +export const useIntegrationsGitHubReposQuery = ( + { orgSlug }: IntegrationsVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + integrationKeys.integrationsList(), // TODO: make a unique key for github repos + ({ signal }) => getIntegrations({ orgSlug }, signal), + { enabled: enabled && typeof orgSlug !== 'undefined', ...options } + ) diff --git a/studio/data/integrations/integrations-query-org-only.ts b/studio/data/integrations/integrations-query-org-only.ts new file mode 100644 index 00000000000..664f4aced96 --- /dev/null +++ b/studio/data/integrations/integrations-query-org-only.ts @@ -0,0 +1,35 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { integrationKeys } from './keys' +import { IntegrationsVariables, Integration } from './integrations.types' + +export type IntegrationsResponse = Integration[] + +export async function getIntegrations({ orgSlug }: IntegrationsVariables, signal?: AbortSignal) { + if (!orgSlug) { + throw new Error('orgSlug is required') + } + const response = await get(`${API_URL}/integrations/${orgSlug}?expand=true`, { + signal, + }) + if (response.error) { + throw response.error + } + + return response as IntegrationsResponse +} + +export type IntegrationsData = Awaited> +export type ProjectIntegrationConnectionsData = Awaited> +export type IntegrationsError = unknown + +export const useOrgIntegrationsQuery = ( + { orgSlug }: IntegrationsVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + integrationKeys.integrationsListWithOrg(orgSlug), + ({ signal }) => getIntegrations({ orgSlug }, signal), + { enabled: enabled && typeof orgSlug !== 'undefined', ...options } + ) diff --git a/studio/data/integrations/integrations-query.ts b/studio/data/integrations/integrations-query.ts new file mode 100644 index 00000000000..a522135fa2a --- /dev/null +++ b/studio/data/integrations/integrations-query.ts @@ -0,0 +1,32 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { integrationKeys } from './keys' +import { IntegrationsVariables, Integration } from './integrations.types' + +export type IntegrationsResponse = Integration[] + +export async function getIntegrations({}: {}, signal?: AbortSignal) { + const response = await get(`${API_URL}/integrations`, { + signal, + }) + if (response.error) { + throw response.error + } + + return response as IntegrationsResponse +} + +export type IntegrationsData = Awaited> +export type ProjectIntegrationConnectionsData = Awaited> +export type IntegrationsError = unknown + +export const useIntegrationsQuery = ({ + enabled = true, + ...options +}: UseQueryOptions = {}) => + useQuery( + integrationKeys.integrationsList(), + ({ signal }) => getIntegrations({}, signal), + { enabled: enabled, ...options } + ) diff --git a/studio/data/integrations/integrations-vercel-installed-connection-delete-mutation.ts b/studio/data/integrations/integrations-vercel-installed-connection-delete-mutation.ts new file mode 100644 index 00000000000..58ab5c10fee --- /dev/null +++ b/studio/data/integrations/integrations-vercel-installed-connection-delete-mutation.ts @@ -0,0 +1,58 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { delete_ } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { UserContent } from 'types' +import { integrationKeys } from './keys' + +type DeleteVariables = { + id: string + organization_integration_id: string + orgSlug: string | undefined +} + +export async function deleteConnection( + { organization_integration_id, id }: DeleteVariables, + signal?: AbortSignal +) { + if (!organization_integration_id) { + throw new Error('organization_integration_id is required') + } + + const response = await delete_( + `${API_URL}/integrations/vercel/connections/${id}`, + { organization_integration_id }, + { signal } + ) + + if (response.error) throw response.error + return response +} + +type DeleteContentData = Awaited> + +export const useIntegrationsVercelInstalledConnectionDeleteMutation = ({ + onSuccess, + ...options +}: Omit, 'mutationFn'> = {}) => { + const queryClient = useQueryClient() + return useMutation( + (args) => deleteConnection(args), + { + async onSuccess(data, variables, context) { + console.log('variables in mutate delete onSuccess', variables) + await Promise.all([ + queryClient.invalidateQueries(integrationKeys.integrationsList()), + queryClient.invalidateQueries(integrationKeys.integrationsListWithOrg(variables.orgSlug)), + queryClient.invalidateQueries( + integrationKeys.vercelProjectList(variables.organization_integration_id) + ), + queryClient.invalidateQueries( + integrationKeys.vercelConnectionsList(variables.organization_integration_id) + ), + ]) + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/data/integrations/integrations-vercel-installed-connections-query.ts b/studio/data/integrations/integrations-vercel-installed-connections-query.ts new file mode 100644 index 00000000000..f2d4bae400f --- /dev/null +++ b/studio/data/integrations/integrations-vercel-installed-connections-query.ts @@ -0,0 +1,90 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { integrationKeys } from './keys' + +export type VercelProjectsVariables = { + organization_integration_id?: string +} + +export type VercelProjectsResponse = { + id: string + name: string + metadata: { + id: string + name: string + framework: string + link: string + } +} + +export async function getVercelInstalledConnections( + { organization_integration_id }: VercelProjectsVariables, + signal?: AbortSignal +) { + if (!organization_integration_id) { + throw new Error('organization_integration_id is required') + } + + const response = await get( + `${API_URL}/integrations/vercel/connections/${organization_integration_id}`, + { + signal, + } + ) + if (response.error) { + throw response.error + } + + return response as VercelProjectsResponse[] +} + +export type VercelProjectsData = Awaited> +export type VercelProjectsError = unknown + +export const useVercelProjectConnectionsQuery = ( + { organization_integration_id }: VercelProjectsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + integrationKeys.vercelConnectionsList(organization_integration_id), + ({ signal }) => getVercelInstalledConnections({ organization_integration_id }, signal), + { + enabled: + enabled && + typeof organization_integration_id !== 'undefined' && + typeof organization_integration_id !== 'undefined', + ...options, + } + ) + +/** + * useVercelProjectsPrefetch is used for prefetching data. For example, starting a query loading before a page is navigated to. + * + * @example + * const prefetch = useVercelProjectsPrefetch({ orgSlug }) + * + * return ( + * prefetch()}> + * Start loading on hover + * + * ) + */ +export const useVercelProjectsPrefetch = ({ + organization_integration_id, +}: VercelProjectsVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (organization_integration_id) { + client.prefetchQuery( + integrationKeys.vercelConnectionsList(organization_integration_id), + ({ signal }) => getVercelInstalledConnections({ organization_integration_id }, signal) + ) + } + }, [organization_integration_id]) +} diff --git a/studio/data/integrations/integrations-vercel-projects-query.ts b/studio/data/integrations/integrations-vercel-projects-query.ts new file mode 100644 index 00000000000..5c6556c283f --- /dev/null +++ b/studio/data/integrations/integrations-vercel-projects-query.ts @@ -0,0 +1,91 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { integrationKeys } from './keys' +import { VercelFramework } from './integrations.types' + +export type VercelProjectsVariables = { + organization_integration_id: string | undefined +} + +export type VercelProjectsResponse = { + id: string + name: string + framework: VercelFramework + metadata: { + id: string + name: string + framework: string + link: string + } +} + +export async function getVercelProjects( + { organization_integration_id }: VercelProjectsVariables, + signal?: AbortSignal +) { + if (!organization_integration_id) { + throw new Error('organization_integration_id is required') + } + + const response = await get( + `${API_URL}/integrations/vercel/projects/${organization_integration_id}`, + { + signal, + } + ) + if (response.error) { + throw response.error + } + + return response as VercelProjectsResponse[] +} + +export type VercelProjectsData = Awaited> +export type VercelProjectsError = unknown + +export const useVercelProjectsQuery = ( + { organization_integration_id }: VercelProjectsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + integrationKeys.vercelProjectList(organization_integration_id), + ({ signal }) => getVercelProjects({ organization_integration_id }, signal), + { + enabled: enabled && typeof organization_integration_id !== 'undefined', + ...options, + } + ) + +/** + * useVercelProjectsPrefetch is used for prefetching data. For example, starting a query loading before a page is navigated to. + * + * @example + * const prefetch = useVercelProjectsPrefetch({ orgSlug }) + * + * return ( + * prefetch()}> + * Start loading on hover + * + * ) + */ +export const useVercelProjectsPrefetch = ({ + // orgSlug, + // orgId, + organization_integration_id, +}: VercelProjectsVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (organization_integration_id) { + client.prefetchQuery( + integrationKeys.vercelProjectList(organization_integration_id), + ({ signal }) => getVercelProjects({ organization_integration_id }, signal) + ) + } + }, [organization_integration_id]) +} diff --git a/studio/data/integrations/integrations.types.ts b/studio/data/integrations/integrations.types.ts new file mode 100644 index 00000000000..13dbf066ea1 --- /dev/null +++ b/studio/data/integrations/integrations.types.ts @@ -0,0 +1,198 @@ +export type VercelFramework = + | ( + | 'blitzjs' + | 'nextjs' + | 'gatsby' + | 'remix' + | 'astro' + | 'hexo' + | 'eleventy' + | 'docusaurus-2' + | 'docusaurus' + | 'preact' + | 'solidstart' + | 'dojo' + | 'ember' + | 'vue' + | 'scully' + | 'ionic-angular' + | 'angular' + | 'polymer' + | 'svelte' + | 'sveltekit' + | 'sveltekit-1' + | 'ionic-react' + | 'create-react-app' + | 'gridsome' + | 'umijs' + | 'sapper' + | 'saber' + | 'stencil' + | 'nuxtjs' + | 'redwoodjs' + | 'hugo' + | 'jekyll' + | 'brunch' + | 'middleman' + | 'zola' + | 'hydrogen' + | 'vite' + | 'vitepress' + | 'vuepress' + | 'parcel' + | 'sanity' + | 'storybook' + ) + | null + +export type VercelGitLink = + | { + /** + * GitHub link + */ + org?: string + repo?: string + repoId?: number + type?: 'github' + createdAt?: number + deployHooks: { + createdAt?: number + id: string + name: string + ref: string + url: string + }[] + gitCredentialId?: string + updatedAt?: number + sourceless?: boolean + productionBranch?: string + } + | { + /** + * GitLab link + */ + projectId?: string + projectName?: string + projectNameWithNamespace?: string + projectNamespace?: string + projectUrl?: string + type?: 'gitlab' + createdAt?: number + deployHooks: { + createdAt?: number + id: string + name: string + ref: string + url: string + }[] + gitCredentialId?: string + updatedAt?: number + sourceless?: boolean + productionBranch?: string + } + | { + /** + * Bitbucket link + */ + name?: string + slug?: string + owner?: string + type?: 'bitbucket' + uuid?: string + workspaceUuid?: string + createdAt?: number + deployHooks: { + createdAt?: number + id: string + name: string + ref: string + url: string + }[] + gitCredentialId?: string + updatedAt?: number + sourceless?: boolean + productionBranch?: string + } + +export type Imetadata = { + id: string + supabaseConfig: { + projectEnvVars: { + write: boolean + } + } + link?: VercelGitLink + name: string + framework: VercelFramework +} + +export type IntegrationProjectConnection = { + id: string + inserted_at: string + updated_at: string + added_by: addedBy + supabase_project_ref: string + foreign_project_id: string + organization_integration_id: string + metadata: Imetadata +} + +export type IntegrationsVariables = { + orgSlug?: string +} + +export type IntegrationProjectConnectionPayload = { + foreignProjectId: string + supabaseProjectId: string + integrationId: string + metadata: Imetadata +} + +export type userDetails = { + username: string + id: string + primary_email: string +} +type addedBy = userDetails +type updatedBy = userDetails + +export type IntegrationName = 'Vercel' | 'Netlify' | 'GitHub' +export type VercelAccountType = 'Team' | 'Personal' +export type VercelSource = 'marketplace' | 'deploy button' + +type BaseVercelAccount = { + name: string + avatar: string + source: VercelSource + owner_id: string +} + +export type VercelAccount = BaseVercelAccount & { + type: 'Personal' +} + +export type VercelTeamAccount = BaseVercelAccount & { + type: 'Team' + team_id: string + team_slug: string +} + +export type Integration = { + id: string + integration: { + name: IntegrationName + } + added_by: addedBy + inserted_at: string + updated_at: string + connections: IntegrationProjectConnection[] | [] + organization: { + slug: string + } + metadata?: { + vercelTeam?: string + gitHubConnectionOwner?: string + account: VercelAccount | VercelTeamAccount + configuration_id: string + } +} diff --git a/studio/data/integrations/keys.ts b/studio/data/integrations/keys.ts new file mode 100644 index 00000000000..c099d9d7783 --- /dev/null +++ b/studio/data/integrations/keys.ts @@ -0,0 +1,9 @@ +export const integrationKeys = { + integrationsListWithOrg: (orgSlug: string | undefined) => + ['organizations', orgSlug, 'integrations'] as const, + integrationsList: () => ['organizations', 'integrations'] as const, + vercelProjectList: (organization_integration_id: string | undefined) => + ['organizations', organization_integration_id, 'vercel-projects'] as const, + vercelConnectionsList: (organization_integration_id: string | undefined) => + ['organizations', organization_integration_id, 'vercel-connections'] as const, +} diff --git a/studio/data/integrations/vercel-integration-create-mutation.ts b/studio/data/integrations/vercel-integration-create-mutation.ts new file mode 100644 index 00000000000..ae99a6e5785 --- /dev/null +++ b/studio/data/integrations/vercel-integration-create-mutation.ts @@ -0,0 +1,65 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { integrationKeys } from './keys' + +export type VercelIntegrationCreateVariables = { + code: string + configurationId: string + orgSlug: string + metadata: { [key: string]: string } + source: string + // teamId is only present when a team is being installed + // personal accounts (hobby) will not have this value defined + teamId?: string +} + +export async function createVercelIntegration({ + code, + configurationId, + orgSlug, + metadata, + source, + teamId, +}: VercelIntegrationCreateVariables) { + const response = await post(`${API_URL}/integrations/vercel`, { + code, + configuration_id: configurationId, + organization_slug: orgSlug, + metadata, + source, + teamId, + }) + if (response.error) { + throw response.error + } + + return response +} + +type VercelIntegrationCreateData = Awaited> + +export const useVercelIntegrationCreateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + console.log('mutating') + return useMutation( + (vars) => createVercelIntegration(vars), + { + async onSuccess(data, variables, context) { + await Promise.all([ + queryClient.invalidateQueries(integrationKeys.integrationsList()), + queryClient.invalidateQueries(integrationKeys.integrationsListWithOrg(variables.orgSlug)), + queryClient.invalidateQueries(integrationKeys.vercelProjectList(data.id)), + ]) + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/data/oauth/authorized-app-revoke-mutation.ts b/studio/data/oauth/authorized-app-revoke-mutation.ts new file mode 100644 index 00000000000..d47499a4a77 --- /dev/null +++ b/studio/data/oauth/authorized-app-revoke-mutation.ts @@ -0,0 +1,44 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { delete_ } from 'lib/common/fetch' +import { API_ADMIN_URL } from 'lib/constants' +import { oauthAppKeys } from './keys' + +export type AuthorizedAppRevokeVariables = { + id: string + slug: string +} + +export async function revokeAuthorizedApp({ id, slug }: AuthorizedAppRevokeVariables) { + if (!id) throw new Error('App ID is required') + if (!slug) throw new Error('Organization slug is required') + + const response = await delete_( + `${API_ADMIN_URL}/organizations/${slug}/oauth/apps/${id}?type=authorized` + ) + if (response.error) throw response.error + return response +} + +type AuthorizedAppRevokeData = Awaited> + +export const useAuthorizedAppRevokeMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => revokeAuthorizedApp(vars), + { + async onSuccess(data, variables, context) { + const { slug } = variables + await queryClient.invalidateQueries(oauthAppKeys.authorizedApps(slug)) + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/data/oauth/authorized-apps-query.ts b/studio/data/oauth/authorized-apps-query.ts new file mode 100644 index 00000000000..6c37cdaf441 --- /dev/null +++ b/studio/data/oauth/authorized-apps-query.ts @@ -0,0 +1,59 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_ADMIN_URL } from 'lib/constants' +import { useCallback } from 'react' +import { oauthAppKeys } from './keys' +import { ResponseError } from 'types' + +export type AuthorizedAppsVariables = { + slug?: string +} + +export type AuthorizedApp = { + id: string + icon: string | null + name: string + website: string + authorized_at: string +} + +export async function getAuthorizedApps({ slug }: AuthorizedAppsVariables, signal?: AbortSignal) { + if (!slug) throw new Error('Organization slug is required') + + const response = await get(`${API_ADMIN_URL}/organizations/${slug}/oauth/apps?type=authorized`, { + signal, + }) + if (response.error) throw response.error + return response as AuthorizedApp[] +} + +export type AuthorizedAppsData = Awaited> +export type AuthorizedAppsError = ResponseError + +export const useAuthorizedAppsQuery = ( + { slug }: AuthorizedAppsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + oauthAppKeys.authorizedApps(slug), + ({ signal }) => getAuthorizedApps({ slug }, signal), + { + enabled: enabled && typeof slug !== 'undefined', + ...options, + } + ) + +export const useAuthorizedAppsPrefetch = ({ slug }: AuthorizedAppsVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (slug) { + client.prefetchQuery(oauthAppKeys.authorizedApps(slug), ({ signal }) => + getAuthorizedApps({ slug }, signal) + ) + } + }, [slug]) +} diff --git a/studio/data/oauth/keys.ts b/studio/data/oauth/keys.ts new file mode 100644 index 00000000000..53b02310942 --- /dev/null +++ b/studio/data/oauth/keys.ts @@ -0,0 +1,4 @@ +export const oauthAppKeys = { + oauthApps: (slug: string | undefined) => [slug, 'oauth-apps'] as const, + authorizedApps: (slug: string | undefined) => [slug, 'authorized-apps'] as const, +} diff --git a/studio/data/oauth/oauth-app-create-mutation.ts b/studio/data/oauth/oauth-app-create-mutation.ts new file mode 100644 index 00000000000..14152e5472f --- /dev/null +++ b/studio/data/oauth/oauth-app-create-mutation.ts @@ -0,0 +1,63 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { post } from 'lib/common/fetch' +import { API_ADMIN_URL } from 'lib/constants' +import { oauthAppKeys } from './keys' + +export type OAuthAppCreateVariables = { + slug: string + name: string + website: string + icon?: string + redirect_uris: string[] +} + +export type OAuthAppCreateResponse = { + id: string + client_id: string + client_secret: string +} + +export async function createOAuthApp({ + slug, + name, + website, + icon, + redirect_uris, +}: OAuthAppCreateVariables) { + if (!slug) throw new Error('Organization slug is required') + if (!name) throw new Error('OAuth app name is required') + if (!website) throw new Error('OAuth app URL is required') + + const response = await post(`${API_ADMIN_URL}/organizations/${slug}/oauth/apps`, { + name, + website, + icon, + redirect_uris, + }) + if (response.error) throw response.error + return response as OAuthAppCreateResponse +} + +type OAuthAppCreateData = Awaited> + +export const useOAuthAppCreateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => createOAuthApp(vars), + { + async onSuccess(data, variables, context) { + const { slug } = variables + await queryClient.invalidateQueries(oauthAppKeys.oauthApps(slug)) + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/data/oauth/oauth-app-delete-mutation.ts b/studio/data/oauth/oauth-app-delete-mutation.ts new file mode 100644 index 00000000000..28592be674b --- /dev/null +++ b/studio/data/oauth/oauth-app-delete-mutation.ts @@ -0,0 +1,44 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { delete_ } from 'lib/common/fetch' +import { API_ADMIN_URL } from 'lib/constants' +import { oauthAppKeys } from './keys' + +export type OAuthAppDeleteVariables = { + id: string + slug: string +} + +export async function deleteOAuthApp({ id, slug }: OAuthAppDeleteVariables) { + if (!id) throw new Error('OAuth app ID is required') + if (!slug) throw new Error('Organization slug is required') + + const response = await delete_( + `${API_ADMIN_URL}/organizations/${slug}/oauth/apps/${id}?type=published` + ) + if (response.error) throw response.error + return response +} + +type OAuthAppDeleteData = Awaited> + +export const useOAuthAppDeleteMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => deleteOAuthApp(vars), + { + async onSuccess(data, variables, context) { + const { slug } = variables + await queryClient.invalidateQueries(oauthAppKeys.oauthApps(slug)) + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/data/oauth/oauth-app-update-mutation.ts b/studio/data/oauth/oauth-app-update-mutation.ts new file mode 100644 index 00000000000..acc4ab79f3b --- /dev/null +++ b/studio/data/oauth/oauth-app-update-mutation.ts @@ -0,0 +1,61 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { put } from 'lib/common/fetch' +import { API_ADMIN_URL } from 'lib/constants' +import { oauthAppKeys } from './keys' + +export type OAuthAppUpdateVariables = { + id: string + slug: string + name: string + website: string + icon?: string | null + redirect_uris: string[] +} + +export async function updateOAuthApp({ + id, + slug, + name, + website, + icon, + redirect_uris, +}: OAuthAppUpdateVariables) { + if (!id) throw new Error('OAuth app ID is required') + if (!slug) throw new Error('Organization slug is required') + if (!name) throw new Error('OAuth app name is required') + if (!website) throw new Error('OAuth app URL is required') + if (!redirect_uris || redirect_uris.length === 0) throw new Error('Redirect URIs are required') + + const response = await put(`${API_ADMIN_URL}/organizations/${slug}/oauth/apps/${id}`, { + name, + website, + icon, + redirect_uris, + }) + if (response.error) throw response.error + return response +} + +type OAuthAppUpdateData = Awaited> + +export const useOAuthAppUpdateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => updateOAuthApp(vars), + { + async onSuccess(data, variables, context) { + const { slug } = variables + await queryClient.invalidateQueries(oauthAppKeys.oauthApps(slug)) + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/data/oauth/oauth-apps-query.ts b/studio/data/oauth/oauth-apps-query.ts new file mode 100644 index 00000000000..5dc6a229ce1 --- /dev/null +++ b/studio/data/oauth/oauth-apps-query.ts @@ -0,0 +1,59 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_ADMIN_URL } from 'lib/constants' +import { useCallback } from 'react' +import { oauthAppKeys } from './keys' +import { ResponseError } from 'types' + +export type OAuthAppsVariables = { + slug?: string +} + +export type OAuthApp = { + id: string + icon: string | null + client_id: string + client_secret_alias: string + created_at: string + name: string + website: string + redirect_uris: string[] +} + +export async function getOAuthApps({ slug }: OAuthAppsVariables, signal?: AbortSignal) { + if (!slug) throw new Error('Organization slug is required') + + const response = await get(`${API_ADMIN_URL}/organizations/${slug}/oauth/apps?type=published`, { + signal, + }) + if (response.error) throw response.error + return response as OAuthApp[] +} + +export type OAuthAppsData = Awaited> +export type OAuthAppsError = ResponseError + +export const useOAuthAppsQuery = ( + { slug }: OAuthAppsVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + oauthAppKeys.oauthApps(slug), + ({ signal }) => getOAuthApps({ slug }, signal), + { + enabled: enabled && typeof slug !== 'undefined', + ...options, + } + ) + +export const useOAuthAppsPrefetch = ({ slug }: OAuthAppsVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (slug) { + client.prefetchQuery(oauthAppKeys.oauthApps(slug), ({ signal }) => + getOAuthApps({ slug }, signal) + ) + } + }, [slug]) +} diff --git a/studio/data/organizations/keys.ts b/studio/data/organizations/keys.ts index 72e646037bb..3d8493ce9b0 100644 --- a/studio/data/organizations/keys.ts +++ b/studio/data/organizations/keys.ts @@ -7,4 +7,12 @@ export const organizationKeys = { ['organizations', slug, 'free-project-limit-check'] as const, customerProfile: (slug: string | undefined) => ['organizations', slug, 'customer-profile'] as const, + auditLogs: ( + slug: string | undefined, + { date_start, date_end }: { date_start: string | undefined; date_end: string | undefined } + ) => ['organizations', slug, 'audit-logs', { date_start, date_end }] as const, + migrateBilling: (slug: string | undefined) => ['organizations', slug, 'migrate-billing'] as const, + migrateBillingPreview: (slug: string | undefined) => + ['organizations', slug, 'migrate-billing', 'preview'] as const, + taxIds: (slug: string | undefined) => ['organizations', slug, 'tax-ids'] as const, } diff --git a/studio/data/organizations/organization-audit-logs-query.ts b/studio/data/organizations/organization-audit-logs-query.ts new file mode 100644 index 00000000000..8b5663dd6c2 --- /dev/null +++ b/studio/data/organizations/organization-audit-logs-query.ts @@ -0,0 +1,99 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { organizationKeys } from './keys' +import { ResponseError } from 'types' +import dayjs from 'dayjs' + +export type OrganizationAuditLog = { + action: { + metadata: { + method?: string + status?: number + }[] + name: string + } + actor: { + id: string + type: 'user' | string + metadata: { + email?: string + }[] + } + target: { + description: string + metadata: { + org_slug?: string + project_ref?: string + } + } + occurred_at: string +} + +export type OrganizationAuditLogsResponse = { + result: OrganizationAuditLog[] + retention_period: number +} + +export type OrganizationAuditLogsVariables = { + slug?: string + iso_timestamp_start?: string + iso_timestamp_end?: string + project_refs?: string // Comma-separated +} + +export async function getOrganizationAuditLogs( + { slug, iso_timestamp_start, iso_timestamp_end, project_refs }: OrganizationAuditLogsVariables, + signal?: AbortSignal +) { + if (!slug) throw new Error('slug is required') + + const response = await get( + `${API_URL}/organizations/${slug}/audit?iso_timestamp_start=${iso_timestamp_start}&iso_timestamp_end=${iso_timestamp_end}`, + { signal } + ) + if (response.error) throw response.error + return response as OrganizationAuditLogsResponse +} + +export type OrganizationAuditLogsData = Awaited> +export type OrganizationAuditLogsError = ResponseError + +export const useOrganizationAuditLogsQuery = ( + vars: OrganizationAuditLogsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => { + const { slug, iso_timestamp_start, iso_timestamp_end } = vars + const date_start = dayjs(iso_timestamp_start).utc().format('YYYY-MM-DD') + const date_end = dayjs(iso_timestamp_end).utc().format('YYYY-MM-DD') + + return useQuery( + organizationKeys.auditLogs(slug, { date_start, date_end }), + ({ signal }) => getOrganizationAuditLogs(vars, signal), + { + enabled: enabled && typeof slug !== 'undefined', + ...options, + } + ) +} + +export const useOrganizationAuditLogsPrefetch = (vars: OrganizationAuditLogsVariables) => { + const { slug, iso_timestamp_start, iso_timestamp_end } = vars + const client = useQueryClient() + + return useCallback(() => { + if (slug) { + const date_start = dayjs(iso_timestamp_start).utc().format('YYYY-MM-DD') + const date_end = dayjs(iso_timestamp_end).utc().format('YYYY-MM-DD') + client.prefetchQuery( + organizationKeys.auditLogs(slug, { date_start, date_end }), + ({ signal }) => getOrganizationAuditLogs(vars, signal) + ) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [client, vars]) +} diff --git a/studio/data/organizations/organization-customer-profile-query.ts b/studio/data/organizations/organization-customer-profile-query.ts index 31c6e4d55b3..3859a64c620 100644 --- a/studio/data/organizations/organization-customer-profile-query.ts +++ b/studio/data/organizations/organization-customer-profile-query.ts @@ -3,13 +3,21 @@ import { get } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { useCallback } from 'react' import { organizationKeys } from './keys' +import { ResponseError } from 'types' export type OrganizationCustomerProfileVariables = { slug?: string } export type OrganizationCustomerProfileResponse = { - address: string | null + address: { + city: string | null + country: string | null + line1: string | null + line2: string | null + postal_code: string | null + state: string | null + } | null balance: number email: string id: string @@ -33,7 +41,7 @@ export async function getOrganizationCustomerProfile( export type OrganizationCustomerProfileData = Awaited< ReturnType > -export type OrganizationCustomerProfileError = unknown +export type OrganizationCustomerProfileError = ResponseError export const useOrganizationCustomerProfileQuery = ( { slug }: OrganizationCustomerProfileVariables, diff --git a/studio/data/organizations/organization-customer-profile-update-mutation.ts b/studio/data/organizations/organization-customer-profile-update-mutation.ts new file mode 100644 index 00000000000..f038e76ab45 --- /dev/null +++ b/studio/data/organizations/organization-customer-profile-update-mutation.ts @@ -0,0 +1,58 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { patch } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { organizationKeys } from './keys' + +export type OrganizationCustomerProfileUpdateVariables = { + slug: string + address?: { + city: string | null + country: string | null + line1: string | null + line2: string | null + postal_code: string | null + state: string | null + } | null +} + +export async function updateOrganizationCustomerProfile({ + slug, + address, +}: OrganizationCustomerProfileUpdateVariables) { + if (!slug) return console.error('Slug is required') + + const response = await patch(`${API_URL}/organizations/${slug}/customer`, { address }) + if (response.error) throw response.error + return response +} + +type OrganizationCustomerProfileUpdateData = Awaited< + ReturnType +> + +export const useOrganizationCustomerProfileUpdateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions< + OrganizationCustomerProfileUpdateData, + unknown, + OrganizationCustomerProfileUpdateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + OrganizationCustomerProfileUpdateData, + unknown, + OrganizationCustomerProfileUpdateVariables + >((vars) => updateOrganizationCustomerProfile(vars), { + async onSuccess(data, variables, context) { + const { slug } = variables + await queryClient.invalidateQueries(organizationKeys.customerProfile(slug)) + await onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/studio/data/organizations/organization-migrate-billing-mutation.ts b/studio/data/organizations/organization-migrate-billing-mutation.ts new file mode 100644 index 00000000000..b9a98271737 --- /dev/null +++ b/studio/data/organizations/organization-migrate-billing-mutation.ts @@ -0,0 +1,66 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { organizationKeys } from './keys' + +export type OrganizationBillingMigrationVariables = { + organizationSlug?: string + tier?: string +} + +type OrganizationBillingMigrationError = { + message: string +} + +export async function migrateBilling({ + organizationSlug, + tier, +}: OrganizationBillingMigrationVariables) { + if (!organizationSlug) throw new Error('organizationSlug is required') + if (!tier) throw new Error('tier is required') + + const payload: { tier: string } = { + tier, + } + + const response = await post( + `${API_URL}/organizations/${organizationSlug}/org-billing-migration`, + payload + ) + if (response.error) throw response.error + + return response +} + +type OrganizationBillingMigrationData = Awaited> + +export const useOrganizationBillingMigrationMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions< + OrganizationBillingMigrationData, + OrganizationBillingMigrationError, + OrganizationBillingMigrationVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + OrganizationBillingMigrationData, + OrganizationBillingMigrationError, + OrganizationBillingMigrationVariables + >((vars) => migrateBilling(vars), { + async onSuccess(data, variables, context) { + const { organizationSlug } = variables + await Promise.all([ + queryClient.invalidateQueries(organizationKeys.detail(organizationSlug)), + queryClient.invalidateQueries(organizationKeys.list()), + queryClient.invalidateQueries(organizationKeys.freeProjectLimitCheck(organizationSlug)), + ]) + await onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/studio/data/organizations/organization-migrate-billing-preview-query.ts b/studio/data/organizations/organization-migrate-billing-preview-query.ts new file mode 100644 index 00000000000..ac6f1cc5c93 --- /dev/null +++ b/studio/data/organizations/organization-migrate-billing-preview-query.ts @@ -0,0 +1,86 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { organizationKeys } from './keys' + +export type OrganizationBillingMigrationPreviewVariables = { + organizationSlug?: string + tier?: string +} + +export type OrganizationBillingMigrationPreviewResponse = { + added_credits: number +} + +export async function previewOrganizationBillingMigration( + { organizationSlug, tier }: OrganizationBillingMigrationPreviewVariables, + signal?: AbortSignal +) { + if (!organizationSlug) throw new Error('organizationSlug is required') + if (!tier) throw new Error('tier is required') + + const payload: { tier: string } = { + tier, + } + + const response = await post( + `${API_URL}/organizations/${organizationSlug}/org-billing-migration/preview`, + payload, + { signal } + ) + + if (response.error) throw response.error + + return response as OrganizationBillingMigrationPreviewResponse +} + +export type OrganizationBillingMigrationPreviewData = Awaited< + ReturnType +> +export type OrganizationBillingMigrationPreviewError = { + message: string +} + +export const useOrganizationBillingMigrationPreview = < + TData = OrganizationBillingMigrationPreviewData +>( + { organizationSlug, tier }: OrganizationBillingMigrationPreviewVariables, + { + enabled = true, + ...options + }: UseQueryOptions< + OrganizationBillingMigrationPreviewData, + OrganizationBillingMigrationPreviewError, + TData + > = {} +) => + useQuery< + OrganizationBillingMigrationPreviewData, + OrganizationBillingMigrationPreviewError, + TData + >( + organizationKeys.migrateBillingPreview(organizationSlug), + ({ signal }) => previewOrganizationBillingMigration({ organizationSlug, tier }, signal), + { + enabled: enabled && typeof organizationSlug !== 'undefined' && typeof tier !== 'undefined', + ...options, + + retry: (failureCount, error) => { + // Don't retry on 400s + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as any).code === 400 + ) { + return false + } + + if (failureCount < 3) { + return true + } + + return false + }, + } + ) diff --git a/studio/data/organizations/organization-payment-method-delete-mutation.ts b/studio/data/organizations/organization-payment-method-delete-mutation.ts new file mode 100644 index 00000000000..de0eb95fd1b --- /dev/null +++ b/studio/data/organizations/organization-payment-method-delete-mutation.ts @@ -0,0 +1,48 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { delete_ } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { organizationKeys } from './keys' +import { ResponseError } from 'types' + +export type OrganizationPaymentMethodDeleteVariables = { + slug: string + cardId: string +} + +export async function deleteOrganizationMember({ + slug, + cardId, +}: OrganizationPaymentMethodDeleteVariables) { + const response = await delete_(`${API_URL}/organizations/${slug}/payments`, { card_id: cardId }) + if (response.error) throw response.error + return response +} + +type OrganizationPaymentMethodDeleteData = Awaited> + +export const useOrganizationPaymentMethodDeleteMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions< + OrganizationPaymentMethodDeleteData, + unknown, + OrganizationPaymentMethodDeleteVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + OrganizationPaymentMethodDeleteData, + unknown, + OrganizationPaymentMethodDeleteVariables + >((vars) => deleteOrganizationMember(vars), { + async onSuccess(data, variables, context) { + const { slug } = variables + await queryClient.invalidateQueries(organizationKeys.paymentMethods(slug)) + await onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/studio/data/organizations/organization-payment-methods-query.ts b/studio/data/organizations/organization-payment-methods-query.ts index 86a4369bdf1..6118492f797 100644 --- a/studio/data/organizations/organization-payment-methods-query.ts +++ b/studio/data/organizations/organization-payment-methods-query.ts @@ -5,7 +5,7 @@ import { useCallback } from 'react' import { organizationKeys } from './keys' export type OrganizationPaymentMethodsVariables = { slug?: string } -export type OrganizationPaymentMethodsResponse = { +export type OrganizationPaymentMethod = { id: string customer: string type: string @@ -29,7 +29,7 @@ export type OrganizationPaymentMethodsResponse = { name: string | null phone: string | null } -}[] +} export async function getOrganizations( { slug }: OrganizationPaymentMethodsVariables, @@ -40,7 +40,7 @@ export async function getOrganizations( const response = await get(`${API_URL}/organizations/${slug}/payments`, { signal }) if (response.error) throw response.error - return response.data as OrganizationPaymentMethodsResponse + return response.data as OrganizationPaymentMethod[] } export type OrganizationPaymentMethodsData = Awaited> diff --git a/studio/data/organizations/organization-tax-ids-query.ts b/studio/data/organizations/organization-tax-ids-query.ts new file mode 100644 index 00000000000..355bb50770a --- /dev/null +++ b/studio/data/organizations/organization-tax-ids-query.ts @@ -0,0 +1,70 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { organizationKeys } from './keys' +import { ResponseError } from 'types' + +export type OrganizationTaxIDsVariables = { + slug?: string +} + +export type TaxId = { + country: string + created: number + customer: string + id: string + livemode: boolean + object: 'tax_id' + type: string + value: string + verification: { + status: string + verified_address: string | null + verified_name: string | null + } +} + +export async function getOrganizationTaxIDs( + { slug }: OrganizationTaxIDsVariables, + signal?: AbortSignal +) { + if (!slug) throw new Error('slug is required') + + const data = await get(`${API_URL}/organizations/${slug}/tax-ids`, { signal }) + if (data.error) throw data.error + + return data.data as TaxId[] +} + +export type OrganizationTaxIDsData = Awaited> +export type OrganizationTaxIDsError = ResponseError + +export const useOrganizationTaxIDsQuery = ( + { slug }: OrganizationTaxIDsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + organizationKeys.taxIds(slug), + ({ signal }) => getOrganizationTaxIDs({ slug }, signal), + { + enabled: enabled && typeof slug !== 'undefined', + ...options, + } + ) + +export const useOrganizationTaxIDsPrefetch = ({ slug }: OrganizationTaxIDsVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (slug) { + client.prefetchQuery(organizationKeys.taxIds(slug), ({ signal }) => + getOrganizationTaxIDs({ slug }, signal) + ) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [slug]) +} diff --git a/studio/data/organizations/organization-tax-ids-update-mutation.ts b/studio/data/organizations/organization-tax-ids-update-mutation.ts new file mode 100644 index 00000000000..36ea34fd23d --- /dev/null +++ b/studio/data/organizations/organization-tax-ids-update-mutation.ts @@ -0,0 +1,82 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { delete_, post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { organizationKeys } from './keys' +import { TaxId } from './organization-tax-ids-query' + +export interface TaxIdValue { + id: string + type: string + value: string + name: string +} + +export type OrganizationTaxIDsUpdateVariables = { + slug: string + existingIds: TaxId[] + newIds: TaxIdValue[] +} + +export async function updateOrganizationTaxIDs({ + slug, + existingIds, + newIds, +}: OrganizationTaxIDsUpdateVariables) { + if (!slug) { + console.error('Slug is required') + return {} + } + + // To make things simple we delete all existing ones and create new ones from this session + if (existingIds.length > 0) { + await Promise.all( + existingIds.map(async (taxId) => { + return await delete_(`${API_URL}/organizations/${slug}/tax-ids`, { id: taxId.id }) + }) + ) + } + + const createdIds = await Promise.all( + newIds.map(async (taxId) => { + const result = await post(`${API_URL}/organizations/${slug}/tax-ids`, { + type: taxId.type, + value: taxId.value, + }) + return { id: taxId.id, result } + }) + ) + + const taxIdsWithErrors = createdIds.filter((taxId) => { + if (taxId.result.error) return taxId + }) + + const idsCreated = createdIds + .filter((taxId: any) => !taxId.result.error) + .map((taxId: any) => taxId.result) + + return { created: idsCreated, errors: taxIdsWithErrors } +} + +type OrganizationTaxIDsUpdateData = Awaited> + +export const useOrganizationTaxIDsUpdateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => updateOrganizationTaxIDs(vars), + { + async onSuccess(data, variables, context) { + const { slug } = variables + await queryClient.invalidateQueries(organizationKeys.customerProfile(slug)) + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/data/organizations/organizations-query.ts b/studio/data/organizations/organizations-query.ts index 598c8b17df4..c58439c1f60 100644 --- a/studio/data/organizations/organizations-query.ts +++ b/studio/data/organizations/organizations-query.ts @@ -1,4 +1,4 @@ -import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { QueryClient, useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' import { get } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { useCallback } from 'react' @@ -7,11 +7,13 @@ import { organizationKeys } from './keys' export type OrganizationsResponse = Organization[] -export async function getOrganizations(signal?: AbortSignal) { +export async function getOrganizations(signal?: AbortSignal): Promise { const data = await get(`${API_URL}/organizations`, { signal }) if (data.error) throw data.error - return data as OrganizationsResponse + const sorted = (data as Organization[]).sort((a, b) => a.name.localeCompare(b.name)) + + return sorted } export type OrganizationsData = Awaited> @@ -27,11 +29,16 @@ export const useOrganizationsQuery = ({ { enabled: enabled, ...options } ) +export function prefetchOrganizations(client: QueryClient) { + return client.prefetchQuery(organizationKeys.list(), ({ signal }) => getOrganizations(signal)) +} + export const useOrganizationsPrefetch = () => { const client = useQueryClient() - return useCallback( - () => client.prefetchQuery(organizationKeys.list(), ({ signal }) => getOrganizations(signal)), - [] - ) + return useCallback(() => prefetchOrganizations(client), []) +} + +export function invalidateOrganizationsQuery(client: QueryClient) { + return client.invalidateQueries(organizationKeys.list()) } diff --git a/studio/data/permissions/permissions-query.ts b/studio/data/permissions/permissions-query.ts index 0dff6750374..0fea45d1876 100644 --- a/studio/data/permissions/permissions-query.ts +++ b/studio/data/permissions/permissions-query.ts @@ -1,8 +1,8 @@ import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' +import { API_URL, IS_PLATFORM } from 'lib/constants' import { useCallback } from 'react' -import { Permission } from 'types' +import { Permission, ResponseError } from 'types' import { permissionKeys } from './keys' export type PermissionsResponse = Permission[] @@ -11,15 +11,13 @@ export async function getPermissions(signal?: AbortSignal) { const response = await get(`${API_URL}/profile/permissions`, { signal, }) - if (response.error) { - throw response.error - } + if (response.error) throw response.error return response as PermissionsResponse } export type PermissionsData = Awaited> -export type PermissionsError = unknown +export type PermissionsError = ResponseError export const usePermissionsQuery = ( options: UseQueryOptions = {} @@ -27,7 +25,10 @@ export const usePermissionsQuery = ( useQuery( permissionKeys.list(), ({ signal }) => getPermissions(signal), - options + { + ...options, + enabled: IS_PLATFORM && options.enabled, + } ) export const usePermissionsPrefetch = () => { diff --git a/studio/data/projects/keys.ts b/studio/data/projects/keys.ts index dd9edab9b99..8e3e5c10db0 100644 --- a/studio/data/projects/keys.ts +++ b/studio/data/projects/keys.ts @@ -1,6 +1,10 @@ export const projectKeys = { - list: () => ['projects'] as const, - detail: (projectRef: string | undefined) => ['projects', projectRef, 'detail'] as const, - readonlyStatus: (projectRef: string | undefined) => - ['projects', projectRef, 'readonlyStatus'] as const, + list: () => ['all-projects'] as const, + detail: (projectRef: string | undefined) => ['project', projectRef, 'detail'] as const, + projectTransfer: (projectRef: string | undefined, targetOrganizationSlug: string | undefined) => + ['projects', 'transfer', projectRef, targetOrganizationSlug] as const, + projectTransferPreview: ( + projectRef: string | undefined, + targetOrganizationSlug: string | undefined + ) => ['projects', 'transfer', projectRef, targetOrganizationSlug, 'preview'] as const, } diff --git a/studio/data/projects/project-create-mutation.ts b/studio/data/projects/project-create-mutation.ts new file mode 100644 index 00000000000..f355ee965f4 --- /dev/null +++ b/studio/data/projects/project-create-mutation.ts @@ -0,0 +1,63 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { post } from 'lib/common/fetch' +import { API_URL, PRICING_TIER_PRODUCT_IDS, PROVIDERS } from 'lib/constants' +import { ProjectBase } from 'types' +import { projectKeys } from './keys' + +export type ProjectCreateVariables = { + name: string + organizationId: number + dbPass: string + dbRegion: string + dbSql?: string + configurationId?: string +} + +export async function createProject({ + name, + organizationId, + dbPass, + dbRegion, + dbSql, + configurationId, +}: ProjectCreateVariables) { + const response = await post(`${API_URL}/projects`, { + cloud_provider: PROVIDERS.AWS.id, // hardcoded for DB instances to be under AWS + org_id: organizationId, + name, + db_pass: dbPass, + db_region: dbRegion, + db_sql: dbSql, + db_pricing_tier_id: PRICING_TIER_PRODUCT_IDS.FREE, + // auth_site_url: _store.selectedVercelProjectUrl, + vercel_configuration_id: configurationId, + }) + if (response.error) { + throw response.error + } + + return response as ProjectBase +} + +type ProjectCreateData = Awaited> + +export const useProjectCreateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => createProject(vars), + { + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries(projectKeys.list()), + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/data/projects/project-detail-query.ts b/studio/data/projects/project-detail-query.ts index 552ede409d3..4e7acae201d 100644 --- a/studio/data/projects/project-detail-query.ts +++ b/studio/data/projects/project-detail-query.ts @@ -26,6 +26,7 @@ export const useProjectDetailQuery = ( ({ signal }) => getProjectDetail({ ref }, signal), { enabled: enabled && typeof ref !== 'undefined', + staleTime: 30 * 1000, // 30 seconds ...options, } ) diff --git a/studio/data/projects/project-transfer-mutation.ts b/studio/data/projects/project-transfer-mutation.ts new file mode 100644 index 00000000000..45cfe5abdcc --- /dev/null +++ b/studio/data/projects/project-transfer-mutation.ts @@ -0,0 +1,60 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { projectKeys } from './keys' + +export type ProjectTransferVariables = { + projectRef?: string + targetOrganizationSlug?: string +} + +type ProjectTransferError = { + message: string +} + +export async function transferProject({ + projectRef, + targetOrganizationSlug, +}: ProjectTransferVariables) { + if (!projectRef) throw new Error('projectRef is required') + if (!targetOrganizationSlug) throw new Error('targetOrganizationSlug is required') + + const payload: { target_organization_slug: string } = { + target_organization_slug: targetOrganizationSlug, + } + + const response = await post(`${API_URL}/projects/${projectRef}/transfer`, payload) + if (response.error) throw response.error + + return response +} + +type ProjectTransferData = Awaited> + +export const useProjectTransferMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => transferProject(vars), + { + async onSuccess(data, variables, context) { + const { projectRef, targetOrganizationSlug } = variables + await Promise.all([ + queryClient.invalidateQueries( + projectKeys.projectTransferPreview(projectRef, targetOrganizationSlug) + ), + queryClient.invalidateQueries(projectKeys.detail(projectRef)), + queryClient.invalidateQueries(projectKeys.list()), + ]) + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/data/projects/project-transfer-preview-query.ts b/studio/data/projects/project-transfer-preview-query.ts new file mode 100644 index 00000000000..a5edae66caf --- /dev/null +++ b/studio/data/projects/project-transfer-preview-query.ts @@ -0,0 +1,123 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { projectKeys } from './keys' + +export type ProjectTransferPreviewVariables = { + projectRef?: string + targetOrganizationSlug?: string +} + +export type PlanId = 'free' | 'pro' | 'team' | 'enterprise' + +type MemberExceedingFreeProjectLimit = { + name: string + limit: number +} + +type PreviewTransferInfo = { + key: string + message: string +} + +export type PreviewProjectTransferResponse = { + valid: boolean + + warnings: PreviewTransferInfo[] + errors: PreviewTransferInfo[] + + members_exceeding_free_project_limit: MemberExceedingFreeProjectLimit[] + + has_permissions_on_source_organization: boolean + has_access_to_target_organization: boolean + + source_project_eligible: boolean + + target_organization_eligible: boolean | null + target_organization_has_free_project_slots: boolean | null + + credits_on_source_organization: number + costs_on_target_organization: number + charge_on_target_organization: number + + source_subscription_plan: PlanId + target_subscription_plan: PlanId | null +} + +export async function previewProjectTransfer( + { projectRef, targetOrganizationSlug }: ProjectTransferPreviewVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + if (!targetOrganizationSlug) throw new Error('targetOrganizationSlug is required') + + const response = await post( + `${API_URL}/projects/${projectRef}/transfer/preview`, + { + target_organization_slug: targetOrganizationSlug, + }, + { signal } + ) + if (response.error) throw response.error + + return response as PreviewProjectTransferResponse +} + +export type ProjectTransferPreviewData = Awaited> +export type ProjectTransferPreviewError = { + message: string +} + +export const useProjectTransferPreviewQuery = ( + { projectRef, targetOrganizationSlug }: ProjectTransferPreviewVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + projectKeys.projectTransferPreview(projectRef, targetOrganizationSlug), + ({ signal }) => previewProjectTransfer({ projectRef, targetOrganizationSlug }, signal), + { + enabled: + enabled && + typeof projectRef !== 'undefined' && + typeof targetOrganizationSlug !== 'undefined', + ...options, + + retry: (failureCount, error) => { + // Don't retry on 400s + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as any).code === 400 + ) { + return false + } + + if (failureCount < 3) { + return true + } + + return false + }, + } + ) + +export const useProjectTransferPreviewPrefetch = ({ + projectRef, + targetOrganizationSlug, +}: ProjectTransferPreviewVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (projectRef && targetOrganizationSlug) { + client.prefetchQuery( + projectKeys.projectTransferPreview(projectRef, targetOrganizationSlug), + ({ signal }) => previewProjectTransfer({ projectRef, targetOrganizationSlug }, signal) + ) + } + }, [projectRef, targetOrganizationSlug]) +} diff --git a/studio/data/projects/projects-query.ts b/studio/data/projects/projects-query.ts index 7429a1fd7c5..4767ace9baa 100644 --- a/studio/data/projects/projects-query.ts +++ b/studio/data/projects/projects-query.ts @@ -1,8 +1,9 @@ -import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { QueryClient, useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { useCallback, useRef } from 'react' + import { get } from 'lib/common/fetch' import { API_URL } from 'lib/constants' -import { useCallback } from 'react' -import { Project } from 'types' +import { Project, ResponseError } from 'types' import { projectKeys } from './keys' export type ProjectsVariables = { @@ -16,7 +17,7 @@ export async function getProjects(signal?: AbortSignal) { } export type ProjectsData = Awaited> -export type ProjectsError = unknown +export type ProjectsError = ResponseError export const useProjectsQuery = ({ enabled = true, @@ -28,10 +29,75 @@ export const useProjectsQuery = ({ { enabled: enabled, ...options } ) -export const useProjectsPrefetch = () => { +export function prefetchProjects(client: QueryClient) { + return client.prefetchQuery(projectKeys.list(), ({ signal }) => getProjects(signal)) +} + +export function useProjectsPrefetch() { const client = useQueryClient() return useCallback(() => { - client.prefetchQuery(projectKeys.list(), ({ signal }) => getProjects(signal)) - }, []) + prefetchProjects(client) + }, [client]) +} + +export function useAutoProjectsPrefetch() { + const prefetch = useProjectsPrefetch() + + const called = useRef(false) + if (called.current === false) { + called.current = true + prefetch() + } +} + +export function invalidateProjectsQuery(client: QueryClient) { + return client.invalidateQueries(projectKeys.list()) +} + +export function setProjectStatus( + client: QueryClient, + projectRef: Project['ref'], + status: Project['status'] +) { + client.setQueriesData( + projectKeys.list(), + (old) => { + if (!old) return old + + return old.map((project) => { + if (project.ref === projectRef) { + return { ...project, status } + } + return project + }) + }, + { updatedAt: Date.now() } + ) + + client.setQueriesData( + projectKeys.detail(projectRef), + (old) => { + if (!old) return old + + return { ...old, status } + }, + { updatedAt: Date.now() } + ) +} + +export function setProjectPostgrestStatus( + client: QueryClient, + projectRef: Project['ref'], + status: Project['postgrestStatus'] +) { + client.setQueriesData( + projectKeys.detail(projectRef), + (old) => { + if (!old) return old + + return { ...old, postgrestStatus: status } + }, + { updatedAt: Date.now() } + ) } diff --git a/studio/data/sql/execute-sql-query.ts b/studio/data/sql/execute-sql-query.ts index 1eb86b56373..76ef0f665aa 100644 --- a/studio/data/sql/execute-sql-query.ts +++ b/studio/data/sql/execute-sql-query.ts @@ -10,12 +10,16 @@ import { post } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { useCallback } from 'react' import { sqlKeys } from './keys' +import { noop } from 'lodash' + +export type Error = { code: number; message: string; requestId: string } export type ExecuteSqlVariables = { projectRef?: string connectionString?: string sql: string queryKey?: QueryKey + handleError?: (error: { code: number; message: string; requestId: string }) => any } export async function executeSql( @@ -24,7 +28,11 @@ export async function executeSql( connectionString, sql, queryKey, - }: Pick, + handleError, + }: Pick< + ExecuteSqlVariables, + 'projectRef' | 'connectionString' | 'sql' | 'queryKey' | 'handleError' + >, signal?: AbortSignal ) { if (!projectRef) { @@ -44,8 +52,13 @@ export async function executeSql( { query: sql }, { headers: Object.fromEntries(headers), signal } ) + if (response.error) { - throw response.error + if (handleError !== undefined) { + return handleError(response.error) + } else { + throw response.error + } } return { result: response } @@ -55,21 +68,22 @@ export type ExecuteSqlData = Awaited> export type ExecuteSqlError = unknown export const useExecuteSqlQuery = ( - { projectRef, connectionString, sql, queryKey }: ExecuteSqlVariables, + { projectRef, connectionString, sql, queryKey, handleError }: ExecuteSqlVariables, { enabled = true, ...options }: UseQueryOptions = {} ) => useQuery( sqlKeys.query(projectRef, queryKey ?? [md5(sql)]), - ({ signal }) => executeSql({ projectRef, connectionString, sql, queryKey }, signal), + ({ signal }) => + executeSql({ projectRef, connectionString, sql, queryKey, handleError }, signal), { enabled: enabled && typeof projectRef !== 'undefined', ...options } ) export const prefetchExecuteSql = ( client: QueryClient, - { projectRef, connectionString, sql, queryKey }: ExecuteSqlVariables + { projectRef, connectionString, sql, queryKey, handleError }: ExecuteSqlVariables ) => { return client.prefetchQuery(sqlKeys.query(projectRef, queryKey ?? [md5(sql)]), ({ signal }) => - executeSql({ projectRef, connectionString, sql, queryKey }, signal) + executeSql({ projectRef, connectionString, sql, queryKey, handleError }, signal) ) } @@ -89,13 +103,14 @@ export const useExecuteSqlPrefetch = () => { const client = useQueryClient() return useCallback( - ({ projectRef, connectionString, sql, queryKey }: ExecuteSqlVariables) => { + ({ projectRef, connectionString, sql, queryKey, handleError }: ExecuteSqlVariables) => { if (projectRef) { return prefetchExecuteSql(client, { projectRef, connectionString, sql, queryKey, + handleError, }) } diff --git a/studio/data/storage/buckets-query.ts b/studio/data/storage/buckets-query.ts index 530c257a52b..793254c8340 100644 --- a/studio/data/storage/buckets-query.ts +++ b/studio/data/storage/buckets-query.ts @@ -1,6 +1,6 @@ import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' import { get } from 'lib/common/fetch' -import { API_URL, IS_PLATFORM } from 'lib/constants' +import { API_URL } from 'lib/constants' import { useCallback } from 'react' import { storageKeys } from './keys' @@ -35,7 +35,7 @@ export const useBucketsQuery = ( useQuery( storageKeys.buckets(projectRef), ({ signal }) => getBuckets({ projectRef }, signal), - { enabled: IS_PLATFORM && enabled && typeof projectRef !== 'undefined', ...options } + { enabled: enabled && typeof projectRef !== 'undefined', ...options } ) export const useBucketsPrefetch = ({ projectRef }: BucketsVariables) => { diff --git a/studio/data/subscriptions/keys.ts b/studio/data/subscriptions/keys.ts index c8f1fddaead..94be5ab2794 100644 --- a/studio/data/subscriptions/keys.ts +++ b/studio/data/subscriptions/keys.ts @@ -1,6 +1,10 @@ // [Joshen TODO] To remove subscription V2 after v2 is launched and stable to deprecate old billing ui + endpoints export const subscriptionKeys = { + orgSubscription: (orgSlug: string | undefined) => + ['organizations', orgSlug, 'subscription'] as const, + orgPlans: (orgSlug: string | undefined) => ['organizations', orgSlug, 'plans'] as const, + subscriptionV2: (projectRef: string | undefined) => ['projects', projectRef, 'subscriptionV2'] as const, addons: (projectRef: string | undefined) => ['projects', projectRef, 'addons'] as const, diff --git a/studio/data/subscriptions/org-plans-query.ts b/studio/data/subscriptions/org-plans-query.ts new file mode 100644 index 00000000000..acfcae395f2 --- /dev/null +++ b/studio/data/subscriptions/org-plans-query.ts @@ -0,0 +1,55 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { subscriptionKeys } from './keys' + +export type OrgPlansVariables = { + orgSlug?: string +} + +export type OrgPlansResponse = { + id: 'free' | 'pro' | 'team' | 'enterprise' + name: string + price: number + is_current: boolean + change_type: 'downgrade' | 'upgrade' | 'none' + effective_at: string +}[] + +export async function getOrgPlans({ orgSlug }: OrgPlansVariables, signal?: AbortSignal) { + if (!orgSlug) throw new Error('orgSlug is required') + + const response = await get(`${API_URL}/organizations/${orgSlug}/billing/plans`, { signal }) + if (response.error) throw response.error + + return response.plans as OrgPlansResponse +} + +export type OrgPlansData = Awaited> +export type OrgPlansError = unknown + +export const useOrgPlansQuery = ( + { orgSlug }: OrgPlansVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + subscriptionKeys.orgPlans(orgSlug), + ({ signal }) => getOrgPlans({ orgSlug }, signal), + { + enabled: enabled && typeof orgSlug !== 'undefined', + ...options, + } + ) + +export const useOrgPlansPrefetch = ({ orgSlug }: OrgPlansVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (orgSlug) { + client.prefetchQuery(subscriptionKeys.orgPlans(orgSlug), ({ signal }) => + getOrgPlans({ orgSlug }, signal) + ) + } + }, [orgSlug]) +} diff --git a/studio/data/subscriptions/org-subscription-query.ts b/studio/data/subscriptions/org-subscription-query.ts new file mode 100644 index 00000000000..8b3d6c9ccb8 --- /dev/null +++ b/studio/data/subscriptions/org-subscription-query.ts @@ -0,0 +1,91 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { subscriptionKeys } from './keys' +import { ResponseError } from 'types' + +export type OrgSubscriptionVariables = { + orgSlug?: string +} + +export type PlanId = 'free' | 'pro' | 'team' | 'enterprise' + +export type OrgSubscriptionResponse = { + billing_cycle_anchor: number + current_period_start: number + current_period_end: number + next_invoice_at: number + usage_billing_enabled: boolean + plan: { + id: PlanId + name: string + price: number + } + addons: { + name: string + supabase_prod_id: string + price: number + }[] + usage_fees: { + metric: string + pricingStrategy: 'UNIT' | 'PACKAGE' + pricingOptions: { + perUnitPrice?: number + packagePrice?: number + packageSize?: number + freeUnits: number + } + }[] + payment_method_id?: string + payment_method_type: 'invoice' | 'card' | 'none' + payment_method_card_details?: { + last_4_digits: string + brand: string + expiry_month: number + expiry_year: number + } +} + +export async function getOrgSubscription( + { orgSlug }: OrgSubscriptionVariables, + signal?: AbortSignal +) { + if (!orgSlug) throw new Error('orgSlug is required') + + const response = await get(`${API_URL}/organizations/${orgSlug}/billing/subscription`, { signal }) + if (response.error) throw response.error + + return response as OrgSubscriptionResponse +} + +export type OrgSubscriptionData = Awaited> +export type OrgSubscriptionError = ResponseError + +export const useOrgSubscriptionQuery = ( + { orgSlug }: OrgSubscriptionVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + subscriptionKeys.orgSubscription(orgSlug), + ({ signal }) => getOrgSubscription({ orgSlug }, signal), + { + enabled: enabled && typeof orgSlug !== 'undefined', + ...options, + } + ) + +export const useOrgSubscriptionPrefetch = ({ orgSlug }: OrgSubscriptionVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (orgSlug) { + client.prefetchQuery(subscriptionKeys.orgSubscription(orgSlug), ({ signal }) => + getOrgSubscription({ orgSlug }, signal) + ) + } + }, [orgSlug]) +} diff --git a/studio/data/subscriptions/org-subscription-update-mutation.ts b/studio/data/subscriptions/org-subscription-update-mutation.ts new file mode 100644 index 00000000000..d2592bbcc8f --- /dev/null +++ b/studio/data/subscriptions/org-subscription-update-mutation.ts @@ -0,0 +1,61 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { put } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { subscriptionKeys } from './keys' +import { SupaResponseV2 } from 'types/base' + +export type SubscriptionTier = + | 'tier_free' + | 'tier_pro' + | 'tier_payg' + | 'tier_team' + | 'tier_enterprise' + +export type OrgSubscriptionUpdateVariables = { + slug: string + paymentMethod?: string + tier: SubscriptionTier +} + +export async function updateOrgSubscription({ + slug, + tier, + paymentMethod, +}: OrgSubscriptionUpdateVariables) { + if (!slug) throw new Error('slug is required') + if (!tier) throw new Error('tier is required') + + const payload: { tier: string; payment_method?: string } = { tier } + if (paymentMethod !== undefined) payload.payment_method = paymentMethod + + const response = (await put( + `${API_URL}/organizations/${slug}/billing/subscription`, + payload + )) as SupaResponseV2 + if (typeof response === 'object' && response !== null && 'error' in response) throw response.error + return response +} + +type OrgSubscriptionUpdateData = Awaited> + +export const useOrgSubscriptionUpdateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => updateOrgSubscription(vars), + { + async onSuccess(data, variables, context) { + const { slug } = variables + await queryClient.invalidateQueries(subscriptionKeys.orgSubscription(slug)) + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/hooks/index.ts b/studio/hooks/index.ts index 25a3607e163..c8c4d6b4db6 100644 --- a/studio/hooks/index.ts +++ b/studio/hooks/index.ts @@ -1,7 +1,9 @@ +export * from './deprecated' +export * from './misc/useCheckPermissions' +export * from './misc/useImmutableValue' +export * from './misc/useLocalStorage' +export * from './misc/useSelectedOrganization' +export * from './misc/useSelectedProject' export * from './misc/useStore' export * from './misc/withAuth' -export * from './misc/useImmutableValue' -export * from './misc/useCheckPermissions' -export * from './misc/useLocalStorage' -export * from './deprecated' export * from './ui' diff --git a/studio/hooks/misc/useAutoAuthRedirect.tsx b/studio/hooks/misc/useAutoAuthRedirect.tsx index 3a63cae6976..ca97fe8d486 100644 --- a/studio/hooks/misc/useAutoAuthRedirect.tsx +++ b/studio/hooks/misc/useAutoAuthRedirect.tsx @@ -19,9 +19,7 @@ export function usePushNext() { // vercel integration const isVercelIntegration = next.includes('https://vercel.com') if (isVercelIntegration) { - searchParams.delete('returnTo') - - return await router.push(`/vercel/integrate?${searchParams.toString()}`) + return } // database.new integration diff --git a/studio/hooks/misc/useCheckPermissions.ts b/studio/hooks/misc/useCheckPermissions.ts index 3866b4295ad..257b4482a67 100644 --- a/studio/hooks/misc/useCheckPermissions.ts +++ b/studio/hooks/misc/useCheckPermissions.ts @@ -1,35 +1,74 @@ -import { useStore } from 'hooks' import jsonLogic from 'json-logic-js' + +import { usePermissionsQuery } from 'data/permissions/permissions-query' import { IS_PLATFORM } from 'lib/constants' +import { Permission } from 'types' +import { useSelectedOrganization } from './useSelectedOrganization' const toRegexpString = (actionOrResource: string) => `^${actionOrResource.replace('.', '\\.').replace('%', '.*')}$` -export function checkPermissions( +export function doPermissionsCheck( + permissions: Permission[] | undefined, action: string, resource: string, data?: object, organizationId?: number ) { - if (!IS_PLATFORM) return true - - const { ui } = useStore() - const orgId = organizationId ?? ui?.selectedOrganization?.id - - return (ui?.permissions ?? []) + return (permissions ?? []) .filter( - (permission: { - actions: string[] - condition: jsonLogic.RulesLogic - organization_id: number - resources: string[] - }) => + (permission) => + permission.organization_id === organizationId && permission.actions.some((act) => (action ? action.match(toRegexpString(act)) : null)) && - permission.resources.some((res) => resource.match(toRegexpString(res))) && - permission.organization_id === orgId + permission.resources.some((res) => resource.match(toRegexpString(res))) ) .some( ({ condition }: { condition: jsonLogic.RulesLogic }) => condition === null || jsonLogic.apply(condition, { resource_name: resource, ...data }) ) } + +export function useGetPermissions( + permissionsOverride?: Permission[], + organizationIdOverride?: number, + enabled = true +) { + const permissionsResult = usePermissionsQuery({ + enabled: permissionsOverride === undefined && enabled, + }) + + const permissions = + permissionsOverride === undefined ? permissionsResult.data : permissionsOverride + + const organizationResult = useSelectedOrganization({ + enabled: organizationIdOverride === undefined && enabled, + }) + + const organization = + organizationIdOverride === undefined ? organizationResult : { id: organizationIdOverride } + const organizationId = organization?.id + + return { + permissions, + organizationId, + } +} + +export function useCheckPermissions( + action: string, + resource: string, + data?: object, + // [Joshen] Pass the variables if you want to avoid hooks in this + // e.g If you want to use useCheckPermissions in a loop like organization settings + organizationId?: number, + permissions?: Permission[] +) { + const { permissions: allPermissions, organizationId: orgId } = useGetPermissions( + permissions, + organizationId + ) + + if (!IS_PLATFORM) return true + + return doPermissionsCheck(allPermissions, action, resource, data, orgId) +} diff --git a/studio/hooks/misc/useSelectedOrganization.ts b/studio/hooks/misc/useSelectedOrganization.ts new file mode 100644 index 00000000000..c2446380fc5 --- /dev/null +++ b/studio/hooks/misc/useSelectedOrganization.ts @@ -0,0 +1,17 @@ +import { useParams } from 'common' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useMemo } from 'react' + +import { useSelectedProject } from './useSelectedProject' + +export function useSelectedOrganization({ enabled = true } = {}) { + const { slug } = useParams() + const { data } = useOrganizationsQuery({ enabled }) + const selectedProject = useSelectedProject() + + return useMemo(() => { + return data?.find( + (org) => org.slug === slug || (selectedProject && org.id === selectedProject.organization_id) + ) + }, [data, selectedProject, slug]) +} diff --git a/studio/hooks/misc/useSelectedProject.ts b/studio/hooks/misc/useSelectedProject.ts new file mode 100644 index 00000000000..824bb14a073 --- /dev/null +++ b/studio/hooks/misc/useSelectedProject.ts @@ -0,0 +1,9 @@ +import { useParams } from 'common' +import { useProjectDetailQuery } from 'data/projects/project-detail-query' + +export function useSelectedProject() { + const { ref } = useParams() + const { data } = useProjectDetailQuery({ ref }) + + return data +} diff --git a/studio/hooks/misc/useStore.tsx b/studio/hooks/misc/useStore.tsx index 2248cda5eee..025301498b5 100644 --- a/studio/hooks/misc/useStore.tsx +++ b/studio/hooks/misc/useStore.tsx @@ -35,8 +35,6 @@ export const StoreProvider: FC = ({ children, rootStore }) => { }, [isDarkMode, monaco]) useEffect(() => { - ui.load() - autorun(() => { if (ui.notification) { const { id, category, error, message, description, progress, duration } = ui.notification diff --git a/studio/hooks/misc/withAuth.tsx b/studio/hooks/misc/withAuth.tsx index be44628398e..21812c5c3f1 100644 --- a/studio/hooks/misc/withAuth.tsx +++ b/studio/hooks/misc/withAuth.tsx @@ -2,13 +2,13 @@ import Head from 'next/head' import { useRouter } from 'next/router' import { ComponentType, useEffect } from 'react' -import { usePermissionsQuery } from 'data/permissions/permissions-query' -import { useStore } from 'hooks' import { useParams } from 'common/hooks' +import { usePermissionsQuery } from 'data/permissions/permissions-query' +import { useSelectedProject, useStore } from 'hooks' import { useAuth } from 'lib/auth' import { IS_PLATFORM } from 'lib/constants' -import { getReturnToPath, STORAGE_KEY } from 'lib/gotrue' -import { isNextPageWithLayout, NextPageWithLayout } from 'types' +import { STORAGE_KEY, getReturnToPath } from 'lib/gotrue' +import { NextPageWithLayout, isNextPageWithLayout } from 'types' import Error500 from '../../pages/500' const PLATFORM_ONLY_PAGES = [ @@ -30,26 +30,17 @@ export function withAuth( const WithAuthHOC: ComponentType = (props: any) => { const router = useRouter() const { basePath } = router - const { ref, slug } = useParams() + const { ref } = useParams() const rootStore = useStore() const { isLoading, session } = useAuth() - const { app, ui } = rootStore + const { ui } = rootStore const page = router.pathname.split('/').slice(3).join('/') const redirectTo = options?.redirectTo ?? defaultRedirectTo(ref) const redirectIfFound = options?.redirectIfFound - useEffect(() => { - if (!app.organizations.isInitialized) app.organizations.load() - if (!app.projects.isInitialized) app.projects.load() - }, [app.organizations.isInitialized, app.projects.isInitialized]) - usePermissionsQuery({ - enabled: IS_PLATFORM, - onSuccess(permissions) { - ui.setPermissions(permissions) - }, onError(error: any) { ui.setNotification({ error, @@ -75,14 +66,12 @@ export function withAuth( } }, [isRedirecting, redirectTo]) + const selectedProject = useSelectedProject() useEffect(() => { - if (router.isReady) { - if (ref) { - rootStore.setProjectRef(ref) - } - rootStore.setOrganizationSlug(slug) + if (selectedProject) { + rootStore.setProject(selectedProject) } - }, [isLoading, router.isReady, ref, slug]) + }, [selectedProject]) if (!isLoading && !isRedirecting && !isLoggedIn) { return diff --git a/studio/lib/auth.tsx b/studio/lib/auth.tsx index 762cc3da511..a0e3e21ed7a 100644 --- a/studio/lib/auth.tsx +++ b/studio/lib/auth.tsx @@ -1,6 +1,6 @@ import { useQueryClient } from '@tanstack/react-query' import { useRouter } from 'next/router' -import { PropsWithChildren, useCallback, useEffect, useRef } from 'react' +import { PropsWithChildren, useCallback, useEffect } from 'react' import { AuthContext as AuthContextInternal, @@ -8,17 +8,16 @@ import { gotrueClient, useTelemetryProps, } from 'common' -import { useProfileQuery } from 'data/profile/profile-query' import { useStore } from 'hooks' import Telemetry from 'lib/telemetry' import { GOTRUE_ERRORS, IS_PLATFORM } from './constants' -import { clearLocalStorage } from './local-storage' -import { useProfileCreateMutation } from 'data/profile/profile-create-mutation' +import { clearLocalStorage, resetSignInClicks } from './local-storage' export const AuthContext = AuthContextInternal export const AuthProvider = ({ children }: PropsWithChildren<{}>) => { - const { ui, app } = useStore() + const queryClient = useQueryClient() + const { ui } = useStore() const router = useRouter() const telemetryProps = useTelemetryProps() @@ -55,41 +54,6 @@ export const AuthProvider = ({ children }: PropsWithChildren<{}>) => { return subscription.unsubscribe }, []) - const { mutate: createProfile } = useProfileCreateMutation({ - onSuccess() { - Telemetry.sendEvent( - { category: 'conversion', action: 'sign_up', label: '' }, - telemetryProps, - router - ) - }, - onError(err) { - ui.setNotification({ - category: 'error', - message: 'Failed to create your profile. Please refresh to try again.', - }) - }, - }) - - // Track telemetry for the current user - useProfileQuery({ - onSuccess(profile) { - Telemetry.sendIdentify(profile, telemetryProps) - - // [Joshen] Temp fix: For new users, the GET profile call also creates a default org - // But because the dashboard's logged in state is using gotrue as the source of truth - // the home page loads before the GET profile call completes (and consequently before the - // creation of the default org). Hence why calling org load here - app.organizations.load() - }, - onError(err) { - // if the user does not yet exist, create a profile for them - if (typeof err === 'object' && err !== null && 'code' in err && (err as any).code === 404) { - createProfile() - } - }, - }) - return {children} } @@ -99,10 +63,12 @@ export function useSignOut() { const queryClient = useQueryClient() return useCallback(async () => { + resetSignInClicks() + const result = await gotrueClient.signOut() clearLocalStorage() await queryClient.resetQueries() return result - }, []) + }, [queryClient]) } diff --git a/studio/lib/constants/infrastructure.ts b/studio/lib/constants/infrastructure.ts index eed060ab177..f86304825c6 100644 --- a/studio/lib/constants/infrastructure.ts +++ b/studio/lib/constants/infrastructure.ts @@ -27,7 +27,7 @@ export const FLY_REGIONS = { } as const export const AWS_REGIONS_DEFAULT = - process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' ? AWS_REGIONS.SOUTHEAST_ASIA : AWS_REGIONS.EAST_US + process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' ? AWS_REGIONS.SOUTHEAST_ASIA : AWS_REGIONS.WEST_US // TO DO, change default to US region for prod export const FLY_REGIONS_DEFAULT = FLY_REGIONS.SOUTHEAST_ASIA @@ -38,6 +38,7 @@ export const PRICING_TIER_LABELS = { } export const PRICING_TIER_LABELS_ORG = { + FREE: 'Free - $0/month', PRO: 'Pro - $25/month', TEAM: 'Team - $599/month', } @@ -53,7 +54,8 @@ export const PRICING_TIER_PRODUCT_IDS = { export const PRICING_TIER_FREE_KEY = 'FREE' export const PRICING_TIER_DEFAULT_KEY = 'FREE' -export const DEFAULT_PROVIDER: CloudProvider = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' ? 'FLY' : 'AWS' +export const DEFAULT_PROVIDER: CloudProvider = + process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' ? 'FLY' : 'AWS' export const PROVIDERS = { FLY: { @@ -134,6 +136,7 @@ export const PROJECT_STATUS = { RESTORING: 'RESTORING', RESTORATION_FAILED: 'RESTORATION_FAILED', UPGRADING: 'UPGRADING', + PAUSING: 'PAUSING', } export const DEFAULT_MINIMUM_PASSWORD_STRENGTH = 4 diff --git a/studio/lib/constants/metrics.tsx b/studio/lib/constants/metrics.tsx index 9ab4fb3374d..360ac5352e4 100644 --- a/studio/lib/constants/metrics.tsx +++ b/studio/lib/constants/metrics.tsx @@ -40,8 +40,14 @@ export const METRIC_CATEGORIES = { export const METRICS = [ { - key: 'cpu_usage', - label: 'CPU % usage', + key: 'avg_cpu_usage', + label: 'Average CPU % usage', + provider: 'infra-monitoring', + category: METRIC_CATEGORIES.INSTANCE, + }, + { + key: 'max_cpu_usage', + label: 'Max CPU % usage', provider: 'infra-monitoring', category: METRIC_CATEGORIES.INSTANCE, }, diff --git a/studio/lib/helpers.ts b/studio/lib/helpers.ts index cc98c0d2b48..767599d3cfa 100644 --- a/studio/lib/helpers.ts +++ b/studio/lib/helpers.ts @@ -160,22 +160,19 @@ export const snakeToCamel = (str: string) => * Safari doesn't support write text into clipboard async, so if you need to load * text content async before coping, please use Promise for the 1st arg. */ -export const copyToClipboard = async ( - str: string, - callback: () => void = () => {} -): Promise => { +export const copyToClipboard = async (str: string | Promise, callback = () => {}) => { const focused = window.document.hasFocus() if (focused) { if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { - try { - await navigator.clipboard.writeText(str) - callback() - } catch (error) { - console.warn('Unable to copy to clipboard') - } - } else { - console.warn('Clipboard writeText method not supported') + const text = await Promise.resolve(str) + Promise.resolve(window.navigator?.clipboard?.writeText(text)).then(callback) + + return } + + Promise.resolve(str) + .then((text) => window.navigator?.clipboard?.writeText(text)) + .then(callback) } else { console.warn('Unable to copy to clipboard') } @@ -249,3 +246,20 @@ export const detectOS = () => { return undefined } } + +/** + * Pluralize a word based on a count + */ +export function pluralize(count: number, singular: string, plural?: string) { + return count === 1 ? singular : plural || singular + 's' +} + +export const isValidHttpUrl = (value: string) => { + let url: URL + try { + url = new URL(value) + } catch (_) { + return false + } + return url.protocol === 'http:' || url.protocol === 'https:' +} diff --git a/studio/lib/integration-utils.ts b/studio/lib/integration-utils.ts new file mode 100644 index 00000000000..87059452094 --- /dev/null +++ b/studio/lib/integration-utils.ts @@ -0,0 +1,122 @@ +import { SupaResponseV2 } from 'types' +import { Integration } from 'data/integrations/integrations.types' + +async function fetchGitHub(url: string, responseJson = true): Promise> { + const response = await fetch(url) + if (!response.ok) { + return { + error: { + code: response.status, + message: response.statusText, + requestId: '', + }, + } + } + try { + return (responseJson ? await response.json() : await response.text()) as T + } catch (error: any) { + return { + error: { + message: error.message, + code: 500, + requestId: '', + }, + } + } +} + +export type File = { + name: string + download_url: string +} + +/** + * Returns the initial migration SQL from a GitHub repo. + * @param externalId An external GitHub URL for example: https://github.com/vercel/next.js/tree/canary/examples/with-supabase + */ +export async function getInitialMigrationSQLFromGitHubRepo( + externalId?: string +): Promise { + if (!externalId) return null + + const [, , , owner, repo, , branch, ...pathSegments] = externalId?.split('/') ?? [] + const path = pathSegments.join('/') + + const baseGitHubUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}` + const supabaseFolderUrl = `${baseGitHubUrl}/supabase?ref=${branch}` + const supabaseMigrationsPath = `supabase/migrations` // TODO: read this from the `supabase/config.toml` file + const migrationsFolderUrl = `${baseGitHubUrl}/${supabaseMigrationsPath}?ref=${branch}` + + const [supabaseFilesResponse, migrationFilesResponse] = await Promise.all([ + fetchGitHub(supabaseFolderUrl), + fetchGitHub(migrationsFolderUrl), + ]) + + if ('error' in supabaseFilesResponse) { + console.warn(`Failed to fetch supabase files from GitHub: ${supabaseFilesResponse.error}`) + return null + } + if ('error' in migrationFilesResponse) { + console.warn(`Failed to fetch migration files from GitHub: ${migrationFilesResponse.error}`) + return null + } + + const seedFileUrl = supabaseFilesResponse.find((file) => file.name === 'seed.sql')?.download_url + const sortedFiles = migrationFilesResponse.sort((a, b) => { + // sort by name ascending + if (a.name < b.name) return -1 + if (a.name > b.name) return 1 + return 0 + }) + const migrationFileDownloadUrlPromises = sortedFiles.map((file) => + fetchGitHub(file.download_url, false) + ) + + const [seedFileResponse, ...migrationFileResponses] = await Promise.all([ + seedFileUrl ? fetchGitHub(seedFileUrl, false) : Promise.resolve(''), + ...migrationFileDownloadUrlPromises, + ]) + + const migrations = migrationFileResponses + .filter((response) => !(typeof response === 'object' && 'error' in response)) + .join(';') + const seed = + typeof seedFileResponse === 'object' && 'error' in seedFileResponse ? '' : seedFileResponse + + const migrationsTableSql = /* SQL */ ` + create schema if not exists supabase_migrations; + create table if not exists supabase_migrations.schema_migrations ( + version text not null primary key, + statements text[], + name text + ); + ${sortedFiles.map((file, i) => { + const migration = migrationFileResponses[i] + if (!migration || (typeof migration === 'object' && 'error' in migration)) return '' + + const version = file.name.split('_')[0] + const statements = JSON.stringify( + migration + .split(';') + .map((statement) => statement.trim()) + .filter(Boolean) + ) + + return /* SQL */ ` + insert into supabase_migrations.schema_migrations (version, statements, name) + select '${version}', array_agg(jsonb_statements)::text[], '${file.name}' + from jsonb_array_elements_text($statements$${statements}$statements$::jsonb) as jsonb_statements; + ` + })} + ` + + return `${migrations};${migrationsTableSql};${seed}` +} + +export function getVercelConfigurationUrl(integration: Integration) { + return `https://vercel.com/dashboard/${ + integration.metadata?.account.type === 'Team' + ? `${integration.metadata?.account.team_slug}/` + : '' + }integrations/${integration.metadata?.configuration_id}` +} diff --git a/studio/lib/local-storage.ts b/studio/lib/local-storage.ts index 611bcd1616c..ce3d2b3f271 100644 --- a/studio/lib/local-storage.ts +++ b/studio/lib/local-storage.ts @@ -1,4 +1,12 @@ -export const LOCAL_STORAGE_KEYS_ALLOWLIST = ['graphiql:theme', 'theme', 'supabaseDarkMode'] +import { IS_PLATFORM } from './constants' + +export const LOCAL_STORAGE_KEYS_ALLOWLIST = [ + 'graphiql:theme', + 'theme', + 'supabaseDarkMode', + 'supabase.dashboard.sign_in_clicks', + 'supabase.dashboard.auth.debug', +] export function clearLocalStorage() { for (const key in localStorage) { @@ -7,3 +15,47 @@ export function clearLocalStorage() { } } } + +function inferSignInClicks() { + if (localStorage.getItem('supabase.dashboard.sign_in_clicks')) { + return + } + + localStorage.setItem( + 'supabase.dashboard.sign_in_clicks', + localStorage.getItem('supabase.dashboard.auth.token') ? '1' : '0' + ) +} + +export function getSignInClicks(): number { + let count: number | null = null + + try { + count = JSON.parse(localStorage.getItem('supabase.dashboard.sign_in_clicks') || '0') + } catch (e: any) { + // do nothing + } + + return count || 0 +} + +export function incrementSignInClicks(): number { + const clicks = getSignInClicks() + + localStorage.setItem('supabase.dashboard.sign_in_clicks', JSON.stringify(clicks + 1)) + + return clicks + 1 +} + +export function resetSignInClicks(): number { + const clicks = getSignInClicks() + + localStorage.setItem('supabase.dashboard.sign_in_clicks', '0') + + return clicks +} + +if (globalThis && globalThis.localStorage && IS_PLATFORM) { + // populate the value based on the current local storage state + inferSignInClicks() +} diff --git a/studio/lib/profile.tsx b/studio/lib/profile.tsx new file mode 100644 index 00000000000..18f91d45900 --- /dev/null +++ b/studio/lib/profile.tsx @@ -0,0 +1,75 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useRouter } from 'next/router' +import { PropsWithChildren, createContext, useContext, useMemo } from 'react' + +import { useTelemetryProps } from 'common' +import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query' +import { usePermissionsQuery } from 'data/permissions/permissions-query' +import { useProfileCreateMutation } from 'data/profile/profile-create-mutation' +import { useProfileQuery } from 'data/profile/profile-query' +import { Profile } from 'data/profile/types' +import { useStore } from 'hooks' +import Telemetry from 'lib/telemetry' + +export type ProfileContextType = { + profile: Profile | undefined + isLoading: boolean +} + +export const ProfileContext = createContext({ + profile: undefined, + isLoading: true, +}) + +export const ProfileProvider = ({ children }: PropsWithChildren<{}>) => { + const queryClient = useQueryClient() + const { ui } = useStore() + const router = useRouter() + const telemetryProps = useTelemetryProps() + + const { mutate: createProfile, isLoading: isCreatingProfile } = useProfileCreateMutation({ + async onSuccess() { + Telemetry.sendEvent( + { category: 'conversion', action: 'sign_up', label: '' }, + telemetryProps, + router + ) + + await invalidateOrganizationsQuery(queryClient) + }, + onError(err) { + ui.setNotification({ + category: 'error', + message: 'Failed to create your profile. Please refresh to try again.', + }) + }, + }) + + // Track telemetry for the current user + const { data: profile, isLoading: isLoadingProfile } = useProfileQuery({ + onSuccess(profile) { + Telemetry.sendIdentify(profile, telemetryProps) + }, + onError(err) { + // if the user does not yet exist, create a profile for them + if (typeof err === 'object' && err !== null && 'code' in err && (err as any).code === 404) { + createProfile() + } + }, + }) + + const { isInitialLoading: isLoadingPermissions } = usePermissionsQuery() + + const value = useMemo(() => { + const isLoading = isLoadingProfile || isCreatingProfile || isLoadingPermissions + + return { + profile, + isLoading, + } + }, [isLoadingProfile, isCreatingProfile, isLoadingPermissions, profile]) + + return {children} +} + +export const useProfile = () => useContext(ProfileContext) diff --git a/studio/lib/telemetry.ts b/studio/lib/telemetry.ts index 9cf951fc0c6..5150ec8ad35 100644 --- a/studio/lib/telemetry.ts +++ b/studio/lib/telemetry.ts @@ -53,7 +53,45 @@ const sendIdentify = (user: User, gaProps?: TelemetryProps) => { }) } +const sendActivity = ( + event: { + activity: string + source: string + projectRef?: string + orgId?: string + data?: object + }, + router: NextRouter +) => { + if (!IS_PLATFORM) return + + const { activity, source, projectRef, orgId, data } = event + + const properties = { + activity, + source, + page: { + path: router.route, + location: router.asPath, + referrer: document?.referrer || '', + title: document?.title || '', + }, + ...(data && { data }), + // add if included, else estimate from path + ...(projectRef && { projectRef }), + ...(router.route.includes('/project/') && + !projectRef && { + projectRef: router.asPath.split('/project/')[1].split('/')[0], + }), + ...(orgId && { orgId }), + ...(router.route.includes('/org/') && + !orgId && { orgId: router.asPath.split('/org/')[1].split('/')[0] }), + } + return post(`${API_URL}/telemetry/activity`, properties) +} + export default { sendEvent, sendIdentify, + sendActivity, } diff --git a/studio/lib/upload.ts b/studio/lib/upload.ts new file mode 100644 index 00000000000..5f141374099 --- /dev/null +++ b/studio/lib/upload.ts @@ -0,0 +1,45 @@ +import { createClient } from '@supabase/supabase-js' + +const SUPPORT_API_URL = process.env.NEXT_PUBLIC_SUPPORT_API_URL || '' +const SUPPORT_API_KEY = process.env.NEXT_PUBLIC_SUPPORT_ANON_KEY || '' + +// [Joshen TODO] Feedback form and support attachments should use this +export const uploadAttachment = async ( + bucket: string, + fileName: string, + image: File, + getUrl: boolean = true +) => { + const supabaseClient = createClient(SUPPORT_API_URL, SUPPORT_API_KEY, { + auth: { + persistSession: false, + autoRefreshToken: false, + // @ts-ignore + multiTab: false, + detectSessionInUrl: false, + localStorage: { + getItem: (key: string) => undefined, + setItem: (key: string, value: string) => {}, + removeItem: (key: string) => {}, + }, + }, + }) + + const options = { cacheControl: '3600' } + + const { data: file, error } = await supabaseClient.storage + .from(bucket) + .upload(fileName, image, options) + + if (error) { + console.error('Failed to upload:', error) + return undefined + } + + if (file && getUrl) { + const { data } = await supabaseClient.storage.from(bucket).getPublicUrl(file.path) + return data?.publicUrl + } + + return undefined +} diff --git a/studio/lib/void.ts b/studio/lib/void.ts new file mode 100644 index 00000000000..edd1d1fe647 --- /dev/null +++ b/studio/lib/void.ts @@ -0,0 +1,7 @@ +// Because {} !== {} in JS, we need a shared object instances for comparison + +export const EMPTY_OBJ = {} + +export const EMPTY_ARR = [] + +export function noop() {} diff --git a/studio/localStores/storageExplorer/StorageExplorerStore.js b/studio/localStores/storageExplorer/StorageExplorerStore.js index 37641c47ac6..f0148c926f2 100644 --- a/studio/localStores/storageExplorer/StorageExplorerStore.js +++ b/studio/localStores/storageExplorer/StorageExplorerStore.js @@ -8,7 +8,7 @@ import { createClient } from '@supabase/supabase-js' import { useStore } from 'hooks' import { copyToClipboard } from 'lib/helpers' import { API_URL, IS_PLATFORM } from 'lib/constants' -import { get, patch, post, delete_ } from 'lib/common/fetch' +import { post, delete_ } from 'lib/common/fetch' import { PROJECT_ENDPOINT_PROTOCOL } from 'pages/api/constants' import { STORAGE_VIEWS, @@ -394,47 +394,26 @@ class StorageExplorerStore { this.selectedFilePreview = {} } - copyFileURLToClipboard = async (file, expiresIn = 0) => { + getFileUrl = async (file, expiresIn = 0) => { const filePreview = find(this.filePreviewCache, { id: file.id }) if (filePreview !== undefined && expiresIn === 0) { - // Already generated signed URL - copyToClipboard(filePreview.url, () => { - this.ui.setNotification({ - category: 'success', - message: `Copied URL for ${file.name} to clipboard.`, - duration: 4000, - }) - }) + return filePreview.url } else { - // Need to generate signed URL, and might as well save it to cache as well - const signedUrlAsync = this.fetchFilePreview(file.name, expiresIn).then((signedUrl) => { - const formattedUrl = new URL(signedUrl) - formattedUrl.searchParams.set('t', new Date().toISOString()) + const signedUrl = await this.fetchFilePreview(file.name, expiresIn) + const formattedUrl = new URL(signedUrl) + formattedUrl.searchParams.set('t', new Date().toISOString()) + const fileUrl = formattedUrl.toString() - return formattedUrl.toString() - }) - - try { - copyToClipboard(signedUrlAsync, () => { - this.ui.setNotification({ - category: 'success', - message: `Copied URL for ${file.name} to clipboard.`, - duration: 4000, - }) - }) - const fileCache = { - id: file.id, - url: await signedUrlAsync, - expiresIn: DEFAULT_EXPIRY, - fetchedAt: Date.now(), - } - this.addFileToPreviewCache(fileCache) - } catch (error) { - this.ui.setNotification({ - category: 'error', - message: `Failed to copy URL: ${error}`, - }) + // Also save it to cache + const fileCache = { + id: file.id, + url: fileUrl, + expiresIn: DEFAULT_EXPIRY, + fetchedAt: Date.now(), } + this.addFileToPreviewCache(fileCache) + + return fileUrl } } diff --git a/studio/next.config.js b/studio/next.config.js index 482be47c169..d95d252877e 100644 --- a/studio/next.config.js +++ b/studio/next.config.js @@ -201,7 +201,7 @@ const nextConfig = { ] }, images: { - domains: ['github.com'], + domains: ['github.com', 'api-frameworks.vercel.sh', 'vercel.com'], }, // Ref: https://nextjs.org/docs/advanced-features/output-file-tracing#caveats experimental: { diff --git a/studio/pages/_app.tsx b/studio/pages/_app.tsx index 7f707309d29..6051ab68387 100644 --- a/studio/pages/_app.tsx +++ b/studio/pages/_app.tsx @@ -35,6 +35,7 @@ import { RootStore } from 'stores' import HCaptchaLoadedStore from 'stores/hcaptcha-loaded-store' import { StoreProvider } from 'hooks' import { AuthProvider } from 'lib/auth' +import { ProfileProvider } from 'lib/profile' import { dart } from 'lib/constants/prism' import { useRootQueryClient } from 'data/query-client' @@ -122,38 +123,39 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { }, [supabase] ) - // return ( - - - Supabase - - - + + + + Supabase + + + - - - - - - - {getLayout()} - - - - - - + + + + + + + {getLayout()} + + + + + + - - - - + + + + + diff --git a/studio/pages/account/me.tsx b/studio/pages/account/me.tsx index 2ecbc5de734..0484fd25730 100644 --- a/studio/pages/account/me.tsx +++ b/studio/pages/account/me.tsx @@ -1,17 +1,17 @@ import { observer } from 'mobx-react-lite' -import { Button, IconMoon, IconSun, Input, Listbox } from 'ui' +import Link from 'next/link' +import { useTheme } from 'common' import { AccountLayout } from 'components/layouts' import SchemaFormPanel from 'components/to-be-cleaned/forms/SchemaFormPanel' import Panel from 'components/ui/Panel' -import { Profile as ProfileType } from 'data/profile/types' import { useProfileUpdateMutation } from 'data/profile/profile-update-mutation' -import { useProfileQuery } from 'data/profile/profile-query' +import { Profile as ProfileType } from 'data/profile/types' import { useStore } from 'hooks' import { useSession } from 'lib/auth' -import Link from 'next/link' +import { useProfile } from 'lib/profile' import { NextPageWithLayout } from 'types' -import { useTheme } from 'common' +import { Button, IconMoon, IconSun, Input, Listbox } from 'ui' const User: NextPageWithLayout = () => { return ( @@ -41,7 +41,7 @@ const ProfileCard = observer(() => { const { ui } = useStore() const { mutateAsync } = useProfileUpdateMutation() - const { data: profile } = useProfileQuery() + const { profile } = useProfile() // TODO: ^ handle loading state const updateUser = async (model: any) => { diff --git a/studio/pages/account/tokens.tsx b/studio/pages/account/tokens.tsx index 7dd76978437..b6a242912d9 100644 --- a/studio/pages/account/tokens.tsx +++ b/studio/pages/account/tokens.tsx @@ -20,7 +20,7 @@ const UserAccessTokens: NextPageWithLayout = () => {
    diff --git a/studio/pages/api/auth/[ref]/config.ts b/studio/pages/api/auth/[ref]/config.ts index 66545dba0ae..34878dd37f3 100644 --- a/studio/pages/api/auth/[ref]/config.ts +++ b/studio/pages/api/auth/[ref]/config.ts @@ -74,6 +74,9 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { EXTERNAL_GOOGLE_ENABLED: false, EXTERNAL_GOOGLE_CLIENT_ID: null, EXTERNAL_GOOGLE_SECRET: null, + EXTERNAL_KAKAO_ENABLED: false, + EXTERNAL_KAKAO_CLIENT_ID: null, + EXTERNAL_KAKAO_SECRET: null, EXTERNAL_KEYCLOAK_ENABLED: false, EXTERNAL_KEYCLOAK_CLIENT_ID: null, EXTERNAL_KEYCLOAK_SECRET: null, @@ -103,7 +106,6 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { EXTERNAL_ZOOM_ENABLED: false, EXTERNAL_ZOOM_CLIENT_ID: null, EXTERNAL_ZOOM_SECRET: null, - MAILER_SUBJECTS_INVITE: 'You have been invited', MAILER_SUBJECTS_CONFIRMATION: 'Confirm Your Signup', MAILER_SUBJECTS_RECOVERY: 'Reset Your Password', @@ -134,6 +136,9 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { SMS_TWILIO_ACCOUNT_SID: null, SMS_TWILIO_AUTH_TOKEN: null, SMS_TWILIO_MESSAGE_SERVICE_SID: null, + SMS_TWILIO_VERIFY_ACCOUNT_SID: null, + SMS_TWILIO_VERIFY_AUTH_TOKEN: null, + SMS_TWILIO_VERIFY_MESSAGE_SERVICE_SID: null, SMS_TEMPLATE: 'Your code is {{ .Code }}', SECURITY_CAPTCHA_ENABLED: false, SECURITY_CAPTCHA_PROVIDER: 'hcaptcha', diff --git a/studio/pages/authorize.tsx b/studio/pages/authorize.tsx index a7f1c81e920..4d28fb24a67 100644 --- a/studio/pages/authorize.tsx +++ b/studio/pages/authorize.tsx @@ -1,4 +1,9 @@ +import dayjs from 'dayjs' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' + import { useParams } from 'common' +import AuthorizeRequesterDetails from 'components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails' import APIAuthorizationLayout from 'components/layouts/APIAuthorizationLayout' import { FormPanel } from 'components/ui/Forms' import ShimmeringLoader from 'components/ui/ShimmeringLoader' @@ -6,10 +11,7 @@ import { useApiAuthorizationApproveMutation } from 'data/api-authorization/api-a import { useApiAuthorizationDeclineMutation } from 'data/api-authorization/api-authorization-decline-mutation' import { useApiAuthorizationQuery } from 'data/api-authorization/api-authorization-query' import { useOrganizationsQuery } from 'data/organizations/organizations-query' -import dayjs from 'dayjs' import { useStore, withAuth } from 'hooks' -import { useRouter } from 'next/router' -import { useEffect, useState } from 'react' import { NextPageWithLayout } from 'types' import { Alert, Button, Listbox } from 'ui' @@ -170,30 +172,11 @@ const APIAuthorizationPage: NextPageWithLayout = () => { >
    {/* API Authorization requester details */} - -
    -
    -
    -
    - {requester?.icon === null && ( -

    {requester?.name[0]}

    - )} -
    -
    -
    -

    - {requester?.name} ({requester?.domain}) is requesting API access to an organization. The - application will be able to{' '} - - read and write the organization's settings and all of its projects. - -

    -
    + {/* Expiry warning */} {isExpired && ( diff --git a/studio/pages/integrations/vercel.tsx b/studio/pages/integrations/vercel.tsx new file mode 100644 index 00000000000..9b2a5ac0c5a --- /dev/null +++ b/studio/pages/integrations/vercel.tsx @@ -0,0 +1,19 @@ +import { useEffect } from 'react' + +import IntegrationWindowLayout from 'components/layouts/IntegrationWindowLayout' +import { useRouter } from 'next/router' +import { NextPageWithLayout } from 'types' + +const VercelIntegration: NextPageWithLayout = () => { + const router = useRouter() + + useEffect(() => { + router.push({ pathname: '/integrations/vercel/install', query: router.query }) + }, [router]) + + return <> +} + +VercelIntegration.getLayout = (page) => {page} + +export default VercelIntegration diff --git a/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx b/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx new file mode 100644 index 00000000000..dd89d74d6d4 --- /dev/null +++ b/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx @@ -0,0 +1,380 @@ +import { debounce } from 'lodash' +import { useRouter } from 'next/router' +import { ChangeEvent, useRef, useState } from 'react' + +import { useParams } from 'common' +import { Markdown } from 'components/interfaces/Markdown' +import IntegrationWindowLayout from 'components/layouts/IntegrationWindowLayout' +import { ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold' +import PasswordStrengthBar from 'components/ui/PasswordStrengthBar' +import { useProjectApiQuery } from 'data/config/project-api-query' +import { useIntegrationConnectionsCreateMutation } from 'data/integrations/integration-connections-create-mutation' +import { useIntegrationsQuery } from 'data/integrations/integrations-query' +import { useVercelProjectsQuery } from 'data/integrations/integrations-vercel-projects-query' +import { Integration } from 'data/integrations/integrations.types' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useProjectCreateMutation } from 'data/projects/project-create-mutation' +import generator from 'generate-password' +import { useSelectedOrganization, useStore } from 'hooks' +import { AWS_REGIONS, DEFAULT_MINIMUM_PASSWORD_STRENGTH, PROVIDERS } from 'lib/constants' +import { passwordStrength } from 'lib/helpers' +import { getInitialMigrationSQLFromGitHubRepo } from 'lib/integration-utils' +import { NextPageWithLayout, ProjectBase } from 'types' +import { Alert, Button, Checkbox, IconBook, IconLifeBuoy, Input, Listbox, LoadingLine } from 'ui' + +const VercelIntegration: NextPageWithLayout = () => { + const [loading, setLoading] = useState(false) + + return ( + <> +
    + + + <> + +

    New project

    + <> + + + +
    + + + + + + + + +
    + +
    + Docs +
    +
    + Support +
    +
    + + ) +} + +VercelIntegration.getLayout = (page) => {page} + +const CreateProject = ({ + loading, + setLoading, +}: { + loading: boolean + setLoading: (e: boolean) => void +}) => { + const router = useRouter() + const { ui } = useStore() + const selectedOrganization = useSelectedOrganization() + const [projectName, setProjectName] = useState('') + const [dbPass, setDbPass] = useState('') + const [passwordStrengthMessage, setPasswordStrengthMessage] = useState('') + const [passwordStrengthScore, setPasswordStrengthScore] = useState(-1) + const [shouldRunMigrations, setShouldRunMigrations] = useState(true) + const [dbRegion, setDbRegion] = useState(PROVIDERS.AWS.default_region) + // const [loading, setLoading] = useState(false) + const delayedCheckPasswordStrength = useRef( + debounce((value: string) => checkPasswordStrength(value), 300) + ).current + + const { + slug, + configurationId, + next, + currentProjectId: foreignProjectId, + externalId, + } = useParams() + + const { mutateAsync: createConnections, isLoading: isLoadingCreateConnections } = + useIntegrationConnectionsCreateMutation({}) + + const { data: organizationData, isLoading: isLoadingOrganizationsQuery } = useOrganizationsQuery() + + const organization = organizationData?.find((x) => x.slug === slug) + + /** + * array of integrations installed + */ + const { data: integrationData, isLoading: integrationDataLoading } = useIntegrationsQuery() + + /** + * the vercel integration installed for organization chosen + */ + const organizationIntegration: Integration | undefined = integrationData?.find( + (x) => x.organization.slug === slug + ) + + /** + * Vercel projects available for this integration + */ + const { data: vercelProjects } = useVercelProjectsQuery( + { + organization_integration_id: organizationIntegration?.id, + }, + { enabled: organizationIntegration !== undefined } + ) + + const canSubmit = + projectName != '' && + passwordStrengthScore >= DEFAULT_MINIMUM_PASSWORD_STRENGTH && + dbRegion != undefined + + function onProjectNameChange(e: ChangeEvent) { + e.target.value = e.target.value.replace(/\./g, '') + setProjectName(e.target.value) + } + + function onDbPassChange(e: ChangeEvent) { + const value = e.target.value + setDbPass(value) + if (value == '') { + setPasswordStrengthScore(-1) + setPasswordStrengthMessage('') + } else delayedCheckPasswordStrength(value) + } + + async function checkPasswordStrength(value: string) { + const { message, strength } = await passwordStrength(value) + setPasswordStrengthScore(strength) + setPasswordStrengthMessage(message) + } + + function generateStrongPassword() { + const password = generator.generate({ + length: 16, + numbers: true, + uppercase: true, + }) + + setDbPass(password) + delayedCheckPasswordStrength(password) + } + + const { mutateAsync: createProject } = useProjectCreateMutation() + + const [newProjectRef, setNewProjectRef] = useState(undefined) + async function onCreateProject() { + if (!organizationIntegration) { + console.error('No organization installation details found') + } + + if (!organizationIntegration?.id) { + console.error('No organization installation ID found') + return + } + + if (!foreignProjectId) { + console.error('No foreignProjectId ID set') + return + } + + if (!configurationId) { + console.error('No configurationId ID set') + return + } + + setLoading(true) + + try { + if (!organization) { + throw new Error('No organization set') + } + + let dbSql: string | undefined + if (shouldRunMigrations) { + const id = ui.setNotification({ + category: 'info', + message: `Fetching initial migrations from GitHub repo`, + }) + + dbSql = (await getInitialMigrationSQLFromGitHubRepo(externalId)) ?? undefined + + ui.setNotification({ + id, + category: 'success', + message: `Done fetching initial migrations`, + }) + } + + let project: ProjectBase + + try { + project = await createProject({ + organizationId: organization.id, + name: projectName, + dbPass, + dbRegion, + dbSql, + configurationId, + }) + + setNewProjectRef(project.ref) + } catch (error: any) { + setLoading(false) + ui.setNotification({ + category: 'error', + message: `Failed to create project: ${error.message}`, + }) + return + } + } catch (error) { + console.error('Error', error) + setLoading(false) + } + } + const isInstallingRef = useRef(false) + + // Wait for the new project to be created before creating the connection + useProjectApiQuery( + { projectRef: newProjectRef }, + { + enabled: newProjectRef !== undefined, + // refetch until the project is created + refetchInterval: (data) => { + return (data?.autoApiService.service_api_keys.length ?? 0) > 0 ? false : 1000 + }, + async onSuccess(data) { + const isReady = data.autoApiService.service_api_keys.length > 0 + + if ( + !isReady || + !organizationIntegration || + !foreignProjectId || + !newProjectRef || + isInstallingRef.current + ) { + return + } + isInstallingRef.current = true + + const projectDetails = vercelProjects?.find((x) => x.id === foreignProjectId) + + // Wrap the createConnections function call in a try-catch block + try { + await createConnections({ + organizationIntegrationId: organizationIntegration?.id, + connection: { + foreign_project_id: foreignProjectId, + supabase_project_ref: newProjectRef, + metadata: { + ...projectDetails, + supabaseConfig: { + projectEnvVars: { + write: true, + }, + }, + }, + }, + orgSlug: selectedOrganization?.slug, + }) + } catch (error) { + console.error('An error occurred during createConnections:', error) + return + } + + setLoading(false) + + if (next) { + window.location.href = next + } + }, + } + ) + + return ( +
    +

    Project details for integration

    +
    + +
    +
    + 0} + onChange={onDbPassChange} + descriptionText={ + + } + /> +
    +
    +
    + setDbRegion(region)} + descriptionText="Select a region close to your users for the best performance." + > + {Object.keys(AWS_REGIONS).map((option: string, i) => { + const label = Object.values(AWS_REGIONS)[i] + return ( + ( + region icon + )} + > + {label} + + ) + })} + +
    +
    +
    + setShouldRunMigrations(e.target.checked)} + /> +
    +
    + +
    +
    + ) +} + +export default VercelIntegration diff --git a/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx b/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx new file mode 100644 index 00000000000..75a9ae1bece --- /dev/null +++ b/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx @@ -0,0 +1,108 @@ +import { useMemo, useState } from 'react' + +import { useParams } from 'common' +import ProjectLinker from 'components/interfaces/Integrations/ProjectLinker' +import { Markdown } from 'components/interfaces/Markdown' +import IntegrationWindowLayout from 'components/layouts/IntegrationWindowLayout' +import { ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold' +import { useOrgIntegrationsQuery } from 'data/integrations/integrations-query-org-only' +import { useVercelProjectsQuery } from 'data/integrations/integrations-vercel-projects-query' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useProjectsQuery } from 'data/projects/projects-query' +import { EMPTY_ARR } from 'lib/void' +import { NextPageWithLayout, Organization } from 'types' +import { IconBook, IconLifeBuoy, LoadingLine } from 'ui' + +const VercelIntegration: NextPageWithLayout = () => { + const { slug, configurationId, next, organizationSlug } = useParams() + + const [loading, setLoading] = useState(false) + + /** + * Fetch the list of organization based integration installations for Vercel. + * + * Array of integrations installed on all + */ + const { data: integrationData, isLoading: integrationDataLoading } = useOrgIntegrationsQuery({ + orgSlug: slug, + }) + + const { data, isLoading: isLoadingOrganizationsQuery } = useOrganizationsQuery({ + enabled: slug !== undefined, + }) + + const organization = data?.find((organization: Organization) => organization.slug === slug) + + const integration = integrationData?.find((x) => x.metadata?.configuration_id === configurationId) + + const { data: supabaseProjectsData } = useProjectsQuery({ + enabled: integration?.id !== undefined, + }) + + const supabaseProjects = useMemo( + () => + supabaseProjectsData + ?.filter((project) => project.organization_id === organization?.id) + .map((project) => ({ id: project.id.toString(), name: project.name, ref: project.ref })) ?? + EMPTY_ARR, + [organizationSlug, supabaseProjectsData] + ) + + const { data: vercelProjectsData } = useVercelProjectsQuery( + { + organization_integration_id: integration?.id, + }, + { enabled: integration?.id !== undefined } + ) + + const vercelProjects = useMemo(() => vercelProjectsData ?? EMPTY_ARR, [vercelProjectsData]) + + return ( + <> +
    + + <> + +
    +

    + Link a Supabase project to a Vercel project +

    + +
    + { + if (next) { + window.location.href = next + } + }} + installedConnections={integration?.connections} + setLoading={setLoading} + /> +
    + + + +
    + +
    + Docs +
    +
    + Support +
    +
    + + ) +} + +VercelIntegration.getLayout = (page) => {page} + +export default VercelIntegration diff --git a/studio/pages/integrations/vercel/install.tsx b/studio/pages/integrations/vercel/install.tsx new file mode 100644 index 00000000000..209884386d6 --- /dev/null +++ b/studio/pages/integrations/vercel/install.tsx @@ -0,0 +1,292 @@ +import { useParams } from 'common' +import { Markdown } from 'components/interfaces/Markdown' +import IntegrationWindowLayout from 'components/layouts/IntegrationWindowLayout' +import { ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold' +import { useIntegrationsQuery } from 'data/integrations/integrations-query' +import { IntegrationName } from 'data/integrations/integrations.types' +import { useVercelIntegrationCreateMutation } from 'data/integrations/vercel-integration-create-mutation' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useStore } from 'hooks' +import { useRouter } from 'next/router' +import { useState } from 'react' +import { useVercelIntegrationInstallationState } from 'state/vercel-integration-installation' +import { NextPageWithLayout, Organization } from 'types' +import { Alert, Badge, Button, IconBook, IconHexagon, IconLifeBuoy, Listbox, LoadingLine } from 'ui' + +/** + * Organization type with `installationInstalled` added + */ +interface OrganizationsResponseWithInstalledData extends Organization { + installationInstalled?: boolean +} + +/** + * Variations of the Vercel integration flow. + * They require different UI and logic. + * + * Deploy Button - the flow that starts from the Deploy Button - https://vercel.com/docs/integrations#deploy-button + * Marketplace - the flow that starts from the Marketplace - https://vercel.com/integrations + * + */ +export type VercelIntegrationFlow = 'deploy-button' | 'marketing' + +const VercelIntegration: NextPageWithLayout = () => { + const router = useRouter() + const { ui } = useStore() + const { code, configurationId, next, teamId, source, externalId } = useParams() + const [selectedOrg, setSelectedOrg] = useState(null) + const [organizationIntegrationId, setOrganizationIntegrationId] = useState(null) + + const snapshot = useVercelIntegrationInstallationState() + + const flow: VercelIntegrationFlow = externalId ? 'marketing' : 'deploy-button' + + /** + * Fetch the list of organization based integration installations for Vercel. + * + * Array of integrations installed on all + */ + const { data: integrationData, isLoading: integrationDataLoading } = useIntegrationsQuery() + + const { data: organizationsData, isLoading: isLoadingOrganizationsQuery } = useOrganizationsQuery( + { + onSuccess(organizations) { + const firstOrg = organizations?.[0] + if (firstOrg && selectedOrg === null) { + setSelectedOrg(firstOrg) + snapshot.setSelectedOrganizationSlug(firstOrg.slug) + router.query.organizationSlug = firstOrg.slug + } + }, + } + ) + + /** + * Flat array of org slugs that have integration installed + * + */ + const flatInstalledConnectionsIds: string[] | [] = + integrationData && integrationData.length > 0 + ? integrationData?.map((x) => x.organization.slug) + : [] + + /** + * Organizations with extra `installationInstalled` attribute + * + * Used to show label/badge and allow/disallow installing + * + */ + const organizationsWithInstalledData: OrganizationsResponseWithInstalledData[] = organizationsData + ? organizationsData.map((org) => { + return { + ...org, + installationInstalled: flatInstalledConnectionsIds.includes(org.slug) ? true : false, + } + }) + : [] + + const { mutate, isLoading: isLoadingVercelIntegrationCreateMutation } = + useVercelIntegrationCreateMutation({ + onSuccess({ id }) { + const orgSlug = selectedOrg?.slug + + setOrganizationIntegrationId(id) + + if (externalId) { + router.push({ + pathname: `/integrations/vercel/${orgSlug}/deploy-button/new-project`, + query: router.query, + }) + } else { + router.push({ + pathname: `/integrations/vercel/${orgSlug}/marketplace/choose-project`, + query: router.query, + }) + } + }, + onError(error: any) { + ui.setNotification({ + category: 'error', + message: `Creating Vercel integration failed: ${error.message}`, + }) + }, + }) + + function onInstall() { + const orgSlug = selectedOrg?.slug + + const isIntegrationInstalled = organizationsWithInstalledData.some( + (x) => x.slug === orgSlug && x.installationInstalled + ) + + if (!orgSlug) { + return ui.setNotification({ category: 'error', message: 'Please select an organization' }) + } + + if (!code) { + return ui.setNotification({ category: 'error', message: 'Vercel code missing' }) + } + + if (!configurationId) { + return ui.setNotification({ category: 'error', message: 'Vercel Configuration ID missing' }) + } + + if (!source) { + return ui.setNotification({ + category: 'error', + message: 'Vercel Configuration source missing', + }) + } + + /** + * Only install if instgration hasn't already been installed + */ + if (!isIntegrationInstalled) { + mutate({ + code, + configurationId, + orgSlug, + metadata: {}, + source, + teamId: teamId, + }) + } + } + + const dataLoading = isLoadingVercelIntegrationCreateMutation || isLoadingOrganizationsQuery + + return ( + <> +
    + + {organizationIntegrationId === null && ( + <> + +

    Choose organization

    + <> + + { + router.query.organizationSlug = e.slug + setSelectedOrg(e) + }} + dataLoading={dataLoading} + /> +
    + +
    + +
    + + + + + + + )} + + +
    + +
    + Docs +
    +
    + Support +
    +
    + + ) +} + +VercelIntegration.getLayout = (page) => {page} + +export interface OrganizationPickerProps { + label?: string + onSelectedOrgChange?: (org: Organization) => void + integrationName: IntegrationName + dataLoading: boolean + organizationsWithInstalledData: OrganizationsResponseWithInstalledData[] +} + +const OrganizationPicker = ({ + label = 'Choose an organization', + onSelectedOrgChange, + integrationName, + dataLoading, + organizationsWithInstalledData, +}: OrganizationPickerProps) => { + const { ui } = useStore() + const [selectedOrg, setSelectedOrg] = useState(null) + + const { data, isLoading } = useOrganizationsQuery({ + onSuccess(organizations) { + const firstOrg = organizations?.[0] + if (firstOrg && selectedOrg === null) { + setSelectedOrg(firstOrg) + onSelectedOrgChange?.(firstOrg) + } + }, + }) + + function _onSelectedOrgChange(slug: string) { + const org = data?.find((org) => org.slug === slug) + + if (org) { + setSelectedOrg(org) + onSelectedOrgChange?.(org) + } + } + + if (dataLoading) { + return ( + + + Loading... + + + ) + } + + return ( + + {organizationsWithInstalledData?.map((org) => { + const label = ( +
    + {org.name} + {org.installationInstalled && Integration Installed} +
    + ) + return ( + } + > + {label} + + ) + })} +
    + ) +} + +export default VercelIntegration diff --git a/studio/pages/join/index.tsx b/studio/pages/join/index.tsx index a87cffe2e10..0f1a5a1f364 100644 --- a/studio/pages/join/index.tsx +++ b/studio/pages/join/index.tsx @@ -1,13 +1,16 @@ +import { useQueryClient } from '@tanstack/react-query' import Link from 'next/link' -import { useState, useEffect } from 'react' import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' import { Button, IconCheckSquare, Loading } from 'ui' +import { useParams } from 'common' +import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query' import { useStore } from 'hooks' import { useSignOut } from 'lib/auth' +import { delete_, get, post } from 'lib/common/fetch' import { API_URL } from 'lib/constants' -import { get, post, delete_ } from 'lib/common/fetch' -import { useProfileQuery } from 'data/profile/profile-query' +import { useProfile } from 'lib/profile' interface ITokenInfo { organization_name?: string | undefined @@ -21,10 +24,11 @@ interface ITokenInfo { type TokenInfo = ITokenInfo | undefined const JoinOrganizationPage = () => { + const queryClient = useQueryClient() const router = useRouter() - const { slug, token, name } = router.query - const { ui, app } = useStore() - const { data: profile } = useProfileQuery() + const { slug, token, name } = useParams() + const { ui } = useStore() + const { profile } = useProfile() const signOut = useSignOut() const [isSubmitting, setIsSubmitting] = useState(false) @@ -38,8 +42,6 @@ const JoinOrganizationPage = () => { useEffect(() => { const fetchTokenInfo = async () => { - await ui.load() - const response = await get(`${API_URL}/organizations/${slug}/members/join?token=${token}`) if (response.error) { @@ -74,7 +76,7 @@ const JoinOrganizationPage = () => { setIsSubmitting(false) } else { setIsSubmitting(false) - app.onOrgAdded(response) + await invalidateOrganizationsQuery(queryClient) router.push('/') } } diff --git a/studio/pages/new/[slug].tsx b/studio/pages/new/[slug].tsx index fe03cf0ddc3..b391a452a6c 100644 --- a/studio/pages/new/[slug].tsx +++ b/studio/pages/new/[slug].tsx @@ -1,47 +1,49 @@ -import Router, { useRouter } from 'next/router' -import { useRef, useState, useEffect, PropsWithChildren } from 'react' -import { debounce, isUndefined, values } from 'lodash' -import { toJS } from 'mobx' -import { observer } from 'mobx-react-lite' -import generator from 'generate-password' -import { Button, Listbox, IconUsers, Input, Alert, IconHelpCircle, Toggle, IconInfo } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { NextPageWithLayout } from 'types' -import { passwordStrength, pluckObjectFields } from 'lib/helpers' +import generator from 'generate-password' +import { debounce, isUndefined } from 'lodash' +import { observer } from 'mobx-react-lite' +import Router, { useRouter } from 'next/router' +import { useEffect, useRef, useState } from 'react' + +import { useParams } from 'common/hooks' +import SpendCapModal from 'components/interfaces/Billing/SpendCapModal' +import { + EmptyPaymentMethodWarning, + FreeProjectLimitWarning, + NotOrganizationOwnerWarning, +} from 'components/interfaces/Organization/NewProject' +import { WizardLayoutWithoutAuth } from 'components/layouts' +import DisabledWarningDueToIncident from 'components/ui/DisabledWarningDueToIncident' +import InformationBox from 'components/ui/InformationBox' +import Panel from 'components/ui/Panel' +import PasswordStrengthBar from 'components/ui/PasswordStrengthBar' +import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import { useCheckPermissions, useFlag, useStore, withAuth } from 'hooks' import { get, post } from 'lib/common/fetch' import { API_URL, - PROVIDERS, + AWS_REGIONS, + CloudProvider, DEFAULT_MINIMUM_PASSWORD_STRENGTH, - PRICING_TIER_LABELS, + DEFAULT_PROVIDER, + FLY_REGIONS, PRICING_TIER_DEFAULT_KEY, PRICING_TIER_FREE_KEY, + PRICING_TIER_LABELS, PRICING_TIER_PRODUCT_IDS, - AWS_REGIONS, - FLY_REGIONS, - CloudProvider, + PROVIDERS, Region, - DEFAULT_PROVIDER, } from 'lib/constants' -import { useStore, useFlag, withAuth, checkPermissions } from 'hooks' -import { useParams } from 'common/hooks' -import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' -import { WizardLayoutWithoutAuth } from 'components/layouts' -import Panel from 'components/ui/Panel' -import PasswordStrengthBar from 'components/ui/PasswordStrengthBar' -import DisabledWarningDueToIncident from 'components/ui/DisabledWarningDueToIncident' -import { - FreeProjectLimitWarning, - NotOrganizationOwnerWarning, - EmptyPaymentMethodWarning, -} from 'components/interfaces/Organization/NewProject' -import SpendCapModal from 'components/interfaces/Billing/SpendCapModal' -import InformationBox from 'components/ui/InformationBox' +import { passwordStrength, pluckObjectFields } from 'lib/helpers' +import { NextPageWithLayout } from 'types' +import { Alert, Button, IconHelpCircle, IconInfo, IconUsers, Input, Listbox, Toggle } from 'ui' const Wizard: NextPageWithLayout = () => { const router = useRouter() const { slug } = useParams() - const { app, ui } = useStore() + const { ui } = useStore() const projectCreationDisabled = useFlag('disableProjectCreationAndUpdate') const cloudProviderEnabled = useFlag('enableFlyCloudProvider') @@ -66,25 +68,32 @@ const Wizard: NextPageWithLayout = () => { const [isSpendCapEnabled, setIsSpendCapEnabled] = useState(true) - const organizations = values(toJS(app.organizations.list())) - const currentOrg = organizations.find((o: any) => o.slug === slug) + const { data: organizations, isSuccess: isOrganizationsSuccess } = useOrganizationsQuery() + const currentOrg = organizations?.find((o: any) => o.slug === slug) const billedViaOrg = Boolean(currentOrg?.subscription_id) + const { data: orgSubscription } = useOrgSubscriptionQuery( + { orgSlug: slug }, + { enabled: billedViaOrg } + ) + const [availableRegions, setAvailableRegions] = useState( getAvailableRegions(PROVIDERS[cloudProvider].id) ) - const isAdmin = checkPermissions(PermissionAction.CREATE, 'projects') + const isAdmin = useCheckPermissions(PermissionAction.CREATE, 'projects') const isInvalidSlug = isUndefined(currentOrg) - const isEmptyOrganizations = organizations.length <= 0 && app.organizations.isInitialized + const isEmptyOrganizations = (organizations?.length ?? 0) <= 0 && isOrganizationsSuccess const isEmptyPaymentMethod = paymentMethods ? !paymentMethods.length : false const isSelectFreeTier = dbPricingTierKey === PRICING_TIER_FREE_KEY const hasMembersExceedingFreeTierLimit = (membersExceededLimit || []).length > 0 const showNonProdFields = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' - const canCreateProject = - isAdmin && (!isSelectFreeTier || (isSelectFreeTier && !hasMembersExceedingFreeTierLimit)) + const freePlanWithExceedingLimits = + (isSelectFreeTier || orgSubscription?.plan?.id === 'free') && hasMembersExceedingFreeTierLimit + + const canCreateProject = isAdmin && !freePlanWithExceedingLimits const canSubmit = projectName !== '' && @@ -108,8 +117,8 @@ const Wizard: NextPageWithLayout = () => { * Redirect to first org if the slug doesn't match an org slug * this is mainly to capture the /project/new url, which is redirected from database.new */ - if (isInvalidSlug && organizations.length > 0) { - router.push(`/new/${organizations[0].slug}`) + if (isInvalidSlug && (organizations?.length ?? 0) > 0) { + router.push(`/new/${organizations?.[0].slug}`) } useEffect(() => { @@ -210,7 +219,6 @@ const Wizard: NextPageWithLayout = () => { message: `Failed to create new project: ${response.error.message}`, }) } else { - app.onProjectCreated(response) router.push(`/project/${response.ref}/building`) } } @@ -245,7 +253,7 @@ const Wizard: NextPageWithLayout = () => { return (

    Create a new project

    @@ -292,14 +300,14 @@ const Wizard: NextPageWithLayout = () => { 'border-panel-border-interior-light dark:border-panel-border-interior-dark', ].join(' ')} > - {organizations.length > 0 && ( + {(organizations?.length ?? 0) > 0 && ( router.push(`/new/${slug}`)} > - {organizations.map((x: any) => ( + {organizations?.map((x: any) => ( { )} - {isAdmin && !billedViaOrg && ( + {isAdmin && ( - - Select a plan that suits your needs.  - - More details - - {!isSelectFreeTier && !isEmptyPaymentMethod && ( - + Select a plan that suits your needs.  + -

    - By creating a new Pro Project, there will be an immediate charge of $25 - once the project has been created. -

    -
    - )} - - } - > - {Object.entries(PRICING_TIER_LABELS).map(([k, v]) => { - const label = `${v}${k === 'PRO' ? ' - $25/month' : ' - $0/month'}` - return ( - - {label} - - ) - })} -
    + More details + + {!isSelectFreeTier && !isEmptyPaymentMethod && ( + +

    + By creating a new Pro Project, there will be an immediate charge of + $25 once the project has been created. +

    +
    + )} + + } + > + {Object.entries(PRICING_TIER_LABELS).map(([k, v]) => { + const label = `${v}${k === 'PRO' ? ' - $25/month' : ' - $0/month'}` + return ( + + {label} + + ) + })} +
    + )} - {isSelectFreeTier && hasMembersExceedingFreeTierLimit && ( + {freePlanWithExceedingLimits && ( )} - {!isSelectFreeTier && isEmptyPaymentMethod && ( + {!billedViaOrg && !isSelectFreeTier && isEmptyPaymentMethod && ( )} @@ -565,22 +575,18 @@ const Wizard: NextPageWithLayout = () => { ) } -const PageLayout = withAuth( - observer>(({ children }) => { - const router = useRouter() - const { slug } = router.query +const PageLayout = withAuth(({ children }) => { + const { slug } = useParams() - const { app } = useStore() - const organizations = values(toJS(app.organizations.list())) - const currentOrg = organizations.find((o: any) => o.slug === slug) + const { data: organizations } = useOrganizationsQuery() + const currentOrg = organizations?.find((o: any) => o.slug === slug) - return ( - - {children} - - ) - }) -) + return ( + + {children} + + ) +}) Wizard.getLayout = (page) => {page} diff --git a/studio/pages/new/index.tsx b/studio/pages/new/index.tsx index 8ca1dcfd02d..b4e54938c00 100644 --- a/studio/pages/new/index.tsx +++ b/studio/pages/new/index.tsx @@ -1,13 +1,15 @@ -import { useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { observer } from 'mobx-react-lite' -import { Button, Input, Listbox } from 'ui' import { useRouter } from 'next/router' +import { useState } from 'react' +import { Button, Input, Listbox } from 'ui' -import { API_URL } from 'lib/constants' -import { useStore } from 'hooks' -import { post } from 'lib/common/fetch' import { WizardLayout } from 'components/layouts' import Panel from 'components/ui/Panel' +import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query' +import { useStore } from 'hooks' +import { post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' import { NextPageWithLayout } from 'types' const ORG_KIND_TYPES = { @@ -33,7 +35,8 @@ const ORG_SIZE_DEFAULT = '1' * No org selected yet, create a new one */ const Wizard: NextPageWithLayout = () => { - const { ui, app } = useStore() + const queryClient = useQueryClient() + const { ui } = useStore() const router = useRouter() const [orgName, setOrgName] = useState('') @@ -81,7 +84,7 @@ const Wizard: NextPageWithLayout = () => { }) } else { const org = response - app.onOrgAdded(org) + await invalidateOrganizationsQuery(queryClient) router.push(`/new/${org.slug}`) } } diff --git a/studio/pages/org/[slug]/apps.tsx b/studio/pages/org/[slug]/apps.tsx new file mode 100644 index 00000000000..961fe12b405 --- /dev/null +++ b/studio/pages/org/[slug]/apps.tsx @@ -0,0 +1,18 @@ +import { OAuthApps } from 'components/interfaces/Organization' +import { OrganizationLayout } from 'components/layouts' +import Loading from 'components/ui/Loading' +import { usePermissionsQuery } from 'data/permissions/permissions-query' +import { useSelectedOrganization } from 'hooks' +import { NextPageWithLayout } from 'types' + +const OrgOAuthApps: NextPageWithLayout = () => { + const selectedOrganization = useSelectedOrganization() + const { isLoading: isLoadingPermissions } = usePermissionsQuery() + + return ( + <>{selectedOrganization === undefined && isLoadingPermissions ? : } + ) +} + +OrgOAuthApps.getLayout = (page) => {page} +export default OrgOAuthApps diff --git a/studio/pages/org/[slug]/audit.tsx b/studio/pages/org/[slug]/audit.tsx new file mode 100644 index 00000000000..895526ed470 --- /dev/null +++ b/studio/pages/org/[slug]/audit.tsx @@ -0,0 +1,18 @@ +import { AuditLogs } from 'components/interfaces/Organization' +import { OrganizationLayout } from 'components/layouts' +import Loading from 'components/ui/Loading' +import { usePermissionsQuery } from 'data/permissions/permissions-query' +import { useSelectedOrganization } from 'hooks' +import { NextPageWithLayout } from 'types' + +const OrgAuditLogs: NextPageWithLayout = () => { + const { isLoading: isLoadingPermissions } = usePermissionsQuery() + const selectedOrganization = useSelectedOrganization() + + return ( + <>{selectedOrganization === undefined && isLoadingPermissions ? : } + ) +} + +OrgAuditLogs.getLayout = (page) => {page} +export default OrgAuditLogs diff --git a/studio/pages/org/[slug]/billing.tsx b/studio/pages/org/[slug]/billing.tsx index 83c44735b40..9b21e5b40ce 100644 --- a/studio/pages/org/[slug]/billing.tsx +++ b/studio/pages/org/[slug]/billing.tsx @@ -1,56 +1,33 @@ -import { useRouter } from 'next/router' -import { observer } from 'mobx-react-lite' -import { Tabs } from 'ui' - -import { NextPageWithLayout } from 'types' -import { useStore } from 'hooks' -import { useParams } from 'common/hooks' -import Loading from 'components/ui/Loading' +import { BillingSettings, BillingSettingsV2 } from 'components/interfaces/Organization' import { OrganizationLayout } from 'components/layouts' -import { BillingSettings } from 'components/interfaces/Organization' +import Loading from 'components/ui/Loading' +import { usePermissionsQuery } from 'data/permissions/permissions-query' +import { useSelectedOrganization } from 'hooks' +import { NextPageWithLayout } from 'types' const OrgBillingSettings: NextPageWithLayout = () => { - const { ui } = useStore() - const { slug } = useParams() - const router = useRouter() + const { isLoading: isLoadingPermissions } = usePermissionsQuery() + const selectedOrganization = useSelectedOrganization() + const isOrgBilling = !!selectedOrganization?.subscription_id return ( <> - {ui.selectedOrganization === undefined && (ui?.permissions ?? []).length === 0 ? ( + {selectedOrganization === undefined && isLoadingPermissions ? ( ) : ( -
    -
    -
    -

    - {ui.selectedOrganization?.name ?? 'Organization'} settings -

    -
    - -
    - -
    - -
    -
    + <> + {isOrgBilling ? ( + + ) : ( +
    + +
    + )} + )} ) } OrgBillingSettings.getLayout = (page) => {page} -export default observer(OrgBillingSettings) +export default OrgBillingSettings diff --git a/studio/pages/org/[slug]/general.tsx b/studio/pages/org/[slug]/general.tsx index f07415ba47e..245a3e23f05 100644 --- a/studio/pages/org/[slug]/general.tsx +++ b/studio/pages/org/[slug]/general.tsx @@ -1,56 +1,24 @@ -import { useRouter } from 'next/router' -import { observer } from 'mobx-react-lite' -import { Tabs } from 'ui' - -import { NextPageWithLayout } from 'types' -import { useStore } from 'hooks' -import { useParams } from 'common/hooks' -import Loading from 'components/ui/Loading' -import { OrganizationLayout } from 'components/layouts' import { GeneralSettings } from 'components/interfaces/Organization' +import { OrganizationLayout } from 'components/layouts' +import Loading from 'components/ui/Loading' +import { usePermissionsQuery } from 'data/permissions/permissions-query' +import { useSelectedOrganization } from 'hooks' +import { NextPageWithLayout } from 'types' const OrgGeneralSettings: NextPageWithLayout = () => { - const { ui } = useStore() - const { slug } = useParams() - const router = useRouter() + const { isLoading: isLoadingPermissions } = usePermissionsQuery() + const selectedOrganization = useSelectedOrganization() return ( <> - {ui.selectedOrganization === undefined && (ui?.permissions ?? []).length === 0 ? ( + {selectedOrganization === undefined && isLoadingPermissions ? ( ) : ( -
    -
    -
    -

    - {ui.selectedOrganization?.name ?? 'Organization'} settings -

    -
    - -
    - -
    - -
    -
    + )} ) } OrgGeneralSettings.getLayout = (page) => {page} -export default observer(OrgGeneralSettings) +export default OrgGeneralSettings diff --git a/studio/pages/org/[slug]/integrations.tsx b/studio/pages/org/[slug]/integrations.tsx new file mode 100644 index 00000000000..0e54ef2778c --- /dev/null +++ b/studio/pages/org/[slug]/integrations.tsx @@ -0,0 +1,11 @@ +import { NextPageWithLayout } from 'types' +import { OrganizationLayout } from 'components/layouts' +import { IntegrationSettings } from 'components/interfaces/Organization' + +const OrgIntegrationSettings: NextPageWithLayout = () => { + return +} + +OrgIntegrationSettings.getLayout = (page) => {page} + +export default OrgIntegrationSettings diff --git a/studio/pages/org/[slug]/invoices.tsx b/studio/pages/org/[slug]/invoices.tsx index 5ccf1ebc825..418510243c3 100644 --- a/studio/pages/org/[slug]/invoices.tsx +++ b/studio/pages/org/[slug]/invoices.tsx @@ -1,56 +1,24 @@ -import { useRouter } from 'next/router' -import { observer } from 'mobx-react-lite' -import { Tabs } from 'ui' - -import { NextPageWithLayout } from 'types' -import { useStore } from 'hooks' -import { useParams } from 'common/hooks' -import Loading from 'components/ui/Loading' -import { OrganizationLayout } from 'components/layouts' import { InvoicesSettings } from 'components/interfaces/Organization' +import { OrganizationLayout } from 'components/layouts' +import Loading from 'components/ui/Loading' +import { usePermissionsQuery } from 'data/permissions/permissions-query' +import { useSelectedOrganization } from 'hooks' +import { NextPageWithLayout } from 'types' const OrgInvoices: NextPageWithLayout = () => { - const { ui } = useStore() - const { slug } = useParams() - const router = useRouter() + const { isLoading: isLoadingPermissions } = usePermissionsQuery() + const selectedOrganization = useSelectedOrganization() return ( <> - {ui.selectedOrganization === undefined && (ui?.permissions ?? []).length === 0 ? ( + {selectedOrganization === undefined && isLoadingPermissions ? ( ) : ( -
    -
    -
    -

    - {ui.selectedOrganization?.name ?? 'Organization'} settings -

    -
    - -
    - -
    - -
    -
    + )} ) } OrgInvoices.getLayout = (page) => {page} -export default observer(OrgInvoices) +export default OrgInvoices diff --git a/studio/pages/org/[slug]/team.tsx b/studio/pages/org/[slug]/team.tsx index 7f96623cd61..d1a75aec2f4 100644 --- a/studio/pages/org/[slug]/team.tsx +++ b/studio/pages/org/[slug]/team.tsx @@ -1,56 +1,20 @@ -import { useRouter } from 'next/router' -import { observer } from 'mobx-react-lite' -import { Tabs } from 'ui' - -import { NextPageWithLayout } from 'types' -import { useStore } from 'hooks' -import { useParams } from 'common/hooks' -import Loading from 'components/ui/Loading' -import { OrganizationLayout } from 'components/layouts' import { TeamSettings } from 'components/interfaces/Organization' +import { OrganizationLayout } from 'components/layouts' +import Loading from 'components/ui/Loading' +import { usePermissionsQuery } from 'data/permissions/permissions-query' +import { useSelectedOrganization } from 'hooks' +import { NextPageWithLayout } from 'types' const OrgTeamSettings: NextPageWithLayout = () => { - const { ui } = useStore() - const { slug } = useParams() - const router = useRouter() + const { isLoading: isLoadingPermissions } = usePermissionsQuery() + const selectedOrganization = useSelectedOrganization() return ( <> - {ui.selectedOrganization === undefined && (ui?.permissions ?? []).length === 0 ? ( - - ) : ( -
    -
    -
    -

    - {ui.selectedOrganization?.name ?? 'Organization'} settings -

    -
    - -
    - -
    - -
    -
    - )} + {selectedOrganization === undefined && isLoadingPermissions ? : } ) } OrgTeamSettings.getLayout = (page) => {page} -export default observer(OrgTeamSettings) +export default OrgTeamSettings diff --git a/studio/pages/org/[slug]/usage.tsx b/studio/pages/org/[slug]/usage.tsx new file mode 100644 index 00000000000..1fc13298eed --- /dev/null +++ b/studio/pages/org/[slug]/usage.tsx @@ -0,0 +1,17 @@ +import { Usage } from 'components/interfaces/Organization' +import { OrganizationLayout } from 'components/layouts' +import Loading from 'components/ui/Loading' +import { usePermissionsQuery } from 'data/permissions/permissions-query' +import { useSelectedOrganization } from 'hooks' +import { NextPageWithLayout } from 'types' + +const OrgUsage: NextPageWithLayout = () => { + const { isLoading } = usePermissionsQuery() + const selectedOrganization = useSelectedOrganization() + + return <>{selectedOrganization === undefined && isLoading ? : } +} + +OrgUsage.getLayout = (page) => {page} + +export default OrgUsage diff --git a/studio/pages/org/_/[[...routeSlug]].tsx b/studio/pages/org/_/[[...routeSlug]].tsx index 1e53059a44d..65e036ed037 100644 --- a/studio/pages/org/_/[[...routeSlug]].tsx +++ b/studio/pages/org/_/[[...routeSlug]].tsx @@ -1,13 +1,13 @@ +import { NextPage } from 'next' import Link from 'next/link' import { useRouter } from 'next/router' -import { NextPage } from 'next' -import { observer } from 'mobx-react-lite' -import { Button, IconPlus } from 'ui' -import { withAuth, useStore } from 'hooks' -import { BASE_PATH } from 'lib/constants' -import CardButton from 'components/ui/CardButton' import ShimmeringCard from 'components/interfaces/Home/ProjectList/ShimmeringCard' +import CardButton from 'components/ui/CardButton' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { withAuth } from 'hooks' +import { BASE_PATH } from 'lib/constants' +import { Button, IconPlus } from 'ui' const Header = () => { return ( @@ -29,12 +29,9 @@ const Header = () => { } const GenericOrganizationPage: NextPage = () => { - const { app } = useStore() const router = useRouter() - const { organizations } = app - const { isLoading } = organizations - const sortedOrganizations = organizations.list() + const { data: organizations, isLoading } = useOrganizationsQuery() const { routeSlug, ...queryParams } = router.query const queryString = Object.keys(queryParams).length > 0 @@ -71,7 +68,7 @@ const GenericOrganizationPage: NextPage = () => { - ) : sortedOrganizations.length === 0 ? ( + ) : organizations?.length === 0 ? (

    You are not part of any organizations yet

    @@ -94,7 +91,7 @@ const GenericOrganizationPage: NextPage = () => { 'sm:grid-cols-1 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3', ].join(' `')} > - {sortedOrganizations.map((organization) => ( + {organizations?.map((organization) => (
  • { ) } -export default withAuth(observer(GenericOrganizationPage)) +export default withAuth(GenericOrganizationPage) diff --git a/studio/pages/project/[ref]/api/graphiql.tsx b/studio/pages/project/[ref]/api/graphiql.tsx index 95492f7a2d1..5f4821e5e44 100644 --- a/studio/pages/project/[ref]/api/graphiql.tsx +++ b/studio/pages/project/[ref]/api/graphiql.tsx @@ -1,18 +1,18 @@ -import { useEffect, useMemo } from 'react' +import { Fetcher, createGraphiQLFetcher } from '@graphiql/toolkit' import { observer } from 'mobx-react-lite' -import { createGraphiQLFetcher, Fetcher } from '@graphiql/toolkit' +import { useEffect, useMemo } from 'react' -import { NextPageWithLayout } from 'types' +import { useTheme } from 'common' import { useParams } from 'common/hooks' -import { useStore } from 'hooks' -import { API_URL, IS_PLATFORM } from 'lib/constants' import ExtensionCard from 'components/interfaces/Database/Extensions/ExtensionCard' import GraphiQL from 'components/interfaces/GraphQL/GraphiQL' import { DocsLayout } from 'components/layouts' import Connecting from 'components/ui/Loading/Loading' import { useSessionAccessTokenQuery } from 'data/auth/session-access-token-query' import { useProjectApiQuery } from 'data/config/project-api-query' -import { useTheme } from 'common' +import { useStore } from 'hooks' +import { API_URL, IS_PLATFORM } from 'lib/constants' +import { NextPageWithLayout } from 'types' const GraphiQLPage: NextPageWithLayout = () => { const { ref: projectRef } = useParams() @@ -32,12 +32,12 @@ const GraphiQLPage: NextPageWithLayout = () => { : undefined useEffect(() => { - if (ui.selectedProject?.ref) { + if (ui.selectedProjectRef) { // Schemas may be needed when enabling the GraphQL extension meta.schemas.load() meta.extensions.load() } - }, [ui.selectedProject?.ref]) + }, [ui.selectedProjectRef]) const graphqlUrl = `${API_URL}/projects/${projectRef}/api/graphql` diff --git a/studio/pages/project/[ref]/api/index.tsx b/studio/pages/project/[ref]/api/index.tsx index 64cb1e74796..c193e99317a 100644 --- a/studio/pages/project/[ref]/api/index.tsx +++ b/studio/pages/project/[ref]/api/index.tsx @@ -1,16 +1,16 @@ +import { observer, useLocalObservable } from 'mobx-react-lite' import { useRouter } from 'next/router' import { FC, createContext, useContext, useEffect, useState } from 'react' -import { observer, useLocalObservable } from 'mobx-react-lite' -import { NextPageWithLayout } from 'types' -import { useStore } from 'hooks' import { useParams } from 'common/hooks' -import { snakeToCamel } from 'lib/helpers' -import { useProjectApiQuery } from 'data/config/project-api-query' -import { DocsLayout } from 'components/layouts' import { GeneralContent, ResourceContent, RpcContent } from 'components/interfaces/Docs' -import { useProjectJsonSchemaQuery } from 'data/docs/project-json-schema-query' import LangSelector from 'components/interfaces/Docs/LangSelector' +import { DocsLayout } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectJsonSchemaQuery } from 'data/docs/project-json-schema-query' +import { snakeToCamel } from 'lib/helpers' +import { NextPageWithLayout } from 'types' const PageContext = createContext(null) @@ -45,9 +45,7 @@ const PageConfig: NextPageWithLayout = () => { const router = useRouter() const { query } = router - - const { ui } = useStore() - const project = ui.selectedProject + const { project } = useProjectContext() PageState.projectRef = query.ref return ( diff --git a/studio/pages/project/[ref]/auth/policies.tsx b/studio/pages/project/[ref]/auth/policies.tsx index 82bf69f90b2..67e6995429c 100644 --- a/studio/pages/project/[ref]/auth/policies.tsx +++ b/studio/pages/project/[ref]/auth/policies.tsx @@ -6,7 +6,7 @@ import { PostgresTable, PostgresPolicy } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' import { NextPageWithLayout } from 'types' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' import { useParams } from 'common/hooks' import { AuthLayout } from 'components/layouts' import { Policies } from 'components/interfaces/Auth/Policies' @@ -70,7 +70,7 @@ const AuthPoliciesPage: NextPageWithLayout = () => { const tables = meta.tables.list((table: { schema: string }) => table.schema === selectedSchema) const filteredTables = onFilterTables(tables, policies, searchString) - const canReadPolicies = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'policies') + const canReadPolicies = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'policies') if (!canReadPolicies) { return diff --git a/studio/pages/project/[ref]/auth/providers.tsx b/studio/pages/project/[ref]/auth/providers.tsx index 47112f72c8c..982b2a7e4e8 100644 --- a/studio/pages/project/[ref]/auth/providers.tsx +++ b/studio/pages/project/[ref]/auth/providers.tsx @@ -1,22 +1,24 @@ -import { useEffect } from 'react' -import { observer } from 'mobx-react-lite' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { observer } from 'mobx-react-lite' +import { useEffect } from 'react' -import { NextPageWithLayout } from 'types' -import { checkPermissions, useStore } from 'hooks' import { AuthProvidersForm } from 'components/interfaces' import { AuthLayout } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { FormsContainer } from 'components/ui/Forms' import NoPermission from 'components/ui/NoPermission' +import { useCheckPermissions, useStore } from 'hooks' +import { NextPageWithLayout } from 'types' const PageLayout: NextPageWithLayout = () => { - const { ui, authConfig } = useStore() + const { authConfig } = useStore() + const { project } = useProjectContext() useEffect(() => { authConfig.load() - }, [ui.selectedProjectRef]) + }, [project?.ref]) - const canReadAuthSettings = checkPermissions(PermissionAction.READ, 'custom_config_gotrue') + const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') if (!canReadAuthSettings) { return diff --git a/studio/pages/project/[ref]/auth/templates.tsx b/studio/pages/project/[ref]/auth/templates.tsx index 6d4db080b1d..da945c6e87c 100644 --- a/studio/pages/project/[ref]/auth/templates.tsx +++ b/studio/pages/project/[ref]/auth/templates.tsx @@ -1,22 +1,24 @@ -import { useEffect } from 'react' -import { observer } from 'mobx-react-lite' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { observer } from 'mobx-react-lite' +import { useEffect } from 'react' -import { NextPageWithLayout } from 'types' -import { useStore, checkPermissions } from 'hooks' -import { AuthLayout } from 'components/layouts' import { EmailTemplates } from 'components/interfaces' +import { AuthLayout } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { FormsContainer } from 'components/ui/Forms' import NoPermission from 'components/ui/NoPermission' +import { useCheckPermissions, useStore } from 'hooks' +import { NextPageWithLayout } from 'types' const PageLayout: NextPageWithLayout = () => { - const { ui, authConfig } = useStore() + const { authConfig } = useStore() + const { project } = useProjectContext() useEffect(() => { authConfig.load() - }, [ui.selectedProjectRef]) + }, [project?.ref]) - const canReadAuthSettings = checkPermissions(PermissionAction.READ, 'custom_config_gotrue') + const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') if (!canReadAuthSettings) { return diff --git a/studio/pages/project/[ref]/auth/url-configuration.tsx b/studio/pages/project/[ref]/auth/url-configuration.tsx index b0415afb0c9..a855a371b95 100644 --- a/studio/pages/project/[ref]/auth/url-configuration.tsx +++ b/studio/pages/project/[ref]/auth/url-configuration.tsx @@ -1,24 +1,25 @@ -import { useEffect } from 'react' -import { observer } from 'mobx-react-lite' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { observer } from 'mobx-react-lite' +import { useEffect } from 'react' -import { NextPageWithLayout } from 'types' -import { useStore, checkPermissions } from 'hooks' -import { AuthLayout } from 'components/layouts' - -import NoPermission from 'components/ui/NoPermission' import RedirectUrls from 'components/interfaces/Auth/RedirectUrls/RedirectUrls' -import { FormsContainer } from 'components/ui/Forms' import SiteUrl from 'components/interfaces/Auth/SiteUrl/SiteUrl' +import { AuthLayout } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { FormsContainer } from 'components/ui/Forms' +import NoPermission from 'components/ui/NoPermission' +import { useCheckPermissions, useStore } from 'hooks' +import { NextPageWithLayout } from 'types' const URLConfiguration: NextPageWithLayout = () => { - const { ui, authConfig } = useStore() + const { authConfig } = useStore() + const { project } = useProjectContext() useEffect(() => { authConfig.load() - }, [ui.selectedProjectRef]) + }, [project?.ref]) - const canReadAuthSettings = checkPermissions(PermissionAction.READ, 'custom_config_gotrue') + const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') if (!canReadAuthSettings) { return diff --git a/studio/pages/project/[ref]/auth/users.tsx b/studio/pages/project/[ref]/auth/users.tsx index 25b4cd71939..e68341b0acb 100644 --- a/studio/pages/project/[ref]/auth/users.tsx +++ b/studio/pages/project/[ref]/auth/users.tsx @@ -4,12 +4,13 @@ import { useRouter } from 'next/router' import { get } from 'lib/common/fetch' import { API_URL } from 'lib/constants' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' import { AuthLayout } from 'components/layouts' import { Users } from 'components/interfaces/Auth' import { NextPageWithLayout } from 'types' import { PermissionAction } from '@supabase/shared-types/out/constants' import NoPermission from 'components/ui/NoPermission' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' export const PageContext = createContext(null) @@ -81,15 +82,14 @@ const PageLayout = ({ children }: PropsWithChildren<{}>) => { } const UsersPage: NextPageWithLayout = () => { - const { ui } = useStore() const PageState: any = useContext(PageContext) - const project: any = ui.selectedProject + const { project } = useProjectContext() useEffect(() => { PageState!.projectKpsVersion = project?.kpsVersion }, [project]) - const canReadUsers = checkPermissions(PermissionAction.TENANT_SQL_SELECT, 'auth.users') + const canReadUsers = useCheckPermissions(PermissionAction.TENANT_SQL_SELECT, 'auth.users') return !canReadUsers ? ( diff --git a/studio/pages/project/[ref]/database/backups/pitr.tsx b/studio/pages/project/[ref]/database/backups/pitr.tsx index 16b78736f2a..c56d9f23da5 100644 --- a/studio/pages/project/[ref]/database/backups/pitr.tsx +++ b/studio/pages/project/[ref]/database/backups/pitr.tsx @@ -1,23 +1,24 @@ -import { useRouter } from 'next/router' -import { observer } from 'mobx-react-lite' -import { Tabs } from 'ui' -import clsx from 'clsx' import { PermissionAction } from '@supabase/shared-types/out/constants' +import clsx from 'clsx' +import { observer } from 'mobx-react-lite' +import { useRouter } from 'next/router' +import { Tabs } from 'ui' -import { NextPageWithLayout } from 'types' -import { useStore, checkPermissions } from 'hooks' +import BackupsError from 'components/interfaces/Database/Backups/BackupsError' +import { PITRNotice, PITRSelection } from 'components/interfaces/Database/Backups/PITR' import { DatabaseLayout } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import Loading from 'components/ui/Loading' import NoPermission from 'components/ui/NoPermission' import UpgradeToPro from 'components/ui/UpgradeToPro' -import { PITRNotice, PITRSelection } from 'components/interfaces/Database/Backups/PITR' -import BackupsError from 'components/interfaces/Database/Backups/BackupsError' +import { useCheckPermissions, useStore } from 'hooks' import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants' +import { NextPageWithLayout } from 'types' const DatabasePhysicalBackups: NextPageWithLayout = () => { - const { ui } = useStore() const router = useRouter() - const ref = ui.selectedProject?.ref ?? 'default' + const { project } = useProjectContext() + const ref = project?.ref ?? 'default' return (
    ( {page} ) -const PITR = () => { - const { ui, backups } = useStore() +const PITR = observer(() => { + const { backups } = useStore() + const { project } = useProjectContext() const { configuration, error, isLoading } = backups - const ref = ui.selectedProject?.ref ?? 'default' - const tier = ui.selectedProject?.subscription_tier + const ref = project?.ref ?? 'default' + const tier = project?.subscription_tier const isEnabled = configuration.walg_enabled - const canReadPhysicalBackups = checkPermissions(PermissionAction.READ, 'physical_backups') + const canReadPhysicalBackups = useCheckPermissions(PermissionAction.READ, 'physical_backups') if (!canReadPhysicalBackups) return if (isLoading) return @@ -86,6 +88,6 @@ const PITR = () => { ) -} +}) -export default observer(DatabasePhysicalBackups) +export default DatabasePhysicalBackups diff --git a/studio/pages/project/[ref]/database/backups/scheduled.tsx b/studio/pages/project/[ref]/database/backups/scheduled.tsx index 1f5b875cbc7..a4f83a5ca93 100644 --- a/studio/pages/project/[ref]/database/backups/scheduled.tsx +++ b/studio/pages/project/[ref]/database/backups/scheduled.tsx @@ -1,24 +1,26 @@ -import { useRouter } from 'next/router' -import { observer } from 'mobx-react-lite' -import { IconInfo, Tabs } from 'ui' -import clsx from 'clsx' import { PermissionAction } from '@supabase/shared-types/out/constants' +import clsx from 'clsx' +import { observer } from 'mobx-react-lite' +import { useRouter } from 'next/router' -import { NextPageWithLayout } from 'types' -import { checkPermissions, useStore } from 'hooks' -import { DatabaseLayout } from 'components/layouts' import { BackupsList } from 'components/interfaces/Database' -import NoPermission from 'components/ui/NoPermission' +import { DatabaseLayout } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import InformationBox from 'components/ui/InformationBox' +import NoPermission from 'components/ui/NoPermission' +import { useCheckPermissions, useStore } from 'hooks' +import { NextPageWithLayout } from 'types' +import { IconInfo, Tabs } from 'ui' const DatabaseScheduledBackups: NextPageWithLayout = () => { - const { ui, backups } = useStore() const router = useRouter() - const ref = ui.selectedProject?.ref + const { backups } = useStore() + const { project } = useProjectContext() + const ref = project?.ref const isPitrEnabled = backups?.configuration?.walg_enabled - const canReadScheduledBackups = checkPermissions(PermissionAction.READ, 'back_ups') + const canReadScheduledBackups = useCheckPermissions(PermissionAction.READ, 'back_ups') return (
    { - const canReadExtensions = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'extensions') + const canReadExtensions = useCheckPermissions( + PermissionAction.TENANT_SQL_ADMIN_READ, + 'extensions' + ) if (!canReadExtensions) { return } diff --git a/studio/pages/project/[ref]/database/functions.tsx b/studio/pages/project/[ref]/database/functions.tsx index 5717e14bbcf..5f8d6472281 100644 --- a/studio/pages/project/[ref]/database/functions.tsx +++ b/studio/pages/project/[ref]/database/functions.tsx @@ -1,27 +1,29 @@ -import { useState, useEffect } from 'react' -import { observer } from 'mobx-react-lite' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { observer } from 'mobx-react-lite' +import { useEffect, useState } from 'react' -import { useStore, checkPermissions } from 'hooks' -import { DatabaseLayout } from 'components/layouts' import { CreateFunction, DeleteFunction } from 'components/interfaces/Database' -import { NextPageWithLayout } from 'types' -import NoPermission from 'components/ui/NoPermission' import FunctionsList from 'components/interfaces/Database/Functions/FunctionsList/FunctionsList' +import { DatabaseLayout } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import NoPermission from 'components/ui/NoPermission' +import { useCheckPermissions, useStore } from 'hooks' +import { NextPageWithLayout } from 'types' const FunctionsPage: NextPageWithLayout = () => { - const { meta, ui } = useStore() + const { meta } = useStore() + const { project } = useProjectContext() const [selectedFunction, setSelectedFunction] = useState() const [showCreateFunctionForm, setShowCreateFunctionForm] = useState(false) const [showDeleteFunctionForm, setShowDeleteFunctionForm] = useState(false) - const canReadFunctions = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'functions') + const canReadFunctions = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'functions') useEffect(() => { - if (ui.selectedProject?.ref) { + if (project?.ref) { fetchFunctions() } - }, [ui.selectedProject?.ref]) + }, [project?.ref]) const fetchFunctions = async () => { meta.functions.load() diff --git a/studio/pages/project/[ref]/database/hooks/index.tsx b/studio/pages/project/[ref]/database/hooks/index.tsx index 4981cae2e65..ac953ba9c61 100644 --- a/studio/pages/project/[ref]/database/hooks/index.tsx +++ b/studio/pages/project/[ref]/database/hooks/index.tsx @@ -1,26 +1,27 @@ -import clsx from 'clsx' -import { IconLoader } from 'ui' -import { useEffect, useState } from 'react' -import { useRouter } from 'next/router' -import { observer } from 'mobx-react-lite' import { PermissionAction } from '@supabase/shared-types/out/constants' +import clsx from 'clsx' +import { observer } from 'mobx-react-lite' +import { useEffect, useState } from 'react' -import { NextPageWithLayout } from 'types' -import { checkPermissions, useStore } from 'hooks' -import { API_URL } from 'lib/constants' -import { post } from 'lib/common/fetch' -import { DatabaseLayout } from 'components/layouts' -import NoPermission from 'components/ui/NoPermission' -import HooksList from 'components/interfaces/Database/Hooks/HooksList/HooksList' +import { useParams } from 'common' import DeleteHookModal from 'components/interfaces/Database/Hooks/DeleteHookModal' import EditHookPanel from 'components/interfaces/Database/Hooks/EditHookPanel' +import HooksList from 'components/interfaces/Database/Hooks/HooksList/HooksList' +import { DatabaseLayout } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' +import NoPermission from 'components/ui/NoPermission' +import { useCheckPermissions, useStore } from 'hooks' +import { post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { NextPageWithLayout } from 'types' +import { IconLoader } from 'ui' const HooksPage: NextPageWithLayout = () => { const { meta, ui } = useStore() + const { project } = useProjectContext() - const router = useRouter() - const { ref } = router.query + const { ref } = useParams() const schemas = meta.schemas.list() const { isLoading: isLoadingSchemas } = meta.schemas @@ -29,12 +30,12 @@ const HooksPage: NextPageWithLayout = () => { const [showDeleteHookForm, setShowDeleteHookForm] = useState(false) const isHooksEnabled = schemas.some((schema: any) => schema.name === 'supabase_functions') - const canReadWebhooks = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers') - const canCreateWebhooks = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers') + const canReadWebhooks = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers') + const canCreateWebhooks = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers') useEffect(() => { - if (ui.selectedProject?.ref) meta.hooks.load() - }, [ui.selectedProject?.ref]) + if (project?.ref) meta.hooks.load() + }, [project?.ref]) const enableHooksForProject = async () => { const res = await post(`${API_URL}/database/${ref}/hook-enable`, {}) diff --git a/studio/pages/project/[ref]/database/migrations.tsx b/studio/pages/project/[ref]/database/migrations.tsx new file mode 100644 index 00000000000..16a3cd2500e --- /dev/null +++ b/studio/pages/project/[ref]/database/migrations.tsx @@ -0,0 +1,29 @@ +import clsx from 'clsx' +import Migrations from 'components/interfaces/Database/Migrations/Migrations' +import { DatabaseLayout } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useMigrationsQuery } from 'data/database/migrations-query' +import { NextPageWithLayout } from 'types' + +const MigrationsPage: NextPageWithLayout = () => { + const { project } = useProjectContext() + const { data, isLoading, isSuccess, isError } = useMigrationsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + return ( +
    + +
    + ) +} + +MigrationsPage.getLayout = (page) => {page} + +export default MigrationsPage diff --git a/studio/pages/project/[ref]/database/replication.tsx b/studio/pages/project/[ref]/database/replication.tsx index d088599b009..7972deedd7a 100644 --- a/studio/pages/project/[ref]/database/replication.tsx +++ b/studio/pages/project/[ref]/database/replication.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { observer } from 'mobx-react-lite' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions, useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' import { NextPageWithLayout } from 'types' import { DatabaseLayout } from 'components/layouts' import { PublicationsList, PublicationsTables } from 'components/interfaces/Database' @@ -16,7 +16,7 @@ const DatabaseReplication: NextPageWithLayout = () => { const { meta } = useStore() const publications = meta.publications.list() - const canViewPublications = checkPermissions( + const canViewPublications = useCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_READ, 'publications' ) diff --git a/studio/pages/project/[ref]/database/tables.tsx b/studio/pages/project/[ref]/database/tables.tsx index 74bfaa45637..16e49895958 100644 --- a/studio/pages/project/[ref]/database/tables.tsx +++ b/studio/pages/project/[ref]/database/tables.tsx @@ -1,23 +1,21 @@ -import { useQueryClient } from '@tanstack/react-query' -import { useState, useEffect } from 'react' -import { observer } from 'mobx-react-lite' -import { isUndefined } from 'lodash' import type { PostgresColumn, PostgresTable } from '@supabase/postgres-meta' -import { Modal } from 'ui' +import { useQueryClient } from '@tanstack/react-query' +import { isUndefined } from 'lodash' +import { observer } from 'mobx-react-lite' +import { useEffect, useState } from 'react' -import { useStore } from 'hooks' -import { sqlKeys } from 'data/sql/keys' - -import { DatabaseLayout } from 'components/layouts' -import ConfirmationModal from 'components/ui/ConfirmationModal' -import { TableList, ColumnList } from 'components/interfaces/Database' +import { ColumnList, TableList } from 'components/interfaces/Database' import { SidePanelEditor } from 'components/interfaces/TableGridEditor' +import { DatabaseLayout } from 'components/layouts' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import ConfirmationModal from 'components/ui/ConfirmationModal' +import { sqlKeys } from 'data/sql/keys' +import { useStore } from 'hooks' import { NextPageWithLayout } from 'types' +import { Modal } from 'ui' const DatabaseTables: NextPageWithLayout = () => { const { meta, ui } = useStore() - const { project } = useProjectContext() const queryClient = useQueryClient() @@ -34,10 +32,10 @@ const DatabaseTables: NextPageWithLayout = () => { const [selectedTableToDelete, setSelectedTableToDelete] = useState() useEffect(() => { - if (ui.selectedProject?.ref) { + if (project?.ref) { meta.types.load() } - }, [ui.selectedProject?.ref]) + }, [project?.ref]) const onAddTable = () => { setSidePanelKey('table') diff --git a/studio/pages/project/[ref]/database/triggers.tsx b/studio/pages/project/[ref]/database/triggers.tsx index f8d61449fa3..d2bd0acf8f6 100644 --- a/studio/pages/project/[ref]/database/triggers.tsx +++ b/studio/pages/project/[ref]/database/triggers.tsx @@ -1,29 +1,31 @@ -import { useEffect, useState } from 'react' -import { observer } from 'mobx-react-lite' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { observer } from 'mobx-react-lite' +import { useEffect, useState } from 'react' -import { NextPageWithLayout } from 'types' -import { checkPermissions, useStore } from 'hooks' -import { DatabaseLayout } from 'components/layouts' import { CreateTrigger, DeleteTrigger } from 'components/interfaces/Database' import TriggersList from 'components/interfaces/Database/Triggers/TriggersList/TriggersList' +import { DatabaseLayout } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import NoPermission from 'components/ui/NoPermission' +import { useCheckPermissions, useStore } from 'hooks' +import { NextPageWithLayout } from 'types' const TriggersPage: NextPageWithLayout = () => { - const { meta, ui } = useStore() + const { meta } = useStore() + const { project } = useProjectContext() const [filterString, setFilterString] = useState('') const [selectedTrigger, setSelectedTrigger] = useState() const [showCreateTriggerForm, setShowCreateTriggerForm] = useState(false) const [showDeleteTriggerForm, setShowDeleteTriggerForm] = useState(false) - const canReadTriggers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers') + const canReadTriggers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers') useEffect(() => { - if (ui.selectedProject?.ref) { + if (project?.ref) { fetchTriggers() } - }, [ui.selectedProject?.ref]) + }, [project?.ref]) const fetchTriggers = async () => { meta.triggers.load() diff --git a/studio/pages/project/[ref]/database/wrappers/[id].tsx b/studio/pages/project/[ref]/database/wrappers/[id].tsx index bf197056957..03c8cc31bcb 100644 --- a/studio/pages/project/[ref]/database/wrappers/[id].tsx +++ b/studio/pages/project/[ref]/database/wrappers/[id].tsx @@ -2,13 +2,13 @@ import { observer } from 'mobx-react-lite' import { PermissionAction } from '@supabase/shared-types/out/constants' import { NextPageWithLayout } from 'types' -import { checkPermissions } from 'hooks' +import { useCheckPermissions } from 'hooks' import { DatabaseLayout } from 'components/layouts' import NoPermission from 'components/ui/NoPermission' import { EditWrapper } from 'components/interfaces/Database' const DatabaseWrappersNew: NextPageWithLayout = () => { - const canReadWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'wrappers') + const canReadWrappers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'wrappers') if (!canReadWrappers) { return diff --git a/studio/pages/project/[ref]/database/wrappers/index.tsx b/studio/pages/project/[ref]/database/wrappers/index.tsx index 3499414d118..6744e65b284 100644 --- a/studio/pages/project/[ref]/database/wrappers/index.tsx +++ b/studio/pages/project/[ref]/database/wrappers/index.tsx @@ -3,13 +3,13 @@ import { observer } from 'mobx-react-lite' import { PermissionAction } from '@supabase/shared-types/out/constants' import { NextPageWithLayout } from 'types' -import { checkPermissions } from 'hooks' +import { useCheckPermissions } from 'hooks' import { DatabaseLayout } from 'components/layouts' import { Wrappers } from 'components/interfaces/Database' import NoPermission from 'components/ui/NoPermission' const DatabaseWrappers: NextPageWithLayout = () => { - const canReadWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'tables') + const canReadWrappers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'tables') if (!canReadWrappers) { return } diff --git a/studio/pages/project/[ref]/database/wrappers/new.tsx b/studio/pages/project/[ref]/database/wrappers/new.tsx index 225b4051123..afc21237d42 100644 --- a/studio/pages/project/[ref]/database/wrappers/new.tsx +++ b/studio/pages/project/[ref]/database/wrappers/new.tsx @@ -2,13 +2,13 @@ import { observer } from 'mobx-react-lite' import { PermissionAction } from '@supabase/shared-types/out/constants' import { NextPageWithLayout } from 'types' -import { checkPermissions } from 'hooks' +import { useCheckPermissions } from 'hooks' import { DatabaseLayout } from 'components/layouts' import NoPermission from 'components/ui/NoPermission' import { CreateWrapper } from 'components/interfaces/Database' const DatabaseWrappersNew: NextPageWithLayout = () => { - const canCreateWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') + const canCreateWrappers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') if (!canCreateWrappers) { return } diff --git a/studio/pages/project/[ref]/editor/index.tsx b/studio/pages/project/[ref]/editor/index.tsx index 23384fd610d..6cb80edcfdb 100644 --- a/studio/pages/project/[ref]/editor/index.tsx +++ b/studio/pages/project/[ref]/editor/index.tsx @@ -1,20 +1,25 @@ -import { useState } from 'react' -import router from 'next/router' -import { observer } from 'mobx-react-lite' -import { isUndefined } from 'lodash' import type { PostgresTable } from '@supabase/postgres-meta' +import { isUndefined } from 'lodash' +import { observer } from 'mobx-react-lite' +import router from 'next/router' +import { useState } from 'react' -import { NextPageWithLayout } from 'types' -import { useStore, withAuth } from 'hooks' -import { Entity } from 'data/entity-types/entity-type-query' -import { TableEditorLayout } from 'components/layouts' import { EmptyState, SidePanelEditor } from 'components/interfaces/TableGridEditor' +import { TableEditorLayout } from 'components/layouts' +import { + ProjectContextFromParamsProvider, + useProjectContext, +} from 'components/layouts/ProjectLayout/ProjectContext' import ConfirmationModal from 'components/ui/ConfirmationModal' -import { ProjectContextFromParamsProvider } from 'components/layouts/ProjectLayout/ProjectContext' +import { Entity } from 'data/entity-types/entity-type-query' +import { useStore, withAuth } from 'hooks' +import { NextPageWithLayout } from 'types' const TableEditorPage: NextPageWithLayout = () => { const { meta, ui } = useStore() - const projectRef = ui.selectedProject?.ref + const { project } = useProjectContext() + const projectRef = project?.ref + const [sidePanelKey, setSidePanelKey] = useState<'row' | 'column' | 'table'>() const [isDeleting, setIsDeleting] = useState(false) const [isDuplicating, setIsDuplicating] = useState(false) diff --git a/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx b/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx index 6a0cc443e90..bb67f902225 100644 --- a/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx +++ b/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx @@ -4,7 +4,7 @@ import { useRouter } from 'next/router' import { Button } from 'ui' import { observer } from 'mobx-react-lite' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions } from 'hooks' +import { useCheckPermissions } from 'hooks' import { useParams } from 'common/hooks' import { ChartIntervals, NextPageWithLayout } from 'types' import NoPermission from 'components/ui/NoPermission' @@ -54,7 +54,6 @@ const PageLayout: NextPageWithLayout = () => { })) }, [data?.result]) - const [startDate, endDate]: [Dayjs, Dayjs] = useMemo(() => { const start = dayjs() .subtract(selectedInterval.startValue, selectedInterval.startUnit as dayjs.ManipulateType) @@ -72,7 +71,10 @@ const PageLayout: NextPageWithLayout = () => { endDate.toISOString() ) - const canReadFunction = checkPermissions(PermissionAction.FUNCTIONS_READ, functionSlug as string) + const canReadFunction = useCheckPermissions( + PermissionAction.FUNCTIONS_READ, + functionSlug as string + ) if (!canReadFunction) { return } diff --git a/studio/pages/project/[ref]/index.tsx b/studio/pages/project/[ref]/index.tsx index af738885e0d..f380381c48c 100644 --- a/studio/pages/project/[ref]/index.tsx +++ b/studio/pages/project/[ref]/index.tsx @@ -1,23 +1,16 @@ -import { observer } from 'mobx-react-lite' - -import { useStore } from 'hooks' -import { useParams } from 'common/hooks' -import { NextPageWithLayout } from 'types' -import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' -import { ProjectLayoutWithAuth } from 'components/layouts' -import { ExampleProject, ClientLibrary } from 'components/interfaces/Home' +import { ClientLibrary, ExampleProject } from 'components/interfaces/Home' import { CLIENT_LIBRARIES, EXAMPLE_PROJECTS } from 'components/interfaces/Home/Home.constants' import ProjectUsageSection from 'components/interfaces/Home/ProjectUsageSection' +import { ProjectLayoutWithAuth } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import ProjectPausedState from 'components/layouts/ProjectLayout/ProjectPausedState' -import OveragesBanner from 'components/ui/OveragesBanner/OveragesBanner' import ProjectUpgradeFailedBanner from 'components/ui/ProjectUpgradeFailedBanner' +import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' +import { NextPageWithLayout } from 'types' const Home: NextPageWithLayout = () => { - const { ui } = useStore() - const { ref } = useParams() - - const project = ui.selectedProject - const projectTier = ui.selectedProject?.subscription_tier + const { project } = useProjectContext() + const projectTier = project?.subscription_tier const projectName = project?.ref !== 'default' && project?.name !== undefined @@ -78,4 +71,4 @@ Home.getLayout = (page) => ( ) -export default observer(Home) +export default Home diff --git a/studio/pages/project/[ref]/logs/auth-logs.tsx b/studio/pages/project/[ref]/logs/auth-logs.tsx index 8dc6e5d9765..de0f83e1d43 100644 --- a/studio/pages/project/[ref]/logs/auth-logs.tsx +++ b/studio/pages/project/[ref]/logs/auth-logs.tsx @@ -1,17 +1,16 @@ -import { observer } from 'mobx-react-lite' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { NextPageWithLayout } from 'types' -import { checkPermissions, useStore } from 'hooks' -import { LogsLayout } from 'components/layouts' import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import { LogsLayout } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import NoPermission from 'components/ui/NoPermission' +import { useCheckPermissions } from 'hooks' +import { NextPageWithLayout } from 'types' const LogsPage: NextPageWithLayout = () => { - const { ui } = useStore() - const project = ui.selectedProject + const { project } = useProjectContext() - const canReadAuthLogs = checkPermissions(PermissionAction.ANALYTICS_READ, 'logflare') + const canReadAuthLogs = useCheckPermissions(PermissionAction.ANALYTICS_READ, 'logflare') return !canReadAuthLogs ? ( @@ -22,4 +21,4 @@ const LogsPage: NextPageWithLayout = () => { LogsPage.getLayout = (page) => {page} -export default observer(LogsPage) +export default LogsPage diff --git a/studio/pages/project/[ref]/logs/explorer/recent.tsx b/studio/pages/project/[ref]/logs/explorer/recent.tsx index 0f8bbbe5089..72917678f32 100644 --- a/studio/pages/project/[ref]/logs/explorer/recent.tsx +++ b/studio/pages/project/[ref]/logs/explorer/recent.tsx @@ -1,23 +1,23 @@ -import { useEffect } from 'react' -import Link from 'next/link' import { observer } from 'mobx-react-lite' -import { Button, IconClock } from 'ui' -import { useStore } from 'hooks' +import Link from 'next/link' +import { useEffect } from 'react' + +import { useParams } from 'common' import RecentQueriesItem from 'components/interfaces/Settings/Logs/RecentQueriesItem' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import Table from 'components/to-be-cleaned/Table' -import { useRouter } from 'next/router' -import { LogSqlSnippets, NextPageWithLayout } from 'types' import LogsExplorerHeader from 'components/ui/Logs/LogsExplorerHeader' +import { useStore } from 'hooks' +import { LogSqlSnippets, NextPageWithLayout } from 'types' +import { Button, IconClock } from 'ui' export const LogsSavedPage: NextPageWithLayout = () => { - const { content, ui } = useStore() - const router = useRouter() - const { ref } = router.query + const { content } = useStore() + const { ref } = useParams() useEffect(() => { content.loadPersistentData() - }, [ui.selectedProjectRef]) + }, [ref]) const recent = content.recentLogSqlSnippets.slice().reverse() return ( diff --git a/studio/pages/project/[ref]/logs/explorer/saved.tsx b/studio/pages/project/[ref]/logs/explorer/saved.tsx index eebeafa3e6a..b327cec4755 100644 --- a/studio/pages/project/[ref]/logs/explorer/saved.tsx +++ b/studio/pages/project/[ref]/logs/explorer/saved.tsx @@ -1,24 +1,23 @@ -import React, { useEffect } from 'react' -import Link from 'next/link' import { observer } from 'mobx-react-lite' -import { IconSave, Loading } from 'ui' -import { useStore } from 'hooks' +import Link from 'next/link' +import { useEffect } from 'react' + +import { useParams } from 'common' import { LogsSavedQueriesItem } from 'components/interfaces/Settings/Logs' import { LogsLayout } from 'components/layouts' - import Table from 'components/to-be-cleaned/Table' -import { useRouter } from 'next/router' -import { NextPageWithLayout } from 'types' import LogsExplorerHeader from 'components/ui/Logs/LogsExplorerHeader' +import { useStore } from 'hooks' +import { NextPageWithLayout } from 'types' +import { IconSave, Loading } from 'ui' export const LogsSavedPage: NextPageWithLayout = () => { - const { content, ui } = useStore() - const router = useRouter() - const { ref } = router.query + const { content } = useStore() + const { ref } = useParams() useEffect(() => { content.load() - }, [ui.selectedProject]) + }, [ref]) if (content.isLoading) { return {null} diff --git a/studio/pages/project/[ref]/reports/api-overview.tsx b/studio/pages/project/[ref]/reports/api-overview.tsx index 3f258f447f6..b9042266eaa 100644 --- a/studio/pages/project/[ref]/reports/api-overview.tsx +++ b/studio/pages/project/[ref]/reports/api-overview.tsx @@ -13,6 +13,7 @@ import { ErrorCountsChartRenderer, ResponseSpeedChartRenderer, TopApiRoutesRenderer, + NetworkTrafficRenderer, } from 'components/interfaces/Reports/renderers/ApiRenderers' import { useState, useEffect } from 'react' import ReportHeader from 'components/interfaces/Reports/ReportHeader' @@ -88,6 +89,15 @@ export const ApiReport: NextPageWithLayout = () => { appendProps={{ data: report.data.topSlowRoutes || [] }} append={TopApiRoutesRenderer} /> + + ) } @@ -106,6 +116,7 @@ const useApiReport = () => { const topErrorRoutes = queryHooks.topErrorRoutes() const responseSpeed = queryHooks.responseSpeed() const topSlowRoutes = queryHooks.topSlowRoutes() + const networkTraffic = queryHooks.networkTraffic() const activeHooks = [ totalRequests, topRoutes, @@ -113,6 +124,7 @@ const useApiReport = () => { topErrorRoutes, responseSpeed, topSlowRoutes, + networkTraffic, ] const [filters, setFilters] = useState([]) const addFilter = (filter: ReportFilterItem) => { @@ -161,17 +173,21 @@ const useApiReport = () => { if (topSlowRoutes.changeQuery) { topSlowRoutes.changeQuery(PRESET_CONFIG.api.queries.topSlowRoutes.sql(filters)) } + + if (networkTraffic.changeQuery) { + networkTraffic.changeQuery(PRESET_CONFIG.api.queries.networkTraffic.sql(filters)) + } }, [JSON.stringify(filters)]) const handleRefresh = async () => { - activeHooks.forEach(hook => hook.runQuery()) + activeHooks.forEach((hook) => hook.runQuery()) } const handleSetParams = (params: Partial) => { - activeHooks.forEach(hook => { + activeHooks.forEach((hook) => { hook.setParams?.((prev: LogsEndpointParams) => ({ ...prev, ...params })) }) } - const isLoading = activeHooks.some(hook => hook.isLoading) + const isLoading = activeHooks.some((hook) => hook.isLoading) return { data: { totalRequests: totalRequests.logData, @@ -180,6 +196,7 @@ const useApiReport = () => { topRoutes: topRoutes.logData, topErrorRoutes: topErrorRoutes.logData, topSlowRoutes: topSlowRoutes.logData, + networkTraffic: networkTraffic.logData, }, params: { totalRequests: totalRequests.params, @@ -188,6 +205,7 @@ const useApiReport = () => { topRoutes: topRoutes.params, topErrorRoutes: topErrorRoutes.params, topSlowRoutes: topSlowRoutes.params, + networkTraffic: networkTraffic.params, }, mergeParams: handleSetParams, filters, diff --git a/studio/pages/project/[ref]/reports/database.tsx b/studio/pages/project/[ref]/reports/database.tsx index 707cbcad33c..a0aaf1f7afd 100644 --- a/studio/pages/project/[ref]/reports/database.tsx +++ b/studio/pages/project/[ref]/reports/database.tsx @@ -1,10 +1,9 @@ import dayjs from 'dayjs' import { observer } from 'mobx-react-lite' -import { FC, useEffect, useState } from 'react' -import { Badge, Button, IconArrowRight, IconExternalLink } from 'ui' +import { useEffect, useState } from 'react' -import { useStore } from 'hooks' import { useParams } from 'common/hooks' +import { useStore } from 'hooks' import { PRICING_TIER_PRODUCT_IDS, TIME_PERIODS_INFRA, @@ -12,8 +11,10 @@ import { } from 'lib/constants' import { formatBytes } from 'lib/helpers' import { NextPageWithLayout } from 'types' +import { Badge, Button, IconArrowRight, IconExternalLink } from 'ui' import { ReportsLayout } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import ChartHandler from 'components/to-be-cleaned/Charts/ChartHandler' import DateRangePicker from 'components/to-be-cleaned/DateRangePicker' import Panel from 'components/ui/Panel' @@ -21,14 +22,11 @@ import SparkBar from 'components/ui/SparkBar' import { useProjectUsageQuery } from 'data/usage/project-usage-query' const DatabaseReport: NextPageWithLayout = () => { - const { ui } = useStore() - const project = ui.selectedProject - return (
    - +
    @@ -37,10 +35,11 @@ const DatabaseReport: NextPageWithLayout = () => { DatabaseReport.getLayout = (page) => {page} -export default observer(DatabaseReport) +export default DatabaseReport -const DatabaseUsage: FC = () => { +const DatabaseUsage = observer(() => { const { meta, ui } = useStore() + const { project } = useProjectContext() const [databaseSize, setDatabaseSize] = useState(0) const [dateRange, setDateRange] = useState(undefined) @@ -76,7 +75,7 @@ const DatabaseUsage: FC = () => { const egressIsApproaching = databaseSizeUsageRatio >= USAGE_APPROACHING_THRESHOLD const egressIsExceeded = databaseSizeUsageRatio >= 1 - const subscriptionTier = ui.selectedProject?.subscription_tier + const subscriptionTier = project?.subscription_tier const isPaidTier = subscriptionTier !== PRICING_TIER_PRODUCT_IDS.FREE @@ -197,8 +196,19 @@ const DatabaseUsage: FC = () => { + )} + + {dateRange && ( + @@ -221,4 +231,4 @@ const DatabaseUsage: FC = () => {
    ) -} +}) diff --git a/studio/pages/project/[ref]/reports/index.tsx b/studio/pages/project/[ref]/reports/index.tsx index 40c56009800..7449cac1495 100644 --- a/studio/pages/project/[ref]/reports/index.tsx +++ b/studio/pages/project/[ref]/reports/index.tsx @@ -1,19 +1,19 @@ -import { useEffect, useState } from 'react' -import { observer } from 'mobx-react-lite' -import { useRouter } from 'next/router' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' -import { NextPageWithLayout } from 'types' -import { checkPermissions, useFlag, useStore } from 'hooks' import { useParams } from 'common/hooks' -import { post } from 'lib/common/fetch' -import { API_URL, PROJECT_STATUS } from 'lib/constants' -import { useProjectContentStore } from 'stores/projectContentStore' -import Loading from 'components/ui/Loading' +import { ReportsLayout } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' import { createReport } from 'components/to-be-cleaned/Reports/Reports.utils' -import { ReportsLayout } from 'components/layouts' -import { useProfileQuery } from 'data/profile/profile-query' +import Loading from 'components/ui/Loading' +import { useCheckPermissions, useFlag, useStore } from 'hooks' +import { post } from 'lib/common/fetch' +import { API_URL, PROJECT_STATUS } from 'lib/constants' +import { useProfile } from 'lib/profile' +import { useProjectContentStore } from 'stores/projectContentStore' +import { NextPageWithLayout } from 'types' export const UserReportPage: NextPageWithLayout = () => { const [loading, setLoading] = useState(true) @@ -21,12 +21,12 @@ export const UserReportPage: NextPageWithLayout = () => { const router = useRouter() const { ref } = useParams() - const { data: profile } = useProfileQuery() + const { profile } = useProfile() const { ui } = useStore() - const project = ui.selectedProject + const { project } = useProjectContext() const contentStore = useProjectContentStore(ref) - const canCreateReport = checkPermissions(PermissionAction.CREATE, 'user_content', { + const canCreateReport = useCheckPermissions(PermissionAction.CREATE, 'user_content', { resource: { type: 'report', owner_id: profile?.id }, subject: { id: profile?.id }, }) @@ -88,4 +88,4 @@ export const UserReportPage: NextPageWithLayout = () => { UserReportPage.getLayout = (page) => {page} -export default observer(UserReportPage) +export default UserReportPage diff --git a/studio/pages/project/[ref]/settings/api.tsx b/studio/pages/project/[ref]/settings/api.tsx index 6bb7f9064ec..e430e0cb57c 100644 --- a/studio/pages/project/[ref]/settings/api.tsx +++ b/studio/pages/project/[ref]/settings/api.tsx @@ -1,20 +1,23 @@ -import { createContext, useEffect } from 'react' import { observer, useLocalObservable } from 'mobx-react-lite' +import { createContext, useEffect } from 'react' -import { useStore } from 'hooks' import { useParams } from 'common/hooks' -import { NextPageWithLayout } from 'types' -import { SettingsLayout } from 'components/layouts' import ServiceList from 'components/interfaces/Settings/API/ServiceList' +import { SettingsLayout } from 'components/layouts' +import { + useIsProjectActive, + useProjectContext, +} from 'components/layouts/ProjectLayout/ProjectContext' +import { useStore } from 'hooks' +import { NextPageWithLayout } from 'types' import { IconAlertCircle } from 'ui' -import { useIsProjectActive } from 'components/layouts/ProjectLayout/ProjectContext' export const PageContext: any = createContext(null) const ApiSettings: NextPageWithLayout = () => { const { ref } = useParams() - const { meta, ui } = useStore() - const project = ui.selectedProject + const { ui, meta } = useStore() + const { project } = useProjectContext() const isActive = useIsProjectActive() // [Joshen] Will need to deprecate this @@ -29,8 +32,10 @@ const ApiSettings: NextPageWithLayout = () => { if (meta) PageState.meta = meta useEffect(() => { - if (project?.ref) meta.schemas.load() - }, [project?.ref]) + if (ui.selectedProjectRef) { + meta.schemas.load() + } + }, [ui.selectedProjectRef]) return ( diff --git a/studio/pages/project/[ref]/settings/auth.tsx b/studio/pages/project/[ref]/settings/auth.tsx index cd090a2af79..be0d718ded0 100644 --- a/studio/pages/project/[ref]/settings/auth.tsx +++ b/studio/pages/project/[ref]/settings/auth.tsx @@ -1,21 +1,23 @@ -import { useEffect } from 'react' -import { observer } from 'mobx-react-lite' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { observer } from 'mobx-react-lite' +import { useEffect } from 'react' -import { NextPageWithLayout } from 'types' -import { checkPermissions, useStore } from 'hooks' +import { useParams } from 'common' +import { AutoSchemaForm, SmtpForm } from 'components/interfaces/Auth' import { SettingsLayout } from 'components/layouts' import NoPermission from 'components/ui/NoPermission' -import { SmtpForm, AutoSchemaForm } from 'components/interfaces/Auth' +import { useCheckPermissions, useStore } from 'hooks' +import { NextPageWithLayout } from 'types' const PageLayout: NextPageWithLayout = () => { - const { ui, authConfig } = useStore() + const { authConfig } = useStore() + const { ref: projectRef } = useParams() useEffect(() => { authConfig.load() - }, [ui.selectedProjectRef]) + }, [projectRef]) - const canReadAuthSettings = checkPermissions(PermissionAction.READ, 'custom_config_gotrue') + const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') if (!canReadAuthSettings) { return diff --git a/studio/pages/project/[ref]/settings/billing/invoices.tsx b/studio/pages/project/[ref]/settings/billing/invoices.tsx index eab1762fe97..491af3ac6ae 100644 --- a/studio/pages/project/[ref]/settings/billing/invoices.tsx +++ b/studio/pages/project/[ref]/settings/billing/invoices.tsx @@ -1,21 +1,16 @@ -import { observer } from 'mobx-react-lite' -import { FC } from 'react' - -import { SettingsLayout } from 'components/layouts' -import { useStore } from 'hooks' -import { NextPageWithLayout, Project } from 'types' - -import { Invoices } from 'components/interfaces/Billing' import Link from 'next/link' -const ProjectBilling: NextPageWithLayout = () => { - const { ui } = useStore() - const project = ui.selectedProject +import { Invoices } from 'components/interfaces/Billing' +import { SettingsLayout } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useSelectedOrganization } from 'hooks' +import { NextPageWithLayout } from 'types' +const ProjectBilling: NextPageWithLayout = () => { return (
    - +
    ) @@ -25,15 +20,12 @@ ProjectBilling.getLayout = (page) => ( {page} ) -export default observer(ProjectBilling) +export default ProjectBilling -interface SettingsProps { - project?: Project -} - -const Settings: FC = ({ project }) => { - const { ui } = useStore() - const orgSlug = ui.selectedOrganization?.slug ?? '' +const Settings = () => { + const selectedOrganization = useSelectedOrganization() + const { project: selectedProject } = useProjectContext() + const orgSlug = selectedOrganization?.slug ?? '' return (
    @@ -52,7 +44,7 @@ const Settings: FC = ({ project }) => {
    - +
  • ) diff --git a/studio/pages/project/[ref]/settings/general.tsx b/studio/pages/project/[ref]/settings/general.tsx index a1981c1e607..9049ec621ac 100644 --- a/studio/pages/project/[ref]/settings/general.tsx +++ b/studio/pages/project/[ref]/settings/general.tsx @@ -7,15 +7,20 @@ import { Infrastructure, CustomDomainConfig, DeleteProjectPanel, + TransferProjectPanel, } from 'components/interfaces/Settings/General' +import { useFlag } from 'hooks' const ProjectSettings: NextPageWithLayout = () => { + const transferProjectEnabled = useFlag('transferProject') + // [Joshen] Opting for larger gap instead of gap-8 as compared to other pages for better grouping of content return (
    + {transferProjectEnabled && }
    ) diff --git a/studio/pages/project/[ref]/settings/storage.tsx b/studio/pages/project/[ref]/settings/storage.tsx index 0ac1df2ea45..8a5bfe475f5 100644 --- a/studio/pages/project/[ref]/settings/storage.tsx +++ b/studio/pages/project/[ref]/settings/storage.tsx @@ -1,12 +1,12 @@ -import React, { useEffect } from 'react' -import { observer } from 'mobx-react-lite' +import { useEffect } from 'react' -import { API_URL, PROJECT_STATUS } from 'lib/constants' -import { useFlag, useStore } from 'hooks' import { useParams } from 'common/hooks' -import { post } from 'lib/common/fetch' import { SettingsLayout } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { StorageSettings } from 'components/to-be-cleaned/Storage' +import { useFlag } from 'hooks' +import { post } from 'lib/common/fetch' +import { API_URL, PROJECT_STATUS } from 'lib/constants' import { NextPageWithLayout } from 'types' /** @@ -14,9 +14,7 @@ import { NextPageWithLayout } from 'types' */ const PageLayout: NextPageWithLayout = () => { const { ref: projectRef } = useParams() - - const { ui } = useStore() - const project = ui.selectedProject + const { project } = useProjectContext() const kpsEnabled = useFlag('initWithKps') @@ -37,4 +35,4 @@ const PageLayout: NextPageWithLayout = () => { PageLayout.getLayout = (page) => {page} -export default observer(PageLayout) +export default PageLayout diff --git a/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx b/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx index 2d3d0c06987..fbdf24b49a5 100644 --- a/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx +++ b/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx @@ -4,20 +4,20 @@ import { useEffect } from 'react' import { useParams } from 'common' import { StorageLayout } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import StorageBucketsError from 'components/layouts/StorageLayout/StorageBucketsError' import { StorageExplorer } from 'components/to-be-cleaned/Storage' import { useBucketsQuery } from 'data/storage/buckets-query' -import { useFlag, useStore } from 'hooks' +import { useFlag } from 'hooks' import { post } from 'lib/common/fetch' import { API_URL, PROJECT_STATUS } from 'lib/constants' import { NextPageWithLayout } from 'types' const PageLayout: NextPageWithLayout = () => { const { ref, bucketId } = useParams() + const { project } = useProjectContext() - const { ui } = useStore() - const project = ui.selectedProject - - const { data, isSuccess } = useBucketsQuery({ projectRef: ref }) + const { data, isSuccess, isError, error } = useBucketsQuery({ projectRef: ref }) const buckets = data ?? [] const kpsEnabled = useFlag('initWithKps') @@ -26,7 +26,7 @@ const PageLayout: NextPageWithLayout = () => { if (project && project.status === PROJECT_STATUS.INACTIVE) { post(`${API_URL}/projects/${ref}/restore`, { kps_enabled: kpsEnabled }) } - }, [project]) + }, [ref, project]) if (!project) return
    @@ -34,6 +34,8 @@ const PageLayout: NextPageWithLayout = () => { return (
    + {isError && } + {isSuccess ? ( !bucket ? (
    diff --git a/studio/pages/project/[ref]/storage/buckets/index.tsx b/studio/pages/project/[ref]/storage/buckets/index.tsx index dc5752c423a..663126a9447 100644 --- a/studio/pages/project/[ref]/storage/buckets/index.tsx +++ b/studio/pages/project/[ref]/storage/buckets/index.tsx @@ -1,27 +1,36 @@ +import { useEffect } from 'react' + import { useParams } from 'common/hooks' import { StorageLayout } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import StorageBucketsError from 'components/layouts/StorageLayout/StorageBucketsError' import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' -import { useFlag, useStore } from 'hooks' +import { useBucketsQuery } from 'data/storage/buckets-query' +import { useFlag } from 'hooks' import { post } from 'lib/common/fetch' import { API_URL, PROJECT_STATUS } from 'lib/constants' -import { observer } from 'mobx-react-lite' -import { useEffect } from 'react' import { NextPageWithLayout } from 'types' -const PageLayout: NextPageWithLayout = ({}) => { +/** + * PageLayout is used to setup layout - as usual it will requires inject global store + */ +const PageLayout: NextPageWithLayout = () => { const { ref } = useParams() - const { ui } = useStore() - const project = ui.selectedProject + const { project } = useProjectContext() const kpsEnabled = useFlag('initWithKps') + const { error, isError } = useBucketsQuery({ projectRef: ref }) + useEffect(() => { if (project && project.status === PROJECT_STATUS.INACTIVE) { post(`${API_URL}/projects/${ref}/restore`, { kps_enabled: kpsEnabled }) } - }, [project]) + }, [ref, project]) if (!project) return
    + if (isError) + return (
    { PageLayout.getLayout = (page) => {page} -export default observer(PageLayout) +export default PageLayout diff --git a/studio/pages/project/[ref]/storage/policies.tsx b/studio/pages/project/[ref]/storage/policies.tsx index 09d5f8d667f..f1809b04734 100644 --- a/studio/pages/project/[ref]/storage/policies.tsx +++ b/studio/pages/project/[ref]/storage/policies.tsx @@ -1,24 +1,23 @@ -import React, { useEffect } from 'react' -import { useRouter } from 'next/router' import { observer } from 'mobx-react-lite' +import { useEffect } from 'react' -import { API_URL } from 'lib/constants' -import { post } from 'lib/common/fetch' -import { PROJECT_STATUS } from 'lib/constants' -import { useFlag, useStore } from 'hooks' +import { useParams } from 'common' import { StorageLayout } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { StoragePolicies } from 'components/to-be-cleaned/Storage' +import { useFlag, useStore } from 'hooks' +import { post } from 'lib/common/fetch' +import { API_URL, PROJECT_STATUS } from 'lib/constants' import { NextPageWithLayout } from 'types' /** * PageLayout is used to setup layout - as usual it will requires inject global store */ const PageLayout: NextPageWithLayout = () => { - const router = useRouter() - const { ref } = router.query + const { ref } = useParams() - const { ui, meta } = useStore() - const project = ui.selectedProject + const { meta } = useStore() + const { project } = useProjectContext() const kpsEnabled = useFlag('initWithKps') diff --git a/studio/pages/projects.tsx b/studio/pages/projects.tsx index 7fb12d0342b..883da26a021 100644 --- a/studio/pages/projects.tsx +++ b/studio/pages/projects.tsx @@ -1,28 +1,48 @@ import ProjectList from 'components/interfaces/Home/ProjectList' import { AccountLayout } from 'components/layouts' import OrganizationDropdown from 'components/to-be-cleaned/Dropdown/OrganizationDropdown' +import AlertError from 'components/ui/AlertError' import Connecting from 'components/ui/Loading/Loading' -import { useStore } from 'hooks' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useAutoProjectsPrefetch } from 'data/projects/projects-query' import { IS_PLATFORM } from 'lib/constants' -import { observer } from 'mobx-react-lite' +import { useProfile } from 'lib/profile' import { NextPageWithLayout } from 'types' const ProjectsPage: NextPageWithLayout = () => { - const { app } = useStore() + const { + data: organizations, + isLoading: isOrganizationLoading, + isError, + isSuccess, + } = useOrganizationsQuery() + useAutoProjectsPrefetch() + + const { isLoading: isProfileLoading } = useProfile() + + const isLoading = isOrganizationLoading || isProfileLoading return ( <> - {!app.organizations.isInitialized ? ( + {isLoading && (
    - ) : ( + )} + + {isError && ( +
    + +
    + )} + + {isSuccess && (
    {IS_PLATFORM && (
    - +
    @@ -50,4 +70,4 @@ ProjectsPage.getLayout = (page) => ( ) -export default observer(ProjectsPage) +export default ProjectsPage diff --git a/studio/pages/vercel/integrate.tsx b/studio/pages/vercel/integrate.tsx index 8f138bbfe8e..48885764496 100644 --- a/studio/pages/vercel/integrate.tsx +++ b/studio/pages/vercel/integrate.tsx @@ -1,31 +1,31 @@ +import Divider from 'components/ui/Divider' +import { makeAutoObservable, runInAction } from 'mobx' +import { observer, useLocalObservable } from 'mobx-react-lite' import Link from 'next/link' +import { useRouter } from 'next/router' import { ChangeEvent, createContext, FC, useContext, useEffect, useState } from 'react' import { toast } from 'react-hot-toast' -import { useRouter } from 'next/router' -import { observer, useLocalObservable } from 'mobx-react-lite' -import { makeAutoObservable, runInAction } from 'mobx' -import { Button, Select, IconPlusCircle, IconX, IconChevronRight, Listbox } from 'ui' -import Divider from 'components/ui/Divider' +import { Button, IconChevronRight, IconPlusCircle, IconX, Listbox, Select } from 'ui' import { Dictionary } from 'components/grid' - -import { useStore, withAuth } from 'hooks' -import { API_URL } from 'lib/constants' -import { get } from 'lib/common/fetch' -import { - VERCEL_INTEGRATION_CONFIGS, - VERCEL_DEFAULT_EXTERNAL_ID, - INTEGRATION_ENVS_ALIAS, -} from 'lib/vercelConfigs' +import VercelIntegrationLayout from 'components/layouts/VercelIntegrationLayout' import { createVercelEnv, fetchVercelProjectEnvs, fetchVercelProjects, prepareVercelEvns, } from 'components/to-be-cleaned/Integration/Vercel.utils' +import { databaseIcon, vercelIcon } from 'components/to-be-cleaned/ListIcons' import Loading from 'components/ui/Loading' -import VercelIntegrationLayout from 'components/layouts/VercelIntegrationLayout' -import { vercelIcon, databaseIcon } from 'components/to-be-cleaned/ListIcons' +import { useProjectsQuery } from 'data/projects/projects-query' +import { useStore, withAuth } from 'hooks' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { + INTEGRATION_ENVS_ALIAS, + VERCEL_DEFAULT_EXTERNAL_ID, + VERCEL_INTEGRATION_CONFIGS, +} from 'lib/vercelConfigs' interface IVercelIntegrationStore { code: string @@ -177,9 +177,8 @@ type VercelIntegrationProps = {} & any const VercelIntegration: FC = ({}) => { // @ts-ignore const _store: IVercelIntegrationStore = useLocalObservable(() => new VercelIntegrationStore()) - const { app } = useStore() - const projects = app.projects.list() - const isSupabaseProjectListEmpty = projects.length == 0 + const { data: projects } = useProjectsQuery() + const isSupabaseProjectListEmpty = !projects || projects?.length === 0 useEffect(() => { _store.loadInitialData() @@ -522,8 +521,7 @@ const ProjectLinkItem: FC = observer( const _store = useContext(PageContext) const selectedVercelProject = _store.vercelProjects.find((x) => x.id == vercelProjectId) - const { app } = useStore() - const sortedProjects = app.projects.list() + const { data: projects } = useProjectsQuery() function onVercelProjectChange(e: string) { const value = e != UNDEFINED_SELECT_VALUE ? e : undefined @@ -591,7 +589,7 @@ const ProjectLinkItem: FC = observer( Choose a project - {sortedProjects?.map((x: Dictionary) => ( + {projects?.map((x: Dictionary) => ( { PageState.loadInitialData() - const sortedOrganizations = app.organizations.list() - PageState.shouldShowOrgCreationUI = sortedOrganizations?.length == 0 - }, []) + PageState.shouldShowOrgCreationUI = organizations?.length == 0 + }, [organizations]) return ( // @ts-ignore @@ -78,7 +78,7 @@ const CreateOrganization = observer(({}) => { const router = useRouter() const { ui, app } = useStore() - const sortedOrganizations = app.organizations.list() + const { data: organizations } = useOrganizationsQuery() const [orgName, setOrgName] = useState('') const [loading, setLoading] = useState(false) @@ -109,7 +109,7 @@ const CreateOrganization = observer(({}) => { return (
    - {sortedOrganizations?.length > 0 ? ( + {(organizations?.length ?? 0) > 0 ? (

    Create a new organization

    ) : ( <> @@ -141,14 +141,13 @@ const OrgSelection = observer(({}) => { const router = useRouter() const [loading, setLoading] = useState(false) - const { app } = useStore() - const sortedOrganizations = app.organizations.list() + const { data: organizations } = useOrganizationsQuery() useEffect(() => { if (!PageState.selectedOrg) { - PageState.selectedOrg = sortedOrganizations?.length > 0 ? sortedOrganizations[0] : undefined + PageState.selectedOrg = (organizations?.length ?? 0) > 0 ? organizations?.[0] : undefined } - }, [sortedOrganizations]) + }, [organizations]) function onContinue() { setLoading(true) @@ -164,7 +163,7 @@ const OrgSelection = observer(({}) => { } function onOrgSelect(e: ChangeEvent) { - const selectedOrg = sortedOrganizations.find((x: any) => x.slug === e.target.value) + const selectedOrg = organizations?.find((x: any) => x.slug === e.target.value) PageState.selectedOrg = selectedOrg } @@ -176,7 +175,7 @@ const OrgSelection = observer(({}) => { value={PageState.selectedOrg?.slug} onChange={onOrgSelect} > - {sortedOrganizations?.map((x) => { + {organizations?.map((x) => { return ( {x.name} diff --git a/studio/public/img/icons/frameworks/angular.svg b/studio/public/img/icons/frameworks/angular.svg new file mode 100644 index 00000000000..4bfc7d11a1e --- /dev/null +++ b/studio/public/img/icons/frameworks/angular.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/astro.svg b/studio/public/img/icons/frameworks/astro.svg new file mode 100644 index 00000000000..afbe94eaefa --- /dev/null +++ b/studio/public/img/icons/frameworks/astro.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/studio/public/img/icons/frameworks/blitzjs.svg b/studio/public/img/icons/frameworks/blitzjs.svg new file mode 100644 index 00000000000..b72ad1c56b0 --- /dev/null +++ b/studio/public/img/icons/frameworks/blitzjs.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/studio/public/img/icons/frameworks/brunch.svg b/studio/public/img/icons/frameworks/brunch.svg new file mode 100644 index 00000000000..a2783a02f57 --- /dev/null +++ b/studio/public/img/icons/frameworks/brunch.svg @@ -0,0 +1,3 @@ + + + diff --git a/studio/public/img/icons/frameworks/create-react-app.svg b/studio/public/img/icons/frameworks/create-react-app.svg new file mode 100644 index 00000000000..523333e1a8c --- /dev/null +++ b/studio/public/img/icons/frameworks/create-react-app.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/docusaurus-2.svg b/studio/public/img/icons/frameworks/docusaurus-2.svg new file mode 100644 index 00000000000..1d2a15489c8 --- /dev/null +++ b/studio/public/img/icons/frameworks/docusaurus-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/docusaurus.svg b/studio/public/img/icons/frameworks/docusaurus.svg new file mode 100644 index 00000000000..1d2a15489c8 --- /dev/null +++ b/studio/public/img/icons/frameworks/docusaurus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/dojo.svg b/studio/public/img/icons/frameworks/dojo.svg new file mode 100644 index 00000000000..0bcb896104f --- /dev/null +++ b/studio/public/img/icons/frameworks/dojo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/eleventy.svg b/studio/public/img/icons/frameworks/eleventy.svg new file mode 100644 index 00000000000..3d10e558a63 --- /dev/null +++ b/studio/public/img/icons/frameworks/eleventy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/ember.svg b/studio/public/img/icons/frameworks/ember.svg new file mode 100644 index 00000000000..2b54132e87e --- /dev/null +++ b/studio/public/img/icons/frameworks/ember.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/gatsby.svg b/studio/public/img/icons/frameworks/gatsby.svg new file mode 100644 index 00000000000..eb92b14c5ff --- /dev/null +++ b/studio/public/img/icons/frameworks/gatsby.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/gridsome.svg b/studio/public/img/icons/frameworks/gridsome.svg new file mode 100644 index 00000000000..d1d8e28a525 --- /dev/null +++ b/studio/public/img/icons/frameworks/gridsome.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/hexo.svg b/studio/public/img/icons/frameworks/hexo.svg new file mode 100644 index 00000000000..ce164e536a8 --- /dev/null +++ b/studio/public/img/icons/frameworks/hexo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/hugo.svg b/studio/public/img/icons/frameworks/hugo.svg new file mode 100644 index 00000000000..593a4cb913f --- /dev/null +++ b/studio/public/img/icons/frameworks/hugo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/studio/public/img/icons/frameworks/hydrogen.svg b/studio/public/img/icons/frameworks/hydrogen.svg new file mode 100644 index 00000000000..0476a43d8c3 --- /dev/null +++ b/studio/public/img/icons/frameworks/hydrogen.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/ionic-angular.svg b/studio/public/img/icons/frameworks/ionic-angular.svg new file mode 100644 index 00000000000..639bca02062 --- /dev/null +++ b/studio/public/img/icons/frameworks/ionic-angular.svg @@ -0,0 +1,4 @@ + + + + diff --git a/studio/public/img/icons/frameworks/ionic-react.svg b/studio/public/img/icons/frameworks/ionic-react.svg new file mode 100644 index 00000000000..639bca02062 --- /dev/null +++ b/studio/public/img/icons/frameworks/ionic-react.svg @@ -0,0 +1,4 @@ + + + + diff --git a/studio/public/img/icons/frameworks/jekyll.svg b/studio/public/img/icons/frameworks/jekyll.svg new file mode 100644 index 00000000000..bb804644182 --- /dev/null +++ b/studio/public/img/icons/frameworks/jekyll.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/studio/public/img/icons/frameworks/middleman.svg b/studio/public/img/icons/frameworks/middleman.svg new file mode 100644 index 00000000000..a66ed5bf783 --- /dev/null +++ b/studio/public/img/icons/frameworks/middleman.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/studio/public/img/icons/frameworks/nextjs.svg b/studio/public/img/icons/frameworks/nextjs.svg new file mode 100644 index 00000000000..8a2f50cac01 --- /dev/null +++ b/studio/public/img/icons/frameworks/nextjs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/nuxtjs.svg b/studio/public/img/icons/frameworks/nuxtjs.svg new file mode 100644 index 00000000000..05a0a131921 --- /dev/null +++ b/studio/public/img/icons/frameworks/nuxtjs.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/studio/public/img/icons/frameworks/parcel.svg b/studio/public/img/icons/frameworks/parcel.svg new file mode 100644 index 00000000000..01e10d87175 --- /dev/null +++ b/studio/public/img/icons/frameworks/parcel.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/studio/public/img/icons/frameworks/polymer.svg b/studio/public/img/icons/frameworks/polymer.svg new file mode 100644 index 00000000000..8c1d5dfb9e6 --- /dev/null +++ b/studio/public/img/icons/frameworks/polymer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/preact.svg b/studio/public/img/icons/frameworks/preact.svg new file mode 100644 index 00000000000..33f1d876e9c --- /dev/null +++ b/studio/public/img/icons/frameworks/preact.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/redwoodjs.svg b/studio/public/img/icons/frameworks/redwoodjs.svg new file mode 100644 index 00000000000..d28afcf66dd --- /dev/null +++ b/studio/public/img/icons/frameworks/redwoodjs.svg @@ -0,0 +1 @@ + diff --git a/studio/public/img/icons/frameworks/remix.svg b/studio/public/img/icons/frameworks/remix.svg new file mode 100644 index 00000000000..ffcdf8287ed --- /dev/null +++ b/studio/public/img/icons/frameworks/remix.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/studio/public/img/icons/frameworks/saber.svg b/studio/public/img/icons/frameworks/saber.svg new file mode 100644 index 00000000000..95dc79b0736 --- /dev/null +++ b/studio/public/img/icons/frameworks/saber.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/sanity.svg b/studio/public/img/icons/frameworks/sanity.svg new file mode 100644 index 00000000000..d2b7aaba44a --- /dev/null +++ b/studio/public/img/icons/frameworks/sanity.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/studio/public/img/icons/frameworks/sapper.svg b/studio/public/img/icons/frameworks/sapper.svg new file mode 100644 index 00000000000..40c960773e8 --- /dev/null +++ b/studio/public/img/icons/frameworks/sapper.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/scully.svg b/studio/public/img/icons/frameworks/scully.svg new file mode 100644 index 00000000000..8cd05527a39 --- /dev/null +++ b/studio/public/img/icons/frameworks/scully.svg @@ -0,0 +1,94 @@ + + + + +logo + + + + + + + + + + + + + + + + diff --git a/studio/public/img/icons/frameworks/solidstart.svg b/studio/public/img/icons/frameworks/solidstart.svg new file mode 100644 index 00000000000..a6a1ccf10ae --- /dev/null +++ b/studio/public/img/icons/frameworks/solidstart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/stencil.svg b/studio/public/img/icons/frameworks/stencil.svg new file mode 100644 index 00000000000..136745e5477 --- /dev/null +++ b/studio/public/img/icons/frameworks/stencil.svg @@ -0,0 +1,4 @@ + + + + diff --git a/studio/public/img/icons/frameworks/storybook.svg b/studio/public/img/icons/frameworks/storybook.svg new file mode 100644 index 00000000000..d94c0ebd176 --- /dev/null +++ b/studio/public/img/icons/frameworks/storybook.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/studio/public/img/icons/frameworks/svelte.svg b/studio/public/img/icons/frameworks/svelte.svg new file mode 100644 index 00000000000..40c960773e8 --- /dev/null +++ b/studio/public/img/icons/frameworks/svelte.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/sveltekit-1.svg b/studio/public/img/icons/frameworks/sveltekit-1.svg new file mode 100644 index 00000000000..40c960773e8 --- /dev/null +++ b/studio/public/img/icons/frameworks/sveltekit-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/sveltekit.svg b/studio/public/img/icons/frameworks/sveltekit.svg new file mode 100644 index 00000000000..40c960773e8 --- /dev/null +++ b/studio/public/img/icons/frameworks/sveltekit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/umijs.svg b/studio/public/img/icons/frameworks/umijs.svg new file mode 100644 index 00000000000..283dbcbae5f --- /dev/null +++ b/studio/public/img/icons/frameworks/umijs.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/vite.svg b/studio/public/img/icons/frameworks/vite.svg new file mode 100644 index 00000000000..de4aeddc12b --- /dev/null +++ b/studio/public/img/icons/frameworks/vite.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/studio/public/img/icons/frameworks/vitepress.svg b/studio/public/img/icons/frameworks/vitepress.svg new file mode 100644 index 00000000000..de4aeddc12b --- /dev/null +++ b/studio/public/img/icons/frameworks/vitepress.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/studio/public/img/icons/frameworks/vue.svg b/studio/public/img/icons/frameworks/vue.svg new file mode 100644 index 00000000000..e4111af7183 --- /dev/null +++ b/studio/public/img/icons/frameworks/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/vuepress.svg b/studio/public/img/icons/frameworks/vuepress.svg new file mode 100644 index 00000000000..e4111af7183 --- /dev/null +++ b/studio/public/img/icons/frameworks/vuepress.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/zola.svg b/studio/public/img/icons/frameworks/zola.svg new file mode 100644 index 00000000000..6d7413f01d9 --- /dev/null +++ b/studio/public/img/icons/frameworks/zola.svg @@ -0,0 +1,4 @@ + + + + diff --git a/studio/public/img/icons/kakao-icon.svg b/studio/public/img/icons/kakao-icon.svg new file mode 100644 index 00000000000..e721ec2b432 --- /dev/null +++ b/studio/public/img/icons/kakao-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/integrations/covers/github-cover.png b/studio/public/img/integrations/covers/github-cover.png new file mode 100644 index 00000000000..853f5322db5 Binary files /dev/null and b/studio/public/img/integrations/covers/github-cover.png differ diff --git a/studio/public/img/integrations/covers/vercel-cover.png b/studio/public/img/integrations/covers/vercel-cover.png new file mode 100644 index 00000000000..d2965eefdc6 Binary files /dev/null and b/studio/public/img/integrations/covers/vercel-cover.png differ diff --git a/studio/public/img/libraries/swift-icon.svg b/studio/public/img/libraries/swift-icon.svg new file mode 100644 index 00000000000..1c038708c1c --- /dev/null +++ b/studio/public/img/libraries/swift-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/studio/state/github-connection-config-panel.ts b/studio/state/github-connection-config-panel.ts new file mode 100644 index 00000000000..78feb1a3615 --- /dev/null +++ b/studio/state/github-connection-config-panel.ts @@ -0,0 +1,19 @@ +import { proxy, snapshot, useSnapshot } from 'valtio' + +export const githubConnectionConfigPanelState = proxy({ + open: false as boolean, + setOpen: (open: boolean) => { + githubConnectionConfigPanelState.open = open + }, + organizationIntegrationId: undefined as string | undefined, + setOrganizationIntegrationId: (organizationIntegrationId: string) => { + githubConnectionConfigPanelState.organizationIntegrationId = organizationIntegrationId + }, +}) + +export const getGithubConnectionConfigPanelSnapshot = () => + snapshot(githubConnectionConfigPanelState) + +export const useGithubConnectionConfigPanelSnapshot = ( + options?: Parameters[1] +) => useSnapshot(githubConnectionConfigPanelState, options) diff --git a/studio/state/organization-settings.ts b/studio/state/organization-settings.ts new file mode 100644 index 00000000000..a6f67c58f78 --- /dev/null +++ b/studio/state/organization-settings.ts @@ -0,0 +1,15 @@ +import { proxy, snapshot, useSnapshot } from 'valtio' + +export type ORG_SETTINGS_PANEL_KEYS = undefined | 'subscriptionPlan' | 'costControl' + +export const orgSettingsPageState = proxy({ + panelKey: undefined as ORG_SETTINGS_PANEL_KEYS, + setPanelKey: (key: ORG_SETTINGS_PANEL_KEYS) => { + orgSettingsPageState.panelKey = key + }, +}) + +export const getOrgSettingsPageStateSnapshot = () => snapshot(orgSettingsPageState) + +export const useOrgSettingsPageStateSnapshot = (options?: Parameters[1]) => + useSnapshot(orgSettingsPageState, options) diff --git a/studio/state/vercel-integration-installation.ts b/studio/state/vercel-integration-installation.ts new file mode 100644 index 00000000000..737879f6fe1 --- /dev/null +++ b/studio/state/vercel-integration-installation.ts @@ -0,0 +1,15 @@ +import { proxy, snapshot, useSnapshot } from 'valtio' + +export const vercelIntegrationInstallationState = proxy({ + selectedOrganizationSlug: '' as string, + setSelectedOrganizationSlug: (selectedOrganizationSlug: string) => { + vercelIntegrationInstallationState.selectedOrganizationSlug = selectedOrganizationSlug + }, +}) + +export const getVercelIntegrationInstallationState = () => + snapshot(vercelIntegrationInstallationState) + +export const useVercelIntegrationInstallationState = ( + options?: Parameters[1] +) => useSnapshot(vercelIntegrationInstallationState, options) diff --git a/studio/stores/RootStore.ts b/studio/stores/RootStore.ts index 3b42662f36d..0846dd49eef 100644 --- a/studio/stores/RootStore.ts +++ b/studio/stores/RootStore.ts @@ -29,8 +29,7 @@ export interface IRootStore { selectedProjectRef?: string - setProjectRef: (value: string) => void - setOrganizationSlug: (value?: string) => void + setProject: (project: Project) => void } export class RootStore implements IRootStore { app: IAppStore @@ -61,40 +60,21 @@ export class RootStore implements IRootStore { * * This method will also trigger project detail loading when it's not available */ - setProjectRef(value: string) { - if (this.selectedProjectRef === value) return - this.selectedProjectRef = value + setProject(project: Project) { + if (this.selectedProjectRef === project.ref) return // reset ui projectRef in case of switching projects // this will show the loading screen instead of showing the previous project this.ui.setProjectRef(undefined) - const setProjectRefs = (project: Project) => { - this.meta.setProjectDetails(project) - this.functions.setProjectRef(project.ref) - this.authConfig.setProjectRef(project.ref) - this.content.setProjectRef(project.ref) - this.backups.setProjectRef(project.ref) - // ui set must come last - this.ui.setProjectRef(project.ref) - } + this.meta.setProjectDetails(project) + this.functions.setProjectRef(project.ref) + this.authConfig.setProjectRef(project.ref) + this.content.setProjectRef(project.ref) + this.backups.setProjectRef(project.ref) + // ui set must come last + this.ui.setProjectRef(project.ref) - // fetch project detail when - // - project not found yet. projectStore is loading - // - connectionString is not available. projectStore loaded - const found = this.app.projects.find((x: Project) => x.ref === value) - if (found?.connectionString === undefined) { - this.app.projects.fetchDetail(value, (project) => { - setProjectRefs(project) - }) - } else { - setProjectRefs(found) - } - } - - setOrganizationSlug(value?: string) { - if (this.ui.selectedOrganization?.slug != value) { - this.ui.setOrganizationSlug(value) - } + this.selectedProjectRef = project.ref } } diff --git a/studio/stores/UiStore.ts b/studio/stores/UiStore.ts index 0e7b35339f9..91734dd99de 100644 --- a/studio/stores/UiStore.ts +++ b/studio/stores/UiStore.ts @@ -1,21 +1,16 @@ -import { uuidv4 } from 'lib/helpers' import { action, makeAutoObservable } from 'mobx' -import { Project, Notification, Organization, ProjectBase, Permission } from 'types' + +import { uuidv4 } from 'lib/helpers' +import { Notification } from 'types' import { IRootStore } from './RootStore' export interface IUiStore { language: 'en-US' selectedProjectRef?: string - selectedProject?: Project - selectedProjectBaseInfo?: ProjectBase - selectedOrganization?: Organization notification?: Notification - permissions?: Permission[] - load: () => void setProjectRef: (ref?: string) => void setOrganizationSlug: (slug?: string) => void setNotification: (notification: Notification) => string - setPermissions: (permissions?: Permission[]) => void } export default class UiStore implements IUiStore { rootStore: IRootStore @@ -23,7 +18,6 @@ export default class UiStore implements IUiStore { selectedProjectRef?: string selectedOrganizationSlug?: string notification?: Notification - permissions?: Permission[] = [] constructor(rootStore: IRootStore) { this.rootStore = rootStore @@ -33,57 +27,6 @@ export default class UiStore implements IUiStore { }) } - /** - * we use this getter to check for project ready. - * Only return selectedProject when it has full detail - * like connectionString prop - * - * @returns Project or undefined - */ - get selectedProject() { - if (this.selectedProjectRef) { - const found = this.rootStore.app.projects.find( - (x: Project) => x.ref === this.selectedProjectRef - ) - // Self-hosted project details has connectionString set to empty string - return found?.connectionString !== undefined ? found : undefined - } - return undefined - } - - /** - * Get selected project base info. - * - * @return ProjectBase or undefined - */ - get selectedProjectBaseInfo(): ProjectBase | undefined { - if (this.selectedProjectRef) { - return this.rootStore.app.projects.find((x: Project) => x.ref == this.selectedProjectRef) - } - return undefined - } - - get selectedOrganization() { - if (this.selectedOrganizationSlug) { - const found = this.rootStore.app.organizations.find( - (x: Organization) => x.slug == this.selectedOrganizationSlug - ) - return found - } - if (this.selectedProjectRef) { - const organizationId = this.selectedProject?.organization_id - const found = this.rootStore.app.organizations.find( - (x: Organization) => x.id == organizationId - ) - return found - } - return undefined - } - - load() { - if (typeof window === 'undefined') return - } - setProjectRef(ref?: string) { this.selectedProjectRef = ref } @@ -97,8 +40,4 @@ export default class UiStore implements IUiStore { this.notification = { ...notification, id } return id } - - setPermissions(permissions?: any) { - this.permissions = permissions - } } diff --git a/studio/stores/app/AppStore.ts b/studio/stores/app/AppStore.ts index 6b436e9ddb9..4c0a84b878b 100644 --- a/studio/stores/app/AppStore.ts +++ b/studio/stores/app/AppStore.ts @@ -1,32 +1,12 @@ -import { cloneDeep } from 'lodash' -import { values } from 'mobx' -import { Organization, Project } from 'types' - -import { API_URL, PROJECT_STATUS } from 'lib/constants' -import { getWithTimeout } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' import { IRootStore } from '../RootStore' import DatabaseStore, { IDatabaseStore } from './DatabaseStore' -import OrganizationStore from './OrganizationStore' -import ProjectStore, { IProjectStore } from './ProjectStore' export interface IAppStore { - projects: IProjectStore - organizations: OrganizationStore database: IDatabaseStore - onProjectCreated: (project: Project) => void - onProjectUpdated: (project: Project) => void - onProjectDeleted: (project: Project) => void - onProjectPaused: (projectId: number) => void - onProjectStatusUpdated: (projectId: number, value: string) => void - onProjectPostgrestStatusUpdated: (projectId: number, value: 'OFFLINE' | 'ONLINE') => void - onOrgAdded: (org: any) => void - onOrgUpdated: (org: any) => void - onOrgDeleted: (org: any) => void } export default class AppStore implements IAppStore { rootStore: IRootStore - projects: ProjectStore - organizations: OrganizationStore database: DatabaseStore baseUrl: string @@ -36,114 +16,6 @@ export default class AppStore implements IAppStore { this.baseUrl = API_URL || '/api' const headers: any = {} - - this.projects = new ProjectStore(rootStore, `${this.baseUrl}/projects`, headers) - this.organizations = new OrganizationStore(rootStore, `${this.baseUrl}/organizations`, headers) this.database = new DatabaseStore(rootStore, `${this.baseUrl}/database`, headers) } - - onProjectCreated(project: any) { - if (project && project.id) { - const temp: Project = { - id: project.id, - ref: project.ref, - name: project.name, - status: project.status, - organization_id: project.organization_id, - cloud_provider: project.cloud_provider, - region: project.region, - inserted_at: project.inserted_at, - subscription_id: project.subscription_id, - } - this.projects.data[project.id] = temp - } - } - - onProjectUpdated(project: any) { - if (project && project.id) { - const clone = cloneDeep(this.projects.data[project.id]) - // only update available param - if (project.name) clone.name = project.name - if (project.status) clone.status = project.status - this.projects.data[project.id] = clone - } - } - - onProjectDeleted(project: any) { - if (project && project.id) { - // cleanup project saved queries - localStorage.removeItem(`supabase-queries-state-${project.ref}`) - localStorage.removeItem(`supabase_${project.ref}`) - delete this.projects.data[project.id] - } - } - - // At global store level so that it can continue checking while the user - // is doing something else outside of the project page - async onProjectPaused(projectId: number) { - const checkProjectInactive = async () => { - const projectRef = this.projects.data[projectId]?.ref - const projectStatus = await getWithTimeout(`${API_URL}/projects/${projectRef}/status`, { - timeout: 2000, - }) - if (projectStatus.status === PROJECT_STATUS.INACTIVE) { - this.onProjectStatusUpdated(projectId, PROJECT_STATUS.INACTIVE) - } else { - setTimeout(() => checkProjectInactive(), 5000) - } - } - - setTimeout(() => checkProjectInactive(), 5000) - } - - onProjectStatusUpdated(projectId: number, value: string) { - const clone = cloneDeep(this.projects.data[projectId]) - clone.status = value - this.projects.data[projectId] = clone - } - - onProjectPostgrestStatusUpdated(projectId: number, value: 'OFFLINE' | 'ONLINE') { - const clone = cloneDeep(this.projects.data[projectId]) - clone.postgrestStatus = value - this.projects.data[projectId] = clone - } - - onOrgUpdated(updatedOrg: Organization) { - if (updatedOrg && updatedOrg.id) { - const originalOrg = this.organizations.data[updatedOrg.id] - this.organizations.data[updatedOrg.id] = { - ...originalOrg, - ...updatedOrg, - } - } - } - - onOrgAdded(org: Organization) { - if (org && org.id) { - this.organizations.data[org.id] = org - } - } - - onOrgDeleted(org: Organization) { - if (org && org.id) { - const projects = values(this.projects.data) - // cleanup projects saved queries - const removedprojects = projects.filter( - (project: Project) => project.organization_id === org.id - ) - removedprojects.forEach((project: Project) => { - localStorage.removeItem(`supabase-queries-state-${project.ref}`) - localStorage.removeItem(`supabase_${project.ref}`) - }) - // remove projects belong to removed org - const updatedProjects = projects.filter( - (project: Project) => project.organization_id != org.id - ) - this.projects.data = updatedProjects.reduce((map: any, x: any) => { - map[x.id] = { ...x } - return map - }, {}) - delete (this.organizations.data as any)[org.id] - } - } } diff --git a/studio/stores/app/OrganizationStore.ts b/studio/stores/app/OrganizationStore.ts deleted file mode 100644 index daa048cc96d..00000000000 --- a/studio/stores/app/OrganizationStore.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Organization } from 'types' -import { IRootStore } from '../RootStore' -import PostgresMetaInterface from '../common/PostgresMetaInterface' - -export default class OrganizationStore extends PostgresMetaInterface { - constructor( - rootStore: IRootStore, - dataUrl: string, - headers?: { - [prop: string]: any - }, - options?: { identifier: string } - ) { - super(rootStore, dataUrl, headers, options) - } -} diff --git a/studio/stores/app/PermissionsStore.ts b/studio/stores/app/PermissionsStore.ts deleted file mode 100644 index a14f98cc1d0..00000000000 --- a/studio/stores/app/PermissionsStore.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { keyBy } from 'lodash' -import { Permission } from 'types' -import { IRootStore } from '../RootStore' -import PostgresMetaInterface from '../common/PostgresMetaInterface' - -export default class PermissionsStore extends PostgresMetaInterface { - constructor( - rootStore: IRootStore, - dataUrl: string, - headers?: { - [prop: string]: any - }, - options?: { identifier: string } - ) { - super(rootStore, dataUrl, headers, options) - } - - // [Joshen] Method override here as permissions are not identified by an id from the API - setDataArray(value: any[]) { - const formattedValues = value.map((permission, idx: number) => { - return { ...permission, id: idx + 1 } - }) - const formattedValue = keyBy(formattedValues, this.identifier) - Object.keys(this.data).forEach((projectId) => { - if (formattedValue[projectId] !== undefined) { - formattedValue[projectId] = this.data[projectId] - } - }) - this.data = formattedValue - } - - // [Joshen] Method override here as permissions do not have `name` property - list() { - return Object.values(this.data) - } -} diff --git a/studio/stores/app/ProjectStore.ts b/studio/stores/app/ProjectStore.ts deleted file mode 100644 index d43814c29a6..00000000000 --- a/studio/stores/app/ProjectStore.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { cloneDeep, keyBy } from 'lodash' -import { Project } from 'types' -import { IRootStore } from '../RootStore' -import { constructHeaders } from 'lib/api/apiHelpers' -import { get } from 'lib/common/fetch' -import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' -import PostgresMetaInterface, { IPostgresMetaInterface } from '../common/PostgresMetaInterface' - -import pingPostgrest from 'lib/pingPostgrest' - -export interface IProjectStore extends IPostgresMetaInterface { - fetchDetail: (projectRef: string, callback?: (project: Project) => void) => Promise - fetchSubscriptionTier: (project: Project) => Promise -} - -export default class ProjectStore extends PostgresMetaInterface { - constructor( - rootStore: IRootStore, - dataUrl: string, - headers?: { - [prop: string]: any - }, - options?: { identifier: string } - ) { - super(rootStore, dataUrl, headers, options) - } - - // [Joshen] Method override here due to race condition between projects.load() and fetchDetail() - // There's a race condition that can happen here when these 2 methods are called, such that if - // fetchDetail() completes before projects.load() completes, the response from project.loads() - // then overrides what fetchDetail() has already populated into the store - causing parameters like - // connectionString to become undefined and hence causing the dashboard to stay in a Connecting state. - // You can reproduce this by giving a setTimeout to project.loads under withAuth.tsx - - // This thus ensures that the response from project.loads() do not override projects which are already - // loaded in the store from a successful fetchDetail() - This should be fine as project.loads() is only - // called once in this whole app under withAuth.tsx - setDataArray(value: any[]) { - const formattedValue = keyBy(value, this.identifier) - Object.keys(this.data).forEach((projectId) => { - if (formattedValue[projectId] !== undefined) { - formattedValue[projectId] = this.data[projectId] - } - }) - this.data = formattedValue - } - - async fetchDetail(projectRef: string, callback?: (project: Project) => void) { - const url = `${this.url}/${projectRef}` - const headers = constructHeaders(this.headers) - const response = await get(url, { headers }) - - if (!response.error) { - const project = response as Project - // to improve UX, we wait for PingPostgrest result before continue - project.postgrestStatus = await this.pingPostgrest(project) - // update project detail by key id - this.data[project.id] = project - - callback?.(project) - - // lazy fetches - if (IS_PLATFORM) { - this.fetchSubscriptionTier(project) - } - } - } - - async pingPostgrest(project: Project): Promise<'ONLINE' | 'OFFLINE' | undefined> { - if (project.status === PROJECT_STATUS.ACTIVE_HEALTHY && project.restUrl) { - const success = await pingPostgrest(project.ref, { kpsVersion: project.kpsVersion }) - return success ? 'ONLINE' : 'OFFLINE' - } - return undefined - } - - async fetchSubscriptionTier(project: Project) { - const { id: projectId, ref: projectRef, status } = project - if (status !== PROJECT_STATUS.REMOVED) { - const url = `${this.url}/${projectRef}/subscription` - const headers = constructHeaders(this.headers) - const response = await get(url, { headers }) - if (!response.error) { - const subscriptionInfo = response as { - tier: { - supabase_prod_id: string - } - } - // update subscription_tier key - const clone = cloneDeep(this.data[projectId]) - clone.subscription_tier = subscriptionInfo.tier.supabase_prod_id - this.data[projectId] = clone - } - } - } -} diff --git a/studio/stores/authConfig/schema/AuthProviders/AuthProvidersFormValidation.tsx b/studio/stores/authConfig/schema/AuthProviders/AuthProvidersFormValidation.tsx index 750ff44fa62..8771c1ca075 100644 --- a/studio/stores/authConfig/schema/AuthProviders/AuthProvidersFormValidation.tsx +++ b/studio/stores/authConfig/schema/AuthProviders/AuthProvidersFormValidation.tsx @@ -1,6 +1,10 @@ -import { boolean, number, object, string } from 'yup' +import { boolean, number, object, string, ValidationError } from 'yup' import { urlRegex } from 'components/interfaces/Auth/Auth.constants' +const parseBase64URL = (b64url: string) => { + return atob(b64url.replace(/[-]/g, '+').replace(/[_]/g, '/')) +} + const JSON_SCHEMA_VERSION = 'http://json-schema.org/draft-07/schema#' const PROVIDER_EMAIL = { @@ -77,6 +81,7 @@ const PROVIDER_PHONE = { { label: 'Messagebird', value: 'messagebird', icon: 'messagebird-icon.svg' }, { label: 'Textlocal', value: 'textlocal', icon: 'textlocal-icon.png' }, { label: 'Vonage', value: 'vonage', icon: 'vonage-icon.svg' }, + { label: 'Twilio Verify', value: 'twilio-verify', icon: 'twilio-icon.svg' } ], }, RATE_LIMIT_SMS_SENT: { @@ -91,7 +96,7 @@ const PROVIDER_PHONE = { title: 'Twilio Account Sid', show: { key: 'SMS_PROVIDER', - matches: 'twilio', + matches: ['twilio'], }, }, SMS_TWILIO_AUTH_TOKEN: { @@ -100,7 +105,7 @@ const PROVIDER_PHONE = { isSecret: true, show: { key: 'SMS_PROVIDER', - matches: 'twilio', + matches: ['twilio'], }, }, SMS_TWILIO_MESSAGE_SERVICE_SID: { @@ -108,7 +113,34 @@ const PROVIDER_PHONE = { title: 'Twilio Message Service Sid', show: { key: 'SMS_PROVIDER', - matches: 'twilio', + matches: ['twilio'], + }, + }, + + // Twilio Verify + SMS_TWILIO_VERIFY_ACCOUNT_SID: { + type: 'string', + title: 'Twilio Account Sid', + show: { + key: 'SMS_PROVIDER', + matches: ['twilio-verify'], + }, + }, + SMS_TWILIO_VERIFY_AUTH_TOKEN: { + type: 'string', + title: 'Twilio Auth Token', + isSecret: true, + show: { + key: 'SMS_PROVIDER', + matches: ['twilio-verify'], + }, + }, + SMS_TWILIO_VERIFY_MESSAGE_SERVICE_SID: { + type: 'string', + title: 'Twilio Message Service Sid', + show: { + key: 'SMS_PROVIDER', + matches: ['twilio-verify'], }, }, @@ -118,7 +150,7 @@ const PROVIDER_PHONE = { title: 'Messagebird Access Key', show: { key: 'SMS_PROVIDER', - matches: 'messagebird', + matches: ['messagebird'], }, }, SMS_MESSAGEBIRD_ORIGINATOR: { @@ -126,7 +158,7 @@ const PROVIDER_PHONE = { title: 'Messagebird Originator', show: { key: 'SMS_PROVIDER', - matches: 'messagebird', + matches: ['messagebird'], }, }, @@ -136,7 +168,7 @@ const PROVIDER_PHONE = { title: 'Textlocal API Key', show: { key: 'SMS_PROVIDER', - matches: 'textlocal', + matches: ['textlocal'], }, }, SMS_TEXTLOCAL_SENDER: { @@ -144,7 +176,7 @@ const PROVIDER_PHONE = { title: 'Textlocal Sender', show: { key: 'SMS_PROVIDER', - matches: 'textlocal', + matches: ['textlocal'], }, }, @@ -154,7 +186,7 @@ const PROVIDER_PHONE = { title: 'Vonage API Key', show: { key: 'SMS_PROVIDER', - matches: 'vonage', + matches: ['vonage'], }, }, SMS_VONAGE_API_SECRET: { @@ -162,7 +194,7 @@ const PROVIDER_PHONE = { title: 'Vonage API Secret', show: { key: 'SMS_PROVIDER', - matches: 'vonage', + matches: ['vonage'], }, }, // [TODO] verify what this is? @@ -171,7 +203,7 @@ const PROVIDER_PHONE = { title: 'Vonage From', show: { key: 'SMS_PROVIDER', - matches: 'vonage', + matches: ['vonage'], }, }, @@ -187,17 +219,29 @@ const PROVIDER_PHONE = { type: 'number', description: 'Duration before an SMS OTP expires', units: 'seconds', + show: { + key: 'SMS_PROVIDER', + matches: ['twilio', 'messagebird', 'textlocal', 'vonage'] + } }, SMS_OTP_LENGTH: { title: 'SMS OTP Length', type: 'number', description: 'Number of digits in OTP', units: 'digits', + show: { + key: 'SMS_PROVIDER', + matches: ['twilio', 'messagebird', 'textlocal', 'vonage'] + } }, SMS_TEMPLATE: { title: 'SMS Message', type: 'string', description: 'To format the OTP code use `{{ .Code }}`', + show: { + key: 'SMS_PROVIDER', + matches: ['twilio', 'messagebird', 'textlocal', 'vonage'] + } }, }, validationSchema: object().shape({ @@ -227,6 +271,29 @@ const PROVIDER_PHONE = { otherwise: (schema) => schema, }), + // Twilio Verify + SMS_TWILIO_VERIFY_ACCOUNT_SID: string().when(['EXTERNAL_PHONE_ENABLED', 'SMS_PROVIDER'], { + is: (EXTERNAL_PHONE_ENABLED: boolean, SMS_PROVIDER: string) => { + return EXTERNAL_PHONE_ENABLED && SMS_PROVIDER === 'twilio-verify' + }, + then: (schema) => schema.required('Twilio Verify Account SID is required'), + otherwise: (schema) => schema, + }), + SMS_TWILIO_VERIFY_AUTH_TOKEN: string().when(['EXTERNAL_PHONE_ENABLED', 'SMS_PROVIDER'], { + is: (EXTERNAL_PHONE_ENABLED: boolean, SMS_PROVIDER: string) => { + return EXTERNAL_PHONE_ENABLED && SMS_PROVIDER === 'twilio-verify' + }, + then: (schema) => schema.required('Twilio Verify Auth Token is required'), + otherwise: (schema) => schema, + }), + SMS_TWILIO_VERIFY_MESSAGE_SERVICE_SID: string().when(['EXTERNAL_PHONE_ENABLED', 'SMS_PROVIDER'], { + is: (EXTERNAL_PHONE_ENABLED: boolean, SMS_PROVIDER: string) => { + return EXTERNAL_PHONE_ENABLED && SMS_PROVIDER === 'twilio-verify' + }, + then: (schema) => schema.required('Twilio Verify Service SID is required'), + otherwise: (schema) => schema, + }), + // Messagebird SMS_MESSAGEBIRD_ACCESS_KEY: string().when(['EXTERNAL_PHONE_ENABLED', 'SMS_PROVIDER'], { is: (EXTERNAL_PHONE_ENABLED: boolean, SMS_PROVIDER: string) => { @@ -300,80 +367,139 @@ const EXTERNAL_PROVIDER_APPLE = { title: 'Apple', properties: { EXTERNAL_APPLE_ENABLED: { - title: 'Enable Apple provider', - description: 'This will enable Apple login for your application', + title: 'Enable Sign in with Apple', + description: + 'Enables Sign in with Apple on the web using OAuth or natively within iOS, macOS, watchOS or tvOS apps.', type: 'boolean', }, EXTERNAL_APPLE_CLIENT_ID: { - /** - * to do: change docs - */ - title: 'Services ID', - description: ` -Client identifier when authenticating or validating users. + title: 'Service ID (for OAuth)', + description: `Client identifier used in the OAuth flow on the web. [Learn more](https://developer.apple.com/documentation/sign_in_with_apple/configuring_your_environment_for_sign_in_with_apple)`, type: 'string', }, - EXTERNAL_IOS_BUNDLE_ID: { - /** - * to do: change docs - */ - title: 'IOS Bundle ID', - description: `The iOS app's unique identifier. [Learn more](https://developer.apple.com/documentation/appstoreconnectapi/bundle_ids)`, - type: 'string', - }, EXTERNAL_APPLE_SECRET: { - /** - * to do: change docs - */ - title: 'Secret key', - description: ` -The secret key is a JWT token that must be generated. + title: 'Secret Key (for OAuth)', + description: `Secret key used in the OAuth flow. [Learn more](https://supabase.com/docs/guides/auth/social-login/auth-apple#generate-a-client_secret)`, type: 'string', isSecret: true, }, - }, - validationSchema: object().shape( - { - EXTERNAL_APPLE_ENABLED: boolean().required(), - EXTERNAL_APPLE_SECRET: string().when(['EXTERNAL_APPLE_ENABLED'], { - is: true, - then: (schema) => schema.required('Secret key is required'), - otherwise: (schema) => schema, - }), - EXTERNAL_APPLE_CLIENT_ID: string().when( - ['EXTERNAL_APPLE_ENABLED', 'EXTERNAL_IOS_BUNDLE_ID'], - { - is: (EXTERNAL_APPLE_ENABLED: boolean, EXTERNAL_IOS_BUNDLE_ID: string) => { - return EXTERNAL_APPLE_ENABLED && !EXTERNAL_IOS_BUNDLE_ID - }, - then: (schema) => schema.required('Either the Services ID or iOS Bundle ID is required'), - otherwise: (schema) => schema, - } - ), - EXTERNAL_IOS_BUNDLE_ID: string().when( - ['EXTERNAL_APPLE_ENABLED', 'EXTERNAL_APPLE_CLIENT_ID'], - { - is: (EXTERNAL_APPLE_ENABLED: boolean, EXTERNAL_APPLE_CLIENT_ID: string) => { - return EXTERNAL_APPLE_ENABLED && !EXTERNAL_APPLE_CLIENT_ID - }, - then: (schema) => schema.required('Either the Services ID or iOS Bundle ID is required'), - otherwise: (schema) => schema, - } - ), + EXTERNAL_APPLE_ADDITIONAL_CLIENT_IDS: { + title: 'Authorized Client IDs (iOS, macOS, watchOS, tvOS bundle IDs or service IDs)', + description: `Comma separated list of allowed Apple app bundle IDs for native sign in, or service IDs for Sign in with Apple JS. [Learn more](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js)`, + type: 'string', }, - // this is necessary for the "either or" validation on EXTERNAL_APPLE_CLIENT_ID and EXTERNAL_IOS_BUNDLE_ID - [['EXTERNAL_APPLE_CLIENT_ID', 'EXTERNAL_IOS_BUNDLE_ID']] - ), + }, + validationSchema: object().shape({ + EXTERNAL_APPLE_ENABLED: boolean().required(), + EXTERNAL_APPLE_SECRET: string() + .when(['EXTERNAL_APPLE_ENABLED', 'EXTERNAL_APPLE_CLIENT_ID'], { + is: (EXTERNAL_APPLE_ENABLED: boolean, EXTERNAL_APPLE_CLIENT_ID: string) => { + return EXTERNAL_APPLE_ENABLED && !!EXTERNAL_APPLE_CLIENT_ID + }, + then: (schema) => + schema + .required('Secret key is required when using the OAuth flow.') + .matches(/^[a-z0-9_-]+([.][a-z0-9_-]+){2}$/i, 'Secret key should be a JWT.') + .test({ + message: 'Secret key is not a correctly generated JWT.', + test: (value?: string): boolean => { + if (!value) { + return true + } + try { + const parts = value.split('.').map((value) => parseBase64URL(value)) + const header = JSON.parse(parts[0]) + const body = JSON.parse(parts[1]) + return ( + typeof header === 'object' && + typeof body === 'object' && + header && + body && + header.typ === 'JWT' && + header.alg === 'ES256' && + body.aud === 'https://appleid.apple.com' + ) + } catch (e: any) { + console.log(e) + return false + } + + return true + }, + }) + .test({ + message: 'Secret key expires in less than 7 days!', + test: (value?: string) => { + if (!value) { + return true + } + try { + const parts = value.split('.').map((value) => parseBase64URL(value)) + const body = JSON.parse(parts[1]) + return Date.now() > body.exp - 7 * 24 * 60 * 60 * 1000 + } catch (e: any) { + console.log(e) + return false + } + + return true + }, + }), + }) + .when( + [ + 'EXTERNAL_APPLE_ENABLED', + 'EXTERNAL_APPLE_ADDITIONAL_CLIENT_IDS', + 'EXTERNAL_APPLE_CLIENT_ID', + ], + { + is: ( + EXTERNAL_APPLE_ENABLED: boolean, + EXTERNAL_APPLE_ADDITIONAL_CLIENT_IDS: string, + EXTERNAL_APPLE_CLIENT_ID: string + ) => { + return ( + EXTERNAL_APPLE_ENABLED && + !!EXTERNAL_APPLE_ADDITIONAL_CLIENT_IDS && + !EXTERNAL_APPLE_CLIENT_ID + ) + }, + then: (schema) => + schema.matches( + /^$/, + 'Secret Key should only be set if Service ID for OAuth is provided.' + ), + } + ), + EXTERNAL_APPLE_CLIENT_ID: string().matches( + /^[a-z0-9.-]+$/i, + 'Invalid characters. Apple recommends a reverse-domain name style string (e.g. com.example.app).' + ), + EXTERNAL_APPLE_ADDITIONAL_CLIENT_IDS: string() + .matches( + /^([.a-z0-9-]+(,\s*[.a-z0-9-]+)*,*\s*)?$/i, + 'Invalid characters. Apple recommends a reverse-domain name style string (e.g. com.example.app). You must only use explicit bundle IDs, asterisks (*) are not allowed.' + ) + .when(['EXTERNAL_APPLE_ENABLED', 'EXTERNAL_APPLE_CLIENT_ID'], { + is: (EXTERNAL_APPLE_ENABLED: boolean, EXTERNAL_APPLE_CLIENT_ID: string) => { + return EXTERNAL_APPLE_ENABLED && !EXTERNAL_APPLE_CLIENT_ID + }, + then: (schema) => + schema.required( + 'At least one Authorized Client ID is required when not using the OAuth flow.' + ), + }), + }), misc: { iconKey: 'apple-icon', requiresRedirect: true, - helper: `To complete setup, add this authorisation callback URL to your app's configuration in the Apple Developer Console. + helper: `Register this callback URL when using Sign in with Apple on the web in the Apple Developer Center. [Learn more](https://supabase.com/docs/guides/auth/social-login/auth-apple#configure-your-services-id)`, alert: { - title: `Apple secrets will self expire every 6 months`, - description: `You will need to regenerate before the 6 months elapses otherwise your users using Apple Login will no longer be able to log back in.`, + title: `Apple OAuth secret keys expire every 6 months`, + description: `A new secret should be generated every 6 months, otherwise users on the web will not be able to sign in.`, }, }, } @@ -630,36 +756,133 @@ const EXTERNAL_PROVIDER_GOOGLE = { title: 'Google', properties: { EXTERNAL_GOOGLE_ENABLED: { - title: 'Google enabled', + title: 'Enable Sign in with Google', + description: + 'Enables Sign in with Google on the web using OAuth or One Tap, or in Android apps or Chrome extensions.', + type: 'boolean', + }, + EXTERNAL_GOOGLE_CLIENT_ID: { + title: 'Client ID (for OAuth)', + description: 'Client ID to use with the OAuth flow on the web.', + type: 'string', + }, + EXTERNAL_GOOGLE_SECRET: { + title: 'Client Secret (for OAuth)', + description: 'Client Secret to use with the OAuth flow on the web.', + type: 'string', + isSecret: true, + }, + EXTERNAL_GOOGLE_ADDITIONAL_CLIENT_IDS: { + title: 'Authorized Client IDs (for Android, One Tap, and Chrome extensions)', + description: + 'Comma separated list of client IDs of Android apps, One Tap or Chrome extensions that are allowed to log in to your project.', + type: 'string', + }, + }, + validationSchema: object().shape({ + EXTERNAL_GOOGLE_ENABLED: boolean().required(), + EXTERNAL_GOOGLE_SECRET: string() + .when(['EXTERNAL_GOOGLE_ENABLED', 'EXTERNAL_GOOGLE_CLIENT_ID'], { + is: (EXTERNAL_GOOGLE_ENABLED: boolean, EXTERNAL_GOOGLE_CLIENT_ID: string) => { + return EXTERNAL_GOOGLE_ENABLED && !!EXTERNAL_GOOGLE_CLIENT_ID + }, + then: (schema) => + schema + .matches( + /^[a-z0-9.\/_-]*$/i, + 'Invalid characters. Google OAuth Client Secrets usually contain letters, numbers, dots, dashes and underscores.' + ) + .required('Client Secret is required when using the OAuth flow.'), + }) + .when( + [ + 'EXTERNAL_GOOGLE_ENABLED', + 'EXTERNAL_GOOGLE_ADDITIONAL_CLIENT_IDS', + 'EXTERNAL_GOOGLE_CLIENT_ID', + ], + { + is: ( + EXTERNAL_GOOGLE_ENABLED: boolean, + EXTERNAL_GOOGLE_ADDITIONAL_CLIENT_IDS: string, + EXTERNAL_GOOGLE_CLIENT_ID: string + ) => { + return ( + EXTERNAL_GOOGLE_ENABLED && + !!EXTERNAL_GOOGLE_ADDITIONAL_CLIENT_IDS && + !EXTERNAL_GOOGLE_CLIENT_ID + ) + }, + then: (schema) => + schema.matches( + /^$/, + 'Client Secret should only be set when Client ID for OAuth is set.' + ), + } + ), + EXTERNAL_GOOGLE_CLIENT_ID: string().matches( + /^([a-z0-9-]+([.][a-z0-9-]+)+)?$/i, + 'Invalid characters. Google OAuth Client IDs are usually a domain-name (e.g. 01234567890-abcdefghijklmnopqrstuvwxyz012345.apps.googleusercontent.com).' + ), + EXTERNAL_GOOGLE_ADDITIONAL_CLIENT_IDS: string() + .matches( + /^([a-z0-9-]+([.][a-z0-9-]+)*(,\s*[a-z0-9-]+([.][a-z0-9-]+)*)*,*\s*)?$/i, + 'Invalid characters. Google Client IDs are usually a domain-name style string (e.g. com.example.com.app or *.apps.googleusercontent.com).' + ) + + .when(['EXTERNAL_GOOGLE_ENABLED', 'EXTERNAL_GOOGLE_CLIENT_ID'], { + is: (EXTERNAL_GOOGLE_ENABLED: boolean, EXTERNAL_GOOGLE_CLIENT_ID: string) => { + return EXTERNAL_GOOGLE_ENABLED && !EXTERNAL_GOOGLE_CLIENT_ID + }, + then: (schema) => + schema.required( + 'At least one Authorized Client ID is required when not using the OAuth flow.' + ), + }), + }), + misc: { + iconKey: 'google-icon', + requiresRedirect: true, + helper: `Register this callback URL when using Sign-in with Google on the web using OAuth. + [Learn more](https://supabase.com/docs/guides/auth/social-login/auth-apple#configure-your-services-id)`, + }, +} + +const EXTERNAL_PROVIDER_KAKAO = { + $schema: JSON_SCHEMA_VERSION, + type: 'object', + title: 'Kakao', + properties: { + EXTERNAL_KAKAO_ENABLED: { + title: 'Kakao enabled', type: 'boolean', }, // [TODO] Update docs - EXTERNAL_GOOGLE_CLIENT_ID: { - title: 'Client ID', + EXTERNAL_KAKAO_CLIENT_ID: { + title: 'REST API Key', type: 'string', }, // [TODO] Update docs - EXTERNAL_GOOGLE_SECRET: { - title: 'Client Secret', + EXTERNAL_KAKAO_SECRET: { + title: 'Client Secret Code', type: 'string', isSecret: true, }, }, validationSchema: object().shape({ - EXTERNAL_GOOGLE_ENABLED: boolean().required(), - EXTERNAL_GOOGLE_CLIENT_ID: string().when('EXTERNAL_GOOGLE_ENABLED', { + EXTERNAL_KAKAO_ENABLED: boolean().required(), + EXTERNAL_KAKAO_CLIENT_ID: string().when('EXTERNAL_KAKAO_ENABLED', { is: true, - then: (schema) => schema.required('Client ID is required'), + then: (schema) => schema.required('REST API Key is required'), otherwise: (schema) => schema, }), - EXTERNAL_GOOGLE_SECRET: string().when('EXTERNAL_GOOGLE_ENABLED', { + EXTERNAL_KAKAO_SECRET: string().when('EXTERNAL_KAKAO_ENABLED', { is: true, - then: (schema) => schema.required('Client Secret is required'), + then: (schema) => schema.required('Client Secret Code is required'), otherwise: (schema) => schema, }), }), misc: { - iconKey: 'google-icon', + iconKey: 'kakao-icon', requiresRedirect: true, }, } @@ -1063,6 +1286,7 @@ export const PROVIDERS_SCHEMAS = [ EXTERNAL_PROVIDER_GITHUB, EXTERNAL_PROVIDER_GITLAB, EXTERNAL_PROVIDER_GOOGLE, + EXTERNAL_PROVIDER_KAKAO, EXTERNAL_PROVIDER_KEYCLOAK, EXTERNAL_PROVIDER_LINKEDIN, EXTERNAL_PROVIDER_NOTION, diff --git a/studio/stores/jsonSchema/auth_gotrue_config.json b/studio/stores/jsonSchema/auth_gotrue_config.json index a0d00a3deb2..9d820c8d39b 100644 --- a/studio/stores/jsonSchema/auth_gotrue_config.json +++ b/studio/stores/jsonSchema/auth_gotrue_config.json @@ -17,6 +17,7 @@ "EXTERNAL_GITHUB_ENABLED", "EXTERNAL_GITLAB_ENABLED", "EXTERNAL_GOOGLE_ENABLED", + "EXTERNAL_KAKAO_ENABLED", "EXTERNAL_KEYCLOAK_ENABLED", "EXTERNAL_LINKEDIN_ENABLED", "EXTERNAL_NOTION_ENABLED", @@ -196,6 +197,18 @@ "title": "Google secret", "type": "string" }, + "EXTERNAL_KAKAO_ENABLED": { + "title": "Kakao enabled", + "type": "boolean" + }, + "EXTERNAL_KAKAO_CLIENT_ID": { + "title": "Kakao client ID", + "type": "string" + }, + "EXTERNAL_KAKAO_SECRET": { + "title": "Kakao secret", + "type": "string" + }, "EXTERNAL_KEYCLOAK_ENABLED": { "title": "Keycloak enabled", "type": "boolean" @@ -443,6 +456,10 @@ "title": "Body", "type": "string" }, + "MFA_MAX_ENROLLED_FACTORS": { + "title": "Maximum enrolled factors", + "type": "integer" + } "PASSWORD_MIN_LENGTH": { "title": "Minimum password length", "type": "integer" @@ -526,6 +543,19 @@ "type": "string", "help": "Twilio message service SID or twilio phone number" }, + "SMS_TWILIO_VERIFY_ACCOUNT_SID": { + "title": "Twilio account SID", + "type": "string" + }, + "SMS_TWILIO_VERIFY_AUTH_TOKEN": { + "title": "Twilio Verify auth token", + "type": "string" + }, + "SMS_TWILIO_VERIFY_MESSAGE_SERVICE_SID": { + "title": "Twilio Verify service SID", + "type": "string", + "help": "Twilio Verify service SID" + }, "SMS_MESSAGEBIRD_ACCESS_KEY": { "title": "Messagebird access key", "type": "string" diff --git a/studio/stores/project/VaultStore.ts b/studio/stores/project/VaultStore.ts index a803ff64dbc..03aa9f72ab3 100644 --- a/studio/stores/project/VaultStore.ts +++ b/studio/stores/project/VaultStore.ts @@ -133,7 +133,7 @@ export default class VaultStore implements IVaultStore { } async encryptColumn(column: PostgresColumn, keyId: string) { - const query = `security label for pgsodium on column "${column.table}"."${column.name}" is 'ENCRYPT WITH KEY ID ${keyId}';` + const query = `security label for pgsodium on column "${column.table}"."${column.name}" is 'ENCRYPT WITH KEY ID ${keyId} SECURITY INVOKER';` return await this.rootStore.meta.query(query) } diff --git a/studio/styles/main.scss b/studio/styles/main.scss index 70970273ab4..344bc70f909 100644 --- a/studio/styles/main.scss +++ b/studio/styles/main.scss @@ -150,6 +150,13 @@ a { } } +.input-xs { + input, + textarea { + @apply text-xs; + } +} + input[type='radio'] { @apply p-0; } diff --git a/studio/tests/pages/projects/billing/Infrastructure.test.tsx b/studio/tests/pages/projects/billing/Infrastructure.test.tsx new file mode 100644 index 00000000000..f28fdb3522d --- /dev/null +++ b/studio/tests/pages/projects/billing/Infrastructure.test.tsx @@ -0,0 +1,24 @@ +import { get } from 'lib/common/fetch' +import { render } from '../../../helpers' +import { screen } from '@testing-library/react' +import Infrastructure from 'components/interfaces/BillingV2/Usage/Infrastructure' +beforeEach(() => { + // reset mocks between tests + ;(get as jest.Mock).mockReset() + ;(get as jest.Mock).mockImplementation(async (_url: string) => { + return [{ result: [] }] + }) +}) + +test(`renders category attributes static elements`, async () => { + render( + + ) + // renders usage categories info + await screen.findByText('Max CPU usage of your server') +}) diff --git a/studio/tests/pages/projects/reports/api-report.test.js b/studio/tests/pages/projects/reports/api-report.test.js index dec669278d7..1e172271d99 100644 --- a/studio/tests/pages/projects/reports/api-report.test.js +++ b/studio/tests/pages/projects/reports/api-report.test.js @@ -15,6 +15,7 @@ test(`static elements`, async () => { await screen.findByText('Total Requests') await screen.findByText('Response Errors') await screen.findByText('Response Speed') + await screen.findByText('Network Traffic') await screen.findByText(/Last 24 hours/) await screen.findByText(/Custom/) }) diff --git a/studio/types/base.ts b/studio/types/base.ts index 2288045ca18..53bef164213 100644 --- a/studio/types/base.ts +++ b/studio/types/base.ts @@ -79,12 +79,17 @@ export interface Permission { resources: string[] } -export interface ResponseError { - message: string -} - export interface ResponseFailure { error: ResponseError } +// [Joshen] This should ideally be | but not changing now as its gonna cause a lot of build errors export type SupaResponse = T & ResponseFailure +// [Joshen] Use this for now, eventually deprecate SupaResponse above +export type SupaResponseV2 = T | ResponseFailure + +export interface ResponseError { + code: number + message: string + requestId: string +}