From 8dbef526e6ee01f97696842c7d1a84fab2514e17 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 8 Oct 2026 08:37:21 -0400 Subject: [PATCH 1/3] feat(ui): make OAuth consent scopes selectable --- .changeset/oauth-consent-scope-selection.md | 5 + .../components/OAuthConsent/OAuthConsent.tsx | 75 ++++++-- .../__tests__/OAuthConsent.test.tsx | 175 +++++++++++++++++- 3 files changed, 236 insertions(+), 19 deletions(-) create mode 100644 .changeset/oauth-consent-scope-selection.md diff --git a/.changeset/oauth-consent-scope-selection.md b/.changeset/oauth-consent-scope-selection.md new file mode 100644 index 00000000000..28a9696ae7b --- /dev/null +++ b/.changeset/oauth-consent-scope-selection.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +Allow users to select or clear optional scopes in ``. Only selected scopes are submitted. When shown, `offline_access` remains checked and cannot be cleared. diff --git a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx index b9f6fcd00cb..423d3c3ff34 100644 --- a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx +++ b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx @@ -1,8 +1,18 @@ import { useClerk, useOAuthConsent, useUser } from '@clerk/shared/react'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useEnvironment, useOAuthConsentContext, withCoreUserGuard } from '@/ui/contexts'; -import { Box, Button, Flow, Grid, localizationKeys, Text, useLocalizations } from '@/ui/customizables'; +import { + Box, + Button, + CheckboxInput, + descriptors, + Flow, + Grid, + localizationKeys, + Text, + useLocalizations, +} from '@/ui/customizables'; import { ApplicationLogo } from '@/ui/elements/ApplicationLogo'; import { Card } from '@/ui/elements/Card'; import { withCardStateProvider } from '@/ui/elements/contexts'; @@ -41,6 +51,7 @@ function _OAuthConsent() { const [isUriModalOpen, setIsUriModalOpen] = useState(false); const [selectedOrg, setSelectedOrg] = useState(null); + const [uncheckedScopes, setUncheckedScopes] = useState([]); // onAllow and onDeny are always provided as a pair by the accounts portal. const hasContextCallbacks = Boolean(ctx.onAllow || ctx.onDeny); @@ -50,6 +61,10 @@ function _OAuthConsent() { const oauthClientId = ctx.oauthClientId ?? fromUrl.oauthClientId; const scope = ctx.scope ?? fromUrl.scope; + useEffect(() => { + setUncheckedScopes([]); + }, [oauthClientId, 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 redirectUri = ctx.redirectUrl ?? getRedirectUriFromSearch(); @@ -70,12 +85,14 @@ function _OAuthConsent() { // Context (accounts portal path) wins over hook data (public path). const scopes = ctx.scopes ?? mappedHookScopes ?? []; + const selectedScopes = scopes.filter(item => !uncheckedScopes.includes(item.scope)); + const selectedScope = selectedScopes.map(item => item.scope).join(' ') || OFFLINE_ACCESS_SCOPE; const oauthApplicationName = ctx.oauthApplicationName ?? data?.oauthApplicationName ?? ''; const oauthApplicationLogoUrl = ctx.oauthApplicationLogoUrl ?? data?.oauthApplicationLogoUrl; const oauthApplicationUrl = ctx.oauthApplicationUrl ?? data?.oauthApplicationUrl; const redirectUrl = ctx.redirectUrl ?? redirectUri; - const hasOrgReadScope = scopes.some(s => s.scope === USER_ORG_READ_SCOPE); + const hasOrgReadScope = selectedScopes.some(s => s.scope === USER_ORG_READ_SCOPE); const orgSelectionEnabled = !!(hasOrgReadScope && organizationSettings.enabled); const orgOptions = orgSelectionEnabled ? (user?.organizationMemberships ?? []).map(m => ({ @@ -131,7 +148,9 @@ function _OAuthConsent() { } const actionUrl = clerk.oauthApplication.buildConsentActionUrl({ clientId: oauthClientId }); - const forwardedParams = getForwardedParams(); + const forwardedParams = getForwardedParams().filter( + ([key]) => !['scope', 'organization_id', 'consented'].includes(key), + ); // Accounts portal path delegates to context callbacks; public path lets the form submit natively. const handleSubmit = (e: React.FormEvent) => { @@ -149,15 +168,13 @@ function _OAuthConsent() { const primaryIdentifier = user?.primaryEmailAddress?.emailAddress || user?.primaryPhoneNumber?.phoneNumber; - const displayedScopes = scopes - .filter(item => item.scope !== OFFLINE_ACCESS_SCOPE) - .map(item => ({ - ...item, - description: - item.scope === PRIVATE_METADATA_SCOPE - ? t(localizationKeys('oauthConsent.scopeList.privateMetadata', { applicationName })) - : item.description, - })); + const displayedScopes = scopes.map(item => ({ + ...item, + description: + item.scope === PRIVATE_METADATA_SCOPE + ? t(localizationKeys('oauthConsent.scopeList.privateMetadata', { applicationName })) + : item.description, + })); const hasOfflineAccess = scopes.some(item => item.scope === OFFLINE_ACCESS_SCOPE); return ( @@ -274,8 +291,29 @@ function _OAuthConsent() { {displayedScopes.map(item => ( - - {item.description || item.scope || ''} + + ({ display: 'flex', alignItems: 'baseline', gap: t.space.$2, width: '100%' })} + > + { + const checked = e.target.checked; + setUncheckedScopes(previous => + checked ? previous.filter(scope => scope !== item.scope) : [...previous, item.scope], + ); + }} + sx={{ flexShrink: 0 }} + /> + {item.description || item.scope || ''} + ))} @@ -343,6 +381,13 @@ function _OAuthConsent() { value={value} /> ))} + {!hasContextCallbacks && ( + + )} {!hasContextCallbacks && orgSelectionEnabled && effectiveOrg && ( { expect(queryByText('Your private metadata')).toBeNull(); }); - it('hides the scope list when only offline_access is requested', async () => { + it('keeps offline_access checked and disabled when it is the only requested scope', async () => { const { wrapper, fixtures, props } = await createFixtures(f => { f.withUser({ email_addresses: ['jane@example.com'] }); }); @@ -140,12 +140,18 @@ describe('OAuthConsent', () => { }), }); - const { getByText, queryByText } = render(, { wrapper }); + const { getByText, findByRole, baseElement, userEvent } = render(, { wrapper }); await waitFor(() => { expect(getByText(/You'll stay signed in until you sign out or revoke access\./)).toBeVisible(); }); - expect(queryByText('This will allow Clerk CLI access to:')).toBeNull(); + const checkbox = await findByRole('checkbox', { name: 'Offline access' }); + expect(checkbox).toBeChecked(); + expect(checkbox).toBeDisabled(); + await userEvent.click(getByText('Offline access')); + expect(checkbox).toBeChecked(); + const form = baseElement.querySelector('form')!; + expect(new FormData(form).getAll('scope')).toEqual(['offline_access']); }); it('supports localizing the private metadata scope description', async () => { @@ -256,6 +262,129 @@ describe('OAuthConsent', () => { expect(submitted).toBe(true); }); + describe('scope selection', () => { + it.each(['Allow', 'Deny'])('submits only checked scopes on %s and preserves OAuth parameters', async action => { + const search = + '?client_id=client_test&redirect_uri=https%3A%2F%2Fapp.example%2Fcallback&scope=openid+email&scope=email&state=original_state&nonce=original_nonce&code_challenge=challenge&code_challenge_method=S256&resource=https%3A%2F%2Fmcp.example&consented=true'; + window.location.search = search; + window.location.href = `https://app.example/${search}`; + + 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) }); + + const { findByRole, getByRole, baseElement, userEvent } = render(, { wrapper }); + const email = await findByRole('checkbox', { name: 'Access your email address' }); + expect(email).toBeChecked(); + expect(getByRole('checkbox', { name: 'View your identity' })).toBeChecked(); + await userEvent.click(email); + + const form = baseElement.querySelector('form')!; + const submitted = vi.fn(); + form.addEventListener('submit', event => { + event.preventDefault(); + submitted(new FormData(form, event.submitter)); + }); + await userEvent.click(getByRole('button', { name: action })); + + expect(submitted).toHaveBeenCalledOnce(); + const data = submitted.mock.calls[0][0] as FormData; + expect(data.getAll('scope')).toEqual(['openid']); + expect(data.getAll('consented')).toEqual([action === 'Allow' ? 'true' : 'false']); + expect(data.get('client_id')).toBe('client_test'); + expect(data.get('redirect_uri')).toBe('https://app.example/callback'); + expect(data.get('state')).toBe('original_state'); + expect(data.get('nonce')).toBe('original_nonce'); + expect(data.get('code_challenge')).toBe('challenge'); + expect(data.get('code_challenge_method')).toBe('S256'); + expect(data.get('resource')).toBe('https://mcp.example'); + }); + + it('lets users select a scope again with the keyboard', 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) }); + + const { findByRole, baseElement, userEvent } = render(, { wrapper }); + const checkbox = await findByRole('checkbox', { name: 'Access your email address' }); + await userEvent.click(checkbox); + expect(checkbox).not.toBeChecked(); + await userEvent.keyboard(' '); + expect(checkbox).toBeChecked(); + expect(new FormData(baseElement.querySelector('form')!).getAll('scope')).toEqual(['openid email']); + expect(fixtures.clerk.oauthApplication.getConsentInfo).toHaveBeenCalledOnce(); + }); + + it.each([false, true])( + 'submits offline_access alone when all optional scopes are cleared (returned: %s)', + async returned => { + 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, + scopes: [ + ...fakeConsentInfo.scopes, + ...(returned ? [{ scope: 'offline_access', description: 'Offline access', requiresConsent: true }] : []), + ], + }), + }); + + const { findByRole, getByRole, queryByRole, baseElement, userEvent } = render(, { wrapper }); + await userEvent.click(await findByRole('checkbox', { name: 'View your identity' })); + await userEvent.click(getByRole('checkbox', { name: 'Access your email address' })); + if (returned) { + const offline = getByRole('checkbox', { name: 'Offline access' }); + expect(offline).toBeChecked(); + expect(offline).toBeDisabled(); + } else { + expect(queryByRole('checkbox', { name: 'Offline access' })).toBeNull(); + } + expect(getByRole('button', { name: 'Allow' })).toBeEnabled(); + expect(new FormData(baseElement.querySelector('form')!).getAll('scope')).toEqual(['offline_access']); + }, + ); + + it('does not treat requiresConsent as a required-scope flag', 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, + scopes: [{ scope: 'email', description: 'Access your email address', requiresConsent: false }], + }), + }); + + const { findByRole, userEvent } = render(, { wrapper }); + const checkbox = await findByRole('checkbox', { name: 'Access your email address' }); + expect(checkbox).toBeEnabled(); + await userEvent.click(checkbox); + expect(checkbox).not.toBeChecked(); + }); + + it('clears scope choices when the OAuth client changes', 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) }); + + const { findByRole, getByRole, rerender, userEvent } = render(, { wrapper }); + await userEvent.click(await findByRole('checkbox', { name: 'Access your email address' })); + props.setProps({ componentName: 'OAuthConsent', oauthClientId: 'another_client' } as any); + rerender(); + await waitFor(() => expect(getByRole('checkbox', { name: 'Access your email address' })).toBeChecked()); + }); + }); + it('renders nothing when unauthenticated', async () => { const { wrapper, fixtures, props } = await createFixtures(); @@ -328,7 +457,7 @@ describe('OAuthConsent', () => { mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) }); - const { getByText, baseElement } = render(, { wrapper }); + const { getByText, getByRole, baseElement, userEvent } = render(, { wrapper }); // Context values win: the displayed name is the accounts portal one, not 'Clerk CLI'. await waitFor(() => expect(getByText('Accounts Portal App')).toBeVisible()); @@ -339,9 +468,18 @@ describe('OAuthConsent', () => { const forwardedInputs = form.querySelectorAll('input[type="hidden"]'); expect(forwardedInputs.length).toBe(0); + const checkbox = getByRole('checkbox', { name: 'Identity' }); + expect(checkbox).toBeChecked(); + expect(checkbox).toBeDisabled(); + await userEvent.click(getByText('Identity')); + expect(checkbox).toBeChecked(); + // Clicking Allow invokes the context callback, not a form submission. getByText('Allow').click(); expect(onAllowSpy).toHaveBeenCalledTimes(1); + expect(onAllowSpy).toHaveBeenCalledWith(); + getByText('Deny').click(); + expect(onDenySpy).toHaveBeenCalledWith(); // The hook should NOT fire a FAPI request on the accounts portal path. expect(fixtures.clerk.oauthApplication.getConsentInfo).not.toHaveBeenCalled(); @@ -418,6 +556,35 @@ describe('OAuthConsent', () => { }); describe('org selection', () => { + it('removes organization selection and its form parameter when user:org:read is unchecked', async () => { + const search = + '?client_id=client_test&redirect_uri=https%3A%2F%2Fapp.example%2Fcallback&scope=openid+email+user%3Aorg%3Aread&organization_id=stale_org'; + window.location.search = search; + window.location.href = `https://app.example/${search}`; + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withUser({ + email_addresses: ['jane@example.com'], + organization_memberships: [{ id: 'org_1', name: 'Acme Corp' }], + }); + f.withOrganizations(); + }); + props.setProps({ componentName: 'OAuthConsent' } as any); + mockOAuthApplication(fixtures.clerk, { + getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfoWithOrgScope), + }); + + const { findByRole, queryByRole, baseElement, userEvent } = render(, { wrapper }); + const checkbox = await findByRole('checkbox', { name: 'Access your organizations' }); + const form = baseElement.querySelector('form')!; + expect(new FormData(form).getAll('organization_id')).toEqual(['org_1']); + await userEvent.click(checkbox); + expect(queryByRole('combobox')).toBeNull(); + expect(new FormData(form).getAll('organization_id')).toEqual([]); + expect(new FormData(form).getAll('scope')).toEqual(['openid email']); + await userEvent.click(checkbox); + expect(new FormData(form).getAll('organization_id')).toEqual(['org_1']); + }); + it('does not render the org selector when user:org:read scope is absent', async () => { const { wrapper, fixtures, props } = await createFixtures(f => { f.withUser({ From da2968462479bc2b1e6483f18bed6d19b63bc509 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 8 Oct 2026 10:14:07 -0400 Subject: [PATCH 2/3] fix(ui): port OAuth consent layout and playground improvements --- .changeset/oauth-consent-scope-selection.md | 2 + .../oauth-consent-single-scope-scrollbar.md | 5 ++ packages/clerk-js/sandbox/app.ts | 18 ++++-- .../src/components/OAuthConsent/ListGroup.tsx | 53 +++++++++++++++--- .../components/OAuthConsent/OAuthConsent.tsx | 55 +++++-------------- .../__tests__/OAuthConsent.test.tsx | 25 +++++++++ .../src/customizables/elementDescriptors.ts | 1 + packages/ui/src/internal/appearance.ts | 1 + 8 files changed, 107 insertions(+), 53 deletions(-) create mode 100644 .changeset/oauth-consent-single-scope-scrollbar.md diff --git a/.changeset/oauth-consent-scope-selection.md b/.changeset/oauth-consent-scope-selection.md index 28a9696ae7b..33a99dfb524 100644 --- a/.changeset/oauth-consent-scope-selection.md +++ b/.changeset/oauth-consent-scope-selection.md @@ -3,3 +3,5 @@ --- Allow users to select or clear optional scopes in ``. Only selected scopes are submitted. When shown, `offline_access` remains checked and cannot be cleared. + +Customize the scope checkboxes with the new `listGroupItemCheckbox` appearance element. Checkboxes align with the first line of descriptions that wrap onto multiple lines. diff --git a/.changeset/oauth-consent-single-scope-scrollbar.md b/.changeset/oauth-consent-single-scope-scrollbar.md new file mode 100644 index 00000000000..ffa84d92306 --- /dev/null +++ b/.changeset/oauth-consent-single-scope-scrollbar.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Fix a stray scrollbar on the `` and `` scope list when only one scope is shown. diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index bf9d3c8b9bf..eb79772a128 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -466,11 +466,19 @@ void (async () => { }, '/oauth-consent': () => { const searchParams = new URLSearchParams(window.location.search); - const scopes = (searchParams.get('scope')?.split(',') ?? []).map(scope => ({ - scope, - description: scope === 'offline_access' ? null : `Grants access to your ${scope}`, - requires_consent: true, - })); + const scopes = [ + ...(searchParams.get('scope')?.split(',') ?? []).map(scope => ({ + scope, + description: scope === 'offline_access' ? null : `Grants access to your ${scope}`, + requires_consent: true, + })), + { + scope: 'sandbox:long_description', + description: + 'Grants access to read and update your profile, connected accounts, and organization memberships across every workspace you belong to', + requires_consent: true, + }, + ]; Clerk.mountOAuthConsent( app, componentControls.oauthConsent.getProps() ?? { diff --git a/packages/ui/src/components/OAuthConsent/ListGroup.tsx b/packages/ui/src/components/OAuthConsent/ListGroup.tsx index 9894228fb7d..6f0da792ddd 100644 --- a/packages/ui/src/components/OAuthConsent/ListGroup.tsx +++ b/packages/ui/src/components/OAuthConsent/ListGroup.tsx @@ -1,6 +1,7 @@ import type { ComponentProps } from 'react'; -import { Box, descriptors, Text } from '@/ui/customizables'; +import { Box, CheckboxInput, descriptors, Flex, Text } from '@/ui/customizables'; +import type { InternalTheme } from '@/ui/styledSystem'; import { common } from '@/ui/styledSystem'; import { colors } from '@/ui/utils/colors'; @@ -74,7 +75,7 @@ export function ListGroupContent({ backgroundColor: t.colors.$colorBackground, overflowY: 'auto', ...common.unstyledScrollbar(t), - '--fade-distance': '2.5rem', + '--fade-distance': '2rem', '&::before, &::after': { content: '""', display: 'block', @@ -131,6 +132,14 @@ export function ListGroupContent({ ); } +const listGroupItemStyles = (t: InternalTheme) => ({ + paddingInline: t.space.$3, + paddingBlock: t.space.$2, + borderTopWidth: t.borderWidths.$normal, + borderTopStyle: t.borderStyles.$solid, + borderTopColor: t.colors.$borderAlpha100, +}); + export function ListGroupItem({ children, sx, @@ -142,13 +151,9 @@ export function ListGroupItem({ as='li' sx={[ t => ({ + ...listGroupItemStyles(t), display: 'flex', alignItems: 'baseline', - paddingInline: t.space.$3, - paddingBlock: t.space.$2, - borderTopWidth: t.borderWidths.$normal, - borderTopStyle: t.borderStyles.$solid, - borderTopColor: t.colors.$borderAlpha100, '&::before': { content: '""', display: 'inline-block', @@ -179,3 +184,37 @@ export function ListGroupItemLabel(props: Omit, 'ele /> ); } + +export function ListGroupCheckboxItem({ + children, + ...props +}: Pick, 'checked' | 'value' | 'isDisabled' | 'onChange'> & { + children: ComponentProps['children']; +}) { + return ( + + + ({ + flexShrink: 0, + cursor: 'pointer', + marginBlockStart: `calc((${t.fontSizes.$md} * ${t.lineHeights.$small} - ${t.sizes.$4}) / 2)`, + })} + /> + {children} + + + ); +} diff --git a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx index 423d3c3ff34..31d8a591307 100644 --- a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx +++ b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx @@ -2,17 +2,7 @@ import { useClerk, useOAuthConsent, useUser } from '@clerk/shared/react'; import { useEffect, useState } from 'react'; import { useEnvironment, useOAuthConsentContext, withCoreUserGuard } from '@/ui/contexts'; -import { - Box, - Button, - CheckboxInput, - descriptors, - Flow, - Grid, - localizationKeys, - Text, - useLocalizations, -} from '@/ui/customizables'; +import { Box, Button, Flow, Grid, localizationKeys, Text, useLocalizations } from '@/ui/customizables'; import { ApplicationLogo } from '@/ui/elements/ApplicationLogo'; import { Card } from '@/ui/elements/Card'; import { withCardStateProvider } from '@/ui/elements/contexts'; @@ -24,14 +14,7 @@ import { Route, Switch } from '@/ui/router'; import { InlineAction } from './InlineAction'; import { getKnownOAuthClient } from './knownClients'; -import { - ListGroup, - ListGroupContent, - ListGroupHeader, - ListGroupHeaderTitle, - ListGroupItem, - ListGroupItemLabel, -} from './ListGroup'; +import { ListGroup, ListGroupCheckboxItem, ListGroupContent, ListGroupHeader, ListGroupHeaderTitle } from './ListGroup'; import { LogoGroup, LogoGroupIcon, LogoGroupItem, LogoGroupItemContainer, LogoGroupSeparator } from './LogoGroup'; import { OrgSelect } from './OrgSelect'; import { getForwardedParams, getOAuthConsentFromSearch, getRedirectDisplay, getRedirectUriFromSearch } from './utils'; @@ -291,30 +274,20 @@ function _OAuthConsent() { {displayedScopes.map(item => ( - { + const checked = e.target.checked; + setUncheckedScopes(previous => + checked ? previous.filter(scope => scope !== item.scope) : [...previous, item.scope], + ); + }} > - ({ display: 'flex', alignItems: 'baseline', gap: t.space.$2, width: '100%' })} - > - { - const checked = e.target.checked; - setUncheckedScopes(previous => - checked ? previous.filter(scope => scope !== item.scope) : [...previous, item.scope], - ); - }} - sx={{ flexShrink: 0 }} - /> - {item.description || item.scope || ''} - - + {item.description || item.scope || ''} + ))} diff --git a/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx b/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx index 06007d5063e..7cb5764bca6 100644 --- a/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx +++ b/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx @@ -109,6 +109,31 @@ describe('OAuthConsent', () => { }); }); + it('toggles only the selected scope when its description is clicked', 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), + }); + + const { findByRole, getByText, userEvent, baseElement } = render(, { wrapper }); + + const identity = await findByRole('checkbox', { name: 'View your identity' }); + const email = await findByRole('checkbox', { name: 'Access your email address' }); + expect(identity).toBeChecked(); + expect(email).toBeChecked(); + expect(email).toHaveClass('cl-listGroupItemCheckbox'); + + await userEvent.click(getByText('Access your email address')); + + expect(email).not.toBeChecked(); + expect(identity).toBeChecked(); + expect(new FormData(baseElement.querySelector('form')!).getAll('scope')).toEqual(['openid']); + }); + it('identifies private metadata as potentially sensitive data set by the Clerk application', async () => { const { wrapper, fixtures, props } = await createFixtures(f => { f.withUser({ email_addresses: ['jane@example.com'] }); diff --git a/packages/ui/src/customizables/elementDescriptors.ts b/packages/ui/src/customizables/elementDescriptors.ts index e1ff3112d55..f1d880951bc 100644 --- a/packages/ui/src/customizables/elementDescriptors.ts +++ b/packages/ui/src/customizables/elementDescriptors.ts @@ -65,6 +65,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'listGroupContent', 'listGroupItem', 'listGroupItemLabel', + 'listGroupItemCheckbox', 'header', 'headerTitle', diff --git a/packages/ui/src/internal/appearance.ts b/packages/ui/src/internal/appearance.ts index 09fc4ffad1a..85da40d1706 100644 --- a/packages/ui/src/internal/appearance.ts +++ b/packages/ui/src/internal/appearance.ts @@ -193,6 +193,7 @@ export type ElementsConfig = { listGroupContent: WithOptions; listGroupItem: WithOptions; listGroupItemLabel: WithOptions; + listGroupItemCheckbox: WithOptions; header: WithOptions; headerTitle: WithOptions; From 935d3849599e15475832655328265f5e4ee6f33c Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 8 Oct 2026 12:33:31 -0400 Subject: [PATCH 3/3] fix(ui): center OAuth scope checkboxes on the first text line --- packages/ui/src/components/OAuthConsent/ListGroup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/OAuthConsent/ListGroup.tsx b/packages/ui/src/components/OAuthConsent/ListGroup.tsx index 6f0da792ddd..3bf21de398c 100644 --- a/packages/ui/src/components/OAuthConsent/ListGroup.tsx +++ b/packages/ui/src/components/OAuthConsent/ListGroup.tsx @@ -210,7 +210,7 @@ export function ListGroupCheckboxItem({ sx={t => ({ flexShrink: 0, cursor: 'pointer', - marginBlockStart: `calc((${t.fontSizes.$md} * ${t.lineHeights.$small} - ${t.sizes.$4}) / 2)`, + marginBlockStart: `calc((${t.fontSizes.$md} * ${t.lineHeights.$small} - ${t.sizes.$3x5}) / 2)`, })} /> {children}