Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/oauth-consent-known-clients.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/ui/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
49 changes: 41 additions & 8 deletions packages/ui/src/components/OAuthConsent/LogoGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,21 @@ export function LogoGroupItem({ children, sx, ...props }: ComponentProps<typeof
);
}

export function LogoGroupIcon({ size = 'md', sx }: { size?: 'sm' | 'md'; sx?: ThemableCssProp }) {
export function LogoGroupItemContainer({
size = 'md',
sx,
children,
}: {
size?: 'sm' | 'md';
sx?: ThemableCssProp;
children: React.ReactNode;
}) {
const scale: ThemableCssProp = t => {
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 (
Expand All @@ -60,16 +71,38 @@ export function LogoGroupIcon({ size = 'md', sx }: { size?: 'sm' | 'md'; sx?: Th
scale,
sx,
]}
elementDescriptor={descriptors.logoGroupIcon}
elementDescriptor={descriptors.logoGroupItemContainer}
>
<Icon
icon={LockDottedCircle}
sx={t => ({ color: t.colors.$primary500 })}
/>
{children}
</Box>
);
}

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 (
<Icon
icon={icon}
sx={iconSx}
elementDescriptor={descriptors.logoGroupIcon}
role={label ? 'img' : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
/>
);
}

export function LogoGroupSeparator() {
return (
<Box
Expand Down
70 changes: 49 additions & 21 deletions packages/ui/src/components/OAuthConsent/OAuthConsent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Alert, Textarea } from '@/ui/primitives';
import { Route, Switch } from '@/ui/router';

import { InlineAction } from './InlineAction';
import { getKnownOAuthClient } from './knownClients';
import {
ListGroup,
ListGroupContent,
Expand All @@ -21,7 +22,7 @@ import {
ListGroupItem,
ListGroupItemLabel,
} from './ListGroup';
import { LogoGroup, LogoGroupIcon, LogoGroupItem, LogoGroupSeparator } from './LogoGroup';
import { LogoGroup, LogoGroupIcon, LogoGroupItem, LogoGroupItemContainer, LogoGroupSeparator } from './LogoGroup';
import { OrgSelect } from './OrgSelect';
import { getForwardedParams, getOAuthConsentFromSearch, getRedirectDisplay, getRedirectUriFromSearch } from './utils';

Expand Down Expand Up @@ -90,6 +91,11 @@ function _OAuthConsent() {
const domainAction = data?.redirectDomain ?? getRedirectDisplay(redirectUrl);
const viewFullUrlText = t(localizationKeys('oauthConsent.viewFullUrl'));

// When the OAuth app has no uploaded logo, fall back to a recognized client's
// brand mark (Claude, ChatGPT, ...). Keyed on the trusted redirect domain, not
// the app-owner-set name, so a look-alike name cannot borrow the branding.
const knownClient = oauthApplicationLogoUrl ? undefined : getKnownOAuthClient(domainAction);

// Error states only apply to the public flow.
if (!hasContextCallbacks) {
const errorMessage = !oauthClientId
Expand Down Expand Up @@ -159,56 +165,78 @@ function _OAuthConsent() {
{oauthApplicationLogoUrl && logoImageUrl && (
<LogoGroup>
<LogoGroupItem justify='end'>
<ApplicationLogo
src={oauthApplicationLogoUrl}
alt={oauthApplicationName}
href={oauthApplicationUrl}
isExternal
/>
<LogoGroupItemContainer>
<ApplicationLogo
src={oauthApplicationLogoUrl}
alt={oauthApplicationName}
href={oauthApplicationUrl}
isExternal
/>
</LogoGroupItemContainer>
</LogoGroupItem>
<LogoGroupSeparator />
<LogoGroupItem justify='start'>
<ApplicationLogo />
<LogoGroupItemContainer>
<ApplicationLogo />
</LogoGroupItemContainer>
</LogoGroupItem>
</LogoGroup>
)}
{/* only OAuth app has an avatar */}
{oauthApplicationLogoUrl && !logoImageUrl && (
<LogoGroup>
<Box sx={{ position: 'relative' }}>
<ApplicationLogo
src={oauthApplicationLogoUrl}
alt={oauthApplicationName}
href={oauthApplicationUrl}
isExternal
/>
<LogoGroupIcon
<LogoGroupItemContainer>
<ApplicationLogo
src={oauthApplicationLogoUrl}
alt={oauthApplicationName}
href={oauthApplicationUrl}
isExternal
/>
</LogoGroupItemContainer>
<LogoGroupItemContainer
size='sm'
sx={t => ({
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)`,
})}
/>
>
<LogoGroupIcon />
</LogoGroupItemContainer>
</Box>
</LogoGroup>
)}
{/* only Clerk application has an avatar */}
{!oauthApplicationLogoUrl && logoImageUrl && (
<LogoGroup>
<LogoGroupItem justify='end'>
<LogoGroupIcon />
<LogoGroupItemContainer>
<LogoGroupIcon
icon={knownClient?.icon}
iconSx={knownClient?.iconSx}
label={knownClient?.name}
/>
</LogoGroupItemContainer>
</LogoGroupItem>
<LogoGroupSeparator />
<LogoGroupItem justify='start'>
<ApplicationLogo />
<LogoGroupItemContainer>
<ApplicationLogo />
</LogoGroupItemContainer>
</LogoGroupItem>
</LogoGroup>
)}
{/* no avatars */}
{!oauthApplicationLogoUrl && !logoImageUrl && (
<LogoGroup>
<LogoGroupIcon />
<LogoGroupItemContainer>
<LogoGroupIcon
icon={knownClient?.icon}
iconSx={knownClient?.iconSx}
label={knownClient?.name}
/>
</LogoGroupItemContainer>
</LogoGroup>
)}
<Header.Title localizationKey={oauthApplicationName} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

sed -n '500,580p' packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx

Repository: clerk/javascript

Length of output: 2681


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '500,580p' packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx

Repository: clerk/javascript

Length of output: 2681


🏁 Script executed:

rg -n "createFixtures|setProps\\(|componentName" packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx packages/ui/src -g '*.{ts,tsx}'

Repository: clerk/javascript

Length of output: 50373


🏁 Script executed:

sed -n '1,220p' packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx

printf '\n---\n'

rg -n "function createFixtures|const createFixtures|type .*props|setProps" packages/ui/src/components/OAuthConsent/__tests__ -g '*.{ts,tsx}'

printf '\n---\n'

rg -n "componentName" packages/ui/src/components/OAuthConsent packages/ui/src -g '*.{ts,tsx}'

Repository: clerk/javascript

Length of output: 33642


🏁 Script executed:

rg -n "createFixtures|setProps|componentName" packages/ui/src -g '*.{ts,tsx}'

Repository: clerk/javascript

Length of output: 50373


🏁 Script executed:

git ls-files 'packages/ui/src/**/*test*' 'packages/ui/src/**/__tests__/*' | sed -n '1,200p'

Repository: clerk/javascript

Length of output: 14571


🏁 Script executed:

sed -n '1,220p' packages/ui/src/test/create-fixtures.tsx

printf '\n---\n'

sed -n '1,260p' packages/ui/src/types.ts

Repository: clerk/javascript

Length of output: 12052


🏁 Script executed:

sed -n '1,220p' packages/ui/src/test/create-fixtures.tsx

printf '\n---\n'

sed -n '150,220p' packages/ui/src/types.ts

Repository: clerk/javascript

Length of output: 7486


🏁 Script executed:

cat -n packages/ui/src/test/create-fixtures.tsx | sed -n '1,220p'

Repository: clerk/javascript

Length of output: 6436


🏁 Script executed:

sed -n '1,260p' packages/ui/src/contexts/ClerkUIComponentsContext.tsx

printf '\n---\n'

rg -n "type AvailableComponentProps|interface AvailableComponentProps|ComponentContextProvider" packages/ui/src -g '*.{ts,tsx}'

Repository: clerk/javascript

Length of output: 7493


🏁 Script executed:

rg -n "export (const|function) ComponentContextProvider|type ComponentContextProvider|interface ComponentContextProvider" packages/ui/src/contexts/ClerkUIComponentsContext.tsx packages/ui/src/contexts -g '*.{ts,tsx}'

printf '\n---\n'

sed -n '1,220p' packages/ui/src/contexts/ClerkUIComponentsContext.tsx

Repository: clerk/javascript

Length of output: 6629


🏁 Script executed:

rg -n "type OAuthConsentProps|interface OAuthConsentProps" . -g '*.{ts,tsx,d.ts}'

Repository: clerk/javascript

Length of output: 230


🏁 Script executed:

rg -n "OAuthConsentProps" packages node_modules -g '*.{ts,tsx,d.ts}' --max-count 20

Repository: clerk/javascript

Length of output: 2583


🏁 Script executed:

rg -n "OAuthConsentProps" packages -g '*.{ts,tsx,d.ts}' --max-count 50

Repository: clerk/javascript

Length of output: 2530


Drop the as any cast here. bindCreateFixtures('OAuthConsent') already supplies the component name; pass a typed OAuthConsentProps value for the fixture context instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx` at
line 551, Remove the as any cast from the setProps call in the OAuthConsent test
and pass a value typed as OAuthConsentProps. Reuse the component name already
supplied by bindCreateFixtures('OAuthConsent') rather than duplicating or
bypassing the fixture context typing.

Source: Coding guidelines

mockOAuthApplication(fixtures.clerk, {
getConsentInfo: vi.fn().mockResolvedValue({
...fakeConsentInfo,
oauthApplicationName: 'Claude',
oauthApplicationLogoUrl: '',
redirectDomain: 'claude.ai',
}),
});

const { getByText, baseElement } = render(<OAuthConsent />, { 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');
});
});
});
Original file line number Diff line number Diff line change
@@ -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();
});
});
11 changes: 11 additions & 0 deletions packages/ui/src/components/OAuthConsent/brandIcons.ts
Original file line number Diff line number Diff line change
@@ -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';
3 changes: 3 additions & 0 deletions packages/ui/src/components/OAuthConsent/claude.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
44 changes: 44 additions & 0 deletions packages/ui/src/components/OAuthConsent/knownClients.ts
Original file line number Diff line number Diff line change
@@ -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));
}
3 changes: 3 additions & 0 deletions packages/ui/src/components/OAuthConsent/openai.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/ui/src/customizables/elementDescriptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([

'logoGroup',
'logoGroupItem',
'logoGroupItemContainer',
'logoGroupIcon',
'logoGroupSeparator',

Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/internal/appearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export type ElementsConfig = {

logoGroup: WithOptions;
logoGroupItem: WithOptions;
logoGroupItemContainer: WithOptions;
logoGroupIcon: WithOptions;
logoGroupSeparator: WithOptions;

Expand Down
5 changes: 5 additions & 0 deletions packages/ui/src/primitives/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ const { applyVariants, filterProps } = createVariants(theme => ({
// @ts-ignore
export type IconProps = StyleVariants<typeof applyVariants> & {
icon: React.ComponentType;
// Icon renders an <svg>; 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 => {
Expand Down
Loading