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 de3252a940b..e872a5a935b 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, @@ -84,6 +85,8 @@ export interface FakeFapiState { mfa: FakeMfaState; activeDevices?: ActiveDeviceRecord[]; deviceTrackingEnabled: boolean; + oauthConsent?: OAuthConsentInfoJSON; + oauthConsentRequests: URL[]; } export interface ActiveDeviceRecord extends SessionWithActivitiesJSON { @@ -321,6 +324,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { waitlistEntries: [], enterpriseConnections: [], deviceTrackingEnabled: true, + oauthConsentRequests: [], mfa: createMfaState(), ...rest, verification: createVerificationState(verification), @@ -948,6 +952,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..2d0851e25ad --- /dev/null +++ b/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.feature.test.tsx @@ -0,0 +1,487 @@ +import type { OAuthConsentInfoJSON } from '@clerk/shared/types'; +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'; + +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'); + }); + + 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', () => { + 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('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)); + + 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() }); + + await waitFor(() => expect(screen.queryByRole('status')).toBeNull()); + expect(screen.queryByRole('heading')).toBeNull(); + expect(fapi.oauthConsentRequests).toEqual([]); + }); + + it('shows a skeleton while consent loads', async () => { + visitConsent(); + const held = holdRequests('get', CONSENT_PATH); + await renderConsent(); + + expect(await screen.findByRole('status')).toHaveTextContent('Loading consent information'); + expect(screen.getByRole('button', { name: 'Allow' })).toBeDisabled(); + held.release(); + await ready(); + expect(screen.getByRole('status')).toHaveTextContent('Consent information loaded'); + }); + + 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..2941d8d3491 --- /dev/null +++ b/packages/mosaic/src/features/oauth-consent/__tests__/oauth-consent.view.test.tsx @@ -0,0 +1,176 @@ +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 { OAuthConsentOrganizationSelection, 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, + onAllow: vi.fn(), + onDeny: vi.fn(), + onRedirectUrlOpenChange: vi.fn(), + actionUrl: '#', + forwardedParams: [], + ...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('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(); + + expect(screen.queryByRole('checkbox')).toBeNull(); + expect(screen.getByText('Read your projects')).toBeVisible(); + }); + }); + + describe('organization selection', () => { + const organizations = [ + { value: 'org_1', label: 'My workspace' }, + { value: 'org_2', label: 'Globex' }, + ]; + + function selection(overrides: Partial = {}): OAuthConsentOrganizationSelection { + return { access: 'all', onAccessChange: vi.fn(), selected: [], onSelectedChange: vi.fn(), ...overrides }; + } + + function postedOrganizationIds() { + return [...document.querySelectorAll('input[name="organization_id"]')].map( + input => input.value, + ); + } + + it('grants every organization without a picker when all organizations is chosen', () => { + renderView({ organizations, organizationSelection: selection({ access: 'all' }) }); + + expect(screen.getByRole('radio', { name: 'All organizations' })).toBeChecked(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + expect(postedOrganizationIds()).toEqual(['org_1', 'org_2']); + }); + + it('reports a switch to only selected organizations', async () => { + const onAccessChange = vi.fn(); + const user = renderView({ organizations, organizationSelection: selection({ onAccessChange }) }); + + await user.click(screen.getByRole('radio', { name: 'Only selected organizations' })); + + expect(onAccessChange).toHaveBeenCalledWith('selected'); + }); + + it('clears the picked organizations when switching back to all organizations', async () => { + const onAccessChange = vi.fn(); + const onSelectedChange = vi.fn(); + const user = renderView({ + organizations, + organizationSelection: selection({ access: 'selected', selected: ['org_2'], onAccessChange, onSelectedChange }), + }); + + await user.click(screen.getByRole('radio', { name: 'All organizations' })); + + expect(onAccessChange).toHaveBeenCalledWith('all'); + expect(onSelectedChange).toHaveBeenCalledWith([]); + }); + + it('shows a placeholder while no organization is picked', () => { + renderView({ organizations, organizationSelection: selection({ access: 'selected', selected: [] }) }); + + expect(screen.getByRole('combobox')).toHaveTextContent('Select organizations'); + }); + + it('reports every organization the user picks', async () => { + const onSelectedChange = vi.fn(); + const user = renderView({ + organizations, + organizationSelection: selection({ access: 'selected', selected: ['org_1'], onSelectedChange }), + }); + + await user.click(screen.getByRole('combobox')); + await user.click(screen.getByRole('option', { name: 'Globex' })); + + expect(onSelectedChange).toHaveBeenCalledWith(['org_1', 'org_2']); + }); + + it('posts only the selected organizations', () => { + renderView({ organizations, organizationSelection: selection({ access: 'selected', selected: ['org_2'] }) }); + + expect(postedOrganizationIds()).toEqual(['org_2']); + }); + + it('names a single selected organization in the picker', () => { + renderView({ organizations, organizationSelection: selection({ access: 'selected', selected: ['org_2'] }) }); + + expect(screen.getByRole('combobox')).toHaveTextContent('Globex'); + }); + + it('counts several selected organizations in the picker', () => { + renderView({ + organizations, + organizationSelection: selection({ access: 'selected', selected: ['org_1', 'org_2'] }), + }); + + expect(screen.getByRole('combobox')).toHaveTextContent('2 organizations'); + }); + }); +}); 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..f89e6414ef6 --- /dev/null +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.controller.ts @@ -0,0 +1,44 @@ +import { useEffect, 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); + 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, + 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, + 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 new file mode 100644 index 00000000000..e0946b3ff56 --- /dev/null +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.messages.ts @@ -0,0 +1,27 @@ +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:', + allOrganizations: 'All organizations', + allOrganizationsDescription: 'Applies to every organization you belong to.', + selectedOrganizations: 'Only selected organizations', + selectedOrganizationsDescription: 'Choose which organizations to grant access to.', + selectOrganizations: 'Select organizations', + organizationCount: { one: '{count} organization', other: '{count} organizations' }, + 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', + allowing: 'Allowing access', + deny: 'Deny', + denying: 'Denying access', + signedInAs: 'Signed in as {identifier}', + loading: 'Loading consent information', + loaded: 'Consent information loaded', + 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..af023eba616 --- /dev/null +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.model.ts @@ -0,0 +1,90 @@ +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 { OAuthConsentKnownData, 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) }; + } + const { user } = session; + const memberships = user.organizationMemberships.map(membership => ({ + value: membership.organization.id, + label: membership.organization.name, + })); + const organizationsFor = (scopes: string[]) => + environment.organizationSettings.enabled && scopes.includes(USER_ORG_READ_SCOPE) ? memberships : []; + const defaultOrganizationIdIn = (organizations: typeof memberships) => + (organizations.find(organization => organization.value === session.lastActiveOrganizationId) ?? organizations[0]) + ?.value ?? null; + const requestedOrganizations = organizationsFor(scope?.split(' ') ?? []); + const known: OAuthConsentKnownData = { + applicationName: environment.displayConfig.applicationName, + identifier: user.primaryEmailAddress?.emailAddress || user.primaryPhoneNumber?.phoneNumber || '', + redirectUrl, + redirectDomain: getRedirectDisplay(redirectUrl), + actionUrl: clerk.oauthApplication.buildConsentActionUrl({ clientId: oauthClientId }), + forwardedParams: [...search.entries()], + renderBranding: environment.displayConfig.branded, + organizations: requestedOrganizations, + defaultOrganizationId: defaultOrganizationIdIn(requestedOrganizations), + }; + if (isLoading || !data) { + return { status: 'loading', known }; + } + + const requested = data.scopes.map(({ scope: name, description }) => ({ scope: name, description })); + const organizations = organizationsFor(requested.map(item => item.scope)); + + return { + status: 'ready', + ...known, + oauthApplicationName: data.oauthApplicationName, + scopes: requested.filter(item => item.scope !== OFFLINE_ACCESS_SCOPE), + hasOfflineAccess: requested.some(item => item.scope === OFFLINE_ACCESS_SCOPE), + organizations, + defaultOrganizationId: defaultOrganizationIdIn(organizations), + redirectDomain: data.redirectDomain ?? known.redirectDomain, + }; +} diff --git a/packages/mosaic/src/features/oauth-consent/oauth-consent.skeleton.tsx b/packages/mosaic/src/features/oauth-consent/oauth-consent.skeleton.tsx new file mode 100644 index 00000000000..ad4a7f051e6 --- /dev/null +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.skeleton.tsx @@ -0,0 +1,135 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Button } from '../../components/button'; +import { Card } from '../../components/card'; +import { Field } from '../../components/field'; +import { Select } from '../../components/select'; +import { Skeleton } from '../../components/skeleton'; +import { Text } from '../../components/text'; +import { VisuallyHidden } from '../../components/visually-hidden'; +import { fill } from '../../localization'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../styles/reset.styles'; +import { styles } from './oauth-consent.styles'; +import type { OAuthConsentKnownData, OAuthConsentMessages } from './oauth-consent.types'; + +const PLACEHOLDER_OAUTH_APPLICATION_NAME = 'App'; +const PLACEHOLDER_SCOPES = ['View your email address', 'View your profile', 'Verify your identity']; +const PLACEHOLDER_KNOWN: OAuthConsentKnownData = { + applicationName: 'Application', + identifier: 'name@example.com', + redirectUrl: 'https://example.com/oauth/callback', + redirectDomain: 'example.com', + actionUrl: '', + forwardedParams: [], + renderBranding: false, + organizations: [], + defaultOrganizationId: null, +}; + +export function OAuthConsentSkeleton({ + known, + messages: m, +}: { + known: OAuthConsentKnownData | undefined; + messages: OAuthConsentMessages; +}) { + const { applicationName, identifier, redirectDomain, organizations, defaultOrganizationId } = + known ?? PLACEHOLDER_KNOWN; + const awaitingClerk = known === undefined; + + return ( + <> + {m.loading} + + + + {fill(m.title, { oauthApplicationName: PLACEHOLDER_OAUTH_APPLICATION_NAME, applicationName })} + + + + + {fill(m.subtitle, { oauthApplicationName: PLACEHOLDER_OAUTH_APPLICATION_NAME, domain: redirectDomain })} + + + + + {organizations.length > 0 ? ( + + {m.organizationLabel} + + + + + + + ) : null} +
+ + + {fill(m.scopeListTitle, { applicationName: PLACEHOLDER_OAUTH_APPLICATION_NAME })} + + +
    + {PLACEHOLDER_SCOPES.map(label => ( +
  • +
    + + {label} + +
    +
  • + ))} +
+
+
+ + +
+ + {fill(m.redirectNotice, { domain: redirectDomain })} + +
+ + + + {fill(m.signedInAs, { identifier })} + + + + + ); +} 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..f3f00749953 --- /dev/null +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.styles.ts @@ -0,0 +1,59 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, durationVars, easingVars, fontWeightVars, radiusVars, space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + skeletonFill: { width: '100%' }, + skeletonCentered: { marginInline: 'auto' }, + organizationAccess: { gap: space['3'], display: 'flex', flexDirection: 'column' }, + organizationPicker: { + display: 'grid', + gridTemplateRows: { + default: '1fr', + ':where(:not([data-open]), [data-starting-style])': '0fr', + }, + marginBlockStart: `calc(${space['3']} * -1)`, + transitionDuration: durationVars['--cl-duration-slow'], + transitionProperty: { + default: 'grid-template-rows', + '@media (prefers-reduced-motion: reduce)': 'none', + }, + transitionTimingFunction: { + default: easingVars['--cl-ease-enter'], + ':where([data-ending-style])': easingVars['--cl-ease-in-out'], + }, + }, + organizationPickerClip: { overflow: 'clip', minHeight: 0 }, + organizationPickerTrigger: { inlineSize: '100%', marginBlockStart: space['3'] }, + organizationAccessLabel: { fontWeight: fontWeightVars['--cl-font-medium'] }, + 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-surface-subtle'], + borderBlockEndColor: colorVars['--cl-color-border'], + borderBlockEndStyle: 'solid', + borderBlockEndWidth: '1px', + fontWeight: fontWeightVars['--cl-font-medium'], + }, + scopeList: { listStyleType: 'none', maxBlockSize: '20rem' }, + 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: { borderWidth: 0, 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..f4cfe871fce --- /dev/null +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.tsx @@ -0,0 +1,89 @@ +'use client'; + +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 { OAuthConsentSkeleton } from './oauth-consent.skeleton'; +import type { OAuthConsentMessages, OAuthConsentReadyModel } from './oauth-consent.types'; +import { OAuthConsentError, OAuthConsentView } from './oauth-consent.view'; + +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 shows a skeleton of + * the consent card 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(options: OAuthConsentProps): ReactElement | null { + const messages = useMessages('oauthConsent'); + const errorText = useErrorText(); + const model = useOAuthConsentModel(options); + + if (model.status === 'hidden') { + return null; + } + return ( + + + {current => ( + <> + + + + + {current.status === 'ready' ? ( + + ) : null} + + + {current.status === 'error' ? ( + + ) : null} + + + )} + + + ); +} + +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..e2e78c403e1 --- /dev/null +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.types.ts @@ -0,0 +1,78 @@ +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 OAuthConsentKnownData { + applicationName: string; + identifier: string; + redirectUrl: string; + redirectDomain: string; + actionUrl: string; + forwardedParams: [string, string][]; + renderBranding: boolean; + organizations: SelectItem[]; + defaultOrganizationId: string | null; +} + +export interface OAuthConsentData extends OAuthConsentKnownData { + oauthApplicationName: string; + scopes: OAuthConsentScopeRecord[]; + hasOfflineAccess: boolean; +} + +export type OAuthConsentReadyModel = OAuthConsentData & { status: 'ready' }; + +export type OAuthConsentModel = + | { status: 'loading'; known?: OAuthConsentKnownData } + | { 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 type OAuthConsentOrganizationAccess = 'all' | 'selected'; + +export interface OAuthConsentOrganizationSelection { + access: OAuthConsentOrganizationAccess; + onAccessChange: (access: OAuthConsentOrganizationAccess) => void; + selected: readonly string[]; + onSelectedChange: (organizationIds: string[]) => 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; + organizationSelection?: OAuthConsentOrganizationSelection; + redirectUrl: string; + redirectDomain: string; + isRedirectUrlOpen: boolean; + onRedirectUrlOpenChange: (open: boolean) => void; + pendingAction?: 'allow' | 'deny'; + onAllow: () => void; + onDeny: () => void; + actionUrl: string; + forwardedParams: [string, string][]; +} 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..7683121ca8c --- /dev/null +++ b/packages/mosaic/src/features/oauth-consent/oauth-consent.view.tsx @@ -0,0 +1,465 @@ +import { inertProps } from '@clerk/shared/inert'; +import * as stylex from '@stylexjs/stylex'; +import { type ReactNode, useId, useRef, useState } from 'react'; + +import { Badge } from '../../components/badge'; +import { Button, SubmitButton } 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 { Radio } from '../../components/radio'; +import { scrollAreaViewport } from '../../components/scroll-area'; +import { Select, type SelectItem } from '../../components/select'; +import { Text } from '../../components/text'; +import { Tooltip } from '../../components/tooltip'; +import { VisuallyHidden } from '../../components/visually-hidden'; +import { ExclamationCircle } from '../../icons/glyphs/exclamation-circle'; +import { fill, plural, rich, useLocale } from '../../localization'; +import { Form } from '../../primitives/form'; +import { useTransition } from '../../primitives/hooks'; +import { Freeze } from '../../primitives/utils'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../styles/reset.styles'; +import { RowAvatar } from '../switcher/switcher-avatar.view'; +import { styles } from './oauth-consent.styles'; +import type { + OAuthConsentOrganizationSelection, + OAuthConsentScopeItem, + OAuthConsentScopeSelection, + OAuthConsentViewProps, +} from './oauth-consent.types'; + +export function OAuthConsentView(props: OAuthConsentViewProps) { + const { messages: m, oauthApplicationName, organizationId } = props; + const scopeListTitleId = useId(); + const postedOrganizationIds = props.organizations.length === 0 ? [] : organizationIdsToPost(props); + const openRedirectUrl = () => props.onRedirectUrlOpenChange(true); + const domain = ( + + ); + + return ( + <> + {m.loaded} + + {fill(m.title, { oauthApplicationName, applicationName: props.applicationName })} + + {rich(m.subtitle, { + values: { oauthApplicationName, domain: props.redirectDomain }, + components: { nowrap: renderNowrap }, + })} + + + + } + > + {props.organizations.length > 0 && props.organizationSelection ? ( + + ) : null} + {props.organizations.length > 0 && !props.organizationSelection ? ( + + {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]) => ( + + ))} + {postedOrganizationIds.map(id => ( + + ))} +
+ + {m.deny} + + + {m.allow} + +
+ + {rich(m.redirectNotice, { + values: { domain }, + })} + {props.hasOfflineAccess ? ` ${m.offlineAccessNotice}` : null} + +
+ + + {fill(m.signedInAs, { identifier: props.identifier })} + + + + + ); +} + +function RedirectUrlDialog({ + messages: m, + oauthApplicationName, + redirectUrl, + open, + onOpenChange, +}: { + messages: OAuthConsentViewProps['messages']; + oauthApplicationName: string; + redirectUrl: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + return ( + + + + + {m.redirectUrlTitle} + + {fill(m.redirectUrlDescription, { applicationName: oauthApplicationName })} + + + + + + + + + ); +} + +function organizationIdsToPost({ organizations, organizationId, organizationSelection }: OAuthConsentViewProps) { + if (!organizationSelection) { + return organizationId === null ? [] : [organizationId]; + } + return organizationSelection.access === 'all' + ? organizations.map(organization => organization.value) + : organizationSelection.selected; +} + +function OrganizationAccess({ + messages: m, + organizations, + selection, + disabled, +}: { + messages: OAuthConsentViewProps['messages']; + organizations: SelectItem[]; + selection: OAuthConsentOrganizationSelection; + disabled: boolean; +}) { + const labelId = useId(); + const locale = useLocale(); + const summarize = (value: string[]) => + value.length === 1 + ? organizations.find(organization => organization.value === value[0])?.label + : plural(m.organizationCount, value.length, locale); + + return ( +
+ + {m.organizationLabel} + + { + if (value === 'selected') { + selection.onAccessChange('selected'); + return; + } + selection.onAccessChange('all'); + selection.onSelectedChange([]); + }} + > + + + + {m.allOrganizations} + {m.allOrganizationsDescription} + + + + + + {m.selectedOrganizations} + {m.selectedOrganizationsDescription} + + + + + + + {summarize} + + + + +
+ ); +} + +function OrganizationPicker({ open, children }: { open: boolean; children: ReactNode }) { + const element = useRef(null); + const { mounted, transitionProps } = useTransition({ open, ref: element }); + const [hasBeenClosed, setHasBeenClosed] = useState(!open); + if (!open && !hasBeenClosed) { + setHasBeenClosed(true); + } + const effectiveTransitionProps = hasBeenClosed + ? transitionProps + : { ...transitionProps, 'data-starting-style': undefined, style: undefined }; + + return ( +
+
+ {mounted ? {children} : null} +
+
+ ); +} + +function OrganizationPopup({ organizations }: { organizations: SelectItem[] }) { + return ( + + {organizations.map(organization => ( + + + + + + {organization.label} + + + ))} + + ); +} + +function ScopeOption({ + item, + selection, + requiredLabel, + disabled, +}: { + item: OAuthConsentScopeItem; + selection: OAuthConsentScopeSelection; + requiredLabel: string; + disabled: boolean; +}) { + 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 function OAuthConsentError({ message }: { message: string }) { + return ( + + + + {message} + + + ); +} diff --git a/packages/mosaic/src/localization/__tests__/messages.test.tsx b/packages/mosaic/src/localization/__tests__/messages.test.tsx index ea37141256c..c73f5d7a0c4 100644 --- a/packages/mosaic/src/localization/__tests__/messages.test.tsx +++ b/packages/mosaic/src/localization/__tests__/messages.test.tsx @@ -19,6 +19,12 @@ describe('fill', () => { it('ignores inherited object properties', () => { expect(fill('{constructor}{toString}', {})).toBe('{constructor}{toString}'); }); + + it('drops tag markers and keeps the text inside them', () => { + expect(fill('Trust {name} {#nowrap}({domain}){/nowrap}.{#br/}', { name: 'Acme', domain: 'acme.com' })).toBe( + 'Trust Acme (acme.com).', + ); + }); }); describe('plural', () => { diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts index 1522b27442f..8ea1d2fa8e3 100644 --- a/packages/mosaic/src/localization/errors.messages.ts +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -55,6 +55,8 @@ export const errorMessages: { readonly generic: string } & Readonly(record: Partial> | undefined, key: string): T export function fill(template: T, values: Values): string; export function fill(template: string, values: Partial): string { - return template.replace(/\{(\w+)\}/g, (match, key: string) => String(own(values, key) ?? match)); + return template + .replace(/\{(?:#\w+\/?|\/\w+)\}/g, '') + .replace(/\{(\w+)\}/g, (match, key: string) => String(own(values, key) ?? match)); } export function plural( diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index c6c67d3600d..709a508aff4 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -3,6 +3,7 @@ import { profileMessages } from '../components/profile/profile.messages'; import { apiKeysTableMessages } from '../features/api-keys/api-keys-table.messages'; import { createOrganizationMessages } from '../features/create-organization/create-organization.messages'; import { inviteMembersButtonMessages } from '../features/invite-members-button/invite-members-button.messages'; +import { oauthConsentMessages } from '../features/oauth-consent/oauth-consent.messages'; import { organizationListMessages } from '../features/organization-list/organization-list.messages'; import { organizationProfileMessages } from '../features/organization-profile/organization-profile.messages'; import { organizationProfileDangerSectionMessages } from '../features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.messages'; @@ -76,6 +77,7 @@ export const mosaicMessages = { apiKeys: apiKeysTableMessages, createOrganization: createOrganizationMessages, organizationList: organizationListMessages, + oauthConsent: oauthConsentMessages, reverification: reverificationMessages, profile: profileMessages, form: formMessages, diff --git a/packages/ui/src/components/OAuthConsent/__tests__/utils.test.ts b/packages/shared/src/internal/clerk-js/__tests__/oauthConsent.test.ts similarity index 97% rename from packages/ui/src/components/OAuthConsent/__tests__/utils.test.ts rename to packages/shared/src/internal/clerk-js/__tests__/oauthConsent.test.ts index f399a795ba1..d0e69e3bd51 100644 --- a/packages/ui/src/components/OAuthConsent/__tests__/utils.test.ts +++ b/packages/shared/src/internal/clerk-js/__tests__/oauthConsent.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { getRedirectDisplay } from '../utils'; +import { getRedirectDisplay } from '../oauthConsent'; describe('getRedirectDisplay', () => { 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 8da091501ad..c1a1b704c59 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -58,6 +58,7 @@ const docModules: Record> = { 'user-avatar': dynamic(() => import('../stories/user-avatar.mdx')), 'invite-members-button': dynamic(() => import('../stories/invite-members-button.mdx')), 'api-keys-table': dynamic(() => import('../stories/api-keys-table.mdx')), + 'o-auth-consent': dynamic(() => import('../stories/oauth-consent.mdx')), reverification: dynamic(() => import('../stories/reverification.mdx')), waitlist: dynamic(() => import('../stories/waitlist.mdx')), }, diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 5ef28810665..862dbc65468 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -162,6 +162,14 @@ import { } from '../stories/localization.stories'; import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../stories/menu.component.stories'; import { meta as menuMeta } from '../stories/menu.stories'; +import { + Default as OAuthConsentDefault, + ManyScopes as OAuthConsentManyScopes, + meta as oauthConsentMeta, + MultipleOrganizations as OAuthConsentMultipleOrganizations, + OfflineAccess as OAuthConsentOfflineAccess, + ScopeList as OAuthConsentScopeList, +} from '../stories/oauth-consent.stories'; import { Default as OrganizationListDefault, Empty as OrganizationListEmpty, @@ -1064,6 +1072,15 @@ const apiKeysTableModule: StoryModule = { Empty: APIKeysTableEmpty, }; +const oauthConsentModule: StoryModule = { + meta: oauthConsentMeta, + Default: OAuthConsentDefault, + ScopeList: OAuthConsentScopeList, + ManyScopes: OAuthConsentManyScopes, + MultipleOrganizations: OAuthConsentMultipleOrganizations, + OfflineAccess: OAuthConsentOfflineAccess, +}; + const reverificationModule: StoryModule = { meta: reverificationMeta, Default: ReverificationDefault, @@ -1139,6 +1156,7 @@ export const registry: StoryModule[] = [ userAvatarModule, inviteMembersButtonModule, apiKeysTableModule, + oauthConsentModule, reverificationModule, waitlistModule, // 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..ba07d9afa8c --- /dev/null +++ b/packages/swingset/src/stories/fixtures/oauth-consent.ts @@ -0,0 +1,96 @@ +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 { useEffect, 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 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'); + 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(() => scopes.map(item => item.scope)); + return { + ...props, + scopes, + 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..106fd4547c0 --- /dev/null +++ b/packages/swingset/src/stories/oauth-consent.mdx @@ -0,0 +1,73 @@ +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 shows a skeleton of the consent 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. Loading shows the consent card as a skeleton. + + + +## 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 | + +## Examples + +### Scope list + +Without `scopeSelection`, the scopes render as a list with no checkboxes. This is what `OAuthConsent` renders today. + + + +### Many scopes + +A long scope list scrolls inside its box, with the edges fading while there is more to see. + + + +### Multiple organizations + +With `organizationSelection`, the user grants access to all of their organizations or picks some of them. Picking shows a select of organizations with their avatars, and the select reads as a count once more than one is picked. The form posts one `organization_id` per granted organization. + + + +### 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 new file mode 100644 index 00000000000..ab30604841c --- /dev/null +++ b/packages/swingset/src/stories/oauth-consent.stories.tsx @@ -0,0 +1,136 @@ +import { Card } from '@clerk/mosaic/components/card'; +import { Flow } from '@clerk/mosaic/components/flow'; +import { SegmentedControl } from '@clerk/mosaic/components/segmented-control'; +import { OAuthConsentSkeleton } from '@clerk/mosaic/features/oauth-consent/oauth-consent.skeleton'; +import type { OAuthConsentOrganizationAccess } from '@clerk/mosaic/features/oauth-consent/oauth-consent.types'; +import { OAuthConsentError, OAuthConsentView } from '@clerk/mosaic/features/oauth-consent/oauth-consent.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +import { manyScopes, useOAuthConsentFixture, useOAuthConsentScopeSelectionFixture } from './fixtures/oauth-consent'; + +export { default as __source } from './oauth-consent.stories?raw'; + +export const meta: StoryMeta = { + group: 'Misc', + status: 'wip', + title: 'OAuthConsent', + label: 'OAuth consent', + 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: [ + { value: 'org_1', label: 'My workspace' }, + { value: 'org_2', label: 'Globex' }, + ], + defaultOrganizationId: 'org_1', + }); + const [status, setStatus] = useState('ready'); + return ( +
+ { + if (isStatus(value)) { + setStatus(value); + } + }} + > + Loading + Consent + Error + + + + {state => ( + <> + + + + + + + + + + + )} + + +
+ ); +} + +export function ScopeList() { + const props = useOAuthConsentFixture(); + return ( + + + + ); +} + +export function ManyScopes() { + const props = useOAuthConsentScopeSelectionFixture({}, manyScopes); + return ( + + + + ); +} + +export function MultipleOrganizations() { + const props = useOAuthConsentScopeSelectionFixture({ + organizations: [ + { value: 'org_1', label: 'My workspace' }, + { value: 'org_2', label: 'Globex' }, + { value: 'org_3', label: 'Initech' }, + ], + }); + const [access, setAccess] = useState('all'); + const [selected, setSelected] = useState(['org_1']); + return ( + + + + ); +} + +export function OfflineAccess() { + const props = useOAuthConsentScopeSelectionFixture({ hasOfflineAccess: true }); + 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 '';