Skip to content
Draft
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
45 changes: 2 additions & 43 deletions packages/shared/src/types/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2281,51 +2281,10 @@ export type __internal_OAuthConsentProps = {
*/
scope?: string;
/**
* Name of the OAuth application.
*
* @deprecated Used by the accounts portal. Pass `client_id` and `redirect_uri` as URL parameters instead.
*/
oAuthApplicationName?: string;
/**
* Logo URL of the OAuth application.
*
* @deprecated Used by the accounts portal. Pass `client_id` and `redirect_uri` as URL parameters instead.
*/
oAuthApplicationLogoUrl?: string;
/**
* URL of the OAuth application.
*
* @deprecated Used by the accounts portal. Pass `client_id` and `redirect_uri` as URL parameters instead.
*/
oAuthApplicationUrl?: string;
/**
* Scopes requested by the OAuth application.
*
* @deprecated Used by the accounts portal. Pass `client_id` and `redirect_uri` as URL parameters instead.
*/
scopes?: {
scope: string;
description: string | null;
requires_consent: boolean;
}[];
/**
* Full URL or path to navigate to after the user allows or denies access.
*
* @deprecated Used by the accounts portal. Pass `client_id` and `redirect_uri` as URL parameters instead.
* Override the redirect URI. Defaults to the `redirect_uri` query parameter
* from the current URL.
*/
redirectUrl?: string;
/**
* Called when user allows access.
*
* @deprecated Used by the accounts portal. Pass `client_id` and `redirect_uri` as URL parameters instead.
*/
onAllow?: () => void;
/**
* Called when user denies access.
*
* @deprecated Used by the accounts portal. Pass `client_id` and `redirect_uri` as URL parameters instead.
*/
onDeny?: () => void;
};

export interface HandleEmailLinkVerificationParams {
Expand Down
128 changes: 45 additions & 83 deletions packages/ui/src/components/OAuthConsent/OAuthConsent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,102 +49,65 @@ function _OAuthConsent() {
const [selectedOrg, setSelectedOrg] = useState<string | null>(null);
const effectiveOrg = selectedOrg ?? orgOptions[0]?.value ?? null;

// onAllow and onDeny are always provided as a pair by the accounts portal.
const hasContextCallbacks = Boolean(ctx.onAllow || ctx.onDeny);

// Resolve oauthClientId and scope once: context overrides URL fallback.
const fromUrl = getOAuthConsentFromSearch();
const oauthClientId = ctx.oauthClientId ?? fromUrl.oauthClientId;
const scope = ctx.scope ?? fromUrl.scope;

// Public path: fetch via hook. Disabled on the accounts portal path
// (which already has all data via context) to avoid a wasted FAPI request.
const { data, isLoading, error } = useOAuthConsent({
oauthClientId,
scope,
// TODO: Remove this once account portal is refactored to use this component
enabled: !hasContextCallbacks,
});

// Hook returns camelCase `requiresConsent`; the render logic uses snake_case.
const mappedHookScopes = data?.scopes?.map(s => ({
scope: s.scope,
description: s.description,
requires_consent: s.requiresConsent,
}));
const { data, isLoading, error } = useOAuthConsent({ oauthClientId, scope });

// Context (accounts portal path) wins over hook data (public path).
const scopes = ctx.scopes ?? mappedHookScopes ?? [];
const oauthApplicationName = ctx.oauthApplicationName ?? data?.oauthApplicationName ?? '';
const oauthApplicationLogoUrl = ctx.oauthApplicationLogoUrl ?? data?.oauthApplicationLogoUrl;
const oauthApplicationUrl = ctx.oauthApplicationUrl ?? data?.oauthApplicationUrl;
const scopes = data?.scopes ?? [];
const oauthApplicationName = data?.oauthApplicationName ?? '';
const oauthApplicationLogoUrl = data?.oauthApplicationLogoUrl;
const oauthApplicationUrl = data?.oauthApplicationUrl;
const redirectUrl = ctx.redirectUrl ?? getRedirectUriFromSearch();

const { t } = useLocalizations();
const domainAction = getRedirectDisplay(redirectUrl);
const viewFullUrlText = t(localizationKeys('oauthConsent.viewFullUrl'));

// Error states only apply to the public flow.
if (!hasContextCallbacks) {
const errorMessage = !oauthClientId
? 'The client ID is missing.'
: !redirectUrl
? 'The redirect URI is missing.'
: error
? (error.message ?? 'Failed to load consent information.')
: undefined;
const errorMessage = !oauthClientId
? 'The client ID is missing.'
: !redirectUrl
? 'The redirect URI is missing.'
: error
? (error.message ?? 'Failed to load consent information.')
: undefined;

if (errorMessage) {
return (
<Card.Root>
<Card.Content>
<Card.Alert>{errorMessage}</Card.Alert>
</Card.Content>
<Card.Footer />
</Card.Root>
);
}
if (errorMessage) {
return (
<Card.Root>
<Card.Content>
<Card.Alert>{errorMessage}</Card.Alert>
</Card.Content>
<Card.Footer />
</Card.Root>
);
}

if (isLoading) {
return (
<Card.Root>
<Card.Content>
<LoadingCardContainer />
</Card.Content>
<Card.Footer />
</Card.Root>
);
}
if (isLoading) {
return (
<Card.Root>
<Card.Content>
<LoadingCardContainer />
</Card.Content>
<Card.Footer />
</Card.Root>
);
}

const actionUrl = clerk.oauthApplication.buildConsentActionUrl({ clientId: oauthClientId });
const forwardedParams = getForwardedParams();

// Accounts portal path delegates to context callbacks; public path lets the form submit natively.
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
if (!hasContextCallbacks) {
return;
}
e.preventDefault();
const submitter = (e.nativeEvent as SubmitEvent).submitter as HTMLButtonElement | null;
if (submitter?.value === 'true') {
ctx.onAllow?.();
} else {
ctx.onDeny?.();
}
};

const primaryIdentifier = user?.primaryEmailAddress?.emailAddress || user?.primaryPhoneNumber?.phoneNumber;

const displayedScopes = scopes.filter(item => item.scope !== OFFLINE_ACCESS_SCOPE);
const hasOfflineAccess = scopes.some(item => item.scope === OFFLINE_ACCESS_SCOPE);
const displayedScopes = scopes.filter(s => s.scope !== OFFLINE_ACCESS_SCOPE);
const hasOfflineAccess = scopes.some(s => s.scope === OFFLINE_ACCESS_SCOPE);

return (
<>
<form
method='POST'
action={actionUrl}
onSubmit={handleSubmit}
>
<Card.Root>
<Card.Content>
Expand Down Expand Up @@ -229,9 +192,9 @@ function _OAuthConsent() {
/>
</ListGroupHeader>
<ListGroupContent>
{displayedScopes.map(item => (
<ListGroupItem key={item.scope}>
<ListGroupItemLabel>{item.description || item.scope || ''}</ListGroupItemLabel>
{displayedScopes.map(s => (
<ListGroupItem key={s.scope}>
<ListGroupItemLabel>{s.description || s.scope || ''}</ListGroupItemLabel>
</ListGroupItem>
))}
</ListGroupContent>
Expand Down Expand Up @@ -289,16 +252,15 @@ function _OAuthConsent() {
</Card.Content>
<Card.Footer />
</Card.Root>
{!hasContextCallbacks &&
forwardedParams.map(([key, value]) => (
<input
key={key}
type='hidden'
name={key}
value={value}
/>
))}
{!hasContextCallbacks && orgSelectionEnabled && effectiveOrg && (
{forwardedParams.map(([key, value]) => (
<input
key={key}
type='hidden'
name={key}
value={value}
/>
))}
{orgSelectionEnabled && effectiveOrg && (
<input
type='hidden'
name='organization_id'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,52 +200,27 @@ describe('OAuthConsent', () => {
});
});

it('uses context values when provided (accounts portal path)', async () => {
const onAllowSpy = vi.fn();
const onDenySpy = vi.fn();

it('uses redirectUrl prop over URL param', async () => {
const { wrapper, fixtures, props } = await createFixtures(f => {
f.withUser({ email_addresses: ['jane@example.com'] });
});

// Simulate the accounts portal path: `clerk.__internal_mountOAuthConsent` is
// called with the legacy `oAuth*` (capital-A) prop shape from
// `__internal_OAuthConsentProps`. The `ComponentContextProvider` translates
// these to the lowercase `oauth*` shape that the component reads from context
// (see the `case 'OAuthConsent':` block in ClerkUIComponentsContext.tsx).
// This test verifies the translation end-to-end: if it were broken, the
// component would fall back to the hook mock's 'Clerk CLI' name and the
// `getByText('Accounts Portal App')` assertion would fail.
props.setProps({
componentName: 'OAuthConsent',
scopes: [{ scope: 'openid', description: 'Identity', requires_consent: true }],
oAuthApplicationName: 'Accounts Portal App',
oAuthApplicationLogoUrl: 'https://example.com/ap-logo.png',
oAuthApplicationUrl: 'https://example.com/ap',
redirectUrl: 'https://example.com/callback',
onAllow: onAllowSpy,
onDeny: onDenySpy,
oauthClientId: 'client_test',
redirectUrl: 'https://override.example/callback',
} as any);

mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) });

const { getByText, baseElement } = render(<OAuthConsent />, { wrapper });

// Context values win: the displayed name is the accounts portal one, not 'Clerk CLI'.
await waitFor(() => expect(getByText('Accounts Portal App')).toBeVisible());

// No forwarded hidden inputs inside the form when context callbacks are provided.
const form = baseElement.querySelector('form[action*="/v1/me/oauth/consent/"]')!;
expect(form).not.toBeNull();
const forwardedInputs = form.querySelectorAll('input[type="hidden"]');
expect(forwardedInputs.length).toBe(0);

// Clicking Allow invokes the context callback, not a form submission.
getByText('Allow').click();
expect(onAllowSpy).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(getByText('Allow')).toBeVisible();
});

// The hook should NOT fire a FAPI request on the accounts portal path.
expect(fixtures.clerk.oauthApplication.getConsentInfo).not.toHaveBeenCalled();
expect(baseElement.textContent).toContain('override.example');
expect(baseElement.textContent).not.toContain('app.example');
});

it('shows missing client_id error in the public flow', async () => {
Expand Down
12 changes: 9 additions & 3 deletions packages/ui/src/components/OAuthConsent/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,20 @@ export function getRedirectDisplay(url: string): string {
} catch {
return '';
}
if (!hostname) return '';
if (!hostname) {
return '';
}

// WHATWG URL.hostname includes surrounding brackets for IPv6 literals on some
// platforms; strip them so detection and output formatting are uniform.
const host = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;

if (IPV4_REGEX.test(host)) return host;
if (host.includes(':')) return `[${host}]`;
if (IPV4_REGEX.test(host)) {
return host;
}
if (host.includes(':')) {
return `[${host}]`;
}
return host.split('.').slice(-2).join('.');
}

Expand Down
10 changes: 0 additions & 10 deletions packages/ui/src/contexts/ClerkUIComponentsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,24 +115,14 @@ export function ComponentContextProvider({
</APIKeysContext.Provider>
);
case 'OAuthConsent': {
// Translate capital-A `oAuth*` props from the accounts portal into
// the lowercase `oauth*` context shape the component reads.
// The public `<OAuthConsent />` wrapper also forwards `oauthClientId`
// and `scope` through the same path.
const p = props as __internal_OAuthConsentProps;
return (
<OAuthConsentContext.Provider
value={{
componentName,
oauthClientId: p.oauthClientId,
scope: p.scope,
scopes: p.scopes,
oauthApplicationName: p.oAuthApplicationName,
oauthApplicationLogoUrl: p.oAuthApplicationLogoUrl,
oauthApplicationUrl: p.oAuthApplicationUrl,
redirectUrl: p.redirectUrl,
onAllow: p.onAllow,
onDeny: p.onDeny,
appearance: p.appearance,
enableOrgSelection: (p as any).__internal_enableOrgSelection === true,
}}
Expand Down
43 changes: 6 additions & 37 deletions packages/ui/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,51 +169,20 @@ export type TaskSetupMFACtx = TaskSetupMFAProps & {
export type OAuthConsentCtx = {
componentName: 'OAuthConsent';
/**
* Public-path override forwarded to `useOAuthConsent`. Falls back to the
* `client_id` query parameter from the current URL when omitted.
* Override the OAuth client ID. Defaults to the `client_id` query parameter
* from the current URL.
*/
oauthClientId?: string;
/**
* Public-path override forwarded to `useOAuthConsent`. Falls back to the
* `scope` query parameter from the current URL when omitted.
* Override the OAuth scope. Defaults to the `scope` query parameter from
* the current URL.
*/
scope?: string;
/**
* Pre-fetched scopes (accounts portal path). Snake-cased to match the
* legacy FAPI response shape.
*/
scopes?: {
scope: string;
description: string | null;
requires_consent: boolean;
}[];
/**
* Pre-fetched OAuth application name (accounts portal path).
*/
oauthApplicationName?: string;
/**
* Pre-fetched OAuth application logo URL (accounts portal path).
*/
oauthApplicationLogoUrl?: string;
/**
* Pre-fetched OAuth application URL (accounts portal path).
*/
oauthApplicationUrl?: string;
/**
* Redirect URI to display in the footer. Accounts portal path pre-populates
* this; public path reads `redirect_uri` from `window.location.search`.
* Override the redirect URI. Defaults to the `redirect_uri` query parameter
* from the current URL.
*/
redirectUrl?: string;
/**
* Custom Allow handler (accounts portal path). When omitted, the component
* submits its internal hidden form instead.
*/
onAllow?: () => void;
/**
* Custom Deny handler (accounts portal path). When omitted, the component
* submits its internal hidden form instead.
*/
onDeny?: () => void;
/**
* Customize the appearance of the component.
*/
Expand Down
Loading