diff --git a/.changeset/shy-pages-share.md b/.changeset/shy-pages-share.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/shy-pages-share.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/.changeset/thin-trains-lose.md b/.changeset/thin-trains-lose.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/thin-trains-lose.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx
index afc43fae780..f52f2cadd02 100644
--- a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx
+++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx
@@ -1,4 +1,4 @@
-import { screen, waitFor, within } from '@testing-library/react';
+import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeAll, describe, expect, it } from 'vitest';
import { page } from 'vitest/browser';
@@ -177,4 +177,26 @@ describe('OrganizationProfile', () => {
await waitFor(() => expect(fapi.memberships).toHaveLength(0));
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/organizations'));
});
+
+ it('keeps the shown page after the open one leaves the navigation', async () => {
+ const globex = fapiOrganization({ id: 'org_2', name: 'Globex', slug: 'globex' });
+ const memberships = [
+ fapiMembership(acme, { permissions: ['org:sys_memberships:read'] }),
+ fapiMembership(globex, { permissions: [] }),
+ ];
+ const user = fapiUser({ id: 'user_1', organization_memberships: memberships });
+ serveFapi({
+ environment: fapiEnvironment(),
+ client: fapiClient([fapiSession({ id: 'sess_1', user, last_active_organization_id: acme.id })]),
+ memberships,
+ });
+ const { clerk } = await renderWithClerk();
+
+ await act(() => clerk.setActive({ organization: globex.id }));
+ await waitFor(() => expect(tabNames()).toEqual(['General']));
+ await act(() => clerk.setActive({ organization: acme.id }));
+ await waitFor(() => expect(tabNames()).toEqual(['General', 'Members']));
+
+ expect(profileNav().getByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true');
+ });
});
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts b/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts
index e2d14f244bd..176d7bd842c 100644
--- a/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts
+++ b/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts
@@ -9,6 +9,7 @@ import { withUniquePaths } from '../profile-pages/profile-pages.layout';
import type {
CustomOrganizationProfilePage,
OrganizationProfilePageId,
+ OrganizationProfilePageListOptions,
OrganizationProfilePages,
} from './organization-profile.types';
@@ -56,3 +57,15 @@ export function resolveOrganizationProfilePages(
];
return applyOrder(order, entries, entry => entry.id);
}
+
+export function organizationProfileNavEntries({
+ pages,
+ customPages,
+ pageOrder,
+}: OrganizationProfilePageListOptions): OrganizationProfileNavEntry[] {
+ return resolveOrganizationProfilePages(getAvailableOrganizationProfilePages(pages), customPages, pageOrder);
+}
+
+export function organizationProfilePageIds(options: OrganizationProfilePageListOptions): string[] {
+ return organizationProfileNavEntries(options).map(entry => entry.id);
+}
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.tsx
index a3b5349aaae..9d736de54df 100644
--- a/packages/mosaic/src/features/organization-profile/organization-profile.tsx
+++ b/packages/mosaic/src/features/organization-profile/organization-profile.tsx
@@ -1,9 +1,10 @@
'use client';
import type { ReactNode } from 'react';
-import { useState } from 'react';
import type { APIKeysPageProps } from '../api-keys/api-keys-table';
+import { useProfilePagesController } from '../profile-pages/profile-pages.controller';
+import { organizationProfilePageIds } from './organization-profile.layout';
import { useOrganizationProfileModel } from './organization-profile.model';
import type { OrganizationProfileOptions } from './organization-profile.provider';
import { OrganizationProfileProvider } from './organization-profile.provider';
@@ -87,14 +88,16 @@ export function OrganizationProfile({
apiKeysProps,
}: OrganizationProfileProps) {
const model = useOrganizationProfileModel();
- const [activePage, setActivePage] = useState(
+ const pages = model.status === 'ready' ? organizationProfilePanels(model.pages, apiKeysProps) : undefined;
+ const navigation = useProfilePagesController(
defaultPage ?? pageOrder?.[0] ?? 'general',
+ pages && organizationProfilePageIds({ pages, customPages, pageOrder }),
);
if (model.status === 'loading') {
return fallback ?? null;
}
- if (model.status === 'hidden') {
+ if (!pages) {
return null;
}
@@ -102,11 +105,10 @@ export function OrganizationProfile({
);
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.types.ts b/packages/mosaic/src/features/organization-profile/organization-profile.types.ts
index d632bbd9f7e..8c421e9a5bf 100644
--- a/packages/mosaic/src/features/organization-profile/organization-profile.types.ts
+++ b/packages/mosaic/src/features/organization-profile/organization-profile.types.ts
@@ -17,3 +17,16 @@ export interface CustomOrganizationProfilePage {
icon?: ReactNode;
content: ReactNode;
}
+
+/** What the organization's navigation is built from. */
+export interface OrganizationProfilePageListOptions {
+ /** The built-in pages. One left undefined is left out of the navigation. */
+ pages: OrganizationProfilePages;
+ /** Pages of the consumer's own, added to the navigation after the built-ins. */
+ customPages?: readonly CustomOrganizationProfilePage[];
+ /**
+ * The order the navigation runs in, by id: a built-in page's id, or a custom page's `path`. Ids
+ * left out keep their default place behind the ones named.
+ */
+ pageOrder?: readonly (OrganizationProfilePageId | (string & {}))[];
+}
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx
index 9f55147fe40..7db4186450b 100644
--- a/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx
+++ b/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx
@@ -5,29 +5,8 @@ import { Icon } from '../../components/icon';
import { useMessages } from '../../localization';
import type { ProfilePage, ProfilePagesViewProps } from '../profile-pages/profile-pages.view';
import { ProfilePagesView } from '../profile-pages/profile-pages.view';
-import {
- getAvailableOrganizationProfilePages,
- ORGANIZATION_PROFILE_PAGE_ICONS,
- resolveOrganizationProfilePages,
-} from './organization-profile.layout';
-import type {
- CustomOrganizationProfilePage,
- OrganizationProfilePageId,
- OrganizationProfilePages,
-} from './organization-profile.types';
-
-/** What the organization's navigation is built from. */
-export interface OrganizationProfilePageListOptions {
- /** The built-in pages. One left undefined is left out of the navigation. */
- pages: OrganizationProfilePages;
- /** Pages of the consumer's own, added to the navigation after the built-ins. */
- customPages?: readonly CustomOrganizationProfilePage[];
- /**
- * The order the navigation runs in, by id: a built-in page's id, or a custom page's `path`. Ids
- * left out keep their default place behind the ones named.
- */
- pageOrder?: readonly (OrganizationProfilePageId | (string & {}))[];
-}
+import { ORGANIZATION_PROFILE_PAGE_ICONS, organizationProfileNavEntries } from './organization-profile.layout';
+import type { OrganizationProfilePageId, OrganizationProfilePageListOptions } from './organization-profile.types';
export interface OrganizationProfileViewProps
extends
@@ -59,23 +38,22 @@ export const OrganizationProfileView = React.forwardRef,
): ProfilePage[] {
- return resolveOrganizationProfilePages(getAvailableOrganizationProfilePages(pages), customPages, pageOrder).map(
- entry =>
- entry.custom
- ? { id: entry.id, label: entry.custom.label, icon: entry.custom.icon, content: entry.custom.content }
- : {
- id: entry.id,
- label: labels[entry.id],
- icon: (
-
- ),
- content: pages[entry.id],
- },
+ return organizationProfileNavEntries(options).map(entry =>
+ entry.custom
+ ? { id: entry.id, label: entry.custom.label, icon: entry.custom.icon, content: entry.custom.content }
+ : {
+ id: entry.id,
+ label: labels[entry.id],
+ icon: (
+
+ ),
+ content: options.pages[entry.id],
+ },
);
}
diff --git a/packages/mosaic/src/features/profile-pages/profile-pages.controller.ts b/packages/mosaic/src/features/profile-pages/profile-pages.controller.ts
new file mode 100644
index 00000000000..bfd0d37f898
--- /dev/null
+++ b/packages/mosaic/src/features/profile-pages/profile-pages.controller.ts
@@ -0,0 +1,14 @@
+import { useState } from 'react';
+
+export function useProfilePagesController(initialPage: string, pageIds: readonly string[] | undefined) {
+ const [activePage, setActivePage] = useState(initialPage);
+
+ if (pageIds) {
+ const [firstPage] = pageIds;
+ if (firstPage !== undefined && !pageIds.includes(activePage)) {
+ setActivePage(firstPage);
+ }
+ }
+
+ return { activePage, onPageChange: setActivePage };
+}
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile.feature.test.tsx
index d73f1de4307..cf80f1acbd9 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.feature.test.tsx
@@ -1,4 +1,4 @@
-import { screen, waitFor, within } from '@testing-library/react';
+import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeAll, describe, expect, it } from 'vitest';
import { page } from 'vitest/browser';
@@ -281,4 +281,52 @@ describe('UserProfile with an active organization', () => {
);
expect(tabsDescribedBy('Organization')).toEqual(['Audit log', 'General', 'Members']);
});
+
+ it('keeps the shown page after the open organization page leaves the navigation', async () => {
+ const globex = fapiOrganization({ id: 'org_2', name: 'Globex' });
+ const memberships = [
+ fapiMembership(acme, { permissions: ['org:sys_memberships:read'] }),
+ fapiMembership(globex, { permissions: [] }),
+ ];
+ const user = fapiUser({ id: 'user_1', organization_memberships: memberships });
+ serveFapi({
+ environment: fapiEnvironment(),
+ client: fapiClient([fapiSession({ id: 'sess_1', user, last_active_organization_id: acme.id })]),
+ memberships,
+ });
+ const { clerk } = await renderWithClerk();
+
+ await act(() => clerk.setActive({ organization: globex.id }));
+ await waitFor(() => expect(tabsDescribedBy('Organization')).toEqual(['General']));
+ await act(() => clerk.setActive({ organization: acme.id }));
+ await waitFor(() => expect(tabsDescribedBy('Organization')).toEqual(['General', 'Members']));
+
+ expect(screen.getByRole('tab', { name: 'Account', description: 'Account' })).toHaveAttribute(
+ 'aria-selected',
+ 'true',
+ );
+ });
+
+ it('stays on the organization page when the next organization lists it too', async () => {
+ const globex = fapiOrganization({ id: 'org_2', name: 'Globex' });
+ const memberships = [
+ fapiMembership(acme, { permissions: ['org:sys_memberships:read'] }),
+ fapiMembership(globex, { permissions: ['org:sys_memberships:read'] }),
+ ];
+ const user = fapiUser({ id: 'user_1', organization_memberships: memberships });
+ serveFapi({
+ environment: fapiEnvironment(),
+ client: fapiClient([fapiSession({ id: 'sess_1', user, last_active_organization_id: acme.id })]),
+ memberships,
+ });
+ const { clerk } = await renderWithClerk();
+
+ await act(() => clerk.setActive({ organization: globex.id }));
+ await waitFor(() => expect(clerk.organization?.id).toBe(globex.id));
+
+ expect(screen.getByRole('tab', { name: 'Members', description: 'Organization' })).toHaveAttribute(
+ 'aria-selected',
+ 'true',
+ );
+ });
});
diff --git a/packages/mosaic/src/features/user-profile/user-profile.layout.ts b/packages/mosaic/src/features/user-profile/user-profile.layout.ts
index 2410672bf9e..e3df4d774f2 100644
--- a/packages/mosaic/src/features/user-profile/user-profile.layout.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile.layout.ts
@@ -1,9 +1,13 @@
+import { logger } from '@clerk/shared/logger';
+
import type { IconComponent } from '../../icons/glyph';
import { Code } from '../../icons/glyphs/code';
import { CreditCard } from '../../icons/glyphs/credit-card';
import { ShieldCheck } from '../../icons/glyphs/shield-check';
import { UserCircle } from '../../icons/glyphs/user-circle';
import { applyOrder } from '../../utils/apply-order';
+import { organizationProfilePageIds } from '../organization-profile/organization-profile.layout';
+import type { OrganizationProfilePageListOptions } from '../organization-profile/organization-profile.types';
import { withUniquePaths } from '../profile-pages/profile-pages.layout';
import type { CustomProfilePage, UserProfilePageId, UserProfilePages } from './user-profile.types';
@@ -55,3 +59,38 @@ export function resolveUserProfilePages(
];
return applyOrder(order, entries, entry => entry.id);
}
+
+type UserProfilePageListOptions = {
+ pages: UserProfilePages;
+ customPages?: readonly CustomProfilePage[];
+ pageOrder?: readonly string[];
+ organization?: OrganizationProfilePageListOptions;
+};
+
+export function userProfileNavEntries({
+ pages,
+ customPages,
+ pageOrder,
+}: UserProfilePageListOptions): UserProfileNavEntry[] {
+ return resolveUserProfilePages(getAvailableUserProfilePages(pages), withoutOrganizationPaths(customPages), pageOrder);
+}
+
+export function userProfilePageIds(options: UserProfilePageListOptions): string[] {
+ const accountPageIds = userProfileNavEntries(options).map(entry => entry.id);
+ const organizationPageIds = options.organization
+ ? organizationProfilePageIds(options.organization).map(id => `${ORGANIZATION_PAGE_PREFIX}${id}`)
+ : [];
+ return [...accountPageIds, ...organizationPageIds];
+}
+
+function withoutOrganizationPaths(customPages: readonly CustomProfilePage[] = []): CustomProfilePage[] {
+ return customPages.filter(page => {
+ if (!page.path.startsWith(ORGANIZATION_PAGE_PREFIX)) {
+ return true;
+ }
+ logger.warnOnce(
+ `[Clerk] UserProfile custom page "${page.path}" uses the reserved "${ORGANIZATION_PAGE_PREFIX}" prefix and was skipped.`,
+ );
+ return false;
+ });
+}
diff --git a/packages/mosaic/src/features/user-profile/user-profile.tsx b/packages/mosaic/src/features/user-profile/user-profile.tsx
index 1591a2ace70..99e07acb914 100644
--- a/packages/mosaic/src/features/user-profile/user-profile.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile.tsx
@@ -1,15 +1,16 @@
'use client';
import type { ReactNode } from 'react';
-import { useState } from 'react';
import type { APIKeysPageProps } from '../api-keys/api-keys-table';
import type { OrganizationProfileProps } from '../organization-profile/organization-profile';
import { organizationProfilePanels } from '../organization-profile/organization-profile';
import { useOrganizationProfileModel } from '../organization-profile/organization-profile.model';
import { OrganizationProfileProvider } from '../organization-profile/organization-profile.provider';
+import { useProfilePagesController } from '../profile-pages/profile-pages.controller';
import { useUserProfilePages } from '../switcher/switcher.pages';
import type { SwitcherMode } from '../switcher/switcher.types';
+import { userProfilePageIds } from './user-profile.layout';
import type { UserProfileOptions } from './user-profile.provider';
import { UserProfileProvider } from './user-profile.provider';
import type { UserProfileViewProps } from './user-profile.view';
@@ -54,7 +55,8 @@ export interface UserProfileProps
mode?: UserProfileMode;
/**
* Options for the organization's pages in `combined` mode: your own pages and their order, the API
- * keys page, and where leaving the organization lands.
+ * keys page, and where leaving the organization lands. `pageOrder` names the organization's pages
+ * without the `organization/` prefix, as in `['members', 'general']`.
*/
organizationProfileProps?: Pick<
OrganizationProfileProps,
@@ -130,9 +132,6 @@ export function UserProfile({
}: UserProfileProps) {
const model = useUserProfileUserModel();
const builtInPages = useUserProfilePages();
- const [activePage, setActivePage] = useState(
- defaultPage ?? pageOrder?.[0] ?? 'account',
- );
if (model.status === 'loading') {
return fallback ?? null;
@@ -141,10 +140,9 @@ export function UserProfile({
return null;
}
- const viewProps: UserProfileViewProps = {
+ const viewProps: AccountViewProps = {
elevation: 'flush',
- activePage,
- onPageChange: setActivePage,
+ initialPage: defaultPage ?? pageOrder?.[0] ?? 'account',
customPages,
pageOrder,
pages: {
@@ -161,7 +159,7 @@ export function UserProfile({
mode='mounted'
>
{mode === 'user' ? (
-
+
) : (
& {
+ initialPage: UserProfileViewProps['activePage'];
+};
+
+function AccountUserProfileView({ initialPage, ...viewProps }: AccountViewProps) {
+ const navigation = useProfilePagesController(initialPage, userProfilePageIds(viewProps));
+
+ return (
+
+ );
+}
+
function CombinedUserProfileView({
+ initialPage,
organizationProfileProps: { customPages, pageOrder, apiKeysProps, afterLeaveOrganizationUrl } = {},
...viewProps
-}: UserProfileViewProps & Pick) {
+}: AccountViewProps & Pick) {
const model = useOrganizationProfileModel();
+ const organization =
+ model.status === 'ready'
+ ? { pages: organizationProfilePanels(model.pages, apiKeysProps), customPages, pageOrder }
+ : undefined;
+ const navigation = useProfilePagesController(
+ initialPage,
+ model.status === 'loading' ? undefined : userProfilePageIds({ ...viewProps, organization }),
+ );
return (
);
diff --git a/packages/mosaic/src/features/user-profile/user-profile.view.tsx b/packages/mosaic/src/features/user-profile/user-profile.view.tsx
index 27994a99272..19845659935 100644
--- a/packages/mosaic/src/features/user-profile/user-profile.view.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile.view.tsx
@@ -3,17 +3,14 @@ import React from 'react';
import { Icon } from '../../components/icon';
import { useMessages } from '../../localization';
-import type { OrganizationProfilePageId } from '../organization-profile/organization-profile.types';
-import type { OrganizationProfilePageListOptions } from '../organization-profile/organization-profile.view';
+import type {
+ OrganizationProfilePageId,
+ OrganizationProfilePageListOptions,
+} from '../organization-profile/organization-profile.types';
import { organizationProfilePageList } from '../organization-profile/organization-profile.view';
import type { ProfilePage, ProfilePageGroup, ProfilePagesViewProps } from '../profile-pages/profile-pages.view';
import { ProfilePagesView } from '../profile-pages/profile-pages.view';
-import {
- getAvailableUserProfilePages,
- ORGANIZATION_PAGE_PREFIX,
- resolveUserProfilePages,
- USER_PROFILE_PAGE_ICONS,
-} from './user-profile.layout';
+import { ORGANIZATION_PAGE_PREFIX, USER_PROFILE_PAGE_ICONS, userProfileNavEntries } from './user-profile.layout';
import type { CustomProfilePage, UserProfilePageId, UserProfilePages } from './user-profile.types';
export interface UserProfileViewProps extends Omit<
@@ -52,16 +49,7 @@ export const UserProfileView = React.forwardRef {
- if (!page.path.startsWith(ORGANIZATION_PAGE_PREFIX)) {
- return true;
- }
- logger.warnOnce(
- `[Clerk] UserProfile custom page "${page.path}" uses the reserved "${ORGANIZATION_PAGE_PREFIX}" prefix and was skipped.`,
- );
- return false;
- });
- const accountPages = userProfilePageList({ pages, customPages: accountCustomPages, pageOrder }, m.pages);
+ const accountPages = userProfilePageList({ pages, customPages, pageOrder }, m.pages);
const groups: ProfilePageGroup[] = organization
? [
{ label: m.accountGroup, pages: accountPages },
@@ -93,7 +81,7 @@ function userProfilePageList(
{ pages, customPages, pageOrder }: Pick,
labels: Record,
): ProfilePage[] {
- return resolveUserProfilePages(getAvailableUserProfilePages(pages), customPages, pageOrder).map(entry =>
+ return userProfileNavEntries({ pages, customPages, pageOrder }).map(entry =>
entry.custom
? { id: entry.id, label: entry.custom.label, icon: entry.custom.icon, content: entry.custom.content }
: {