Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ab31caa
feat(mosaic): list the active organization's pages in UserProfile
alexcarpenter Oct 28, 2026
980173c
test(mosaic): cover organizationProfileProps API keys and leave URL i…
alexcarpenter Oct 28, 2026
c555c8c
feat(mosaic): prefix organization page ids with organization/ so they…
alexcarpenter Oct 28, 2026
851638f
feat(mosaic): show the combined profile in the UserButton profile mod…
alexcarpenter Oct 28, 2026
b171411
docs(mosaic): document the profile page, select group and organizatio…
alexcarpenter Oct 28, 2026
0fc8cc2
fix(mosaic): keep custom profile page ids from colliding
austincalvelage Oct 28, 2026
97424eb
fix(mosaic): forward refs from Select.Group and Select.GroupLabel
austincalvelage Oct 28, 2026
dfb5a77
fix(mosaic): reset the open profile page when it leaves the navigation
austincalvelage Oct 28, 2026
e31fc63
docs(mosaic): note that organizationProfileProps.pageOrder takes unpr…
austincalvelage Oct 29, 2026
cf2bcee
refactor(mosaic): move profile navigation state into controllers
austincalvelage Oct 29, 2026
2b151eb
feat(mosaic): export UserProfile
austincalvelage Oct 29, 2026
3680afa
feat(mosaic): open the Mosaic UserProfile from UserButton
austincalvelage Oct 29, 2026
0b86fd7
test(mosaic): cover UserProfile and the UserButton profile modal in i…
austincalvelage Oct 29, 2026
37a4ba6
feat(mosaic): export OrganizationProfile and open the Mosaic profiles…
austincalvelage Oct 29, 2026
bde3694
test(mosaic): cover OrganizationProfile and the switcher profile moda…
austincalvelage Oct 29, 2026
2c19a24
feat(mosaic): reopen the user profile modal after a connected account…
Ephem Oct 29, 2026
2a0d34f
feat(mosaic): restore the opener's options when the user profile moda…
alexcarpenter Oct 29, 2026
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-profile-modal-oauth-return.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/mosaic-profile-modal-return-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-profile-combined.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-profile-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/thin-trains-lose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client';

import { MosaicProvider, OrganizationProfile } from '@clerk/mosaic';

export default function Page() {
return (
<MosaicProvider>
<OrganizationProfile
customPages={[{ label: 'Custom page', path: 'custom-page', content: <p>custom-page-content</p> }]}
/>
</MosaicProvider>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
'use client';

import { MosaicProvider, UserProfile } from '@clerk/mosaic';

export default function Page() {
return (
<MosaicProvider>
<UserProfile customPages={[{ label: 'Custom page', path: 'custom-page', content: <p>custom-page-content</p> }]} />
</MosaicProvider>
);
}
82 changes: 82 additions & 0 deletions integration/tests/mosaic/organization-profile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { Organization } from '@clerk/backend';
import type { BrowserContext, Page } from '@playwright/test';
import { expect, test } from '@playwright/test';

import type { FakeUser } from '../../testUtils';
import { createTestUtils, testAgainstRunningApps } from '../../testUtils';

testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic OrganizationProfile @mosaic', ({ app }) => {
test.describe.configure({ mode: 'serial' });

let fakeUser: FakeUser;
let organization: Organization;
const cleanups: Array<() => Promise<unknown>> = [];

test.beforeAll(async () => {
const u = await createTestUtils({ app });
fakeUser = u.services.users.createFakeUser(test);
cleanups.push(() => fakeUser.deleteIfExists());
const user = await u.services.users.createBapiUser(fakeUser);
organization = await u.services.clerk.organizations.createOrganization({
name: `Mosaic ${Date.now()}`,
createdBy: user.id,
});
cleanups.push(() => u.services.clerk.organizations.deleteOrganization(organization.id));
});

test.afterAll(async () => {
const results = await Promise.allSettled(cleanups.map(cleanup => cleanup()));
await app.teardown();
const failures = results.flatMap(result => (result.status === 'rejected' ? [result.reason] : []));
if (failures.length > 0) {
throw new AggregateError(failures, 'Mosaic OrganizationProfile cleanup failed');
}
});

const path = '/organization-profile';
const tab = (page: Page, name: string) => page.getByRole('tab', { name, exact: true });

async function signIn({ page, context }: { page: Page; context: BrowserContext }) {
const u = await createTestUtils({ app, page, context });
await page.setViewportSize({ width: 1280, height: 800 });
await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
await u.page.goToRelative(path);
await u.po.expect.toBeSignedIn();
await page.evaluate(orgId => window.Clerk?.setActive({ organization: orgId }), organization.id);
await page.waitForFunction(orgId => window.Clerk?.organization?.id === orgId, organization.id);
await expect(tab(page, 'General')).toBeVisible();
return u;
}

test('renders nothing when signed out', async ({ page, context }) => {
const u = await createTestUtils({ app, page, context });
await u.page.goToRelative(path);
await u.page.waitForClerkJsLoaded();

await expect(tab(page, 'General')).toHaveCount(0);
});

test('opens on the general page with the active organization', async ({ page, context }) => {
await signIn({ page, context });

await expect(tab(page, 'General')).toHaveAttribute('aria-selected', 'true');
await expect(page.getByText(organization.name).first()).toBeVisible();
});

test('switches to the members page', async ({ page, context }) => {
await signIn({ page, context });

await tab(page, 'Members').click();

await expect(tab(page, 'Members')).toHaveAttribute('aria-selected', 'true');
await expect(page.getByText(fakeUser.email)).toBeVisible();
});

test('renders a custom page', async ({ page, context }) => {
await signIn({ page, context });

await tab(page, 'Custom page').click();
await expect(page.getByText('custom-page-content')).toBeVisible();
});
});
6 changes: 4 additions & 2 deletions integration/tests/mosaic/organization-switcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,15 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })(
});

test('renders a custom page inside the OrganizationProfile modal', async ({ page, context }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await signIn({ page, context });

await trigger(page).click();
await popup(page).getByRole('button', { name: 'Settings' }).click();

const profile = page.locator('.cl-organizationProfile-root');
await profile.getByText('Custom page').click();
await expect(page.getByRole('tab', { name: 'General', exact: true })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.cl-organizationProfile-root')).toHaveCount(0);
await page.getByRole('tab', { name: 'Custom page', exact: true }).click();
await expect(page.getByText('custom-page-content')).toBeVisible();
});
},
Expand Down
21 changes: 17 additions & 4 deletions integration/tests/mosaic/user-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User
async function openProfileSettings(page: Page) {
await popup(page).getByRole('button', { name: 'Settings' }).click();
const profileSettings = page.getByRole('menuitem', { name: 'Manage profile' });
await profileSettings.or(page.locator('.cl-userProfile-root')).first().waitFor();
const profile = page.getByRole('tab', { name: 'Account', exact: true });
await profileSettings.or(profile).first().waitFor();
if (await profileSettings.isVisible()) {
await profileSettings.click();
}
Expand Down Expand Up @@ -101,14 +102,26 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User
await expect(page.getByText('custom-link-target')).toBeVisible();
});

test('opens the Mosaic UserProfile in a modal', async ({ page, context }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await signIn({ page, context });

await trigger(page).click();
await openProfileSettings(page);

await expect(page.getByRole('tab', { name: 'Account', exact: true })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.cl-userProfile-root')).toHaveCount(0);
await expect(popup(page)).toBeHidden();
});

test('renders a custom page inside the UserProfile modal', async ({ page, context }) => {
const u = await signIn({ page, context }, '/custom');
await page.setViewportSize({ width: 1280, height: 800 });
await signIn({ page, context }, '/custom');

await trigger(page).click();
await openProfileSettings(page);
await u.po.userProfile.waitForUserProfileModal();

await page.locator('.cl-userProfile-root').getByText('Custom page').click();
await page.getByRole('tab', { name: 'Custom page', exact: true }).click();
await expect(page.getByText('custom-page-content')).toBeVisible();
});
});
82 changes: 82 additions & 0 deletions integration/tests/mosaic/user-profile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { Organization } from '@clerk/backend';
import type { BrowserContext, Page } from '@playwright/test';
import { expect, test } from '@playwright/test';

import type { FakeUser } from '../../testUtils';
import { createTestUtils, testAgainstRunningApps } from '../../testUtils';

testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic UserProfile @mosaic', ({ app }) => {
test.describe.configure({ mode: 'serial' });

let fakeUser: FakeUser;
let organization: Organization;
const cleanups: Array<() => Promise<unknown>> = [];

test.beforeAll(async () => {
const u = await createTestUtils({ app });
fakeUser = u.services.users.createFakeUser(test);
cleanups.push(() => fakeUser.deleteIfExists());
const user = await u.services.users.createBapiUser(fakeUser);
organization = await u.services.clerk.organizations.createOrganization({
name: `Mosaic ${Date.now()}`,
createdBy: user.id,
});
cleanups.push(() => u.services.clerk.organizations.deleteOrganization(organization.id));
});

test.afterAll(async () => {
const results = await Promise.allSettled(cleanups.map(cleanup => cleanup()));
await app.teardown();
const failures = results.flatMap(result => (result.status === 'rejected' ? [result.reason] : []));
if (failures.length > 0) {
throw new AggregateError(failures, 'Mosaic UserProfile cleanup failed');
}
});

const path = '/user-profile';
const tab = (page: Page, name: string) => page.getByRole('tab', { name, exact: true });

async function signIn({ page, context }: { page: Page; context: BrowserContext }) {
const u = await createTestUtils({ app, page, context });
await page.setViewportSize({ width: 1280, height: 800 });
await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
await u.page.goToRelative(path);
await u.po.expect.toBeSignedIn();
await expect(tab(page, 'Account')).toBeVisible();
return u;
}

test('renders nothing when signed out', async ({ page, context }) => {
const u = await createTestUtils({ app, page, context });
await u.page.goToRelative(path);
await u.page.waitForClerkJsLoaded();

await expect(tab(page, 'Account')).toHaveCount(0);
});

test('opens on the account page with the signed-in user', async ({ page, context }) => {
await signIn({ page, context });

await expect(tab(page, 'Account')).toHaveAttribute('aria-selected', 'true');
await expect(page.getByText(fakeUser.email)).toBeVisible();
});

test("lists the active organization's pages after the account's", async ({ page, context }) => {
await signIn({ page, context });

await page.evaluate(orgId => window.Clerk?.setActive({ organization: orgId }), organization.id);
await page.waitForFunction(orgId => window.Clerk?.organization?.id === orgId, organization.id);

await tab(page, 'General').click();
await expect(tab(page, 'General')).toHaveAttribute('aria-selected', 'true');
await expect(page.getByText(organization.name).first()).toBeVisible();
});

test('renders a custom page', async ({ page, context }) => {
await signIn({ page, context });

await tab(page, 'Custom page').click();
await expect(page.getByText('custom-page-content')).toBeVisible();
});
});
1 change: 1 addition & 0 deletions packages/mosaic/src/components/profile/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { Profile } from './profile';
export type {
ProfileContentProps,
ProfileElevation,
ProfileNavGroupProps,
ProfileNavItemProps,
ProfileNavProps,
ProfileContentPanelProps,
Expand Down
16 changes: 16 additions & 0 deletions packages/mosaic/src/components/profile/profile.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,22 @@ export const styles = stylex.create({
minWidth: 0,
},

navGroup: {
gap: NAV_GAP,
display: 'flex',
flexDirection: 'column',
marginBlockStart: { default: null, ':not(:first-child)': space['4'] },
},

navGroupLabel: {
paddingBlock: space['1'],
paddingInline: space['2.5'],
color: colorVars['--cl-color-foreground-secondary'],
fontSize: typeScaleVars['--cl-text-xs-size'],
fontWeight: fontWeightVars['--cl-font-medium'],
lineHeight: typeScaleVars['--cl-text-xs-leading'],
},

navItem: {
borderColor: 'transparent',
borderRadius: radiusVars['--cl-radius-md'],
Expand Down
Loading
Loading