Skip to content

feat(clerk-js,clerk-react,types): Introduce TaskChooseOrganization #6446

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
96dd1b0
Remove tasks handling from orgs AIOs
LauraBeatris Jul 30, 2025
0f1f9b8
Add component to sandbox playground
LauraBeatris Jul 30, 2025
c1bbf90
Add localization keys
LauraBeatris Jul 30, 2025
0309250
Add basic UI structure with header and footer
LauraBeatris Jul 30, 2025
52bd271
Add initial structure for create org screen
LauraBeatris Jul 30, 2025
0469b25
chore: format template.html
iagodahlem Jul 31, 2025
798efa1
Add basic version for org list screen
LauraBeatris Jul 31, 2025
dfdee22
Extract organization avatar to a shared component
LauraBeatris Jul 31, 2025
6fc564b
Extract organization preview components to shared module
LauraBeatris Jul 31, 2025
d99c7d3
Introduce specific descriptors for `TaskSelectOrganization`
LauraBeatris Jul 31, 2025
8a06eff
refactor: update TaskSelectOrganization spacing
iagodahlem Jul 31, 2025
e7966b6
fix: add missing translation keys
iagodahlem Jul 31, 2025
7415b35
feat: implement signout action
iagodahlem Jul 31, 2025
9365dd2
chore: update card gap
iagodahlem Jul 31, 2025
5a8bf60
Always display slug field on create form
LauraBeatris Jul 31, 2025
38ce37e
Handle sign out with multi-session
LauraBeatris Jul 31, 2025
9498d48
Do not render identifier if not email or username
LauraBeatris Jul 31, 2025
5395dde
Add unit tests
LauraBeatris Jul 31, 2025
f6ae7f6
Extract `useOrganizationListInView` to separate file
LauraBeatris Jul 31, 2025
937abcd
Do not use OrganizationList components
LauraBeatris Jul 31, 2025
4a9fa4a
Use common components on OrganizationList
LauraBeatris Jul 31, 2025
e5a7f47
Add changeset
LauraBeatris Jul 31, 2025
98af5a0
chore: format template.html
iagodahlem Aug 1, 2025
d40e808
Truncate identifier
iagodahlem Aug 1, 2025
cff4295
Fix data mocking for tests
LauraBeatris Aug 1, 2025
edda41b
Bump `clerk.js` max bundle size
LauraBeatris Aug 1, 2025
9aff5a7
Use default gap and remove unused styles
LauraBeatris Aug 1, 2025
af167c4
Update gap for avatar uploader button
LauraBeatris Aug 1, 2025
1485c58
Update horizontal gap for sign out action
LauraBeatris Aug 1, 2025
6ee7498
Update icon for avatar uploader
LauraBeatris Aug 1, 2025
3978ebc
Update localization keys to remove `screen` prefix
LauraBeatris Aug 1, 2025
6a84a0d
Update identifier truncate
iagodahlem Aug 1, 2025
7313027
Fix footer layout
LauraBeatris Aug 1, 2025
b11fe3b
fix: address footer action alignment
iagodahlem Aug 1, 2025
25b2551
Update guard to update to sign-in on undefined session
LauraBeatris Aug 2, 2025
a4e3c51
Revalidate server cache on `navigateToTask`
LauraBeatris Aug 4, 2025
3fe79ee
Fix unit tests mock for `isEligibleForTouch`
LauraBeatris Aug 4, 2025
0192b52
Remove logo field from create org form
LauraBeatris Aug 5, 2025
1fc4022
Introduce different headings per screen
LauraBeatris Aug 5, 2025
bac7a96
Update copy per feedback
LauraBeatris Aug 6, 2025
0b5fac0
Rollback unused changes
LauraBeatris Aug 6, 2025
caac240
Rename `TaskSelectOrganization` to `TaskChooseOrganization`
LauraBeatris Aug 6, 2025
9497c2c
Introduce new changeset
LauraBeatris Aug 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/brown-garlics-boil.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@clerk/tanstack-react-start': patch
'@clerk/localizations': patch
'@clerk/react-router': patch
'@clerk/clerk-js': patch
'@clerk/testing': patch
'@clerk/nextjs': patch
'@clerk/clerk-react': patch
'@clerk/remix': patch
'@clerk/types': patch
---

Introduce `TaskChooseOrganization` component which replaces `TaskSelectOrganization` with a new UI that make the experience similar to the previous `SignIn` and `SignUp` steps
4 changes: 2 additions & 2 deletions integration/tests/session-tasks-eject-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,11 @@ return (
.addFile(
'src/app/onboarding/select-organization/page.tsx',
() => `
import { TaskSelectOrganization } from '@clerk/nextjs';
import { TaskChooseOrganization } from '@clerk/nextjs';

export default function Page() {
return (
<TaskSelectOrganization redirectUrlComplete='/'/>
<TaskChooseOrganization redirectUrlComplete='/'/>
);
}`,
)
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"files": [
{ "path": "./dist/clerk.js", "maxSize": "618KB" },
{ "path": "./dist/clerk.js", "maxSize": "620KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "74KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "115.08KB" },
{ "path": "./dist/clerk.headless*.js", "maxSize": "55.2KB" },
Expand Down
12 changes: 11 additions & 1 deletion packages/clerk-js/sandbox/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as l from '../../localizations';
import type { Clerk as ClerkType } from '../';
import * as l from '../../localizations';

const AVAILABLE_LOCALES = Object.keys(l) as (keyof typeof l)[];

Expand Down Expand Up @@ -35,6 +35,7 @@ const AVAILABLE_COMPONENTS = [
'pricingTable',
'apiKeys',
'oauthConsent',
'taskChooseOrganization',
] as const;

const COMPONENT_PROPS_NAMESPACE = 'clerk-js-sandbox';
Expand Down Expand Up @@ -95,6 +96,7 @@ const componentControls: Record<(typeof AVAILABLE_COMPONENTS)[number], Component
pricingTable: buildComponentControls('pricingTable'),
apiKeys: buildComponentControls('apiKeys'),
oauthConsent: buildComponentControls('oauthConsent'),
taskChooseOrganization: buildComponentControls('taskChooseOrganization'),
};

declare global {
Expand Down Expand Up @@ -335,6 +337,14 @@ void (async () => {
},
);
},
'/task-choose-organization': () => {
Clerk.mountTaskChooseOrganization(
app,
componentControls.taskChooseOrganization.getProps() ?? {
redirectUrlComplete: '/user-profile',
},
);
},
'/open-sign-in': () => {
mountOpenSignInButton(app, componentControls.signIn.getProps() ?? {});
},
Expand Down
8 changes: 8 additions & 0 deletions packages/clerk-js/sandbox/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,14 @@
OAuthConsent
</a>
</li>
<li class="relative">
<a
class="relative isolate flex w-full rounded-md border border-white px-2 py-[0.4375rem] text-sm hover:bg-gray-50 aria-[current]:bg-gray-50"
href="/task-choose-organization"
>
TaskChooseOrganization
</a>
</li>
<li class="relative">
<a
class="relative isolate flex w-full rounded-md border border-white px-2 py-[0.4375rem] text-sm hover:bg-gray-50 aria-[current]:bg-gray-50"
Expand Down
16 changes: 14 additions & 2 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,7 @@ describe('Clerk singleton', () => {
signUp: new SignUp({
status: 'complete',
} as any as SignUpJSON),
isEligibleForTouch: () => false,
}),
);

Expand Down Expand Up @@ -995,6 +996,7 @@ describe('Clerk singleton', () => {
signedInSessions: [mockResource],
signIn: new SignIn(null),
signUp: new SignUp(null),
isEligibleForTouch: () => false,
}),
);

Expand Down Expand Up @@ -2451,7 +2453,12 @@ describe('Clerk singleton', () => {

beforeEach(() => {
mockResource.touch.mockReturnValueOnce(Promise.resolve());
mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockResource] }));
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [mockResource],
isEligibleForTouch: () => false,
}),
);
});

afterEach(() => {
Expand Down Expand Up @@ -2516,7 +2523,12 @@ describe('Clerk singleton', () => {

it('navigates to redirect url on completion', async () => {
mockSession.touch.mockReturnValue(Promise.resolve());
mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] }));
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [mockSession],
isEligibleForTouch: () => false,
}),
);

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
Expand Down
41 changes: 32 additions & 9 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ import type {
SignUpProps,
SignUpRedirectOptions,
SignUpResource,
TaskSelectOrganizationProps,
TaskChooseOrganizationProps,
UnsubscribeCallback,
UserButtonProps,
UserProfileProps,
Expand Down Expand Up @@ -1163,31 +1163,31 @@ export class Clerk implements ClerkInterface {
void this.#componentControls.ensureMounted().then(controls => controls.unmountComponent({ node }));
};

public mountTaskSelectOrganization = (node: HTMLDivElement, props?: TaskSelectOrganizationProps) => {
public mountTaskChooseOrganization = (node: HTMLDivElement, props?: TaskChooseOrganizationProps) => {
this.assertComponentsReady(this.#componentControls);

if (disabledOrganizationsFeature(this, this.environment)) {
if (this.#instanceType === 'development') {
throw new ClerkRuntimeError(warnings.cannotRenderAnyOrganizationComponent('TaskSelectOrganization'), {
throw new ClerkRuntimeError(warnings.cannotRenderAnyOrganizationComponent('TaskChooseOrganization'), {
code: CANNOT_RENDER_ORGANIZATIONS_DISABLED_ERROR_CODE,
});
}
return;
}

void this.#componentControls.ensureMounted({ preloadHint: 'TaskSelectOrganization' }).then(controls =>
void this.#componentControls.ensureMounted({ preloadHint: 'TaskChooseOrganization' }).then(controls =>
controls.mountComponent({
name: 'TaskSelectOrganization',
appearanceKey: 'taskSelectOrganization',
name: 'TaskChooseOrganization',
appearanceKey: 'taskChooseOrganization',
node,
props,
}),
);

this.telemetry?.record(eventPrebuiltComponentMounted('TaskSelectOrganization', props));
this.telemetry?.record(eventPrebuiltComponentMounted('TaskChooseOrganization', props));
};

public unmountTaskSelectOrganization = (node: HTMLDivElement) => {
public unmountTaskChooseOrganization = (node: HTMLDivElement) => {
this.assertComponentsReady(this.#componentControls);
void this.#componentControls.ensureMounted().then(controls => controls.unmountComponent({ node }));
};
Expand Down Expand Up @@ -1388,6 +1388,16 @@ export class Clerk implements ClerkInterface {
public __internal_navigateToTaskIfAvailable = async ({
redirectUrlComplete,
}: __internal_NavigateToTaskIfAvailableParams = {}): Promise<void> => {
const onBeforeSetActive: SetActiveHook =
typeof window !== 'undefined' && typeof window.__unstable__onBeforeSetActive === 'function'
? window.__unstable__onBeforeSetActive
: noop;

const onAfterSetActive: SetActiveHook =
typeof window !== 'undefined' && typeof window.__unstable__onAfterSetActive === 'function'
? window.__unstable__onAfterSetActive
: noop;

const session = this.session;
if (!session || !this.environment) {
return;
Expand All @@ -1403,17 +1413,30 @@ export class Clerk implements ClerkInterface {
return;
}

await onBeforeSetActive();

if (redirectUrlComplete) {
const tracker = createBeforeUnloadTracker(this.#options.standardBrowser);

await tracker.track(async () => {
await this.navigate(redirectUrlComplete);
if (!this.client) {
return;
}

if (this.client.isEligibleForTouch()) {
const absoluteRedirectUrl = new URL(redirectUrlComplete, window.location.href);
await this.navigate(this.buildUrlWithAuth(this.client.buildTouchUrl({ redirectUrl: absoluteRedirectUrl })));
} else {
await this.navigate(redirectUrlComplete);
}
});

if (tracker.isUnloading()) {
return;
}
}

await onAfterSetActive();
};

public addListener = (listener: ListenerCallback): UnsubscribeCallback => {
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/warnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const createMessageForDisabledOrganizations = (
| 'OrganizationSwitcher'
| 'OrganizationList'
| 'CreateOrganization'
| 'TaskSelectOrganization',
| 'TaskChooseOrganization',
) => {
return formatWarning(
`The <${componentName}/> cannot be rendered when the feature is turned off. Visit 'dashboard.clerk.com' to enable the feature. Since the feature is turned off, this is no-op.`,
Expand All @@ -29,7 +29,7 @@ const warnings = {
cannotRenderSignUpComponentWhenTaskExists:
'The <SignUp/> component cannot render when a user has a pending task, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, Clerk is redirecting to the task instead.',
cannotRenderComponentWhenTaskDoesNotExist:
'<TaskSelectOrganization/> cannot render unless a session task is pending. Clerk is redirecting to the value set in `redirectUrlComplete` instead.',
'<TaskChooseOrganization/> cannot render unless a session task is pending. Clerk is redirecting to the value set in `redirectUrlComplete` instead.',
cannotRenderSignInComponentWhenSessionExists:
'The <SignIn/> component cannot render when a user is already signed in, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, Clerk is redirecting to the `afterSignIn` URL instead.',
cannotRenderSignInComponentWhenTaskExists:
Expand Down
138 changes: 138 additions & 0 deletions packages/clerk-js/src/ui/common/organizations/OrganizationPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import type { UserOrganizationInvitationResource } from '@clerk/types';
import type { PropsWithChildren } from 'react';
import { forwardRef } from 'react';

import type { ElementDescriptor } from '@/ui/customizables/elementDescriptors';
import { OrganizationPreview } from '@/ui/elements/OrganizationPreview';
import { PreviewButton } from '@/ui/elements/PreviewButton';

import { Box, Button, Col, descriptors, Flex, Spinner } from '../../customizables';
import { SwitchArrowRight } from '../../icons';
import type { ThemableCssProp } from '../../styledSystem';
import { common } from '../../styledSystem';

type OrganizationPreviewListItemsProps = PropsWithChildren<{
elementDescriptor: ElementDescriptor;
}>;

export const OrganizationPreviewListItems = ({ elementDescriptor, children }: OrganizationPreviewListItemsProps) => {
return (
<Col
elementDescriptor={elementDescriptor}
sx={t => ({
maxHeight: `calc(8 * ${t.sizes.$12})`,
overflowY: 'auto',
borderTopWidth: t.borderWidths.$normal,
borderTopStyle: t.borderStyles.$solid,
borderTopColor: t.colors.$borderAlpha100,
...common.unstyledScrollbar(t),
})}
>
{children}
</Col>
);
};

const sharedStyles: ThemableCssProp = t => ({
padding: `${t.space.$4} ${t.space.$5}`,
});

export const sharedMainIdentifierSx: ThemableCssProp = t => ({
color: t.colors.$colorForeground,
':hover': {
color: t.colors.$colorForeground,
},
});

type OrganizationPreviewListItemProps = PropsWithChildren<{
elementId: React.ComponentProps<typeof OrganizationPreview>['elementId'];
elementDescriptor: React.ComponentProps<typeof OrganizationPreview>['elementDescriptor'];
organizationData: UserOrganizationInvitationResource['publicOrganizationData'];
}>;

export const OrganizationPreviewListItem = ({
children,
elementId,
elementDescriptor,
organizationData,
}: OrganizationPreviewListItemProps) => {
return (
<Flex
align='center'
gap={2}
sx={[
t => ({
minHeight: 'unset',
justifyContent: 'space-between',
borderTopWidth: t.borderWidths.$normal,
borderTopStyle: t.borderStyles.$solid,
borderTopColor: t.colors.$borderAlpha100,
}),
sharedStyles,
]}
elementDescriptor={elementDescriptor}
>
<OrganizationPreview
elementId={elementId}
mainIdentifierSx={sharedMainIdentifierSx}
organization={organizationData}
/>
{children}
</Flex>
);
};

export const OrganizationPreviewSpinner = forwardRef<HTMLDivElement>((_, ref) => {
return (
<Box
ref={ref}
sx={t => ({
width: '100%',
height: t.space.$12,
position: 'relative',
})}
>
<Box
sx={{
margin: 'auto',
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translateY(-50%) translateX(-50%)',
}}
>
<Spinner
size='sm'
colorScheme='primary'
elementDescriptor={descriptors.spinner}
/>
</Box>
</Box>
);
});

export const OrganizationPreviewListItemButton = (props: Parameters<typeof Button>[0]) => {
return (
<Button
textVariant='buttonSmall'
variant='outline'
size='xs'
{...props}
/>
);
};

type OrganizationPreviewButtonProps = PropsWithChildren<{
onClick: () => void | Promise<void>;
elementDescriptor: ElementDescriptor;
}>;

export const OrganizationPreviewButton = (props: OrganizationPreviewButtonProps) => {
return (
<PreviewButton
sx={[sharedStyles]}
icon={SwitchArrowRight}
{...props}
/>
);
};
Loading
Loading