mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
1 parent
c8aca8d3a0
commit
e19cd1863d
15 files changed
+523
-96
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
+47
-48
@@ -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 }
|
||||
}
|
||||
+1
-6
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user