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>
This commit is contained in:
Danny WhiteandJoshen Lim authored and GitHub committed 2026-07-23 02:20:42 +10:00
1 parent c8aca8d3a0
commit e19cd1863d
15 files changed
+523 -96

No files matched your search

+22
View File
@@ -2634,6 +2634,28 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"connect-interstitial-logo-unknown": {
name: "connect-interstitial-logo-unknown",
type: "components:example",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/example/connect-interstitial-logo-unknown")),
source: "",
files: ["registry/default/example/connect-interstitial-logo-unknown.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"connect-interstitial-logo-uploaded": {
name: "connect-interstitial-logo-uploaded",
type: "components:example",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/example/connect-interstitial-logo-uploaded")),
source: "",
files: ["registry/default/example/connect-interstitial-logo-uploaded.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"page-layout-auth-emails": {
name: "page-layout-auth-emails",
type: "components:example",
@@ -81,10 +81,26 @@ project linking.
## Logos
Use `LogoPair` when the user is connecting two services, and `SupabaseLogo`
alone for first-party flows. `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`.
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"
@@ -96,9 +112,13 @@ Store new partner icons in `apps/studio/public/img/icons`.
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-single"
description="SupabaseLogo alone for first-party flows"
name="connect-interstitial-logo-unknown"
description="No trusted requester mark: Supabase alone"
align="start"
className="p-0"
padded={false}
@@ -106,15 +126,51 @@ Store new partner icons in `apps/studio/public/img/icons`.
wide
/>
```tsx
const AwsLogo = () => (
<LogoBox className="border-[#232f3e] bg-[#232f3e]">
<img alt="AWS" src={`${BASE_PATH}/img/icons/aws-icon.svg`} className="w-8" />
</LogoBox>
)
**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.
<LogoPair left={<AwsLogo />} right={<SupabaseLogo />} />
```
<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 only,
not from self-asserted `name` or `website`. 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.
## Account row
@@ -150,6 +206,16 @@ 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 137 B

@@ -0,0 +1,25 @@
import { Button } from 'ui'
import {
AccountRow,
InterstitialShell,
SignOutButton,
SupabaseLogo,
} from './connect-interstitial-shared'
export default function ConnectInterstitialLogoUnknown() {
return (
<InterstitialShell
logo={<SupabaseLogo />}
title="Authorize Acme"
description="Acme is requesting access to your organization"
>
<div className="flex flex-col gap-4">
<AccountRow displayName="alex@example.com" action={<SignOutButton />} />
<Button variant="primary" block>
Authorize Acme
</Button>
</div>
</InterstitialShell>
)
}
@@ -0,0 +1,40 @@
import { Button } from 'ui'
import {
AccountRow,
InterstitialShell,
LogoBox,
LogoPair,
SignOutButton,
SupabaseLogo,
} from './connect-interstitial-shared'
/** Stand-in uploaded OAuth icon: checked-in solid-colour bitmap (not a real brand). */
function UploadedAppLogo() {
return (
<LogoBox className="border-black/10 bg-white">
<img
alt="Acme"
src={`${process.env.NEXT_PUBLIC_BASE_PATH || '/design-system'}/img/icons/acme-oauth-icon.png`}
className="size-full object-cover"
/>
</LogoBox>
)
}
export default function ConnectInterstitialLogoUploaded() {
return (
<InterstitialShell
logo={<LogoPair left={<UploadedAppLogo />} right={<SupabaseLogo forceLight />} />}
title="Authorize Acme"
description="Acme is requesting access to your organization"
>
<div className="flex flex-col gap-4">
<AccountRow displayName="alex@example.com" action={<SignOutButton />} />
<Button variant="primary" block>
Authorize Acme
</Button>
</div>
</InterstitialShell>
)
}
@@ -45,9 +45,9 @@ export function StripeLogo() {
)
}
export function SupabaseLogo() {
export function SupabaseLogo({ forceLight = false }: { forceLight?: boolean } = {}) {
return (
<LogoBox className="bg-surface-75">
<LogoBox className={forceLight ? 'border-black/10 bg-white' : 'bg-surface-75'}>
<svg viewBox="0 0 109 113" className="size-7" aria-hidden>
<path
d="M63.708 110.284c-2.86 3.601-8.658 1.628-8.727-2.97L53.974 40.063h45.22c8.19 0 12.758 9.46 7.665 15.874L63.708 110.284Z"
+10
View File
@@ -1413,6 +1413,16 @@ export const examples: Registry = [
type: 'components:example',
files: ['example/connect-interstitial-logo-single.tsx'],
},
{
name: 'connect-interstitial-logo-unknown',
type: 'components:example',
files: ['example/connect-interstitial-logo-unknown.tsx'],
},
{
name: 'connect-interstitial-logo-uploaded',
type: 'components:example',
files: ['example/connect-interstitial-logo-uploaded.tsx'],
},
{
name: 'page-layout-auth-emails',
type: 'components:example',
@@ -3,10 +3,10 @@ import type { ReactNode } from 'react'
import { Admonition } from 'ui-patterns/admonition'
import {
AuthorizeConnectLogo,
AuthorizeRequesterDetails,
RequesterLogo,
} from '@/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails'
import { InterstitialLayout, LogoPair, SupabaseLogo } from '@/components/layouts/InterstitialLayout'
import { InterstitialLayout } from '@/components/layouts/InterstitialLayout'
import type { ApiAuthorizationResponse } from '@/data/api-authorization/api-authorization-query'
import type { Organization } from '@/types'
@@ -24,9 +24,10 @@ export function ApiAuthorizationApprovedScreen({
return (
<InterstitialLayout
logo={
<LogoPair
left={<RequesterLogo icon={requester.icon} name={requester.name} />}
right={<SupabaseLogo />}
<AuthorizeConnectLogo
icon={requester.icon}
name={requester.name}
redirectUri={requester.redirect_uri}
/>
}
title={requester.name}
@@ -40,7 +41,6 @@ export function ApiAuthorizationApprovedScreen({
/>
<AuthorizeRequesterDetails
showOnlyScopes
icon={requester.icon}
name={requester.name}
domain={requester.domain}
scopes={requester.scopes}
@@ -20,10 +20,10 @@ import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import type { ApprovalState, IApprovalFormSchema } from './ApiAuthorization.Schema'
import {
AuthorizeConnectLogo,
AuthorizeRequesterDetails,
RequesterLogo,
} from '@/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails'
import { InterstitialLayout, LogoPair, SupabaseLogo } from '@/components/layouts/InterstitialLayout'
import { InterstitialLayout } from '@/components/layouts/InterstitialLayout'
import type { ApiAuthorizationResponse } from '@/data/api-authorization/api-authorization-query'
import type { Organization, ResponseError } from '@/types'
@@ -92,9 +92,10 @@ export function ApiAuthorizationMainView({
return (
<InterstitialLayout
logo={
<LogoPair
left={<RequesterLogo icon={requester.icon} name={requester.name} />}
right={<SupabaseLogo />}
<AuthorizeConnectLogo
icon={requester.icon}
name={requester.name}
redirectUri={requester.redirect_uri}
/>
}
title={`Authorize ${requester.name}`}
@@ -125,7 +126,6 @@ export function ApiAuthorizationMainView({
{showReadyContent && (
<>
<AuthorizeRequesterDetails
icon={requester.icon}
name={requester.name}
domain={requester.domain}
scopes={requester.scopes}
@@ -12,10 +12,15 @@ import {
CollapsibleTrigger,
} from 'ui'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import { getMcpClientIconSrc } from 'ui-patterns/McpUrlBuilder'
import { PERMISSIONS_DESCRIPTIONS } from './OAuthApps.constants'
import { LogoBox } from '@/components/layouts/InterstitialLayout'
import { getRequesterLogo } from './OAuthApps.utils'
import {
CONNECT_LOGO_LIGHT_TILE_CLASSNAME,
LogoBox,
LogoPair,
SupabaseLogo,
} from '@/components/layouts/InterstitialLayout'
import { InlineLink } from '@/components/ui/InlineLink'
import { DOCS_URL } from '@/lib/constants'
@@ -24,7 +29,6 @@ const PERMISSION_DETAILS_TRIGGER_CLASSNAME =
'mx-auto flex h-7 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2 text-xs text-foreground-lighter transition-colors hover:bg-surface-200 hover:text-foreground'
export interface AuthorizeRequesterDetailsProps {
icon: string | null
name: string
domain: string
scopes: OAuthScope[]
@@ -167,64 +171,59 @@ const PERMISSION_GROUPS: PermissionGroup[] = [
},
]
const CUSTOM_LOGO_KEYS = {
perplexity: { icon: 'perplexity', hasDistinctDarkIcon: true },
cursor: { icon: 'cursor', hasDistinctDarkIcon: true },
claude: { icon: 'claude', hasDistinctDarkIcon: false },
chatgpt: { icon: 'openai', hasDistinctDarkIcon: true },
openai: { icon: 'openai', hasDistinctDarkIcon: true },
} as const
function getRequesterLogo({
/**
* Connect interstitial header mark for `/authorize`.
* Curated logos resolve from allowlisted redirect_uri hosts; otherwise pair only
* when a usable remote icon is present, else show Supabase alone.
*
* Uploaded / unknown bitmaps have no light/dark metadata, so both tiles use
* fixed light chrome (`forceLight`) across Studio themes. Curated partners keep
* theme-reactive tiles and may swap dark assets when available.
*/
export const AuthorizeConnectLogo = ({
icon,
name,
useDarkVariant,
redirectUri,
}: {
icon: string | null
name: string
useDarkVariant: boolean
}) {
const searchableText = `${icon ?? ''} ${name}`.toLowerCase()
for (const [match, asset] of Object.entries(CUSTOM_LOGO_KEYS)) {
if (searchableText.includes(match)) {
const customLogoUrl = getMcpClientIconSrc({
icon: asset.icon,
useDarkVariant,
hasDistinctDarkIcon: asset.hasDistinctDarkIcon,
})
if (customLogoUrl) return { src: customLogoUrl, isKnownClient: true }
}
}
return { src: icon || '', isKnownClient: false }
}
export const RequesterLogo = ({ icon, name }: { icon: string | null; name: string }) => {
redirectUri?: string | null
}) => {
const [failedIcon, setFailedIcon] = useState<string | null>(null)
const { resolvedTheme } = useTheme()
const logo = useMemo(
() => getRequesterLogo({ icon, name, useDarkVariant: resolvedTheme === 'dark' }),
[icon, name, resolvedTheme]
() =>
getRequesterLogo({
icon,
redirectUri,
useDarkVariant: resolvedTheme === 'dark',
}),
[icon, redirectUri, resolvedTheme]
)
const showLetter = !logo.src || failedIcon === logo.src
const hasUsableLogo = Boolean(logo.src) && failedIcon !== logo.src
if (!hasUsableLogo) {
return <SupabaseLogo />
}
const forceLightPair = !logo.isKnownClient
return (
<LogoBox className="bg-surface-75">
{showLetter ? (
<span className="text-lg font-medium text-foreground-light">{name.slice(0, 1)}</span>
) : (
<img
alt={name}
src={logo.src}
className={cn(logo.isKnownClient ? 'size-7 object-contain' : 'size-full object-cover')}
onError={() => setFailedIcon(logo.src)}
/>
)}
</LogoBox>
<LogoPair
left={
<LogoBox className={forceLightPair ? CONNECT_LOGO_LIGHT_TILE_CLASSNAME : 'bg-surface-75'}>
<img
alt={name}
src={logo.src}
className={cn(logo.isKnownClient ? 'size-7 object-contain' : 'size-full object-cover')}
onError={() => setFailedIcon(logo.src)}
/>
</LogoBox>
}
right={<SupabaseLogo forceLight={forceLightPair} />}
/>
)
}
@@ -0,0 +1,91 @@
import { getMcpClientIconSrc } from 'ui-patterns/McpUrlBuilder'
import { describe, expect, test } from 'vitest'
import {
findTrustedPartnerByRedirectUri,
getRedirectHostname,
getRequesterLogo,
hostMatchesAllowlist,
isLocalRedirectHost,
} from './OAuthApps.utils'
describe('hostMatchesAllowlist', () => {
test('allows exact and subdomain hosts', () => {
expect(hostMatchesAllowlist('claude.ai', ['claude.ai'])).toBe(true)
expect(hostMatchesAllowlist('api.claude.ai', ['claude.ai'])).toBe(true)
})
test('rejects lookalike hosts', () => {
expect(hostMatchesAllowlist('claude.ai.evil.com', ['claude.ai'])).toBe(false)
expect(hostMatchesAllowlist('notclaude.ai', ['claude.ai'])).toBe(false)
expect(hostMatchesAllowlist('evilclaude.ai', ['claude.ai'])).toBe(false)
})
})
describe('isLocalRedirectHost', () => {
test.each(['localhost', '127.0.0.1', '[::1]', '::1', 'app.localhost'])(
'treats %s as local',
(host) => {
expect(isLocalRedirectHost(host)).toBe(true)
}
)
test('treats public hosts as remote', () => {
expect(isLocalRedirectHost('claude.ai')).toBe(false)
expect(isLocalRedirectHost('evil.com')).toBe(false)
})
})
describe('getRedirectHostname', () => {
test('parses https redirect URIs', () => {
expect(getRedirectHostname('https://claude.ai/api/mcp/auth_callback')).toBe('claude.ai')
})
test('returns null for invalid URIs', () => {
expect(getRedirectHostname('not-a-url')).toBe(null)
expect(getRedirectHostname(null)).toBe(null)
})
})
describe('findTrustedPartnerByRedirectUri', () => {
test('resolves Claude from redirect host', () => {
expect(
findTrustedPartnerByRedirectUri('https://claude.ai/api/mcp/auth_callback')?.displayName
).toBe('Claude')
})
test('ignores localhost redirects', () => {
expect(findTrustedPartnerByRedirectUri('http://127.0.0.1:42813/callback')).toBe(null)
})
})
describe('getRequesterLogo', () => {
test('uses curated assets only when redirect host is allowlisted', () => {
const trusted = getRequesterLogo({
icon: null,
redirectUri: 'https://claude.ai/api/mcp/auth_callback',
useDarkVariant: false,
})
expect(trusted).toEqual({
src: getMcpClientIconSrc({ icon: 'claude', useDarkVariant: false }),
isKnownClient: true,
})
const namedOnly = getRequesterLogo({
icon: null,
redirectUri: 'https://evil.com/callback',
useDarkVariant: false,
})
expect(namedOnly).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',
redirectUri: 'https://evil.com/callback',
useDarkVariant: false,
})
).toEqual({ src: 'https://example.com/icon.png', isKnownClient: false })
})
})
@@ -0,0 +1,101 @@
import { getMcpClientIconSrc } from 'ui-patterns/McpUrlBuilder'
export type TrustedOAuthPartner = {
displayName: string
icon: string
hasDistinctDarkIcon: boolean
/** Exact host or parent host for redirect_uri (subdomains allowed). */
redirectHosts: readonly string[]
}
/**
* High-traffic MCP / OAuth partners with curated Connect logos.
* Logos resolve from redirect_uri host only — never from self-asserted name/website.
*/
export const TRUSTED_OAUTH_PARTNERS: readonly TrustedOAuthPartner[] = [
{
displayName: 'Claude',
icon: 'claude',
hasDistinctDarkIcon: false,
redirectHosts: ['claude.ai', 'anthropic.com'],
},
{
displayName: 'Cursor',
icon: 'cursor',
hasDistinctDarkIcon: true,
redirectHosts: ['cursor.com', 'cursor.sh'],
},
{
displayName: 'ChatGPT',
icon: 'openai',
hasDistinctDarkIcon: true,
redirectHosts: ['chatgpt.com', 'openai.com'],
},
{
displayName: 'Perplexity',
icon: 'perplexity',
hasDistinctDarkIcon: true,
redirectHosts: ['perplexity.ai'],
},
]
const LOCAL_REDIRECT_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
export function getRedirectHostname(redirectUri: string | null | undefined): string | null {
if (!redirectUri) return null
try {
const { hostname } = new URL(redirectUri)
return hostname.toLowerCase() || null
} catch {
return null
}
}
export function isLocalRedirectHost(hostname: string | null | undefined): boolean {
if (!hostname) return false
const host = hostname.toLowerCase()
return LOCAL_REDIRECT_HOSTS.has(host) || host.endsWith('.localhost')
}
export function hostMatchesAllowlist(hostname: string, allowedHosts: readonly string[]): boolean {
const host = hostname.toLowerCase()
return allowedHosts.some((allowed) => {
const entry = allowed.toLowerCase()
return host === entry || host.endsWith(`.${entry}`)
})
}
export function findTrustedPartnerByRedirectUri(
redirectUri: string | null | undefined
): TrustedOAuthPartner | null {
const hostname = getRedirectHostname(redirectUri)
if (!hostname || isLocalRedirectHost(hostname)) return null
return (
TRUSTED_OAUTH_PARTNERS.find((partner) =>
hostMatchesAllowlist(hostname, partner.redirectHosts)
) ?? null
)
}
export function getRequesterLogo({
icon,
redirectUri,
useDarkVariant,
}: {
icon: 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 }
}
return { src: icon || '', isKnownClient: false }
}
@@ -449,12 +449,7 @@ export const PublishAppSidePanel = ({
</div>
</AlertDialogTitle>
<AlertDialogDescription>
<AuthorizeRequesterDetails
icon={iconUrl || null}
name={name}
domain={website}
scopes={scopes}
/>
<AuthorizeRequesterDetails name={name} domain={website} scopes={scopes} />
<div className="pt-4 space-y-2">
<p className="prose text-sm">Select an organization to grant API access to</p>
<div className="border border-control text-foreground-light rounded-sm px-4 py-2 text-sm bg-surface-200">
@@ -158,9 +158,12 @@ export const DestinationLogo = ({ icon, name }: { icon?: ReactNode; name: string
</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 = () => (
<LogoBox className="bg-surface-75">
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>
)
@@ -10,7 +10,7 @@ import {
ApiAuthorizationScreen,
type ApiAuthorizationScreenProps,
} from '@/components/interfaces/ApiAuthorization/ApiAuthorization'
import { RequesterLogo } from '@/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails'
import { AuthorizeConnectLogo } from '@/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails'
import type { ApiAuthorizationResponse } from '@/data/api-authorization/api-authorization-query'
import type { ProfileContextType } from '@/lib/profile'
import { createMockOrganizationResponse } from '@/tests/helpers'
@@ -122,28 +122,74 @@ function renderScreen(props: Partial<ApiAuthorizationScreenProps> = {}) {
// --- Tests ---
describe('RequesterLogo', () => {
describe('AuthorizeConnectLogo', () => {
test.each([
['Cursor', 'cursor'],
['Claude', 'claude'],
['ChatGPT', 'openai'],
['OpenAI', 'openai'],
['Perplexity', 'perplexity'],
])('resolves %s to a shared MCP icon asset', (name, iconKey) => {
customRender(<RequesterLogo icon={null} name={name} />)
['Cursor', 'https://cursor.com/callback', 'cursor'],
['Claude', 'https://claude.ai/api/mcp/auth_callback', 'claude'],
['ChatGPT', 'https://chatgpt.com/callback', 'openai'],
['OpenAI', 'https://openai.com/callback', 'openai'],
['Perplexity', 'https://www.perplexity.ai/callback', 'perplexity'],
])('pairs %s with Supabase when redirect host is allowlisted', (name, redirectUri, iconKey) => {
customRender(<AuthorizeConnectLogo icon={null} name={name} redirectUri={redirectUri} />)
expect(screen.getByAltText(name)).toHaveAttribute(
'src',
getMcpClientIconSrc({ icon: iconKey, useDarkVariant: false })
)
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
})
test('falls back to the requester initial when the icon fails to load', () => {
customRender(<RequesterLogo icon="https://example.com/broken-logo.svg" name="Unknown App" />)
test('does not use a curated logo from the requester name alone', () => {
customRender(
<AuthorizeConnectLogo icon={null} name="Claude" redirectUri="https://evil.com/callback" />
)
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
expect(screen.queryByAltText('Claude')).not.toBeInTheDocument()
})
test('shows Supabase alone when the requester has no icon', () => {
customRender(<AuthorizeConnectLogo icon={null} name="Acme" />)
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
expect(screen.queryByAltText('Acme')).not.toBeInTheDocument()
expect(screen.queryByText('A')).not.toBeInTheDocument()
})
test('shows Supabase alone when the requester icon fails to load', () => {
customRender(
<AuthorizeConnectLogo icon="https://example.com/broken-logo.svg" name="Unknown App" />
)
fireEvent.error(screen.getByAltText('Unknown App'))
expect(screen.getByText('U')).toBeInTheDocument()
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
expect(screen.queryByAltText('Unknown App')).not.toBeInTheDocument()
expect(screen.queryByText('U')).not.toBeInTheDocument()
})
test('forces light tiles when pairing an uploaded OAuth app icon', () => {
customRender(
<AuthorizeConnectLogo
icon="https://example.com/uploaded-icon.png"
name="Acme"
redirectUri="https://acme.example/callback"
/>
)
expect(screen.getByAltText('Acme').parentElement).toHaveClass('bg-white')
expect(screen.getByAltText('Acme').parentElement).toHaveClass('border-black/10')
expect(screen.getByAltText('Supabase').parentElement).toHaveClass('bg-white')
expect(screen.getByAltText('Supabase').parentElement).toHaveClass('border-black/10')
})
test('keeps theme tiles for curated partners', () => {
customRender(
<AuthorizeConnectLogo icon={null} name="Cursor" redirectUri="https://cursor.com/callback" />
)
expect(screen.getByAltText('Cursor').parentElement).toHaveClass('bg-surface-75')
expect(screen.getByAltText('Supabase').parentElement).toHaveClass('bg-surface-75')
})
})
@@ -282,11 +328,40 @@ describe('ApiAuthorizationScreen', () => {
renderScreen()
await screen.findByText('Authorize API access for My OAuth App')
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
expect(screen.queryByText('M')).not.toBeInTheDocument()
expect(screen.getByRole('combobox')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Authorize My OAuth App/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
})
test('pairs curated MCP requesters with Supabase when redirect host is allowlisted', async () => {
mockBothEndpoints(
createMockAuthResponse({
name: 'Cursor',
icon: null,
redirect_uri: 'https://cursor.com/callback',
})
)
renderScreen()
await screen.findByText('Authorize API access for Cursor')
expect(screen.getByAltText('Cursor')).toBeInTheDocument()
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
})
test('shows Supabase alone when name looks trusted but redirect host is not allowlisted', async () => {
mockBothEndpoints(
createMockAuthResponse({
name: 'Claude',
icon: null,
redirect_uri: 'https://evil.com/callback',
})
)
renderScreen()
await screen.findByText('Authorize API access for Claude')
expect(screen.queryByAltText('Claude')).not.toBeInTheDocument()
expect(screen.getByAltText('Supabase')).toBeInTheDocument()
})
test('auto-selects the only organization when no organization_slug is provided', async () => {
mockBothEndpoints()
renderScreen()