diff --git a/apps/docs/components/FrameworkQuickstarts.tsx b/apps/docs/components/FrameworkQuickstarts.tsx index d4939b72455a7..398b099f5de59 100644 --- a/apps/docs/components/FrameworkQuickstarts.tsx +++ b/apps/docs/components/FrameworkQuickstarts.tsx @@ -47,6 +47,21 @@ const frameworks = [ icon: '/docs/img/icons/svelte-icon', href: '/guides/getting-started/quickstarts/sveltekit', }, + { + name: 'SolidJS', + icon: '/docs/img/icons/solidjs-icon', + href: '/guides/getting-started/quickstarts/solidjs', + }, + { + name: 'RedwoodJS', + icon: '/docs/img/icons/redwood-icon', + href: '/guides/getting-started/quickstarts/redwoodjs', + }, + { + name: 'Refine', + icon: '/docs/img/icons/refine-icon', + href: '/guides/getting-started/quickstarts/refine', + }, { name: 'Hono', icon: '/docs/img/icons/hono-icon', @@ -81,6 +96,16 @@ const frameworks = [ icon: '/docs/img/icons/python-icon', href: '/guides/getting-started/quickstarts/flask', }, + { + name: 'Laravel', + icon: '/docs/img/icons/laravel-icon', + href: '/guides/getting-started/quickstarts/laravel', + }, + { + name: 'Ruby on Rails', + icon: '/docs/img/icons/rails-icon', + href: '/guides/getting-started/quickstarts/ruby-on-rails', + }, ] export function FrameworkQuickstarts({ labelledBy }: { labelledBy?: string }) { diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 6f13f250734c7..940b6da11ea11 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -2772,6 +2772,10 @@ export const platform: NavMenuConstant = { name: 'Testing and Best Practices', url: '/guides/platform/sso/testing-best-practices' as `/${string}`, }, + { + name: 'Enterprise-Managed Authentication for MCP', + url: '/guides/platform/sso/enterprise-mcp-authentication' as `/${string}`, + }, ], }, ], diff --git a/apps/docs/content/_partials/quickstart_ai_tooling.mdx b/apps/docs/content/_partials/quickstart_ai_tooling.mdx new file mode 100644 index 0000000000000..2a66a6d3f72a2 --- /dev/null +++ b/apps/docs/content/_partials/quickstart_ai_tooling.mdx @@ -0,0 +1,19 @@ +Supabase provides two ways to give AI tools context about your project: Agent Skills, which give your AI coding agent procedural knowledge, and the MCP server, which connects AI assistants to your Supabase project directly. + +### Agent Skills + +[Agent Skills](/docs/guides/ai-tools/ai-skills) is a curated set of instructions that give your AI agent procedural knowledge about working with Supabase. + +Install them so your AI coding agent can produce more accurate, reliable code using current Supabase patterns, such as authentication, server-side rendering, and database migrations, rather than relying solely on training data. + +#### Installing Agent Skills + +To install, run the following command in the root of your project: + +```bash +npx skills add supabase/agent-skills +``` + +### Supabase MCP server + +The Supabase MCP server connects AI assistants to Supabase, so they can inspect your schema and act on your projects on your behalf. Find out how to add it to your client in [the MCP docs](/docs/guides/ai-tools/mcp). diff --git a/apps/docs/content/_partials/quickstart_connection_string.mdx b/apps/docs/content/_partials/quickstart_connection_string.mdx new file mode 100644 index 0000000000000..48422f629c2e7 --- /dev/null +++ b/apps/docs/content/_partials/quickstart_connection_string.mdx @@ -0,0 +1,13 @@ +1. Navigate to your project dashboard and click on [Connect](/dashboard/project/_?showConnect=true&connectTab=direct&method=session). + + + + Don't use the Transaction pooler (port `6543`) as your app's main data source. Most ORMs rely on server-side prepared statements, which the Transaction pooler doesn't support. Use the Session pooler (port `5432`), or the direct connection string if you're in an [IPv6 environment](/docs/guides/troubleshooting/supabase--your-network-ipv4-and-ipv6-compatibility-cHe3BP) or have the [IPv4 Add-On](/docs/guides/platform/ipv4-address). + + + +1. Look for the **Session pooler** connection string and copy it. Replace the password placeholder with your saved database password, and [percent-encode](https://en.wikipedia.org/wiki/Percent-encoding) any reserved characters it contains, such as `&`, `#`, `?`, or a space. If you don't have your database password, you can reset it in your [Database Settings](/dashboard/project/_/database/settings). + +1. Set `sslmode=require` either on the connection string itself or as an explicit config option if your framework sets it separately. Most drivers default to `prefer`, which falls back to sending your data in plaintext if the encrypted attempt fails. You can also [enforce SSL](/docs/guides/platform/ssl-enforcement) on the database side. + +The connection strings below show the format only. Take the host, port, and username from the string you copied rather than typing the bracketed placeholders literally. diff --git a/apps/docs/content/_partials/quickstart_going_to_production.mdx b/apps/docs/content/_partials/quickstart_going_to_production.mdx new file mode 100644 index 0000000000000..a3df52188463e --- /dev/null +++ b/apps/docs/content/_partials/quickstart_going_to_production.mdx @@ -0,0 +1,9 @@ +## Production requirements + +The quickstart procedure in this guide optimizes for getting you to a working app, not for production. + +Before you deploy: + +- If your app reads or writes through the Data API, review your [Row Level Security](/docs/guides/database/postgres/row-level-security) policies. Any policy you added here is scoped to this quickstart's sample data, not to real user data. +- Set your Supabase credentials as environment variables on whatever platform you deploy to, rather than committing them to source control. +- Configure a [custom domain](/docs/guides/platform/custom-domains) for your Supabase project once you're ready to go live. diff --git a/apps/docs/content/_partials/quickstart_mobile_env_note.mdx b/apps/docs/content/_partials/quickstart_mobile_env_note.mdx new file mode 100644 index 0000000000000..7aee6d8e073b1 --- /dev/null +++ b/apps/docs/content/_partials/quickstart_mobile_env_note.mdx @@ -0,0 +1,5 @@ + + +This guide substitutes your project URL and key directly into the code above, rather than reading them from a `.env` file. Mobile apps don't get environment variables injected at runtime the way a bundler-based web app does. You'd need a build-time mechanism specific to your toolchain, such as `--dart-define-from-file` for Flutter, an `.xcconfig` file for iOS, or a `Gradle` `BuildConfig` field for Android. That's a good next step once you're past this quickstart, so your keys aren't committed to source control. + + diff --git a/apps/docs/content/guides/getting-started/quickstarts/_template.mdx b/apps/docs/content/guides/getting-started/quickstarts/_template.mdx new file mode 100644 index 0000000000000..5a448263a533d --- /dev/null +++ b/apps/docs/content/guides/getting-started/quickstarts/_template.mdx @@ -0,0 +1,154 @@ +This file is a reference contract for framework quickstarts in this directory. It is +not a rendered page (filenames starting with `_` are excluded from the docs build +and from `supa-mdx-lint`) — it exists so every quickstart conforms to the same shape, +and so Phase 3's lint rule has a single source to check against. + +## Required frontmatter + +```yaml +--- +title: 'Use Supabase with ' +subtitle: '' +breadcrumb: 'Framework Quickstarts' +--- +``` + +## Required section order + +Before the numbered steps, and before any heading: + +- `` — always first. Every id must exist as a key in + `apps/docs/data/ai-prompts.data.ts`. +- An optional `## Prerequisites` section, for guides whose toolchain isn't implied + by the framework itself. `spring-boot.mdx` is the current example: Java 17, + `curl`, `unzip`. Don't add one to restate the obvious. + +The list below is the canonical order, not the literal heading numbers. +`quickstart_db_setup.mdx` supplies headings 1 and 2, so guides that use it start +their own headings at 3. Guides that use `quickstart_create_project.mdx` alone get +heading 1 from the partial and start at 2. A guide may also insert a +framework-specific step — `astrojs.mdx` adds **Configure Astro for SSR** between the +client library and the environment variables — so number each guide's headings +sequentially from where its partial leaves off rather than copying numbers from here. + +1. **Create a Supabase project** — via `<$Partial path="quickstart_create_project.mdx" />`, + either directly or nested inside `quickstart_db_setup.mdx` (see below). + - **Set up your database** (also numbered step 2, replacing the above) — only + for guides that query the shared `instruments` sample table through a + Supabase client library. Use `<$Partial path="quickstart_db_setup.mdx" />` + instead (it nests the project-creation partial). Guides that connect + directly to Postgres with their own ORM (Laravel, Rails, RedwoodJS, Spring + Boot) skip this and use `quickstart_create_project.mdx` alone — add a + one-line note stating the guide uses the framework's own tables instead, so + the omission reads as deliberate rather than a gap. +2. **Create a `` app** +3. **Set up AI tooling (optional)** — `<$Partial path="quickstart_ai_tooling.mdx" />`. + Covers both Agent Skills and the MCP server in one step. Keep them together: + two adjacent optional AI steps push the first real Supabase code further down + the page for no reader benefit, and the prose is identical across all 19 guides, + so it lives in the partial rather than being copied per guide. +4. **Install the Supabase client library** + - Guides that start from a scaffold which already depends on `supabase-js` + keep the step but retitle it to what the reader actually does. `hono.mdx` + uses **Install dependencies**, because `npx supabase bootstrap hono` already + lists the packages in `package.json` and the reader only runs `npm install`. + `nextjs.mdx` drops the step entirely, because the `with-supabase` template + installs them as part of step 3. +5. **Declare Supabase environment variables** — env vars only, never literal + credentials in code. Mobile guides (Flutter, iOS SwiftUI, Kotlin) are the + documented exception — they use `YOUR_SUPABASE_URL` / `YOUR_SUPABASE_PUBLISHABLE_KEY` + placeholder substitution instead of a `.env` file, with + `<$Partial path="quickstart_mobile_env_note.mdx" />` explaining why. Include the + ` <$Partial path="api_settings.mdx" variables={{ "framework": "", "tab": "" }} /> -## 7. Start the app +## 7. Set up anonymous sign-ins + +This app signs users in anonymously, so [enable anonymous sign-ins](/dashboard/project/_/auth/providers) in the Auth settings. -Start the app, go to http://localhost:5173. +Anonymous sign-ins use the `authenticated` role, but the database setup in step 2 grants read access to the `anon` role only. Without the privilege and a matching policy for `authenticated`, the instruments query returns no rows. Run the following in the [SQL Editor](/dashboard/project/_/sql/new) to grant the privilege and add the policy: -Learn how [server side auth](/docs/guides/auth/server-side/creating-a-client?queryGroups=framework&framework=hono) works with Hono. +```sql SQL_EDITOR +grant select on public.instruments to authenticated; + +create policy "authenticated can read instruments" +on public.instruments +for select to authenticated +using (true); +``` + +## 8. Query data from the app + +The bootstrapped app already includes the route that reads your `instruments` table, in `src/index.tsx`. The middleware in `src/middleware/auth.middleware.ts` creates a request-scoped Supabase client, so `getSupabase(c)` returns a client that already carries the signed-in user's auth token. Your RLS policies apply to the query. + +```tsx name=src/index.tsx +app.get('/instruments', async (c) => { + const supabase = getSupabase(c) + const { data, error } = await supabase.from('instruments').select('*') + + if (error) { + console.error(error) + return c.json({ error: error.message }, 500) + } + + return c.json(data) +}) +``` + +## 9. Start the app + +Start the app, go to http://localhost:5173, sign in anonymously, then open the instruments list. ```bash npm run dev ``` +<$Partial path="quickstart_going_to_production.mdx" /> + ## Next steps - Learn how [server side auth](/docs/guides/auth/server-side/creating-a-client?queryGroups=framework&framework=hono) works with Hono. diff --git a/apps/docs/content/guides/getting-started/quickstarts/ios-swiftui.mdx b/apps/docs/content/guides/getting-started/quickstarts/ios-swiftui.mdx index 99ed3f1d3516f..ab5adf65d3d17 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/ios-swiftui.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/ios-swiftui.mdx @@ -12,17 +12,9 @@ breadcrumb: 'Framework Quickstarts' Select the **Xcode > New Project > iOS > App** menu item. -## 4. Install Supabase's Agent Skills (optional) +## 4. Set up AI tooling (optional) -Supabase's [Agent Skills](/docs/guides/ai-tools/ai-skills) is a curated set of instructions that give your AI agent procedural knowledge about working with Supabase. - -Install them so your AI coding agent can produce more accurate, reliable code using current Supabase patterns, such as authentication, server-side rendering, and database migrations, rather than relying solely on training data. - -To install, run the following command in the root of your project: - -```bash -npx skills add supabase/agent-skills -``` +<$Partial path="quickstart_ai_tooling.mdx" /> ## 5. Install the Supabase client library @@ -34,7 +26,7 @@ Make sure to add `Supabase` product package as a dependency to your application ## 6. Initialize the Supabase client -Create a new `Supabase.swift` file add a new Supabase instance using your project URL and publishable key, which you can get from the helper below, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true&framework=swift&connectTab=mobiles): +Create a new `Supabase.swift` file and initialize a Supabase client using your project URL and publishable key, which you can get from the helper below, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true&framework=swift&connectTab=mobiles): ```kotlin +import io.github.jan.supabase.postgrest.from import ... val supabase = createSupabaseClient( - supabaseUrl = "https://xyzcompany.supabase.co", - supabaseKey = "your_publishable_key" + supabaseUrl = "YOUR_SUPABASE_URL", + supabaseKey = "YOUR_SUPABASE_PUBLISHABLE_KEY" ) { install(Postgrest) } @@ -88,6 +97,8 @@ val supabase = createSupabaseClient( <$Partial path="api_settings.mdx" variables={{ "framework": "androidkotlin", "tab": "mobiles" }} /> +<$Partial path="quickstart_mobile_env_note.mdx" /> + ## 8. Create a data model for instruments Create a serializable data class to represent the data from the database. @@ -95,6 +106,8 @@ Create a serializable data class to represent the data from the database. Add the following below the `createSupabaseClient` function in the `MainActivity.kt` file. ```kotlin +import kotlinx.serialization.Serializable + @Serializable data class Instrument( val id: Int, @@ -114,19 +127,23 @@ This example application makes a network request from the UI code. In production + + +This snippet omits the app-specific theme wrapper that Android Studio generates (named after your project, e.g. `Theme`), so it compiles regardless of what you named your project. Wrap the `Surface` in your generated theme composable from `ui/theme/Theme.kt` if you want your project's Material theme applied. + + + ```kotlin class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - SupabaseTutorialTheme { - // A surface container using the 'background' color from the theme - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - InstrumentsList() - } + // A surface container using the 'background' color from the theme + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + InstrumentsList() } } } @@ -135,12 +152,24 @@ class MainActivity : ComponentActivity() { @Composable fun InstrumentsList() { var instruments by remember { mutableStateOf>(listOf()) } + var error by remember { mutableStateOf(null) } LaunchedEffect(Unit) { withContext(Dispatchers.IO) { - instruments = supabase.from("instruments") - .select().decodeList() + try { + instruments = supabase.from("instruments") + .select().decodeList() + } catch (e: Exception) { + error = e.message + } } } + if (error != null) { + Text( + "Error loading instruments: $error", + modifier = Modifier.padding(8.dp), + ) + return + } LazyColumn { items( instruments, @@ -159,6 +188,8 @@ fun InstrumentsList() { Run the app on an emulator or a physical device by clicking the `Run app` button in Android Studio. +<$Partial path="quickstart_going_to_production.mdx" /> + ## Next steps - Learn how to build a complete user management app with authentication in the [Kotlin tutorial](/docs/guides/getting-started/tutorials/with-kotlin) diff --git a/apps/docs/content/guides/getting-started/quickstarts/laravel.mdx b/apps/docs/content/guides/getting-started/quickstarts/laravel.mdx index 9e4d868bc29a1..cce73dd279023 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/laravel.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/laravel.mdx @@ -6,65 +6,61 @@ breadcrumb: 'Framework Quickstarts' -<$Partial path="quickstart_db_setup.mdx" /> +<$Partial path="quickstart_create_project.mdx" /> -## 3. Create a Laravel project +Save your database password securely. You need it for the connection string. -Make sure your PHP and Composer versions are up to date, then use `composer create-project` to scaffold a new Laravel project. - -See the [Laravel docs](https://laravel.com/docs/10.x/installation#creating-a-laravel-project) for more details. + -```bash -composer create-project laravel/laravel example-app -``` +This guide uses Laravel's own database tables (via Breeze and Eloquent), not the shared `instruments` sample table used by other quickstarts. -## 4. Install Supabase's Agent Skills (optional) + -Supabase's [Agent Skills](/docs/guides/ai-tools/ai-skills) is a curated set of instructions that give your AI agent procedural knowledge about working with Supabase. +## 2. Create a Laravel project -Install them so your AI coding agent can produce more accurate, reliable code using current Supabase patterns, such as authentication, server-side rendering, and database migrations, rather than relying solely on training data. +Make sure your PHP and Composer versions are up to date, then use `composer create-project` to scaffold a new Laravel project. -To install, run the following command in the root of your project: +See the [Laravel docs](https://laravel.com/docs/12.x/installation#creating-a-laravel-project) for more details. ```bash -npx skills add supabase/agent-skills +composer create-project laravel/laravel example-app ``` -## 5. Install the authentication template +## 3. Set up AI tooling (optional) + +<$Partial path="quickstart_ai_tooling.mdx" /> + +## 4. Install the authentication template -Install [Laravel Breeze](https://laravel.com/docs/10.x/starter-kits#laravel-breeze), a basic implementation of all of Laravel's [authentication features](https://laravel.com/docs/10.x/authentication). +Install [Laravel Breeze](https://github.com/laravel/breeze), a basic implementation of all of Laravel's [authentication features](https://laravel.com/docs/12.x/authentication). It ships the migrations that the next step runs against your Supabase database. ```bash composer require laravel/breeze --dev -php artisan breeze:install +php artisan breeze:install blade ``` -## 6. Set up the Postgres connection details +Pass a stack name such as `blade`, `react`, or `vue` when prompted. The example above uses Blade templates. -Navigate to your project dashboard and click on [Connect](/dashboard/project/_?showConnect=true&connectTab=direct&method=session). +## 5. Set up the Postgres connection details -Look for the Session Pooler connection string and copy the string. You will need to replace the Password with your saved database password. You can reset your database password in your [Database Settings](/dashboard/project/_/database/settings) if you do not have it. - - - -If you're in an [IPv6 environment](https://github.com/orgs/supabase/discussions/27034) or have the IPv4 Add-On, you can use the direct connection string instead of Supavisor in Session mode. - - +<$Partial path="quickstart_connection_string.mdx" /> ```bash name=.env DB_CONNECTION=pgsql -DB_URL=postgres://postgres.[PROJECT-REF]:[YOUR-PASSWORD]@aws-[REGION].pooler.supabase.com:5432/postgres +DB_URL=postgres://postgres.[PROJECT-REF]:[YOUR-PASSWORD]@[POOLER-HOST]:5432/postgres ``` -## 7. Change the default schema +Laravel sets `sslmode` as an explicit `config/database.php` option rather than a URL parameter. The next step covers this. + +## 6. Change the default schema By default Laravel uses the `public` schema. We recommend changing this as Supabase exposes the `public` schema as a [data API](/docs/guides/api). -You can change the schema of your Laravel application by modifying the `search_path` variable `app/config/database.php`. +You can change the schema of your Laravel application by modifying the `search_path` variable in `config/database.php`. The schema you specify in `search_path` has to exist on Supabase. You can create a new schema from the [Table Editor](/dashboard/project/_/editor). -```php name=app/config/database.php +```php name=config/database.php 'pgsql' => [ 'driver' => 'pgsql', 'url' => env('DB_URL'), @@ -83,17 +79,21 @@ The schema you specify in `search_path` has to exist on Supabase. You can create Laravel ships with `sslmode` set to `prefer`, which sends your data in plaintext if the encrypted attempt fails. Set it to `require` so the connection fails instead. You can also [enforce SSL](/docs/guides/platform/ssl-enforcement) on the database side. -## 8. Run the database migrations +## 7. Run the database migrations Laravel ships with database migration files that set up the required tables for Laravel Authentication and User Management. -Note: Laravel does not use Supabase Auth but rather implements its own authentication system! + + +Laravel implements its own authentication system rather than using Supabase Auth. Your users are stored in Laravel's `users` table, not in Supabase Auth. + + ```bash php artisan migrate ``` -## 9. Start the app +## 8. Start the app Run the development server. Go to http://127.0.0.1:8000 in a browser to see your application. You can also navigate to http://127.0.0.1:8000/register and http://127.0.0.1:8000/login to register and log in users. @@ -101,6 +101,8 @@ Run the development server. Go to http://127.0.0.1:8000 in a browser to see your php artisan serve ``` +<$Partial path="quickstart_going_to_production.mdx" /> + ## Next steps - Learn more about [Supabase Auth](/docs/guides/auth) if you want to replace Laravel's built-in authentication diff --git a/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx b/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx index af16da8b6cbcc..9ca784530e1f2 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx @@ -13,20 +13,12 @@ breadcrumb: 'Framework Quickstarts' Use the `create-next-app` command and the `with-supabase` template, to create a Next.js app pre-configured with [Cookie-based Auth](/docs/guides/auth/server-side/creating-a-client?queryGroups=package-manager&package-manager=npm&queryGroups=framework&framework=nextjs&queryGroups=environment&environment=server), [TypeScript](https://www.typescriptlang.org/), and [Tailwind CSS](https://tailwindcss.com/). ```bash -npx create-next-app -e with-supabase +npx create-next-app@latest my-app -e with-supabase ``` -## 4. Install Supabase's Agent Skills (optional) +## 4. Set up AI tooling (optional) -Supabase's [Agent Skills](/docs/guides/ai-tools/ai-skills) is a curated set of instructions that give your AI agent procedural knowledge about working with Supabase. - -Install them so your AI coding agent can produce more accurate, reliable code using current Supabase patterns, such as authentication, server-side rendering, and database migrations, rather than relying solely on training data. - -To install, run the following command in the root of your project: - -```bash -npx skills add supabase/agent-skills -``` +<$Partial path="quickstart_ai_tooling.mdx" /> ## 5. Declare Supabase environment variables @@ -45,7 +37,37 @@ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= <$Partial path="api_settings.mdx" variables={{ "framework": "nextjs", "tab": "frameworks" }} /> -## 6. Query Supabase data from Next.js +## 6. Allow public access to the instruments page + +The `with-supabase` template redirects unauthenticated visitors to the login page for most routes. The `instruments` table is publicly readable, so update `lib/supabase/proxy.ts` to skip that redirect for `/instruments`. + +Find this `if` statement: + +```ts name=lib/supabase/proxy.ts + if ( + request.nextUrl.pathname !== "/" && + !user && + !request.nextUrl.pathname.startsWith("/login") && + !request.nextUrl.pathname.startsWith("/auth") + ) { +``` + +Add a condition for `/instruments`: + +```ts name=lib/supabase/proxy.ts + if ( + request.nextUrl.pathname !== "/" && + !user && + !request.nextUrl.pathname.startsWith("/login") && + !request.nextUrl.pathname.startsWith("/auth") && + request.nextUrl.pathname !== "/instruments" && + !request.nextUrl.pathname.startsWith("/instruments/") + ) { +``` + +## 7. Query Supabase data from Next.js + +The `with-supabase` template already installs `@supabase/supabase-js` and `@supabase/ssr` and creates the clients for you, in `lib/supabase/client.ts` for the browser and `lib/supabase/server.ts` for Server Components. The code below imports the server client from there. Create a new file at `app/instruments/page.tsx` and populate with the following. @@ -59,7 +81,11 @@ import { Suspense } from "react"; async function InstrumentsData() { const supabase = await createClient(); - const { data: instruments } = await supabase.from("instruments").select(); + const { data: instruments, error } = await supabase.from("instruments").select(); + + if (error) { + return

Error loading instruments: {error.message}

; + } return
{JSON.stringify(instruments, null, 2)}
; } @@ -75,7 +101,7 @@ export default function Instruments() { -## 7. Start the app +## 8. Start the app Run the development server, go to http://localhost:3000/instruments in a browser and you should see the list of instruments. @@ -83,9 +109,11 @@ Run the development server, go to http://localhost:3000/instruments in a browser npm run dev ``` +<$Partial path="quickstart_going_to_production.mdx" /> + ## Next steps -- Explore [drop-in UI components](/ui) for your Supabase app - Set up [Auth](/docs/guides/auth) for your app - [Insert more data](/docs/guides/database/import-data) into your database - Upload and serve static files using [Storage](/docs/guides/storage) +- Explore [drop-in UI components](/ui) for your Supabase app diff --git a/apps/docs/content/guides/getting-started/quickstarts/nuxtjs.mdx b/apps/docs/content/guides/getting-started/quickstarts/nuxtjs.mdx index f8db14935c5af..3d282e492b9e7 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/nuxtjs.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/nuxtjs.mdx @@ -16,18 +16,20 @@ Create a Nuxt app using the `npx nuxi` command. npx nuxi@latest init my-app ``` -## 4. Install Supabase's Agent Skills (optional) - -Supabase's [Agent Skills](/docs/guides/ai-tools/ai-skills) is a curated set of instructions that give your AI agent procedural knowledge about working with Supabase. - -Install them so your AI coding agent can produce more accurate, reliable code using current Supabase patterns, such as authentication, server-side rendering, and database migrations, rather than relying solely on training data. + -To install, run the following command in the root of your project: +The CLI prompts for a template, a package manager, and whether to initialize a git repository. Choose a minimal template, or pass flags to skip the prompts (required in non-interactive shells): ```bash -npx skills add supabase/agent-skills +npx nuxi@latest init my-app --template minimal --no-gitInit --packageManager npm ``` + + +## 4. Set up AI tooling (optional) + +<$Partial path="quickstart_ai_tooling.mdx" /> + ## 5. Install the Supabase client library The fastest way to get started is to use the `supabase-js` client library which provides a convenient interface for working with Supabase from a Nuxt app. @@ -70,20 +72,37 @@ export default defineNuxtConfig({ <$Partial path="api_settings.mdx" variables={{ "framework": "nuxt", "tab": "frameworks" }} /> -## 7. Query data from the app +## 7. Create the Supabase client -In `app.vue`, create a Supabase client using your config values and replace the existing content with the following code. +Create a composable at `app/composables/useSupabase.ts` that builds the client from your config values. `useRuntimeConfig()` is only available inside a Nuxt context, such as a composable or a component's `setup`, so the client is created there rather than at the top level of a module. Nuxt auto-imports anything in `app/composables/`, so you don't need to import `useSupabase` where you use it. -```vue name=app.vue - ``` -## 8. Start the app + + +This example fetches data in `onMounted`, so the instrument list appears after the page loads in the browser. + + + +## 9. Start the app Start the app, navigate to http://localhost:3000 in the browser, and you should see the list of instruments. @@ -113,9 +139,11 @@ The community-maintained [@nuxtjs/supabase](https://supabase.nuxtjs.org/) module +<$Partial path="quickstart_going_to_production.mdx" /> + ## Next steps -- Explore [drop-in UI components](/ui) for your Supabase app - Set up [Auth](/docs/guides/auth) for your app - [Insert more data](/docs/guides/database/import-data) into your database - Upload and serve static files using [Storage](/docs/guides/storage) +- Explore [drop-in UI components](/ui) for your Supabase app diff --git a/apps/docs/content/guides/getting-started/quickstarts/reactjs.mdx b/apps/docs/content/guides/getting-started/quickstarts/reactjs.mdx index 03ba4f2da3f40..804bf92b4345e 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/reactjs.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/reactjs.mdx @@ -16,17 +16,9 @@ Create a React app using a [Vite](https://vitejs.dev/guide/) template. npm create vite@latest my-app -- --template react ``` -## 4. Install Supabase's Agent Skills (optional) +## 4. Set up AI tooling (optional) -Supabase's [Agent Skills](/docs/guides/ai-tools/ai-skills) is a curated set of instructions that give your AI agent procedural knowledge about working with Supabase. - -Install them so your AI coding agent can produce more accurate, reliable code using current Supabase patterns, such as authentication, server-side rendering, and database migrations, rather than relying solely on training data. - -To install, run the following command in the root of your project: - -```bash -npx skills add supabase/agent-skills -``` +<$Partial path="quickstart_ai_tooling.mdx" /> ## 5. Install the Supabase client library @@ -55,18 +47,27 @@ VITE_SUPABASE_PUBLISHABLE_KEY= <$Partial path="api_settings.mdx" variables={{ "framework": "react", "tab": "frameworks" }} /> -## 7. Query data from the app +## 7. Create the Supabase client -Replace the contents of `App.jsx` with a `getInstruments` function that fetches the data and displays the query result on the page using a Supabase client. +Create a `src/lib` directory in your React app, create a file called `supabaseClient.js`, and add the following code to initialize the Supabase client: -```js name=src/App.jsx +```js name=src/lib/supabaseClient.js import { createClient } from '@supabase/supabase-js' + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL +const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY + +export const supabase = createClient(supabaseUrl, supabasePublishableKey) +``` + +## 8. Query data from the app + +Replace the contents of `App.jsx` with a `getInstruments` function that fetches the data and displays the query result on the page. + +```js name=src/App.jsx import { useEffect, useState } from 'react' -const supabase = createClient( - import.meta.env.VITE_SUPABASE_URL, - import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY -) +import { supabase } from './lib/supabaseClient' function App() { const [instruments, setInstruments] = useState([]) @@ -89,7 +90,7 @@ function App() { return (
    {instruments.map((instrument) => ( -
  • {instrument.name}
  • +
  • {instrument.name}
  • ))}
) @@ -98,7 +99,7 @@ function App() { export default App ``` -## 8. Start the app +## 9. Start the app Run the development server, go to http://localhost:5173 in a browser, and you should see the list of instruments. @@ -106,9 +107,11 @@ Run the development server, go to http://localhost:5173 in a browser, and you sh npm run dev ``` +<$Partial path="quickstart_going_to_production.mdx" /> + ## Next steps -- Explore [drop-in UI components](/ui) for your Supabase app - Set up [Auth](/docs/guides/auth) for your app - [Insert more data](/docs/guides/database/import-data) into your database - Upload and serve static files using [Storage](/docs/guides/storage) +- Explore [drop-in UI components](/ui) for your Supabase app diff --git a/apps/docs/content/guides/getting-started/quickstarts/redwoodjs.mdx b/apps/docs/content/guides/getting-started/quickstarts/redwoodjs.mdx index 4de8629573052..5ece18913c99e 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/redwoodjs.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/redwoodjs.mdx @@ -10,6 +10,12 @@ breadcrumb: 'Framework Quickstarts' Save your database password securely. You need it for the connection string. + + +This quickstart uses Prisma migrations against your Postgres database. Use a **dedicated Supabase project** (or an empty database) so Prisma does not try to reconcile tables created by other apps or quickstarts. + + + ## 2. Gather database connection strings Open the project [**Connect** panel](/dashboard/project/_?showConnect=true&connectTab=direct). This quickstart connects using the [**Transaction pooler**](/dashboard/project/_?showConnect=true&connectTab=direct&method=transaction) and [**Session pooler**](/dashboard/project/_?showConnect=true&connectTab=direct&method=session) mode. Transaction mode is used for application queries and Session mode is used for running migrations with Prisma. @@ -18,7 +24,7 @@ To do this, set the connection mode to `Transaction` in the [Database Settings p To get the Session mode connection pooler string, change the port of the connection string from the dashboard to 5432. -You will need the Transaction mode connection string and the Session mode connection string to set up environment variables in Step 6. +You will need the Transaction mode connection string and the Session mode connection string to set up environment variables in Step 5. @@ -34,31 +40,21 @@ Create a RedwoodJS app with TypeScript. The [`yarn` package manager](https://yarnpkg.com) is required to create a RedwoodJS app. You will use it to run RedwoodJS commands later. -While TypeScript is recommended, If you want a JavaScript app, omit the `--ts` flag. - - - -```bash -yarn create redwood-app my-app --ts -``` +While TypeScript is recommended, if you want a JavaScript app, omit the `--ts` flag. -## 4. Install Supabase's Agent Skills (optional) +RedwoodJS 8.x officially supports Node `20.x`. On Node 22 or later, `create-redwood-app` may prompt you to override the version check. Select **Override error and continue install**, or switch to Node 20 with a version manager such as [`nvm`](https://github.com/nvm-sh/nvm). -Supabase's [Agent Skills](/docs/guides/ai-tools/ai-skills) is a curated set of instructions that give your AI agent procedural knowledge about working with Supabase. - -Install them so your AI coding agent can produce more accurate, reliable code using current Supabase patterns, such as authentication, server-side rendering, and database migrations, rather than relying solely on training data. - -To install, run the following command in the root of your project: + ```bash -npx skills add supabase/agent-skills +yarn create redwood-app my-app --ts --git-init false ``` -## 5. Install MCP server (optional) +## 4. Set up AI tooling (optional) -The Supabase MCP server connects AI assistants to Supabase, allowing you to interact with your projects on your behalf. Find out more on how to add it to your client in [the MCP docs](/docs/guides/ai-tools/mcp). +<$Partial path="quickstart_ai_tooling.mdx" /> -## 6. Configure environment variables +## 5. Configure environment variables In your `.env` file, add the following environment variables for your database connection: @@ -67,18 +63,18 @@ In your `.env` file, add the following environment variables for your database c - The `DIRECT_URL` should use the Session mode connection string you copied in Step 2. ```bash name=.env -# Transaction mode connection string — used by Prisma Client for app queries -DATABASE_URL="postgres://postgres.[PROJECT-REF]:[YOUR-PASSWORD]@aws-[REGION].pooler.supabase.com:6543/postgres?pgbouncer=true&connection_limit=1" +# Transaction mode connection string for Prisma Client app queries +DATABASE_URL="postgres://postgres.[PROJECT-REF]:[YOUR-PASSWORD]@[POOLER-HOST]:6543/postgres?pgbouncer=true&connection_limit=1" -# Session mode connection string — used by Prisma Migrate -DIRECT_URL="postgres://postgres.[PROJECT-REF]:[YOUR-PASSWORD]@aws-[REGION].pooler.supabase.com:5432/postgres" +# Session mode connection string for Prisma Migrate +DIRECT_URL="postgres://postgres.[PROJECT-REF]:[YOUR-PASSWORD]@[POOLER-HOST]:5432/postgres" ``` -## 7. Update your Prisma schema +## 6. Update your Prisma schema By default, RedwoodJS ships with a SQLite database, but we want to use Postgres. -Update your Prisma schema file `api/db/schema.prisma` to use your Supabase Postgres database connection environment variables you set up in Step 6. +Update your Prisma schema file `api/db/schema.prisma` to use your Supabase Postgres database connection environment variables you set up in Step 5. ```prisma name=api/db/schema.prisma datasource db { @@ -88,10 +84,16 @@ datasource db { } ``` -## 8. Create the instrument model and apply a schema migration +## 7. Create the instrument model and apply a schema migration Create the Instrument model in `api/db/schema.prisma` and then run `yarn rw prisma migrate dev` from your terminal to apply the migration. + + +`yarn rw prisma migrate dev` requires an interactive terminal. It prompts for a migration name and cannot run in fully non-interactive CI shells. + + + ```prisma name=api/db/schema.prisma model Instrument { id Int @id @default(autoincrement()) @@ -99,7 +101,7 @@ model Instrument { } ``` -## 9. Update seed script +## 8. Update seed script Seed the database with a few instruments. @@ -128,7 +130,7 @@ export default async () => { } ``` -## 10. Seed your database +## 9. Seed your database Run the seed database command to populate the `Instrument` table with the instruments you created. @@ -142,7 +144,7 @@ The reset database command `yarn rw prisma db reset` recreates the tables and al yarn rw prisma db seed ``` -## 11. Scaffold the instrument UI +## 10. Scaffold the instrument UI Use RedwoodJS generators to scaffold a CRUD UI for the `Instrument` model. @@ -150,16 +152,18 @@ Use RedwoodJS generators to scaffold a CRUD UI for the `Instrument` model. yarn rw g scaffold instrument ``` -## 12. Start the app +## 11. Start the app Start the app via `yarn rw dev`. A browser will open to the RedwoodJS Splash page. -## 13. View instruments UI +## 12. View instruments UI Click on `/instruments` to visit http://localhost:8910/instruments where should see the list of instruments. You may now edit, delete, and add new instruments using the scaffolded UI. +<$Partial path="quickstart_going_to_production.mdx" /> + ## Next steps - Set up [Auth](/docs/guides/auth) for your app diff --git a/apps/docs/content/guides/getting-started/quickstarts/refine.mdx b/apps/docs/content/guides/getting-started/quickstarts/refine.mdx index d36cd46a83cd3..1d6921561d508 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/refine.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/refine.mdx @@ -18,18 +18,22 @@ The `refine-supabase` preset adds `@refinedev/supabase` supplementary package th npm create refine-app@latest -- --preset refine-supabase my-app ``` -## 4. Install Supabase's Agent Skills (optional) - -Supabase's [Agent Skills](/docs/guides/ai-tools/ai-skills) is a curated set of instructions that give your AI agent procedural knowledge about working with Supabase. + -Install them so your AI coding agent can produce more accurate, reliable code using current Supabase patterns, such as authentication, server-side rendering, and database migrations, rather than relying solely on training data. +The CLI may prompt for an email address. The `refine-supabase` preset also ships with demo Supabase credentials. Replace them in step 5 with your own project. -To install, run the following command in the root of your project: +To skip the email prompt in a non-interactive shell, pipe a blank line: ```bash -npx skills add supabase/agent-skills +printf '\n' | npm create refine-app@latest -- --preset refine-supabase my-app ``` + + +## 4. Set up AI tooling (optional) + +<$Partial path="quickstart_ai_tooling.mdx" /> + ## 5. Update `supabaseClient` with environment variables Create a `.env` file and populate it with your Supabase URL and publishable key, which you can get from the helper below, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true&framework=refine&connectTab=frameworks). @@ -45,24 +49,15 @@ VITE_SUPABASE_URL= VITE_SUPABASE_PUBLISHABLE_KEY= ``` -Update `src/utility/supabaseClient.ts` to read the URL and publishable key from these environment variables. The `supabaseClient` is used in auth provider and data provider methods that allow the Refine app to connect to your Supabase backend. - -```ts name=src/utility/supabaseClient.ts -import { createClient } from '@refinedev/supabase' +The `refine-supabase` preset hardcodes Refine's own demo Supabase project in `src/providers/constants.ts`, and initializes the client from it in `src/providers/supabase-client.ts`. Replace the hardcoded values so the client reads your project credentials from the environment variables above instead: -const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL -const SUPABASE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY - -export const supabaseClient = createClient(SUPABASE_URL, SUPABASE_KEY, { - db: { - schema: 'public', - }, - auth: { - persistSession: true, - }, -}) +```ts name=src/providers/constants.ts +export const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL +export const SUPABASE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY ``` +The `supabaseClient` is used by the auth and data providers to connect your Refine app to Supabase. + <$Partial path="api_settings.mdx" variables={{ "framework": "refine", "tab": "frameworks" }} /> ## 6. Add instruments resource and pages @@ -71,7 +66,11 @@ Use the following code to automatically add resources and generate code for the This defines pages for `list`, `create`, `show` and `edit` actions inside the `src/pages/instruments/` directory with a `` component. -The `` component depends on `@refinedev/react-table` and `@refinedev/react-hook-form` packages. To avoid errors, you should install them as dependencies with `npm install @refinedev/react-table @refinedev/react-hook-form`. +The `` component depends on `@refinedev/react-table`, `@refinedev/react-hook-form`, and `react-live` packages. To avoid errors, install them as dependencies: + +```bash +npm install @refinedev/react-table @refinedev/react-hook-form react-live +``` @@ -103,26 +102,27 @@ import routerProvider, { NavigateToResource, UnsavedChangesNotifier, } from '@refinedev/react-router' -import { dataProvider, liveProvider } from '@refinedev/supabase' -import { BrowserRouter, Route, Routes } from 'react-router-dom' +import { liveProvider } from '@refinedev/supabase' +import { BrowserRouter, Route, Routes } from 'react-router' import './App.css' -import authProvider from './authProvider' +import authProvider from './providers/auth' +import { dataProvider } from './providers/data' +import { supabaseClient } from './providers/supabase-client' import { InstrumentsCreate, InstrumentsEdit, InstrumentsList, InstrumentsShow, } from './pages/instruments' -import { supabaseClient } from './utility' function App() { return ( + +These policies let anyone with your publishable key modify the `instruments` table. They exist so you can try the scaffolded UI against sample data. Scope writes to authenticated users before you put real data in this table. + + + +## 9. Start the app + +Run the development server, then open `/instruments` in your browser (Vite defaults to http://localhost:5173). You should see the instruments pages along the `/instruments` routes. You can edit and add new instruments using the Inferencer-generated UI. ```bash npm run dev @@ -171,6 +203,8 @@ npm run dev The Inferencer auto-generated code gives you a good starting point on which to keep building your `list`, `create`, `show` and `edit` pages. You can get these by clicking the `Show the auto-generated code` buttons in their respective pages. +<$Partial path="quickstart_going_to_production.mdx" /> + ## Next steps - Set up [Auth](/docs/guides/auth) for your app diff --git a/apps/docs/content/guides/getting-started/quickstarts/ruby-on-rails.mdx b/apps/docs/content/guides/getting-started/quickstarts/ruby-on-rails.mdx index dc79d96a58e7c..15d7a9122fca8 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/ruby-on-rails.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/ruby-on-rails.mdx @@ -10,6 +10,12 @@ breadcrumb: 'Framework Quickstarts' Save your database password securely. You need it for the connection string. + + +This guide uses Rails' own Active Record models and migrations, not the shared `instruments` sample table used by other quickstarts. + + + ## 2. Create a Rails project With your Ruby and Rails versions up to date, run `rails new` on your terminal to scaffold a new project. @@ -23,41 +29,21 @@ rails new blog -d=postgresql cd blog ``` -## 3. Install Supabase's Agent Skills (optional) +## 3. Set up AI tooling (optional) -Supabase's [Agent Skills](/docs/guides/ai-tools/ai-skills) is a curated set of instructions that give your AI agent procedural knowledge about working with Supabase. - -Install them so your AI coding agent can produce more accurate, reliable code using current Supabase patterns, such as authentication, server-side rendering, and database migrations, rather than relying solely on training data. - -To install, run the following command in the root of your project: - -```bash -npx skills add supabase/agent-skills -``` +<$Partial path="quickstart_ai_tooling.mdx" /> -## 4. Install MCP server (optional) +## 4. Set up the Postgres connection details -The Supabase MCP server connects AI assistants to Supabase, allowing you to interact with your projects on your behalf. Find out more on how to add it to your client in [the MCP docs](/docs/guides/ai-tools/mcp). +<$Partial path="quickstart_connection_string.mdx" /> -## 5. Set up the Postgres connection details - -Navigate to your project dashboard and click on [Connect](/dashboard/project/_?showConnect=true&connectTab=direct&method=session). - -Look for the Session Pooler connection string and copy the string. You will need to replace the Password with your saved database password, and [percent-encode](https://en.wikipedia.org/wiki/Percent-encoding) any reserved characters it contains, such as `&`, `#`, `?`, or a space. You can reset your database password in your [Database Settings](/dashboard/project/_/database/settings) if you do not have it. - - - -If you're in an [IPv6 environment](https://github.com/orgs/supabase/discussions/27034) or have the IPv4 Add-On, you can use the direct connection string instead of Supavisor in Session mode. - - - -Set the connection string as an environment variable. Rails reads `DATABASE_URL` from the environment and connects with it, so you don't need to edit `config/database.yml`. The export applies to the current shell session, so run it in the same shell as the Rails commands in the following steps. `sslmode=require` stops the driver from falling back to sending your data in plaintext. +Set the connection string as an environment variable. Rails reads `DATABASE_URL` from the environment and connects with it, so you don't need to edit `config/database.yml`. The export applies to the current shell session, so run it in the same shell as the Rails commands in the following steps. ```bash -export DATABASE_URL=postgres://postgres.[PROJECT-REF]:[YOUR-PASSWORD]@aws-[REGION].pooler.supabase.com:5432/postgres?sslmode=require +export DATABASE_URL=postgres://postgres.[PROJECT-REF]:[YOUR-PASSWORD]@[POOLER-HOST]:5432/postgres?sslmode=require ``` -## 6. Create and run a database migration +## 5. Create and run a database migration Rails includes Active Record as the ORM as well as database migration tooling which generates the SQL migration files for you. @@ -68,7 +54,7 @@ bin/rails generate model Article title:string body:text bin/rails db:migrate ``` -## 7. Use the model to interact with the database +## 6. Use the model to interact with the database You can use the included Rails console to interact with the database. For example, you can create new entries or list all entries in a Model's table. @@ -83,7 +69,7 @@ article.save # Saves the entry to the database Article.all ``` -## 8. Start the app +## 7. Start the app Run the development server. Go to http://127.0.0.1:3000 in a browser to see your application running. @@ -91,6 +77,8 @@ Run the development server. Go to http://127.0.0.1:3000 in a browser to see your bin/rails server ``` +<$Partial path="quickstart_going_to_production.mdx" /> + ## Next steps - Set up [Auth](/docs/guides/auth) for your app diff --git a/apps/docs/content/guides/getting-started/quickstarts/solidjs.mdx b/apps/docs/content/guides/getting-started/quickstarts/solidjs.mdx index 5b6b966247194..be6fc3f1eca0b 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/solidjs.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/solidjs.mdx @@ -13,21 +13,19 @@ breadcrumb: 'Framework Quickstarts' Create a SolidJS app using the `degit` command. ```bash -npx degit solidjs/templates/js my-app +npx degit solidjs/templates/vanilla/basic my-app ``` -## 4. Install Supabase's Agent Skills (optional) - -Supabase's [Agent Skills](/docs/guides/ai-tools/ai-skills) is a curated set of instructions that give your AI agent procedural knowledge about working with Supabase. - -Install them so your AI coding agent can produce more accurate, reliable code using current Supabase patterns, such as authentication, server-side rendering, and database migrations, rather than relying solely on training data. - -To install, run the following command in the root of your project: +The template ships with a `pnpm-lock.yaml`. Remove it so `npm install` in the next steps doesn't create a second, conflicting lockfile: ```bash -npx skills add supabase/agent-skills +rm my-app/pnpm-lock.yaml ``` +## 4. Set up AI tooling (optional) + +<$Partial path="quickstart_ai_tooling.mdx" /> + ## 5. Install the Supabase client library The fastest way to get started is to use the `supabase-js` client library which provides a convenient interface for working with Supabase from a SolidJS app. @@ -55,23 +53,35 @@ VITE_SUPABASE_PUBLISHABLE_KEY= <$Partial path="api_settings.mdx" variables={{ "framework": "solidjs", "tab": "frameworks" }} /> -## 7. Query data from the app - -In `App.jsx`, create a Supabase client to fetch the instruments data. +## 7. Create the Supabase client -Add a `getInstruments` function to fetch the data and display the query result to the page. +Create a `src/lib` directory in your SolidJS app, create a file called `supabaseClient.ts`, and add the following code to initialize the Supabase client: -```jsx name=src/App.jsx +```ts name=src/lib/supabaseClient.ts import { createClient } from '@supabase/supabase-js' -import { createResource, For } from 'solid-js' -const supabase = createClient( - import.meta.env.VITE_SUPABASE_URL, - import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY -) +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL +const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY + +export const supabase = createClient(supabaseUrl, supabasePublishableKey) +``` + +## 8. Query data from the app + +In `src/App.tsx`, add a `getInstruments` function to fetch the data and display the query result to the page. + +```tsx name=src/App.tsx +import { createResource, For, Show } from 'solid-js' + +import { supabase } from './lib/supabaseClient' async function getInstruments() { - const { data } = await supabase.from('instruments').select() + const { data, error } = await supabase.from('instruments').select() + + if (error) { + throw error + } + return data } @@ -79,16 +89,21 @@ function App() { const [instruments] = createResource(getInstruments) return ( -
    - {(instrument) =>
  • {instrument.name}
  • }
    -
+ Error loading instruments: {instruments.error?.message}

} + > +
    + {(instrument) =>
  • {instrument.name}
  • }
    +
+
) } export default App ``` -## 8. Start the app +## 9. Start the app Start the app and go to http://localhost:3000 in a browser and you should see the list of instruments. @@ -96,6 +111,8 @@ Start the app and go to http://localhost:3000 in a browser and you should see th npm run dev ``` +<$Partial path="quickstart_going_to_production.mdx" /> + ## Next steps - Set up [Auth](/docs/guides/auth) for your app diff --git a/apps/docs/content/guides/getting-started/quickstarts/spring-boot.mdx b/apps/docs/content/guides/getting-started/quickstarts/spring-boot.mdx index 6093d91292a3e..84e57cdec8101 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/spring-boot.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/spring-boot.mdx @@ -17,6 +17,12 @@ Before you begin, make sure you have: Save your database password securely. You need it for the connection string. + + +This guide uses Spring Boot's own JPA entities and generated schema, not the shared `instruments` sample table used by other quickstarts. + + + ## 2. Create a Spring Boot project Use [Spring Initializr](https://start.spring.io) to scaffold a new project with the Web, Spring Data JPA, and Postgres Driver dependencies. Run the following from the directory where you keep your projects. @@ -33,44 +39,22 @@ curl https://start.spring.io/starter.zip \ unzip instruments.zip -d instruments && cd instruments ``` -## 3. Install Supabase's Agent Skills (optional) - -Supabase's [Agent Skills](/docs/guides/ai-tools/ai-skills) is a curated set of instructions that give your AI agent procedural knowledge about working with Supabase. - -Install them so your AI coding agent can produce more accurate, reliable code using current Supabase patterns, such as authentication, server-side rendering, and database migrations, rather than relying solely on training data. +## 3. Set up AI tooling (optional) -To install, run the following command in the root of your project: - -```bash -npx skills add supabase/agent-skills -``` +<$Partial path="quickstart_ai_tooling.mdx" /> ## 4. Set up the Postgres connection details -Navigate to your project dashboard and click on [Connect](/dashboard/project/_?showConnect=true&connectTab=direct&method=session). - - - -The Transaction pooler (port `6543`) doesn't work as your app's main data source, because Spring Data JPA uses Hibernate, which relies on server-side prepared statements. Use the Session pooler, or the direct connection string if you're in an [IPv6 environment](/docs/guides/troubleshooting/supabase--your-network-ipv4-and-ipv6-compatibility-cHe3BP) or have the [IPv4 Add-On](/docs/guides/platform/ipv4-address). - - - -Under the **Session pooler** (port `5432`), select the **JDBC** tab and copy the connection string. Replace the password placeholder with your saved database password, and [percent-encode](https://en.wikipedia.org/wiki/Percent-encoding) any reserved characters it contains, such as `&`, `#`, `?`, or a space. - - +<$Partial path="quickstart_connection_string.mdx" /> -You can reset your database password in your [Database Settings](/dashboard/project/_/database/settings) if you do not have it. - - +Select the **JDBC** tab to copy the connection string in the right format for Spring Boot. The connection string contains your database password, and `application.properties` is committed with your project. Set the string as an environment variable instead, and set it the same way on whatever platform you deploy to. ```bash -export SUPABASE_DB_URL='jdbc:postgresql://aws-[REGION].pooler.supabase.com:5432/postgres?user=postgres.[PROJECT-REF]&password=[YOUR-PASSWORD]&sslmode=require' +export SUPABASE_DB_URL='jdbc:postgresql://[POOLER-HOST]:5432/postgres?user=postgres.[PROJECT-REF]&password=[YOUR-PASSWORD]&sslmode=require' ``` -The string you copied doesn't set `sslmode`, so add it. The driver defaults to `prefer`, which falls back to sending your data in plaintext if the encrypted attempt fails. You can also [enforce SSL](/docs/guides/platform/ssl-enforcement) on the database side. - Then reference the variable, along with the driver, in `src/main/resources/application.properties`. ```text name=src/main/resources/application.properties @@ -85,7 +69,13 @@ If the app fails to start with `Unable to determine Dialect without JDBC metadat By default Hibernate creates tables in the `public` schema. We recommend changing this as Supabase exposes the `public` schema as a [data API](/docs/guides/api). -Create the schema from the [Table Editor](/dashboard/project/_/editor) as your app will need it before start. Then point **Hibernate** at it in `application.properties`. +Create the `app` schema before you start the app. Hibernate creates tables in that schema on startup, but it does not create the schema itself. Run the following in the [SQL Editor](/dashboard/project/_/sql/new): + +```sql SQL_EDITOR +create schema if not exists app; +``` + +Then point Hibernate at the schema in `application.properties`. ```text name=src/main/resources/application.properties spring.jpa.properties.hibernate.default_schema=app @@ -212,9 +202,11 @@ Run the Spring Boot app, and go to http://localhost:8080/instruments in your bro ./mvnw spring-boot:run ``` +<$Partial path="quickstart_going_to_production.mdx" /> + ## Next steps - Set up [Auth](/docs/guides/auth) for your app -- Replace `ddl-auto` with [database migrations](/docs/guides/deployment/database-migrations) before going to production - [Insert more data](/docs/guides/database/import-data) into your database - Upload and serve static files using [Storage](/docs/guides/storage) +- Replace `ddl-auto` with [database migrations](/docs/guides/deployment/database-migrations) before going to production diff --git a/apps/docs/content/guides/getting-started/quickstarts/sveltekit.mdx b/apps/docs/content/guides/getting-started/quickstarts/sveltekit.mdx index adc04d1e0c3b0..d512f518dc0cc 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/sveltekit.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/sveltekit.mdx @@ -10,23 +10,15 @@ breadcrumb: 'Framework Quickstarts' ## 3. Create a SvelteKit app -Create a SvelteKit app using the `npm create` command. +Create a SvelteKit app using the `sv` CLI. ```bash npx sv create my-app ``` -## 4. Install Supabase's Agent Skills (optional) +## 4. Set up AI tooling (optional) -Supabase's [Agent Skills](/docs/guides/ai-tools/ai-skills) is a curated set of instructions that give your AI agent procedural knowledge about working with Supabase. - -Install them so your AI coding agent can produce more accurate, reliable code using current Supabase patterns, such as authentication, server-side rendering, and database migrations, rather than relying solely on training data. - -To install, run the following command in the root of your project: - -```bash -npx skills add supabase/agent-skills -``` +<$Partial path="quickstart_ai_tooling.mdx" /> ## 5. Install the Supabase client library @@ -89,9 +81,16 @@ Create `+page.server.js` file in the `src/routes` directory with the following c import { supabase } from '$lib/supabaseClient' export async function load() { - const { data } = await supabase.from('instruments').select() + const { data, error } = await supabase.from('instruments').select() + + if (error) { + console.error('Error loading instruments:', error.message) + return { instruments: [], error: error.message } + } + return { instruments: data ?? [], + error: null, } } ``` @@ -107,15 +106,16 @@ type Instrument = { } export const load: PageServerLoad = async () => { - const { data, error } = await supabase.from('instruments').select<'instruments', Instrument>() + const { data, error } = await supabase.from('instruments').select<'*', Instrument>() if (error) { console.error('Error loading instruments:', error.message) - return { instruments: [] } + return { instruments: [], error: error.message } } return { instruments: data ?? [], + error: null, } } ``` @@ -129,11 +129,15 @@ Replace the existing content in your `+page.svelte` file in the `src/routes` dir let { data } = $props(); -
    - {#each data.instruments as instrument} -
  • {instrument.name}
  • - {/each} -
+{#if data.error} +

Error loading instruments: {data.error}

+{:else} +
    + {#each data.instruments as instrument} +
  • {instrument.name}
  • + {/each} +
+{/if} ``` ## 9. Start the app @@ -144,6 +148,8 @@ Start the app and go to http://localhost:5173 in a browser and you should see th npm run dev ``` +<$Partial path="quickstart_going_to_production.mdx" /> + ## Next steps - Set up [Auth](/docs/guides/auth) for your app diff --git a/apps/docs/content/guides/getting-started/quickstarts/tanstack.mdx b/apps/docs/content/guides/getting-started/quickstarts/tanstack.mdx index ff9a466be8017..9d0f220885f99 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/tanstack.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/tanstack.mdx @@ -16,17 +16,9 @@ Create a TanStack Start app using the official CLI. npx @tanstack/cli@latest create my-app ``` -## 4. Install Supabase's Agent Skills (optional) +## 4. Set up AI tooling (optional) -Supabase's [Agent Skills](/docs/guides/ai-tools/ai-skills) is a curated set of instructions that give your AI agent procedural knowledge about working with Supabase. - -Install them so your AI coding agent can produce more accurate, reliable code using current Supabase patterns, such as authentication, server-side rendering, and database migrations, rather than relying solely on training data. - -To install, run the following command in the root of your project: - -```bash -npx skills add supabase/agent-skills -``` +<$Partial path="quickstart_ai_tooling.mdx" /> ## 5. Install the Supabase client libraries @@ -57,8 +49,10 @@ VITE_SUPABASE_PUBLISHABLE_KEY= TanStack Start needs two Supabase clients: a browser client for components that run in the browser, and a server client for loaders and server functions. Create a `src/lib/supabase` folder with a file for each client. +Both clients read the same two variables, through the API available in each environment. The browser client uses `import.meta.env`, which Vite replaces at build time. The server client uses `process.env`, which the server runtime populates from your `.env.local` file. + ```ts name=src/lib/supabase/client.ts -/// +/// import { createBrowserClient } from '@supabase/ssr' export function createClient() { @@ -99,29 +93,49 @@ export function createClient() { ## 8. Query Supabase data from TanStack Start -Replace the contents of `src/routes/index.tsx` with the following to add a loader that queries the `instruments` table through the server client. The loader runs on the server, so the data is part of the initial server-rendered response. +Create a server function that queries the `instruments` table through the server client. TanStack Start's import protection blocks direct server imports in route files, so wrap the Supabase call in `createServerFn`. + +```ts name=src/lib/supabase/fetch-instruments-server-fn.ts +import { createServerFn } from '@tanstack/react-start' + +import { createClient } from '@/lib/supabase/server' + +export const fetchInstruments = createServerFn({ method: 'GET' }).handler(async () => { + const supabase = createClient() + const { data: instruments, error } = await supabase.from('instruments').select() + + if (error) { + console.error(error) + return { instruments: [], error: error.message } + } + + return { instruments, error: null } +}) +``` + +Replace the contents of `src/routes/index.tsx` with the following to call the server function from a route loader. The loader runs on the server, so the data is part of the initial server-rendered response. ```tsx name=src/routes/index.tsx import { createFileRoute } from '@tanstack/react-router' -import { createClient } from '@/lib/supabase/server' +import { fetchInstruments } from '@/lib/supabase/fetch-instruments-server-fn' export const Route = createFileRoute('/')({ - loader: async () => { - const supabase = createClient() - const { data: instruments } = await supabase.from('instruments').select() - return { instruments } - }, + loader: async () => fetchInstruments(), component: Home, }) function Home() { - const { instruments } = Route.useLoaderData() + const { instruments, error } = Route.useLoaderData() + + if (error) { + return

Error loading instruments: {error}

+ } return (
    {instruments?.map((instrument) => ( -
  • {instrument.name}
  • +
  • {instrument.name}
  • ))}
) @@ -136,10 +150,11 @@ Run the development server, go to http://localhost:3000 in a browser and you sho npm run dev ``` +<$Partial path="quickstart_going_to_production.mdx" /> + ## Next steps -- Learn how to [protect routes and check sessions](/docs/guides/auth/server-side/creating-a-client?queryGroups=framework&framework=tanstack) with the server client -- Set up a complete [login and sign-up flow](/library/docs/tanstack/password-based-auth) from Supabase Library -- Explore [drop-in UI components](/ui) for your Supabase app +- Learn how to [protect routes and check sessions](/docs/guides/auth/server-side/creating-a-client?queryGroups=framework&framework=tanstack) with the server client, or drop in a complete [login and sign-up flow](/library/docs/tanstack/password-based-auth) from Supabase Library - [Insert more data](/docs/guides/database/import-data) into your database - Upload and serve static files using [Storage](/docs/guides/storage) +- Explore [drop-in UI components](/ui) for your Supabase app diff --git a/apps/docs/content/guides/getting-started/quickstarts/vue.mdx b/apps/docs/content/guides/getting-started/quickstarts/vue.mdx index ff3b4c06480ef..3de10fe7e2119 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/vue.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/vue.mdx @@ -16,17 +16,9 @@ Create a Vue app using the `npm init` command. npm init vue@latest my-app ``` -## 4. Install Supabase's Agent Skills (optional) +## 4. Set up AI tooling (optional) -Supabase's [Agent Skills](/docs/guides/ai-tools/ai-skills) is a curated set of instructions that give your AI agent procedural knowledge about working with Supabase. - -Install them so your AI coding agent can produce more accurate, reliable code using current Supabase patterns, such as authentication, server-side rendering, and database migrations, rather than relying solely on training data. - -To install, run the following command in the root of your project: - -```bash -npx skills add supabase/agent-skills -``` +<$Partial path="quickstart_ai_tooling.mdx" /> ## 5. Install the Supabase client library @@ -57,9 +49,15 @@ VITE_SUPABASE_PUBLISHABLE_KEY= ## 7. Create the Supabase client -Create a `/src/lib` directory in your Vue app, create a file called `supabaseClient.js` and add the following code to initialize the Supabase client: +Create a `/src/lib` directory in your Vue app, create a file called `supabaseClient.ts` and add the following code to initialize the Supabase client: + + + +`npm init vue@latest` scaffolds a TypeScript project by default. If you chose a JavaScript-only project, use a `.js` extension instead and drop the type import. + + -```js name=src/lib/supabaseClient.js +```ts name=src/lib/supabaseClient.ts import { createClient } from '@supabase/supabase-js' const supabaseUrl = import.meta.env.VITE_SUPABASE_URL @@ -73,15 +71,27 @@ export const supabase = createClient(supabaseUrl, supabasePublishableKey) Replace the existing content in your `App.vue` file with the following code. ```vue name=src/App.vue - @@ -105,9 +116,11 @@ Start the app and go to http://localhost:5173 in a browser and you should see th npm run dev ``` +<$Partial path="quickstart_going_to_production.mdx" /> + ## Next steps -- Explore [drop-in UI components](/ui) for your Supabase app - Set up [Auth](/docs/guides/auth) for your app - [Insert more data](/docs/guides/database/import-data) into your database - Upload and serve static files using [Storage](/docs/guides/storage) +- Explore [drop-in UI components](/ui) for your Supabase app diff --git a/apps/docs/content/guides/platform/sso.mdx b/apps/docs/content/guides/platform/sso.mdx index 1b892d37b3f02..881157813373b 100644 --- a/apps/docs/content/guides/platform/sso.mdx +++ b/apps/docs/content/guides/platform/sso.mdx @@ -158,3 +158,7 @@ Most organizations use a single SSO provider for all users. However, Supabase su - Gradual migration from one identity provider to another If you need to configure multiple SSO providers, refer to the [Multiple SSO Providers](/docs/guides/platform/sso/multiple-providers) guide for detailed configuration steps, and contact your Supabase support representative if you need additional guidance. + +## Enterprise-managed authentication for MCP + +Once SSO is configured, you can let your identity provider automatically authorize MCP clients for your organization, without individual members having to approve each one. See [Enterprise-Managed Authentication for MCP](/docs/guides/platform/sso/enterprise-mcp-authentication) for details. diff --git a/apps/docs/content/guides/platform/sso/enterprise-mcp-authentication.mdx b/apps/docs/content/guides/platform/sso/enterprise-mcp-authentication.mdx new file mode 100644 index 0000000000000..1b887d93a7d30 --- /dev/null +++ b/apps/docs/content/guides/platform/sso/enterprise-mcp-authentication.mdx @@ -0,0 +1,85 @@ +--- +title: 'Enterprise-Managed Authentication for MCP' +description: "Let your identity provider automatically authorize your organization's use of the Supabase MCP Server, without per-user OAuth prompts." +--- + + + +This feature is only available on the [Team and Enterprise Plans](/pricing), and requires [SSO](/docs/guides/platform/sso) to already be configured for your organization. + + + + + +This page covers using the [Supabase MCP Server](/docs/guides/ai-tools/mcp) and connecting AI tools like Cursor or Claude to your Supabase organization and projects. If you're building your own MCP server backed by Supabase Auth, see [Model Context Protocol (MCP) Authentication](/docs/guides/auth/oauth-server/mcp-authentication) instead. + + + +Normally, each member of your organization has to individually sign in and approve their AI tool's connection to the [Supabase MCP Server](/docs/guides/ai-tools/mcp). Enterprise-managed authentication removes that per-user step. Once your identity provider (IdP) and MCP client both trust each other for single sign-on, members get access to the Supabase MCP Server automatically, without a separate approval prompt. + +This is Supabase's implementation of the MCP [Enterprise-Managed Authorization](https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/stable/enterprise-managed-authorization.mdx) extension, which relies on an **ID-JAG** (Identity Assertion JWT Authorization Grant) issued by your IdP. + +## Prerequisites + +Before members can use enterprise-managed authentication: + +1. **SSO must be configured** for your organization. See [Enable SSO for your organization](/docs/guides/platform/sso) if you haven't set this up yet. +2. **Your identity provider must support issuing ID-JAGs** for MCP's Enterprise-Managed Authorization extension. Check with your IdP whether this is available and how to enable it. +3. **The MCP client must be authorized for your organization.** An organization owner does this from [Authorized Apps](/dashboard/org/_/apps) in your organization settings, the same place you manage other third-party integrations. + +## Who's involved + +| Party | Role | +| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| **Identity provider (IdP)** (e.g. Okta) | Signs your members in via SSO, and issues the MCP client an ID-JAG scoped for Supabase when asked | +| **MCP client** | The AI tool your members use. It signs the member in to the IdP, requests the ID-JAG, then presents it to Supabase | +| **Supabase** | Runs the MCP Server your client ultimately calls, and the OAuth server that validates the ID-JAG and issues the access token used to call it | + +## How it works + +1. **Member signs in to the MCP client via your IdP**, using the same SSO login your members already use, over OpenID Connect or SAML. The IdP returns an identity token to the MCP client. +2. **MCP client exchanges that identity token for an ID-JAG, at the IdP.** This is a separate request the client makes to the IdP (an [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange), asking for a token scoped specifically to Supabase. The IdP checks its own policy before issuing one. +3. **IdP returns the ID-JAG to the MCP client.** +4. **MCP client presents the ID-JAG to Supabase's OAuth server**, using it as a [JWT authorization grant](https://datatracker.ietf.org/doc/html/rfc7523), with no interactive consent screen. +5. **Supabase validates the ID-JAG**, checking it against your IdP's public keys, confirming the member belongs to your organization with a matching SSO identity, and confirming an organization owner has authorized this MCP client. If everything checks out, Supabase issues a short-lived access token. +6. **MCP client calls the Supabase MCP Server** using that access token, same as any other authenticated request. + +The access token from step 5 can't be refreshed. The MCP client repeats steps 2 through 5 whenever it needs a new one, and its access is always limited to what your organization's existing permissions already allow. + +## Why use enterprise-managed authentication + +Without it, every member has to manually connect and authorize the MCP client against Supabase, and do it again whenever access expires. At the scale of an organization, this creates: + +- **Onboarding friction**: new members have to discover and go through the approval flow themselves. +- **No central control**: admins can't see or revoke the MCP client's access at the organization level; it's spread across individual user approvals. +- **Inconsistent access**: a member's access through the MCP client isn't guaranteed to stay in sync with the role your IdP already assigns them. + +With enterprise-managed authentication, an organization owner authorizes the MCP client once for the whole organization. From then on, access follows your existing SSO login, with no separate approval and no manual reconnection when a token expires. + +## Configuring your ID-JAG issuer + +Once SSO is set up, an organization owner can add the ID-JAG issuer URL from your identity provider: + +1. Go to the [**SSO**](/dashboard/org/_/sso) page of your organization settings. +2. Open **Advanced settings**. +3. Enter your identity provider's **IDJAG Issuer** URL. + + + +Unlike your SAML metadata, the issuer URL isn't something Supabase can derive automatically: SAML assertions don't carry an equivalent value. Your IdP's documentation for ID-JAG or OIDC will list this as the `iss` claim on the tokens it issues, usually the same base URL used for OIDC discovery. + + + +Once saved, Supabase uses this URL to fetch your IdP's public keys and verify ID-JAGs presented by your MCP client on behalf of your members. + +## Security considerations + +- **Access is always scoped to user.** An ID-JAG can never grant access beyond what the authenticated member already has permission to see. +- **The MCP client must be explicitly authorized.** Adding an ID-JAG issuer alone doesn't grant it access; an organization owner still has to authorize it from [Authorized Apps](/dashboard/org/_/apps). +- **Access tokens are short-lived and non-renewable.** There's no refresh token. +- **Revoke access at the source.** To cut off the MCP client for your whole organization, remove its authorization from [Authorized Apps](/dashboard/org/_/apps). To cut off a single member, remove them in Supabase dashboard. + +## Next steps + +- [Enable SSO for your organization](/docs/guides/platform/sso) +- [Enterprise-Managed Authorization for MCP (blog post)](https://blog.modelcontextprotocol.io/posts/enterprise-managed-auth/) diff --git a/apps/docs/data/content-listings/getting-started.data.ts b/apps/docs/data/content-listings/getting-started.data.ts index 2d5ae192dce75..07e7320a1dfd7 100644 --- a/apps/docs/data/content-listings/getting-started.data.ts +++ b/apps/docs/data/content-listings/getting-started.data.ts @@ -65,7 +65,7 @@ export const gettingStartedFrameworkQuickstarts: ContentListingGroup = { icon: '/docs/img/icons/react-icon', hasLightIcon: false, description: - 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a React app.', + 'Build single-page apps from reusable components, and query Supabase Postgres from the browser.', }, { title: 'Next.js', @@ -73,7 +73,7 @@ export const gettingStartedFrameworkQuickstarts: ContentListingGroup = { icon: '/docs/img/icons/nextjs-icon', hasLightIcon: true, description: - 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a Next.js app.', + 'Full-stack React with server rendering, wired to Supabase Postgres and cookie-based auth.', }, { title: 'Nuxt', @@ -81,7 +81,15 @@ export const gettingStartedFrameworkQuickstarts: ContentListingGroup = { icon: '/docs/img/icons/nuxt-icon', hasLightIcon: false, description: - 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a Nuxt app.', + 'Full-stack Vue with server rendering, reading Postgres through a Supabase composable.', + }, + { + title: 'Astro', + href: '/guides/getting-started/quickstarts/astrojs', + icon: '/docs/img/icons/astro-icon', + hasLightIcon: true, + description: + 'Content-driven sites that render on the server and pull Supabase Postgres data per request.', }, { title: 'Hono', @@ -89,7 +97,7 @@ export const gettingStartedFrameworkQuickstarts: ContentListingGroup = { icon: '/docs/img/icons/hono-icon', hasLightIcon: false, description: - 'Learn how to create a Supabase project, add some sample data to your database, secure it with auth, and query the data from a Hono app.', + 'Lightweight web APIs with Supabase Auth anonymous sign-in and RLS-protected reads.', }, { title: 'RedwoodJS', @@ -97,7 +105,15 @@ export const gettingStartedFrameworkQuickstarts: ContentListingGroup = { icon: '/docs/img/icons/redwood-icon', hasLightIcon: false, description: - 'Learn how to create a Supabase project, add some sample data to your database using Prisma migration and seeds, and query the data from a RedwoodJS app.', + 'Full-stack React and GraphQL, with Prisma migrations against your Supabase Postgres database.', + }, + { + title: 'Expo React Native', + href: '/guides/getting-started/quickstarts/expo-react-native', + icon: '/docs/img/icons/expo-icon', + hasLightIcon: true, + description: + 'Ship iOS and Android from one React Native codebase, backed by Supabase Postgres.', }, { title: 'Flutter', @@ -105,8 +121,7 @@ export const gettingStartedFrameworkQuickstarts: ContentListingGroup = { icon: '/docs/img/icons/flutter-icon', hasLightIcon: false, feature: 'sdk:dart', - description: - 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a Flutter app.', + description: 'Ship iOS and Android from one Dart codebase, backed by Supabase Postgres.', }, { title: 'iOS SwiftUI', @@ -114,8 +129,7 @@ export const gettingStartedFrameworkQuickstarts: ContentListingGroup = { icon: '/docs/img/icons/swift-icon', hasLightIcon: false, feature: 'sdk:swift', - description: - 'Learn how to create a Supabase project, add some sample data to your database, and query the data from an iOS app.', + description: 'Native iOS apps in Swift, reading Postgres through the Supabase Swift SDK.', }, { title: 'Android Kotlin', @@ -124,15 +138,14 @@ export const gettingStartedFrameworkQuickstarts: ContentListingGroup = { hasLightIcon: false, feature: 'sdk:kotlin', description: - 'Learn how to create a Supabase project, add some sample data to your database, and query the data from an Android Kotlin app.', + 'Native Android apps in Kotlin and Jetpack Compose, using the Supabase Kotlin SDK.', }, { title: 'SvelteKit', href: '/guides/getting-started/quickstarts/sveltekit', icon: '/docs/img/icons/svelte-icon', hasLightIcon: false, - description: - 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a SvelteKit app.', + description: 'Full-stack Svelte that loads Supabase Postgres data in server load functions.', }, { title: 'SolidJS', @@ -140,7 +153,7 @@ export const gettingStartedFrameworkQuickstarts: ContentListingGroup = { icon: '/docs/img/icons/solidjs-icon', hasLightIcon: false, description: - 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a SolidJS app.', + 'Fine-grained reactive UIs that load Supabase Postgres data with Solid resources.', }, { title: 'Vue', @@ -148,15 +161,14 @@ export const gettingStartedFrameworkQuickstarts: ContentListingGroup = { icon: '/docs/img/icons/vuejs-icon', hasLightIcon: false, description: - 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a Vue app.', + 'Build single-page apps with the Vue composition API, backed by Supabase Postgres.', }, { title: 'TanStack Start', href: '/guides/getting-started/quickstarts/tanstack', icon: '/docs/img/icons/tanstack-icon', hasLightIcon: true, - description: - 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a TanStack Start app.', + description: 'Type-safe full-stack React that queries Supabase Postgres in server functions.', }, { title: 'Refine', @@ -164,7 +176,30 @@ export const gettingStartedFrameworkQuickstarts: ContentListingGroup = { icon: '/docs/img/icons/refine-icon', hasLightIcon: false, description: - 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a Refine app.', + 'Scaffold CRUD dashboards and admin panels straight from your Supabase Postgres tables.', + }, + { + title: 'Python', + href: '/guides/getting-started/quickstarts/flask', + icon: '/docs/img/icons/python-icon', + hasLightIcon: false, + description: 'Serve Flask web apps that query Postgres with the Supabase Python client.', + }, + { + title: 'Laravel', + href: '/guides/getting-started/quickstarts/laravel', + icon: '/docs/img/icons/laravel-icon', + hasLightIcon: false, + description: + 'Full-stack PHP with Eloquent ORM connected directly to your Supabase Postgres database.', + }, + { + title: 'Ruby on Rails', + href: '/guides/getting-started/quickstarts/ruby-on-rails', + icon: '/docs/img/icons/rails-icon', + hasLightIcon: false, + description: + 'Convention-driven Ruby apps with Active Record connected directly to your Supabase Postgres database.', }, ], } diff --git a/apps/docs/public/img/icons/laravel-icon.svg b/apps/docs/public/img/icons/laravel-icon.svg new file mode 100644 index 0000000000000..7b6ba90d83ff8 --- /dev/null +++ b/apps/docs/public/img/icons/laravel-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/docs/public/img/icons/rails-icon.svg b/apps/docs/public/img/icons/rails-icon.svg new file mode 100644 index 0000000000000..ca2756f86f36f --- /dev/null +++ b/apps/docs/public/img/icons/rails-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.test.tsx b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.test.tsx new file mode 100644 index 0000000000000..479d1664acd33 --- /dev/null +++ b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.test.tsx @@ -0,0 +1,216 @@ +import { fireEvent, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { mockAnimationsApi } from 'jsdom-testing-mocks' +import { http, HttpResponse } from 'msw' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { AccountIdentities } from './AccountIdentities' +import { BASE_PATH } from '@/lib/constants' +import { customRender } from '@/tests/lib/custom-render' +import { mswServer } from '@/tests/lib/msw' + +// Radix Dialog relies on the Web Animations API, which jsdom lacks. +mockAnimationsApi() + +const { getUserMock, updateUserMock, refreshSessionMock, signOutMock, useFlagMock } = vi.hoisted( + () => ({ + getUserMock: vi.fn(), + updateUserMock: vi.fn(), + refreshSessionMock: vi.fn(), + signOutMock: vi.fn(), + useFlagMock: vi.fn(), + }) +) + +// Overrides the global partial mock from vitestSetup, so re-apply `useParams` alongside `useFlag`. +vi.mock('common', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useParams: () => ({ ref: 'default' }), + useFlag: useFlagMock, + } +}) + +vi.mock('@/lib/gotrue', () => ({ + auth: { + getUser: getUserMock, + updateUser: updateUserMock, + refreshSession: refreshSessionMock, + signOut: signOutMock, + }, + buildPathWithParams: (path: string) => path, +})) + +const EMAIL = 'user@example.com' +const VALID_PASSWORD = 'Str0ng!password' + +type IdentityFixture = { + identity_id: string + id: string + user_id: string + provider: string + identity_data: Record + email: string +} + +const githubIdentity: IdentityFixture = { + identity_id: 'github-identity-id', + id: 'github-user-id', + user_id: 'user-id', + provider: 'github', + identity_data: { user_name: 'testuser' }, + email: EMAIL, +} + +const emailIdentity: IdentityFixture = { + identity_id: 'email-identity-id', + id: 'user-id', + user_id: 'user-id', + provider: 'email', + identity_data: {}, + email: EMAIL, +} + +const ssoIdentity: IdentityFixture = { + identity_id: 'sso-identity-id', + id: 'sso-user-id', + user_id: 'user-id', + provider: 'sso:4d21b3cf-3a2f-44d3-b7d6-2b0dd393f671', + identity_data: {}, + email: EMAIL, +} + +const mockGetUser = (identities: IdentityFixture[]) => { + getUserMock.mockResolvedValue({ + data: { user: { id: 'user-id', email: EMAIL, identities } }, + error: null, + }) +} + +const renderAccountIdentities = () => { + mswServer.use( + http.get(`${BASE_PATH}/api/enabled-features-overrides`, () => + HttpResponse.json({ disabled_features: [] }) + ) + ) + + return customRender() +} + +const openAddPasswordDialog = async () => { + const openButton = await screen.findByRole('button', { name: 'Add password' }) + fireEvent.click(openButton) + + return await screen.findByRole('dialog') +} + +describe('AccountIdentities', () => { + beforeEach(() => { + vi.clearAllMocks() + refreshSessionMock.mockResolvedValue({ data: {}, error: null }) + signOutMock.mockResolvedValue({ error: null }) + useFlagMock.mockImplementation((name: string) => name === 'enableAccountPassword') + }) + + it('offers to add a password when the user only has OAuth identities', async () => { + mockGetUser([githubIdentity]) + + renderAccountIdentities() + + expect(await screen.findByRole('button', { name: 'Add password' })).toBeInTheDocument() + expect(screen.getByText('Email')).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'Change password' })).not.toBeInTheDocument() + }) + + it('does not offer to add a password when an email identity exists', async () => { + mockGetUser([githubIdentity, emailIdentity]) + + renderAccountIdentities() + + expect(await screen.findByRole('link', { name: 'Change password' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Add password' })).not.toBeInTheDocument() + }) + + it('does not offer to add a password when the feature flag is disabled', async () => { + useFlagMock.mockReturnValue(false) + mockGetUser([githubIdentity]) + + renderAccountIdentities() + + expect(await screen.findByText('GitHub')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Add password' })).not.toBeInTheDocument() + expect(screen.queryByText('Email')).not.toBeInTheDocument() + }) + + it('does not offer to add a password to SSO users', async () => { + mockGetUser([ssoIdentity]) + + renderAccountIdentities() + + expect(await screen.findByText('SSO')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Add password' })).not.toBeInTheDocument() + }) + + it('adds a password and flips the row to the email identity actions', async () => { + mockGetUser([githubIdentity]) + updateUserMock.mockImplementation(async () => { + // GoTrue creates the email identity when the password is set, so the + // post-mutation refetch sees both identities. + mockGetUser([githubIdentity, emailIdentity]) + return { data: { user: { id: 'user-id', email: EMAIL } }, error: null } + }) + + renderAccountIdentities() + const dialog = await openAddPasswordDialog() + + const emailInput = within(dialog).getByLabelText('Email') + expect(emailInput).toBeDisabled() + expect(emailInput).toHaveValue(EMAIL) + + await userEvent.type(within(dialog).getByPlaceholderText('••••••••'), VALID_PASSWORD) + fireEvent.click(within(dialog).getByRole('button', { name: 'Add password' })) + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(updateUserMock).toHaveBeenCalledWith({ password: VALID_PASSWORD }) + expect(signOutMock).toHaveBeenCalledWith({ scope: 'others' }) + + expect(await screen.findByRole('link', { name: 'Change password' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Add password' })).not.toBeInTheDocument() + }) + + it('keeps the dialog open when setting the password fails', async () => { + mockGetUser([githubIdentity]) + updateUserMock.mockResolvedValue({ + data: { user: null }, + error: { message: 'Password update failed' }, + }) + + renderAccountIdentities() + const dialog = await openAddPasswordDialog() + + await userEvent.type(within(dialog).getByPlaceholderText('••••••••'), VALID_PASSWORD) + fireEvent.click(within(dialog).getByRole('button', { name: 'Add password' })) + + await waitFor(() => expect(updateUserMock).toHaveBeenCalledWith({ password: VALID_PASSWORD })) + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(signOutMock).not.toHaveBeenCalled() + }) + + it('does not submit a password that fails validation', async () => { + mockGetUser([githubIdentity]) + + renderAccountIdentities() + const dialog = await openAddPasswordDialog() + + await userEvent.type(within(dialog).getByPlaceholderText('••••••••'), 'weak') + fireEvent.click(within(dialog).getByRole('button', { name: 'Add password' })) + + expect( + await within(dialog).findByText( + 'Password must contain at least 8 characters, including uppercase, lowercase, number, and special character' + ) + ).toBeInTheDocument() + expect(updateUserMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx index 2456d57068dd6..9e74deca4fd83 100644 --- a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx @@ -1,4 +1,5 @@ import type { Provider } from '@supabase/auth-js' +import { useFlag } from 'common' import dayjs from 'dayjs' import { Edit, Unlink } from 'lucide-react' import Link from 'next/link' @@ -29,7 +30,8 @@ import { } from 'ui-patterns/PageSection' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' -import { parseRedirectMessage } from './AccountIdentities.utils' +import { parseRedirectMessage, shouldShowAddPasswordRow } from './AccountIdentities.utils' +import { AddPasswordRow } from './AddPasswordRow' import { ChangeEmailAddressForm, GitHubChangeEmailAddress, @@ -60,7 +62,11 @@ export const AccountIdentities = () => { [enabledProviders] ) + const isAddAccountPasswordEnabled = useFlag('enableAccountPassword') + const identities = data?.identities ?? [] + const showAddPasswordRow = + isAddAccountPasswordEnabled && shouldShowAddPasswordRow({ identities, email: data?.email }) const isChangeExpired = data?.email_change_sent_at ? dayjs().utc().diff(dayjs(data?.email_change_sent_at).utc(), 'minute') > 10 : false @@ -127,7 +133,7 @@ export const AccountIdentities = () => { - Account identities + Sign-in methods Manage the providers linked to your Supabase account and update their details. @@ -142,6 +148,8 @@ export const AccountIdentities = () => { )} {isSuccess && (
+ {showAddPasswordRow && !!data.email && } + {identities.map((identity) => { const { identity_id, provider } = identity const username = identity.identity_data?.user_name diff --git a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.test.ts b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.test.ts index fffe0612f4096..8a0d2f0ead36f 100644 --- a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.test.ts +++ b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { parseRedirectMessage } from './AccountIdentities.utils' +import { parseRedirectMessage, shouldShowAddPasswordRow } from './AccountIdentities.utils' describe('parseRedirectMessage', () => { it('drops the trailing sb marker and decodes + as spaces', () => { @@ -27,3 +27,49 @@ describe('parseRedirectMessage', () => { expect(parseRedirectMessage('/account/me#message=a%2Bb&sb=')).toBe('a+b') }) }) + +describe('shouldShowAddPasswordRow', () => { + const email = 'user@example.com' + + it('shows the row for an OAuth-only user', () => { + expect(shouldShowAddPasswordRow({ identities: [{ provider: 'github' }], email })).toBe(true) + }) + + it('shows the row when there are no identities at all', () => { + expect(shouldShowAddPasswordRow({ identities: [], email })).toBe(true) + }) + + it('hides the row when an email identity already exists', () => { + expect(shouldShowAddPasswordRow({ identities: [{ provider: 'email' }], email })).toBe(false) + expect( + shouldShowAddPasswordRow({ + identities: [{ provider: 'github' }, { provider: 'email' }], + email, + }) + ).toBe(false) + }) + + it('hides the row for SSO users', () => { + expect( + shouldShowAddPasswordRow({ + identities: [{ provider: 'sso:4d21b3cf-3a2f-44d3-b7d6-2b0dd393f671' }], + email, + }) + ).toBe(false) + expect( + shouldShowAddPasswordRow({ + identities: [{ provider: 'github' }, { provider: 'sso' }], + email, + }) + ).toBe(false) + }) + + it('hides the row when the user has no email', () => { + expect( + shouldShowAddPasswordRow({ identities: [{ provider: 'github' }], email: undefined }) + ).toBe(false) + expect(shouldShowAddPasswordRow({ identities: [{ provider: 'github' }], email: '' })).toBe( + false + ) + }) +}) diff --git a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.ts b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.ts index 842c102db1c08..8c0baeed9c4f3 100644 --- a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.ts +++ b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.ts @@ -1,2 +1,21 @@ export const parseRedirectMessage = (asPath: string) => new URLSearchParams(asPath.split('#')[1] ?? '').get('message') ?? undefined + +/** + * Whether to offer creating a password (which creates an email identity) to a user + * who signs in only through OAuth. + */ +export const shouldShowAddPasswordRow = ({ + identities, + email, +}: { + identities: { provider: string }[] + email: string | undefined +}): boolean => { + if (!email) return false + + const hasEmailIdentity = identities.some((identity) => identity.provider === 'email') + const hasSsoIdentity = identities.some((identity) => identity.provider.startsWith('sso')) + + return !hasEmailIdentity && !hasSsoIdentity +} diff --git a/apps/studio/components/interfaces/Account/Preferences/AddPasswordRow.tsx b/apps/studio/components/interfaces/Account/Preferences/AddPasswordRow.tsx new file mode 100644 index 0000000000000..825d034c78537 --- /dev/null +++ b/apps/studio/components/interfaces/Account/Preferences/AddPasswordRow.tsx @@ -0,0 +1,149 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { Eye, EyeOff } from 'lucide-react' +import { useState } from 'react' +import { useForm, useWatch } from 'react-hook-form' +import { toast } from 'sonner' +import { + Button, + CardContent, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogSection, + DialogTitle, + Form, + FormControl, + FormField, + Input, +} from 'ui' +import { Input as InputWithActions } from 'ui-patterns/DataInputs/Input' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { z } from 'zod' + +import PasswordConditionsHelper from '@/components/interfaces/SignIn/PasswordConditionsHelper' +import { IdentityProviderIcon } from '@/components/ui/ProviderIcon' +import { useSetPasswordMutation } from '@/data/profile/profile-set-password-mutation' +import { getProviderDisplay } from '@/lib/external-identity-providers' +import { passwordValidation } from '@/lib/password-validation' + +const FORM_ID = 'add-password-form' +const EMAIL_INPUT_ID = 'add-password-email' + +const FormSchema = z.object({ password: passwordValidation }) +type FormValues = z.infer + +const defaultValues: FormValues = { password: '' } + +/** + * Offers a user without an email identity (OAuth-only account) a way to create one by setting a + * password. + * + * Auth creates the email identity for the user's current email when the password is + * saved. + */ +export const AddPasswordRow = ({ email }: { email: string }) => { + const [isDialogOpen, setIsDialogOpen] = useState(false) + const providerDisplay = getProviderDisplay('email') + + return ( + <> + +
+ +
+

{providerDisplay.displayName}

+

{email}

+
+
+ +
+ + + + + Add password + + setIsDialogOpen(false)} /> + + + + ) +} + +const AddPasswordForm = ({ email, onClose }: { email: string; onClose: () => void }) => { + const [passwordHidden, setPasswordHidden] = useState(true) + + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues, + mode: 'onChange', + }) + const password = useWatch({ control: form.control, name: 'password' }) + + const { mutate: setPassword, isPending } = useSetPasswordMutation({ + onSuccess: () => { + toast.success('Password added successfully') + onClose() + }, + }) + + const onSubmit = (values: FormValues) => setPassword({ password: values.password }) + + return ( +
+ + + + + + + ( + + + : } + variant="default" + className="w-7" + onClick={() => setPasswordHidden((prev) => !prev)} + /> + } + {...field} + /> + + + )} + /> + + + + + + + + +
+ + ) +} diff --git a/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx b/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx index 3b95637b04965..4ded5d995f773 100644 --- a/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx +++ b/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx @@ -27,6 +27,7 @@ import { MarkdownCell } from './MarkdownCell' import { QueryCell } from './QueryCell' import { createMarkdownCellSkeleton, createQueryCellSkeleton } from './utils' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { isQueryCell } from '@/data/content/notebooks/notebook-schema' import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' import { createTabId, useTabsStateSnapshot } from '@/state/tabs' @@ -112,15 +113,13 @@ export const NotebookEditor = () => { strategy={verticalListSortingStrategy} >
- {cells.map((cell) => { - switch (cell._tag) { - case 'markdown_cell': - return - case 'database_cell': - case 'log_cell': - return - } - })} + {cells.map((cell) => + isQueryCell(cell) ? ( + + ) : ( + + ) + )}
diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/DisplaySettingsButton.tsx b/apps/studio/components/interfaces/Explorer/QueryCell/DisplaySettingsButton.tsx index d826537d3723e..b8eb3785b0e6b 100644 --- a/apps/studio/components/interfaces/Explorer/QueryCell/DisplaySettingsButton.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryCell/DisplaySettingsButton.tsx @@ -20,8 +20,9 @@ import { import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { ExplorerToolbarAction } from '../ExplorerToolbar' -import { type QueryChartConfig, type QueryDisplay, type QueryResult } from '../types' +import { type QueryDisplay, type QueryResult } from '../types' import { checkHasNonPositiveValues } from '@/components/ui/QueryBlock/QueryBlock.utils' +import { type ChartConfig } from '@/data/content/notebooks/notebook-schema' interface DisplaySettingsButtonProps { display: QueryDisplay @@ -66,7 +67,7 @@ export const DisplaySettingsButton = ({ onChange({ ...display, view }) } - const onUpdateChartConfig = (payload: Partial) => { + const onUpdateChartConfig = (payload: Partial) => { onChange({ ...display, chart: { diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/QueryCell.utils.test.ts b/apps/studio/components/interfaces/Explorer/QueryCell/QueryCell.utils.test.ts new file mode 100644 index 0000000000000..030942943a65d --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/QueryCell/QueryCell.utils.test.ts @@ -0,0 +1,167 @@ +import { untrustedSql } from '@supabase/pg-meta' +import { describe, expect, it } from 'vitest' + +import { + changeCellSource, + cloneQueryCell, + DEFAULT_CELL_ROW_LIMIT, + getCellDisplay, + setCellSql, + toQueryModel, +} from './QueryCell.utils' +import { type ChartConfig, type QueryCell } from '@/data/content/notebooks/notebook-schema' +import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' + +const CHART: ChartConfig = { + type: 'bar', + x_column: 'day', + y_columns: ['signups'], + cumulative: false, + scale: 'linear', + show_labels: true, +} + +const DATABASE_CELL: QueryCell = { + _tag: 'database_cell', + id: 'cell-1', + title: 'Signups', + view: 'chart', + chart: CHART, + unchecked_sql: untrustedSql('select * from auth.users'), + row_limit: 50, + database_identifier: 'replica-1', +} + +const LOG_CELL: QueryCell = { + _tag: 'log_cell', + id: 'cell-2', + title: 'Edge errors', + view: 'table', + chart: CHART, + unchecked_sql: untrustedLogSql('select timestamp from logs'), + time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 1 }, +} + +describe('changeCellSource', () => { + it('keeps the query when only the database changes', () => { + const next = changeCellSource(DATABASE_CELL, { + _tag: 'database', + database_identifier: 'replica-2', + }) + + expect(next).toEqual({ ...DATABASE_CELL, database_identifier: 'replica-2' }) + }) + + it('keeps the query when only the log time range changes', () => { + const time_range = { _tag: 'relative_time_range', unit: 'day', amount: 7 } as const + const next = changeCellSource(LOG_CELL, { _tag: 'logs', time_range }) + + expect(next).toEqual({ ...LOG_CELL, time_range }) + }) + + it('carries the query text over when moving from the database to logs', () => { + const time_range = { _tag: 'relative_time_range', unit: 'hour', amount: 1 } as const + const next = changeCellSource(DATABASE_CELL, { _tag: 'logs', time_range }) + + expect(next).toEqual({ + _tag: 'log_cell', + id: 'cell-1', + title: 'Signups', + view: 'chart', + chart: CHART, + unchecked_sql: 'select * from auth.users', + time_range, + }) + }) + + it('carries the query text over and restores a default row limit when moving from logs to the database', () => { + const next = changeCellSource(LOG_CELL, { _tag: 'database', database_identifier: undefined }) + + expect(next).toEqual({ + _tag: 'database_cell', + id: 'cell-2', + title: 'Edge errors', + view: 'table', + chart: CHART, + unchecked_sql: 'select timestamp from logs', + row_limit: DEFAULT_CELL_ROW_LIMIT, + database_identifier: undefined, + }) + }) + + it('applies the selected database when moving from logs to the database', () => { + const next = changeCellSource(LOG_CELL, { + _tag: 'database', + database_identifier: 'replica-2', + }) + + expect(next).toMatchObject({ _tag: 'database_cell', database_identifier: 'replica-2' }) + }) + + it('preserves the chart across a backend change so display settings survive', () => { + const next = changeCellSource(DATABASE_CELL, { + _tag: 'logs', + time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 1 }, + }) + + expect(next.chart).toEqual(CHART) + expect(next.chart).not.toBe(DATABASE_CELL.chart) + }) +}) + +describe('setCellSql', () => { + it('writes the text back onto a database cell without touching its source', () => { + expect(setCellSql(DATABASE_CELL, 'select 1')).toEqual({ + ...DATABASE_CELL, + unchecked_sql: 'select 1', + }) + }) + + it('writes the text back onto a log cell without touching its time range', () => { + expect(setCellSql(LOG_CELL, 'select 2')).toEqual({ ...LOG_CELL, unchecked_sql: 'select 2' }) + }) +}) + +describe('cloneQueryCell', () => { + it('copies the chart series array rather than aliasing it', () => { + const clone = cloneQueryCell(DATABASE_CELL) + + expect(clone).toEqual(DATABASE_CELL) + expect(clone.chart?.y_columns).not.toBe(DATABASE_CELL.chart?.y_columns) + }) +}) + +describe('getCellDisplay', () => { + it('keeps a configured chart while the table view is selected', () => { + expect(getCellDisplay({ ...DATABASE_CELL, view: 'table' })).toEqual({ + view: 'table', + chart: CHART, + }) + }) + + it('reports no chart when a cell has never configured one', () => { + expect(getCellDisplay({ ...DATABASE_CELL, view: 'table', chart: undefined })).toEqual({ + view: 'table', + chart: undefined, + }) + }) +}) + +describe('toQueryModel', () => { + it('tags a database cell with its row limit and database', () => { + expect(toQueryModel(DATABASE_CELL, 'select 3')).toEqual({ + _tag: 'database', + uncheckedSql: 'select 3', + database_identifier: 'replica-1', + rowLimit: 50, + }) + }) + + it('tags a log cell with its time range and no row limit', () => { + expect(toQueryModel(LOG_CELL, 'select 4')).toEqual({ + _tag: 'logs', + uncheckedSql: 'select 4', + time_range: LOG_CELL.time_range, + }) + }) +}) diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/QueryCell.utils.ts b/apps/studio/components/interfaces/Explorer/QueryCell/QueryCell.utils.ts new file mode 100644 index 0000000000000..0e5386d9831d1 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/QueryCell/QueryCell.utils.ts @@ -0,0 +1,135 @@ +import { untrustedSql } from '@supabase/pg-meta' +import { type Snapshot } from 'valtio' + +import { type ExplorerQueryModel } from '../QueryEditor' +import { type QueryDisplay } from '../types' +import { type ChartConfig, type QueryCell } from '@/data/content/notebooks/notebook-schema' +import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' +import { + getQuerySourceBinding, + type QuerySourceBinding, +} from '@/data/query-sources/query-source-registry' + +/** Row limit a database cell starts with when it has no saved one to carry over. */ +export const DEFAULT_CELL_ROW_LIMIT = 100 + +/** + * Valtio snapshots are deep-readonly. Readonly properties assign to mutable ones, so only + * the array needs rebuilding to turn a snapshot's chart back into a writable config. + */ +type ReadonlyChartConfig = Omit & { + readonly y_columns: readonly string[] +} + +export const cloneChartConfig = ( + chart: ReadonlyChartConfig | undefined +): ChartConfig | undefined => (chart ? { ...chart, y_columns: [...chart.y_columns] } : undefined) + +/** The display state a query cell hands the shared editor. */ +// `view` is already defaulted to 'table' by the domain transform, so there is nothing to +// fall back to here — only the chart needs copying out of the snapshot. +export const getCellDisplay = (cell: Snapshot): QueryDisplay => ({ + view: cell.view, + chart: cloneChartConfig(cell.chart), +}) + +/** Fields every query cell carries, copied out of a snapshot so the result is writable. */ +const copyQueryCellBase = (cell: Snapshot) => ({ + id: cell.id, + title: cell.title, + view: cell.view, + chart: cloneChartConfig(cell.chart), +}) + +/** A writable copy of a query cell, preserving its backend and every backend-specific field. */ +export const cloneQueryCell = (cell: Snapshot): QueryCell => + cell._tag === 'log_cell' + ? { + ...copyQueryCellBase(cell), + _tag: 'log_cell', + unchecked_sql: cell.unchecked_sql, + time_range: cell.time_range, + } + : { + ...copyQueryCellBase(cell), + _tag: 'database_cell', + unchecked_sql: cell.unchecked_sql, + row_limit: cell.row_limit, + database_identifier: cell.database_identifier, + } + +/** + * Applies a source binding to a query cell, carrying the query text across unchanged and + * rebranding it for the new backend's dialect. + * + * NOTE — carrying the text over is very likely not what a user wants when the backend + * actually changes. Postgres SQL and logs SQL are separate dialects over separate schemas, + * so a carried-over query will almost always fail to run, and the rebrand asserts a + * dialect the text was never written in. We keep it for now because it is the + * least-destructive option and needs no confirmation prompt; revisit once we know whether + * people switch source to port an existing query or to start a fresh one, at which point + * clearing the body (behind a confirmation) is the likely answer. + */ +export function changeCellSource(cell: Snapshot, source: QuerySourceBinding): QueryCell { + const base = copyQueryCellBase(cell) + + if (source._tag === 'logs') { + return { + ...base, + _tag: 'log_cell', + unchecked_sql: untrustedLogSql(cell.unchecked_sql), + time_range: source.time_range, + } + } + + return { + ...base, + _tag: 'database_cell', + unchecked_sql: untrustedSql(cell.unchecked_sql), + row_limit: cell._tag === 'database_cell' ? cell.row_limit : DEFAULT_CELL_ROW_LIMIT, + database_identifier: source.database_identifier, + } +} + +/** + * Writes the editor's text back onto a cell, branded for that cell's dialect. Separate + * from `cloneQueryCell` so the brand stays correlated with the cell tag in one narrowing + * rather than being re-derived at each call site. + */ +export function setCellSql(cell: Snapshot, sql: string): QueryCell { + const base = copyQueryCellBase(cell) + + if (cell._tag === 'log_cell') { + return { + ...base, + _tag: 'log_cell', + unchecked_sql: untrustedLogSql(sql), + time_range: cell.time_range, + } + } + + return { + ...base, + _tag: 'database_cell', + unchecked_sql: untrustedSql(sql), + row_limit: cell.row_limit, + database_identifier: cell.database_identifier, + } +} + +/** + * Builds the editor's query model from a cell and the editor's live text buffer. Branding + * the buffer is the editor boundary the safe-SQL model expects; which brand applies is + * decided by the cell's tag, so the dialect can't drift from the cell it belongs to. + */ +export function toQueryModel(cell: Snapshot, sql: string): ExplorerQueryModel { + if (cell._tag === 'log_cell') { + return { ...getQuerySourceBinding(cell), uncheckedSql: untrustedLogSql(sql) } + } + + return { + ...getQuerySourceBinding(cell), + uncheckedSql: untrustedSql(sql), + rowLimit: cell.row_limit, + } +} diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx b/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx index 0348eaf1942a8..3807fca17c8a9 100644 --- a/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx @@ -1,4 +1,3 @@ -import { untrustedSql } from '@supabase/pg-meta' import { useState } from 'react' import { type Snapshot } from 'valtio' @@ -6,121 +5,78 @@ import { AddCellDropdown } from '../AddCellDropdown' import { MoveCellDropdownContent } from '../MoveCellDropdownContent' import { QueryEditor } from '../QueryEditor' import { type QueryDisplay, type QueryResult } from '../types' +import { + changeCellSource, + cloneChartConfig, + cloneQueryCell, + getCellDisplay, + setCellSql, + toQueryModel, +} from './QueryCell.utils' import { SortableSection } from '@/components/ui/SortableSection' import { - type DatabaseCell as DatabaseCellSchema, - type LogCell as LogCellSchema, + isQueryCell, + type QueryCell as QueryCellSchema, } from '@/data/content/notebooks/notebook-schema' -import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' -import { - getQuerySourceBinding, - type QuerySourceBinding, -} from '@/data/query-sources/query-source-registry' +import { type QuerySourceBinding } from '@/data/query-sources/query-source-registry' import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' interface QueryCellProps { - cell: Snapshot + cell: Snapshot } -/** - * [Joshen] Aiming to keep PRs small so the following are deliberating missing for now: - * - Auto limit logic - * - Database selection logic - * - * QueryCell atm minimally supports running queries and rendering results - */ - -type QueryCellUpdate = { sql: string } | { title: string } | { display: QueryDisplay } - /** Notebook adapter around the shared QueryEditor. */ export const QueryCell = ({ cell }: QueryCellProps) => { const snap = useNotebooksStateSnapshot() const currentNotebook = useCurrentNotebook() - const { id, title: cellTitle, view, chart, unchecked_sql } = cell - const rowLimit = 'row_limit' in cell ? cell.row_limit : undefined - const source = getQuerySourceBinding(cell) - - const [sql, setSql] = useState(unchecked_sql) + const [sql, setSql] = useState(cell.unchecked_sql) const [result, setResult] = useState() - const title = cellTitle ?? 'Untitled snippet' - const display: QueryDisplay = { - view: view ?? 'table', - chart: chart ? { ...chart, y_columns: [...chart.y_columns] } : undefined, - } + const title = cell.title ?? 'Untitled query' - const handleSourceChange = (source: QuerySourceBinding) => { + /** + * Applies an update to this cell. The updater runs against the cell as the store holds + * it rather than the snapshot this component rendered with, so a concurrent edit isn't + * clobbered; `isQueryCell` keeps the per-backend helpers off a markdown cell that + * somehow shares the id. + */ + const updateQueryCell = (updater: (candidate: Snapshot) => QueryCellSchema) => { const notebookId = currentNotebook?.notebook.id if (!notebookId) return snap.updateCell({ id: notebookId, - cellId: id, - updater: (candidate) => { - if (source._tag === 'database' && candidate._tag === 'log_cell') { - const { _tag, time_range, unchecked_sql, ...rest } = candidate - return { - ...rest, - _tag: 'database_cell' as const, - row_limit: 100, - database_identifier: source.database_identifier, - unchecked_sql: untrustedSql(unchecked_sql), - } - } - - if (source._tag === 'logs' && candidate._tag === 'database_cell') { - const { _tag, row_limit, database_identifier, unchecked_sql, ...rest } = candidate - return { - ...rest, - _tag: 'log_cell' as const, - time_range: source.time_range, - unchecked_sql: untrustedLogSql(unchecked_sql), - } - } - - if (source._tag === 'database' && candidate._tag === 'database_cell') { - return { ...candidate, database_identifier: source.database_identifier } - } - - if (source._tag === 'logs' && candidate._tag === 'log_cell') { - return { ...candidate, time_range: source.time_range } - } - - return candidate - }, + cellId: cell.id, + updater: (candidate) => (isQueryCell(candidate) ? updater(candidate) : candidate), }) } - const handleUpdateCell = (payload: QueryCellUpdate) => { - const notebookId = currentNotebook?.notebook.id - if (!notebookId) return + const handleSourceChange = (source: QuerySourceBinding) => { + // The query text carries over (see `changeCellSource`), so the editor's buffer stays + // valid — but a result the old backend produced does not, since another engine + // returns unrelated columns. + const isBackendChange = (source._tag === 'logs') !== (cell._tag === 'log_cell') + if (isBackendChange) setResult(undefined) - snap.updateCell({ - id: notebookId, - cellId: id, - updater: (candidate) => { - if (candidate._tag !== 'database_cell' && candidate._tag !== 'log_cell') return candidate + updateQueryCell((candidate) => changeCellSource(candidate, source)) + } - if ('sql' in payload) { - return candidate._tag === 'database_cell' - ? { ...candidate, unchecked_sql: untrustedSql(payload.sql) } - : { ...candidate, unchecked_sql: untrustedLogSql(payload.sql) } - } + const handleTitleChange = (value: string) => { + const nextTitle = value.trim() + if (!nextTitle) return + updateQueryCell((candidate) => ({ ...cloneQueryCell(candidate), title: nextTitle })) + } - if ('title' in payload) { - const nextTitle = payload.title.trim() - return nextTitle ? { ...candidate, title: nextTitle } : candidate - } + const handleSqlCommit = (value: string) => + updateQueryCell((candidate) => setCellSql(candidate, value)) - return { - ...candidate, - view: payload.display.view, - chart: payload.display.chart, - } - }, - }) - } + const handleDisplayChange = (display: QueryDisplay) => + updateQueryCell((candidate) => ({ + ...cloneQueryCell(candidate), + view: display.view, + chart: cloneChartConfig(display.chart), + })) return ( { gripClassName="mt-2 opacity-0 group-hover:opacity-100 has-[[data-state=open]]:opacity-100 transition" > handleUpdateCell({ title })} + display={getCellDisplay(cell)} + onTitleChange={handleTitleChange} onSqlChange={setSql} - onSqlCommit={(sql) => handleUpdateCell({ sql })} + onSqlCommit={handleSqlCommit} onSourceChange={handleSourceChange} onResultChange={setResult} - onDisplayChange={ - cell._tag === 'database_cell' ? (display) => handleUpdateCell({ display }) : undefined - } + onDisplayChange={handleDisplayChange} /> ) diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor.tsx index fba1aaa5503eb..06e391b58aeb8 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor.tsx @@ -1,4 +1,4 @@ -import { acceptUntrustedSql, untrustedSql } from '@supabase/pg-meta' +import { acceptUntrustedSql, untrustedSql, type UntrustedSqlFragment } from '@supabase/pg-meta' import { useFlag } from 'common' import { CodeSquare, Eye, EyeOff, Play } from 'lucide-react' import { useState, type ReactNode } from 'react' @@ -21,16 +21,23 @@ import { ExplorerToolbarTitle, } from './ExplorerToolbar' import { DisplaySettingsButton } from './QueryCell/DisplaySettingsButton' -import { QueryResultChart } from './QueryCell/QueryResultChart' -import { QueryResultTable } from './QueryResultTable' +import { QueryResultRenderer } from './QueryResultRenderer' import { type QueryDisplay, type QueryResult } from './types' import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' +import { + type DatabaseSourceParameters, + type LogsSourceParameters, +} from '@/data/content/notebooks/notebook-schema' import { isValidConnString } from '@/data/fetchers' import { useExecuteLogsSqlMutation } from '@/data/logs/execute-logs-sql-mutation' -import { acceptUntrustedLogsSql, untrustedLogSql } from '@/data/logs/safe-analytics-sql' import { - createDefaultSourceBinding, + acceptUntrustedLogsSql, + untrustedLogSql, + type UntrustedLogSqlFragment, +} from '@/data/logs/safe-analytics-sql' +import { QUERY_SOURCE_REGISTRY, + toQuerySourceBinding, type QuerySourceBinding, } from '@/data/query-sources/query-source-registry' import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' @@ -39,14 +46,29 @@ import { applyAutoLimit } from '@/data/sql/utils' import { useLatest } from '@/hooks/misc/useLatest' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +/** + * The query this editor is showing, tagged by backend. The tag correlates the SQL's + * dialect brand with that backend's parameters, so a single `_tag` check inside + * `handleRunQuery` narrows both at once and there is no path that sends a query to the + * wrong wire boundary. + */ +export type ExplorerQueryModel = + | ({ + _tag: 'database' + uncheckedSql: UntrustedSqlFragment + rowLimit?: number + } & DatabaseSourceParameters) + | ({ + _tag: 'logs' + uncheckedSql: UntrustedLogSqlFragment + } & LogsSourceParameters) + export type QueryEditorProps = { id: string variant: 'embedded' | 'viewport' title: string - sql: string - source?: QuerySourceBinding + query: ExplorerQueryModel result?: QueryResult - rowLimit?: number display?: QueryDisplay toolbarActions?: ReactNode onTitleChange: (title: string) => void @@ -66,10 +88,8 @@ export const QueryEditor = ({ id, variant, title, - sql, - source, + query, result, - rowLimit, display, toolbarActions, onTitleChange, @@ -79,7 +99,8 @@ export const QueryEditor = ({ onResultChange, onDisplayChange, }: QueryEditorProps) => { - const sqlRef = useLatest(sql) + const sql = query.uncheckedSql + const sqlRef = useLatest(sql) const onSqlCommitRef = useLatest(onSqlCommit) const isOtelLogsEnabled = useFlag('otelLegacyLogs') @@ -87,13 +108,11 @@ export const QueryEditor = ({ const view = display?.view ?? 'table' const columns = Object.keys(result?.rows?.[0] ?? {}) - const sourceBinding = source ?? createDefaultSourceBinding('database') + const rowLimit = query._tag === 'database' ? query.rowLimit : undefined + const databaseIdentifier = query._tag === 'database' ? query.database_identifier : undefined const [showQuery, setShowQuery] = useState(true) - const databaseIdentifier = - sourceBinding._tag === 'database' ? sourceBinding.database_identifier : undefined - const { data: databases, isPending: isLoadingDatabases } = useReadReplicasQuery( { projectRef: project?.ref }, { @@ -119,12 +138,19 @@ export const QueryEditor = ({ const isExecuting = isExecutingSql || isExecutingLogs const isBusy = isLoadingProject || isResolvingDatabase || isExecuting - const handleRunQuery = (sqlToRun: string = sql) => { - if (!project || isBusy || sqlToRun.trim().length === 0) return + /** + * The user's run gesture, and therefore the promotion point for this query's SQL. The + * raw text comes straight off the editor, so it is (re)branded untrusted here — the + * editor boundary — and promoted in the same handler. Which pair of helpers applies is + * decided by `query._tag`, the same discriminant that picks the execution endpoint, so + * Postgres SQL cannot reach the analytics wire or vice versa. + */ + const handleRunQuery = (rawSql: string = sql) => { + if (!project || isBusy || rawSql.trim().length === 0) return - onSqlCommit?.(sql) + onSqlCommit?.(rawSql) - if (sourceBinding._tag === 'logs') { + if (query._tag === 'logs') { if (!isOtelLogsEnabled) { onResultChange({ error: { message: "Querying logs isn't available for this project yet." }, @@ -134,14 +160,14 @@ export const QueryEditor = ({ executeLogsSql({ projectRef: project.ref, - sql: acceptUntrustedLogsSql(untrustedLogSql(sqlToRun)), - range: resolveLogTimeRange(sourceBinding.time_range), + sql: acceptUntrustedLogsSql(untrustedLogSql(rawSql)), + range: resolveLogTimeRange(query.time_range), endpoint: QUERY_SOURCE_REGISTRY.logs.endpoint, }) return } - const safeSql = acceptUntrustedSql(untrustedSql(sqlToRun)) + const safeSql = acceptUntrustedSql(untrustedSql(rawSql)) const limitedSql = applyAutoLimit(safeSql, rowLimit) const connectionString = databaseIdentifier === undefined || databaseIdentifier === project.ref @@ -175,8 +201,11 @@ export const QueryEditor = ({ {title} {toolbarActions} - {source && onSourceChange && ( - + {onSourceChange && ( + )} {display && onDisplayChange && ( - {view === 'table' && } - {view === 'chart' && } + diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/QueryResultChart.tsx b/apps/studio/components/interfaces/Explorer/QueryResultChart.tsx similarity index 95% rename from apps/studio/components/interfaces/Explorer/QueryCell/QueryResultChart.tsx rename to apps/studio/components/interfaces/Explorer/QueryResultChart.tsx index da90821dbd208..3d92ec07b8ee1 100644 --- a/apps/studio/components/interfaces/Explorer/QueryCell/QueryResultChart.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryResultChart.tsx @@ -1,12 +1,13 @@ import { useMemo } from 'react' import { Chart, ChartBar, ChartCard, ChartContent, ChartLine } from 'ui-patterns/Chart' -import { type QueryChartConfig, type QueryResult } from '../types' +import { type QueryResult } from './types' import NoDataPlaceholder from '@/components/ui/Charts/NoDataPlaceholder' import { formatLogTick, getCumulativeResults } from '@/components/ui/QueryBlock/QueryBlock.utils' +import { type ChartConfig } from '@/data/content/notebooks/notebook-schema' interface QueryResultChartProps { - chart?: QueryChartConfig + chart?: ChartConfig result?: QueryResult } diff --git a/apps/studio/components/interfaces/Explorer/QueryResultTable.tsx b/apps/studio/components/interfaces/Explorer/QueryResultError.tsx similarity index 82% rename from apps/studio/components/interfaces/Explorer/QueryResultTable.tsx rename to apps/studio/components/interfaces/Explorer/QueryResultError.tsx index abf405d0e7827..3e06ecd10e02e 100644 --- a/apps/studio/components/interfaces/Explorer/QueryResultTable.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryResultError.tsx @@ -7,7 +7,6 @@ import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription. import { type QueryResult } from './types' import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' import CopyButton from '@/components/ui/CopyButton' -import { DataGridResults } from '@/components/ui/DataGridResults' import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink' import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' import { getSqlErrorLines } from '@/data/sql/utils' @@ -15,37 +14,7 @@ import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-q import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { DOCS_URL } from '@/lib/constants' -interface QueryResultTableProps { - result?: QueryResult -} - -// [Joshen] This is essentially a duplicate of UtilityTabResults from the SQL Editor -// I'll eventually migrate the Results component over - just trying to avoid bloating -// changes wherever possible - -// [Joshen] Should be shifted into QueryCell folder - -export const QueryResultTable = ({ result }: QueryResultTableProps) => { - const { rows, error, autoLimit } = result ?? {} - - if (!result) { - return

Run the query to see results

- } - - if (error) { - return - } - - if ((rows ?? []).length === 0) { - return

Success. No rows returned

- } - - if (rows && rows.length > 0) { - return - } -} - -const QueryError = ({ +export const QueryResultError = ({ error, autoLimit, }: { @@ -75,7 +44,7 @@ const QueryError = ({ ) return ( -
+
{isTimeout ? (
@@ -183,8 +152,3 @@ const QueryError = ({
) } - -// [Joshen] Eventually migrate the Results component here from SQL Editor -const QueryResults = ({ rows }: { rows: NonNullable }) => { - return -} diff --git a/apps/studio/components/interfaces/Explorer/QueryResultRenderer.tsx b/apps/studio/components/interfaces/Explorer/QueryResultRenderer.tsx new file mode 100644 index 0000000000000..1915d3c7328c0 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/QueryResultRenderer.tsx @@ -0,0 +1,34 @@ +import { QueryResultChart } from './QueryResultChart' +import { QueryResultError } from './QueryResultError' +import { type QueryResult } from './types' +import { DataGridResults } from '@/components/ui/DataGridResults' +import { type ChartConfig } from '@/data/content/notebooks/notebook-schema' + +interface QueryResultRendererProps { + result?: QueryResult + view?: 'table' | 'chart' + chart?: ChartConfig +} + +export const QueryResultRenderer = ({ result, view, chart }: QueryResultRendererProps) => { + const { rows, error, autoLimit } = result ?? {} + + if (!result) { + return

Run the query to see results

+ } + + if (error) { + return + } + + if ((rows ?? []).length === 0) { + return

Success. No rows returned

+ } + + if (rows && rows.length > 0) { + if (view === 'table') return + if (view === 'chart') return + } + + return null +} diff --git a/apps/studio/components/interfaces/Explorer/QueryTab.tsx b/apps/studio/components/interfaces/Explorer/QueryTab.tsx index 0948b97a77341..83f32f4aa5efa 100644 --- a/apps/studio/components/interfaces/Explorer/QueryTab.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryTab.tsx @@ -4,8 +4,9 @@ import { useRouter } from 'next/router' import { useContext, useEffect, useState } from 'react' import { Button } from 'ui' -import { QueryEditor } from './QueryEditor' +import { QueryEditor, type ExplorerQueryModel } from './QueryEditor' import { type QueryResult } from './types' +import { toQuerySourceBinding } from '@/data/query-sources/query-source-registry' import { explorerQueryState, useExplorerQueryStateSnapshot } from '@/state/explorer-query' import { createTabId, TabsStateContext } from '@/state/tabs' @@ -74,15 +75,22 @@ export const QueryTab = () => { }) } + const query: ExplorerQueryModel = + draft._tag === 'logs' + ? { ...toQuerySourceBinding(draft), uncheckedSql: draft.uncheckedSql } + : { + ...toQuerySourceBinding(draft), + uncheckedSql: draft.uncheckedSql, + rowLimit: QUERY_ROW_LIMIT, + } + return ( { const name = value.trim() || 'Untitled query' explorerQueryState.updateDraft({ id, name }) diff --git a/apps/studio/components/interfaces/Explorer/types.ts b/apps/studio/components/interfaces/Explorer/types.ts index c3b07cad34e5c..4109350cbae4c 100644 --- a/apps/studio/components/interfaces/Explorer/types.ts +++ b/apps/studio/components/interfaces/Explorer/types.ts @@ -1,19 +1,17 @@ +import { type ChartConfig } from '@/data/content/notebooks/notebook-schema' + export type QueryResult = { rows?: readonly Record[] error?: { message: string; formattedError?: string } autoLimit?: number } -export type QueryChartConfig = { - type: 'bar' | 'line' - x_column: string - y_columns: string[] - cumulative: boolean - scale: 'linear' | 'log' - show_labels: boolean -} - +/** + * How a query's results are rendered. `chart` is kept independently of `view` so a user + * who switches to the table and back gets their chart configuration returned rather than + * rebuilt — the same reason the notebook wire schema persists the two separately. + */ export type QueryDisplay = { view: 'table' | 'chart' - chart?: QueryChartConfig + chart?: ChartConfig } diff --git a/apps/studio/components/interfaces/Explorer/utils.ts b/apps/studio/components/interfaces/Explorer/utils.ts index 059c84784d444..70caeba255f24 100644 --- a/apps/studio/components/interfaces/Explorer/utils.ts +++ b/apps/studio/components/interfaces/Explorer/utils.ts @@ -1,5 +1,6 @@ import { untrustedSql } from '@supabase/pg-meta' +import { DEFAULT_CELL_ROW_LIMIT } from './QueryCell/QueryCell.utils' import { generateUuid } from '@/lib/api/snippets.browser' export const createQueryCellSkeleton = ({ sql }: { sql?: string } = {}) => { @@ -9,7 +10,7 @@ export const createQueryCellSkeleton = ({ sql }: { sql?: string } = {}) => { view: 'table' as const, chart: undefined, unchecked_sql: untrustedSql(sql ?? ''), - row_limit: 100, + row_limit: DEFAULT_CELL_ROW_LIMIT, } } diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx index 5e3ad93b6db1b..99f9cb7658ede 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx @@ -443,13 +443,9 @@ export const ProjectCreationForm = ({ extractPostgresVersionDetails(postgresVersionSelection) const { smartGroup = [], specific = [] } = availableRegionsData?.all ?? {} - const selectedRegion = - highAvailability && highAvailabilityRegionCode !== undefined - ? specific.find((region) => region.code === highAvailabilityRegionCode) - : smartRegionEnabled - ? (smartGroup.find((x) => x.name === dbRegion) ?? - specific.find((x) => x.name === dbRegion)) - : undefined + const selectedRegion = smartRegionEnabled + ? (smartGroup.find((x) => x.name === dbRegion) ?? specific.find((x) => x.name === dbRegion)) + : undefined if (highAvailability && highAvailabilityRegionCode !== undefined && !selectedRegion) { return toast.error( diff --git a/apps/studio/components/interfaces/SignIn/ResetPasswordForm.tsx b/apps/studio/components/interfaces/SignIn/ResetPasswordForm.tsx index c5257fd3afa95..09b425ef49ba1 100644 --- a/apps/studio/components/interfaces/SignIn/ResetPasswordForm.tsx +++ b/apps/studio/components/interfaces/SignIn/ResetPasswordForm.tsx @@ -13,20 +13,7 @@ import { z } from 'zod' import PasswordConditionsHelper from './PasswordConditionsHelper' import { captureCriticalError } from '@/lib/error-reporting' import { auth, getReturnToPath } from '@/lib/gotrue' - -const passwordValidation = z - .string() - .min(1, 'Password is required') - .max(72, 'Password cannot exceed 72 characters') - .refine((password) => { - const hasUppercase = /[A-Z]/.test(password) - const hasLowercase = /[a-z]/.test(password) - const hasNumber = /[0-9]/.test(password) - const hasSpecialChar = /[!@#$%^&*()_+\-=\[\]{};`':"\\|,.<>\/?]/.test(password) - const isLongEnough = password.length >= 8 - - return hasUppercase && hasLowercase && hasNumber && hasSpecialChar && isLongEnough - }, 'Password must contain at least 8 characters, including uppercase, lowercase, number, and special character') +import { passwordValidation } from '@/lib/password-validation' const passwordSchema = z.object({ currentPassword: z.string().min(1, 'Current password is required'), diff --git a/apps/studio/data/profile/profile-identities-query.ts b/apps/studio/data/profile/profile-identities-query.ts index cb7260499c15b..fc88b1e2a5b55 100644 --- a/apps/studio/data/profile/profile-identities-query.ts +++ b/apps/studio/data/profile/profile-identities-query.ts @@ -14,12 +14,13 @@ export async function getProfileIdentities() { if (error) throw error if (!data.user) throw new Error('User not found with getUser()') - const { identities = [], new_email, email_change_sent_at } = data.user - return { identities, new_email, email_change_sent_at } + const { identities = [], email, new_email, email_change_sent_at } = data.user + return { identities, email, new_email, email_change_sent_at } } type ProfileIdentitiesData = { identities: (UserIdentity & { email?: string })[] + email?: string new_email?: string email_change_sent_at?: string } diff --git a/apps/studio/data/profile/profile-set-password-mutation.ts b/apps/studio/data/profile/profile-set-password-mutation.ts new file mode 100644 index 0000000000000..62437117c4273 --- /dev/null +++ b/apps/studio/data/profile/profile-set-password-mutation.ts @@ -0,0 +1,51 @@ +import type { AuthError } from '@supabase/auth-js' +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { profileKeys } from './keys' +import { auth } from '@/lib/gotrue' + +export type SetPasswordVariables = { + password: string +} + +async function setPassword({ password }: SetPasswordVariables) { + const { data, error } = await auth.updateUser({ password }) + + if (error) throw error + return data +} + +export type SetPasswordData = Awaited> +export type SetPasswordError = AuthError + +export const useSetPasswordMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (vars) => setPassword(vars), + async onSuccess(data, variables, context) { + // logout all other sessions after setting a password + await auth.signOut({ scope: 'others' }) + await Promise.all([ + auth.refreshSession(), + queryClient.invalidateQueries({ queryKey: profileKeys.identities() }), + ]) + await onSuccess?.(data, variables, context) + }, + async onError(error, variables, context) { + if (onError === undefined) { + toast.error(`Failed to add password: ${error.message}`) + } else { + onError(error, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/lib/password-validation.ts b/apps/studio/lib/password-validation.ts new file mode 100644 index 0000000000000..cfe97f2ce329d --- /dev/null +++ b/apps/studio/lib/password-validation.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' + +export const passwordValidation = z + .string() + .min(1, 'Password is required') + .max(72, 'Password cannot exceed 72 characters') + .refine((password) => { + const hasUppercase = /[A-Z]/.test(password) + const hasLowercase = /[a-z]/.test(password) + const hasNumber = /[0-9]/.test(password) + const hasSpecialChar = /[!@#$%^&*()_+\-=\[\]{};`':"\\|,.<>\/?]/.test(password) + const isLongEnough = password.length >= 8 + + return hasUppercase && hasLowercase && hasNumber && hasSpecialChar && isLongEnough + }, 'Password must contain at least 8 characters, including uppercase, lowercase, number, and special character') diff --git a/apps/studio/pages/account/me.tsx b/apps/studio/pages/account/me.tsx index f069bdee82c52..0e24daaa36ff9 100644 --- a/apps/studio/pages/account/me.tsx +++ b/apps/studio/pages/account/me.tsx @@ -150,7 +150,7 @@ const ProfileLoadingSections = ({ - Account identities + Sign-in methods Manage the providers linked to your Supabase account and update their details. diff --git a/apps/studio/state/explorer-query.test.ts b/apps/studio/state/explorer-query.test.ts index de2ccae4f207f..e23f7f9fbdb56 100644 --- a/apps/studio/state/explorer-query.test.ts +++ b/apps/studio/state/explorer-query.test.ts @@ -17,6 +17,11 @@ const createMemoryStorage = () => { } } +const LOGS_SOURCE = { + _tag: 'logs', + time_range: { _tag: 'relative_time_range', amount: 3, unit: 'hour' }, +} as const + describe('explorer query drafts', () => { beforeEach(() => vi.useFakeTimers()) afterEach(() => vi.useRealTimers()) @@ -32,36 +37,59 @@ describe('explorer query drafts', () => { expect(secondState.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true) expect(secondState.drafts['query-1']).toMatchObject({ + _tag: 'database', name: 'Active users', - source: { _tag: 'database' }, uncheckedSql: 'select * from users', projectRef: 'project-a', }) expect(secondState.restoreDraft({ id: 'query-1', projectRef: 'project-b' })).toBe(false) }) - it('persists source parameters and clears stale results when they change', () => { + it('persists source parameters and clears stale results when the backend changes', () => { const storage = createMemoryStorage() const state = createExplorerQueryState(storage) state.createDraft({ id: 'query-1', projectRef: 'project-a', sql: 'select 1' }) state.setResult({ id: 'query-1', result: { rows: [{ value: 1 }], executedAt: 1 } }) - state.updateDraft({ - id: 'query-1', - source: { - _tag: 'logs', - time_range: { _tag: 'relative_time_range', amount: 3, unit: 'hour' }, - }, - }) + state.updateDraft({ id: 'query-1', source: LOGS_SOURCE }) expect(state.results['query-1']).toBeUndefined() const restored = createExplorerQueryState(storage) expect(restored.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true) - expect(restored.drafts['query-1'].source).toEqual({ + expect(restored.drafts['query-1']).toMatchObject(LOGS_SOURCE) + }) + + it('carries the query text over when the backend changes, rebranded for the new dialect', () => { + const storage = createMemoryStorage() + const state = createExplorerQueryState(storage) + + state.createDraft({ id: 'query-1', projectRef: 'project-a', sql: 'select * from users' }) + state.updateDraft({ id: 'query-1', source: LOGS_SOURCE }) + + expect(state.drafts['query-1']).toMatchObject({ _tag: 'logs', - time_range: { _tag: 'relative_time_range', amount: 3, unit: 'hour' }, + uncheckedSql: 'select * from users', + }) + }) + + it('keeps the query when only the parameters of the same backend change', () => { + const storage = createMemoryStorage() + const state = createExplorerQueryState(storage) + + state.createDraft({ id: 'query-1', projectRef: 'project-a', sql: 'select * from users' }) + state.setResult({ id: 'query-1', result: { rows: [{ value: 1 }], executedAt: 1 } }) + state.updateDraft({ + id: 'query-1', + source: { _tag: 'database', database_identifier: 'replica-1' }, + }) + + expect(state.drafts['query-1']).toMatchObject({ + _tag: 'database', + database_identifier: 'replica-1', + uncheckedSql: 'select * from users', }) + expect(state.results['query-1']).toBeDefined() }) it('restores pre-source drafts as database queries', () => { @@ -75,7 +103,7 @@ describe('explorer query drafts', () => { const state = createExplorerQueryState(storage) expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true) - expect(state.drafts['query-1'].source).toEqual({ _tag: 'database' }) + expect(state.drafts['query-1']._tag).toBe('database') }) it('ignores a malformed root value', () => { @@ -115,7 +143,10 @@ describe('explorer query drafts', () => { const state = createExplorerQueryState(storage) expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true) - expect(state.drafts['query-1'].source).toEqual({ _tag: 'database' }) + expect(state.drafts['query-1']).toMatchObject({ + _tag: 'database', + uncheckedSql: 'select 1', + }) }) it('debounces SQL persistence while updating in-memory state immediately', () => { diff --git a/apps/studio/state/explorer-query.ts b/apps/studio/state/explorer-query.ts index 805e7efd8eb83..ae1f1972c6177 100644 --- a/apps/studio/state/explorer-query.ts +++ b/apps/studio/state/explorer-query.ts @@ -4,25 +4,54 @@ import { proxy, ref, snapshot, useSnapshot } from 'valtio' import { z } from 'zod' import { type QueryResult } from '@/components/interfaces/Explorer/types' +import { + type DatabaseSourceParameters, + type LogsSourceParameters, +} from '@/data/content/notebooks/notebook-schema' +import { untrustedLogSql, type UntrustedLogSqlFragment } from '@/data/logs/safe-analytics-sql' import { createDefaultSourceBinding, querySourceBindingSchema, + toQuerySourceBinding, type QuerySourceBinding, } from '@/data/query-sources/query-source-registry' -export type ExplorerQueryDraft = { +type ExplorerQueryDraftBase = { id: string projectRef: string name: string - source: QuerySourceBinding - uncheckedSql: UntrustedSqlFragment updatedAt: number } +/** + * A standalone Explorer query draft. Tagged by backend rather than carrying a separate + * `source` object, mirroring how notebook cells store their binding: the tag narrows + * `uncheckedSql` to that backend's brand, so a Postgres draft's text can never be handed + * to the analytics wire boundary (or vice versa) without failing to compile. + */ +export type DatabaseQueryDraft = ExplorerQueryDraftBase & + DatabaseSourceParameters & { + _tag: 'database' + uncheckedSql: UntrustedSqlFragment + } + +export type LogsQueryDraft = ExplorerQueryDraftBase & + LogsSourceParameters & { + _tag: 'logs' + uncheckedSql: UntrustedLogSqlFragment + } + +export type ExplorerQueryDraft = DatabaseQueryDraft | LogsQueryDraft + export type ExplorerQueryResult = QueryResult & { executedAt: number } +/** + * Drafts persist their binding under a single `source` key. This is browser-local storage, + * not the notebook wire contract, so nesting costs nothing here and lets the whole binding + * be validated in one `safeParse`. + */ type PersistedExplorerQueryDraft = { name: string source: QuerySourceBinding @@ -45,6 +74,39 @@ const persistedDraftSchema = z.object({ source: z.unknown().optional(), }) +/** + * Rebuilds a draft from its persisted form, branding the SQL for the backend the binding + * names. The single place a stored string re-enters the type system as untrusted SQL, which + * is what keeps the brand correlated with the backend rather than assumed. + */ +const toDraft = ({ + id, + projectRef, + persisted, +}: { + id: string + projectRef: string + persisted: PersistedExplorerQueryDraft +}): ExplorerQueryDraft => { + const base = { id, projectRef, name: persisted.name, updatedAt: persisted.updatedAt } + + if (persisted.source._tag === 'logs') { + return { + ...base, + _tag: 'logs', + time_range: persisted.source.time_range, + uncheckedSql: untrustedLogSql(persisted.sql), + } + } + + return { + ...base, + _tag: 'database', + database_identifier: persisted.source.database_identifier, + uncheckedSql: untrustedSql(persisted.sql), + } +} + const readPersistedDrafts = (storage: StorageLike, projectRef: string) => { const raw = storage.getItem(LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS(projectRef)) if (!raw) return {} as PersistedExplorerQueryDrafts @@ -103,6 +165,17 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage { timeout: ReturnType; persist: () => void } >() + const persistDraft = (draft: ExplorerQueryDraft) => { + const persisted = readPersistedDrafts(storage, draft.projectRef) + persisted[draft.id] = { + name: draft.name, + source: toQuerySourceBinding(draft), + sql: draft.uncheckedSql, + updatedAt: draft.updatedAt, + } + writePersistedDrafts(storage, draft.projectRef, persisted) + } + const state = proxy({ drafts: {} as Record, results: {} as Record, @@ -120,19 +193,19 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage sql?: string source?: QuerySourceBinding }) => { - const draft: ExplorerQueryDraft = { + const draft = toDraft({ id, projectRef, - name, - source: querySourceBindingSchema.parse(source), - uncheckedSql: untrustedSql(sql), - updatedAt: Date.now(), - } - state.drafts[id] = draft + persisted: { + name, + source: querySourceBindingSchema.parse(source), + sql, + updatedAt: Date.now(), + }, + }) - const persisted = readPersistedDrafts(storage, projectRef) - persisted[id] = { name, source: draft.source, sql, updatedAt: draft.updatedAt } - writePersistedDrafts(storage, projectRef, persisted) + state.drafts[id] = draft + persistDraft(draft) return id }, @@ -143,17 +216,22 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage const persisted = readPersistedDrafts(storage, projectRef)[id] if (!persisted) return false - state.drafts[id] = { - id, - projectRef, - name: persisted.name, - source: persisted.source, - uncheckedSql: untrustedSql(persisted.sql), - updatedAt: persisted.updatedAt, - } + state.drafts[id] = toDraft({ id, projectRef, persisted }) return true }, + /** + * Applies an edit to a draft. The draft is rebuilt rather than mutated in place, since + * a backend change changes which brand its SQL carries; a stale result from the old + * backend is dropped, because another engine returns unrelated columns. + * + * NOTE — see `changeCellSource`: keeping the query text across a backend change is very + * likely not what the user wants, since the dialects differ, and is kept for now only + * because it destroys nothing. Worth revisiting alongside the notebook-cell behavior. + * + * A rename or a source change is a discrete action, so it writes through immediately; + * SQL keystrokes are debounced by `EXPLORER_QUERY_PERSIST_DELAY`. + */ updateDraft: ({ id, name, @@ -168,29 +246,28 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage const draft = state.drafts[id] if (!draft) return - if (name !== undefined) draft.name = name - if (source !== undefined) { - draft.source = querySourceBindingSchema.parse(source) - delete state.results[id] - } - if (sql !== undefined) draft.uncheckedSql = untrustedSql(sql) - draft.updatedAt = Date.now() + const nextSource = source === undefined ? undefined : querySourceBindingSchema.parse(source) + if (nextSource !== undefined && nextSource._tag !== draft._tag) delete state.results[id] + + state.drafts[id] = toDraft({ + id, + projectRef: draft.projectRef, + persisted: { + name: name ?? draft.name, + source: nextSource ?? toQuerySourceBinding(draft), + sql: sql ?? draft.uncheckedSql, + updatedAt: Date.now(), + }, + }) const persist = () => { const pending = pendingPersistence.get(id) if (pending) clearTimeout(pending.timeout) pendingPersistence.delete(id) + const currentDraft = state.drafts[id] if (!currentDraft) return - - const persisted = readPersistedDrafts(storage, currentDraft.projectRef) - persisted[id] = { - name: currentDraft.name, - source: currentDraft.source, - sql: currentDraft.uncheckedSql, - updatedAt: currentDraft.updatedAt, - } - writePersistedDrafts(storage, currentDraft.projectRef, persisted) + persistDraft(currentDraft) } const pending = pendingPersistence.get(id) diff --git a/apps/studio/tests/pages/new/[slug].test.tsx b/apps/studio/tests/pages/new/[slug].test.tsx index 652b1f59c8778..46b5ddc229d77 100644 --- a/apps/studio/tests/pages/new/[slug].test.tsx +++ b/apps/studio/tests/pages/new/[slug].test.tsx @@ -792,6 +792,37 @@ describe('project creation wizard', () => { expect(body.custom_supabase_internal_requests).toBeUndefined() }) + // Regression (FE-4174): in local dev, region choice isn't restricted to the fixed HA + // region (unlike staging), so a manual selection should be respected. onSubmit used to + // resolve the HA region purely from highAvailabilityRegionCode without that same + // exception, silently sending the fixed region regardless of what was displayed. + test('submits the manually selected region in local dev instead of the fixed HA default', async () => { + vi.stubEnv('NEXT_PUBLIC_ENVIRONMENT', 'local') + try { + mockWizardEndpoints({ availableRegions: AVAILABLE_REGIONS_WITH_FRANKFURT }) + const onRequest = vi.fn() + mockCreateProject(onRequest) + + await renderWizard() + + await fillProjectName('Local HA Region Project') + await generateAndWaitForStrongPassword() + + await user.click(await screen.findByRole('switch', { name: 'Enable high availability' })) + // Local dev stacks aren't restricted to the fixed HA region, so the user can still + // pick a different one from the (unrestricted) list. + await selectRegion(/East US/) + expect(getSelectTriggerByLabel('Region')).toHaveTextContent('East US (North Virginia)') + + fireEvent.click(screen.getByRole('button', { name: 'Create new project' })) + + await waitFor(() => expect(onRequest).toHaveBeenCalled()) + expect(onRequest.mock.calls[0][0].region_selection).toMatchObject({ code: 'us-east-1' }) + } finally { + vi.unstubAllEnvs() + } + }) + test('forces the high availability region over a manually selected region and restores it', async () => { vi.stubEnv('NEXT_PUBLIC_ENVIRONMENT', 'staging') try { diff --git a/examples/auth/hono/package.json b/examples/auth/hono/package.json index b22cd49e2c9bb..3038a234fa246 100644 --- a/examples/auth/hono/package.json +++ b/examples/auth/hono/package.json @@ -15,6 +15,7 @@ "@hono/vite-build": "^1.1.0", "@hono/vite-dev-server": "^0.17.0", "@types/node": "^20.11.17", + "typescript": "^5.6.2", "vite": "^5.4.2" } } diff --git a/examples/auth/hono/src/client.tsx b/examples/auth/hono/src/client.tsx new file mode 100644 index 0000000000000..a8ccf01c1bb6e --- /dev/null +++ b/examples/auth/hono/src/client.tsx @@ -0,0 +1,109 @@ +import type { AppType } from '.' +import { createBrowserClient } from '@supabase/ssr' +import { hc } from 'hono/client' +import { useEffect, useState } from 'hono/jsx' +import { render } from 'hono/jsx/dom' + +const client = hc('/') + +const supabase = createBrowserClient( + import.meta.env.VITE_SUPABASE_URL!, + import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY! +) + +function App() { + const [user, setUser] = useState(null) + // Check client-side if user is logged in: + useEffect(() => { + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((event, session) => { + console.log('Auth event:', event) + if (event === 'SIGNED_OUT') { + setUser(null) + } else { + setUser(session?.user!) + } + }) + + return () => subscription.unsubscribe() + }, []) + + return ( + <> +

Hono Supabase Auth Example!

+

Sign in

+ {!user ? ( + + ) : ( +
+ +
+ )} +

Example of API fetch()

+ +

Example of database read

+

Sign in anonymously, then open the instruments list.

+
Get instruments + + ) +} + +function SignIn() { + return ( + <> +

+ Read about and enable{' '} + + anonymous sign-ins here! + +

+ + + ) +} + +const UserDetailsButton = () => { + const [response, setResponse] = useState(null) + + const handleClick = async () => { + const response = await client.api.user.$get() + const data = await response.json() + const headers = Array.from(response.headers.entries()).reduce>( + (acc, [key, value]) => { + acc[key] = value + return acc + }, + {} + ) + const fullResponse = { + url: response.url, + status: response.status, + headers, + body: data, + } + setResponse(JSON.stringify(fullResponse, null, 2)) + } + + return ( +
+ + {response &&
{response}
} +
+ ) +} + +const root = document.getElementById('root')! +render(, root) diff --git a/examples/auth/hono/src/index.tsx b/examples/auth/hono/src/index.tsx index 9a97606c35a72..584a57dda7678 100644 --- a/examples/auth/hono/src/index.tsx +++ b/examples/auth/hono/src/index.tsx @@ -1,16 +1,19 @@ import { Hono } from 'hono' +import { csrf } from 'hono/csrf' + import { getSupabase, supabaseMiddleware } from './middleware/auth.middleware' const app = new Hono() +app.use('*', csrf()) app.use('*', supabaseMiddleware()) -app.get('/api/user', async (c) => { +const routes = app.get('/api/user', async (c) => { const supabase = getSupabase(c) const { data, error } = await supabase.auth.getClaims() if (error) console.log('error', error) - if (!data?.user) { + if (!data?.claims) { return c.json({ message: 'You are not logged in.', }) @@ -18,23 +21,49 @@ app.get('/api/user', async (c) => { return c.json({ message: 'You are logged in!', - userId: data.user, + userId: data.claims.sub, }) }) -app.get('/signout', async (c) => { +app.post('/signout', async (c) => { const supabase = getSupabase(c) await supabase.auth.signOut() console.log('Signed out server-side!') - return c.redirect('/') + return c.redirect('/', 303) }) -// Retrieve data with RLS enabled. The signed in user's auth token is automatically sent. -app.get('/countries', async (c) => { +app.get('/instruments', async (c) => { const supabase = getSupabase(c) - const { data, error } = await supabase.from('countries').select('*') - if (error) console.log(error) + const { data, error } = await supabase.from('instruments').select('*') + + if (error) { + console.error(error) + return c.json({ error: error.message }, 500) + } + return c.json(data) }) +export type AppType = typeof routes + +app.get('/', (c) => { + return c.html( + + + + + + {import.meta.env.PROD ? ( +