From 2b26da360e27d3c95ef19bc088840dc8984e0541 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:43:23 +1000 Subject: [PATCH 1/6] show API and AWS authorization errors inline (#48471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Bug fix and design-system update. ## What is the current behavior? API authorisation and AWS Marketplace action failures use transient toasts. The inline action-error treatment introduced for organisation invitations is implemented locally. ## What is the new behavior? Action failures remain visible below their actions and clear on retry or organisation change. This PR adds a shared `InterstitialActionError` component, updates the connect-interstitial guidance and demo to use it, and retroactively applies it to `OrganizationInvite`. Mutation errors are read directly from their mutation hooks rather than copied into component state. | Before | After | | --- | --- | | Authorize API Access Supabase | Authorize API Access Supabase | | Link AWS Marketplace Supabase | Link AWS Marketplace Supabase | _Note since taking that AWS screenshot: the error message now replaces the prior footer text. I.e. “Learn more about billing through AWS.” is now gone when an error message is present._ ## To test ### AWS Marketplace For a visual check with local Studio running: 1. In `apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx`, immediately before `if (!buyerId)`, temporarily add: ```tsx return (
undefined} />

Learn more {' '} about billing through AWS.

) ``` 2. Open `http://localhost:8082/aws-marketplace-onboarding?buyer_id=test` while signed in. 3. Confirm the error appears below **Link organization** with a divider. Remove the temporary return before committing anything. ### API authorization For a visual check with local Studio running: 1. In `apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx`, immediately before `if (isLoading)`, temporarily add: ```tsx return ( undefined} onApprove={() => undefined} onDecline={() => undefined} /> ) ``` 2. Open `http://localhost:8082/authorize?auth_id=test` while signed in. 3. Confirm the error appears below the authorisation actions with a divider. Remove the temporary return before committing anything. ## Summary by CodeRabbit * **New Features** * Added consistent inline error messaging for authorization, organization invitations, and AWS Marketplace onboarding. * Error messages now appear within the relevant interstitial and replace supporting footer content until resolved. * Retry and action buttons remain available after failed operations. * **Bug Fixes** * AWS Marketplace linking failures no longer trigger toast notifications. * Billing guidance is hidden while an onboarding error is displayed. * **Tests** * Added coverage for authorization, cancellation, and AWS Marketplace failure states. --------- Co-authored-by: Joshen Lim --- .../ui-patterns/connect-interstitials.mdx | 14 +-- .../connect-interstitial-action-error.tsx | 7 +- .../example/connect-interstitial-shared.tsx | 12 +++ .../ApiAuthorization.Form.tsx | 19 +++- .../ApiAuthorization.Valid.tsx | 41 ++++++--- .../AwsMarketplaceOnboarding.tsx | 90 ++++++++++++------- .../OrganizationInvite/OrganizationInvite.tsx | 11 +-- .../components/layouts/InterstitialLayout.tsx | 12 +++ .../components/ApiAuthorization.test.tsx | 38 ++++++++ .../pages/aws-marketplace-onboarding.test.tsx | 27 ++++++ 10 files changed, 203 insertions(+), 68 deletions(-) diff --git a/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx b/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx index 99c0671ea699e..4457f7ef69cbe 100644 --- a/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx +++ b/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx @@ -217,20 +217,14 @@ Match feedback to its scope: feedback for a failure the user needs to resolve on the current card. ```tsx -{ - actionError && ( -
-

- {actionError} -

-
- ) -} + ``` Clear stale action feedback when the user retries or changes a relevant selection. Error copy should say what failed and, when it is not obvious, what -the user can do next. +the user can do next. When passive supporting copy occupies the same footer +region, replace it with the action error until the error is cleared instead of +stacking both messages. Cancel -
-

- Failed to authorize Stripe Projects. Please try again. -

-
+ diff --git a/apps/design-system/registry/default/example/connect-interstitial-shared.tsx b/apps/design-system/registry/default/example/connect-interstitial-shared.tsx index e9100175ae04e..49c53778e8032 100644 --- a/apps/design-system/registry/default/example/connect-interstitial-shared.tsx +++ b/apps/design-system/registry/default/example/connect-interstitial-shared.tsx @@ -120,6 +120,18 @@ export function InterstitialShell({ ) } +export function InterstitialActionError({ error }: { error?: React.ReactNode }) { + if (!error) return null + + return ( +
+

+ {error} +

+
+ ) +} + export function SignOutButton() { return - {redirectUrl && ( + + {!actionError && redirectUrl && (

Authorizing will redirect you to {redirectUrl} diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx index 621e49ea9d7ce..6d1ab488c80f4 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx @@ -142,38 +142,57 @@ export function ApiAuthorizationValidScreen({ } = useApiAuthorizationQuery({ id: auth_id }) const isApproved = (requester?.approved_at ?? null) !== null - const { mutate: approveRequest } = useApiAuthorizationApproveMutation({ + const { + mutate: approveRequest, + error: approveError, + reset: resetApproveError, + } = useApiAuthorizationApproveMutation({ onSuccess: (res) => { window.location.href = res.url }, + onError: () => { + setApprovalState('indeterminate') + }, }) - const { mutate: declineRequest } = useApiAuthorizationDeclineMutation({ + const { + mutate: declineRequest, + error: declineError, + reset: resetDeclineError, + } = useApiAuthorizationDeclineMutation({ onSuccess: () => { toast.success('Declined API authorization request') navigate('/organizations') }, + onError: () => { + setApprovalState('indeterminate') + }, }) + const actionError = approveError + ? `Failed to authorize request: ${approveError.message}` + : declineError + ? `Failed to cancel authorization request: ${declineError.message}` + : undefined + const resetActionError = () => { + resetApproveError() + resetDeclineError() + } const onApproveRequest = form.handleSubmit((values) => { if (approvalState !== 'indeterminate') { return } + resetActionError() setApprovalState('approving') - approveRequest( - { id: auth_id, slug: values.selectedOrgSlug }, - { onError: () => setApprovalState('indeterminate') } - ) + approveRequest({ id: auth_id, slug: values.selectedOrgSlug }) }) const onDeclineRequest = form.handleSubmit((values) => { if (approvalState !== 'indeterminate') { return } + resetActionError() setApprovalState('declining') - declineRequest( - { id: auth_id, slug: values.selectedOrgSlug }, - { onError: () => setApprovalState('indeterminate') } - ) + declineRequest({ id: auth_id, slug: values.selectedOrgSlug }) }) if (isLoading) { @@ -224,6 +243,8 @@ export function ApiAuthorizationValidScreen({ requester={effectiveRequester} requestedOrganizationSlug={effectiveOrganizationSlug} organizations={effectiveOrganizationsState} + actionError={actionError} + onOrganizationChange={resetActionError} onApprove={onApproveRequest} onDecline={onDeclineRequest} /> diff --git a/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx index 1dd3511cd3d6f..42fababc170ff 100644 --- a/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx +++ b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx @@ -1,6 +1,5 @@ import Link from 'next/link' -import { useEffect, useMemo, useState } from 'react' -import { toast } from 'sonner' +import { useEffect, useEffectEvent, useMemo, useState } from 'react' import { Button } from 'ui' import { Admonition } from 'ui-patterns/Admonition' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' @@ -20,7 +19,10 @@ import { type CloudMarketplaceOnboardingInfo, } from '@/components/interfaces/Organization/CloudMarketplace/cloud-marketplace-query' import { NewAwsMarketplaceOrgModal } from '@/components/interfaces/Organization/CloudMarketplace/NewAwsMarketplaceOrgModal' -import { InterstitialAccountRow } from '@/components/layouts/InterstitialLayout' +import { + InterstitialAccountRow, + InterstitialActionError, +} from '@/components/layouts/InterstitialLayout' import { InlineLink } from '@/components/ui/InlineLink' import { useOrganizationLinkAwsMarketplaceMutation } from '@/data/organizations/organization-link-aws-marketplace-mutation' import { useOrganizationsQuery } from '@/data/organizations/organizations-query' @@ -37,12 +39,6 @@ export const AwsMarketplaceOnboardingScreen = ({ buyerId }: { buyerId?: string } const [linkedOrgSlug, setLinkedOrgSlug] = useState(null) const [showOrgCreationDialog, setShowOrgCreationDialog] = useState(false) - useEffect(() => { - setSelectedOrgSlug(null) - setLinkedOrgSlug(null) - setShowOrgCreationDialog(false) - }, [buyerId]) - const { data: organizations, error: organizationsError, @@ -72,15 +68,20 @@ export const AwsMarketplaceOnboardingScreen = ({ buyerId }: { buyerId?: string } { enabled: !!shouldLoadOnboardingInfo } ) - const { mutate: linkOrganization, isPending: isLinkingOrganization } = - useOrganizationLinkAwsMarketplaceMutation({ - onSuccess: (_, variables) => { - setLinkedOrgSlug(variables.slug) - }, - onError: (error) => { - toast.error(error.message, { duration: 7_000 }) - }, - }) + const { + mutate: linkOrganization, + isPending: isLinkingOrganization, + error: linkOrganizationError, + reset: resetLinkOrganizationError, + } = useOrganizationLinkAwsMarketplaceMutation({ + onSuccess: (_, variables) => { + setLinkedOrgSlug(variables.slug) + }, + onError: () => undefined, + }) + const linkError = linkOrganizationError + ? `Failed to link organization: ${linkOrganizationError.message}` + : undefined const effectiveOrganizations = useMemo( () => organizations ?? EMPTY_ORGANIZATIONS, @@ -142,6 +143,18 @@ export const AwsMarketplaceOnboardingScreen = ({ buyerId }: { buyerId?: string } } }, [onboardingInfo, effectiveOrganizations]) + const resetOnBuyerChange = useEffectEvent(() => { + setSelectedOrgSlug(null) + setLinkedOrgSlug(null) + setShowOrgCreationDialog(false) + resetLinkOrganizationError() + }) + + useEffect(() => { + resetOnBuyerChange() + // eslint-disable-next-line react-hooks/exhaustive-deps -- useEffectEvent fn intentionally not a dep (eslint-plugin-react-hooks v5 doesn't recognize stable useEffectEvent yet) + }, [buyerId]) + if (!buyerId) { return ( @@ -249,6 +262,7 @@ export const AwsMarketplaceOnboardingScreen = ({ buyerId }: { buyerId?: string } const primaryAction = hasLinkableOrganizations ? () => { if (!selectedOrgSlug || !buyerId) return + resetLinkOrganizationError() linkOrganization({ slug: selectedOrgSlug, buyerId }) } : () => setShowOrgCreationDialog(true) @@ -278,7 +292,10 @@ export const AwsMarketplaceOnboardingScreen = ({ buyerId }: { buyerId?: string } } selectedSlug={selectedOrgSlug} disabled={isLinking} - onSelect={setSelectedOrgSlug} + onSelect={(slug) => { + setSelectedOrgSlug(slug) + resetLinkOrganizationError() + }} createLabel={hasLinkableOrganizations ? 'Create new organization' : undefined} onCreate={hasLinkableOrganizations ? () => setShowOrgCreationDialog(true) : undefined} /> @@ -292,21 +309,26 @@ export const AwsMarketplaceOnboardingScreen = ({ buyerId }: { buyerId?: string } )}

- -

- - Learn more - {' '} - about billing through AWS. -

+
+ + +
+ {!linkError && ( +

+ + Learn more + {' '} + about billing through AWS. +

+ )}
diff --git a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx index 7e62eb43785b2..bdd1c39c301d1 100644 --- a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx @@ -13,6 +13,7 @@ import { import { OrganizationInviteError } from './OrganizationInviteError' import { InterstitialAccountRow, + InterstitialActionError, InterstitialLayout, SupabaseLogo, } from '@/components/layouts/InterstitialLayout' @@ -197,13 +198,9 @@ export const OrganizationInvite = () => { - {joinError && ( -
-

- Failed to join organization: {joinError.message} -

-
- )} + ) diff --git a/apps/studio/components/layouts/InterstitialLayout.tsx b/apps/studio/components/layouts/InterstitialLayout.tsx index 1764b546c7259..d7597dd32a450 100644 --- a/apps/studio/components/layouts/InterstitialLayout.tsx +++ b/apps/studio/components/layouts/InterstitialLayout.tsx @@ -204,3 +204,15 @@ export const InterstitialAccountRow = ({ ) + +export const InterstitialActionError = ({ error }: { error?: ReactNode }) => { + if (!error) return null + + return ( +
+

+ {error} +

+
+ ) +} diff --git a/apps/studio/tests/components/ApiAuthorization.test.tsx b/apps/studio/tests/components/ApiAuthorization.test.tsx index 477bc847fd108..4f88fadf43e1c 100644 --- a/apps/studio/tests/components/ApiAuthorization.test.tsx +++ b/apps/studio/tests/components/ApiAuthorization.test.tsx @@ -419,6 +419,25 @@ describe('ApiAuthorizationScreen', () => { await user.click(screen.getByRole('button', { name: /Authorize Test App/ })) await waitFor(() => expect(approveHandler).toHaveBeenCalled()) }) + + test('shows an approval failure inline and keeps the action available', async () => { + const user = userEvent.setup() + mockBothEndpoints() + addAPIMock({ + method: 'post', + path: '/platform/organizations/:slug/oauth/authorizations/:id', + response: () => + HttpResponse.json({ message: 'Authorization failed' }, { status: 500 }), + }) + renderScreen() + + await user.click(await screen.findByRole('button', { name: /Authorize Test App/ })) + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Failed to authorize request: Authorization failed' + ) + expect(screen.getByRole('button', { name: /Authorize Test App/ })).toBeEnabled() + }) }) describe('decline action', () => { @@ -439,6 +458,25 @@ describe('ApiAuthorizationScreen', () => { await waitFor(() => expect(declineHandler).toHaveBeenCalled()) await waitFor(() => expect(navigate).toHaveBeenCalledWith('/organizations')) }) + + test('shows a cancellation failure inline and keeps the action available', async () => { + const user = userEvent.setup() + mockBothEndpoints() + addAPIMock({ + method: 'delete', + path: '/platform/organizations/:slug/oauth/authorizations/:id', + response: () => + HttpResponse.json({ message: 'Cancellation failed' }, { status: 500 }), + }) + renderScreen() + + await user.click(await screen.findByRole('button', { name: 'Cancel' })) + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Failed to cancel authorization request: Cancellation failed' + ) + expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled() + }) }) describe('form validation', () => { diff --git a/apps/studio/tests/pages/aws-marketplace-onboarding.test.tsx b/apps/studio/tests/pages/aws-marketplace-onboarding.test.tsx index 1422fe9d8b92f..466f16385bb27 100644 --- a/apps/studio/tests/pages/aws-marketplace-onboarding.test.tsx +++ b/apps/studio/tests/pages/aws-marketplace-onboarding.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event' import { platformComponents as components } from 'api-types' import { LOCAL_STORAGE_KEYS } from 'common' import { http, HttpResponse } from 'msw' +import { toast } from 'sonner' import { beforeEach, describe, expect, test, vi } from 'vitest' import { AwsMarketplaceOnboardingScreen } from '@/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding' @@ -17,6 +18,10 @@ import { createMockOrganizationResponse } from '@/tests/helpers' import { customRender } from '@/tests/lib/custom-render' import { addAPIMock, mswServer } from '@/tests/lib/msw' +vi.mock('sonner', () => ({ + toast: { error: vi.fn() }, +})) + type OrganizationResponse = components['schemas']['OrganizationResponse'] const DEFAULT_PROFILE_CONTEXT: ProfileContextType = { @@ -170,6 +175,28 @@ describe('AwsMarketplaceOnboardingScreen', () => { await screen.findByText('Organization linked') }) + test('renders a link failure inline and keeps the action available', async () => { + const user = userEvent.setup() + mockAwsEndpoints() + mswServer.use( + http.put(`${API_URL}/platform/organizations/:slug/cloud-marketplace/link`, () => + HttpResponse.json({ message: 'Marketplace link failed' }, { status: 500 }) + ) + ) + + renderScreen() + + await user.click(await screen.findByRole('button', { name: /Acme Production/ })) + await user.click(screen.getByRole('button', { name: 'Link organization' })) + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Failed to link organization: Marketplace link failed' + ) + expect(toast.error).not.toHaveBeenCalled() + expect(screen.queryByText(/Learn more/)).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Link organization' })).toBeEnabled() + }) + test('creates an AWS-managed organization with buyerId and returns to linked state', async () => { const user = userEvent.setup() let createRequest: unknown From f7454cf94ee2d2f52d5ae7ffb80057bf8a65ee56 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:57:27 +1000 Subject: [PATCH 2/6] feat(studio): oauth impersonation warning on authorize (#48162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Feature + docs. Stacked on #48161 (logo contract / [DEPR-604](https://linear.app/supabase/issue/DEPR-604/define-connect-logo-asset-and-variant-contract)). ## What is the current behavior? After #48161, curated logos only resolve from allowlisted `redirect_uri` hosts. A requester can still present a trusted partner **name** (e.g. Claude) while redirecting to an unrelated remote host; the UI shows Supabase alone but does not call out the mismatch. ## What is the new behavior? - Shows a caution admonition when the requester name looks like a trusted partner (Claude, Cursor, ChatGPT/OpenAI, Perplexity) but `redirect_uri` is a **remote** host outside that partner's allowlist. - Skips localhost / loopback redirects for the caution (common for local MCP clients); those still get curated logos when the name matches a trusted partner. - Highlights the footer redirect URL in warning colour when the caution is shown. - Documents the behaviour in the Connect interstitials pattern. ### To test Real MCP clients (Claude, Cursor, etc.) only send users to **production** `/authorize`, so you cannot drive a local or preview Studio build from those tools. Use a Network override instead: 1. Start Studio and sign in (`pnpm dev:studio`, or use the [Vercel preview](https://studio-staging-git-danny-oauth-impersonation-warning-supabase.vercel.app/)). 2. Open `/dashboard/authorize?auth_id=foo` (any `auth_id` is fine; the real response may 404) ([Vercel preview](https://studio-staging-git-danny-oauth-impersonation-warning-supabase.vercel.app/dashboard/authorize?auth_id=foo)). 3. DevTools → **Network** → find `GET …/platform/oauth/authorizations/foo` (or whatever id you used). 4. Right-click → **Override content** (enable Local Overrides / pick a folder if prompted). 5. Paste one of the payloads below (status **200**), save, then reload the authorize page. 6. Keep `expires_at` in the future so the request does not look expired. #### Impersonation caution (trusted name + remote non-allowlisted redirect) Expect: - Supabase alone (no curated Claude mark) - Caution: “Redirect does not match this app name” - Footer redirect URL in warning colour ```json { "name": "Claude", "website": "https://claude.ai", "icon": null, "domain": "claude.ai", "redirect_uri": "https://evil.com/callback", "expires_at": "2099-01-01T00:00:00.000Z", "scopes": ["organizations:read", "projects:read"], "approved_at": null, "registration_type": "dynamic" } ``` | Preview | | --- | | Authorize Claude Supabase | #### Localhost MCP: no caution Expect curated Claude + Supabase pair (name match + loopback), **no** caution, normal footer colour. Local MCP clients often use loopback redirects. ```json { "name": "Claude", "website": "https://claude.ai", "icon": null, "domain": "claude.ai", "redirect_uri": "http://127.0.0.1:42813/callback", "expires_at": "2099-01-01T00:00:00.000Z", "scopes": ["organizations:read", "projects:read"], "approved_at": null, "registration_type": "dynamic" } ``` | Preview | | --- | | Authorize Claude Supabase | #### Legitimate curated partner: no caution Expect curated Cursor + Supabase pair, no admonition, normal footer colour. ```json { "name": "Cursor", "website": "https://cursor.com", "icon": null, "domain": "cursor.com", "redirect_uri": "https://cursor.com/callback", "expires_at": "2099-01-01T00:00:00.000Z", "scopes": ["organizations:read", "projects:read"], "approved_at": null, "registration_type": "dynamic" } ``` | Preview | | --- | | 56164 | #### Unrelated name + remote redirect: no caution Expect Supabase alone (no icon), no admonition. ```json { "name": "Acme Tools", "website": "https://evil.com", "icon": null, "domain": "evil.com", "redirect_uri": "https://evil.com/callback", "expires_at": "2099-01-01T00:00:00.000Z", "scopes": ["organizations:read", "projects:read"], "approved_at": null, "registration_type": "dynamic" } ``` | Preview | | --- | | Authorize Acme Tools Supabase | ## Summary by CodeRabbit ## Summary by CodeRabbit - **New Features** - Added an OAuth caution when a requester name matches a known partner but uses an unapproved remote redirect host. - Improved trusted partner logo selection for localhost/loopback redirects while preserving safe fallbacks for untrusted redirects. - **Documentation** - Updated Connect interstitial guidance for redirect mismatches and localhost/loopback behavior. - **Tests** - Expanded coverage for caution visibility, messaging, localhost logo pairing, and trusted redirect scenarios. --- .../ui-patterns/connect-interstitials.mdx | 11 ++- .../ApiAuthorization.Form.tsx | 18 +++- .../OAuthApps/AuthorizeRequesterDetails.tsx | 25 ++++- .../OAuthApps/OAuthApps.utils.test.ts | 89 ++++++++++++++++-- .../Organization/OAuthApps/OAuthApps.utils.ts | 91 +++++++++++++++++-- .../components/ApiAuthorization.test.tsx | 41 ++++++++- 6 files changed, 249 insertions(+), 26 deletions(-) diff --git a/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx b/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx index 4457f7ef69cbe..0ef1e2da938da 100644 --- a/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx +++ b/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx @@ -165,12 +165,17 @@ the pair should use the dark set together. **Where logos come from on `/authorize`** -- Curated partner logos resolve from allowlisted `redirect_uri` hosts only, - not from self-asserted `name` or `website`. Those pairs may use theme tiles - and dark assets when the partner has them. +- Curated partner logos resolve from allowlisted `redirect_uri` hosts, or from + a trusted partner name when `redirect_uri` is localhost / loopback (local MCP + clients). Do not resolve curated logos from self-asserted `name` or `website` + on a remote host. Those pairs may use theme tiles and dark assets when the + partner has them. - Published organisation OAuth app icons uploaded in Studio remain trusted remote images, paired with forced-light tiles on both sides. - Everything else falls back to `SupabaseLogo` alone. +- If the requester name looks like a known partner but `redirect_uri` is a + remote host outside that partner's allowlist, show a caution admonition. + Localhost MCP redirects are excluded. ## Account row diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Form.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Form.tsx index 8a858ec839d07..c5269d6ecfb77 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Form.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Form.tsx @@ -21,8 +21,10 @@ import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import type { ApprovalState, IApprovalFormSchema } from './ApiAuthorization.Schema' import { AuthorizeConnectLogo, + AuthorizeImpersonationWarning, AuthorizeRequesterDetails, } from '@/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails' +import { getOAuthImpersonationWarning } from '@/components/interfaces/Organization/OAuthApps/OAuthApps.utils' import { InterstitialActionError, InterstitialLayout, @@ -115,6 +117,10 @@ export function ApiAuthorizationMainView({ ) : ( <> + {organizations._tag === 'loading' && } {organizations._tag === 'error' && ( @@ -312,6 +318,13 @@ function FormFooter({ onDecline, onApprove, }: FormFooterProps): ReactNode { + const hasImpersonationWarning = Boolean( + getOAuthImpersonationWarning({ + name: requester.name, + redirectUri: requester.redirect_uri, + }) + ) + return (

- Authorizing will redirect you to {redirectUrl} + Authorizing will redirect you to{' '} + + {redirectUrl} +

)} diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx index ddff1416f86a7..ea477f8749466 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx @@ -11,10 +11,11 @@ import { CollapsibleContent, CollapsibleTrigger, } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' import { InfoTooltip } from 'ui-patterns/info-tooltip' import { PERMISSIONS_DESCRIPTIONS } from './OAuthApps.constants' -import { getRequesterLogo } from './OAuthApps.utils' +import { getOAuthImpersonationWarning, getRequesterLogo } from './OAuthApps.utils' import { CONNECT_LOGO_LIGHT_TILE_CLASSNAME, LogoBox, @@ -196,10 +197,11 @@ export const AuthorizeConnectLogo = ({ () => getRequesterLogo({ icon, + name, redirectUri, useDarkVariant: resolvedTheme === 'dark', }), - [icon, redirectUri, resolvedTheme] + [icon, name, redirectUri, resolvedTheme] ) const hasUsableLogo = Boolean(logo.src) && failedIcon !== logo.src @@ -227,6 +229,25 @@ export const AuthorizeConnectLogo = ({ ) } +export const AuthorizeImpersonationWarning = ({ + name, + redirectUri, +}: { + name: string + redirectUri?: string | null +}) => { + const warning = getOAuthImpersonationWarning({ name, redirectUri }) + if (!warning) return null + + return ( + + ) +} + export const AuthorizeRequesterDetails = ({ name, scopes, diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.test.ts b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.test.ts index cce0b1b7fe911..46f5f78ceeb1d 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.test.ts +++ b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from 'vitest' import { findTrustedPartnerByRedirectUri, + getOAuthImpersonationWarning, getRedirectHostname, getRequesterLogo, hostMatchesAllowlist, @@ -60,9 +61,10 @@ describe('findTrustedPartnerByRedirectUri', () => { }) describe('getRequesterLogo', () => { - test('uses curated assets only when redirect host is allowlisted', () => { + test('uses curated assets when redirect host is allowlisted', () => { const trusted = getRequesterLogo({ icon: null, + name: 'Claude', redirectUri: 'https://claude.ai/api/mcp/auth_callback', useDarkVariant: false, }) @@ -70,22 +72,97 @@ describe('getRequesterLogo', () => { src: getMcpClientIconSrc({ icon: 'claude', useDarkVariant: false }), isKnownClient: true, }) + }) - const namedOnly = getRequesterLogo({ - icon: null, - redirectUri: 'https://evil.com/callback', - useDarkVariant: false, + test('uses curated assets for localhost when the name matches a trusted partner', () => { + expect( + getRequesterLogo({ + icon: null, + name: 'Claude', + redirectUri: 'http://127.0.0.1:42813/callback', + useDarkVariant: false, + }) + ).toEqual({ + src: getMcpClientIconSrc({ icon: 'claude', useDarkVariant: false }), + isKnownClient: true, }) - expect(namedOnly).toEqual({ src: '', isKnownClient: false }) + }) + + test('does not use curated assets from name alone on a remote host', () => { + expect( + getRequesterLogo({ + icon: null, + name: 'Claude', + redirectUri: 'https://evil.com/callback', + useDarkVariant: false, + }) + ).toEqual({ src: '', isKnownClient: false }) }) test('falls back to the supplied icon URL when redirect is not trusted', () => { expect( getRequesterLogo({ icon: 'https://example.com/icon.png', + name: 'Acme', redirectUri: 'https://evil.com/callback', useDarkVariant: false, }) ).toEqual({ src: 'https://example.com/icon.png', isKnownClient: false }) }) }) + +describe('getOAuthImpersonationWarning', () => { + test('warns when a trusted name redirects to a remote non-allowlisted host', () => { + expect( + getOAuthImpersonationWarning({ + name: 'Claude Desktop', + redirectUri: 'https://evil.com/callback', + }) + ).toEqual({ + brandDisplayName: 'Claude', + redirectHost: 'evil.com', + }) + }) + + test('skips localhost MCP redirects', () => { + expect( + getOAuthImpersonationWarning({ + name: 'Claude', + redirectUri: 'http://127.0.0.1:42813/callback', + }) + ).toBe(null) + }) + + test('skips when redirect host matches the named partner', () => { + expect( + getOAuthImpersonationWarning({ + name: 'Claude', + redirectUri: 'https://claude.ai/api/mcp/auth_callback', + }) + ).toBe(null) + }) + + test('skips when the name does not match a trusted partner', () => { + expect( + getOAuthImpersonationWarning({ + name: 'Acme Tools', + redirectUri: 'https://evil.com/callback', + }) + ).toBe(null) + }) + + test('skips missing or unparsable redirect URIs', () => { + expect( + getOAuthImpersonationWarning({ + name: 'Claude', + redirectUri: null, + }) + ).toBe(null) + expect( + getOAuthImpersonationWarning({ + name: 'Claude', + redirectUri: 'not-a-url', + }) + ).toBe(null) + }) +}) diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts index bc0df5ef05ee4..e8f942cf7a0ca 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts +++ b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts @@ -1,6 +1,8 @@ import { getMcpClientIconSrc } from 'ui-patterns/McpUrlBuilder' export type TrustedOAuthPartner = { + /** Substrings matched against the requester name (case-insensitive). */ + nameMatchers: readonly string[] displayName: string icon: string hasDistinctDarkIcon: boolean @@ -10,28 +12,34 @@ export type TrustedOAuthPartner = { /** * High-traffic MCP / OAuth partners with curated Connect logos. - * Logos resolve from redirect_uri host only — never from self-asserted name/website. + * Logos resolve from allowlisted redirect_uri hosts, or from a trusted name when + * redirect_uri is localhost / loopback (common for local MCP clients). + * Never from self-asserted name alone on a remote host. */ export const TRUSTED_OAUTH_PARTNERS: readonly TrustedOAuthPartner[] = [ { + nameMatchers: ['claude'], displayName: 'Claude', icon: 'claude', hasDistinctDarkIcon: false, redirectHosts: ['claude.ai', 'anthropic.com'], }, { + nameMatchers: ['cursor'], displayName: 'Cursor', icon: 'cursor', hasDistinctDarkIcon: true, redirectHosts: ['cursor.com', 'cursor.sh'], }, { + nameMatchers: ['chatgpt', 'openai'], displayName: 'ChatGPT', icon: 'openai', hasDistinctDarkIcon: true, redirectHosts: ['chatgpt.com', 'openai.com'], }, { + nameMatchers: ['perplexity'], displayName: 'Perplexity', icon: 'perplexity', hasDistinctDarkIcon: true, @@ -78,24 +86,89 @@ export function findTrustedPartnerByRedirectUri( ) } +export function findTrustedPartnerByName(name: string): TrustedOAuthPartner | null { + const searchable = name.toLowerCase() + return ( + TRUSTED_OAUTH_PARTNERS.find((partner) => + partner.nameMatchers.some((matcher) => searchable.includes(matcher)) + ) ?? null + ) +} + +function curatedLogoForPartner( + partner: TrustedOAuthPartner, + useDarkVariant: boolean +): { src: string; isKnownClient: boolean } | null { + const customLogoUrl = getMcpClientIconSrc({ + icon: partner.icon, + useDarkVariant, + hasDistinctDarkIcon: partner.hasDistinctDarkIcon, + }) + if (!customLogoUrl) return null + return { src: customLogoUrl, isKnownClient: true } +} + export function getRequesterLogo({ icon, + name, redirectUri, useDarkVariant, }: { icon: string | null + name?: string | null redirectUri: string | null | undefined useDarkVariant: boolean }): { src: string; isKnownClient: boolean } { - const trusted = findTrustedPartnerByRedirectUri(redirectUri) - if (trusted) { - const customLogoUrl = getMcpClientIconSrc({ - icon: trusted.icon, - useDarkVariant, - hasDistinctDarkIcon: trusted.hasDistinctDarkIcon, - }) - if (customLogoUrl) return { src: customLogoUrl, isKnownClient: true } + const byRedirect = findTrustedPartnerByRedirectUri(redirectUri) + if (byRedirect) { + const curated = curatedLogoForPartner(byRedirect, useDarkVariant) + if (curated) return curated + } + + // Local MCP clients (Claude Desktop, Cursor, etc.) use loopback redirects. + // Name match is enough there — remote hosts still require the allowlist. + const hostname = getRedirectHostname(redirectUri) + if (hostname && isLocalRedirectHost(hostname) && name) { + const byName = findTrustedPartnerByName(name) + if (byName) { + const curated = curatedLogoForPartner(byName, useDarkVariant) + if (curated) return curated + } } return { src: icon || '', isKnownClient: false } } + +export type OAuthImpersonationWarning = { + /** Trusted partner label used in the caution copy. */ + brandDisplayName: string + redirectHost: string +} + +/** + * Warn when the requester name looks like a known partner but redirect_uri is a + * remote host outside that partner's allowlist. Localhost redirects are skipped + * (common for local MCP clients). Missing or malformed redirect URIs are skipped. + */ +export function getOAuthImpersonationWarning({ + name, + redirectUri, +}: { + name: string + redirectUri: string | null | undefined +}): OAuthImpersonationWarning | null { + const namedPartner = findTrustedPartnerByName(name) + if (!namedPartner) return null + + const hostname = getRedirectHostname(redirectUri) + if (!hostname || isLocalRedirectHost(hostname)) return null + + if (hostMatchesAllowlist(hostname, namedPartner.redirectHosts)) { + return null + } + + return { + brandDisplayName: namedPartner.displayName, + redirectHost: hostname, + } +} diff --git a/apps/studio/tests/components/ApiAuthorization.test.tsx b/apps/studio/tests/components/ApiAuthorization.test.tsx index 4f88fadf43e1c..c1046c17aa3f1 100644 --- a/apps/studio/tests/components/ApiAuthorization.test.tsx +++ b/apps/studio/tests/components/ApiAuthorization.test.tsx @@ -148,6 +148,22 @@ describe('AuthorizeConnectLogo', () => { expect(screen.queryByAltText('Claude')).not.toBeInTheDocument() }) + test('pairs curated logos for localhost when the name matches a trusted partner', () => { + customRender( + + ) + + expect(screen.getByAltText('Claude')).toHaveAttribute( + 'src', + getMcpClientIconSrc({ icon: 'claude', useDarkVariant: false }) + ) + expect(screen.getByAltText('Supabase')).toBeInTheDocument() + }) + test('shows Supabase alone when the requester has no icon', () => { customRender() @@ -346,9 +362,12 @@ describe('ApiAuthorizationScreen', () => { await screen.findByText('Authorize API access for Cursor') expect(screen.getByAltText('Cursor')).toBeInTheDocument() expect(screen.getByAltText('Supabase')).toBeInTheDocument() + expect( + screen.queryByText('Check this redirect before authorizing') + ).not.toBeInTheDocument() }) - test('shows Supabase alone when name looks trusted but redirect host is not allowlisted', async () => { + test('warns when a trusted name redirects to a non-allowlisted host', async () => { mockBothEndpoints( createMockAuthResponse({ name: 'Claude', @@ -357,7 +376,14 @@ describe('ApiAuthorizationScreen', () => { }) ) renderScreen() - await screen.findByText('Authorize API access for Claude') + expect( + await screen.findByText('Check this redirect before authorizing') + ).toBeInTheDocument() + expect( + screen.getByText( + 'This request uses the name Claude, but after you authorize you will be redirected to evil.com, not Claude.' + ) + ).toBeInTheDocument() expect(screen.queryByAltText('Claude')).not.toBeInTheDocument() expect(screen.getByAltText('Supabase')).toBeInTheDocument() }) @@ -382,14 +408,19 @@ describe('ApiAuthorizationScreen', () => { describe('expiration', () => { test('shows expiration warning and hides action buttons when request has expired', async () => { mockBothEndpoints( - createMockAuthResponse({ expires_at: dayjs().subtract(1, 'hour').toISOString() }) + createMockAuthResponse({ + name: 'Claude', + redirect_uri: 'https://evil.com/callback', + expires_at: dayjs().subtract(1, 'hour').toISOString(), + }) ) renderScreen() await screen.findByText('Authorization request expired') - expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument() expect( - screen.queryByRole('button', { name: /Authorize Test App/ }) + screen.queryByText('Check this redirect before authorizing') ).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Authorize Claude/ })).not.toBeInTheDocument() }) test('does not show expiration warning when request has not expired', async () => { From eef0f5730997b1ccecf747c221fd3e901a3f385f Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:59:19 +1000 Subject: [PATCH 3/6] fix(studio): clarify Storage columns keyboard focus and selection (#48222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Accessibility / UX fix ([DEPR-630](https://linear.app/supabase/issue/DEPR-630)). ## What is the current behavior? In Storage **As columns** view, keyboard select is unclear: the checkbox stays hidden until hover, so Tab/Space selection is hard to see. The row actions (three dots) menu also shows a browser default blue outline on Tab. ## What is the new behavior? Same icon/checkbox swap as list/hover, but also on keyboard focus: - Checkbox replaces the icon on hover, `:focus-within`, and when selected (no layout shift) - Checkbox becomes visible when focused via keyboard (without needing hover) - Row gets an inset outline while the checkbox is focused - Folders have no checkbox (non-focusable spacer only) - Row actions trigger uses `focus-ring` instead of the browser blue outline | Before | After | | --- | --- | | CleanShot 2026-07-31 at 14 37
47@2x | CleanShot 2026-07-31 at 14 37
13@2x | | _Checkbox focussed but not visually shown_ | _Checkbox focussed and visually shown_ | ## To test 1. Open the **Studio preview** for this PR. 2. Go to **Storage → Files** → open a bucket with several files. 3. Set view to **As columns**. 4. Tab until a **file** checkbox is focused. **Expect:** - Icon is replaced by the checkbox (same slot; neighbouring row icons should not look shifted) - Checkbox visible without hovering - Row shows an inset outline 5. Press **Space** to select. Checkbox stays in the icon slot; selection background applies. 6. Hover another file. Same icon to checkbox swap as before. 7. Tab to the three-dot actions control on a row. Expect the shared focus ring (not a blue browser outline); the menu icon should become visible. 8. Folders: no checkbox in the tab order; click icon/name still opens the folder. 9. Smoke **As list**. Same swap behaviour. ## Additional context From Kemal's DEPR-621 review. --- .../StorageExplorer/FileExplorerRow.tsx | 60 ++++++++++++------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx index 48c7bc68b5d64..f63c45e77ea86 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx @@ -268,6 +268,10 @@ export const FileExplorerRow = ({ const mimeType = item.metadata ? item.metadata.mimetype : '-' const createdAt = item.created_at ? new Date(item.created_at).toLocaleString() : '-' const updatedAt = item.updated_at ? new Date(item.updated_at).toLocaleString() : '-' + const isFile = item.type === STORAGE_ROW_TYPES.FILE + // Files: checkbox replaces icon on hover, keyboard focus, and when selected. + // Folders: icon only (no selection checkbox). + const showRowIcon = !isFile || !isSelected const nameWidth = view === STORAGE_VIEWS.LIST && item.isCorrupted @@ -290,12 +294,14 @@ export const FileExplorerRow = ({ >
{ event.stopPropagation() @@ -313,12 +319,14 @@ export const FileExplorerRow = ({ view === STORAGE_VIEWS.LIST ? 'w-[40%] min-w-[250px]' : 'w-[90%]' )} > -
event.stopPropagation()}> - {!isSelected && ( +
+ {showRowIcon && (
)} - { - onCheckItem(event.nativeEvent.shiftKey) - }} - aria-label="Check to select this item" - /> + {isFile ? ( + { + event.stopPropagation() + onCheckItem(event.nativeEvent.shiftKey) + }} + aria-label="Check to select this item" + /> + ) : ( + // Reserve the same slot as the file checkbox without a focusable control + + )}

{item.name} @@ -382,7 +398,7 @@ export const FileExplorerRow = ({ /> ) : ( - +

{item.name} actions From 7a77760a10225370d86a064ba51d358f8617edbb Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:00:41 +1000 Subject: [PATCH 4/6] fix(studio): confirm before discarding dirty replication destination forms (#48522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Bug fix (dirty form dismissal for Replication destination sheets), plus small docs/skill updates so agents pick up the existing modality pattern. ## What is the current behavior? Closing the Add/Edit destination sheet (Cancel, Escape, or backdrop) discards in-progress form state with no confirm. Same for the nested Create publication sheet. ## What is the new behavior? Dirty closes go through `useConfirmOnClose` + `DiscardChangesConfirmationDialog`, matching other Studio sheets. Successful submit still closes without prompting. Also: skills + `forms.mdx` now point at Modality “Dirty form dismissal”. | After | | --- | | Replication Database Chisel
Toolshed Supabase | ### How to test 1. Studio → Database → Replication → **Add destination** (any pipelines type with access). 2. Change a field so the form is dirty. 3. Try Cancel, Escape, and backdrop click → discard dialog appears; **Keep editing** stays open; **Discard changes** closes. 4. Submit successfully with a valid config → sheet closes with no discard dialog. 5. Repeat for **Edit destination** from a destination row menu. 6. Optional: Add destination → create a new publication from the publication picker → dirty that nested sheet and dismiss the same way. 7. Optional: Add destination → Read Replica → change region → dismiss → discard dialog; deploy still closes without prompting. ## Additional context Sheet owns the close guard; forms report dirty via a ref because RHF lives in the child. Nested `NewPublicationPanel` wires the guard locally. ## Summary by CodeRabbit - **New Features** - Added unsaved-changes tracking to replication destination and publication forms. - Added confirmation prompts before closing forms with unsaved changes via Cancel, Escape, or backdrop dismissal. - Forms now reset appropriately after successful submission or confirmed dismissal. - **Documentation** - Updated form and UI pattern guidance to document dirty-form dismissal behavior for sheets and dialogs. --------- Co-authored-by: Joshen Lim --- .claude/skills/react-hook-form/SKILL.md | 6 + .claude/skills/studio-ui-patterns/SKILL.md | 4 + .../content/docs/ui-patterns/forms.mdx | 2 +- .../DestinationForm/NewPublicationPanel.tsx | 188 ++++++++++-------- .../DestinationForm/index.tsx | 27 ++- .../DestinationPanel/DestinationPanel.tsx | 24 ++- .../ReadReplicaForm/index.tsx | 24 ++- 7 files changed, 175 insertions(+), 100 deletions(-) diff --git a/.claude/skills/react-hook-form/SKILL.md b/.claude/skills/react-hook-form/SKILL.md index 29571cb24c742..c08c5037a2b4b 100644 --- a/.claude/skills/react-hook-form/SKILL.md +++ b/.claude/skills/react-hook-form/SKILL.md @@ -235,6 +235,12 @@ schema, `onChange`, rendering, and the submit mapping. - Gate Save on `isDirty`; show Cancel only when dirty. In the owner, destructure from `form.formState`; anywhere else, `useFormState({ control })`. +- When the form lives in a Sheet or Dialog, also wire dirty dismissal: + `useConfirmOnClose` + `DiscardChangesConfirmationDialog`. Route Cancel, + Escape, and backdrop through the guard; call the raw `onClose` on successful + submit so you do not prompt after save. Details: + `apps/design-system/content/docs/ui-patterns/modality.mdx` (Dirty form + dismissal) and the studio-ui-patterns skill Sheets section. - To show _which_ fields changed (review/summary dialogs), read `dirtyFields` from the same subscription instead of hand-comparing `defaultValues.x !== watchedX`. RHF already does that comparison correctly; diff --git a/.claude/skills/studio-ui-patterns/SKILL.md b/.claude/skills/studio-ui-patterns/SKILL.md index cc875ec57104a..3f8740ec5040b 100644 --- a/.claude/skills/studio-ui-patterns/SKILL.md +++ b/.claude/skills/studio-ui-patterns/SKILL.md @@ -125,6 +125,10 @@ Forms in sheets: - `layout="horizontal"` for wider sheets - `layout="vertical"` for narrow sheets (`size="sm"` or below) +- When the sheet contains a form, wire dirty dismissal with `useConfirmOnClose` + + `DiscardChangesConfirmationDialog` (Cancel, Escape, and backdrop). Source of + truth: `apps/design-system/content/docs/ui-patterns/modality.mdx` (Dirty form + dismissal). Also see the react-hook-form skill for `isDirty` destructuring. ## Copy diff --git a/apps/design-system/content/docs/ui-patterns/forms.mdx b/apps/design-system/content/docs/ui-patterns/forms.mdx index add92626642f2..5c0560d2a2731 100644 --- a/apps/design-system/content/docs/ui-patterns/forms.mdx +++ b/apps/design-system/content/docs/ui-patterns/forms.mdx @@ -55,7 +55,7 @@ Build a custom row when the cells are mixed controls, such as an input paired wi 4. **Use Cards for grouping**: Wrap form sections in `Card` components with `CardContent` and `CardFooter` for actions. -5. **Handle dirty state**: Show cancel buttons and disable save buttons based on `form.formState.isDirty`. Make sure you destructure `isDirty` from `form.formState` (see https://react-hook-form.com/docs/useform/formstate) +5. **Handle dirty state**: Show cancel buttons and disable save buttons based on `form.formState.isDirty`. Make sure you destructure `isDirty` from `form.formState` (see https://react-hook-form.com/docs/useform/formstate). When the form is in a dialog or sheet, also follow [Dirty form dismissal](./modality#dirty-form-dismissal) so Cancel, Escape, and backdrop ask before discarding unsaved changes. 6. **Error handling**: Match feedback to its scope. Use `FormMessage` or `FieldError` for field validation. Show submission failures inline near the form actions when the user needs to retry or change something. Reserve toasts for non-blocking feedback or completed operations whose originating surface is no longer visible. diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NewPublicationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NewPublicationPanel.tsx index 9e5ca2799ddf5..57991eda2e6fd 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NewPublicationPanel.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NewPublicationPanel.tsx @@ -20,10 +20,12 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { MultiSelector } from 'ui-patterns/multi-select' import { z } from 'zod' +import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { useCreatePublicationMutation } from '@/data/replication/publication-create-mutation' import { useReplicationSourceId } from '@/data/replication/sources-query' import { useReplicationTablesQuery } from '@/data/replication/tables-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose' interface NewPublicationPanelProps { visible: boolean @@ -37,21 +39,12 @@ export const NewPublicationPanel = ({ visible, onClose }: NewPublicationPanelPro const { data: tables } = useReplicationTablesQuery({ projectRef, sourceId }, { enabled: visible }) - const { mutate: createPublication, isPending: creatingPublication } = - useCreatePublicationMutation({ - onSuccess: (_, vars) => { - toast.success('Successfully created publication') - form.reset(defaultValues) - onClose(vars.name) - }, - }) - const formId = 'publication-editor' const FormSchema = z.object({ name: z.string().min(1, 'Name is required'), tables: z.array(z.string()).min(1, 'At least one table is required'), }) - const defaultValues = { + const defaultValues: z.infer = { name: '', tables: [], } @@ -62,6 +55,28 @@ export const NewPublicationPanel = ({ visible, onClose }: NewPublicationPanelPro defaultValues, }) + // Always destructure formState values otherwise they won't be updated + // See https://react-hook-form.com/docs/useform/formstate + const { isDirty } = form.formState + + const closePanel = (newPublication?: string) => { + form.reset(defaultValues) + onClose(newPublication) + } + + const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ + checkIsDirty: () => isDirty, + onClose: () => closePanel(), + }) + + const { mutate: createPublication, isPending: creatingPublication } = + useCreatePublicationMutation({ + onSuccess: (_, vars) => { + toast.success('Successfully created publication') + closePanel(vars.name) + }, + }) + const onSubmit = async (data: z.infer) => { if (!projectRef) return console.error('Project ref is required') if (!project) return console.error('Project is required') @@ -82,80 +97,83 @@ export const NewPublicationPanel = ({ visible, onClose }: NewPublicationPanelPro } return ( - onClose()}> - -
- - Create a new publication - Choose which tables to replicate to destinations. - - - - - ( - - - - - - )} - /> - ( - - - - - - - {tables?.map((table) => ( - - {`${table.schema}.${table.name}`} - - ))} - - - - - - )} - /> - - - - - - - -
-
-
+ <> + + +
+ + Create a new publication + Choose which tables to replicate to destinations. + + +
+ + ( + + + + + + )} + /> + ( + + + + + + + {tables?.map((table) => ( + + {`${table.schema}.${table.name}`} + + ))} + + + + + + )} + /> + + +
+ + + + +
+
+
+ + ) } diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx index a88710f4b5365..69b6b7b1e8fa1 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx @@ -3,7 +3,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { AnimatePresence, motion } from 'framer-motion' import { Loader2 } from 'lucide-react' -import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { RefObject, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useForm, useWatch } from 'react-hook-form' import { AWS_REGIONS } from 'shared-data' import { toast } from 'sonner' @@ -83,7 +83,9 @@ interface DestinationFormProps { visible: boolean existingDestination?: ExistingDestination typeSelection?: ReactNode + checkIsDirtyRef?: RefObject<() => boolean> onClose: () => void + onCancel?: () => void } export const DestinationForm = ({ @@ -91,7 +93,9 @@ export const DestinationForm = ({ visible, existingDestination, typeSelection, + checkIsDirtyRef, onClose, + onCancel = onClose, }: DestinationFormProps) => { const { ref: projectRef } = useParams() @@ -261,6 +265,10 @@ export const DestinationForm = ({ defaultValues, }) + // Always destructure formState values otherwise they won't be updated + // See https://react-hook-form.com/docs/useform/formstate + const { isDirty } = form.formState + const publicationName = useWatch({ control: form.control, name: 'publicationName' }) const publicationNames = useMemo(() => publications?.map((pub) => pub.name) ?? [], [publications]) @@ -409,11 +417,22 @@ export const DestinationForm = ({ } useEffect(() => { - if (visible && !form.formState.isDirty) { + if (!checkIsDirtyRef) return + + checkIsDirtyRef.current = () => isDirty + return () => { + checkIsDirtyRef.current = () => false + } + }, [checkIsDirtyRef, isDirty]) + + useEffect(() => { + // Reset when closed (including after discard) so reopening does not restore + // discarded values, and when open but pristine so async defaults can apply. + if (!visible || !isDirty) { form.reset(defaultValues) resetValidation() } - }, [visible, defaultValues, form, resetValidation]) + }, [visible, defaultValues, form, isDirty, resetValidation]) useEffect(() => { if (visible && projectRef && sourceId) { @@ -567,7 +586,7 @@ export const DestinationForm = ({ )}
-
- - +
diff --git a/packages/ui/src/components/shadcn/ui/sheet.test.tsx b/packages/ui/src/components/shadcn/ui/sheet.test.tsx new file mode 100644 index 0000000000000..92cb5d8190dea --- /dev/null +++ b/packages/ui/src/components/shadcn/ui/sheet.test.tsx @@ -0,0 +1,58 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { Sheet, SheetContent, SheetDescription, SheetTitle } from './sheet' + +const TestSheet = ({ tabIndex }: { tabIndex?: number }) => { + const tabIndexProps = tabIndex === undefined ? {} : { tabIndex } + + return ( + + + Sheet title + Sheet description + + + + ) +} + +describe('SheetContent', () => { + it('focuses its first interactive child without making the sheet focusable', async () => { + render() + + const sheet = screen.getByRole('dialog') + const input = screen.getByRole('textbox', { name: 'First field' }) + + await waitFor(() => expect(input).toHaveFocus()) + expect(sheet).not.toHaveAttribute('tabindex') + }) + + it('allows the sheet to be made programmatically focusable explicitly', () => { + render() + + expect(screen.getByRole('dialog')).toHaveAttribute('tabindex', '-1') + }) + + it('does not move focus to the sheet when a focused child is removed', async () => { + const renderSheet = (showFirstButton: boolean) => ( + + + Sheet title + Sheet description + {showFirstButton && } + + + + ) + const { rerender } = render(renderSheet(true)) + const button = screen.getByRole('button', { name: 'Remove me' }) + + await waitFor(() => expect(button).toHaveFocus()) + rerender(renderSheet(false)) + + await waitFor(() => expect(button).not.toBeInTheDocument()) + await new Promise((resolve) => queueMicrotask(resolve)) + expect(screen.getByRole('dialog')).not.toHaveFocus() + }) +}) diff --git a/packages/ui/src/components/shadcn/ui/sheet.tsx b/packages/ui/src/components/shadcn/ui/sheet.tsx index 4f822924b7083..586d56febe023 100644 --- a/packages/ui/src/components/shadcn/ui/sheet.tsx +++ b/packages/ui/src/components/shadcn/ui/sheet.tsx @@ -174,6 +174,7 @@ const SheetContent = React.forwardRef< {children} From c0f1ef51fb9c083ab3a2e6867def0c4c7b2fa521 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:52:44 +1000 Subject: [PATCH 6/6] feat(docs): migrate resources and getting-started to ContentListings (#48517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Docs update / follow-up to #48379. ## What is the current behavior? `/guides/resources` and `/guides/getting-started` hand-roll `GlassPanel` grids in MDX. They look like ContentListings cards after the chrome PR, but they do not use the shared data files, so they miss PostHog `docs_content_listing_clicked` telemetry and the CONTRIBUTING contribution path. ## What is the new behavior? Those pages use `` backed by `resources.data.ts` and `getting-started.data.ts`, same pattern as storage. - Section-level `$Show` wrappers stay for framework / web / mobile blocks - Nimbus stays a `$Partial` behind `$Show` - New optional per-item `feature` field gates SDK links (e.g. Flutter / Swift / Kotlin) without splitting whole sections - CONTRIBUTING notes when to use `feature` vs a partial-level `$Show` ## To test Compare the following against `master`: - [Resources](https://docs-git-dnywh-docs-content-listings-resources-239158-supabase.vercel.app/docs/guides/resources): overview, migrate, and postgres grids; icons in light/dark - [Getting started](https://docs-git-dnywh-docs-content-listings-resources-239158-supabase.vercel.app/docs/guides/getting-started): overview, use cases, framework quickstarts, web demos, mobile tutorials; nimbus partial when enabled - Click a card and confirm `docs_content_listing_clicked` fires with the expected `listingId` Everything should look and feel the same. It’s just that we’re using `ContentListings` instead of `GlassPanel` grids. ## Summary by CodeRabbit - **New Features** - Added centralized Getting Started and Resources content listings, including quickstarts, demos, tutorials, migration guides, and Postgres resources. - Added feature-based visibility controls for individual content listing items. - **Improvements** - Disabled content is now automatically hidden from documentation pages and generated Markdown. - Pages and sections with no available content are omitted entirely. - External documentation links are more secure. - Updated contribution guidance with instructions and examples for feature flags. --- apps/docs/CONTRIBUTING.md | 2 +- .../ContentListings.client.tsx | 12 +- apps/docs/content/guides/getting-started.mdx | 372 +----------------- apps/docs/content/guides/resources.mdx | 186 +-------- .../content-listings/getting-started.data.ts | 327 +++++++++++++++ apps/docs/data/content-listings/index.ts | 16 + .../data/content-listings/resources.data.ts | 131 ++++++ .../markdown-schema/ContentListings.ts | 11 +- apps/docs/lib/content-listings.test.ts | 68 ++++ apps/docs/lib/content-listings.utils.ts | 8 +- apps/docs/lib/content-listings.zod.mjs | 5 + 11 files changed, 581 insertions(+), 557 deletions(-) create mode 100644 apps/docs/data/content-listings/getting-started.data.ts create mode 100644 apps/docs/data/content-listings/resources.data.ts diff --git a/apps/docs/CONTRIBUTING.md b/apps/docs/CONTRIBUTING.md index 41cb00108b413..3f868c0feb1c1 100644 --- a/apps/docs/CONTRIBUTING.md +++ b/apps/docs/CONTRIBUTING.md @@ -260,7 +260,7 @@ Run `pnpm test:local lib/content-listings.test.ts` from apps/docs. **Manually add content listings:** 1. Add or update a `ContentListingGroup` export in [`data/content-listings/[topic].data.ts`](data/content-listings/). The `id` field must be globally unique across all listing groups. For example, use `storage-get-started` rather than `get-started`. The ID is both the lookup key and the telemetry `listingId`. -2. Place the component inline in guide MDX, for example ``. Use a partial only when the block is reused or gated with `$Show` at the partial level. +2. Place the component inline in guide MDX, for example ``. Use a partial only when the block is reused or gated with `$Show` at the partial level. For individual items that depend on a feature flag (for example `sdk:dart`), set `feature` on the item instead of wrapping the whole listing. 3. Run `pnpm test:local lib/content-listings.test.ts` from `apps/docs`. Code snippets for manually adding content listings are available in [`.vscode/content-listing.code-snippets`](../../.vscode/content-listing.code-snippets). Use `cl-data` for a data export with a namespaced ID. Use `cl-inline` for an MDX component. diff --git a/apps/docs/components/ContentListings/ContentListings.client.tsx b/apps/docs/components/ContentListings/ContentListings.client.tsx index f71fd0236b174..548bde14d5070 100644 --- a/apps/docs/components/ContentListings/ContentListings.client.tsx +++ b/apps/docs/components/ContentListings/ContentListings.client.tsx @@ -2,13 +2,14 @@ import type { ContentListingGroup, ContentListingItem } from '~/lib/content-listings.schema' import { + filterContentListingItems, getContentListingById, getContentListingGroupLabel, isExternalContentListingHref, } from '~/lib/content-listings.utils' import { useSendTelemetryEvent } from '~/lib/telemetry' import Link from 'next/link' -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import { Badge } from 'ui' import { GlassPanel } from 'ui-patterns/GlassPanel' import { Heading } from 'ui/src/components/CustomHTMLElements' @@ -52,10 +53,13 @@ function ContentListingGroupHeading({ group }: { group: ContentListingGroup }) { function ContentListingsGroup({ group }: { group: ContentListingGroup }) { const { trackClick } = useContentListingClickHandler(group) + const items = useMemo(() => filterContentListingItems(group.items), [group.items]) const isGrid = group.type === 'grid' const listClassName = isGrid ? 'grid md:grid-cols-12 gap-4' : 'list-disc pl-6 space-y-2' const gridItemClassName = isGrid ? GRID_ITEM_CLASS[group.columns ?? 3] : undefined + if (!items.length) return null + // Heading stays outside `not-prose` so it inherits the surrounding MDX prose // typography. The list itself opts out so its explicit Tailwind layout wins. return ( @@ -64,7 +68,7 @@ function ContentListingsGroup({ group }: { group: ContentListingGroup }) {
{group.description &&

{group.description}

}
    - {group.items.map((item) => { + {items.map((item) => { const external = isExternalContentListingHref(item.href) const key = `${group.id}-${item.href}` @@ -77,6 +81,7 @@ function ContentListingsGroup({ group }: { group: ContentListingGroup }) { className="block h-full" onClick={() => trackClick(item)} target={external ? '_blank' : undefined} + rel={external ? 'noopener noreferrer' : undefined} > trackClick(item)} target={external ? '_blank' : undefined} + rel={external ? 'noopener noreferrer' : undefined} > {item.title}: {item.description} @@ -120,7 +126,7 @@ function ContentListingsGroup({ group }: { group: ContentListingGroup }) { export function ContentListings({ id }: { id: string }) { const group = getContentListingById(id) - if (!group || !group.items.length) return null + if (!group || !filterContentListingItems(group.items).length) return null return (
    diff --git a/apps/docs/content/guides/getting-started.mdx b/apps/docs/content/guides/getting-started.mdx index 9ec3e750fd906..ede5583753d36 100644 --- a/apps/docs/content/guides/getting-started.mdx +++ b/apps/docs/content/guides/getting-started.mdx @@ -5,214 +5,13 @@ description: 'Resources for getting started with Supabase.' hideToc: true --- -
    + -
    - -
    - - - Develop with Supabase AI-first using plugins, MCP, and skills. - - - - - Learn about the different API keys in Supabase and how to use them. - - - - - Use the Supabase CLI to develop locally and collaborate between teams. - - -
    - -
    - -
    - -## Use cases - -
    - - - Build AI-enabled applications using our Vector toolkit. - - - - - Clone, deploy, and fully customize a SaaS subscription application with Next.js. - - - - - Postgres full-text search, image storage, and more. - - -
    - -## Framework quickstarts + <$Show if="docs:framework_quickstarts"> -
    - - - Learn how to create a Supabase project, add some sample data to your database, and query the - data from a React app. - - - - - Learn how to create a Supabase project, add some sample data to your database, and query the - data from a Next.js app. - - - - - Learn how to create a Supabase project, add some sample data to your database, and query the - data from a Nuxt app. - - - - - 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. - - - - - 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. - - - <$Show if="sdk:dart"> - - - Learn how to create a Supabase project, add some sample data to your database, and query the - data from a Flutter app. - - - - <$Show if="sdk:swift"> - - - Learn how to create a Supabase project, add some sample data to your database, and query the - data from an iOS app. - - - - <$Show if="sdk:kotlin"> - - - Learn how to create a Supabase project, add some sample data to your database, and query the - data from an Android Kotlin app. - - - - - - Learn how to create a Supabase project, add some sample data to your database, and query the - data from a SvelteKit app. - - - - - Learn how to create a Supabase project, add some sample data to your database, and query the - data from a SolidJS app. - - - - - Learn how to create a Supabase project, add some sample data to your database, and query the - data from a Vue app. - - - - - Learn how to create a Supabase project, add some sample data to your database, and query the - data from a TanStack Start app. - - - - - Learn how to create a Supabase project, add some sample data to your database, and query the - data from a Refine app. - - -
    + @@ -224,173 +23,12 @@ hideToc: true <$Show if="docs:web_apps"> -## Web app demos + -
    - - - Learn how to build a user management app with Next.js and Supabase Database, Auth, and Storage functionality. - - - - - Learn how to build a user management app with React and Supabase Database, Auth, and Storage functionality. - - - - - Learn how to build a user management app with Vue 3 and Supabase Database, Auth, and Storage functionality. - - - - - Learn how to build a user management app with Nuxt 3 and Supabase Database, Auth, and Storage functionality. - - - - - Learn how to build a user management app with Angular and Supabase Database, Auth, and Storage functionality. - - - - - Learn how to build a user management app with RedwoodJS and Supabase Database, Auth, and Storage functionality. - - - - - Learn how to build a user management app with Svelte and Supabase Database, Auth, and Storage functionality. - - - - - Learn how to build a user management app with SvelteKit and Supabase Database, Auth, and Storage functionality. - - - - - Learn how to build a user management app with Refine and Supabase Database, Auth, and Storage functionality. - - -
    <$Show if="docs:mobile_tutorials"> -## Mobile tutorials + -
    - <$Show if="sdk:dart"> - - - Learn how to build a user management app with Flutter and Supabase Database, Auth, and Storage functionality. - - - - - - Learn how to build a user management app with Expo React Native and Supabase Database, Auth, and Storage functionality. - - - - - Learn how to implement social authentication in an app with Expo React Native and Supabase Database and Auth functionality. - - - <$Show if="sdk:kotlin"> - - - Learn how to build a product management app with Android and Supabase Database, Auth, and Storage functionality. - - - - <$Show if="sdk:swift"> - - - Learn how to build a user management app with iOS and Supabase Database, Auth, and Storage functionality. - - - - - - Learn how to build a user management app with Ionic React and Supabase Database, Auth, and Storage functionality. - - - - - Learn how to build a user management app with Ionic Vue and Supabase Database, Auth, and Storage functionality. - - - - - Learn how to build a user management app with Ionic Angular and Supabase Database, Auth, and Storage functionality. - - -
    diff --git a/apps/docs/content/guides/resources.mdx b/apps/docs/content/guides/resources.mdx index 5268fbf5927c0..158c26665256b 100644 --- a/apps/docs/content/guides/resources.mdx +++ b/apps/docs/content/guides/resources.mdx @@ -6,188 +6,8 @@ hideToc: true {/* */} -
    + -
    + -
    - - - Official GitHub examples, curated content from the community, and more. - - - - - Definitions for terminology and acronyms used in the Supabase documentation. - - -
    - -
    - -
    -
    - -## Migrate to Supabase - -
    - -
    - - - Move your auth users from Auth0 to a Supabase project. - - - - - Move your auth users from a Firebase project to a Supabase project. - - - - - Migrate the contents of a Firestore collection to a single Postgres table. - - - - - Convert your Firebase Storage files to Supabase Storage. - - - - - Migrate your Heroku Postgres database to Supabase. - - - - - Migrate your Render Postgres database to Supabase. - - - - - Migrate your Amazon RDS database to Supabase. - - - - - Migrate your Postgres database to Supabase. - - - - - Migrate your MySQL database to Supabase. - - - - - Migrate your Microsoft SQL Server database to Supabase. - - -
    - -
    - -
    -
    - -## Postgres resources - -
    - -
    - - - Improve query performance using various index types in Postgres. - - - - - Understand the types of foreign key constraint deletes. - - - - - Delete all tables in a given schema. - - - - - Retrieve the first row in each distinct group. - - - - - Find out which version of Postgres you are running. - - -
    - -
    - -{/* end of container */} - -
    + diff --git a/apps/docs/data/content-listings/getting-started.data.ts b/apps/docs/data/content-listings/getting-started.data.ts new file mode 100644 index 0000000000000..2d5ae192dce75 --- /dev/null +++ b/apps/docs/data/content-listings/getting-started.data.ts @@ -0,0 +1,327 @@ +import type { ContentListingGroup } from '~/lib/content-listings.schema' + +export const gettingStartedOverview: ContentListingGroup = { + id: 'getting-started-overview', + type: 'grid', + items: [ + { + title: 'Build with AI tools', + href: '/guides/ai-tools', + description: 'Develop with Supabase AI-first using plugins, MCP, and skills.', + }, + { + title: 'API Keys', + href: '/guides/getting-started/api-keys', + description: 'Learn about the different API keys in Supabase and how to use them.', + }, + { + title: 'Local Development', + href: '/guides/local-development', + description: 'Use the Supabase CLI to develop locally and collaborate between teams.', + }, + ], +} + +export const gettingStartedUseCases: ContentListingGroup = { + id: 'getting-started-use-cases', + heading: 'Use cases', + headingLevel: 'h3', + type: 'grid', + items: [ + { + title: 'AI, Vectors, and embeddings', + href: '/guides/ai#examples', + icon: '/docs/img/icons/openai_logo', + hasLightIcon: true, + description: 'Build AI-enabled applications using our Vector toolkit.', + }, + { + title: 'Subscription Payments (SaaS)', + href: 'https://github.com/vercel/nextjs-subscription-payments#nextjs-subscription-payments-starter', + icon: '/docs/img/icons/nextjs-icon', + hasLightIcon: false, + description: + 'Clone, deploy, and fully customize a SaaS subscription application with Next.js.', + }, + { + title: 'Partner Gallery', + href: 'https://github.com/supabase-community/partner-gallery-example#supabase-partner-gallery-example', + icon: '/docs/img/icons/nextjs-icon', + hasLightIcon: false, + description: 'Postgres full-text search, image storage, and more.', + }, + ], +} + +export const gettingStartedFrameworkQuickstarts: ContentListingGroup = { + id: 'getting-started-framework-quickstarts', + heading: 'Framework quickstarts', + headingLevel: 'h3', + type: 'grid', + items: [ + { + title: 'React', + href: '/guides/getting-started/quickstarts/reactjs', + 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.', + }, + { + title: 'Next.js', + href: '/guides/getting-started/quickstarts/nextjs', + 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.', + }, + { + title: 'Nuxt', + href: '/guides/getting-started/quickstarts/nuxtjs', + 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.', + }, + { + title: 'Hono', + href: '/guides/getting-started/quickstarts/hono', + 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.', + }, + { + title: 'RedwoodJS', + href: '/guides/getting-started/quickstarts/redwoodjs', + 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.', + }, + { + title: 'Flutter', + href: '/guides/getting-started/quickstarts/flutter', + 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.', + }, + { + title: 'iOS SwiftUI', + href: '/guides/getting-started/quickstarts/ios-swiftui', + 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.', + }, + { + title: 'Android Kotlin', + href: '/guides/getting-started/quickstarts/kotlin', + icon: '/docs/img/icons/kotlin-icon', + 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.', + }, + { + 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.', + }, + { + title: 'SolidJS', + href: '/guides/getting-started/quickstarts/solidjs', + 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.', + }, + { + title: 'Vue', + href: '/guides/getting-started/quickstarts/vue', + 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.', + }, + { + 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.', + }, + { + title: 'Refine', + href: '/guides/getting-started/quickstarts/refine', + 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.', + }, + ], +} + +export const gettingStartedWebAppDemos: ContentListingGroup = { + id: 'getting-started-web-app-demos', + heading: 'Web app demos', + headingLevel: 'h3', + type: 'grid', + items: [ + { + title: 'Next.js', + href: '/guides/getting-started/tutorials/with-nextjs', + icon: '/docs/img/icons/nextjs-icon', + hasLightIcon: true, + description: + 'Learn how to build a user management app with Next.js and Supabase Database, Auth, and Storage functionality.', + }, + { + title: 'React', + href: '/guides/getting-started/tutorials/with-react', + icon: '/docs/img/icons/react-icon', + hasLightIcon: false, + description: + 'Learn how to build a user management app with React and Supabase Database, Auth, and Storage functionality.', + }, + { + title: 'Vue 3', + href: '/guides/getting-started/tutorials/with-vue-3', + icon: '/docs/img/icons/vuejs-icon', + hasLightIcon: false, + description: + 'Learn how to build a user management app with Vue 3 and Supabase Database, Auth, and Storage functionality.', + }, + { + title: 'Nuxt 3', + href: '/guides/getting-started/tutorials/with-nuxt-3', + icon: '/docs/img/icons/nuxt-icon', + hasLightIcon: false, + description: + 'Learn how to build a user management app with Nuxt 3 and Supabase Database, Auth, and Storage functionality.', + }, + { + title: 'Angular', + href: '/guides/getting-started/tutorials/with-angular', + icon: '/docs/img/icons/angular-icon', + hasLightIcon: false, + description: + 'Learn how to build a user management app with Angular and Supabase Database, Auth, and Storage functionality.', + }, + { + title: 'RedwoodJS', + href: '/guides/getting-started/tutorials/with-redwoodjs', + icon: '/docs/img/icons/redwood-icon', + hasLightIcon: false, + description: + 'Learn how to build a user management app with RedwoodJS and Supabase Database, Auth, and Storage functionality.', + }, + { + title: 'Svelte', + href: '/guides/getting-started/tutorials/with-svelte', + icon: '/docs/img/icons/svelte-icon', + hasLightIcon: false, + description: + 'Learn how to build a user management app with Svelte and Supabase Database, Auth, and Storage functionality.', + }, + { + title: 'SvelteKit', + href: '/guides/getting-started/tutorials/with-sveltekit', + icon: '/docs/img/icons/svelte-icon', + hasLightIcon: false, + description: + 'Learn how to build a user management app with SvelteKit and Supabase Database, Auth, and Storage functionality.', + }, + { + title: 'Refine', + href: '/guides/getting-started/tutorials/with-refine', + icon: '/docs/img/icons/refine-icon', + hasLightIcon: false, + description: + 'Learn how to build a user management app with Refine and Supabase Database, Auth, and Storage functionality.', + }, + ], +} + +export const gettingStartedMobileTutorials: ContentListingGroup = { + id: 'getting-started-mobile-tutorials', + heading: 'Mobile tutorials', + headingLevel: 'h3', + type: 'grid', + items: [ + { + title: 'Flutter', + href: '/guides/getting-started/tutorials/with-flutter', + icon: '/docs/img/icons/flutter-icon', + hasLightIcon: false, + feature: 'sdk:dart', + description: + 'Learn how to build a user management app with Flutter and Supabase Database, Auth, and Storage functionality.', + }, + { + title: 'Expo React Native', + href: '/guides/getting-started/tutorials/with-expo-react-native', + icon: '/docs/img/icons/expo-icon', + hasLightIcon: true, + description: + 'Learn how to build a user management app with Expo React Native and Supabase Database, Auth, and Storage functionality.', + }, + { + title: 'Expo React Native Social Auth', + href: '/guides/auth/quickstarts/with-expo-react-native-social-auth', + icon: '/docs/img/icons/expo-icon', + hasLightIcon: true, + description: + 'Learn how to implement social authentication in an app with Expo React Native and Supabase Database and Auth functionality.', + }, + { + title: 'Android Kotlin', + href: '/guides/getting-started/tutorials/with-kotlin', + icon: '/docs/img/icons/kotlin-icon', + hasLightIcon: false, + feature: 'sdk:kotlin', + description: + 'Learn how to build a product management app with Android and Supabase Database, Auth, and Storage functionality.', + }, + { + title: 'iOS Swift', + href: '/guides/getting-started/tutorials/with-swift', + icon: '/docs/img/icons/swift-icon', + hasLightIcon: false, + feature: 'sdk:swift', + description: + 'Learn how to build a user management app with iOS and Supabase Database, Auth, and Storage functionality.', + }, + { + title: 'Ionic React', + href: '/guides/getting-started/tutorials/with-ionic-react', + icon: '/docs/img/icons/ionic-icon', + hasLightIcon: false, + description: + 'Learn how to build a user management app with Ionic React and Supabase Database, Auth, and Storage functionality.', + }, + { + title: 'Ionic Vue', + href: '/guides/getting-started/tutorials/with-ionic-vue', + icon: '/docs/img/icons/ionic-icon', + hasLightIcon: false, + description: + 'Learn how to build a user management app with Ionic Vue and Supabase Database, Auth, and Storage functionality.', + }, + { + title: 'Ionic Angular', + href: '/guides/getting-started/tutorials/with-ionic-angular', + icon: '/docs/img/icons/ionic-icon', + hasLightIcon: false, + description: + 'Learn how to build a user management app with Ionic Angular and Supabase Database, Auth, and Storage functionality.', + }, + ], +} diff --git a/apps/docs/data/content-listings/index.ts b/apps/docs/data/content-listings/index.ts index bf0e5f9353750..ef18cdc48ae72 100644 --- a/apps/docs/data/content-listings/index.ts +++ b/apps/docs/data/content-listings/index.ts @@ -11,8 +11,16 @@ import { functionsExamplesWebhooksPayments, functionsGetStarted, } from './functions.data' +import { + gettingStartedFrameworkQuickstarts, + gettingStartedMobileTutorials, + gettingStartedOverview, + gettingStartedUseCases, + gettingStartedWebAppDemos, +} from './getting-started.data' import { logDrainsDestinations } from './log-drains.data' import { realtimeExamples, realtimeGetStarted, realtimeResources } from './realtime.data' +import { resourcesMigrate, resourcesOverview, resourcesPostgres } from './resources.data' import { selfHostingCommunity, selfHostingGetHelp, @@ -37,10 +45,18 @@ const ALL_GROUPS: readonly ContentListingGroup[] = [ functionsExamplesAiMedia, functionsExamplesMessaging, functionsExamplesOperations, + gettingStartedOverview, + gettingStartedUseCases, + gettingStartedFrameworkQuickstarts, + gettingStartedWebAppDemos, + gettingStartedMobileTutorials, logDrainsDestinations, realtimeGetStarted, realtimeExamples, realtimeResources, + resourcesOverview, + resourcesMigrate, + resourcesPostgres, selfHostingGetStarted, selfHostingCommunity, selfHostingResolveIssues, diff --git a/apps/docs/data/content-listings/resources.data.ts b/apps/docs/data/content-listings/resources.data.ts new file mode 100644 index 0000000000000..bca9a8af82684 --- /dev/null +++ b/apps/docs/data/content-listings/resources.data.ts @@ -0,0 +1,131 @@ +import type { ContentListingGroup } from '~/lib/content-listings.schema' + +export const resourcesOverview: ContentListingGroup = { + id: 'resources-overview', + type: 'grid', + items: [ + { + title: 'Examples', + href: '/guides/getting-started', + description: 'Official GitHub examples, curated content from the community, and more.', + }, + { + title: 'Glossary', + href: '/guides/resources/glossary', + description: 'Definitions for terminology and acronyms used in the Supabase documentation.', + }, + ], +} + +export const resourcesMigrate: ContentListingGroup = { + id: 'resources-migrate', + heading: 'Migrate to Supabase', + headingLevel: 'h3', + type: 'grid', + items: [ + { + title: 'Auth0', + href: '/guides/platform/migrating-to-supabase/auth0', + icon: '/docs/img/icons/auth0-icon', + hasLightIcon: true, + description: 'Move your auth users from Auth0 to a Supabase project.', + }, + { + title: 'Firebase Auth', + href: '/guides/platform/migrating-to-supabase/firebase-auth', + icon: '/docs/img/icons/firebase-icon', + hasLightIcon: false, + description: 'Move your auth users from a Firebase project to a Supabase project.', + }, + { + title: 'Firestore Data', + href: '/guides/platform/migrating-to-supabase/firestore-data', + icon: '/docs/img/icons/firebase-icon', + hasLightIcon: false, + description: 'Migrate the contents of a Firestore collection to a single Postgres table.', + }, + { + title: 'Firebase Storage', + href: '/guides/platform/migrating-to-supabase/firebase-storage', + icon: '/docs/img/icons/firebase-icon', + hasLightIcon: false, + description: 'Convert your Firebase Storage files to Supabase Storage.', + }, + { + title: 'Heroku', + href: '/guides/platform/migrating-to-supabase/heroku', + icon: '/docs/img/icons/heroku-icon', + hasLightIcon: false, + description: 'Migrate your Heroku Postgres database to Supabase.', + }, + { + title: 'Render', + href: '/guides/platform/migrating-to-supabase/render', + icon: '/docs/img/icons/render-icon', + hasLightIcon: false, + description: 'Migrate your Render Postgres database to Supabase.', + }, + { + title: 'Amazon RDS', + href: '/guides/platform/migrating-to-supabase/amazon-rds', + icon: '/docs/img/icons/aws-rds-icon', + hasLightIcon: false, + description: 'Migrate your Amazon RDS database to Supabase.', + }, + { + title: 'Postgres', + href: '/guides/platform/migrating-to-supabase/postgres', + icon: '/docs/img/icons/postgres-icon', + hasLightIcon: false, + description: 'Migrate your Postgres database to Supabase.', + }, + { + title: 'MySQL', + href: '/guides/platform/migrating-to-supabase/mysql', + icon: '/docs/img/icons/mysql-icon', + hasLightIcon: false, + description: 'Migrate your MySQL database to Supabase.', + }, + { + title: 'Microsoft SQL Server', + href: '/guides/platform/migrating-to-supabase/mssql', + icon: '/docs/img/icons/mssql-icon', + hasLightIcon: false, + description: 'Migrate your Microsoft SQL Server database to Supabase.', + }, + ], +} + +export const resourcesPostgres: ContentListingGroup = { + id: 'resources-postgres', + heading: 'Postgres resources', + headingLevel: 'h3', + type: 'grid', + items: [ + { + title: 'Managing Indexes', + href: '/guides/database/postgres/indexes', + description: 'Improve query performance using various index types in Postgres.', + }, + { + title: 'Cascade Deletes', + href: '/guides/database/postgres/cascade-deletes', + description: 'Understand the types of foreign key constraint deletes.', + }, + { + title: 'Drop all tables in schema', + href: '/guides/database/postgres/dropping-all-tables-in-schema', + description: 'Delete all tables in a given schema.', + }, + { + title: 'Select first row per group', + href: '/guides/database/postgres/first-row-in-group', + description: 'Retrieve the first row in each distinct group.', + }, + { + title: 'Print Postgres version', + href: '/guides/database/postgres/which-version-of-postgres', + description: 'Find out which version of Postgres you are running.', + }, + ], +} diff --git a/apps/docs/internals/markdown-schema/ContentListings.ts b/apps/docs/internals/markdown-schema/ContentListings.ts index 299b23c06ecf2..ce78463b70afa 100644 --- a/apps/docs/internals/markdown-schema/ContentListings.ts +++ b/apps/docs/internals/markdown-schema/ContentListings.ts @@ -1,6 +1,10 @@ import { withDocsBasePath } from '~/internals/internal-links' import type { ContentListingGroup } from '~/lib/content-listings.schema' -import { getContentListingById, isExternalContentListingHref } from '~/lib/content-listings.utils' +import { + filterContentListingItems, + getContentListingById, + isExternalContentListingHref, +} from '~/lib/content-listings.utils' import { getInternalLinkBaseUrl } from '../internal-links' @@ -14,6 +18,9 @@ export function serializeContentListingGroupToMarkdown( group: ContentListingGroup, linkBaseUrl: string ): string { + const items = filterContentListingItems(group.items) + if (!items.length) return '' + const lines: string[] = [] if (group.heading) { const level = group.headingLevel ?? 'h2' @@ -26,7 +33,7 @@ export function serializeContentListingGroupToMarkdown( lines.push('') } - for (const item of group.items) { + for (const item of items) { const href = isExternalContentListingHref(item.href) ? item.href : `${linkBaseUrl}${withDocsBasePath(item.href)}` diff --git a/apps/docs/lib/content-listings.test.ts b/apps/docs/lib/content-listings.test.ts index 0c609a60699bb..87b0d5d82fea7 100644 --- a/apps/docs/lib/content-listings.test.ts +++ b/apps/docs/lib/content-listings.test.ts @@ -144,6 +144,74 @@ describe('serializeContentListingGroupToMarkdown', () => { expect(markdown).not.toMatch(/^#+\s/m) expect(markdown).toContain('**[Connect]') }) + + it('omits feature-gated items when those features are disabled', () => { + const previous = process.env.ENABLED_FEATURES_OVERRIDE_DISABLE_ALL + process.env.ENABLED_FEATURES_OVERRIDE_DISABLE_ALL = 'true' + + try { + const markdown = serializeContentListingGroupToMarkdown( + { + id: 'frameworks', + heading: 'Frameworks', + items: [ + { + title: 'React', + href: '/guides/getting-started/quickstarts/reactjs', + description: 'Web framework.', + }, + { + title: 'Flutter', + href: '/guides/getting-started/quickstarts/flutter', + description: 'Mobile framework.', + feature: 'sdk:dart', + }, + ], + }, + '' + ) + + expect(markdown).toContain('**[React]') + expect(markdown).not.toContain('Flutter') + } finally { + if (previous === undefined) { + delete process.env.ENABLED_FEATURES_OVERRIDE_DISABLE_ALL + } else { + process.env.ENABLED_FEATURES_OVERRIDE_DISABLE_ALL = previous + } + } + }) + + it('returns empty string when every item is feature-gated off', () => { + const previous = process.env.ENABLED_FEATURES_OVERRIDE_DISABLE_ALL + process.env.ENABLED_FEATURES_OVERRIDE_DISABLE_ALL = 'true' + + try { + const markdown = serializeContentListingGroupToMarkdown( + { + id: 'sdk-only', + heading: 'SDKs', + items: [ + { + title: 'Flutter', + href: '/guides/getting-started/quickstarts/flutter', + description: 'Mobile framework.', + feature: 'sdk:dart', + }, + ], + }, + '' + ) + + expect(markdown).toBe('') + } finally { + if (previous === undefined) { + delete process.env.ENABLED_FEATURES_OVERRIDE_DISABLE_ALL + } else { + process.env.ENABLED_FEATURES_OVERRIDE_DISABLE_ALL = previous + } + } + }) }) describe('ContentListings markdown handler', () => { diff --git a/apps/docs/lib/content-listings.utils.ts b/apps/docs/lib/content-listings.utils.ts index 10c73b4d4f1e4..93c12304399dd 100644 --- a/apps/docs/lib/content-listings.utils.ts +++ b/apps/docs/lib/content-listings.utils.ts @@ -1,6 +1,7 @@ import { CONTENT_LISTINGS } from '~/data/content-listings' +import { isFeatureEnabled, type Feature } from 'common/enabled-features' -import type { ContentListingGroup } from './content-listings.schema' +import type { ContentListingGroup, ContentListingItem } from './content-listings.schema' /** Label for telemetry — prefers heading, falls back to id. */ export function getContentListingGroupLabel(group: ContentListingGroup): string { @@ -14,3 +15,8 @@ export function isExternalContentListingHref(href: string): boolean { export function getContentListingById(id: string): ContentListingGroup | undefined { return CONTENT_LISTINGS[id] } + +/** Omits items whose `feature` flag is disabled. Shared by UI and markdown export. */ +export function filterContentListingItems(items: ContentListingItem[]): ContentListingItem[] { + return items.filter((item) => !item.feature || isFeatureEnabled(item.feature as Feature)) +} diff --git a/apps/docs/lib/content-listings.zod.mjs b/apps/docs/lib/content-listings.zod.mjs index 3f7060ef8cb21..a678cb6b57863 100644 --- a/apps/docs/lib/content-listings.zod.mjs +++ b/apps/docs/lib/content-listings.zod.mjs @@ -29,6 +29,11 @@ export const contentListingItemSchema = z.object({ badge: z.string().min(1).optional(), /** Grid cards only. Defaults to inline (next to the title), matching existing usage. */ badgePosition: z.enum(['inline', 'below']).optional(), + /** + * When set, the item is omitted unless `isFeatureEnabled(feature)` is true. + * Use for SDK- or product-gated links (e.g. `sdk:dart`) inside a shared listing. + */ + feature: z.string().min(1).optional(), }) export const contentListingGroupTypeSchema = z.enum(['list', 'grid'])