Files
supabase/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx
Danny White f7454cf94e feat(studio): oauth impersonation warning on authorize (#48162)
## 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 |
| --- |
| <img width="764" height="958" alt="Authorize Claude Supabase"
src="https://github.com/user-attachments/assets/e6eee016-5710-41ba-9925-87511e009e22"
/> |

#### 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 |
| --- |
| <img width="764" height="958" alt="Authorize Claude Supabase"
src="https://github.com/user-attachments/assets/79f36865-3c8e-43e5-9490-24288efc74aa"
/> |

#### 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 |
| --- |
| <img width="764" height="958" alt="56164"
src="https://github.com/user-attachments/assets/412333a3-a74f-42eb-9f63-d56b6a26bf91"
/> |

#### 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 |
| --- |
| <img width="764" height="958" alt="Authorize Acme Tools Supabase"
src="https://github.com/user-attachments/assets/dab24817-5c26-4aa1-a447-796c4af5868b"
/> |

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-02 23:57:27 +00:00

269 lines
9.0 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: Connect Interstitials
description: Shared layout guidance for focused authorisation, invite, marketplace, CLI, and credit redemption flows.
---
Connect interstitials are focused, single-card flows that sit outside the main
Studio shell. Use the shared `InterstitialLayout` family instead of building
bespoke centered cards, logos, account rows, or organisation selectors.
<ComponentPreview
name="connect-interstitial-demo"
description="Centered 400px card with partner branding, account row, and a single primary action"
align="start"
className="p-0"
padded={false}
peekCode
wide
/>
## Use this pattern for
This pattern fits short-lived connect flows: partner authorisation and consent
(OAuth, MCP, Stripe Projects), organisation invites, marketplace and billing
connections (AWS Marketplace, Vercel install, credit redemption), and CLI or
device-code sign-in. Use the same shell for their loading, error, success, and
wrong-account states.
Do not use it for normal authenticated Studio pages. Those should use the
standard [page layout](./layout) patterns.
## Source of truth
```tsx
import { OrganizationSelector } from '@/components/interfaces/Connect/OrganizationSelector'
import {
InterstitialAccountRow,
InterstitialLayout,
LogoBox,
LogoPair,
PartnerLogo,
SupabaseLogo,
} from '@/components/layouts/InterstitialLayout'
```
## Basic shape
Use `InterstitialLayout` for the outer card, then put route-specific content in
`px-6 pb-6`. Widen the card only when the flow embeds a real tool, such as
project linking.
```tsx
<InterstitialLayout
logo={
<LogoPair
left={<PartnerLogo src={`${BASE_PATH}/img/icons/stripe-icon.svg`} alt="Stripe" />}
right={<SupabaseLogo />}
/>
}
title="Authorize Stripe Projects"
description="This will create an organization on your behalf in Supabase"
>
<div className="px-6 pb-6">
<InterstitialAccountRow displayName={displayName} />
<Button variant="primary" block>
Continue
</Button>
</div>
</InterstitialLayout>
```
```tsx
<InterstitialLayout
logo={<LogoPair left={<VercelLogo />} right={<SupabaseLogo />} />}
title="Connect Vercel project"
containerClassName="items-start"
cardClassName="max-w-[900px]"
>
<div className="px-6 pb-6">{projectLinker}</div>
</InterstitialLayout>
```
## Logos
Use `LogoPair` when the user is connecting two known services, and
`SupabaseLogo` alone for first-party flows or when the requester has no trusted
mark. `PartnerLogo` fills the 48px box edge-to-edge; `LogoBox` is for custom
inset marks or logos that need their own background. Store new partner icons in
`apps/studio/public/img/icons`.
### Pairing
| Requester logo | Header treatment |
| -------------------------------------------------------- | ---------------------------------------------------- |
| Curated partner / MCP client, or a trusted uploaded icon | `LogoPair` with requester left, `SupabaseLogo` right |
| Unknown, missing, blocked, or failed-to-load icon | `SupabaseLogo` alone. Do not invent an initial tile. |
The user is usually arriving from the third-party app. The interstitial should
confirm they are connecting to Supabase. Put the requester name in the title and
description; do not manufacture a letter avatar to fill the left side of a pair.
**Known services.** When both sides are curated (or otherwise known), pair them.
Theme-reactive tiles are fine when both marks have matching light/dark
treatment.
<ComponentPreview
name="connect-interstitial-logo-pair"
description="LogoPair when the user is connecting two services"
align="start"
className="p-0"
padded={false}
peekCode
wide
/>
**No trusted requester mark.** If the icon is missing, blocked, or fails to
load, show `SupabaseLogo` alone. Do not invent an initial tile to fill the
pair.
<ComponentPreview
name="connect-interstitial-logo-unknown"
description="No trusted requester mark: Supabase alone"
align="start"
className="p-0"
padded={false}
peekCode
wide
/>
**Uploaded organisation OAuth icons.** Icons published via Studio’s OAuth app
builder are unclassified bitmaps — we do not know if they were authored for
light or dark. Treat the pair as light on both Studio themes: fixed light tile
chrome (`border-black/10 bg-white`, `SupabaseLogo forceLight`) on both sides.
Do not invent a dark variant for the upload. Toggle the docs theme to dark to
see the light tiles hold against the Studio chrome.
<ComponentPreview
name="connect-interstitial-logo-uploaded"
description="Uploaded OAuth app icon: forced-light tiles on both sides"
align="start"
className="p-0"
padded={false}
peekCode
wide
/>
### Assets
Treat Connect logos as assets, not theme tokens.
**Default to light.** Prefer a single static light mark inside `LogoBox`.
Light assets read fine on both light and dark Studio themes. That is the
default for Connect tiles.
**Keep pairs matched.** In a `LogoPair`, both marks must use the same
treatment: both light, or both dark. Do not mix a light partner tile with a
dark-theme-only Supabase treatment, or the reverse. Theme-aware dark variants
are fine for curated partners that already have them, but then both sides of
the pair should use the dark set together.
**What not to do**
- Do not invent light/dark pairs for arbitrary remote OAuth icons.
- Do not recolour vendor SVGs with theme CSS. Monochrome identity-provider
masks (for example GitHub on sign-in) stay a separate pattern.
**Where logos come from on `/authorize`**
- 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
Use `InterstitialAccountRow` for signed-in context. Do not recreate it locally.
```tsx
<InterstitialAccountRow avatarUrl={avatarUrl} displayName={displayName} action={signOutButton} />
```
## Organisation selection
Use `OrganizationSelector` when the flow needs an organisation pick. Extend it
for new states instead of inventing a parallel card style.
```tsx
<OrganizationSelector
organizations={linkableOrganizations}
selectedSlug={selectedOrgSlug}
onSelect={setSelectedOrgSlug}
createLabel="Create new organization"
onCreate={() => setShowOrgCreationDialog(true)}
/>
```
## Actions
Prefer one full-width primary action. A full-width text button is fine for a
secondary action that still belongs in the flow.
### Action feedback
Match feedback to its scope:
- Use `FormMessage` or `FieldError` beside a field when that field needs to
change.
- Show a submission or action failure as simple destructive text below the
actions. Separate it with a subtle divider when needed for composition. Keep
the current account, selections, and actions visible so the user can retry.
- Use `Admonition` when the whole interstitial is blocked or has materially
changed state, such as an invalid link, wrong account, or partially completed
setup.
- Use a toast only for non-blocking feedback or a completed action whose
originating surface is no longer visible. A toast must not be the only
feedback for a failure the user needs to resolve on the current card.
```tsx
<InterstitialActionError error={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. 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.
<ComponentPreview
name="connect-interstitial-action-error"
description="Retryable action error shown beside the actions"
align="start"
className="p-0"
padded={false}
peekCode
wide
/>
## States
Keep loading, invalid, error, and success states inside the same card when the
route can explain them. Use `ShimmeringLoader` for loading, and `Admonition`
for warning, error, note, and success copy.
<ComponentPreview
name="connect-interstitial-logo-single"
description="Wrong-account warning inside the same interstitial card"
align="start"
className="p-0"
padded={false}
peekCode
wide
/>
## Copy
Use sentence case. Prefer `sign in` over `login`. Titles and primary actions
should follow `Verb -> Thing`, for example `Authorize Stripe Projects` or
`Install Vercel`.
Keep the layout title static across states and put state-specific copy in the
body. Header descriptions should stay short and should not end with a full
stop.