Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 25 additions & 0 deletions .changeset/mosaic-profile-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@clerk/mosaic': minor
---

Configure the profile on `MosaicProvider` with the new `profile` prop, so `UserButton`, `OrganizationSwitcher` and `UserProfile` share one setup. This makes pairings like a user-only `UserButton` beside an `OrganizationSwitcher` with one combined profile possible.

```tsx
<MosaicProvider profile={{ combined: true, userProfileProps, organizationProfileProps }}>
<OrganizationSwitcher />
<UserButton combined={false} />
</MosaicProvider>
```

Breaking changes:

- `UserButton`: `mode` is replaced by `combined`, which only decides what the menu lists. `mode='user'` becomes `combined={false}`, and to keep the account-only profile too, also set `profile={{ combined: false }}` on `MosaicProvider`.
- `UserButton` and `OrganizationSwitcher`: `userProfileProps` and `organizationProfileProps` move to `MosaicProvider`'s `profile`.
- `UserButton` and `OrganizationSwitcher`: `userProfileUrl` and `organizationProfileUrl` move to `MosaicProvider`'s `profile`.
- `UserButton` and `OrganizationSwitcher`: `userProfileMode` and `organizationProfileMode` are removed. Setting a URL routes there, and leaving it out opens the modal.
- `UserButton` and `OrganizationSwitcher`: `appearance` is no longer accepted in `userProfileProps` or `organizationProfileProps`.
- `UserButton` and `OrganizationSwitcher`: link items (`href`) are no longer accepted in `customPages`.
- `UserButton` and `OrganizationSwitcher`: `afterLeaveOrganizationUrl` moves to `profile.organizationProfileProps`, which also takes `apiKeysProps`.
- `UserProfile`: `mode` is replaced by `combined`. When unset it follows `profile.combined`, and it also picks up the provider's profile props, with its own props winning. `OrganizationProfile` picks them up the same way.
- With `experimental.newProfiles`, `OrganizationSwitcher` now opens the combined profile by default. Set `profile={{ combined: false }}` for the standalone organization profile.
- The `UserProfileMode` type is removed.
5 changes: 5 additions & 0 deletions .changeset/mosaic-switcher-sso-bypass-page-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/mosaic': patch
---

Fix the organization profile's page order when `OrganizationSwitcher` or `UserButton` opens the legacy profile for a member who can manage the SSO bypass allowlist. The Security page now stays where `pageOrder` puts it instead of moving to the top.
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { MosaicProvider, UserButton } from '@clerk/mosaic';

export default function Page() {
return (
<MosaicProvider experimental={{ newProfiles: true }}>
<UserButton
customMenuItems={[{ id: 'custom-link', label: 'Custom link', href: '/custom/link-target' }]}
userProfileProps={{
<MosaicProvider
experimental={{ newProfiles: true }}
profile={{
userProfileProps: {
customPages: [{ label: 'Custom page', path: 'custom-page', content: <p>custom-page-content</p> }],
}}
/>
},
}}
>
<UserButton customMenuItems={[{ id: 'custom-link', label: 'Custom link', href: '/custom/link-target' }]} />
</MosaicProvider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ import { MosaicProvider, OrganizationSwitcher } from '@clerk/mosaic';

export default function Page() {
return (
<MosaicProvider experimental={{ newProfiles: true }}>
<MosaicProvider
experimental={{ newProfiles: true }}
profile={{
organizationProfileProps: {
customPages: [{ label: 'Custom page', path: 'custom-page', content: <p>custom-page-content</p> }],
},
}}
>
<OrganizationSwitcher
customMenuItems={[{ id: 'custom-link', label: 'Custom link', href: '/custom/link-target' }]}
organizationProfileProps={{
customPages: [{ label: 'Custom page', path: 'custom-page', content: <p>custom-page-content</p> }],
}}
/>
</MosaicProvider>
);
Expand Down
6 changes: 3 additions & 3 deletions packages/mosaic/docs/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ drops the fallback instead of holding the space open. Keep the two apart.
pair so it cannot contradict itself:

```ts
type UserProfileMode =
| { userProfileUrl: string; userProfileMode?: 'navigation' }
| { userProfileUrl?: never; userProfileMode?: 'modal' };
type CreateOrganizationMode =
| { createOrganizationUrl: string; createOrganizationMode?: 'navigation' }
| { createOrganizationUrl?: never; createOrganizationMode?: 'modal' };
```

## Rules
Expand Down
3 changes: 3 additions & 0 deletions packages/mosaic/src/__tests__/feature/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { act, render } from '@testing-library/react';
import type { ReactElement } from 'react';
import { vi } from 'vitest';

import type { MosaicProfile } from '../../features/profile-pages/use-mosaic-profile';
import type { MosaicExperimental } from '../../hooks/use-mosaic-experimental';
import type { MosaicLocalization } from '../../localization';
import { MosaicProvider } from '../../mosaic-provider';
Expand All @@ -14,6 +15,7 @@ export async function renderWithClerk(
options?: Parameters<Clerk['load']>[0],
localization?: MosaicLocalization,
experimental?: MosaicExperimental,
profile?: MosaicProfile,
) {
const clerk = new Clerk(publishableKey());
const navigate = vi.fn((_to: string) => Promise.resolve());
Expand All @@ -26,6 +28,7 @@ export async function renderWithClerk(
<MosaicProvider
localization={localization}
experimental={experimental}
profile={profile}
>
{element}
</MosaicProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ReactNode } from 'react';

import type { APIKeysPageProps } from '../api-keys/api-keys-table';
import { useProfilePagesController } from '../profile-pages/profile-pages.controller';
import { useMosaicProfile } from '../profile-pages/use-mosaic-profile';
import { organizationProfilePageIds } from './organization-profile.layout';
import { useOrganizationProfileModel } from './organization-profile.model';
import type { OrganizationProfileOptions } from './organization-profile.provider';
Expand Down Expand Up @@ -79,14 +80,16 @@ export interface OrganizationProfileProps
* <OrganizationProfile afterLeaveOrganizationUrl='/organizations' />
* ```
*/
export function OrganizationProfile({
customPages,
pageOrder,
defaultPage,
afterLeaveOrganizationUrl,
fallback,
apiKeysProps,
}: OrganizationProfileProps) {
export function OrganizationProfile(props: OrganizationProfileProps) {
const { organizationProfileProps } = useMosaicProfile();
const {
customPages = organizationProfileProps?.customPages,
pageOrder = organizationProfileProps?.pageOrder,
defaultPage,
afterLeaveOrganizationUrl = organizationProfileProps?.afterLeaveOrganizationUrl,
fallback,
apiKeysProps = organizationProfileProps?.apiKeysProps,
} = props;
const model = useOrganizationProfileModel();
const pages = model.status === 'ready' ? organizationProfilePanels(model.pages, apiKeysProps) : undefined;
const navigation = useProfilePagesController(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
fapiUser,
} from '../../../__tests__/feature/fapi';
import { renderWithClerk } from '../../../__tests__/feature/render';
import type { MosaicProfile } from '../../profile-pages/use-mosaic-profile';
import type { OrganizationSwitcherProps } from '../organization-switcher';
import { OrganizationSwitcher } from '../organization-switcher';

Expand Down Expand Up @@ -71,9 +72,13 @@ function tree(props: OrganizationSwitcherProps = {}) {
);
}

function renderSwitcher(props: OrganizationSwitcherProps = {}, seed: FakeFapiSeed = signedIn()) {
function renderSwitcher(
props: OrganizationSwitcherProps = {},
seed: FakeFapiSeed = signedIn(),
profile?: MosaicProfile,
) {
const fapi = serveFapi(seed);
return renderWithClerk(tree(props)).then(view => ({ ...view, fapi }));
return renderWithClerk(tree(props), undefined, undefined, undefined, profile).then(view => ({ ...view, fapi }));
}

const host = () => screen.getByTestId('host');
Expand Down Expand Up @@ -222,7 +227,9 @@ describe('OrganizationSwitcher', () => {
});

it('tells the organization profile where to land after leaving', async () => {
const { clerk } = await renderSwitcher({ afterLeaveOrganizationUrl: '/left' }, noOffers());
const { clerk } = await renderSwitcher({}, noOffers(), {
organizationProfileProps: { afterLeaveOrganizationUrl: '/left' },
});
const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
const user = await openWithList();

Expand Down Expand Up @@ -298,6 +305,38 @@ describe('OrganizationSwitcher', () => {
expect(reading('Settings', 'Terms of service', 'Support')).toEqual(['Support', 'Terms of service', 'Settings']);
});

describe('the organization profile page order', () => {
const termsPage = { label: 'Terms', path: 'terms', icon: <span />, content: <p>Terms body</p> };

async function openedPageLabels(permissions: string[]) {
const memberships = [fapiMembership(acme, { permissions }), fapiMembership(other)];
const user = fapiUser({ ...alice, organization_memberships: memberships });
const session = fapiSession({ id: 'sess_1', user, last_active_organization_id: 'org_1' });
const { clerk } = await renderSwitcher({}, noOffers({ client: fapiClient([session]), memberships }), {
organizationProfileProps: { customPages: [termsPage], pageOrder: ['terms', 'security'] },
});
const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
const clicker = await openWithList();

await clicker.click(screen.getByRole('button', { name: 'Settings' }));

return openOrganizationProfile.mock.calls[0]?.[0]?.customPages?.map(page => page.label);
}

it('keeps Security where it was asked for when the member can manage the SSO bypass allowlist', async () => {
expect(await openedPageLabels(['org:sys_entconns_sso_bypass:manage'])).toEqual([
'Terms',
'security',
'general',
'members',
]);
});

it('leaves Security out when the organization profile will not show it', async () => {
expect(await openedPageLabels([])).toEqual(['Terms', 'general', 'members']);
});
});

it('themes its parts through organization-switcher slots', async () => {
await renderSwitcher({}, noOffers());
await openWithList();
Expand Down Expand Up @@ -378,11 +417,11 @@ describe('OrganizationSwitcher custom trigger badges', () => {
});

describe('OrganizationSwitcher with experimental.newProfiles', () => {
async function renderWithNewProfiles(props: OrganizationSwitcherProps = {}) {
async function renderWithNewProfiles(profile: MosaicProfile = { combined: false }) {
await page.viewport(1280, 800);
onTestFinished(() => page.viewport(414, 896));
serveFapi(noOffers());
return renderWithClerk(tree(props), undefined, undefined, { newProfiles: true });
return renderWithClerk(tree(), undefined, undefined, { newProfiles: true }, profile);
}

it('opens the OrganizationProfile, without the account pages, from Settings', async () => {
Expand All @@ -397,8 +436,14 @@ describe('OrganizationSwitcher with experimental.newProfiles', () => {
expect(openOrganizationProfile).not.toHaveBeenCalled();
});

it('lands on afterLeaveOrganizationUrl after leaving from the opened profile', async () => {
const { navigate } = await renderWithNewProfiles({ afterLeaveOrganizationUrl: '/left' });
it.each([
{ name: 'separate', combined: false },
{ name: 'combined', combined: true },
])('lands on afterLeaveOrganizationUrl after leaving from the opened profile: $name', async ({ combined }) => {
const { navigate } = await renderWithNewProfiles({
combined,
organizationProfileProps: { afterLeaveOrganizationUrl: '/left' },
});
const user = await openWithList();

await user.click(screen.getByRole('button', { name: 'Settings' }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import type { OrganizationSwitcherModelOptions } from '../switcher/switcher.mode
import type { SwitcherMenuProps } from '../switcher/switcher.types';
import type { SwitcherTriggerProps } from '../switcher/switcher.view';

/** Everything `<OrganizationSwitcher />` takes: profile routing, trigger content, the app's own menu rows, and the profile it opens. */
/** Everything `<OrganizationSwitcher />` takes: trigger content, and the app's own menu rows. */
export type OrganizationSwitcherProps = OrganizationSwitcherModelOptions &
SwitcherTriggerProps &
SwitcherMenuProps &
Pick<SwitcherProps, 'organizationProfileProps' | 'fallback'>;
Pick<SwitcherProps, 'fallback'>;

/**
* The active organization's avatar, and the menu behind it: switch organization or back to the
Expand All @@ -35,26 +35,22 @@ export type OrganizationSwitcherProps = OrganizationSwitcherModelOptions &
* ```
*
* @example
* Passing a URL routes to a page of your own instead of opening Clerk's modal. `afterSelectOrganizationUrl`
* is where switching organization lands, and takes a `:param` template, a plain path, or a function.
* `createOrganizationUrl` routes to a page of your own instead of opening Clerk's modal. `afterSelectOrganizationUrl`
* is where switching organization lands, and takes a `:param` template, a plain path, or a function. Set
* `organizationProfileUrl` on `MosaicProvider`'s `profile` to route Settings to a page of your own.
* ```tsx
* <OrganizationSwitcher
* organizationProfileUrl='/settings/organization'
* createOrganizationUrl='/orgs/new'
* afterSelectOrganizationUrl='/orgs/:slug'
* afterSelectPersonalUrl='/dashboard'
* />
* ```
*
* @example
* `customPages` adds your own pages to the organization profile; `customMenuItems` adds your own rows
* beside Settings, each one either an `onClick` action or an `href` link.
* `customMenuItems` adds your own rows beside Settings, each one either an `onClick` action or an
* `href` link. The profile's own pages are set on `MosaicProvider`'s `profile`.
* ```tsx
* <OrganizationSwitcher
* organizationProfileProps={{
* customPages: [{ path: 'audit', label: 'Audit log', icon: <ListIcon />, content: <AuditPage /> }],
* pageOrder: ['general', 'members', 'audit'],
* }}
* customMenuItems={[{ id: 'docs', label: 'Documentation', icon: <BookIcon />, href: 'https://example.com/docs' }]}
* />
* ```
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { useState } from 'react';

export function useProfilePagesController(initialPage: string, pageIds: readonly string[] | undefined) {
export function useProfilePagesController(
initialPage: string,
pageIds: readonly string[] | undefined,
openingFallbackPage?: string,
) {
const [activePage, setActivePage] = useState(initialPage);
const [isOpening, setIsOpening] = useState(true);

if (pageIds) {
const [firstPage] = pageIds;
if (firstPage !== undefined && !pageIds.includes(activePage)) {
setActivePage(firstPage);
if (isOpening) {
setIsOpening(false);
}
const fallbackPage = (isOpening ? openingFallbackPage : undefined) ?? pageIds[0];
if (fallbackPage !== undefined && !pageIds.includes(activePage)) {
setActivePage(fallbackPage);
}
}

Expand Down
76 changes: 76 additions & 0 deletions packages/mosaic/src/features/profile-pages/use-mosaic-profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { createContext, useContext } from 'react';

import type { OrganizationProfileProps } from '../organization-profile/organization-profile';
import type { OrganizationProfilePageId } from '../organization-profile/organization-profile.types';
import type { UserProfileProps } from '../user-profile/user-profile';
import type { CustomProfilePage, UserProfilePageId } from '../user-profile/user-profile.types';

export interface MosaicProfile {
/**
* Whether one profile carries the account's pages and the active organization's. `false` keeps them
* in separate profiles. It decides the profile's shape, not what `UserButton`'s menu lists.
*
* @default true
*/
combined?: boolean;
/**
* Options for the user profile, whichever component opens or mounts it.
*/
userProfileProps?: ProfileUserProps;
/**
* Options for the organization profile, or the organization's pages in a combined profile,
* whichever component opens or mounts it.
*/
organizationProfileProps?: ProfileOrganizationProps;
/**
* Full URL or path of your own user profile page. Setting it routes there instead of opening the modal.
*/
userProfileUrl?: string;
/**
* Full URL or path of your own organization profile page. Setting it routes there instead of opening the modal.
*/
organizationProfileUrl?: string;
}

/** What a profile takes beyond the profile component's own props. */
export interface ProfilePages<PageId extends string> {
/**
* Provide custom pages to be rendered inside the profile.
*/
customPages?: CustomProfilePage[];
/**
* Controls the order of the profile's navigation. Accepts the ids of built-in pages and the
* `path` of custom pages. Pages not listed are placed after the listed ones. The first entry is
* the page the profile opens on.
*
* @default undefined
*/
pageOrder?: (PageId | (string & {}))[];
}

/** Options for the underlying `<UserProfile />` component. */
export interface ProfileUserProps
extends ProfilePages<UserProfilePageId>, Pick<UserProfileProps, 'additionalOAuthScopes' | 'apiKeysProps'> {}

/** Options for the underlying `<OrganizationProfile />` component. */
export interface ProfileOrganizationProps
extends
ProfilePages<OrganizationProfilePageId>,
Pick<OrganizationProfileProps, 'afterLeaveOrganizationUrl' | 'apiKeysProps'> {}

const MosaicProfileContext = createContext<MosaicProfile>({});

export const MosaicProfileProvider = MosaicProfileContext.Provider;

export function useMosaicProfile(): MosaicProfile {
return useContext(MosaicProfileContext);
}

/** What the user profile modal opens with, whichever path opens it. */
export function userProfileModalOptions({
combined = true,
userProfileProps,
organizationProfileProps,
}: MosaicProfile) {
return { ...userProfileProps, combined, organizationProfileProps };
}
Loading
Loading