From b2de739fd66c556c9d54b208ebabe9aa6d755066 Mon Sep 17 00:00:00 2001 From: Kyle MacDonald Date: Mon, 2 Nov 2026 16:29:43 -0500 Subject: [PATCH 1/2] feat(mosaic): add internal TaskChooseOrganization and Swingset previews --- ...osaic-task-choose-organization-internal.md | 2 + .../mosaic/src/__tests__/feature/fake-fapi.ts | 11 + .../create-organization.controller.ts | 11 +- .../create-organization.tsx | 7 +- .../create-organization.view.tsx | 9 +- .../organization-list.controller.ts | 109 ++++---- .../organization-list.model.ts | 114 ++++---- .../organization-list.view.tsx | 146 ++++++---- .../task-choose-organization.feature.test.tsx | 259 ++++++++++++++++++ .../task-choose-organization.controller.ts | 51 ++++ .../task-choose-organization.messages.ts | 16 ++ .../task-choose-organization.model.ts | 115 ++++++++ .../task-choose-organization.skeleton.tsx | 53 ++++ .../task-choose-organization.styles.ts | 25 ++ .../task-choose-organization.tsx | 121 ++++++++ .../task-choose-organization.types.ts | 18 ++ .../task-choose-organization.view.tsx | 169 ++++++++++++ packages/mosaic/src/localization/registry.ts | 2 + packages/mosaic/src/styles/index.ts | 1 + .../misc/task-choose-organization/page.tsx | 45 +++ .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/live-navigation.ts | 1 + packages/swingset/src/lib/registry.ts | 22 ++ .../src/stories/task-choose-organization.mdx | 125 +++++++++ .../task-choose-organization.stories.tsx | 224 +++++++++++++++ 25 files changed, 1496 insertions(+), 161 deletions(-) create mode 100644 .changeset/mosaic-task-choose-organization-internal.md create mode 100644 packages/mosaic/src/features/task-choose-organization/__tests__/task-choose-organization.feature.test.tsx create mode 100644 packages/mosaic/src/features/task-choose-organization/task-choose-organization.controller.ts create mode 100644 packages/mosaic/src/features/task-choose-organization/task-choose-organization.messages.ts create mode 100644 packages/mosaic/src/features/task-choose-organization/task-choose-organization.model.ts create mode 100644 packages/mosaic/src/features/task-choose-organization/task-choose-organization.skeleton.tsx create mode 100644 packages/mosaic/src/features/task-choose-organization/task-choose-organization.styles.ts create mode 100644 packages/mosaic/src/features/task-choose-organization/task-choose-organization.tsx create mode 100644 packages/mosaic/src/features/task-choose-organization/task-choose-organization.types.ts create mode 100644 packages/mosaic/src/features/task-choose-organization/task-choose-organization.view.tsx create mode 100644 packages/swingset/src/app/(clerk)/live/misc/task-choose-organization/page.tsx create mode 100644 packages/swingset/src/stories/task-choose-organization.mdx create mode 100644 packages/swingset/src/stories/task-choose-organization.stories.tsx diff --git a/.changeset/mosaic-task-choose-organization-internal.md b/.changeset/mosaic-task-choose-organization-internal.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-task-choose-organization-internal.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index de3252a940b..27bca48c005 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -5,6 +5,7 @@ import type { EmailAddressJSON, EnterpriseConnectionJSON, OAuthProvider, + OrganizationCreationDefaultsJSON, OrganizationInvitationJSON, OrganizationJSON, OrganizationMembershipJSON, @@ -84,6 +85,7 @@ export interface FakeFapiState { mfa: FakeMfaState; activeDevices?: ActiveDeviceRecord[]; deviceTrackingEnabled: boolean; + organizationCreationDefaults?: OrganizationCreationDefaultsJSON; } export interface ActiveDeviceRecord extends SessionWithActivitiesJSON { @@ -509,8 +511,14 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { return missing(); } const organizationId = body.get('active_organization_id') || null; + const choosesOrganization = organizationId !== null && session.tasks?.[0]?.key === 'choose-organization'; + const remainingTasks = choosesOrganization ? (session.tasks?.slice(1) ?? []) : session.tasks; const touched = { ...session, + ...(choosesOrganization && { + tasks: remainingTasks?.length ? remainingTasks : null, + status: remainingTasks?.length ? session.status : ('active' as const), + }), last_active_organization_id: organizationId, last_active_token: fapiToken({ sid: session.id, sub: session.user.id, org_id: organizationId }), }; @@ -842,6 +850,9 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { })); return envelope(organization, state.client); }), + http.get(fapiUrl('/v1/me/organization_creation_defaults'), () => + state.organizationCreationDefaults ? envelope(state.organizationCreationDefaults, state.client) : missing(), + ), http.get(fapiUrl('/v1/organizations/:organizationId'), ({ params }) => { const membership = state.memberships.find(m => m.organization.id === params.organizationId); return membership ? envelope(membership.organization, null) : missing(); diff --git a/packages/mosaic/src/features/create-organization/create-organization.controller.ts b/packages/mosaic/src/features/create-organization/create-organization.controller.ts index 93f9ed0030f..5ba588db85c 100644 --- a/packages/mosaic/src/features/create-organization/create-organization.controller.ts +++ b/packages/mosaic/src/features/create-organization/create-organization.controller.ts @@ -16,8 +16,6 @@ import { isSlug, toSlug } from './create-organization.utils'; export type CreateOrganizationReadyModel = Extract; -const initialValues: CreateOrganizationValues = { name: '', slug: '', logo: null }; - export interface CreateOrganizationController { step: CreateOrganizationStep; form: UseFormResult; @@ -26,9 +24,14 @@ export interface CreateOrganizationController { onInvitationsSent: () => void; } +export interface CreateOrganizationControllerOptions { + onRestart?: () => void; + initialValues?: Partial>; +} + export function useCreateOrganizationController( model: CreateOrganizationReadyModel, - { onRestart }: { onRestart?: () => void } = {}, + { onRestart, initialValues }: CreateOrganizationControllerOptions = {}, ): CreateOrganizationController { const m = useMessages('createOrganization'); const [step, setStep] = useState({ step: 'create' }); @@ -39,7 +42,7 @@ export function useCreateOrganizationController( }; const form = useForm({ - initialValues, + initialValues: { name: initialValues?.name ?? '', slug: initialValues?.slug ?? '', logo: null }, fields: { slug: { validate: slug => diff --git a/packages/mosaic/src/features/create-organization/create-organization.tsx b/packages/mosaic/src/features/create-organization/create-organization.tsx index 069e8efb876..dffe8e02009 100644 --- a/packages/mosaic/src/features/create-organization/create-organization.tsx +++ b/packages/mosaic/src/features/create-organization/create-organization.tsx @@ -4,7 +4,10 @@ import type { ReactElement, ReactNode } from 'react'; import { useState } from 'react'; import { useMembersPanelRolesModel } from '../organization-profile/organization-profile-members-panel/organization-profile-members-panel.model'; -import type { CreateOrganizationReadyModel } from './create-organization.controller'; +import type { + CreateOrganizationControllerOptions, + CreateOrganizationReadyModel, +} from './create-organization.controller'; import { useCreateOrganizationController, useCreateOrganizationInviteController, @@ -88,7 +91,7 @@ function ConnectedCreateOrganization({ export function useCreateOrganizationFlow( model: CreateOrganizationReadyModel, - options: { onRestart?: () => void } = {}, + options: CreateOrganizationControllerOptions = {}, ): CreateOrganizationStepsProps { const controller = useCreateOrganizationController(model, options); const organizationId = controller.step.step === 'create' ? null : controller.step.organizationId; diff --git a/packages/mosaic/src/features/create-organization/create-organization.view.tsx b/packages/mosaic/src/features/create-organization/create-organization.view.tsx index 3497d22088f..8a58429c4d3 100644 --- a/packages/mosaic/src/features/create-organization/create-organization.view.tsx +++ b/packages/mosaic/src/features/create-organization/create-organization.view.tsx @@ -3,6 +3,7 @@ import * as stylex from '@stylexjs/stylex'; import { useId, useState } from 'react'; import { Avatar } from '../../components/avatar'; +import { Banner } from '../../components/banner'; import { Button, SubmitButton } from '../../components/button'; import { Card } from '../../components/card'; import { Field } from '../../components/field'; @@ -40,6 +41,7 @@ export interface CreateOrganizationStepsProps { invite: CreateOrganizationInviteController; onFinish: () => void; onCancel?: () => void; + notice?: string; } export interface CreateOrganizationViewProps extends CreateOrganizationStepsProps { @@ -77,7 +79,7 @@ export function CreateOrganizationSteps(props: CreateOrganizationStepsProps) { ); } -function CreateStep({ form, slugEnabled, onNameChange, onCancel }: CreateOrganizationStepsProps) { +function CreateStep({ form, slugEnabled, onNameChange, onCancel, notice }: CreateOrganizationStepsProps) { const m = useMessages('createOrganization'); const { ref: nameRef, onChange: _onChange, ...nameControl } = form.register('name'); const autoFocus = useFlowAutoFocus(); @@ -104,6 +106,11 @@ function CreateStep({ form, slugEnabled, onNameChange, onCancel }: CreateOrganiz /> } > + {notice ? ( + + {notice} + + ) : null} ; -interface OrganizationListMachineContext { +interface OrganizationListMachineContext { pendingKey: string | null; - frozenModel: OrganizationListReadyModel | null; + frozenModel: M | null; run: () => Promise; error: ErrorDescription | undefined; } -type OrganizationListMachineEvent = - | { type: 'RUN'; key: string; frozenModel: OrganizationListReadyModel; run: () => Promise } +type OrganizationListMachineEvent = + | { type: 'RUN'; key: string; frozenModel: M; run: () => Promise } | { type: 'DISMISS' }; -const { createMachine, assign, fromPromise } = setup(); - -const settled = { pendingKey: null, frozenModel: null }; - -const organizationListMachine = createMachine({ - id: 'organizationList', - initial: 'idle', - context: { - pendingKey: null, - frozenModel: null, - run: () => Promise.resolve(), - error: undefined, - }, - states: { - idle: { - on: { - RUN: { - target: 'busy', - actions: assign((_, event) => ({ - pendingKey: event.key, - frozenModel: event.frozenModel, - run: event.run, - error: undefined, - })), - }, - DISMISS: { actions: assign(() => ({ error: undefined })) }, - }, +function createOrganizationListMachine() { + const { createMachine, assign, fromPromise } = setup< + OrganizationListMachineContext, + OrganizationListMachineEvent + >(); + const settled = { pendingKey: null, frozenModel: null }; + + return createMachine({ + id: 'organizationList', + initial: 'idle', + context: { + pendingKey: null, + frozenModel: null, + run: () => Promise.resolve(), + error: undefined, }, - busy: { - invoke: fromPromise(context => context.run(), { - onDone: { target: 'idle', actions: assign(() => settled) }, - onError: { - target: 'idle', - actions: assign((_, event) => ({ ...settled, error: toLocalizableError(event.error) })), + states: { + idle: { + on: { + RUN: { + target: 'busy', + actions: assign((_, event) => ({ + pendingKey: event.key, + frozenModel: event.frozenModel, + run: event.run, + error: undefined, + })), + }, + DISMISS: { actions: assign(() => ({ error: undefined })) }, }, - }), + }, + busy: { + invoke: fromPromise(context => context.run(), { + onDone: { target: 'idle', actions: assign(() => settled) }, + onError: { + target: 'idle', + actions: assign((_, event) => ({ ...settled, error: toLocalizableError(event.error) })), + }, + }), + }, }, - }, -}); + }); +} + +type OrganizationListActions = 'onSelectOrganization' | 'onAcceptInvitation' | 'onAcceptSuggestion'; -export type OrganizationListController = +export type OrganizationListController = | { status: 'loading' } | { status: 'hidden' } - | (OrganizationListData & { + | (Omit & { status: 'ready'; - createOrganization?: OrganizationListReadyModel['createOrganization']; pendingKey: string | null; error: string | undefined; onDismissError: () => void; @@ -77,12 +81,16 @@ export type OrganizationListController = onAcceptSuggestion: (suggestionId: string) => void; }); -export type OrganizationListReadyController = Extract; +export type OrganizationListReadyController = + Extract, { status: 'ready' }>; -export function useOrganizationListController(model: OrganizationListModel): OrganizationListController { +export function useOrganizationListController( + model: M | Exclude, +): OrganizationListController { const m = useMessages('organizationList'); const errorText = useErrorText(); - const [{ context }, send] = useMachine(organizationListMachine); + const machine = useMemo(() => createOrganizationListMachine(), []); + const [{ context }, send] = useMachine(machine); const pendingKey = useSpinDelay(context.pendingKey, { delay: 0 }); const resolvedModel = context.frozenModel ?? model; @@ -117,7 +125,10 @@ export interface OrganizationListScreenController { } export function useOrganizationListScreenController( - controller: OrganizationListReadyController, + controller: Pick< + OrganizationListReadyController, + 'memberships' | 'invitations' | 'suggestions' | 'hidePersonal' | 'createOrganization' | 'onDismissError' + >, ): OrganizationListScreenController { const hasOrganizations = controller.memberships.length + controller.invitations.length + controller.suggestions.length > 0; diff --git a/packages/mosaic/src/features/organization-list/organization-list.model.ts b/packages/mosaic/src/features/organization-list/organization-list.model.ts index 40317568988..8bc45bbfac9 100644 --- a/packages/mosaic/src/features/organization-list/organization-list.model.ts +++ b/packages/mosaic/src/features/organization-list/organization-list.model.ts @@ -1,6 +1,6 @@ import { ClerkRuntimeError, isClerkAPIResponseError } from '@clerk/shared/error'; import { useClerk, useUser } from '@clerk/shared/react'; -import type { OrganizationListProps } from '@clerk/shared/types'; +import type { OrganizationListProps, OrganizationResource } from '@clerk/shared/types'; import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; import { replaceInPages, shrinkTotal } from '../../utils/infinite-pages'; @@ -37,7 +37,7 @@ export type OrganizationListModel = const UNAVAILABLE_ORGANIZATION_CODES = ['organization_not_found_or_unauthorized', 'not_a_member_in_organization']; -function unavailableOrganization(error: unknown, createOrganizationEnabled: boolean): unknown { +export function unavailableOrganization(error: unknown, createOrganizationEnabled: boolean): unknown { if ( createOrganizationEnabled || !isClerkAPIResponseError(error) || @@ -51,27 +51,17 @@ function unavailableOrganization(error: unknown, createOrganizationEnabled: bool }); } -export function useOrganizationListModel(options: OrganizationListModelOptions = {}): OrganizationListModel { - const { isLoaded: isUserLoaded, user } = useUser(); - const clerk = useClerk(); - const environment = useMosaicEnvironment(); - const createOrganization = useCreateOrganizationModel({ - afterCreateOrganizationUrl: options.afterCreateOrganizationUrl, - skipInvitationScreen: options.skipInvitationScreen, - }); - const { userMemberships, userInvitations, userSuggestions, ref } = useOrganizationListInView(); - - if (!isUserLoaded || !environment) { - return { status: 'loading' }; - } - - if (!user) { - return { status: 'hidden' }; - } +export type OrganizationListEntries = Pick< + OrganizationListData, + 'organizationsLoading' | 'memberships' | 'invitations' | 'suggestions' | 'paging' +> & { + findOrganization: (organizationId: string) => OrganizationResource | undefined; + onAcceptInvitation: (invitationId: string) => Promise; + onAcceptSuggestion: (suggestionId: string) => Promise; +}; - if (createOrganization.status === 'loading') { - return { status: 'loading' }; - } +export function useOrganizationListEntries(): OrganizationListEntries { + const { userMemberships, userInvitations, userSuggestions, ref } = useOrganizationListInView(); const membershipData = userMemberships.data ?? []; const invitationData = userInvitations.data?.filter(Boolean) ?? []; @@ -113,25 +103,7 @@ export function useOrganizationListModel(options: OrganizationListModelOptions = status: s.status, })); - const afterSelectUrl = async (organizationId: string | null): Promise => { - if (!organizationId) { - return resolveAfterUrl(options.afterSelectPersonalUrl, user); - } - if (!options.afterSelectOrganizationUrl) { - return undefined; - } - const selected = - membershipData.find(m => m.organization.id === organizationId)?.organization ?? - (await clerk.getOrganization(organizationId)); - return resolveAfterUrl(options.afterSelectOrganizationUrl, selected); - }; - return { - status: 'ready', - applicationName: environment.displayConfig.applicationName, - renderBranding: environment.displayConfig.branded, - hidePersonal: environment.organizationSettings.forceOrganizationSelection || (options.hidePersonal ?? false), - personalImageUrl: user.imageUrl || undefined, organizationsLoading: userMemberships.isLoading || userInvitations.isLoading || userSuggestions.isLoading, memberships, invitations, @@ -140,15 +112,7 @@ export function useOrganizationListModel(options: OrganizationListModelOptions = ref, hasMore: Boolean(userMemberships.hasNextPage || userInvitations.hasNextPage || userSuggestions.hasNextPage), }, - createOrganization: - createOrganization.status === 'ready' && user.createOrganizationEnabled ? createOrganization : undefined, - onSelectOrganization: async organizationId => { - try { - await clerk.setActive({ organization: organizationId, redirectUrl: await afterSelectUrl(organizationId) }); - } catch (error) { - throw unavailableOrganization(error, user.createOrganizationEnabled); - } - }, + findOrganization: organizationId => membershipData.find(m => m.organization.id === organizationId)?.organization, onAcceptInvitation: async invitationId => { const invitation = invitationData.find(i => i.id === invitationId); try { @@ -175,3 +139,55 @@ export function useOrganizationListModel(options: OrganizationListModelOptions = }, }; } + +export function useOrganizationListModel(options: OrganizationListModelOptions = {}): OrganizationListModel { + const { isLoaded: isUserLoaded, user } = useUser(); + const clerk = useClerk(); + const environment = useMosaicEnvironment(); + const createOrganization = useCreateOrganizationModel({ + afterCreateOrganizationUrl: options.afterCreateOrganizationUrl, + skipInvitationScreen: options.skipInvitationScreen, + }); + const { findOrganization, ...entries } = useOrganizationListEntries(); + + if (!isUserLoaded || !environment) { + return { status: 'loading' }; + } + + if (!user) { + return { status: 'hidden' }; + } + + if (createOrganization.status === 'loading') { + return { status: 'loading' }; + } + + const afterSelectUrl = async (organizationId: string | null): Promise => { + if (!organizationId) { + return resolveAfterUrl(options.afterSelectPersonalUrl, user); + } + if (!options.afterSelectOrganizationUrl) { + return undefined; + } + const selected = findOrganization(organizationId) ?? (await clerk.getOrganization(organizationId)); + return resolveAfterUrl(options.afterSelectOrganizationUrl, selected); + }; + + return { + status: 'ready', + applicationName: environment.displayConfig.applicationName, + renderBranding: environment.displayConfig.branded, + hidePersonal: environment.organizationSettings.forceOrganizationSelection || (options.hidePersonal ?? false), + personalImageUrl: user.imageUrl || undefined, + ...entries, + createOrganization: + createOrganization.status === 'ready' && user.createOrganizationEnabled ? createOrganization : undefined, + onSelectOrganization: async organizationId => { + try { + await clerk.setActive({ organization: organizationId, redirectUrl: await afterSelectUrl(organizationId) }); + } catch (error) { + throw unavailableOrganization(error, user.createOrganizationEnabled); + } + }, + }; +} diff --git a/packages/mosaic/src/features/organization-list/organization-list.view.tsx b/packages/mosaic/src/features/organization-list/organization-list.view.tsx index 3ff8a2e00fc..ebfbf409f95 100644 --- a/packages/mosaic/src/features/organization-list/organization-list.view.tsx +++ b/packages/mosaic/src/features/organization-list/organization-list.view.tsx @@ -68,22 +68,13 @@ export function OrganizationListView({ function ListStep({ applicationName, hidePersonal, - personalImageUrl, - memberships, - invitations, - suggestions, - paging: { ref: loadMoreRef, hasMore }, pendingKey, error, - onSelectOrganization, - onAcceptInvitation, - onAcceptSuggestion, onCreateOrganization, + ...rows }: OrganizationListStepProps) { const m = useMessages('organizationList'); const createButton = useFlowAutoFocus(); - const listed = new Set(memberships.map(membership => membership.organizationId)); - const visibleInvitations = invitations.filter(i => i.status === 'pending' || !listed.has(i.organizationId)); return ( <> @@ -98,52 +89,11 @@ function ListStep({ {error} - - {hidePersonal ? null : ( - onSelectOrganization(null) : undefined} - /> - )} - {memberships.map(membership => ( - - ))} - {visibleInvitations.map(invitation => ( - - ))} - {suggestions.map(suggestion => ( - - ))} - {hasMore ? ( - - - - - - ) : null} - + {onCreateOrganization ? ( @@ -165,6 +115,90 @@ function ListStep({ ); } +export type OrganizationListRowsProps = Pick< + OrganizationListViewProps, + | 'hidePersonal' + | 'personalImageUrl' + | 'memberships' + | 'invitations' + | 'suggestions' + | 'paging' + | 'pendingKey' + | 'onSelectOrganization' + | 'onAcceptInvitation' + | 'onAcceptSuggestion' +> & { + children?: ReactNode; +}; + +export function OrganizationListRows({ + hidePersonal, + personalImageUrl, + memberships, + invitations, + suggestions, + paging: { ref: loadMoreRef, hasMore }, + pendingKey, + onSelectOrganization, + onAcceptInvitation, + onAcceptSuggestion, + children, +}: OrganizationListRowsProps) { + const m = useMessages('organizationList'); + const listed = new Set(memberships.map(membership => membership.organizationId)); + const visibleInvitations = invitations.filter(i => i.status === 'pending' || !listed.has(i.organizationId)); + + return ( + + {hidePersonal ? null : ( + onSelectOrganization(null) : undefined} + /> + )} + {memberships.map(membership => ( + + ))} + {visibleInvitations.map(invitation => ( + + ))} + {suggestions.map(suggestion => ( + + ))} + {hasMore ? ( + + + + + + ) : null} + {children} + + ); +} + export function OrganizationListHeader({ hidePersonal, applicationName, diff --git a/packages/mosaic/src/features/task-choose-organization/__tests__/task-choose-organization.feature.test.tsx b/packages/mosaic/src/features/task-choose-organization/__tests__/task-choose-organization.feature.test.tsx new file mode 100644 index 00000000000..2d73acd9562 --- /dev/null +++ b/packages/mosaic/src/features/task-choose-organization/__tests__/task-choose-organization.feature.test.tsx @@ -0,0 +1,259 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import { type FakeFapiSeed, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { + fapiClient, + fapiEmailAddress, + fapiEnvironment, + fapiInvitation, + fapiMembership, + fapiOrganization, + fapiSession, + fapiUser, +} from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { TaskChooseOrganization } from '../task-choose-organization'; + +const acme = fapiOrganization({ id: 'org_1', name: 'Acme', slug: 'acme' }); +const other = fapiOrganization({ id: 'org_2', name: 'Other', slug: 'other' }); +const gamma = fapiOrganization({ id: 'org_3', name: 'Gamma', slug: 'gamma' }); + +const chooseOrganization = { key: 'choose-organization' } as const; + +function pending({ + createOrganizationEnabled = true, + tasks = [chooseOrganization], + organizations = [acme, other], + exclusive, + ...overrides +}: FakeFapiSeed & { + createOrganizationEnabled?: boolean; + tasks?: Array<{ key: 'choose-organization' | 'reset-password' | 'setup-mfa' }>; + organizations?: Array; + exclusive?: typeof acme; +} = {}): FakeFapiSeed { + const memberships = exclusive + ? [fapiMembership({ ...exclusive, exclusive_membership: true })] + : organizations.map(organization => fapiMembership(organization)); + const alice = fapiUser({ + id: 'user_1', + first_name: 'Alice', + email_addresses: [fapiEmailAddress({ id: 'email_1', email_address: 'alice@example.com' })], + create_organization_enabled: createOrganizationEnabled, + organization_memberships: memberships, + }); + return { + environment: fapiEnvironment({ organization_settings: { force_organization_selection: true } }), + client: fapiClient([fapiSession({ id: 'sess_1', user: alice, status: 'pending', tasks })]), + memberships, + ...overrides, + }; +} + +async function renderTask(seed: FakeFapiSeed = pending()) { + const fapi = serveFapi(seed); + const view = await renderWithClerk(); + await waitFor(() => expect(screen.queryByText('Loading organizations…')).toBeNull()); + return { ...view, fapi }; +} + +const session = (fapi: ReturnType) => fapi.client.sessions.find(s => s.id === 'sess_1'); + +describe('TaskChooseOrganization', () => { + describe('availability', () => { + it('renders nothing when nobody is signed in', async () => { + serveFapi(pending({ client: fapiClient() })); + const { container } = await renderWithClerk(); + + await waitFor(() => expect(container).toBeEmptyDOMElement()); + }); + + it('sends the user on when the session has no organization to choose', async () => { + const { navigate, container } = await renderTask(pending({ tasks: [] })); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/done')); + expect(container).toBeEmptyDOMElement(); + }); + }); + + describe('choosing an organization', () => { + it('makes the selected organization active and finishes the task', async () => { + const { fapi, navigate } = await renderTask(); + const user = userEvent.setup(); + + expect(screen.getByRole('heading', { name: 'Choose an organization' })).toBeVisible(); + expect(screen.getByText('Join an existing organization or create a new one')).toBeVisible(); + + await user.click(screen.getByRole('button', { name: 'Other' })); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/done')); + expect(session(fapi)?.last_active_organization_id).toBe('org_2'); + expect(session(fapi)?.status).toBe('active'); + }); + + it('goes on to the next task when one remains', async () => { + const { navigate } = await renderTask(pending({ tasks: [chooseOrganization, { key: 'setup-mfa' }] })); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Acme' })); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith(expect.stringContaining('/tasks/setup-mfa'))); + expect(navigate).not.toHaveBeenCalledWith('/done'); + }); + + it('holds the list still and disables the other rows while selecting', async () => { + await renderTask(); + const touch = holdRequests('post', '/v1/client/sessions/:id/touch'); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Other' })); + await waitFor(() => expect(touch.requests).toHaveLength(1)); + + expect(screen.getByRole('button', { name: /Other/ })).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('button', { name: 'Acme' })).toHaveAttribute('aria-disabled', 'true'); + touch.release(); + }); + + it('explains an organization the user can no longer reach', async () => { + await renderTask(pending({ createOrganizationEnabled: false })); + const touch = holdRequests('post', '/v1/client/sessions/:id/touch'); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Other' })); + await waitFor(() => expect(touch.requests).toHaveLength(1)); + touch.fail('organization_not_found_or_unauthorized'); + + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent( + 'You are no longer a member of this organization. Please choose another one.', + ), + ); + }); + + it('lists invitations to join', async () => { + await renderTask(pending({ invitations: [fapiInvitation('inv_1', gamma)] })); + + expect(screen.getByText('Gamma')).toBeVisible(); + expect(screen.getByRole('button', { name: 'Join' })).toBeVisible(); + }); + }); + + describe('an exclusive member', () => { + it('activates their organization without asking', async () => { + const { fapi, navigate } = await renderTask(pending({ exclusive: acme })); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/done')); + expect(session(fapi)?.last_active_organization_id).toBe('org_1'); + }); + + it('falls back to the list when activating fails', async () => { + serveFapi(pending({ exclusive: acme })); + const touch = holdRequests('post', '/v1/client/sessions/:id/touch'); + const { navigate } = await renderWithClerk(); + + await waitFor(() => expect(touch.requests).toHaveLength(1)); + touch.fail('organization_not_found_or_unauthorized'); + + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent( + 'You are no longer a member of this organization. Please choose or create another one.', + ), + ); + expect(screen.getByRole('button', { name: 'Acme' })).toBeVisible(); + expect(navigate).not.toHaveBeenCalled(); + }); + }); + + describe('creating an organization', () => { + it('switches to the create form and back', async () => { + await renderTask(); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Create new organization' })); + expect(await screen.findByRole('heading', { name: 'Create organization' })).toBeVisible(); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(await screen.findByRole('heading', { name: 'Choose an organization' })).toBeVisible(); + }); + + it('starts on the create form, with no way back, when there is nothing to list', async () => { + await renderTask(pending({ organizations: [] })); + + expect(await screen.findByRole('heading', { name: 'Create organization' })).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull(); + }); + + it('makes the new organization active and finishes the task', async () => { + const { fapi, navigate } = await renderTask(pending({ organizations: [] })); + const user = userEvent.setup(); + + await user.type(await screen.findByRole('textbox', { name: 'Name' }), 'Initech'); + await user.click(screen.getByRole('button', { name: 'Create organization' })); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/done')); + expect(session(fapi)?.last_active_organization_id).toBe(fapi.memberships.at(-1)?.organization.id); + expect(fapi.memberships.at(-1)?.organization.name).toBe('Initech'); + }); + + it('fills the form from the organization creation defaults and shows their advisory', async () => { + await renderTask( + pending({ + organizations: [], + environment: fapiEnvironment({ + organization_settings: { + force_organization_selection: true, + organization_creation_defaults: { enabled: true }, + }, + }), + organizationCreationDefaults: { + object: 'organization_creation_defaults', + id: '', + advisory: { + code: 'organization_already_exists', + severity: 'warning', + meta: { organization_name: 'Initech', organization_domain: 'initech.com' }, + }, + form: { name: 'Initech', slug: 'initech', logo: null, blur_hash: null }, + }, + }), + ); + + expect(await screen.findByRole('textbox', { name: 'Name' })).toHaveValue('Initech'); + expect(screen.getByRole('textbox', { name: 'Slug' })).toHaveValue('initech'); + expect( + screen.getByText( + 'An organization already exists for the detected company name (Initech) and initech.com. Join by invitation.', + ), + ).toBeVisible(); + }); + + it('offers no create button when the user cannot create organizations', async () => { + await renderTask(pending({ createOrganizationEnabled: false })); + + expect(screen.getByText('Join an existing organization')).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Create new organization' })).toBeNull(); + }); + + it('explains that an invitation is needed when there is nothing to list and nothing to create', async () => { + await renderTask(pending({ createOrganizationEnabled: false, organizations: [] })); + + expect(await screen.findByRole('heading', { name: 'You must belong to an organization' })).toBeVisible(); + expect(screen.getByText('Contact your organization admin for an invitation.')).toBeVisible(); + }); + }); + + describe('signing out', () => { + it('names the signed-in user and signs them out', async () => { + const { fapi, navigate } = await renderTask(); + const user = userEvent.setup(); + + expect(screen.getByText('Signed in as alice@example.com')).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Sign out' })); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/')); + expect(fapi.client.sessions).toHaveLength(0); + }); + }); +}); diff --git a/packages/mosaic/src/features/task-choose-organization/task-choose-organization.controller.ts b/packages/mosaic/src/features/task-choose-organization/task-choose-organization.controller.ts new file mode 100644 index 00000000000..4777f7ee7ef --- /dev/null +++ b/packages/mosaic/src/features/task-choose-organization/task-choose-organization.controller.ts @@ -0,0 +1,51 @@ +import { useLayoutEffect } from 'react'; + +import type { + OrganizationListController, + OrganizationListReadyController, + OrganizationListScreenController, +} from '../organization-list/organization-list.controller'; +import { + useOrganizationListController, + useOrganizationListScreenController, +} from '../organization-list/organization-list.controller'; +import type { TaskChooseOrganizationModel, TaskChooseOrganizationReadyModel } from './task-choose-organization.model'; +import type { TaskChooseOrganizationStep } from './task-choose-organization.types'; + +export type TaskChooseOrganizationController = OrganizationListController; + +export type TaskChooseOrganizationReadyController = OrganizationListReadyController; + +export function useTaskChooseOrganizationController( + model: TaskChooseOrganizationModel, +): TaskChooseOrganizationController { + return useOrganizationListController( + model.status === 'complete' ? { status: 'hidden' } : model, + ); +} + +export interface TaskChooseOrganizationScreenController extends OrganizationListScreenController { + step: TaskChooseOrganizationStep; +} + +export function useTaskChooseOrganizationScreenController( + controller: TaskChooseOrganizationReadyController, +): TaskChooseOrganizationScreenController { + const screen = useOrganizationListScreenController(controller); + const { exclusiveOrganizationId, onSelectOrganization } = controller; + + useLayoutEffect(() => { + if (exclusiveOrganizationId) { + onSelectOrganization(exclusiveOrganizationId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- An exclusive member is activated once, when the chooser first has their organizations; a failure falls back to the chooser instead of retrying. + }, []); + + const hasOrganizations = + controller.memberships.length + controller.invitations.length + controller.suggestions.length > 0; + + return { + ...screen, + step: screen.creating ? 'create' : hasOrganizations || controller.createOrganization ? 'choose' : 'unavailable', + }; +} diff --git a/packages/mosaic/src/features/task-choose-organization/task-choose-organization.messages.ts b/packages/mosaic/src/features/task-choose-organization/task-choose-organization.messages.ts new file mode 100644 index 00000000000..4ca526b475b --- /dev/null +++ b/packages/mosaic/src/features/task-choose-organization/task-choose-organization.messages.ts @@ -0,0 +1,16 @@ +export const taskChooseOrganizationMessages = { + title: 'Choose an organization', + subtitle: 'Join an existing organization or create a new one', + subtitleWithoutCreate: 'Join an existing organization', + createOrganization: 'Create new organization', + organizationAlreadyExists: + 'An organization already exists for the detected company name ({organizationName}) and {organizationDomain}. Join by invitation.', + unavailable: { + title: 'You must belong to an organization', + description: 'Contact your organization admin for an invitation.', + }, + signOut: { + signedInAs: 'Signed in as {identifier}', + action: 'Sign out', + }, +} as const; diff --git a/packages/mosaic/src/features/task-choose-organization/task-choose-organization.model.ts b/packages/mosaic/src/features/task-choose-organization/task-choose-organization.model.ts new file mode 100644 index 00000000000..fd25ceda2c3 --- /dev/null +++ b/packages/mosaic/src/features/task-choose-organization/task-choose-organization.model.ts @@ -0,0 +1,115 @@ +import { buildTaskUrl } from '@clerk/shared/internal/clerk-js/sessionTasks'; +import { useClerk, useOrganizationCreationDefaults, useSession, useUser } from '@clerk/shared/react'; +import type { DecorateUrl, SessionResource, TaskChooseOrganizationProps } from '@clerk/shared/types'; + +import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; +import { useMosaicRouter } from '../../hooks/use-mosaic-router'; +import { useCreateOrganizationModel } from '../create-organization/create-organization.model'; +import type { OrganizationListReadyModel } from '../organization-list/organization-list.controller'; +import { unavailableOrganization, useOrganizationListEntries } from '../organization-list/organization-list.model'; +import type { TaskChooseOrganizationData } from './task-choose-organization.types'; + +export type TaskChooseOrganizationModelOptions = Pick; + +export type TaskChooseOrganizationReadyModel = OrganizationListReadyModel & + TaskChooseOrganizationData & { + onSignOut: () => Promise; + }; + +export type TaskChooseOrganizationModel = + | { status: 'loading' } + | { status: 'hidden' } + | { status: 'complete'; onContinue: () => Promise } + | TaskChooseOrganizationReadyModel; + +const TASK_KEY = 'choose-organization'; +const ABSOLUTE_URL = /^https?:\/\//; + +export function useTaskChooseOrganizationModel({ + redirectUrlComplete, +}: TaskChooseOrganizationModelOptions): TaskChooseOrganizationModel { + const { isLoaded: isUserLoaded, user } = useUser(); + const { isLoaded: isSessionLoaded, session } = useSession(); + const clerk = useClerk(); + const environment = useMosaicEnvironment(); + const router = useMosaicRouter(); + const createOrganization = useCreateOrganizationModel({ skipInvitationScreen: true }); + const { findOrganization: _findOrganization, ...entries } = useOrganizationListEntries(); + const creationDefaults = useOrganizationCreationDefaults(); + + if (!isUserLoaded || !isSessionLoaded || !environment) { + return { status: 'loading' }; + } + + if (!user || !session) { + return { status: 'hidden' }; + } + + const continueAfterTask = async (current: SessionResource, decorateUrl?: DecorateUrl) => { + const task = current.currentTask; + if (task) { + await router.navigate( + clerk.__internal_getOption('taskUrls')?.[task.key] ?? buildTaskUrl(task, { base: clerk.buildSignInUrl() }), + ); + return; + } + const decorated = decorateUrl ? decorateUrl(redirectUrlComplete) : redirectUrlComplete; + if (decorated !== redirectUrlComplete && ABSOLUTE_URL.test(decorated)) { + router.windowNavigate(decorated); + return; + } + await router.navigate(redirectUrlComplete); + }; + + const task = session.currentTask; + if (!task || (task.key !== TASK_KEY && !clerk.__internal_setActiveInProgress)) { + return { status: 'complete', onContinue: () => continueAfterTask(session) }; + } + + if (createOrganization.status === 'loading') { + return { status: 'loading' }; + } + + const advisory = creationDefaults.data?.advisory; + const otherSessions = (clerk.client?.signedInSessions ?? []).filter(s => s.id !== session.id); + + return { + status: 'ready', + applicationName: environment.displayConfig.applicationName, + renderBranding: environment.displayConfig.branded, + hidePersonal: true, + ...entries, + organizationsLoading: entries.organizationsLoading || creationDefaults.isLoading, + identifier: user.primaryEmailAddress?.emailAddress ?? user.username ?? undefined, + exclusiveOrganizationId: + user.organizationMemberships.find(m => m.organization.exclusiveMembership)?.organization.id ?? null, + creationDefaults: creationDefaults.data?.form + ? { name: creationDefaults.data.form.name, slug: creationDefaults.data.form.slug } + : undefined, + advisory: + advisory?.code === 'organization_already_exists' + ? { + organizationName: advisory.meta.organization_name ?? '', + organizationDomain: advisory.meta.organization_domain ?? '', + } + : undefined, + createOrganization: + createOrganization.status === 'ready' && user.createOrganizationEnabled + ? { ...createOrganization, onComplete: () => continueAfterTask(clerk.session ?? session) } + : undefined, + onSelectOrganization: async organizationId => { + try { + await clerk.setActive({ + organization: organizationId, + navigate: ({ session: next, decorateUrl }) => continueAfterTask(next, decorateUrl), + }); + } catch (error) { + throw unavailableOrganization(error, user.createOrganizationEnabled); + } + }, + onSignOut: () => + otherSessions.length > 0 + ? clerk.signOut({ sessionId: session.id, redirectUrl: clerk.buildAfterMultiSessionSingleSignOutUrl() }) + : clerk.signOut({ redirectUrl: clerk.buildAfterSignOutUrl() }), + }; +} diff --git a/packages/mosaic/src/features/task-choose-organization/task-choose-organization.skeleton.tsx b/packages/mosaic/src/features/task-choose-organization/task-choose-organization.skeleton.tsx new file mode 100644 index 00000000000..52e67621a4b --- /dev/null +++ b/packages/mosaic/src/features/task-choose-organization/task-choose-organization.skeleton.tsx @@ -0,0 +1,53 @@ +import { Card } from '../../components/card'; +import { Item } from '../../components/item'; +import { Skeleton } from '../../components/skeleton'; +import { VisuallyHidden } from '../../components/visually-hidden'; +import { useMessages } from '../../localization'; +import { RowAvatar } from '../switcher/switcher-avatar.view'; +import { TaskChooseOrganizationHeader, TaskChooseOrganizationSignOut } from './task-choose-organization.view'; + +const PLACEHOLDER_ORGANIZATIONS = ['Acme Corporation', 'Globex']; + +export function TaskChooseOrganizationSkeleton({ + canCreate, + identifier, + renderBranding, +}: { + canCreate: boolean; + identifier?: string; + renderBranding: boolean; +}) { + const m = useMessages('organizationList'); + return ( + + + + {m.loading} + + {PLACEHOLDER_ORGANIZATIONS.map(name => ( + + + + + + + + + {name} + + + + ))} + + + + + ); +} diff --git a/packages/mosaic/src/features/task-choose-organization/task-choose-organization.styles.ts b/packages/mosaic/src/features/task-choose-organization/task-choose-organization.styles.ts new file mode 100644 index 00000000000..b5f588b7b14 --- /dev/null +++ b/packages/mosaic/src/features/task-choose-organization/task-choose-organization.styles.ts @@ -0,0 +1,25 @@ +import * as stylex from '@stylexjs/stylex'; + +import { space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + footer: { + justifyContent: 'center', + }, + signOut: { + alignItems: 'center', + columnGap: space['1'], + display: 'flex', + justifyContent: 'center', + minWidth: 0, + }, + identifier: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + minWidth: 0, + }, + signOutAction: { + flexShrink: 0, + }, +}); diff --git a/packages/mosaic/src/features/task-choose-organization/task-choose-organization.tsx b/packages/mosaic/src/features/task-choose-organization/task-choose-organization.tsx new file mode 100644 index 00000000000..94a45ef1a83 --- /dev/null +++ b/packages/mosaic/src/features/task-choose-organization/task-choose-organization.tsx @@ -0,0 +1,121 @@ +'use client'; + +import type { ReactElement, ReactNode } from 'react'; +import { useEffect } from 'react'; + +import { fill, useMessages } from '../../localization'; +import { useCreateOrganizationFlow } from '../create-organization/create-organization'; +import type { CreateOrganizationReadyModel } from '../create-organization/create-organization.controller'; +import { CreateOrganizationSteps } from '../create-organization/create-organization.view'; +import type { TaskChooseOrganizationReadyController } from './task-choose-organization.controller'; +import { + useTaskChooseOrganizationController, + useTaskChooseOrganizationScreenController, +} from './task-choose-organization.controller'; +import type { TaskChooseOrganizationModelOptions } from './task-choose-organization.model'; +import { useTaskChooseOrganizationModel } from './task-choose-organization.model'; +import { TaskChooseOrganizationSkeleton } from './task-choose-organization.skeleton'; +import type { TaskChooseOrganizationData } from './task-choose-organization.types'; +import { TaskChooseOrganizationView } from './task-choose-organization.view'; + +/** Everything `` takes: where to go once the session has no tasks left. */ +export type TaskChooseOrganizationProps = TaskChooseOrganizationModelOptions & { + /** + * Rendered while Clerk loads. Nothing renders once Clerk answers that nobody is signed in. + */ + fallback?: ReactNode; +}; + +/** + * The `choose-organization` session task: a card where a signed-in user whose session is pending + * picks the organization to continue with. It lists the organizations they belong to, and the + * ones they are invited to or may request to join. Where the user may create organizations, a + * button opens the create-organization flow in place of the list. Once an organization is active, + * the user goes on to their next task, or to `redirectUrlComplete` when none is left. A user whose + * session has no `choose-organization` task is sent on straight away. + * + * @example + * ```tsx + * + * ``` + */ +export function TaskChooseOrganization({ fallback, ...options }: TaskChooseOrganizationProps): ReactElement | null { + const model = useTaskChooseOrganizationModel(options); + const controller = useTaskChooseOrganizationController(model); + + if (controller.status === 'ready') { + if (controller.organizationsLoading) { + return ( + + ); + } + return ; + } + + if (model.status === 'complete') { + return ; + } + + if (controller.status === 'loading') { + return <>{fallback}; + } + + return null; +} + +function ContinueAfterTask({ onContinue }: { onContinue: () => Promise }): null { + useEffect(() => { + void onContinue(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- The session leaves this task once; a later render must not navigate again. + }, []); + + return null; +} + +function ConnectedTaskChooseOrganization({ controller }: { controller: TaskChooseOrganizationReadyController }) { + const screen = useTaskChooseOrganizationScreenController(controller); + const { createOrganization } = controller; + + return ( + + ) : undefined + } + onCreateOrganization={screen.onCreateOrganization} + /> + ); +} + +function TaskChooseOrganizationCreateSteps({ + model, + creationDefaults, + advisory, + onCancel, +}: Pick & { + model: CreateOrganizationReadyModel; + onCancel?: () => void; +}) { + const m = useMessages('taskChooseOrganization'); + const steps = useCreateOrganizationFlow(model, { initialValues: creationDefaults }); + + return ( + + ); +} diff --git a/packages/mosaic/src/features/task-choose-organization/task-choose-organization.types.ts b/packages/mosaic/src/features/task-choose-organization/task-choose-organization.types.ts new file mode 100644 index 00000000000..a7dd435f03f --- /dev/null +++ b/packages/mosaic/src/features/task-choose-organization/task-choose-organization.types.ts @@ -0,0 +1,18 @@ +import type { OrganizationListData } from '../organization-list/organization-list.types'; + +export interface TaskChooseOrganizationAdvisory { + organizationName: string; + organizationDomain: string; +} + +export interface TaskChooseOrganizationData extends Pick< + OrganizationListData, + 'renderBranding' | 'organizationsLoading' | 'memberships' | 'invitations' | 'suggestions' | 'paging' +> { + identifier?: string; + exclusiveOrganizationId: string | null; + creationDefaults?: { name: string; slug: string }; + advisory?: TaskChooseOrganizationAdvisory; +} + +export type TaskChooseOrganizationStep = 'choose' | 'create' | 'unavailable'; diff --git a/packages/mosaic/src/features/task-choose-organization/task-choose-organization.view.tsx b/packages/mosaic/src/features/task-choose-organization/task-choose-organization.view.tsx new file mode 100644 index 00000000000..9974df7f960 --- /dev/null +++ b/packages/mosaic/src/features/task-choose-organization/task-choose-organization.view.tsx @@ -0,0 +1,169 @@ +import type { ReactNode } from 'react'; + +import { Button } from '../../components/button'; +import { Card } from '../../components/card'; +import { Flow, useFlowAutoFocus } from '../../components/flow'; +import { Icon } from '../../components/icon'; +import { Text } from '../../components/text'; +import { Plus } from '../../icons/glyphs/plus'; +import { fill, useMessages } from '../../localization'; +import type { OrganizationListRowsProps } from '../organization-list/organization-list.view'; +import { OrganizationListRows } from '../organization-list/organization-list.view'; +import { styles } from './task-choose-organization.styles'; +import type { TaskChooseOrganizationStep } from './task-choose-organization.types'; + +export interface TaskChooseOrganizationViewProps extends Omit< + OrganizationListRowsProps, + 'hidePersonal' | 'personalImageUrl' | 'children' +> { + step: TaskChooseOrganizationStep; + renderBranding?: boolean; + identifier?: string; + error?: string; + createSteps?: ReactNode; + onCreateOrganization?: () => void; + onSignOut: () => void; +} + +type ChooseStepProps = Omit< + TaskChooseOrganizationViewProps, + 'step' | 'renderBranding' | 'identifier' | 'createSteps' | 'onSignOut' +>; + +export function TaskChooseOrganizationView({ + step, + renderBranding = true, + identifier, + createSteps, + onSignOut, + ...props +}: TaskChooseOrganizationViewProps) { + return ( + + + {current => ( + <> + + + + {createSteps} + + + + + )} + + + + ); +} + +export function TaskChooseOrganizationHeader({ canCreate }: { canCreate: boolean }) { + const m = useMessages('taskChooseOrganization'); + return ( + + {m.title} + {canCreate ? m.subtitle : m.subtitleWithoutCreate} + + ); +} + +function ChooseStep({ error, onCreateOrganization, ...rows }: ChooseStepProps) { + const m = useMessages('taskChooseOrganization'); + const createButton = useFlowAutoFocus(); + + return ( + <> + + + {error} + + + + + {onCreateOrganization ? ( + + + + ) : null} + + ); +} + +function UnavailableStep() { + const m = useMessages('taskChooseOrganization').unavailable; + return ( + + {m.title} + {m.description} + + ); +} + +export function TaskChooseOrganizationSignOut({ + identifier, + disabled = false, + onSignOut, +}: { + identifier?: string; + disabled?: boolean; + onSignOut?: () => void; +}) { + const m = useMessages('taskChooseOrganization').signOut; + return ( + + + {identifier ? ( + } + size='xs' + color='foreground-secondary' + xstyle={styles.identifier} + > + {fill(m.signedInAs, { identifier })} + + ) : null} + + + + ); +} diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index 248157151d5..18a85f5158b 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -12,6 +12,7 @@ import { requestsTableTabMessages } from '../features/organization-profile/organ import { organizationProfileProfileSectionMessages } from '../features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.messages'; import { organizationSwitcherMessages } from '../features/organization-switcher/organization-switcher.messages'; import { reverificationMessages } from '../features/reverification/reverification.messages'; +import { taskChooseOrganizationMessages } from '../features/task-choose-organization/task-choose-organization.messages'; import { userButtonMessages } from '../features/user-button/user-button.messages'; import { userProfileMessages } from '../features/user-profile/user-profile.messages'; import { userProfileActiveDevicesMessages } from '../features/user-profile/user-profile-active-devices-section/user-profile-active-devices.messages'; @@ -74,6 +75,7 @@ export const mosaicMessages = { apiKeys: apiKeysTableMessages, createOrganization: createOrganizationMessages, organizationList: organizationListMessages, + taskChooseOrganization: taskChooseOrganizationMessages, reverification: reverificationMessages, profile: profileMessages, form: formMessages, diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts index 47c479b561f..3d26f90b5c3 100644 --- a/packages/mosaic/src/styles/index.ts +++ b/packages/mosaic/src/styles/index.ts @@ -181,6 +181,7 @@ export type { export { CreateOrganization } from '../features/create-organization/create-organization'; export { OrganizationList } from '../features/organization-list/organization-list'; export { OrganizationSwitcher } from '../features/organization-switcher/organization-switcher'; +export { TaskChooseOrganization } from '../features/task-choose-organization/task-choose-organization'; export { UserButton } from '../features/user-button/user-button'; export { Waitlist } from '../features/waitlist/waitlist'; diff --git a/packages/swingset/src/app/(clerk)/live/misc/task-choose-organization/page.tsx b/packages/swingset/src/app/(clerk)/live/misc/task-choose-organization/page.tsx new file mode 100644 index 00000000000..dc0ab21af65 --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live/misc/task-choose-organization/page.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { TaskChooseOrganization } from '@clerk/mosaic/features/task-choose-organization/task-choose-organization'; +import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; +import { useSession } from '@clerk/nextjs'; +import Link from 'next/link'; + +import { LivePageHeader } from '../../../live-page-header'; + +export default function TaskChooseOrganizationLivePage() { + const { isLoaded, session } = useSession(); + const hasTask = session?.currentTask?.key === 'choose-organization'; + + return ( + +
+ + Resolves the real choose-organization session task: selecting or creating an organization makes it active and + goes on to the next task or back to the live overview. It needs a session with the task pending, which an + application that requires an organization gives every user without one. + + {isLoaded && !session ? ( +

+ + Sign in + {' '} + to use the live harness. +

+ ) : null} + {isLoaded && session && !hasTask ? ( +

This session has no choose-organization task to resolve.

+ ) : null} + {hasTask ? ( + Loading…

} + /> + ) : null} +
+
+ ); +} diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index fba08be3a54..9322d2b64a6 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -55,6 +55,7 @@ const docModules: Record> = { 'organization-switcher': dynamic(() => import('../stories/organization-switcher.mdx')), 'create-organization': dynamic(() => import('../stories/create-organization.mdx')), 'organization-list': dynamic(() => import('../stories/organization-list.mdx')), + 'task-choose-organization': dynamic(() => import('../stories/task-choose-organization.mdx')), 'user-avatar': dynamic(() => import('../stories/user-avatar.mdx')), 'api-keys-table': dynamic(() => import('../stories/api-keys-table.mdx')), reverification: dynamic(() => import('../stories/reverification.mdx')), diff --git a/packages/swingset/src/lib/live-navigation.ts b/packages/swingset/src/lib/live-navigation.ts index 69103c776c8..8ac728726e8 100644 --- a/packages/swingset/src/lib/live-navigation.ts +++ b/packages/swingset/src/lib/live-navigation.ts @@ -40,6 +40,7 @@ const livePages = new Set([ '/misc/reverification', '/misc/create-organization', '/misc/organization-list', + '/misc/task-choose-organization', '/misc/waitlist', '/user-button/user-button', ]); diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 6af0bc06f24..b42fe87ebad 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -345,6 +345,16 @@ import { Validation as TagInputComponentValidation, } from '../stories/tag-input.component.stories'; import { meta as tagInputMeta } from '../stories/tag-input.stories'; +import { + Default as TaskChooseOrganizationDefault, + Empty as TaskChooseOrganizationEmpty, + Loading as TaskChooseOrganizationLoading, + meta as taskChooseOrganizationMeta, + Unavailable as TaskChooseOrganizationUnavailable, + WithDefaults as TaskChooseOrganizationWithDefaults, + WithError as TaskChooseOrganizationWithError, + WithoutCreate as TaskChooseOrganizationWithoutCreate, +} from '../stories/task-choose-organization.stories'; import { Colors as TextColors, Default as TextDefault, @@ -751,6 +761,17 @@ const organizationListModule: StoryModule = { WithError: OrganizationListWithError, }; +const taskChooseOrganizationModule: StoryModule = { + meta: taskChooseOrganizationMeta, + Default: TaskChooseOrganizationDefault, + WithoutCreate: TaskChooseOrganizationWithoutCreate, + Empty: TaskChooseOrganizationEmpty, + WithDefaults: TaskChooseOrganizationWithDefaults, + Unavailable: TaskChooseOrganizationUnavailable, + Loading: TaskChooseOrganizationLoading, + WithError: TaskChooseOrganizationWithError, +}; + const headingModule: StoryModule = { meta: headingMeta, Default: HeadingDefault, @@ -1123,6 +1144,7 @@ export const registry: StoryModule[] = [ organizationSwitcherModule, createOrganizationModule, organizationListModule, + taskChooseOrganizationModule, userAvatarModule, apiKeysTableModule, reverificationModule, diff --git a/packages/swingset/src/stories/task-choose-organization.mdx b/packages/swingset/src/stories/task-choose-organization.mdx new file mode 100644 index 00000000000..8fdcbc8f98f --- /dev/null +++ b/packages/swingset/src/stories/task-choose-organization.mdx @@ -0,0 +1,125 @@ +import * as TaskChooseOrganizationStories from './task-choose-organization.stories'; + +# TaskChooseOrganization + +## Example + + + +## Usage + +TaskChooseOrganization is available internally in Swingset. It is not yet exported from `@clerk/mosaic`. + +```tsx + +``` + +| Prop | Type | Default | Description | +| --------------------- | ----------- | ------------ | ------------------------------------------------------- | +| `redirectUrlComplete` | `string` | — (required) | Where the user goes once the session has no tasks left. | +| `fallback` | `ReactNode` | — | Rendered while Clerk loads. | + +It renders `fallback` while Clerk loads and nothing when nobody is signed in. When the session has no +`choose-organization` task, it sends the user on straight away. + +## Choosing an organization + +The list holds the organizations the user belongs to, then invitations, then suggestions, the same +rows as [OrganizationList](/misc/organization-list) without the personal account. Selecting an +organization makes it active and finishes the task. The user goes on to their next task, at its +`taskUrls` entry or the sign-in task route, or to `redirectUrlComplete` when none is left. + +A user whose only membership is exclusive is not asked. Their organization is activated as soon as the +card has their memberships, and the list stays on screen with that row pending. If activating fails, +the error appears above the list and the user can choose again. + +## Loading + +The rows stand in as placeholders until the first page of every list, and the organization creation +defaults, have loaded. + + + +## Creating an organization + +**Create new organization** replaces the list with the [CreateOrganization](/misc/create-organization) +form, and **Cancel** returns to it. Creating the organization makes it active and finishes the task. +When there is nothing to list, the form opens straight away and offers no way back. + + + +When the instance has organization creation defaults, they fill the name and slug, and an advisory +about them appears above the form. + + + +For users who cannot create organizations, the button is left out and the subtitle only offers to join. + + + +When such a user has nothing to join either, the card says they need an invitation. + + + +## Signing out + +The foot of the card names the signed-in user by email address or username, with **Sign out**. Sign +out ends the session and goes to the after-sign-out URL. While other accounts stay signed in, it ends +only this one and goes to the after-multi-session-single-sign-out URL. + +## Errors + +A failed action shows its error above the list. Selecting an organization the user is no longer a +member of explains that, and suggests creating another one where the user may. + + + +## Styling + +| Slot | Attributes | +| ---------------------- | ----------------------------------------------- | +| `.cl-card-root` | `data-elevation`, `data-size` | +| `.cl-card-header` | | +| `.cl-card-title` | | +| `.cl-card-description` | | +| `.cl-card-banner` | | +| `.cl-card-content` | | +| `.cl-card-footer` | `data-elevation` | +| `.cl-flow-root` | `data-value="choose \| create \| unavailable"` | +| `.cl-flow-step` | `data-step="choose \| create \| unavailable"` | +| `.cl-item-group` | `data-variant` | +| `.cl-item` | `data-interactive`, `data-size`, `data-variant` | +| `.cl-item-media` | `data-size` | +| `.cl-item-content` | | +| `.cl-item-label` | `data-variant` | +| `.cl-item-actions` | | diff --git a/packages/swingset/src/stories/task-choose-organization.stories.tsx b/packages/swingset/src/stories/task-choose-organization.stories.tsx new file mode 100644 index 00000000000..ddec2149875 --- /dev/null +++ b/packages/swingset/src/stories/task-choose-organization.stories.tsx @@ -0,0 +1,224 @@ +import type { CreateOrganizationReadyModel } from '@clerk/mosaic/features/create-organization/create-organization.controller'; +import { + useCreateOrganizationController, + useCreateOrganizationInviteController, +} from '@clerk/mosaic/features/create-organization/create-organization.controller'; +import { CreateOrganizationSteps } from '@clerk/mosaic/features/create-organization/create-organization.view'; +import type { + OrganizationListInvitation, + OrganizationListMembership, + OrganizationListSuggestion, +} from '@clerk/mosaic/features/organization-list/organization-list.types'; +import type { TaskChooseOrganizationReadyController } from '@clerk/mosaic/features/task-choose-organization/task-choose-organization.controller'; +import { + useTaskChooseOrganizationController, + useTaskChooseOrganizationScreenController, +} from '@clerk/mosaic/features/task-choose-organization/task-choose-organization.controller'; +import type { TaskChooseOrganizationReadyModel } from '@clerk/mosaic/features/task-choose-organization/task-choose-organization.model'; +import { TaskChooseOrganizationSkeleton } from '@clerk/mosaic/features/task-choose-organization/task-choose-organization.skeleton'; +import { TaskChooseOrganizationView } from '@clerk/mosaic/features/task-choose-organization/task-choose-organization.view'; +import { fill, useMessages } from '@clerk/mosaic/localization'; +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './task-choose-organization.stories?raw'; + +export const meta: StoryMeta = { + group: 'Misc', + title: 'TaskChooseOrganization', + label: 'Task: choose organization', + status: 'wip', + source: 'packages/mosaic/src/features/task-choose-organization/task-choose-organization.tsx', +}; + +const settleAfter = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +const unavailable = () => + new ClerkAPIResponseError('Organization not found', { + status: 404, + data: [ + { + code: 'organization_not_found_or_unauthorized', + message: 'Organization not found', + long_message: 'Organization not found', + }, + ], + }); + +const initialMemberships: OrganizationListMembership[] = [ + { kind: 'membership', organizationId: 'org_1', name: 'Acme Corporation' }, + { kind: 'membership', organizationId: 'org_2', name: 'Globex' }, +]; + +const initialInvitations: OrganizationListInvitation[] = [ + { kind: 'invitation', id: 'inv_1', organizationId: 'org_3', name: 'Initech', status: 'pending' }, +]; + +const initialSuggestions: OrganizationListSuggestion[] = [ + { kind: 'suggestion', id: 'sug_1', organizationId: 'org_4', name: 'Umbrella', status: 'pending' }, +]; + +const createOrganization: CreateOrganizationReadyModel = { + status: 'ready', + slugEnabled: true, + renderBranding: true, + onCreate: async () => { + await settleAfter(1000); + return { organizationId: 'org_new', skipInvitations: true }; + }, + onInvite: () => settleAfter(1000), + onComplete: () => Promise.resolve(), +}; + +interface HarnessProps { + empty?: boolean; + canCreate?: boolean; + failSelect?: boolean; + withDefaults?: boolean; +} + +function TaskChooseOrganizationHarness({ + empty = false, + canCreate = true, + failSelect = false, + withDefaults = false, +}: HarnessProps) { + const [invitations, setInvitations] = useState(empty ? [] : initialInvitations); + const [suggestions, setSuggestions] = useState(empty ? [] : initialSuggestions); + + const model: TaskChooseOrganizationReadyModel = { + status: 'ready', + applicationName: 'Acme', + renderBranding: true, + hidePersonal: true, + organizationsLoading: false, + identifier: 'alice@example.com', + exclusiveOrganizationId: null, + memberships: empty ? [] : initialMemberships, + invitations, + suggestions, + paging: { ref: () => {}, hasMore: false }, + creationDefaults: withDefaults ? { name: 'Initech', slug: 'initech' } : undefined, + advisory: withDefaults ? { organizationName: 'Initech', organizationDomain: 'initech.com' } : undefined, + createOrganization: canCreate ? createOrganization : undefined, + onSelectOrganization: async () => { + await settleAfter(1000); + if (failSelect) { + throw unavailable(); + } + }, + onAcceptInvitation: async id => { + await settleAfter(1000); + setInvitations(current => current.map(i => (i.id === id ? { ...i, status: 'accepted' } : i))); + }, + onAcceptSuggestion: async id => { + await settleAfter(1000); + setSuggestions(current => current.map(s => (s.id === id ? { ...s, status: 'accepted' } : s))); + }, + onSignOut: () => settleAfter(500), + }; + const controller = useTaskChooseOrganizationController(model); + + if (controller.status !== 'ready') { + return null; + } + + return ; +} + +function TaskChooseOrganizationScreens({ controller }: { controller: TaskChooseOrganizationReadyController }) { + const m = useMessages('taskChooseOrganization'); + const screen = useTaskChooseOrganizationScreenController(controller); + + return ( + + ) : undefined + } + onCreateOrganization={screen.onCreateOrganization} + /> + ); +} + +function CreateOrganizationStepsHarness({ + initialValues, + notice, + onCancel, +}: { + initialValues?: { name: string; slug: string }; + notice?: string; + onCancel?: () => void; +}) { + const controller = useCreateOrganizationController(createOrganization, { initialValues }); + const invite = useCreateOrganizationInviteController({ + organizationId: controller.step.step === 'create' ? null : controller.step.organizationId, + roles: null, + defaultRole: null, + onInvite: createOrganization.onInvite, + onSent: controller.onInvitationsSent, + }); + + return ( + + ); +} + +export function Default(_args: Record) { + return ; +} + +export function WithoutCreate(_args: Record) { + return ; +} + +export function Empty(_args: Record) { + return ; +} + +export function WithDefaults(_args: Record) { + return ( + + ); +} + +export function Unavailable(_args: Record) { + return ( + + ); +} + +export function Loading(_args: Record) { + return ( + + ); +} + +export function WithError(_args: Record) { + return ; +} From b405cb2c8ee53aabd57ef3ed8757ae3e23baeb5d Mon Sep 17 00:00:00 2001 From: Kyle MacDonald Date: Wed, 4 Nov 2026 17:11:21 -0500 Subject: [PATCH 2/2] fix(mosaic): show a pending suggestion as a disabled button --- .../__tests__/organization-list.feature.test.tsx | 8 +++++++- .../organization-list/organization-list.view.tsx | 12 ++++-------- packages/swingset/src/stories/organization-list.mdx | 3 ++- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/mosaic/src/features/organization-list/__tests__/organization-list.feature.test.tsx b/packages/mosaic/src/features/organization-list/__tests__/organization-list.feature.test.tsx index 53b8246ab8f..3156b33e1ba 100644 --- a/packages/mosaic/src/features/organization-list/__tests__/organization-list.feature.test.tsx +++ b/packages/mosaic/src/features/organization-list/__tests__/organization-list.feature.test.tsx @@ -193,9 +193,15 @@ describe('OrganizationList', () => { await user.click(screen.getByRole('button', { name: 'Request to join' })); - expect(await screen.findByText('Pending approval')).toBeVisible(); + const pending = await screen.findByRole('button', { name: 'Pending approval' }); + await waitFor(() => expect(pending).not.toHaveAttribute('aria-busy')); + expect(pending).toHaveAttribute('aria-disabled', 'true'); + expect(pending).toHaveFocus(); expect(screen.queryByRole('button', { name: 'Request to join' })).toBeNull(); expect(fapi.suggestions[0]?.status).toBe('accepted'); + + await user.click(pending); + expect(fapi.suggestions[0]?.status).toBe('accepted'); }); it('stands the other rows down while one action runs, and shows why it failed', async () => { diff --git a/packages/mosaic/src/features/organization-list/organization-list.view.tsx b/packages/mosaic/src/features/organization-list/organization-list.view.tsx index 4886520f52e..d8635429635 100644 --- a/packages/mosaic/src/features/organization-list/organization-list.view.tsx +++ b/packages/mosaic/src/features/organization-list/organization-list.view.tsx @@ -366,11 +366,7 @@ function OfferRow({ name, imageUrl, actionLabel, note, busy, disabled, onAccept {name} - {note ? ( - - {note} - - ) : onAccept ? ( + {note || onAccept ? ( - {actionLabel} + {note ?? actionLabel} ) : null} diff --git a/packages/swingset/src/stories/organization-list.mdx b/packages/swingset/src/stories/organization-list.mdx index 436c58ec7c1..9a0dc98591a 100644 --- a/packages/swingset/src/stories/organization-list.mdx +++ b/packages/swingset/src/stories/organization-list.mdx @@ -44,7 +44,8 @@ invitations, then suggestions. Invitations and suggestions appear once every pag loaded. More pages load as the foot of the list scrolls into view. **Join** accepts an invitation, and the row then selects its organization like any other. -**Request to join** files a request on a suggestion, and the row then reads **Pending approval**. +**Request to join** files a request on a suggestion, and the button then reads **Pending approval** +and is disabled. ## Without a personal account