Files
supabase/apps/studio/components/layouts/InterstitialLayout.tsx
Danny White e19cd1863d feat(studio): connect logo contract for authorize (#48161)
## What kind of change does this PR introduce?

Feature + docs. Closes
[DEPR-604](https://linear.app/supabase/issue/DEPR-604/define-connect-logo-asset-and-variant-contract).

## What is the current behavior?

`/authorize` logo resolution trusted self-asserted requester `name` (and
similar) for curated MCP marks, fell back to a letter tile when there
was no usable icon, and always used theme-reactive tile chrome. This
includes the scenario when pairing against unclassified uploaded OAuth
app bitmaps.

## What is the new behavior?

- [Documents the Connect logo asset/variant
contract](https://design-system-git-danny-depr-604-connect-logo-contract-supabase.vercel.app/design-system/docs/ui-patterns/connect-interstitials#logos)
(default to light, keep pairs matched, no theme-recolour of vendor
SVGs).
- Resolves curated partner logos from allowlisted `redirect_uri` hosts
only (`claude.ai` / `anthropic.com`, `cursor.com` / `cursor.sh`,
`chatgpt.com` / `openai.com`, `perplexity.ai`).
- Unknown / missing / failed requester icons show `SupabaseLogo` alone
(no letter tile).
- Uploaded organisation OAuth app icons (unclassified bitmaps) pair with
fixed light tile chrome (`border-black/10 bg-white` / `SupabaseLogo
forceLight`) on both sides across Studio themes.
- Curated partners keep theme-reactive tiles and may use dark assets
when available.

### 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 once available).
2. Open `/dashboard/authorize?auth_id=foo` (any `auth_id` is fine — the
real response may 404).
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.

The fields that matter for this PR are `name`, `icon`, and
`redirect_uri`.

#### Curated pair (allowlisted redirect)

Expect Cursor mark + Supabase pair. Toggle light/dark: curated dark
assets may swap; tiles stay theme-reactive (`bg-surface-75`).

```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"
}
```

#### Unknown → Supabase alone

Expect Supabase bolt alone. No letter tile. No curated mark even if
`name` says Claude.

```json
{
  "name": "Acme",
  "website": "https://acme.example",
  "icon": null,
  "domain": "acme.example",
  "redirect_uri": "https://acme.example/callback",
  "expires_at": "2099-01-01T00:00:00.000Z",
  "scopes": ["organizations:read", "projects:read"],
  "approved_at": null,
  "registration_type": "dynamic"
}
```

#### Spoofed trusted name, non-allowlisted redirect (logo only)

Expect Supabase alone (no Claude mark). This PR does **not** show the
impersonation caution (that is coming in #48162).

```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"
}
```

#### Uploaded OAuth app icon → forced-light pair

Expect remote icon + Supabase pair with forced-light tiles
(`border-black/10 bg-white`) on both sides in light and dark Studio
themes. The icon URL below is the checked-in solid-colour Acme bitmap on
this branch.

```json
{
  "name": "Acme",
  "website": "https://acme.example",
  "icon": "https://raw.githubusercontent.com/supabase/supabase/danny/depr-604-connect-logo-contract/apps/design-system/public/img/icons/acme-oauth-icon.png",
  "domain": "acme.example",
  "redirect_uri": "https://acme.example/callback",
  "expires_at": "2099-01-01T00:00:00.000Z",
  "scopes": ["organizations:read", "projects:read"],
  "approved_at": null,
  "registration_type": "static"
}
```

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Improved authorization interstitial branding with trusted requester
logos and safer fallback behavior.
* Added support for consistent light-theme treatment of uploaded OAuth
app icons.
* Added examples and documentation for unknown requesters, uploaded
logos, and wrong-account states.
* **Bug Fixes**
* Prevented unverified or unavailable requester icons from being
presented as trusted.
* Ensured logo pairing remains visually consistent across light and dark
themes.
* **Tests**
* Added coverage for trusted-host validation, fallback branding, icon
loading failures, and theme behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-07-23 02:20:42 +10:00

207 lines
6.1 KiB
TypeScript

import { motion } from 'framer-motion'
import { ArrowRightLeft } from 'lucide-react'
import type { PropsWithChildren, ReactNode } from 'react'
import { Card, CardContent, CardHeader, cn } from 'ui'
import { ProfileImage } from '@/components/ui/ProfileImage'
import { BASE_PATH } from '@/lib/constants'
const MotionCard = motion.create(Card)
interface InterstitialLayoutProps {
logo?: ReactNode
title?: ReactNode
description?: ReactNode
/** Optional content rendered beneath the card (e.g. a terms disclaimer), at the card's width. */
footer?: ReactNode
containerClassName?: string
cardClassName?: string
/** Shared max-width for the card and footer column. Defaults to `max-w-[400px]`. */
widthClassName?: string
titleClassName?: string
descriptionClassName?: string
}
/**
* Minimal full-screen centered layout for interstitial flows:
* partner authorization, org invites, CLI auth, credit redemption, etc.
*
* The logo, title, and description render inside the card (above children),
* so every consumer gets a consistent header for free.
*/
export const InterstitialLayout = ({
logo,
title,
description,
footer,
containerClassName,
cardClassName,
widthClassName = 'max-w-[400px]',
titleClassName,
descriptionClassName,
children,
}: PropsWithChildren<InterstitialLayoutProps>) => {
const TitleElement = typeof title === 'string' ? 'h1' : 'div'
const DescriptionElement = typeof description === 'string' ? 'p' : 'div'
const titleElement = title ? (
<TitleElement
className={cn(
'font-sans tracking-tight text-balance text-lg font-medium normal-case text-foreground',
titleClassName
)}
>
{title}
</TitleElement>
) : null
const descriptionElement = description ? (
<DescriptionElement
className={cn(
'!m-0 px-3 !text-balance text-sm text-foreground-lighter leading-tight',
descriptionClassName
)}
>
{description}
</DescriptionElement>
) : null
const card = (
<MotionCard
layout="size"
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className={cn('overflow-hidden w-full mx-auto', widthClassName, cardClassName)}
>
{(logo || title || description) && (
<CardHeader className="font-normal items-center gap-0 space-y-0 px-6 py-6 text-center [--card-padding-x:1.5rem] border-0">
{logo && <div className="mb-4 flex justify-center">{logo}</div>}
{(titleElement || descriptionElement) && (
<div className="flex flex-col items-center gap-1">
{titleElement}
{descriptionElement}
</div>
)}
</CardHeader>
)}
{children}
</MotionCard>
)
return (
<div
className={cn(
'flex min-h-screen w-full items-center justify-center bg-studio px-2 py-6',
containerClassName
)}
>
{footer ? (
<div className={cn('flex w-full flex-col items-center gap-4', widthClassName)}>
{card}
<div className="px-2 text-center text-balance">{footer}</div>
</div>
) : (
card
)}
</div>
)
}
/**
* Standard rounded-rect logo container (48x48).
* Partner logos fill edge-to-edge (see `PartnerLogo`); the Supabase symbol and
* Lucide icons sit inset (sized at `size-7`).
*/
export const LogoBox = ({ children, className }: { children: ReactNode; className?: string }) => (
<div
className={cn(
'flex size-12 items-center justify-center overflow-hidden rounded-xl border bg-muted',
className
)}
>
{children}
</div>
)
/** Two pre-boxed logos side-by-side with a swap separator. */
export const LogoPair = ({ left, right }: { left: ReactNode; right: ReactNode }) => (
<div className="flex items-center justify-center gap-2.5">
{left}
<ArrowRightLeft className="size-4 text-foreground-muted" />
{right}
</div>
)
/** Partner logo rendered edge-to-edge inside a LogoBox by default. */
export const PartnerLogo = ({
src,
alt,
className,
imageClassName,
}: {
src: string
alt: string
className?: string
imageClassName?: string
}) => (
<LogoBox className={className}>
<img alt={alt} src={src} className={cn('size-full object-cover', imageClassName)} />
</LogoBox>
)
/**
* Sign-in destination mark, inset to match {@link SupabaseLogo}. Falls back to the destination's
* initial when no icon is available.
*/
export const DestinationLogo = ({ icon, name }: { icon?: ReactNode; name: string }) => (
<LogoBox>
{icon ?? <span className="text-lg font-medium text-foreground-light">{name.slice(0, 1)}</span>}
</LogoBox>
)
/** Fixed light tile chrome for Connect pairs with unclassified (uploaded) marks. */
export const CONNECT_LOGO_LIGHT_TILE_CLASSNAME = 'border-black/10 bg-white'
/** Supabase symbol (not the wordmark) rendered inset inside a LogoBox. */
export const SupabaseLogo = ({ forceLight = false }: { forceLight?: boolean } = {}) => (
<LogoBox className={forceLight ? CONNECT_LOGO_LIGHT_TILE_CLASSNAME : 'bg-surface-75'}>
<img alt="Supabase" src={`${BASE_PATH}/img/supabase-logo.svg`} className="size-7" />
</LogoBox>
)
export const InterstitialAccountRow = ({
avatarUrl,
displayName,
action,
className,
detail,
}: {
avatarUrl?: string
displayName?: string
action?: ReactNode
className?: string
detail?: string
}) => (
<Card className={cn('shadow-none', !action && 'border-muted bg-surface-200/50', className)}>
<CardContent
className={cn(
'flex gap-3 border-none',
action ? 'items-center px-4 py-3' : 'items-start p-3'
)}
>
<ProfileImage
src={avatarUrl}
alt={displayName}
className="size-8 flex-shrink-0 rounded-full border border-muted"
/>
<div className="min-w-0 flex-1">
<p className="text-xs text-foreground-light">Signed in as</p>
<p className="truncate text-sm text-foreground">
{displayName || <span className="invisible">Loading account</span>}
</p>
{detail && <p className="mt-1 truncate text-xs text-foreground-light">{detail}</p>}
</div>
{action}
</CardContent>
</Card>
)