From 76c23db41e034da031ab4482872bc49e32f3eacb Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 13 Oct 2026 17:14:49 -0400 Subject: [PATCH 1/5] feat(mosaic): add OAuthConsent component (unexported, WIP) --- .changeset/mosaic-oauth-consent.md | 7 + .../mosaic/src/__tests__/feature/fake-fapi.ts | 9 + packages/mosaic/src/__tests__/feature/fapi.ts | 17 + .../__tests__/oauth-consent.feature.test.tsx | 446 ++++++++++++++++++ .../__tests__/oauth-consent.view.test.tsx | 78 +++ .../oauth-consent/oauth-consent.controller.ts | 30 ++ .../oauth-consent/oauth-consent.messages.ts | 17 + .../oauth-consent/oauth-consent.model.ts | 82 ++++ .../oauth-consent/oauth-consent.styles.ts | 36 ++ .../features/oauth-consent/oauth-consent.tsx | 64 +++ .../oauth-consent/oauth-consent.types.ts | 63 +++ .../oauth-consent/oauth-consent.view.tsx | 275 +++++++++++ .../src/localization/errors.messages.ts | 2 + packages/mosaic/src/localization/registry.ts | 2 + .../clerk-js/__tests__/oauthConsent.test.ts} | 2 +- .../src/internal/clerk-js/oauthConsent.ts | 25 + .../swingset/src/components/DocsViewer.tsx | 3 + packages/swingset/src/lib/registry.ts | 16 + .../src/stories/fixtures/oauth-consent.ts | 60 +++ .../swingset/src/stories/oauth-consent.mdx | 59 +++ .../src/stories/oauth-consent.stories.tsx | 40 ++ .../components/OAuthConsent/OAuthConsent.tsx | 3 +- .../ui/src/components/OAuthConsent/utils.ts | 26 - 23 files changed, 1334 insertions(+), 28 deletions(-) create mode 100644 .changeset/mosaic-oauth-consent.md create mode 100644 packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.feature.test.tsx create mode 100644 packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.view.test.tsx create mode 100644 packages/mosaic/src/features/oauth-consent/oauth-consent.controller.ts create mode 100644 packages/mosaic/src/features/oauth-consent/oauth-consent.messages.ts create mode 100644 packages/mosaic/src/features/oauth-consent/oauth-consent.model.ts create mode 100644 packages/mosaic/src/features/oauth-consent/oauth-consent.styles.ts create mode 100644 packages/mosaic/src/features/oauth-consent/oauth-consent.tsx create mode 100644 packages/mosaic/src/features/oauth-consent/oauth-consent.types.ts create mode 100644 packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx rename packages/{ui/src/components/OAuthConsent/__tests__/utils.test.ts => shared/src/internal/clerk-js/__tests__/oauthConsent.test.ts} (97%) create mode 100644 packages/shared/src/internal/clerk-js/oauthConsent.ts create mode 100644 packages/swingset/src/stories/fixtures/oauth-consent.ts create mode 100644 packages/swingset/src/stories/oauth-consent.mdx create mode 100644 packages/swingset/src/stories/oauth-consent.stories.tsx diff --git a/.changeset/mosaic-oauth-consent.md b/.changeset/mosaic-oauth-consent.md new file mode 100644 index 00000000000..fbd3eecbb5f --- /dev/null +++ b/.changeset/mosaic-oauth-consent.md @@ -0,0 +1,7 @@ +--- +'@clerk/shared': patch +'@clerk/ui': patch +'@clerk/mosaic': patch +--- + +Move the OAuth consent redirect domain helper into `@clerk/shared` so it can be shared across UI packages. No behavior change. diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index 07ffcd6d258..2466f460aa0 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -4,6 +4,7 @@ import type { ClientJSON, EmailAddressJSON, EnterpriseConnectionJSON, + OAuthConsentInfoJSON, OAuthProvider, OrganizationInvitationJSON, OrganizationJSON, @@ -82,6 +83,8 @@ export interface FakeFapiState { mfa: FakeMfaState; activeDevices?: ActiveDeviceRecord[]; deviceTrackingEnabled: boolean; + oauthConsent?: OAuthConsentInfoJSON; + oauthConsentRequests: URL[]; } export interface ActiveDeviceRecord extends SessionWithActivitiesJSON { @@ -318,6 +321,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { passwordUpdates: [], enterpriseConnections: [], deviceTrackingEnabled: true, + oauthConsentRequests: [], mfa: createMfaState(), ...rest, verification: createVerificationState(verification), @@ -843,6 +847,11 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { state.apiKeys = state.apiKeys.map(k => (k.id === revoked.id ? revoked : k)); return HttpResponse.json(revoked); }), + http.get(fapiUrl('/v1/me/oauth/consent/:clientId'), ({ request, params }) => { + state.oauthConsentRequests.push(new URL(request.url)); + const consent = state.oauthConsent; + return consent?.client_id === params.clientId ? envelope(consent, null) : missing(); + }), ); return state; diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts index f53462b5f2e..b4d04d1ad97 100644 --- a/packages/mosaic/src/__tests__/feature/fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -12,6 +12,7 @@ import type { EnterpriseConnectionJSON, EnvironmentJSON, ExternalAccountJSON, + OAuthConsentInfoJSON, OAuthProviders, OrganizationJSON, OrganizationMembershipJSON, @@ -592,6 +593,22 @@ export function fapiApiKey(overrides: Partial & Pick & Pick, +): OAuthConsentInfoJSON { + return { + object: 'oauth_consent_info', + id: overrides.client_id, + oauth_application_name: 'Linear', + oauth_application_logo_url: '', + oauth_application_url: 'https://linear.app', + state: '', + redirect_domain: null, + scopes: [], + ...overrides, + }; +} + export function fapiPage(data: T[], totalCount = data.length): FapiPage { return { data, total_count: totalCount }; } diff --git a/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.feature.test.tsx b/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.feature.test.tsx new file mode 100644 index 00000000000..cad984c78c1 --- /dev/null +++ b/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.feature.test.tsx @@ -0,0 +1,446 @@ +import type { OAuthConsentInfoJSON } from '@clerk/shared/types'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { describe, expect, it, onTestFinished } from 'vitest'; + +import { openDialog } from '../../../__tests__/feature/dialog'; +import { type FakeFapiSeed, fapiUrl, holdRequests, serveFapi, worker } from '../../../__tests__/feature/fake-fapi'; +import { + fapiClient, + fapiEmailAddress, + fapiEnvironment, + fapiMembership, + fapiOAuthConsentInfo, + fapiOrganization, + fapiPhoneNumber, + fapiSession, + fapiUser, +} from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import type { MosaicLocalization } from '../../../localization'; +import type { OAuthConsentProps } from '../oauth-consent'; +import { OAuthConsent } from '../oauth-consent'; + +const REDIRECT_URI = 'https://app.linear.example.com/oauth/callback'; +const CONSENT_PATH = '/v1/me/oauth/consent/client_1'; + +const acme = fapiOrganization({ id: 'org_1', name: 'Acme' }); +const globex = fapiOrganization({ id: 'org_2', name: 'Globex' }); + +const alice = fapiUser({ + id: 'user_1', + email_addresses: [fapiEmailAddress({ id: 'idn_alice', email_address: 'alice@example.com' })], + organization_memberships: [fapiMembership(acme), fapiMembership(globex)], +}); + +const scope = (name: string, description: string | null = null) => ({ + scope: name, + description, + requires_consent: true, +}); + +function consent(overrides: Partial = {}) { + return fapiOAuthConsentInfo({ + client_id: 'client_1', + scopes: [scope('profile', 'Your profile'), scope('email', 'Your email address')], + ...overrides, + }); +} + +function signedIn(overrides: FakeFapiSeed = {}, lastActiveOrganizationId: string | null = null): FakeFapiSeed { + return { + client: fapiClient([ + fapiSession({ id: 'sess_1', user: alice, last_active_organization_id: lastActiveOrganizationId }), + ]), + oauthConsent: consent(), + ...overrides, + }; +} + +function visit(params: Record) { + const previous = window.location.href; + window.history.replaceState(null, '', `?${new URLSearchParams(params).toString()}`); + onTestFinished(() => window.history.replaceState(null, '', previous)); +} + +function visitConsent(params: Record = {}) { + visit({ client_id: 'client_1', scope: 'profile email', redirect_uri: REDIRECT_URI, state: 'st_1', ...params }); +} + +async function renderConsent( + seed: FakeFapiSeed = signedIn(), + props: Partial = {}, + localization?: MosaicLocalization, +) { + const fapi = serveFapi(seed); + const view = await renderWithClerk(, undefined, localization); + return { ...view, fapi, user: userEvent.setup() }; +} + +async function ready(name = 'Linear would like to access your Acme account') { + return screen.findByRole('heading', { name }); +} + +function scopeList(name = 'Linear') { + return screen.getByRole('list', { name: `This will allow ${name} access to:` }); +} + +function scopeLabels(name?: string) { + return within(scopeList(name)) + .getAllByRole('listitem') + .map(item => item.textContent); +} + +function captureSubmits() { + const submits: { action: string; method: string; data: FormData }[] = []; + const onSubmit = (event: SubmitEvent) => { + event.preventDefault(); + if (event.target instanceof HTMLFormElement) { + submits.push({ + action: event.target.action, + method: event.target.method, + data: new FormData(event.target, event.submitter), + }); + } + }; + document.addEventListener('submit', onSubmit); + onTestFinished(() => document.removeEventListener('submit', onSubmit)); + return submits; +} + +describe('OAuthConsent', () => { + describe('consent screen', () => { + it('names the app and who is granting access, and lists the requested scopes', async () => { + visitConsent(); + await renderConsent(); + + await ready(); + expect(screen.getByText(/Only continue if you trust/)).toHaveTextContent( + 'Only continue if you trust Linear (example.com).', + ); + expect(screen.getByText('Signed in as alice@example.com')).toBeVisible(); + expect(scopeLabels()).toEqual(['Your profile', 'Your email address']); + }); + + it('asks the Frontend API for the client, scope and redirect URI in the URL', async () => { + visitConsent(); + const { fapi } = await renderConsent(); + + await ready(); + const [request] = fapi.oauthConsentRequests; + expect(request?.pathname).toBe(CONSENT_PATH); + expect(request?.searchParams.get('scope')).toBe('profile email'); + expect(request?.searchParams.get('redirect_uri')).toBe(REDIRECT_URI); + }); + + it('prefers props over the URL', async () => { + visit({ client_id: 'client_from_url', scope: 'email', redirect_uri: 'https://url.example.com/cb' }); + const { fapi } = await renderConsent(signedIn(), { + oauthClientId: 'client_1', + scope: 'profile', + redirectUrl: REDIRECT_URI, + }); + + await ready(); + const [request] = fapi.oauthConsentRequests; + expect(request?.pathname).toBe(CONSENT_PATH); + expect(request?.searchParams.get('scope')).toBe('profile'); + expect(request?.searchParams.get('redirect_uri')).toBe(REDIRECT_URI); + }); + + it('names the user by phone number when they have no email address', async () => { + visitConsent(); + const bob = fapiUser({ + id: 'user_2', + phone_numbers: [fapiPhoneNumber({ id: 'idn_phone', phone_number: '+15555550100' })], + primary_phone_number_id: 'idn_phone', + }); + await renderConsent(signedIn({ client: fapiClient([fapiSession({ id: 'sess_1', user: bob })]) })); + + await ready(); + expect(screen.getByText('Signed in as +15555550100')).toBeVisible(); + }); + + it('falls back to the scope name when the scope has no description', async () => { + visitConsent(); + await renderConsent(signedIn({ oauthConsent: consent({ scopes: [scope('openid')] }) })); + + await ready(); + expect(scopeLabels()).toEqual(['openid']); + }); + + it('describes private metadata in its own words', async () => { + visitConsent(); + await renderConsent( + signedIn({ oauthConsent: consent({ scopes: [scope('private_metadata', 'Server description')] }) }), + ); + + await ready(); + expect(scopeLabels()).toEqual(['Your private metadata set by Acme, which may include sensitive information']); + }); + + it('localizes the private metadata description', async () => { + visitConsent(); + await renderConsent( + signedIn({ oauthConsent: consent({ scopes: [scope('private_metadata')] }) }), + {}, + { overrides: { 'oauthConsent.privateMetadata': 'Métadonnées privées de {applicationName}' } }, + ); + + await ready(); + expect(scopeLabels()).toEqual(['Métadonnées privées de Acme']); + }); + + it('keeps offline access out of the list and says the user stays signed in', async () => { + visitConsent(); + await renderConsent( + signedIn({ oauthConsent: consent({ scopes: [scope('profile', 'Your profile'), scope('offline_access')] }) }), + ); + + await ready(); + expect(scopeLabels()).toEqual(['Your profile']); + expect(screen.getByText(/You'll stay signed in until you sign out or revoke access\./)).toBeVisible(); + }); + + it('hides the scope list when offline access is the only scope', async () => { + visitConsent(); + await renderConsent(signedIn({ oauthConsent: consent({ scopes: [scope('offline_access')] }) })); + + await ready(); + expect(screen.queryByRole('list')).toBeNull(); + }); + + it('leaves out the offline access notice when it is not requested', async () => { + visitConsent(); + await renderConsent(); + + await ready(); + expect(screen.queryByText(/You'll stay signed in/)).toBeNull(); + }); + }); + + describe('redirect domain', () => { + it('warns about the domain the Frontend API reports, and where the user will land', async () => { + visitConsent(); + await renderConsent(signedIn({ oauthConsent: consent({ redirect_domain: 'linear.app' }) })); + + await ready(); + expect(screen.getByText(/Only continue if you trust/)).toHaveTextContent( + 'Only continue if you trust Linear (linear.app).', + ); + expect(screen.getByText(/If you allow access, this app will redirect you to/)).toHaveTextContent( + 'If you allow access, this app will redirect you to linear.app.', + ); + }); + + it('derives the domain from the redirect URI when the Frontend API reports none', async () => { + visitConsent(); + await renderConsent(); + + await ready(); + expect(screen.getByText(/Only continue if you trust/)).toHaveTextContent('(example.com)'); + expect(screen.getByRole('button', { name: 'example.com' })).toBeVisible(); + }); + + it('shows the full redirect URL from the domain', async () => { + visitConsent(); + const { user } = await renderConsent(); + + await ready(); + const domain = screen.getByRole('button', { name: 'example.com' }); + await user.hover(domain); + expect(await screen.findByRole('tooltip')).toHaveTextContent('View full URL'); + + const dialog = await openDialog(user, domain, { name: 'Redirect URL' }); + expect(dialog).toHaveTextContent('Make sure you trust Linear and that this URL belongs to Linear.'); + const url = within(dialog).getByRole('textbox', { name: 'Redirect URL' }); + expect(url).toHaveValue(REDIRECT_URI); + expect(url).toHaveAttribute('readonly'); + }); + }); + + describe('submitting', () => { + it('posts one form to the consent endpoint for the signed-in session', async () => { + visitConsent(); + await renderConsent(); + + await ready(); + const forms = document.querySelectorAll('form'); + expect(forms).toHaveLength(1); + const [form] = forms; + expect(form?.method).toBe('post'); + const action = new URL(form?.action ?? ''); + expect(action.pathname).toBe(CONSENT_PATH); + expect(action.searchParams.get('_clerk_session_id')).toBe('sess_1'); + }); + + it('allows with every URL parameter forwarded', async () => { + visitConsent(); + const { user } = await renderConsent(); + const submits = captureSubmits(); + + await ready(); + await user.click(screen.getByRole('button', { name: 'Allow' })); + + expect(submits).toHaveLength(1); + const data = submits[0]?.data; + expect(data?.get('consented')).toBe('true'); + expect(data?.get('client_id')).toBe('client_1'); + expect(data?.get('scope')).toBe('profile email'); + expect(data?.get('redirect_uri')).toBe(REDIRECT_URI); + expect(data?.get('state')).toBe('st_1'); + }); + + it('denies with the same form', async () => { + visitConsent(); + const { user } = await renderConsent(); + const submits = captureSubmits(); + + await ready(); + await user.click(screen.getByRole('button', { name: 'Deny' })); + + expect(submits).toHaveLength(1); + expect(submits[0]?.data.get('consented')).toBe('false'); + expect(submits[0]?.data.get('state')).toBe('st_1'); + }); + }); + + describe('organization', () => { + const orgScopes = { oauthConsent: consent({ scopes: [scope('profile', 'Your profile'), scope('user:org:read')] }) }; + + async function submitAllow(user: ReturnType) { + const submits = captureSubmits(); + await user.click(screen.getByRole('button', { name: 'Allow' })); + return submits[0]?.data; + } + + it('asks which organization to share when the app reads organizations', async () => { + visitConsent(); + const { user } = await renderConsent(signedIn(orgScopes)); + + await ready(); + expect(scopeLabels()).toEqual(['Your profile', 'user:org:read']); + const select = screen.getByRole('combobox', { name: /^Grant access for:/ }); + expect(select).toHaveTextContent('Acme'); + expect((await submitAllow(user))?.get('organization_id')).toBe('org_1'); + }); + + it('defaults to the last active organization', async () => { + visitConsent(); + const { user } = await renderConsent(signedIn(orgScopes, globex.id)); + + await ready(); + expect(screen.getByRole('combobox', { name: /^Grant access for:/ })).toHaveTextContent('Globex'); + expect((await submitAllow(user))?.get('organization_id')).toBe('org_2'); + }); + + it('falls back to the first membership when the last active organization is not one', async () => { + visitConsent(); + const { user } = await renderConsent(signedIn(orgScopes, 'org_gone')); + + await ready(); + expect(screen.getByRole('combobox', { name: /^Grant access for:/ })).toHaveTextContent('Acme'); + expect((await submitAllow(user))?.get('organization_id')).toBe('org_1'); + }); + + it('submits the organization the user picks', async () => { + visitConsent(); + const { user } = await renderConsent(signedIn(orgScopes)); + + await ready(); + await user.click(screen.getByRole('combobox', { name: /^Grant access for:/ })); + await user.click(await screen.findByRole('option', { name: 'Globex' })); + + expect((await submitAllow(user))?.get('organization_id')).toBe('org_2'); + }); + + it('does not ask when the app does not read organizations', async () => { + visitConsent(); + const { user } = await renderConsent(); + + await ready(); + expect(screen.queryByRole('combobox', { name: /^Grant access for:/ })).toBeNull(); + expect((await submitAllow(user))?.has('organization_id')).toBe(false); + }); + + it('does not ask when organizations are disabled', async () => { + visitConsent(); + const { user } = await renderConsent( + signedIn({ ...orgScopes, environment: fapiEnvironment({ organization_settings: { enabled: false } }) }), + ); + + await ready(); + expect(screen.queryByRole('combobox', { name: /^Grant access for:/ })).toBeNull(); + expect((await submitAllow(user))?.has('organization_id')).toBe(false); + }); + + it('does not ask when the user has no memberships', async () => { + visitConsent(); + const loner = fapiUser({ id: 'user_3', organization_memberships: [] }); + const { user } = await renderConsent( + signedIn({ ...orgScopes, client: fapiClient([fapiSession({ id: 'sess_1', user: loner })]) }), + ); + + await ready(); + expect(screen.queryByRole('combobox', { name: /^Grant access for:/ })).toBeNull(); + expect((await submitAllow(user))?.has('organization_id')).toBe(false); + }); + }); + + describe('availability', () => { + it('renders nothing when signed out', async () => { + visitConsent(); + const { fapi } = await renderConsent({ oauthConsent: consent() }, { fallback:

Loading

}); + + await waitFor(() => expect(screen.queryByText('Loading')).toBeNull()); + expect(screen.queryByRole('heading')).toBeNull(); + expect(fapi.oauthConsentRequests).toEqual([]); + }); + + it('shows the fallback while consent loads', async () => { + visitConsent(); + const held = holdRequests('get', CONSENT_PATH); + await renderConsent(signedIn(), { fallback:

Loading

}); + + expect(await screen.findByText('Loading')).toBeVisible(); + held.release(); + await ready(); + expect(screen.queryByText('Loading')).toBeNull(); + }); + + it('explains a missing client ID without asking the Frontend API', async () => { + visit({ redirect_uri: REDIRECT_URI }); + const { fapi } = await renderConsent(); + + expect(await screen.findByRole('alert')).toHaveTextContent('The client ID is missing.'); + expect(fapi.oauthConsentRequests).toEqual([]); + }); + + it('explains a missing redirect URI', async () => { + visit({ client_id: 'client_1' }); + await renderConsent(); + + expect(await screen.findByRole('alert')).toHaveTextContent('The redirect URI is missing.'); + }); + + it('shows why consent could not load', { timeout: 20_000 }, async () => { + visitConsent(); + serveFapi(signedIn()); + worker.use( + http.get(fapiUrl(CONSENT_PATH), () => + HttpResponse.json( + { errors: [{ code: 'oauth_consent_error', message: 'Consent metadata unavailable' }] }, + { status: 400 }, + ), + ), + ); + await renderWithClerk(); + + expect(await screen.findByRole('alert', undefined, { timeout: 15_000 })).toHaveTextContent( + 'Consent metadata unavailable', + ); + expect(screen.queryByRole('button', { name: 'Allow' })).toBeNull(); + }); + }); +}); diff --git a/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.view.test.tsx b/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.view.test.tsx new file mode 100644 index 00000000000..faa5ca74fdf --- /dev/null +++ b/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.view.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../mosaic-provider'; +import { oauthConsentMessages } from '../oauth-consent.messages'; +import type { OAuthConsentViewProps } from '../oauth-consent.types'; +import { OAuthConsentView } from '../oauth-consent.view'; + +function propsFor(overrides: Partial = {}): OAuthConsentViewProps { + return { + messages: oauthConsentMessages, + oauthApplicationName: 'Linear', + identifier: 'alice@example.com', + applicationName: 'Acme', + scopes: [ + { scope: 'email', label: 'View your email address', description: 'alice@example.com', required: true }, + { scope: 'projects', label: 'Read your projects', description: 'Read the projects in your account.' }, + ], + hasOfflineAccess: false, + organizations: [], + organizationId: null, + onOrganizationChange: vi.fn(), + redirectUrl: 'https://app.linear.example.com/oauth/callback', + redirectDomain: 'example.com', + isRedirectUrlOpen: false, + onRedirectUrlOpenChange: vi.fn(), + actionUrl: '#', + forwardedParams: [], + renderBranding: false, + ...overrides, + }; +} + +function renderView(overrides: Partial = {}) { + render( + + + , + ); + return userEvent.setup(); +} + +describe('OAuthConsentView', () => { + describe('scope selection', () => { + it('labels each checkbox with its scope and shows the description', () => { + renderView({ scopeSelection: { checked: ['projects'], onCheckedChange: vi.fn() } }); + + expect(screen.getByRole('checkbox', { name: /Read your projects/ })).toBeChecked(); + expect(screen.getByText('Read the projects in your account.')).toBeVisible(); + }); + + it('keeps a required scope checked and locked', () => { + renderView({ scopeSelection: { checked: [], onCheckedChange: vi.fn() } }); + + const email = screen.getByRole('checkbox', { name: /View your email address/ }); + expect(email).toBeChecked(); + expect(email).toBeDisabled(); + expect(screen.getByText('Required')).toBeVisible(); + }); + + it('reports the scope the user unchecks', async () => { + const onCheckedChange = vi.fn(); + const user = renderView({ scopeSelection: { checked: ['projects'], onCheckedChange } }); + + await user.click(screen.getByRole('checkbox', { name: /Read your projects/ })); + + expect(onCheckedChange).toHaveBeenCalledWith('projects', false); + }); + + it('lists the scopes without checkboxes when there is no selection', () => { + renderView(); + + expect(screen.queryByRole('checkbox')).toBeNull(); + expect(screen.getByText('Read your projects')).toBeVisible(); + }); + }); +}); diff --git a/packages/mosaic/src/features/oauth-consent/oauth-consent.controller.ts b/packages/mosaic/src/features/oauth-consent/oauth-consent.controller.ts new file mode 100644 index 00000000000..13b023dc191 --- /dev/null +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.controller.ts @@ -0,0 +1,30 @@ +import { useState } from 'react'; + +import { fill } from '../../localization'; +import type { OAuthConsentMessages, OAuthConsentReadyModel, OAuthConsentViewProps } from './oauth-consent.types'; + +const PRIVATE_METADATA_SCOPE = 'private_metadata'; + +export function useOAuthConsentController( + { status: _status, scopes, defaultOrganizationId, ...model }: OAuthConsentReadyModel, + messages: OAuthConsentMessages, +): OAuthConsentViewProps { + const [selectedOrganizationId, setSelectedOrganizationId] = useState(null); + const [isRedirectUrlOpen, setIsRedirectUrlOpen] = useState(false); + + return { + ...model, + messages, + scopes: scopes.map(({ scope, description }) => ({ + scope, + label: + scope === PRIVATE_METADATA_SCOPE + ? fill(messages.privateMetadata, { applicationName: model.applicationName }) + : description || scope, + })), + organizationId: selectedOrganizationId ?? defaultOrganizationId, + onOrganizationChange: setSelectedOrganizationId, + isRedirectUrlOpen, + onRedirectUrlOpenChange: setIsRedirectUrlOpen, + }; +} diff --git a/packages/mosaic/src/features/oauth-consent/oauth-consent.messages.ts b/packages/mosaic/src/features/oauth-consent/oauth-consent.messages.ts new file mode 100644 index 00000000000..23b4a786484 --- /dev/null +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.messages.ts @@ -0,0 +1,17 @@ +export const oauthConsentMessages = { + title: '{oauthApplicationName} would like to access your {applicationName} account', + subtitle: 'Only continue if you trust {oauthApplicationName} {#nowrap}({domain}){/nowrap}.', + scopeListTitle: 'This will allow {applicationName} access to:', + privateMetadata: 'Your private metadata set by {applicationName}, which may include sensitive information', + organizationLabel: 'Grant access for:', + required: 'Required', + viewFullUrl: 'View full URL', + redirectNotice: 'If you allow access, this app will redirect you to {domain}.', + offlineAccessNotice: "You'll stay signed in until you sign out or revoke access.", + redirectUrlTitle: 'Redirect URL', + redirectUrlDescription: 'Make sure you trust {applicationName} and that this URL belongs to {applicationName}.', + allow: 'Allow', + deny: 'Deny', + signedInAs: 'Signed in as {identifier}', + loadError: 'Could not load consent information.', +} as const; diff --git a/packages/mosaic/src/features/oauth-consent/oauth-consent.model.ts b/packages/mosaic/src/features/oauth-consent/oauth-consent.model.ts new file mode 100644 index 00000000000..73e1074316a --- /dev/null +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.model.ts @@ -0,0 +1,82 @@ +import { getRedirectDisplay } from '@clerk/shared/internal/clerk-js/oauthConsent'; +import { useClerk, useOAuthConsent, useSession } from '@clerk/shared/react'; + +import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; +import { toLocalizableError } from '../../utils/errors'; +import type { OAuthConsentModel } from './oauth-consent.types'; + +const OFFLINE_ACCESS_SCOPE = 'offline_access'; +const USER_ORG_READ_SCOPE = 'user:org:read'; + +export interface OAuthConsentModelOptions { + oauthClientId?: string; + scope?: string; + redirectUrl?: string; +} + +function readSearchParams(): URLSearchParams { + return typeof window === 'undefined' ? new URLSearchParams() : new URLSearchParams(window.location.search); +} + +export function useOAuthConsentModel(options: OAuthConsentModelOptions): OAuthConsentModel { + const clerk = useClerk(); + const { isLoaded, session } = useSession(); + const environment = useMosaicEnvironment(); + const search = readSearchParams(); + const oauthClientId = options.oauthClientId ?? search.get('client_id') ?? ''; + const scope = options.scope ?? search.get('scope') ?? undefined; + const redirectUrl = options.redirectUrl ?? search.get('redirect_uri') ?? ''; + const { data, error, isLoading } = useOAuthConsent({ + oauthClientId, + scope, + redirectUri: redirectUrl || undefined, + enabled: redirectUrl !== '', + }); + + if (!isLoaded || !environment) { + return { status: 'loading' }; + } + if (!session) { + return { status: 'hidden' }; + } + if (!oauthClientId) { + return { status: 'error', error: { code: 'oauth_consent_missing_client_id' } }; + } + if (!redirectUrl) { + return { status: 'error', error: { code: 'oauth_consent_missing_redirect_uri' } }; + } + if (error) { + return { status: 'error', error: toLocalizableError(error) }; + } + if (isLoading || !data) { + return { status: 'loading' }; + } + + const { user } = session; + const requested = data.scopes.map(({ scope: name, description }) => ({ scope: name, description })); + const readsOrganizations = + environment.organizationSettings.enabled && requested.some(item => item.scope === USER_ORG_READ_SCOPE); + const organizations = readsOrganizations + ? user.organizationMemberships.map(membership => ({ + value: membership.organization.id, + label: membership.organization.name, + })) + : []; + const lastActive = organizations.find(organization => organization.value === session.lastActiveOrganizationId); + + return { + status: 'ready', + applicationName: environment.displayConfig.applicationName, + oauthApplicationName: data.oauthApplicationName, + identifier: user.primaryEmailAddress?.emailAddress || user.primaryPhoneNumber?.phoneNumber || '', + scopes: requested.filter(item => item.scope !== OFFLINE_ACCESS_SCOPE), + hasOfflineAccess: requested.some(item => item.scope === OFFLINE_ACCESS_SCOPE), + organizations, + defaultOrganizationId: (lastActive ?? organizations[0])?.value ?? null, + redirectUrl, + redirectDomain: data.redirectDomain ?? getRedirectDisplay(redirectUrl), + actionUrl: clerk.oauthApplication.buildConsentActionUrl({ clientId: oauthClientId }), + forwardedParams: [...search.entries()], + renderBranding: environment.displayConfig.branded, + }; +} diff --git a/packages/mosaic/src/features/oauth-consent/oauth-consent.styles.ts b/packages/mosaic/src/features/oauth-consent/oauth-consent.styles.ts new file mode 100644 index 00000000000..1ae812d22f7 --- /dev/null +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.styles.ts @@ -0,0 +1,36 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontWeightVars, radiusVars, space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + scopes: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-md'], + borderStyle: 'solid', + borderWidth: '1px', + overflow: 'hidden', + }, + scopesTitle: { + paddingBlock: space['2'], + paddingInline: space['3'], + backgroundColor: colorVars['--cl-color-background-subtle'], + borderBlockEndColor: colorVars['--cl-color-border'], + borderBlockEndStyle: 'solid', + borderBlockEndWidth: '1px', + fontWeight: fontWeightVars['--cl-font-medium'], + }, + scopeList: { listStyleType: 'none' }, + scopeRow: { + paddingBlock: space['2'], + paddingInline: space['3'], + borderBlockStartColor: colorVars['--cl-color-border'], + borderBlockStartStyle: 'solid', + borderBlockStartWidth: { default: 0, ':not(:first-child)': '1px' }, + }, + scopeText: { gap: space['0.5'], display: 'flex', flexDirection: 'column' }, + scopeLabel: { gap: space['2'], alignItems: 'center', display: 'flex' }, + centered: { justifyContent: 'center', textAlign: 'center' }, + actions: { gap: space['3'], display: 'flex' }, + inlineLink: { fontSize: 'inherit', lineHeight: 'inherit', verticalAlign: 'baseline' }, + nowrap: { whiteSpace: 'nowrap' }, +}); diff --git a/packages/mosaic/src/features/oauth-consent/oauth-consent.tsx b/packages/mosaic/src/features/oauth-consent/oauth-consent.tsx new file mode 100644 index 00000000000..e5b9b2776c1 --- /dev/null +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.tsx @@ -0,0 +1,64 @@ +'use client'; + +import type { ReactElement, ReactNode } from 'react'; + +import { useErrorText, useMessages } from '../../localization'; +import { useOAuthConsentController } from './oauth-consent.controller'; +import type { OAuthConsentModelOptions } from './oauth-consent.model'; +import { useOAuthConsentModel } from './oauth-consent.model'; +import type { OAuthConsentMessages, OAuthConsentReadyModel } from './oauth-consent.types'; +import { OAuthConsentErrorView, OAuthConsentView } from './oauth-consent.view'; + +export interface OAuthConsentProps extends OAuthConsentModelOptions { + /** Rendered while Clerk and the consent details load. */ + fallback?: ReactNode; +} + +/** + * Asks the signed-in user whether an OAuth application may access their account. It reads the client, + * scopes and redirect URI from the `client_id`, `scope` and `redirect_uri` URL parameters unless props + * set them, and posts the answer to Clerk with every URL parameter forwarded. It renders `fallback` + * until the consent details load, and nothing at all when nobody is signed in. + * + * @example + * ```tsx + * import { OAuthConsent } from '@clerk/mosaic'; + * + * + * ``` + * + * @example + * ```tsx + * } + * /> + * ``` + */ +export function OAuthConsent({ fallback, ...options }: OAuthConsentProps): ReactElement | null { + const messages = useMessages('oauthConsent'); + const errorText = useErrorText(); + const model = useOAuthConsentModel(options); + + if (model.status === 'loading') { + return <>{fallback ?? null}; + } + if (model.status === 'hidden') { + return null; + } + if (model.status === 'error') { + return ; + } + return ( + + ); +} + +function ReadyOAuthConsent({ model, messages }: { model: OAuthConsentReadyModel; messages: OAuthConsentMessages }) { + return ; +} diff --git a/packages/mosaic/src/features/oauth-consent/oauth-consent.types.ts b/packages/mosaic/src/features/oauth-consent/oauth-consent.types.ts new file mode 100644 index 00000000000..f69ec4a4cee --- /dev/null +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.types.ts @@ -0,0 +1,63 @@ +import type { OAuthConsentScope } from '@clerk/shared/types'; + +import type { SelectItem } from '../../components/select'; +import type { ErrorDescription, MosaicMessages } from '../../localization'; + +export type OAuthConsentMessages = MosaicMessages['oauthConsent']; + +export type OAuthConsentScopeRecord = Pick; + +export interface OAuthConsentData { + applicationName: string; + oauthApplicationName: string; + identifier: string; + scopes: OAuthConsentScopeRecord[]; + hasOfflineAccess: boolean; + organizations: SelectItem[]; + defaultOrganizationId: string | null; + redirectUrl: string; + redirectDomain: string; + actionUrl: string; + forwardedParams: [string, string][]; + renderBranding: boolean; +} + +export type OAuthConsentReadyModel = OAuthConsentData & { status: 'ready' }; + +export type OAuthConsentModel = + | { status: 'loading' } + | { status: 'hidden' } + | { status: 'error'; error: ErrorDescription } + | OAuthConsentReadyModel; + +export interface OAuthConsentScopeItem { + scope: string; + label: string; + description?: string; + required?: boolean; +} + +export interface OAuthConsentScopeSelection { + checked: readonly string[]; + onCheckedChange: (scope: string, checked: boolean) => void; +} + +export interface OAuthConsentViewProps { + messages: OAuthConsentMessages; + oauthApplicationName: string; + identifier: string; + applicationName: string; + scopes: OAuthConsentScopeItem[]; + scopeSelection?: OAuthConsentScopeSelection; + hasOfflineAccess: boolean; + organizations: SelectItem[]; + organizationId: string | null; + onOrganizationChange: (organizationId: string) => void; + redirectUrl: string; + redirectDomain: string; + isRedirectUrlOpen: boolean; + onRedirectUrlOpenChange: (open: boolean) => void; + actionUrl: string; + forwardedParams: [string, string][]; + renderBranding: boolean; +} diff --git a/packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx b/packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx new file mode 100644 index 00000000000..b751efb02c3 --- /dev/null +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx @@ -0,0 +1,275 @@ +import * as stylex from '@stylexjs/stylex'; +import { type ReactNode, useId } from 'react'; + +import { Badge } from '../../components/badge'; +import { Button } from '../../components/button'; +import { Card } from '../../components/card'; +import { Checkbox } from '../../components/checkbox'; +import { Dialog } from '../../components/dialog'; +import { EmptyState } from '../../components/empty-state'; +import { Field } from '../../components/field'; +import { Input } from '../../components/input'; +import { Select } from '../../components/select'; +import { Text } from '../../components/text'; +import { Tooltip } from '../../components/tooltip'; +import { fill, rich } from '../../localization'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../styles/reset.styles'; +import { styles } from './oauth-consent.styles'; +import type { OAuthConsentScopeItem, OAuthConsentScopeSelection, OAuthConsentViewProps } from './oauth-consent.types'; + +export function OAuthConsentView(props: OAuthConsentViewProps) { + const { messages: m, oauthApplicationName, organizationId } = props; + const scopeListTitleId = useId(); + const openRedirectUrl = () => props.onRedirectUrlOpenChange(true); + const domain = ( + + ); + + return ( + <> + + } + > + + {fill(m.title, { oauthApplicationName, applicationName: props.applicationName })} + + {rich(m.subtitle, { + values: { oauthApplicationName, domain: props.redirectDomain }, + components: { nowrap: renderNowrap }, + })} + + + + {props.organizations.length > 0 ? ( + + {m.organizationLabel} + { + if (typeof value === 'string') { + props.onOrganizationChange(value); + } + }} + > + + + + + ) : null} + {props.scopes.length > 0 ? ( +
+ + {fill(m.scopeListTitle, { applicationName: oauthApplicationName })} + +
    + {props.scopes.map(item => ( +
  • + {props.scopeSelection ? ( + + ) : ( + + )} +
  • + ))} +
+
+ ) : null} + {props.forwardedParams.map(([name, value]) => ( + + ))} + {organizationId !== null && props.organizations.length > 0 ? ( + + ) : null} +
+ + +
+ + {rich(m.redirectNotice, { values: { domain } })} + {props.hasOfflineAccess ? ` ${m.offlineAccessNotice}` : null} + +
+ + + {fill(m.signedInAs, { identifier: props.identifier })} + + +
+ + + + + {m.redirectUrlTitle} + + {fill(m.redirectUrlDescription, { applicationName: oauthApplicationName })} + + + + + + + + + + ); +} + +function ScopeOption({ + item, + selection, + requiredLabel, +}: { + item: OAuthConsentScopeItem; + selection: OAuthConsentScopeSelection; + requiredLabel: string; +}) { + return ( + + selection.onCheckedChange(item.scope, event.currentTarget.checked)} + /> + +
+ {item.label} + {item.required ? {requiredLabel} : null} +
+ {item.description ? {item.description} : null} +
+
+ ); +} + +function ScopeText({ item, requiredLabel }: { item: OAuthConsentScopeItem; requiredLabel: string }) { + return ( +
+
+ {item.label} + {item.required ? {requiredLabel} : null} +
+ {item.description ? ( + + {item.description} + + ) : null} +
+ ); +} + +function renderNowrap(children: ReactNode) { + return {children}; +} + +function RedirectDomainButton({ domain, label, onClick }: { domain: string; label: string; onClick: () => void }) { + return ( + + + } + > + {domain} + + {label} + + ); +} + +export interface OAuthConsentErrorViewProps { + message: string; +} + +export function OAuthConsentErrorView({ message }: OAuthConsentErrorViewProps) { + return ( + + + + + {message} + + + + ); +} diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts index d4dff125961..eabf4cf647e 100644 --- a/packages/mosaic/src/localization/errors.messages.ts +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -52,6 +52,8 @@ export const errorMessages: { readonly generic: string } & Readonly { describe('ip literal', () => { diff --git a/packages/shared/src/internal/clerk-js/oauthConsent.ts b/packages/shared/src/internal/clerk-js/oauthConsent.ts new file mode 100644 index 00000000000..e6056c45eb1 --- /dev/null +++ b/packages/shared/src/internal/clerk-js/oauthConsent.ts @@ -0,0 +1,25 @@ +const IPV4_REGEX = /^(\d{1,3}\.){3}\d{1,3}$/; + +export function getRedirectDisplay(url: string): string { + let hostname: string; + try { + hostname = new URL(url).hostname; + } catch { + 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}]`; + } + return host.split('.').slice(-2).join('.'); +} diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 24a57c4a7ea..7bc82041a41 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -54,6 +54,9 @@ const docModules: Record> = { 'api-keys': { 'api-keys-table': dynamic(() => import('../stories/api-keys-table.mdx')), }, + 'o-auth-consent': { + 'o-auth-consent': dynamic(() => import('../stories/oauth-consent.mdx')), + }, reverification: { reverification: dynamic(() => import('../stories/reverification.mdx')), }, diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 8825ab578d9..2ede33d1197 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -13,6 +13,13 @@ import { Organization as APIKeysTableOrganization, ProposedTable as APIKeysTableProposedTable, } from '../stories/api-keys-table.stories'; +import { + Default as OAuthConsentDefault, + LoadError as OAuthConsentLoadError, + meta as oauthConsentMeta, + OfflineAccess as OAuthConsentOfflineAccess, + ScopeList as OAuthConsentScopeList, +} from '../stories/oauth-consent.stories'; import { meta as autocompleteMeta } from '../stories/autocomplete.stories'; import { Fallback as AvatarFallbackStory, @@ -921,6 +928,14 @@ const apiKeysTableModule: StoryModule = { Empty: APIKeysTableEmpty, }; +const oauthConsentModule: StoryModule = { + meta: oauthConsentMeta, + Default: OAuthConsentDefault, + ScopeList: OAuthConsentScopeList, + OfflineAccess: OAuthConsentOfflineAccess, + LoadError: OAuthConsentLoadError, +}; + const reverificationModule: StoryModule = { meta: reverificationMeta, Default: ReverificationDefault, @@ -991,6 +1006,7 @@ export const registry: StoryModule[] = [ organizationProfileDangerSectionModule, // API Keys apiKeysTableModule, + oauthConsentModule, // Reverification reverificationModule, // Blocks — flows assembled from components, wired by the caller's machine. diff --git a/packages/swingset/src/stories/fixtures/oauth-consent.ts b/packages/swingset/src/stories/fixtures/oauth-consent.ts new file mode 100644 index 00000000000..520deaca12c --- /dev/null +++ b/packages/swingset/src/stories/fixtures/oauth-consent.ts @@ -0,0 +1,60 @@ +import { useOAuthConsentController } from '@clerk/mosaic/features/oauth-consent/oauth-consent.controller'; +import type { + OAuthConsentReadyModel, + OAuthConsentScopeItem, + OAuthConsentViewProps, +} from '@clerk/mosaic/features/oauth-consent/oauth-consent.types'; +import { useMessages } from '@clerk/mosaic/localization'; +import { useState } from 'react'; + +const redirectUrl = 'https://app.linear.example.com/oauth/callback'; + +const baseModel: OAuthConsentReadyModel = { + status: 'ready', + applicationName: 'Acme', + oauthApplicationName: 'Linear', + identifier: 'alice@example.com', + scopes: [ + { scope: 'profile', description: 'Read your name and profile image' }, + { scope: 'email', description: 'Read your email address' }, + { scope: 'private_metadata', description: null }, + ], + hasOfflineAccess: false, + organizations: [], + defaultOrganizationId: null, + redirectUrl, + redirectDomain: 'example.com', + actionUrl: '#', + forwardedParams: [ + ['client_id', 'client_1'], + ['redirect_uri', redirectUrl], + ], + renderBranding: true, +}; + +const selectableScopes: OAuthConsentScopeItem[] = [ + { scope: 'email', label: 'View your email address', description: 'alice@example.com', required: true }, + { scope: 'projects', label: 'Read your projects', description: 'Read the projects in your account.' }, + { scope: 'phone', label: 'View your phone number', description: 'Allow Linear to view your phone number' }, +]; + +export function useOAuthConsentFixture(overrides: Partial = {}) { + const messages = useMessages('oauthConsent'); + return useOAuthConsentController({ ...baseModel, ...overrides }, messages); +} + +export function useOAuthConsentScopeSelectionFixture( + overrides: Partial = {}, +): OAuthConsentViewProps { + const props = useOAuthConsentFixture(overrides); + const [checked, setChecked] = useState(() => selectableScopes.map(item => item.scope)); + return { + ...props, + scopes: selectableScopes, + scopeSelection: { + checked, + onCheckedChange: (scope, isChecked) => + setChecked(current => (isChecked ? [...current, scope] : current.filter(item => item !== scope))), + }, + }; +} diff --git a/packages/swingset/src/stories/oauth-consent.mdx b/packages/swingset/src/stories/oauth-consent.mdx new file mode 100644 index 00000000000..003fc3e5145 --- /dev/null +++ b/packages/swingset/src/stories/oauth-consent.mdx @@ -0,0 +1,59 @@ +import * as Stories from './oauth-consent.stories'; + +# OAuthConsent + +Asks the signed-in user whether an OAuth application may access their account. `OAuthConsent` reads the client, scopes and redirect URI from the `client_id`, `scope` and `redirect_uri` URL parameters unless props set them, and posts the answer to Clerk with every URL parameter forwarded. When the application asks for `user:org:read`, the user picks which organization to grant. Each scope can show a checkbox with a label and a description; a required scope stays checked. It renders `fallback` until the consent details load, and nothing when nobody is signed in. + +## Example + + + +## Usage + +```tsx +import { OAuthConsent } from '@clerk/mosaic/features/oauth-consent/oauth-consent'; + +} />; +``` + +## Props + +| Prop | Type | Default | +| --------------- | ----------- | ---------------------------- | +| `oauthClientId` | `string` | The `client_id` parameter | +| `scope` | `string` | The `scope` parameter | +| `redirectUrl` | `string` | The `redirect_uri` parameter | +| `fallback` | `ReactNode` | `null` | + +## Examples + +### Scope list + +Without `scopeSelection`, the scopes render as a list with no checkboxes. This is what `OAuthConsent` renders today. + + + +### Offline access + +Requesting `offline_access` hides that scope from the list and adds a note that the user stays signed in. + + + +### Error + + diff --git a/packages/swingset/src/stories/oauth-consent.stories.tsx b/packages/swingset/src/stories/oauth-consent.stories.tsx new file mode 100644 index 00000000000..18f28476fe3 --- /dev/null +++ b/packages/swingset/src/stories/oauth-consent.stories.tsx @@ -0,0 +1,40 @@ +import { OAuthConsentErrorView, OAuthConsentView } from '@clerk/mosaic/features/oauth-consent/oauth-consent.view'; + +import type { StoryMeta } from '@/lib/types'; + +import { useOAuthConsentFixture, useOAuthConsentScopeSelectionFixture } from './fixtures/oauth-consent'; + +export { default as __source } from './oauth-consent.stories?raw'; + +export const meta: StoryMeta = { + group: 'OAuth Consent', + status: 'wip', + title: 'OAuthConsent', + label: 'OAuth consent', + source: 'packages/mosaic/src/features/oauth-consent/oauth-consent.tsx', +}; + +export function Default() { + const props = useOAuthConsentScopeSelectionFixture({ + organizations: [ + { value: 'org_1', label: 'My workspace' }, + { value: 'org_2', label: 'Globex' }, + ], + defaultOrganizationId: 'org_1', + }); + return ; +} + +export function ScopeList() { + const props = useOAuthConsentFixture(); + return ; +} + +export function OfflineAccess() { + const props = useOAuthConsentScopeSelectionFixture({ hasOfflineAccess: true }); + return ; +} + +export function LoadError() { + return ; +} diff --git a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx index b9f6fcd00cb..ff4c8dac910 100644 --- a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx +++ b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx @@ -1,3 +1,4 @@ +import { getRedirectDisplay } from '@clerk/shared/internal/clerk-js/oauthConsent'; import { useClerk, useOAuthConsent, useUser } from '@clerk/shared/react'; import { useState } from 'react'; @@ -24,7 +25,7 @@ import { } from './ListGroup'; import { LogoGroup, LogoGroupIcon, LogoGroupItem, LogoGroupItemContainer, LogoGroupSeparator } from './LogoGroup'; import { OrgSelect } from './OrgSelect'; -import { getForwardedParams, getOAuthConsentFromSearch, getRedirectDisplay, getRedirectUriFromSearch } from './utils'; +import { getForwardedParams, getOAuthConsentFromSearch, getRedirectUriFromSearch } from './utils'; const OFFLINE_ACCESS_SCOPE = 'offline_access'; const PRIVATE_METADATA_SCOPE = 'private_metadata'; diff --git a/packages/ui/src/components/OAuthConsent/utils.ts b/packages/ui/src/components/OAuthConsent/utils.ts index f511c600df8..c6727d3c085 100644 --- a/packages/ui/src/components/OAuthConsent/utils.ts +++ b/packages/ui/src/components/OAuthConsent/utils.ts @@ -1,31 +1,5 @@ const canReadLocation = () => typeof window !== 'undefined' && !!window.location; -const IPV4_REGEX = /^(\d{1,3}\.){3}\d{1,3}$/; - -export function getRedirectDisplay(url: string): string { - let hostname: string; - try { - hostname = new URL(url).hostname; - } catch { - 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}]`; - } - return host.split('.').slice(-2).join('.'); -} - export function getRedirectUriFromSearch(): string { if (!canReadLocation()) { return ''; From 0ee7f547ca3c92754374fe5271f33f0bb680d1c1 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 13 Oct 2026 17:27:17 -0400 Subject: [PATCH 2/5] feat(mosaic): show pending state on OAuthConsent allow and deny --- .../__tests__/oauth-consent.feature.test.tsx | 42 ++++++++++++++++++- .../__tests__/oauth-consent.view.test.tsx | 8 ++++ .../oauth-consent/oauth-consent.controller.ts | 16 ++++++- .../oauth-consent/oauth-consent.messages.ts | 2 + .../oauth-consent/oauth-consent.types.ts | 3 ++ .../oauth-consent/oauth-consent.view.tsx | 27 ++++++++---- packages/swingset/src/lib/registry.ts | 14 +++---- 7 files changed, 94 insertions(+), 18 deletions(-) diff --git a/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.feature.test.tsx b/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.feature.test.tsx index cad984c78c1..665fb20affd 100644 --- a/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.feature.test.tsx +++ b/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.feature.test.tsx @@ -1,5 +1,5 @@ import type { OAuthConsentInfoJSON } from '@clerk/shared/types'; -import { screen, waitFor, within } from '@testing-library/react'; +import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { describe, expect, it, onTestFinished } from 'vitest'; @@ -304,6 +304,36 @@ describe('OAuthConsent', () => { expect(submits[0]?.data.get('consented')).toBe('false'); expect(submits[0]?.data.get('state')).toBe('st_1'); }); + + it('shows the chosen button as pending, locks the other, and submits only once', async () => { + visitConsent(); + const { user } = await renderConsent(); + const submits = captureSubmits(); + + await ready(); + await user.click(screen.getByRole('button', { name: 'Allow' })); + await user.keyboard('{Enter}'); + + expect(submits).toHaveLength(1); + expect(submits[0]?.data.get('consented')).toBe('true'); + expect(screen.getByRole('button', { name: 'Allow' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('button', { name: 'Deny' })).toBeDisabled(); + }); + + it('unlocks when the browser restores the page from history', async () => { + visitConsent(); + const { user } = await renderConsent(); + captureSubmits(); + + await ready(); + await user.click(screen.getByRole('button', { name: 'Deny' })); + act(() => { + window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })); + }); + + expect(screen.getByRole('button', { name: 'Allow' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Deny' })).not.toHaveAttribute('aria-busy'); + }); }); describe('organization', () => { @@ -326,6 +356,16 @@ describe('OAuthConsent', () => { expect((await submitAllow(user))?.get('organization_id')).toBe('org_1'); }); + it('locks the organization once the form submits', async () => { + visitConsent(); + const { user } = await renderConsent(signedIn(orgScopes)); + + await ready(); + await submitAllow(user); + + expect(screen.getByRole('combobox', { name: /^Grant access for:/ })).toBeDisabled(); + }); + it('defaults to the last active organization', async () => { visitConsent(); const { user } = await renderConsent(signedIn(orgScopes, globex.id)); diff --git a/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.view.test.tsx b/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.view.test.tsx index faa5ca74fdf..bc651e63f72 100644 --- a/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.view.test.tsx +++ b/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.view.test.tsx @@ -24,6 +24,8 @@ function propsFor(overrides: Partial = {}): OAuthConsentV redirectUrl: 'https://app.linear.example.com/oauth/callback', redirectDomain: 'example.com', isRedirectUrlOpen: false, + onAllow: vi.fn(), + onDeny: vi.fn(), onRedirectUrlOpenChange: vi.fn(), actionUrl: '#', forwardedParams: [], @@ -68,6 +70,12 @@ describe('OAuthConsentView', () => { expect(onCheckedChange).toHaveBeenCalledWith('projects', false); }); + it('locks every checkbox while the form submits', () => { + renderView({ pendingAction: 'allow', scopeSelection: { checked: ['projects'], onCheckedChange: vi.fn() } }); + + expect(screen.getByRole('checkbox', { name: /Read your projects/ })).toBeDisabled(); + }); + it('lists the scopes without checkboxes when there is no selection', () => { renderView(); diff --git a/packages/mosaic/src/features/oauth-consent/oauth-consent.controller.ts b/packages/mosaic/src/features/oauth-consent/oauth-consent.controller.ts index 13b023dc191..f89e6414ef6 100644 --- a/packages/mosaic/src/features/oauth-consent/oauth-consent.controller.ts +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.controller.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { fill } from '../../localization'; import type { OAuthConsentMessages, OAuthConsentReadyModel, OAuthConsentViewProps } from './oauth-consent.types'; @@ -11,6 +11,17 @@ export function useOAuthConsentController( ): OAuthConsentViewProps { const [selectedOrganizationId, setSelectedOrganizationId] = useState(null); const [isRedirectUrlOpen, setIsRedirectUrlOpen] = useState(false); + const [pendingAction, setPendingAction] = useState<'allow' | 'deny'>(); + + useEffect(() => { + const onPageShow = (event: PageTransitionEvent) => { + if (event.persisted) { + setPendingAction(undefined); + } + }; + window.addEventListener('pageshow', onPageShow); + return () => window.removeEventListener('pageshow', onPageShow); + }, []); return { ...model, @@ -26,5 +37,8 @@ export function useOAuthConsentController( onOrganizationChange: setSelectedOrganizationId, isRedirectUrlOpen, onRedirectUrlOpenChange: setIsRedirectUrlOpen, + pendingAction, + onAllow: () => setPendingAction('allow'), + onDeny: () => setPendingAction('deny'), }; } diff --git a/packages/mosaic/src/features/oauth-consent/oauth-consent.messages.ts b/packages/mosaic/src/features/oauth-consent/oauth-consent.messages.ts index 23b4a786484..5fd84aac1b2 100644 --- a/packages/mosaic/src/features/oauth-consent/oauth-consent.messages.ts +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.messages.ts @@ -11,7 +11,9 @@ export const oauthConsentMessages = { redirectUrlTitle: 'Redirect URL', redirectUrlDescription: 'Make sure you trust {applicationName} and that this URL belongs to {applicationName}.', allow: 'Allow', + allowing: 'Allowing access', deny: 'Deny', + denying: 'Denying access', signedInAs: 'Signed in as {identifier}', loadError: 'Could not load consent information.', } as const; diff --git a/packages/mosaic/src/features/oauth-consent/oauth-consent.types.ts b/packages/mosaic/src/features/oauth-consent/oauth-consent.types.ts index f69ec4a4cee..1e3aaff4ad2 100644 --- a/packages/mosaic/src/features/oauth-consent/oauth-consent.types.ts +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.types.ts @@ -57,6 +57,9 @@ export interface OAuthConsentViewProps { redirectDomain: string; isRedirectUrlOpen: boolean; onRedirectUrlOpenChange: (open: boolean) => void; + pendingAction?: 'allow' | 'deny'; + onAllow: () => void; + onDeny: () => void; actionUrl: string; forwardedParams: [string, string][]; renderBranding: boolean; diff --git a/packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx b/packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx index b751efb02c3..7e6ace49c47 100644 --- a/packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx @@ -2,7 +2,7 @@ import * as stylex from '@stylexjs/stylex'; import { type ReactNode, useId } from 'react'; import { Badge } from '../../components/badge'; -import { Button } from '../../components/button'; +import { Button, SubmitButton } from '../../components/button'; import { Card } from '../../components/card'; import { Checkbox } from '../../components/checkbox'; import { Dialog } from '../../components/dialog'; @@ -52,7 +52,7 @@ export function OAuthConsentView(props: OAuthConsentViewProps) { {props.organizations.length > 0 ? ( - + {m.organizationLabel} ) : ( @@ -119,23 +120,29 @@ export function OAuthConsentView(props: OAuthConsentViewProps) { /> ) : null}
- - +
Date: Tue, 13 Oct 2026 17:33:14 -0400 Subject: [PATCH 3/5] feat(mosaic): cap OAuthConsent scope list height and mock submits in swingset --- .../oauth-consent/oauth-consent.styles.ts | 2 +- .../oauth-consent/oauth-consent.view.tsx | 3 +- packages/swingset/src/lib/registry.ts | 2 + .../src/stories/fixtures/oauth-consent.ts | 46 +++++++++++++++++-- .../swingset/src/stories/oauth-consent.mdx | 6 +++ .../src/stories/oauth-consent.stories.tsx | 7 ++- 6 files changed, 58 insertions(+), 8 deletions(-) diff --git a/packages/mosaic/src/features/oauth-consent/oauth-consent.styles.ts b/packages/mosaic/src/features/oauth-consent/oauth-consent.styles.ts index 1ae812d22f7..a47a9159866 100644 --- a/packages/mosaic/src/features/oauth-consent/oauth-consent.styles.ts +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.styles.ts @@ -19,7 +19,7 @@ export const styles = stylex.create({ borderBlockEndWidth: '1px', fontWeight: fontWeightVars['--cl-font-medium'], }, - scopeList: { listStyleType: 'none' }, + scopeList: { listStyleType: 'none', maxBlockSize: '12.5rem' }, scopeRow: { paddingBlock: space['2'], paddingInline: space['3'], diff --git a/packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx b/packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx index 7e6ace49c47..a4446ef4d78 100644 --- a/packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx @@ -9,6 +9,7 @@ import { Dialog } from '../../components/dialog'; import { EmptyState } from '../../components/empty-state'; import { Field } from '../../components/field'; import { Input } from '../../components/input'; +import { scrollAreaViewport } from '../../components/scroll-area'; import { Select } from '../../components/select'; import { Text } from '../../components/text'; import { Tooltip } from '../../components/tooltip'; @@ -79,7 +80,7 @@ export function OAuthConsentView(props: OAuthConsentViewProps) {
    {props.scopes.map(item => (
  • = {}) { +export const manyScopes: OAuthConsentScopeItem[] = [ + ...selectableScopes, + { scope: 'issues', label: 'Read your issues', description: 'Read the issues assigned to you.' }, + { scope: 'comments', label: 'Write comments', description: 'Comment on issues on your behalf.' }, + { scope: 'teams', label: 'Read your teams', description: 'See the teams you belong to.' }, + { scope: 'webhooks', label: 'Manage webhooks', description: 'Create and delete webhooks in your workspace.' }, +]; + +const SUBMIT_ROUND_TRIP_MS = 1500; + +function useMockSubmit() { + const [pendingAction, setPendingAction] = useState<'allow' | 'deny'>(); + + useEffect(() => { + const blockNavigation = (event: SubmitEvent) => event.preventDefault(); + document.addEventListener('submit', blockNavigation); + return () => document.removeEventListener('submit', blockNavigation); + }, []); + + useEffect(() => { + if (!pendingAction) { + return; + } + const timeout = setTimeout(() => setPendingAction(undefined), SUBMIT_ROUND_TRIP_MS); + return () => clearTimeout(timeout); + }, [pendingAction]); + + return { + pendingAction, + onAllow: () => setPendingAction('allow'), + onDeny: () => setPendingAction('deny'), + }; +} + +export function useOAuthConsentFixture(overrides: Partial = {}): OAuthConsentViewProps { const messages = useMessages('oauthConsent'); - return useOAuthConsentController({ ...baseModel, ...overrides }, messages); + const props = useOAuthConsentController({ ...baseModel, ...overrides }, messages); + return { ...props, ...useMockSubmit() }; } export function useOAuthConsentScopeSelectionFixture( overrides: Partial = {}, + scopes: OAuthConsentScopeItem[] = selectableScopes, ): OAuthConsentViewProps { const props = useOAuthConsentFixture(overrides); - const [checked, setChecked] = useState(() => selectableScopes.map(item => item.scope)); + const [checked, setChecked] = useState(() => scopes.map(item => item.scope)); return { ...props, - scopes: selectableScopes, + scopes, scopeSelection: { checked, onCheckedChange: (scope, isChecked) => diff --git a/packages/swingset/src/stories/oauth-consent.mdx b/packages/swingset/src/stories/oauth-consent.mdx index 003fc3e5145..f7dcf03cdbd 100644 --- a/packages/swingset/src/stories/oauth-consent.mdx +++ b/packages/swingset/src/stories/oauth-consent.mdx @@ -48,6 +48,12 @@ Without `scopeSelection`, the scopes render as a list with no checkboxes. This i +### Many scopes + +A long scope list scrolls inside its box, with the edges fading while there is more to see. + + + ### Offline access Requesting `offline_access` hides that scope from the list and adds a note that the user stays signed in. diff --git a/packages/swingset/src/stories/oauth-consent.stories.tsx b/packages/swingset/src/stories/oauth-consent.stories.tsx index 18f28476fe3..41df4430e3a 100644 --- a/packages/swingset/src/stories/oauth-consent.stories.tsx +++ b/packages/swingset/src/stories/oauth-consent.stories.tsx @@ -2,7 +2,7 @@ import { OAuthConsentErrorView, OAuthConsentView } from '@clerk/mosaic/features/ import type { StoryMeta } from '@/lib/types'; -import { useOAuthConsentFixture, useOAuthConsentScopeSelectionFixture } from './fixtures/oauth-consent'; +import { manyScopes, useOAuthConsentFixture, useOAuthConsentScopeSelectionFixture } from './fixtures/oauth-consent'; export { default as __source } from './oauth-consent.stories?raw'; @@ -30,6 +30,11 @@ export function ScopeList() { return ; } +export function ManyScopes() { + const props = useOAuthConsentScopeSelectionFixture({}, manyScopes); + return ; +} + export function OfflineAccess() { const props = useOAuthConsentScopeSelectionFixture({ hasOfflineAccess: true }); return ; From 8e5aac78fa46c63b74db50a2bc3f1fc9f7518b28 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 13 Oct 2026 19:53:48 -0400 Subject: [PATCH 4/5] feat(mosaic): raise OAuthConsent scope list max height --- .../mosaic/src/features/oauth-consent/oauth-consent.styles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mosaic/src/features/oauth-consent/oauth-consent.styles.ts b/packages/mosaic/src/features/oauth-consent/oauth-consent.styles.ts index a47a9159866..de0eb3d4c72 100644 --- a/packages/mosaic/src/features/oauth-consent/oauth-consent.styles.ts +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.styles.ts @@ -19,7 +19,7 @@ export const styles = stylex.create({ borderBlockEndWidth: '1px', fontWeight: fontWeightVars['--cl-font-medium'], }, - scopeList: { listStyleType: 'none', maxBlockSize: '12.5rem' }, + scopeList: { listStyleType: 'none', maxBlockSize: '20rem' }, scopeRow: { paddingBlock: space['2'], paddingInline: space['3'], From 335e4b1794fd4998ffbc1aec59b044790c71d111 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 14 Oct 2026 10:13:40 -0400 Subject: [PATCH 5/5] feat(mosaic): animate OAuthConsent between loading, consent and error with Flow --- .../__tests__/oauth-consent.feature.test.tsx | 12 +- .../__tests__/oauth-consent.view.test.tsx | 6 +- .../oauth-consent/oauth-consent.messages.ts | 1 + .../oauth-consent/oauth-consent.styles.ts | 1 + .../features/oauth-consent/oauth-consent.tsx | 53 ++-- .../oauth-consent/oauth-consent.types.ts | 1 - .../oauth-consent/oauth-consent.view.tsx | 262 +++++++++--------- packages/swingset/src/lib/registry.ts | 2 - .../swingset/src/stories/oauth-consent.mdx | 24 +- .../src/stories/oauth-consent.stories.tsx | 78 +++++- 10 files changed, 260 insertions(+), 180 deletions(-) diff --git a/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.feature.test.tsx b/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.feature.test.tsx index 665fb20affd..ab61229caf8 100644 --- a/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.feature.test.tsx +++ b/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.feature.test.tsx @@ -431,22 +431,22 @@ describe('OAuthConsent', () => { describe('availability', () => { it('renders nothing when signed out', async () => { visitConsent(); - const { fapi } = await renderConsent({ oauthConsent: consent() }, { fallback:

    Loading

    }); + const { fapi } = await renderConsent({ oauthConsent: consent() }); - await waitFor(() => expect(screen.queryByText('Loading')).toBeNull()); + await waitFor(() => expect(screen.queryByRole('status')).toBeNull()); expect(screen.queryByRole('heading')).toBeNull(); expect(fapi.oauthConsentRequests).toEqual([]); }); - it('shows the fallback while consent loads', async () => { + it('shows a loading card while consent loads', async () => { visitConsent(); const held = holdRequests('get', CONSENT_PATH); - await renderConsent(signedIn(), { fallback:

    Loading

    }); + await renderConsent(); - expect(await screen.findByText('Loading')).toBeVisible(); + expect(await screen.findByRole('status')).toHaveTextContent('Loading consent information'); held.release(); await ready(); - expect(screen.queryByText('Loading')).toBeNull(); + await waitFor(() => expect(screen.queryByRole('status')).toBeNull()); }); it('explains a missing client ID without asking the Frontend API', async () => { diff --git a/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.view.test.tsx b/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.view.test.tsx index bc651e63f72..4d85e5434b6 100644 --- a/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.view.test.tsx +++ b/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.view.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; +import { Card } from '../../../components/card'; import { MosaicProvider } from '../../../mosaic-provider'; import { oauthConsentMessages } from '../oauth-consent.messages'; import type { OAuthConsentViewProps } from '../oauth-consent.types'; @@ -29,7 +30,6 @@ function propsFor(overrides: Partial = {}): OAuthConsentV onRedirectUrlOpenChange: vi.fn(), actionUrl: '#', forwardedParams: [], - renderBranding: false, ...overrides, }; } @@ -37,7 +37,9 @@ function propsFor(overrides: Partial = {}): OAuthConsentV function renderView(overrides: Partial = {}) { render( - + + + , ); return userEvent.setup(); diff --git a/packages/mosaic/src/features/oauth-consent/oauth-consent.messages.ts b/packages/mosaic/src/features/oauth-consent/oauth-consent.messages.ts index 5fd84aac1b2..b6e154b81af 100644 --- a/packages/mosaic/src/features/oauth-consent/oauth-consent.messages.ts +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.messages.ts @@ -15,5 +15,6 @@ export const oauthConsentMessages = { deny: 'Deny', denying: 'Denying access', signedInAs: 'Signed in as {identifier}', + loading: 'Loading consent information', loadError: 'Could not load consent information.', } as const; diff --git a/packages/mosaic/src/features/oauth-consent/oauth-consent.styles.ts b/packages/mosaic/src/features/oauth-consent/oauth-consent.styles.ts index de0eb3d4c72..bd44f9681e7 100644 --- a/packages/mosaic/src/features/oauth-consent/oauth-consent.styles.ts +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.styles.ts @@ -33,4 +33,5 @@ export const styles = stylex.create({ actions: { gap: space['3'], display: 'flex' }, inlineLink: { fontSize: 'inherit', lineHeight: 'inherit', verticalAlign: 'baseline' }, nowrap: { whiteSpace: 'nowrap' }, + pending: { alignItems: 'center', display: 'flex', justifyContent: 'center', minBlockSize: '20rem' }, }); diff --git a/packages/mosaic/src/features/oauth-consent/oauth-consent.tsx b/packages/mosaic/src/features/oauth-consent/oauth-consent.tsx index e5b9b2776c1..ed90054a719 100644 --- a/packages/mosaic/src/features/oauth-consent/oauth-consent.tsx +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.tsx @@ -1,23 +1,22 @@ 'use client'; -import type { ReactElement, ReactNode } from 'react'; +import type { ReactElement } from 'react'; +import { Card } from '../../components/card'; +import { Flow } from '../../components/flow'; import { useErrorText, useMessages } from '../../localization'; import { useOAuthConsentController } from './oauth-consent.controller'; import type { OAuthConsentModelOptions } from './oauth-consent.model'; import { useOAuthConsentModel } from './oauth-consent.model'; import type { OAuthConsentMessages, OAuthConsentReadyModel } from './oauth-consent.types'; -import { OAuthConsentErrorView, OAuthConsentView } from './oauth-consent.view'; +import { OAuthConsentError, OAuthConsentPending, OAuthConsentView } from './oauth-consent.view'; -export interface OAuthConsentProps extends OAuthConsentModelOptions { - /** Rendered while Clerk and the consent details load. */ - fallback?: ReactNode; -} +export type OAuthConsentProps = OAuthConsentModelOptions; /** * Asks the signed-in user whether an OAuth application may access their account. It reads the client, * scopes and redirect URI from the `client_id`, `scope` and `redirect_uri` URL parameters unless props - * set them, and posts the answer to Clerk with every URL parameter forwarded. It renders `fallback` + * set them, and posts the answer to Clerk with every URL parameter forwarded. It shows a loading card * until the consent details load, and nothing at all when nobody is signed in. * * @example @@ -33,29 +32,45 @@ export interface OAuthConsentProps extends OAuthConsentModelOptions { * oauthClientId='client_123' * scope='openid profile email' * redirectUrl='https://app.example.com/oauth/callback' - * fallback={} * /> * ``` */ -export function OAuthConsent({ fallback, ...options }: OAuthConsentProps): ReactElement | null { +export function OAuthConsent(options: OAuthConsentProps): ReactElement | null { const messages = useMessages('oauthConsent'); const errorText = useErrorText(); const model = useOAuthConsentModel(options); - if (model.status === 'loading') { - return <>{fallback ?? null}; - } if (model.status === 'hidden') { return null; } - if (model.status === 'error') { - return ; - } return ( - + + + {current => ( + <> + + + + + {current.status === 'ready' ? ( + + ) : null} + + + {current.status === 'error' ? ( + + ) : null} + + + )} + + ); } diff --git a/packages/mosaic/src/features/oauth-consent/oauth-consent.types.ts b/packages/mosaic/src/features/oauth-consent/oauth-consent.types.ts index 1e3aaff4ad2..15b4a955ae3 100644 --- a/packages/mosaic/src/features/oauth-consent/oauth-consent.types.ts +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.types.ts @@ -62,5 +62,4 @@ export interface OAuthConsentViewProps { onDeny: () => void; actionUrl: string; forwardedParams: [string, string][]; - renderBranding: boolean; } diff --git a/packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx b/packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx index a4446ef4d78..ab310895d2e 100644 --- a/packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx @@ -11,8 +11,10 @@ import { Field } from '../../components/field'; import { Input } from '../../components/input'; import { scrollAreaViewport } from '../../components/scroll-area'; import { Select } from '../../components/select'; +import { Spinner } from '../../components/spinner'; import { Text } from '../../components/text'; import { Tooltip } from '../../components/tooltip'; +import { VisuallyHidden } from '../../components/visually-hidden'; import { fill, rich } from '../../localization'; import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../../styles/reset.styles'; @@ -33,8 +35,16 @@ export function OAuthConsentView(props: OAuthConsentViewProps) { return ( <> - + {fill(m.title, { oauthApplicationName, applicationName: props.applicationName })} + + {rich(m.subtitle, { + values: { oauthApplicationName, domain: props.redirectDomain }, + components: { nowrap: renderNowrap }, + })} + + + } > - - {fill(m.title, { oauthApplicationName, applicationName: props.applicationName })} - - {rich(m.subtitle, { - values: { oauthApplicationName, domain: props.redirectDomain }, - components: { nowrap: renderNowrap }, - })} - - - - {props.organizations.length > 0 ? ( - - {m.organizationLabel} - { - if (typeof value === 'string') { - props.onOrganizationChange(value); - } - }} - > - - - - - ) : null} - {props.scopes.length > 0 ? ( -
    - - {fill(m.scopeListTitle, { applicationName: oauthApplicationName })} - -
      - {props.scopes.map(item => ( -
    • - {props.scopeSelection ? ( - - ) : ( - - )} -
    • - ))} -
    -
    - ) : null} - {props.forwardedParams.map(([name, value]) => ( - - ))} - {organizationId !== null && props.organizations.length > 0 ? ( - - ) : null} -
    - 0 ? ( + + {m.organizationLabel} + { + if (typeof value === 'string') { + props.onOrganizationChange(value); + } + }} > - {m.deny} - - + + + + ) : null} + {props.scopes.length > 0 ? ( +
    + - {m.allow} - + {fill(m.scopeListTitle, { applicationName: oauthApplicationName })} + +
      + {props.scopes.map(item => ( +
    • + {props.scopeSelection ? ( + + ) : ( + + )} +
    • + ))} +
    - ( + + ))} + {organizationId !== null && props.organizations.length > 0 ? ( + + ) : null} +
    + - {rich(m.redirectNotice, { values: { domain } })} - {props.hasOfflineAccess ? ` ${m.offlineAccessNotice}` : null} - - - - + - {fill(m.signedInAs, { identifier: props.identifier })} - - - + {m.allow} + +
    + + {rich(m.redirectNotice, { values: { domain } })} + {props.hasOfflineAccess ? ` ${m.offlineAccessNotice}` : null} + + + + + {fill(m.signedInAs, { identifier: props.identifier })} + + + + + {label} + + + ); } -export function OAuthConsentErrorView({ message }: OAuthConsentErrorViewProps) { +export function OAuthConsentError({ message }: { message: string }) { return ( - - - - - {message} - - - + + + + {message} + + ); } diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 589009fbc20..4eec0b26274 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -152,7 +152,6 @@ import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../s import { meta as menuMeta } from '../stories/menu.stories'; import { Default as OAuthConsentDefault, - LoadError as OAuthConsentLoadError, ManyScopes as OAuthConsentManyScopes, meta as oauthConsentMeta, OfflineAccess as OAuthConsentOfflineAccess, @@ -935,7 +934,6 @@ const oauthConsentModule: StoryModule = { ScopeList: OAuthConsentScopeList, ManyScopes: OAuthConsentManyScopes, OfflineAccess: OAuthConsentOfflineAccess, - LoadError: OAuthConsentLoadError, }; const reverificationModule: StoryModule = { diff --git a/packages/swingset/src/stories/oauth-consent.mdx b/packages/swingset/src/stories/oauth-consent.mdx index f7dcf03cdbd..3e73db9ea8c 100644 --- a/packages/swingset/src/stories/oauth-consent.mdx +++ b/packages/swingset/src/stories/oauth-consent.mdx @@ -2,15 +2,20 @@ import * as Stories from './oauth-consent.stories'; # OAuthConsent -Asks the signed-in user whether an OAuth application may access their account. `OAuthConsent` reads the client, scopes and redirect URI from the `client_id`, `scope` and `redirect_uri` URL parameters unless props set them, and posts the answer to Clerk with every URL parameter forwarded. When the application asks for `user:org:read`, the user picks which organization to grant. Each scope can show a checkbox with a label and a description; a required scope stays checked. It renders `fallback` until the consent details load, and nothing when nobody is signed in. +Asks the signed-in user whether an OAuth application may access their account. `OAuthConsent` reads the client, scopes and redirect URI from the `client_id`, `scope` and `redirect_uri` URL parameters unless props set them, and posts the answer to Clerk with every URL parameter forwarded. When the application asks for `user:org:read`, the user picks which organization to grant. Each scope can show a checkbox with a label and a description; a required scope stays checked. It shows a loading card until the consent details load, and nothing when nobody is signed in. ## Example +Switch between loading, the consent screen and an error to see the card animate between them. + } />; +; ``` ## Props -| Prop | Type | Default | -| --------------- | ----------- | ---------------------------- | -| `oauthClientId` | `string` | The `client_id` parameter | -| `scope` | `string` | The `scope` parameter | -| `redirectUrl` | `string` | The `redirect_uri` parameter | -| `fallback` | `ReactNode` | `null` | +| Prop | Type | Default | +| --------------- | -------- | ---------------------------- | +| `oauthClientId` | `string` | The `client_id` parameter | +| `scope` | `string` | The `scope` parameter | +| `redirectUrl` | `string` | The `redirect_uri` parameter | ## Examples @@ -59,7 +63,3 @@ A long scope list scrolls inside its box, with the edges fading while there is m Requesting `offline_access` hides that scope from the list and adds a note that the user stays signed in. - -### Error - - diff --git a/packages/swingset/src/stories/oauth-consent.stories.tsx b/packages/swingset/src/stories/oauth-consent.stories.tsx index 41df4430e3a..85cb3276c73 100644 --- a/packages/swingset/src/stories/oauth-consent.stories.tsx +++ b/packages/swingset/src/stories/oauth-consent.stories.tsx @@ -1,4 +1,12 @@ -import { OAuthConsentErrorView, OAuthConsentView } from '@clerk/mosaic/features/oauth-consent/oauth-consent.view'; +import { Card } from '@clerk/mosaic/components/card'; +import { Flow } from '@clerk/mosaic/components/flow'; +import { SegmentedControl } from '@clerk/mosaic/components/segmented-control'; +import { + OAuthConsentError, + OAuthConsentPending, + OAuthConsentView, +} from '@clerk/mosaic/features/oauth-consent/oauth-consent.view'; +import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -14,6 +22,13 @@ export const meta: StoryMeta = { source: 'packages/mosaic/src/features/oauth-consent/oauth-consent.tsx', }; +const statuses = ['loading', 'ready', 'error'] as const; +type Status = (typeof statuses)[number]; + +function isStatus(value: unknown): value is Status { + return statuses.some(status => status === value); +} + export function Default() { const props = useOAuthConsentScopeSelectionFixture({ organizations: [ @@ -22,24 +37,69 @@ export function Default() { ], defaultOrganizationId: 'org_1', }); - return ; + const [status, setStatus] = useState('ready'); + return ( +
    + { + if (isStatus(value)) { + setStatus(value); + } + }} + > + Loading + Consent + Error + + + + {state => ( + <> + + + + + + + + + + + )} + + +
    + ); } export function ScopeList() { const props = useOAuthConsentFixture(); - return ; + return ( + + + + ); } export function ManyScopes() { const props = useOAuthConsentScopeSelectionFixture({}, manyScopes); - return ; + return ( + + + + ); } export function OfflineAccess() { const props = useOAuthConsentScopeSelectionFixture({ hasOfflineAccess: true }); - return ; -} - -export function LoadError() { - return ; + return ( + + + + ); }