Skip to content

Commit 0fc8cc2

Browse files
fix(mosaic): keep custom profile page ids from colliding
Skip account custom pages under the organization/ prefix in UserProfile, and keep only the first custom page per path in both profiles, warning about the rest.
1 parent b171411 commit 0fc8cc2

7 files changed

Lines changed: 75 additions & 7 deletions

File tree

‎packages/mosaic/src/features/organization-profile/__tests__/organization-profile.layout.test.tsx‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
11
import { describe, expect, it } from 'vitest';
22

3-
import { getAvailableOrganizationProfilePages } from '../organization-profile.layout';
4-
import type { OrganizationProfilePages } from '../organization-profile.types';
3+
import { getAvailableOrganizationProfilePages, resolveOrganizationProfilePages } from '../organization-profile.layout';
4+
import type { CustomOrganizationProfilePage, OrganizationProfilePages } from '../organization-profile.types';
55

66
const general: OrganizationProfilePages['general'] = <p>General</p>;
77

8+
describe('resolveOrganizationProfilePages', () => {
9+
it('keeps the first custom page for a repeated path', () => {
10+
const audit: CustomOrganizationProfilePage = { label: 'Audit', path: 'audit', content: null };
11+
const otherAudit: CustomOrganizationProfilePage = { label: 'Other audit', path: 'audit', content: null };
12+
13+
expect(resolveOrganizationProfilePages(['general'], [audit, otherAudit])).toEqual([
14+
{ id: 'general' },
15+
{ id: 'audit', custom: audit },
16+
]);
17+
});
18+
});
19+
820
describe('getAvailableOrganizationProfilePages', () => {
921
it('lists api keys given content', () => {
1022
expect(getAvailableOrganizationProfilePages({ general, apiKeys: <p>API keys</p> })).toEqual(['general', 'apiKeys']);

‎packages/mosaic/src/features/organization-profile/organization-profile.layout.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Key } from '../../icons/glyphs/key';
55
import { SecurityLockSquare } from '../../icons/glyphs/security-lock-square';
66
import { Users } from '../../icons/glyphs/users';
77
import { applyOrder } from '../../utils/apply-order';
8+
import { withUniquePaths } from '../profile-pages/profile-pages.layout';
89
import type {
910
CustomOrganizationProfilePage,
1011
OrganizationProfilePageId,
@@ -43,14 +44,15 @@ export function resolveOrganizationProfilePages(
4344
customPages: readonly CustomOrganizationProfilePage[] = [],
4445
order?: readonly string[],
4546
): OrganizationProfileNavEntry[] {
46-
const customById = new Map(customPages.map(page => [page.path, page]));
47+
const uniqueCustomPages = withUniquePaths(customPages);
48+
const customById = new Map(uniqueCustomPages.map(page => [page.path, page]));
4749
const builtInIds = new Set<string>(builtIn);
4850
const entries: OrganizationProfileNavEntry[] = [
4951
...builtIn.map((id): OrganizationProfileNavEntry => {
5052
const custom = customById.get(id);
5153
return custom ? { id, custom } : { id };
5254
}),
53-
...customPages.filter(page => !builtInIds.has(page.path)).map(page => ({ id: page.path, custom: page })),
55+
...uniqueCustomPages.filter(page => !builtInIds.has(page.path)).map(page => ({ id: page.path, custom: page })),
5456
];
5557
return applyOrder(order, entries, entry => entry.id);
5658
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { logger } from '@clerk/shared/logger';
2+
3+
export function withUniquePaths<Page extends { path: string }>(pages: readonly Page[]): Page[] {
4+
const seen = new Set<string>();
5+
return pages.filter(page => {
6+
if (seen.has(page.path)) {
7+
logger.warnOnce(
8+
`[Clerk] Duplicate path "${page.path}" found in custom pages. This may cause unexpected behavior.`,
9+
);
10+
return false;
11+
}
12+
seen.add(page.path);
13+
return true;
14+
});
15+
}

‎packages/mosaic/src/features/user-profile/__tests__/user-profile.feature.test.tsx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,23 @@ describe('UserProfile with an active organization', () => {
252252
expect(screen.getByText('Organization security')).toBeVisible();
253253
});
254254

255+
it('skips an account page whose path is an organization page id', async () => {
256+
serveWithOrganization();
257+
await renderWithClerk(
258+
<UserProfile
259+
defaultPage='organization/general'
260+
customPages={[{ path: 'organization/general', label: 'Imposter', content: <p>Imposter content</p> }]}
261+
/>,
262+
);
263+
264+
expect(tabNames()).toEqual(['Account', 'Security', 'General', 'Members']);
265+
expect(screen.getByRole('tab', { name: 'General', description: 'Organization' })).toHaveAttribute(
266+
'aria-selected',
267+
'true',
268+
);
269+
expect(screen.queryByText('Imposter content')).not.toBeInTheDocument();
270+
});
271+
255272
it('passes the organization options to its pages', async () => {
256273
serveWithOrganization();
257274
await renderWithClerk(

‎packages/mosaic/src/features/user-profile/__tests__/user-profile.layout.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ describe('getAvailableUserProfilePages', () => {
2525
});
2626

2727
describe('resolveUserProfilePages', () => {
28+
it('keeps the first custom page for a repeated path', () => {
29+
const otherTerms: CustomProfilePage = { label: 'Other terms', path: 'terms', content: null };
30+
const otherAccount: CustomProfilePage = { label: 'Other account', path: 'account', content: null };
31+
const account: CustomProfilePage = { label: 'My account', path: 'account', content: null };
32+
33+
expect(resolveUserProfilePages(['account'], [account, terms, otherTerms, otherAccount])).toEqual([
34+
{ id: 'account', custom: account },
35+
{ id: 'terms', custom: terms },
36+
]);
37+
});
38+
2839
it('lists the built-ins, then the custom pages by path', () => {
2940
expect(resolveUserProfilePages(['account', 'security'], [terms, help])).toEqual([
3041
{ id: 'account' },

‎packages/mosaic/src/features/user-profile/user-profile.layout.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { CreditCard } from '../../icons/glyphs/credit-card';
44
import { ShieldCheck } from '../../icons/glyphs/shield-check';
55
import { UserCircle } from '../../icons/glyphs/user-circle';
66
import { applyOrder } from '../../utils/apply-order';
7+
import { withUniquePaths } from '../profile-pages/profile-pages.layout';
78
import type { CustomProfilePage, UserProfilePageId, UserProfilePages } from './user-profile.types';
89

910
/** The built-in pages in the order the profile lists them before a consumer reorders anything. */
@@ -42,14 +43,15 @@ export function resolveUserProfilePages(
4243
customPages: readonly CustomProfilePage[] = [],
4344
order?: readonly string[],
4445
): UserProfileNavEntry[] {
45-
const customById = new Map(customPages.map(page => [page.path, page]));
46+
const uniqueCustomPages = withUniquePaths(customPages);
47+
const customById = new Map(uniqueCustomPages.map(page => [page.path, page]));
4648
const builtInIds = new Set<string>(builtIn);
4749
const entries: UserProfileNavEntry[] = [
4850
...builtIn.map((id): UserProfileNavEntry => {
4951
const custom = customById.get(id);
5052
return custom ? { id, custom } : { id };
5153
}),
52-
...customPages.filter(page => !builtInIds.has(page.path)).map(page => ({ id: page.path, custom: page })),
54+
...uniqueCustomPages.filter(page => !builtInIds.has(page.path)).map(page => ({ id: page.path, custom: page })),
5355
];
5456
return applyOrder(order, entries, entry => entry.id);
5557
}

‎packages/mosaic/src/features/user-profile/user-profile.view.tsx‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,16 @@ export const UserProfileView = React.forwardRef<HTMLDivElement, UserProfileViewP
5252
) {
5353
const m = useMessages('userProfile');
5454
const organizationLabels = useMessages('organizationProfile').pages;
55-
const accountPages = userProfilePageList({ pages, customPages, pageOrder }, m.pages);
55+
const accountCustomPages = customPages?.filter(page => {
56+
if (!page.path.startsWith(ORGANIZATION_PAGE_PREFIX)) {
57+
return true;
58+
}
59+
logger.warnOnce(
60+
`[Clerk] UserProfile custom page "${page.path}" uses the reserved "${ORGANIZATION_PAGE_PREFIX}" prefix and was skipped.`,
61+
);
62+
return false;
63+
});
64+
const accountPages = userProfilePageList({ pages, customPages: accountCustomPages, pageOrder }, m.pages);
5665
const groups: ProfilePageGroup[] = organization
5766
? [
5867
{ label: m.accountGroup, pages: accountPages },

0 commit comments

Comments
 (0)