From e19cd1863dd6560b0e473f1814ea45230245e6fb Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:20:42 -0400 Subject: [PATCH] feat(studio): connect logo contract for authorize (#48161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Feature + docs. 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" } ``` ## 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. --------- Co-authored-by: Joshen Lim --- apps/design-system/__registry__/index.tsx | 22 ++++ .../ui-patterns/connect-interstitials.mdx | 94 +++++++++++++--- .../public/img/icons/acme-oauth-icon.png | Bin 0 -> 137 bytes .../connect-interstitial-logo-unknown.tsx | 25 +++++ .../connect-interstitial-logo-uploaded.tsx | 40 +++++++ .../example/connect-interstitial-shared.tsx | 4 +- apps/design-system/registry/examples.ts | 10 ++ .../ApiAuthorization.Approved.tsx | 12 +-- .../ApiAuthorization.Form.tsx | 12 +-- .../OAuthApps/AuthorizeRequesterDetails.tsx | 95 ++++++++-------- .../OAuthApps/OAuthApps.utils.test.ts | 91 ++++++++++++++++ .../Organization/OAuthApps/OAuthApps.utils.ts | 101 ++++++++++++++++++ .../OAuthApps/PublishAppSidePanel/index.tsx | 7 +- .../components/layouts/InterstitialLayout.tsx | 7 +- .../components/ApiAuthorization.test.tsx | 99 ++++++++++++++--- 15 files changed, 523 insertions(+), 96 deletions(-) create mode 100644 apps/design-system/public/img/icons/acme-oauth-icon.png create mode 100644 apps/design-system/registry/default/example/connect-interstitial-logo-unknown.tsx create mode 100644 apps/design-system/registry/default/example/connect-interstitial-logo-uploaded.tsx create mode 100644 apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.test.ts create mode 100644 apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index 84d94abdbc4..3dbe1fb2acf 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -2634,6 +2634,28 @@ export const Index: Record = { 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", diff --git a/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx b/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx index b5f1514af02..ceb6d36b189 100644 --- a/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx +++ b/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx @@ -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. +**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. + -```tsx -const AwsLogo = () => ( - - AWS - -) +**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. -} right={} /> -``` + + +### 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. + + ## Copy Use sentence case. Prefer `sign in` over `login`. Titles and primary actions diff --git a/apps/design-system/public/img/icons/acme-oauth-icon.png b/apps/design-system/public/img/icons/acme-oauth-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6cf13412dfbc077db8bc28cf78e9b2a5e3c1b68c GIT binary patch literal 137 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLGJM08bakkcwMx&pUE5FmSMJ@P2;% qALE-umivbiSf{P4)MX+KOlLLwvM@@#f67Uq*$kepelF{r5}E*C^BU>^ literal 0 HcmV?d00001 diff --git a/apps/design-system/registry/default/example/connect-interstitial-logo-unknown.tsx b/apps/design-system/registry/default/example/connect-interstitial-logo-unknown.tsx new file mode 100644 index 00000000000..3255c7f5472 --- /dev/null +++ b/apps/design-system/registry/default/example/connect-interstitial-logo-unknown.tsx @@ -0,0 +1,25 @@ +import { Button } from 'ui' + +import { + AccountRow, + InterstitialShell, + SignOutButton, + SupabaseLogo, +} from './connect-interstitial-shared' + +export default function ConnectInterstitialLogoUnknown() { + return ( + } + title="Authorize Acme" + description="Acme is requesting access to your organization" + > +
+ } /> + +
+
+ ) +} diff --git a/apps/design-system/registry/default/example/connect-interstitial-logo-uploaded.tsx b/apps/design-system/registry/default/example/connect-interstitial-logo-uploaded.tsx new file mode 100644 index 00000000000..5aaf43fab02 --- /dev/null +++ b/apps/design-system/registry/default/example/connect-interstitial-logo-uploaded.tsx @@ -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 ( + + Acme + + ) +} + +export default function ConnectInterstitialLogoUploaded() { + return ( + } right={} />} + title="Authorize Acme" + description="Acme is requesting access to your organization" + > +
+ } /> + +
+
+ ) +} diff --git a/apps/design-system/registry/default/example/connect-interstitial-shared.tsx b/apps/design-system/registry/default/example/connect-interstitial-shared.tsx index 24c35e4da08..e9100175ae0 100644 --- a/apps/design-system/registry/default/example/connect-interstitial-shared.tsx +++ b/apps/design-system/registry/default/example/connect-interstitial-shared.tsx @@ -45,9 +45,9 @@ export function StripeLogo() { ) } -export function SupabaseLogo() { +export function SupabaseLogo({ forceLight = false }: { forceLight?: boolean } = {}) { return ( - + } - right={} + } title={requester.name} @@ -40,7 +41,6 @@ export function ApiAuthorizationApprovedScreen({ /> } - right={} + } title={`Authorize ${requester.name}`} @@ -125,7 +126,6 @@ export function ApiAuthorizationMainView({ {showReadyContent && ( <> { + redirectUri?: string | null +}) => { const [failedIcon, setFailedIcon] = useState(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 + } + + const forceLightPair = !logo.isKnownClient return ( - - {showLetter ? ( - {name.slice(0, 1)} - ) : ( - {name} setFailedIcon(logo.src)} - /> - )} - + + {name} setFailedIcon(logo.src)} + /> + + } + right={} + /> ) } diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.test.ts b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.test.ts new file mode 100644 index 00000000000..cce0b1b7fe9 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.test.ts @@ -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 }) + }) +}) diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts new file mode 100644 index 00000000000..bc0df5ef05e --- /dev/null +++ b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts @@ -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 } +} diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/index.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/index.tsx index 2f4dbd6b39e..0d47c4163ca 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/index.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/index.tsx @@ -449,12 +449,7 @@ export const PublishAppSidePanel = ({ - +

Select an organization to grant API access to

diff --git a/apps/studio/components/layouts/InterstitialLayout.tsx b/apps/studio/components/layouts/InterstitialLayout.tsx index d17b469956e..1764b546c72 100644 --- a/apps/studio/components/layouts/InterstitialLayout.tsx +++ b/apps/studio/components/layouts/InterstitialLayout.tsx @@ -158,9 +158,12 @@ export const DestinationLogo = ({ icon, name }: { icon?: ReactNode; name: string ) +/** 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 = () => ( - +export const SupabaseLogo = ({ forceLight = false }: { forceLight?: boolean } = {}) => ( + Supabase ) diff --git a/apps/studio/tests/components/ApiAuthorization.test.tsx b/apps/studio/tests/components/ApiAuthorization.test.tsx index 5d45af7e14b..477bc847fd1 100644 --- a/apps/studio/tests/components/ApiAuthorization.test.tsx +++ b/apps/studio/tests/components/ApiAuthorization.test.tsx @@ -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 = {}) { // --- 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() + ['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() 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() + test('does not use a curated logo from the requester name alone', () => { + customRender( + + ) + + expect(screen.getByAltText('Supabase')).toBeInTheDocument() + expect(screen.queryByAltText('Claude')).not.toBeInTheDocument() + }) + + test('shows Supabase alone when the requester has no icon', () => { + customRender() + + 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( + + ) 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( + + ) + + 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( + + ) + + 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()