diff --git a/.changeset/oauth-consent-known-clients.md b/.changeset/oauth-consent-known-clients.md new file mode 100644 index 00000000000..200a84478c3 --- /dev/null +++ b/.changeset/oauth-consent-known-clients.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +The OAuth consent screen now shows a recognizable brand mark for well-known OAuth clients (Claude, ChatGPT) when the requesting application has not uploaded its own logo. diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json index c3e10aadcff..6b07013cfbe 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -26,7 +26,7 @@ { "path": "./dist/planDetails*.js", "maxSize": "5.5KB" }, { "path": "./dist/subscriptionDetails*.js", "maxSize": "7KB" }, { "path": "./dist/apiKeys*.js", "maxSize": "6KB" }, - { "path": "./dist/oauthConsent*.js", "maxSize": "6KB" }, + { "path": "./dist/oauthConsent*.js", "maxSize": "7.5KB" }, { "path": "./dist/up-billing-page*.js", "maxSize": "3KB" }, { "path": "./dist/op-billing-page*.js", "maxSize": "3KB" }, { "path": "./dist/up-plans-page*.js", "maxSize": "2.5KB" }, diff --git a/packages/ui/src/components/OAuthConsent/LogoGroup.tsx b/packages/ui/src/components/OAuthConsent/LogoGroup.tsx index 09292f3f7c3..446a7b3b2b9 100644 --- a/packages/ui/src/components/OAuthConsent/LogoGroup.tsx +++ b/packages/ui/src/components/OAuthConsent/LogoGroup.tsx @@ -35,10 +35,21 @@ export function LogoGroupItem({ children, sx, ...props }: ComponentProps { - const value = size === 'sm' ? t.space.$6 : t.space.$12; - return { width: value, height: value }; + const container = size === 'sm' ? t.space.$6 : t.space.$12; + const inner = size === 'sm' ? t.space.$3 : t.space.$5; + // `&&` doubles the container's specificity so it wins over a bare child + // (Icon / ApplicationLogo) that sizes itself with a single class. + return { width: container, height: container, '&& > *': { width: inner, height: inner } }; }; return ( @@ -60,16 +71,38 @@ export function LogoGroupIcon({ size = 'md', sx }: { size?: 'sm' | 'md'; sx?: Th scale, sx, ]} - elementDescriptor={descriptors.logoGroupIcon} + elementDescriptor={descriptors.logoGroupItemContainer} > - ({ color: t.colors.$primary500 })} - /> + {children} ); } +export function LogoGroupIcon({ + icon = LockDottedCircle, + iconSx = t => ({ color: t.colors.$primary500 }), + label, +}: { + icon?: React.ComponentType; + iconSx?: ThemableCssProp; + /** + * Accessible name for a meaningful mark (e.g. a recognized client's brand). + * Omit for the decorative fallback, which is then hidden from assistive tech. + */ + label?: string; +}) { + return ( + + ); +} + export function LogoGroupSeparator() { return ( - + + + - + + + )} @@ -176,20 +186,24 @@ function _OAuthConsent() { {oauthApplicationLogoUrl && !logoImageUrl && ( - - + + + ({ position: 'absolute', - bottom: `calc(${t.space.$3} * -1)`, - insetInlineEnd: `calc(${t.space.$3} * -1)`, + bottom: `calc(${t.space.$2x5} * -1)`, + insetInlineEnd: `calc(${t.space.$2x5} * -1)`, })} - /> + > + + )} @@ -197,18 +211,32 @@ function _OAuthConsent() { {!oauthApplicationLogoUrl && logoImageUrl && ( - + + + - + + + )} {/* no avatars */} {!oauthApplicationLogoUrl && !logoImageUrl && ( - + + + )} diff --git a/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx b/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx index 893b1344f2f..fc401643835 100644 --- a/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx +++ b/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx @@ -542,4 +542,31 @@ describe('OAuthConsent', () => { }); }); }); + + it('renders the branded logo badge for a recognized client with no uploaded logo', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withUser({ email_addresses: ['jane@example.com'] }); + }); + + props.setProps({ componentName: 'OAuthConsent' } as any); + mockOAuthApplication(fixtures.clerk, { + getConsentInfo: vi.fn().mockResolvedValue({ + ...fakeConsentInfo, + oauthApplicationName: 'Claude', + oauthApplicationLogoUrl: '', + redirectDomain: 'claude.ai', + }), + }); + + const { getByText, baseElement } = render(, { wrapper }); + + await waitFor(() => { + expect(getByText('Claude')).toBeVisible(); + const icon = baseElement.querySelector('.cl-logoGroupIcon'); + expect(icon).not.toBeNull(); + // The brand mark conveys which app is requesting access, so it needs an accessible name. + expect(icon).toHaveAttribute('role', 'img'); + expect(icon).toHaveAttribute('aria-label', 'Claude'); + }); + }); }); diff --git a/packages/ui/src/components/OAuthConsent/__tests__/knownClients.test.ts b/packages/ui/src/components/OAuthConsent/__tests__/knownClients.test.ts new file mode 100644 index 00000000000..7dbe643f3e7 --- /dev/null +++ b/packages/ui/src/components/OAuthConsent/__tests__/knownClients.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { getKnownOAuthClient } from '../knownClients'; + +describe('getKnownOAuthClient', () => { + it('resolves Claude from its registrable domains', () => { + expect(getKnownOAuthClient('claude.ai')?.name).toBe('Claude'); + expect(getKnownOAuthClient('claude.com')?.name).toBe('Claude'); + expect(getKnownOAuthClient('anthropic.com')?.name).toBe('Claude'); + }); + + it('resolves ChatGPT from its registrable domains', () => { + expect(getKnownOAuthClient('chatgpt.com')?.name).toBe('ChatGPT'); + expect(getKnownOAuthClient('openai.com')?.name).toBe('ChatGPT'); + }); + + it('is case-insensitive and trims surrounding whitespace', () => { + expect(getKnownOAuthClient(' Claude.AI ')?.name).toBe('Claude'); + }); + + it('returns undefined for empty, nullish, or unknown domains', () => { + expect(getKnownOAuthClient('')).toBeUndefined(); + expect(getKnownOAuthClient(null)).toBeUndefined(); + expect(getKnownOAuthClient(undefined)).toBeUndefined(); + expect(getKnownOAuthClient('example.com')).toBeUndefined(); + }); + + it('does not match a subdomain string that is not the registrable domain', () => { + // Matching is exact on the PSL-resolved registrable domain, so a look-alike + // host must not borrow the branding. + expect(getKnownOAuthClient('claude.ai.evil.com')).toBeUndefined(); + expect(getKnownOAuthClient('notclaude.ai')).toBeUndefined(); + }); + + it('provides an icon component for matched clients', () => { + expect(getKnownOAuthClient('claude.ai')?.icon).toBeDefined(); + expect(getKnownOAuthClient('chatgpt.com')?.icon).toBeDefined(); + }); +}); diff --git a/packages/ui/src/components/OAuthConsent/brandIcons.ts b/packages/ui/src/components/OAuthConsent/brandIcons.ts new file mode 100644 index 00000000000..80730c02b83 --- /dev/null +++ b/packages/ui/src/components/OAuthConsent/brandIcons.ts @@ -0,0 +1,11 @@ +// @ts-nocheck +/** + * Brand marks for recognized OAuth clients. These live in the component + * directory (not `src/icons`) on purpose: the `ui-common` rspack cacheGroup + * captures everything outside `/components`, so keeping them here bundles the + * marks into the lazy `oauthConsent` chunk instead of the shared chunk that + * loads on every component. The `@ts-nocheck` mirrors `icons/index.ts`: `.svg` + * modules are resolved by the bundler (svgr), not by tsc. + */ +export { default as Claude } from './claude.svg'; +export { default as OpenAI } from './openai.svg'; diff --git a/packages/ui/src/components/OAuthConsent/claude.svg b/packages/ui/src/components/OAuthConsent/claude.svg new file mode 100644 index 00000000000..786fa29d08b --- /dev/null +++ b/packages/ui/src/components/OAuthConsent/claude.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/components/OAuthConsent/knownClients.ts b/packages/ui/src/components/OAuthConsent/knownClients.ts new file mode 100644 index 00000000000..ebb18858578 --- /dev/null +++ b/packages/ui/src/components/OAuthConsent/knownClients.ts @@ -0,0 +1,44 @@ +import type React from 'react'; + +import type { ThemableCssProp } from '@/ui/styledSystem'; + +import { Claude, OpenAI } from './brandIcons'; + +/** + * A curated OAuth client we can recognize and brand on the consent screen. + * Matching is keyed on the registrable redirect domain (the trusted, + * backend-resolved signal), never on the app-owner-set name or homepage. + */ +type KnownOAuthClient = { + name: string; + icon: React.ComponentType; + /** + * Overrides the default badge icon tint. Omit for icons that carry their own + * fill (e.g. a fixed brand color). + */ + iconSx?: ThemableCssProp; + /** Registrable domains (PSL-resolved) that identify this client. */ + domains: string[]; +}; + +const KNOWN_OAUTH_CLIENTS: KnownOAuthClient[] = [ + { name: 'Claude', icon: Claude, domains: ['claude.ai', 'claude.com', 'anthropic.com'] }, + { + name: 'ChatGPT', + icon: OpenAI, + iconSx: t => ({ color: t.colors.$colorForeground }), + domains: ['chatgpt.com', 'openai.com'], + }, +]; + +/** + * Resolves a known OAuth client from its registrable redirect domain, or + * `undefined` when the domain is empty or unrecognized. + */ +export function getKnownOAuthClient(domain?: string | null): KnownOAuthClient | undefined { + if (!domain) { + return undefined; + } + const normalized = domain.trim().toLowerCase(); + return KNOWN_OAUTH_CLIENTS.find(client => client.domains.includes(normalized)); +} diff --git a/packages/ui/src/components/OAuthConsent/openai.svg b/packages/ui/src/components/OAuthConsent/openai.svg new file mode 100644 index 00000000000..747e3342e82 --- /dev/null +++ b/packages/ui/src/components/OAuthConsent/openai.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/customizables/elementDescriptors.ts b/packages/ui/src/customizables/elementDescriptors.ts index 0e8f3dc9d92..abe2260ba9f 100644 --- a/packages/ui/src/customizables/elementDescriptors.ts +++ b/packages/ui/src/customizables/elementDescriptors.ts @@ -55,6 +55,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'logoGroup', 'logoGroupItem', + 'logoGroupItemContainer', 'logoGroupIcon', 'logoGroupSeparator', diff --git a/packages/ui/src/internal/appearance.ts b/packages/ui/src/internal/appearance.ts index bb64ee12425..82010ace01b 100644 --- a/packages/ui/src/internal/appearance.ts +++ b/packages/ui/src/internal/appearance.ts @@ -183,6 +183,7 @@ export type ElementsConfig = { logoGroup: WithOptions; logoGroupItem: WithOptions; + logoGroupItemContainer: WithOptions; logoGroupIcon: WithOptions; logoGroupSeparator: WithOptions; diff --git a/packages/ui/src/primitives/Icon.tsx b/packages/ui/src/primitives/Icon.tsx index 42a29334628..0e0d04c396f 100644 --- a/packages/ui/src/primitives/Icon.tsx +++ b/packages/ui/src/primitives/Icon.tsx @@ -29,6 +29,11 @@ const { applyVariants, filterProps } = createVariants(theme => ({ // @ts-ignore export type IconProps = StyleVariants & { icon: React.ComponentType; + // Icon renders an ; allow the accessibility attributes an svg accepts so a + // meaningful icon can carry a name (decorative ones stay aria-hidden). + role?: React.AriaRole; + 'aria-label'?: string; + 'aria-hidden'?: boolean; }; export const Icon = (props: IconProps): JSX.Element => {