From 7cf9e545c13084d5eb85d6eb72ba13f417961c78 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 9 Oct 2026 19:31:35 -0400 Subject: [PATCH 1/3] feat(mosaic): add user and organization profile providers --- .changeset/mosaic-profile-providers.md | 2 + packages/mosaic/src/components/panel/index.ts | 2 +- .../mosaic/src/components/panel/panel.tsx | 23 +++++++-- .../src/components/profile/profile.test.tsx | 32 +++++++++++- ...on-profile-danger-section.feature.test.tsx | 19 ++++--- ...ganization-profile-danger-section.model.ts | 8 ++- .../organization-profile-danger-section.tsx | 3 +- ...ion-profile-general-panel.feature.test.tsx | 22 ++++++++ .../organization-profile.provider.tsx | 23 +++++++++ .../user-profile-profile-panel.view.test.tsx | 15 ++---- ...onnected-accounts.connect.feature.test.tsx | 15 ++++-- ...er-profile-connected-accounts.fixtures.tsx | 10 ++-- ...nected-accounts.reconnect.feature.test.tsx | 27 +++++++--- ...onnected-accounts.removal.feature.test.tsx | 51 ++++++++++++------- ...e-connected-accounts-section.controller.ts | 7 ++- ...rofile-connected-accounts-section.model.ts | 10 ++-- ...ser-profile-connected-accounts-section.tsx | 27 ++-------- ...ofile-enterprise-accounts.feature.test.tsx | 7 ++- ...ofile-enterprise-accounts-section.model.ts | 6 +-- ...er-profile-enterprise-accounts-section.tsx | 5 +- .../user-profile-profile-panel.tsx | 11 ++-- .../user-profile-profile-panel.view.tsx | 12 ++--- ...profile-web3-wallets-section.controller.ts | 7 ++- .../user-profile-web3-wallets-section.tsx | 13 +---- .../user-profile/user-profile.provider.tsx | 22 ++++++++ .../(clerk)/live/organization-danger/page.tsx | 7 ++- .../user-profile-connected-accounts.ts | 3 -- .../fixtures/user-profile-web3-wallets.ts | 3 -- .../src/stories/fixtures/user-profile.tsx | 8 ++- .../organization-profile-danger-section.mdx | 3 +- .../organization-profile-general-panel.mdx | 11 ++++ .../stories/user-profile-profile-panel.mdx | 11 ++++ .../user-profile-profile-panel.stories.tsx | 8 ++- 33 files changed, 279 insertions(+), 154 deletions(-) create mode 100644 .changeset/mosaic-profile-providers.md create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile.provider.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile.provider.tsx diff --git a/.changeset/mosaic-profile-providers.md b/.changeset/mosaic-profile-providers.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-profile-providers.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/panel/index.ts b/packages/mosaic/src/components/panel/index.ts index 015b71bc43c..5ffa79d605f 100644 --- a/packages/mosaic/src/components/panel/index.ts +++ b/packages/mosaic/src/components/panel/index.ts @@ -1,2 +1,2 @@ -export { Panel } from './panel'; +export { Panel, usePanelTitle } from './panel'; export type { PanelRootProps, PanelSectionsProps, PanelTitleProps } from './panel'; diff --git a/packages/mosaic/src/components/panel/panel.tsx b/packages/mosaic/src/components/panel/panel.tsx index 71de23b56e2..126b2f2b3ea 100644 --- a/packages/mosaic/src/components/panel/panel.tsx +++ b/packages/mosaic/src/components/panel/panel.tsx @@ -1,3 +1,4 @@ +import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; @@ -13,13 +14,22 @@ export type PanelRootProps = MosaicComponentProps<'div'>; export type PanelTitleProps = MosaicComponentProps<'div'>; export type PanelSectionsProps = MosaicComponentProps<'div'>; +const PanelTitleContext = React.createContext | null>(null); + +export function usePanelTitle(): () => HTMLDivElement | null { + const titleRef = React.useContext(PanelTitleContext); + return () => titleRef?.current ?? null; +} + const Root = React.forwardRef(function PanelRoot({ render, xstyle, ...rest }, ref) { - return useRender({ + const titleRef = React.useRef(null); + const element = useRender({ defaultTagName: 'div', render, ref, props: mergeStyleProps(themeProps('panel'), stylex.props(reset.base, styles.root, xstyle), rest), }); + return {element}; }); // Inside a profile page, the profile renders the page title, and the ref reaches that instead. @@ -29,16 +39,21 @@ const Title = React.forwardRef(function PanelTi ) { const inProfilePage = React.useContext(ContentPanelContext); const profile = React.useContext(ProfileContext); + const panelTitleRef = React.useContext(PanelTitleContext); + const titleRef = useMergeRefs([ref, panelTitleRef]); const level = useHeadingLevel(); const pageTitleRef = inProfilePage ? profile?.pageTitleRef : undefined; - React.useImperativeHandle(pageTitleRef ? ref : null, () => pageTitleRef?.current as HTMLDivElement); + React.useImperativeHandle(pageTitleRef ? titleRef : null, () => pageTitleRef?.current as HTMLDivElement); return useRender({ defaultTagName: 'div', render, - ref: pageTitleRef ? null : ref, + ref: pageTitleRef ? null : titleRef, enabled: !inProfilePage, props: { - ...mergeStyleProps(themeProps('panel-title'), stylex.props(reset.base, styles.title, xstyle), rest), + ...mergeStyleProps(themeProps('panel-title'), stylex.props(reset.base, styles.title, xstyle), { + tabIndex: -1, + ...rest, + }), children: ( { expect(titleRef.current).toBe(title()); }); + it('makes the page title the panel title inside a profile page', () => { + let panelTitle: () => HTMLDivElement | null = () => null; + function ReadPanelTitle() { + panelTitle = usePanelTitle(); + return null; + } + render( + + + User profile + + Account + + + + + Account + + + + + + + + , + ); + + expect(panelTitle()).toBe(screen.getByRole('heading', { level: 3, name: 'Account' })); + }); + it('returns the tablist to the column when the width comes back', () => { renderSurface(); act(() => observe?.(3)); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/__tests__/organization-profile-danger-section.feature.test.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/__tests__/organization-profile-danger-section.feature.test.tsx index 0f497c96d6d..153f2629746 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/__tests__/organization-profile-danger-section.feature.test.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/__tests__/organization-profile-danger-section.feature.test.tsx @@ -14,10 +14,8 @@ import { fapiUser, } from '../../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../../__tests__/feature/render'; -import { - OrganizationProfileDangerSection, - type OrganizationProfileDangerSectionProps, -} from '../organization-profile-danger-section'; +import { OrganizationProfileProvider } from '../../organization-profile.provider'; +import { OrganizationProfileDangerSection } from '../organization-profile-danger-section'; const acme = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 20 }); const deletePermission = ['org:sys_profile:delete']; @@ -38,9 +36,9 @@ function signedIn({ } satisfies FakeFapiSeed; } -async function renderSection(seed: FakeFapiSeed = signedIn(), props: OrganizationProfileDangerSectionProps = {}) { +async function renderSection(seed: FakeFapiSeed = signedIn()) { const fapi = serveFapi(seed); - const view = await renderWithClerk(); + const view = await renderWithClerk(); return { ...view, fapi }; } @@ -152,8 +150,13 @@ describe('OrganizationProfileDangerSection', () => { expect(dialog).toHaveTextContent('This removes 1 member and permanently deletes all organization data.'); }); - it('deletes the organization and sends the user to the url the host passed', async () => { - const { fapi, navigate } = await renderSection(signedIn(), { afterLeaveOrganizationUrl: '/organizations' }); + it('deletes the organization and sends the user to the url from the provider', async () => { + const fapi = serveFapi(signedIn()); + const { navigate } = await renderWithClerk( + + + , + ); const user = userEvent.setup(); const dialog = await openDialog(user, await screen.findByRole('button', { name: 'Delete organization' }), { role: 'alertdialog', diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.model.ts b/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.model.ts index c9782b20e72..f9840626c5d 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.model.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.model.ts @@ -2,6 +2,7 @@ import { useOrganization, useOrganizationList, useSession, useUser } from '@cler import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; import { useMosaicRouter } from '../../../hooks/use-mosaic-router'; +import { useOrganizationProfileOptions } from '../organization-profile.provider'; const organizationListParams = { userMemberships: { infinite: true }, @@ -20,11 +21,8 @@ export type OrganizationProfileDangerSectionModel = deleteOrganization?: () => Promise; }; -export function useOrganizationProfileDangerSectionModel({ - afterLeaveOrganizationUrl, -}: { - afterLeaveOrganizationUrl?: string; -}): OrganizationProfileDangerSectionModel { +export function useOrganizationProfileDangerSectionModel(): OrganizationProfileDangerSectionModel { + const { afterLeaveOrganizationUrl } = useOrganizationProfileOptions(); const { isLoaded, organization, membership } = useOrganization(); const { user } = useUser(); const { session } = useSession(); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.tsx index 14bc6fbdb48..55a86cc3e3d 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.tsx @@ -4,12 +4,11 @@ import { useOrganizationProfileDangerSectionModel } from './organization-profile import { OrganizationProfileDangerSectionView } from './organization-profile-danger-section.view'; export type OrganizationProfileDangerSectionProps = { - afterLeaveOrganizationUrl?: string; fallback?: React.ReactNode; }; export function OrganizationProfileDangerSection(props: OrganizationProfileDangerSectionProps) { - const model = useOrganizationProfileDangerSectionModel(props); + const model = useOrganizationProfileDangerSectionModel(); if (model.status === 'loading') { return props.fallback ?? null; diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.feature.test.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.feature.test.tsx index db60148b232..0afac059dce 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.feature.test.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.feature.test.tsx @@ -15,6 +15,7 @@ import { } from '../../__tests__/feature/fapi'; import { renderWithClerk } from '../../__tests__/feature/render'; import { MosaicProvider } from '../../mosaic-provider'; +import { OrganizationProfileProvider } from './organization-profile.provider'; import { OrganizationProfileDangerSection } from './organization-profile-danger-section/organization-profile-danger-section'; import { OrganizationProfileGeneralPanel } from './organization-profile-general-panel'; import { OrganizationProfileProfileSection } from './organization-profile-profile-section/organization-profile-profile-section'; @@ -112,6 +113,27 @@ describe('OrganizationProfileGeneralPanel', () => { it.todo('shows verified domains when enabled and the member can read them'); }); + describe('leave', () => { + it('sends the user to the provider url after leaving from the default sections', async () => { + const fapi = serveFapi(signedIn()); + const { navigate } = await renderWithClerk( + + + , + ); + const user = userEvent.setup(); + const dialog = await openDialog(user, await screen.findByRole('button', { name: 'Leave organization' }), { + role: 'alertdialog', + }); + + await user.type(within(dialog).getByRole('textbox'), 'Acme'); + await user.click(within(dialog).getByRole('button', { name: 'Leave organization' })); + + await waitFor(() => expect(fapi.memberships).toHaveLength(0)); + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/organizations')); + }); + }); + describe('edit name', () => { it('updates only the changed name and shows the refreshed value', async () => { const { fapi } = await renderPanel(); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.provider.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.provider.tsx new file mode 100644 index 00000000000..41517a54d49 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile.provider.tsx @@ -0,0 +1,23 @@ +import { createContext, type ReactNode, useContext } from 'react'; + +export interface OrganizationProfileOptions { + afterLeaveOrganizationUrl?: string; +} + +export interface OrganizationProfileProviderProps extends OrganizationProfileOptions { + children: ReactNode; +} + +const OrganizationProfileContext = createContext({}); + +export function OrganizationProfileProvider({ afterLeaveOrganizationUrl, children }: OrganizationProfileProviderProps) { + return ( + + {children} + + ); +} + +export function useOrganizationProfileOptions(): OrganizationProfileOptions { + return useContext(OrganizationProfileContext); +} diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 1d2c5be675f..11026dd9f18 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -1,7 +1,6 @@ import { createDeferredPromise } from '@clerk/shared/utils'; import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { createRef } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { useConfirmController } from '../../../blocks/confirm'; @@ -15,11 +14,9 @@ import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets- function Web3Wallets({ wallets, - fallbackFocus, onRemove, }: { wallets: UserProfileWeb3Wallet[]; - fallbackFocus: () => HTMLElement | null; onRemove: (id: string) => Promise; }) { const controller = useUserProfileWeb3WalletsController({ @@ -28,7 +25,6 @@ function Web3Wallets({ connect: () => Promise.resolve(), setPrimary: () => Promise.resolve(), remove: onRemove, - fallbackFocus, }); return ; } @@ -54,19 +50,16 @@ describe('UserProfileProfilePanelView', () => { expect(screen.queryByRole('tab')).toBeNull(); }); - it('keeps the final wallet confirmation mounted until removal settles', async () => { + it('keeps the final wallet confirmation mounted until removal settles, then focuses the title', async () => { const user = userEvent.setup(); - const titleRef = createRef(); const removal = createDeferredPromise(); const onRemoveWeb3Wallet = vi.fn(async () => { await removal.promise; }); const { rerender } = renderView({ - titleRef, children: ( titleRef.current} onRemove={onRemoveWeb3Wallet} /> ), @@ -76,10 +69,9 @@ describe('UserProfileProfilePanelView', () => { await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); rerender( - + titleRef.current} onRemove={onRemoveWeb3Wallet} /> @@ -92,6 +84,9 @@ describe('UserProfileProfilePanelView', () => { await removal.promise; }); await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + await waitFor(() => + expect(screen.getByRole('heading', { name: 'Account', level: 2 }).closest('.cl-panel-title')).toHaveFocus(), + ); }); it('renders the danger zone when provided', () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx index 1251159e030..bdaf35be9bc 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx @@ -15,6 +15,7 @@ import { } from '../../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../../__tests__/feature/render'; import { MosaicProvider } from '../../../../mosaic-provider'; +import { UserProfileProvider } from '../../user-profile.provider'; import { UserProfileConnectedAccountsSection } from '../user-profile-connected-accounts-section'; import { deferred, @@ -100,7 +101,9 @@ describe('connected accounts', () => { it('sends requested scopes and the current URL to connect', async () => { serveFapi(signedIn([])); const { clerk } = await renderWithClerk( - , + + + , ); const navigate = vi.spyOn(clerk, 'navigate').mockImplementation(() => Promise.resolve()); const request = holdRequests('post', '/v1/me/external_accounts'); @@ -183,7 +186,11 @@ describe('connected accounts', () => { it('preserves modal state when connecting', async () => { serveFapi(signedIn([])); - await renderWithClerk(); + await renderWithClerk( + + + , + ); const request = holdRequests('post', '/v1/me/external_accounts'); await userEvent.setup().click(screen.getByRole('button', { name: 'Connect GitHub' })); @@ -374,7 +381,9 @@ describe('connected accounts', () => { const callback = deferred<{ callbackUrl: string }>(); const open = vi.fn(() => callback.promise); const { clerk } = await renderWithClerk( - , + + + , { __internal_oauthTransport: { getRedirectUrl: () => 'https://app.example/callback', open }, }, diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.fixtures.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.fixtures.tsx index 0e8d0096d89..49ccd2ee2b4 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.fixtures.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.fixtures.tsx @@ -1,6 +1,5 @@ import { screen } from '@testing-library/react'; import type userEvent from '@testing-library/user-event'; -import { createRef } from 'react'; import { type FakeFapiSeed, serveFapi } from '../../../../__tests__/feature/fake-fapi'; import { @@ -12,7 +11,7 @@ import { fapiVerification, } from '../../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../../__tests__/feature/render'; -import { UserProfileProfilePanelView } from '../../user-profile-profile-panel.view'; +import { UserProfileProfilePanel } from '../../user-profile-profile-panel'; import { UserProfileConnectedAccountsSection } from '../user-profile-connected-accounts-section'; export const google = fapiExternalAccount({ id: 'idn_google', provider: 'google', username: 'jdoe' }); @@ -65,11 +64,10 @@ export function deferred() { export async function renderSection(accounts = [google], overrides: FakeFapiSeed = {}) { const fapi = serveFapi(signedIn(accounts, overrides)); - const titleRef = createRef(); const view = await renderWithClerk( - - titleRef.current} /> - , + + + , ); return { ...view, fapi }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.reconnect.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.reconnect.feature.test.tsx index 1265415da1e..84adc5fdf81 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.reconnect.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.reconnect.feature.test.tsx @@ -11,6 +11,7 @@ import { fapiVerification, } from '../../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../../__tests__/feature/render'; +import { UserProfileProvider } from '../../user-profile.provider'; import { UserProfileConnectedAccountsSection } from '../user-profile-connected-accounts-section'; import { deferred, @@ -112,7 +113,11 @@ describe('connected accounts', () => { it('preserves modal state when reconnecting', async () => { serveFapi(signedIn([disconnectedGoogle])); - await renderWithClerk(); + await renderWithClerk( + + + , + ); const request = holdRequests('post', '/v1/me/external_accounts'); const user = userEvent.setup(); @@ -128,7 +133,9 @@ describe('connected accounts', () => { it('reauthorizes a connected account when it lacks requested scopes', async () => { serveFapi(signedIn([google])); const { clerk } = await renderWithClerk( - , + + + , ); const navigate = vi.spyOn(clerk, 'navigate').mockImplementation(() => Promise.resolve()); const request = holdRequests('post', '/v1/me/external_accounts/idn_google/reauthorize'); @@ -146,7 +153,9 @@ describe('connected accounts', () => { it('shows a failed reauthorization and allows another attempt', async () => { serveFapi(signedIn([google])); const { clerk } = await renderWithClerk( - , + + + , ); const navigate = vi.spyOn(clerk, 'navigate').mockImplementation(() => Promise.resolve()); const request = holdRequests('post', '/v1/me/external_accounts/idn_google/reauthorize'); @@ -167,7 +176,9 @@ describe('connected accounts', () => { const callback = deferred<{ callbackUrl: string }>(); const open = vi.fn(() => callback.promise); const { clerk } = await renderWithClerk( - , + + + , { __internal_oauthTransport: { getRedirectUrl: () => 'https://app.example/callback', open }, }, @@ -218,7 +229,9 @@ describe('connected accounts', () => { const getRedirectUrl = vi.fn(() => redirect.promise); const open = vi.fn(() => Promise.resolve({ callbackUrl: 'https://app.example/callback' })); const { clerk } = await renderWithClerk( - , + + + , { __internal_oauthTransport: { getRedirectUrl, open }, }, @@ -247,7 +260,9 @@ describe('connected accounts', () => { it('allows another connection when reauthorizing an account fails to navigate', async () => { serveFapi(signedIn([google])); const { clerk } = await renderWithClerk( - , + + + , ); const navigate = vi.spyOn(clerk, 'navigate').mockRejectedValueOnce(new Error('Navigation failed')); const user = userEvent.setup(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.removal.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.removal.feature.test.tsx index f0c0f0ee367..fde8e4c8049 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.removal.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.removal.feature.test.tsx @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest'; import { demandReverificationOnce, holdRequests, serveFapi } from '../../../../__tests__/feature/fake-fapi'; import { fapiClient, fapiEnterpriseAccount, fapiSession, fapiUser } from '../../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../../__tests__/feature/render'; +import { UserProfileProfilePanel } from '../../user-profile-profile-panel'; import { UserProfileConnectedAccountsSection } from '../user-profile-connected-accounts-section'; import { github, google, renderSection, signedIn } from './user-profile-connected-accounts.fixtures'; @@ -100,32 +101,46 @@ describe('connected accounts', () => { expect(fapi.client.sessions[0]?.user.external_accounts.map(account => account.id)).toEqual(['idn_google']); }); - it('hides the section after removing the final enterprise-restricted account', async () => { + it.each([ + ['default sections', ], + [ + 'custom sections', + + + , + ], + ])('hides the section and focuses the panel title after removing the final account with %s', async (_, panel) => { const environment = signedIn().environment; - await renderSection([google], { - client: fapiClient([ - fapiSession({ - id: 'sess_1', - user: fapiUser({ - id: 'user_1', - external_accounts: [google], - enterprise_accounts: [fapiEnterpriseAccount({ id: 'sso_1' })], + serveFapi( + signedIn([google], { + client: fapiClient([ + fapiSession({ + id: 'sess_1', + user: fapiUser({ + id: 'user_1', + external_accounts: [google], + enterprise_accounts: [fapiEnterpriseAccount({ id: 'sso_1' })], + }), }), - }), - ]), - environment: { - ...environment, - user_settings: { - ...environment.user_settings, - enterprise_sso: { enabled: true, self_serve_sso: false, self_serve_directory_sync: false }, + ]), + environment: { + ...environment, + user_settings: { + ...environment.user_settings, + enterprise_sso: { enabled: true, self_serve_sso: false, self_serve_directory_sync: false }, + }, }, - }, - }); + }), + ); + await renderWithClerk(panel); const user = userEvent.setup(); const dialog = await openRemoval(user, 'Google'); await user.click(within(dialog).getByRole('button', { name: 'Remove' })); await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); expect(screen.queryByRole('group', { name: 'Connected accounts' })).toBeNull(); + await waitFor(() => + expect(screen.getByRole('heading', { name: 'Account', level: 2 }).closest('.cl-panel-title')).toHaveFocus(), + ); }); it.each(['switch', 'sign out'] as const)('closes a stale removal confirmation after %s', async change => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.ts index f759c1a8db4..0ab91223d0f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.ts @@ -4,6 +4,7 @@ import { useRef } from 'react'; import type { ConfirmController } from '../../../blocks/confirm'; import { useConfirmController } from '../../../blocks/confirm'; import type { DialogFocusTarget } from '../../../components/dialog'; +import { usePanelTitle } from '../../../components/panel'; import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus'; import { usePendingAction } from '../../../hooks/use-pending-action'; import { useMessages } from '../../../localization'; @@ -20,7 +21,6 @@ export interface UserProfileConnectedAccountsControllerOptions { onConnect: (id: string) => Promise; onReconnect: (id: string) => Promise; onRemove?: (id: string) => void | Promise; - fallbackFocus?: () => HTMLElement | null; reverification?: ReverificationController; } @@ -44,20 +44,19 @@ export function useUserProfileConnectedAccountsController({ onConnect, onReconnect, onRemove, - fallbackFocus, reverification, }: UserProfileConnectedAccountsControllerOptions): UserProfileConnectedAccountsController { const messages = useMessages('userProfileConnectedAccounts'); const actions = usePendingAction({ errorFallback: messages.errors.generic }); const sectionRef = useRef(null); + const panelTitle = usePanelTitle(); const removalFocus = useListRemovalFocus({ ids: accounts.map(account => account.id), onRemove, fallback: () => sectionRef.current?.querySelector('button:not([disabled])') ?? sectionRef.current ?? - fallbackFocus?.() ?? - null, + panelTitle(), }); const remove = useConfirmController({ onConfirm: account => removalFocus.remove(account.id), diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts index 2a2af35966e..fd0f508bfbc 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts @@ -17,6 +17,7 @@ import type { import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; import { useMosaicRouter } from '../../../hooks/use-mosaic-router'; import { allowsIdentificationCreation } from '../../../utils/allows-identification-creation'; +import { useUserProfileOptions } from '../user-profile.provider'; import type { ConnectedAccountActionResult, ConnectedAccountProviderDisplay, @@ -213,13 +214,8 @@ export type UserProfileConnectedAccountsModel = remove: (accountId: string) => Promise; }; -export function useUserProfileConnectedAccountsModel({ - additionalOAuthScopes, - mode, -}: { - additionalOAuthScopes?: AdditionalOAuthScopes; - mode?: 'modal' | 'mounted'; -}): UserProfileConnectedAccountsModel { +export function useUserProfileConnectedAccountsModel(): UserProfileConnectedAccountsModel { + const { additionalOAuthScopes, mode } = useUserProfileOptions(); const clerk = useClerk(); const { isLoaded, user } = useUser(); const environment = useMosaicEnvironment(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.tsx index 841e310530d..b3530300e5e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.tsx @@ -2,27 +2,16 @@ import type { ReactNode } from 'react'; import { useReverificationFlow } from '../../reverification'; import { useUserProfileConnectedAccountsController } from './user-profile-connected-accounts-section.controller'; -import type { - AdditionalOAuthScopes, - UserProfileConnectedAccountsModel, -} from './user-profile-connected-accounts-section.model'; +import type { UserProfileConnectedAccountsModel } from './user-profile-connected-accounts-section.model'; import { useUserProfileConnectedAccountsModel } from './user-profile-connected-accounts-section.model'; import { UserProfileConnectedAccountsSectionView } from './user-profile-connected-accounts-section.view'; export type UserProfileConnectedAccountsSectionProps = { - additionalOAuthScopes?: AdditionalOAuthScopes; fallback?: ReactNode; - fallbackFocus?: () => HTMLElement | null; - mode?: 'modal' | 'mounted'; }; -export function UserProfileConnectedAccountsSection({ - additionalOAuthScopes, - fallback, - fallbackFocus, - mode, -}: UserProfileConnectedAccountsSectionProps) { - const model = useUserProfileConnectedAccountsModel({ additionalOAuthScopes, mode }); +export function UserProfileConnectedAccountsSection({ fallback }: UserProfileConnectedAccountsSectionProps) { + const model = useUserProfileConnectedAccountsModel(); if (model.status === 'loading') { return fallback ?? null; } @@ -33,18 +22,11 @@ export function UserProfileConnectedAccountsSection({ ); } -function ConnectedAccounts({ - model, - fallbackFocus, -}: { - model: Extract; - fallbackFocus?: () => HTMLElement | null; -}) { +function ConnectedAccounts({ model }: { model: Extract }) { const [remove, reverification] = useReverificationFlow((id: string) => model.remove(id)); const controller = useUserProfileConnectedAccountsController({ accounts: model.accounts, @@ -52,7 +34,6 @@ function ConnectedAccounts({ onConnect: model.connect, onReconnect: model.reconnect, onRemove: remove, - fallbackFocus, reverification, }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts.feature.test.tsx index a32b5c4bb94..cbf2647af83 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts.feature.test.tsx @@ -15,6 +15,7 @@ import { } from '../../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../../__tests__/feature/render'; import { MosaicProvider } from '../../../../mosaic-provider'; +import { UserProfileProvider } from '../../user-profile.provider'; import { UserProfileEnterpriseAccountsSection } from '../user-profile-enterprise-accounts-section'; import { useUserProfileEnterpriseAccountsModel } from '../user-profile-enterprise-accounts-section.model'; import { custom, enterpriseAccountSeed as signedIn, enterpriseMember, okta } from './enterprise-accounts.fixtures'; @@ -179,7 +180,11 @@ describe('enterprise accounts', () => { it('preserves modal return state in the redirect URL', async () => { serveFapi(signedIn()); - await renderWithClerk(); + await renderWithClerk( + + + , + ); const request = holdRequests('post', '/v1/me/external_accounts'); await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect Acme Okta' })); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts index 00dd76a2054..bcb6055617e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts @@ -5,6 +5,7 @@ import type { EnterpriseAccountResource, EnterpriseConnectionResource } from '@c import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; import { useMosaicRouter } from '../../../hooks/use-mosaic-router'; +import { useUserProfileOptions } from '../user-profile.provider'; import type { EnterpriseAccountActionResult, UserProfileEnterpriseAccount, @@ -71,9 +72,8 @@ export type UserProfileEnterpriseAccountsModel = connect: (connectionId: string) => Promise; }; -export function useUserProfileEnterpriseAccountsModel({ - mode, -}: { mode?: 'modal' | 'mounted' } = {}): UserProfileEnterpriseAccountsModel { +export function useUserProfileEnterpriseAccountsModel(): UserProfileEnterpriseAccountsModel { + const { mode } = useUserProfileOptions(); const clerk = useClerk(); const router = useMosaicRouter(); const { isLoaded, user } = useUser(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.tsx index 153c981c49f..b1326e319e7 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.tsx @@ -8,11 +8,10 @@ import { UserProfileEnterpriseAccountsSectionView } from './user-profile-enterpr export type UserProfileEnterpriseAccountsSectionProps = { fallback?: ReactNode; - mode?: 'modal' | 'mounted'; }; -export function UserProfileEnterpriseAccountsSection({ fallback, mode }: UserProfileEnterpriseAccountsSectionProps) { - const model = useUserProfileEnterpriseAccountsModel({ mode }); +export function UserProfileEnterpriseAccountsSection({ fallback }: UserProfileEnterpriseAccountsSectionProps) { + const model = useUserProfileEnterpriseAccountsModel(); if (model.status === 'loading') { return fallback ?? null; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.tsx index 9bf7400227d..f2fc7e6d4da 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useRef } from 'react'; +import type { ReactNode } from 'react'; import { UserProfileConnectedAccountsSection } from './user-profile-connected-accounts-section/user-profile-connected-accounts-section'; import { UserProfileDangerSection } from './user-profile-danger-section/user-profile-danger-section'; @@ -14,19 +14,16 @@ export interface UserProfileProfilePanelProps { } export function UserProfileProfilePanel({ children }: UserProfileProfilePanelProps) { - const titleRef = useRef(null); - const focusTitle = () => titleRef.current; - return ( - + {children ?? ( <> - + - + )} diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx index 021de69f020..5d826497360 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx @@ -1,4 +1,4 @@ -import type { ReactElement, ReactNode, Ref } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import { Panel } from '../../components/panel'; import { themeProps } from '../../props'; @@ -11,19 +11,13 @@ export type { UserProfileNameAttribute } from './user-profile-profile-section/us export type { UserProfileEditNameValue } from './user-profile-profile-section/user-profile-edit-name.dialog'; export interface UserProfileProfilePanelViewProps { - titleRef?: Ref; children?: ReactNode; } -export function UserProfileProfilePanelView({ titleRef, children }: UserProfileProfilePanelViewProps): ReactElement { +export function UserProfileProfilePanelView({ children }: UserProfileProfilePanelViewProps): ReactElement { return ( }> - - Account - + Account {children} ); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts index e0ea1c8dfb7..1f0c3ad68dc 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts @@ -1,6 +1,7 @@ import { useRef, useState } from 'react'; import { useConfirmController } from '../../../blocks/confirm'; +import { usePanelTitle } from '../../../components/panel'; import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus'; import { usePendingAction } from '../../../hooks/use-pending-action'; import { useMessages } from '../../../localization'; @@ -19,24 +20,22 @@ export function useUserProfileWeb3WalletsController({ connect, setPrimary, remove, - fallbackFocus, reverification, }: Pick & { - fallbackFocus?: () => HTMLElement | null; reverification?: ReverificationController; }) { const messages = useMessages('userProfileWeb3Wallets'); const action = usePendingAction({ errorFallback: messages.errors.generic }); const [picker, setPicker] = useState({ open: false }); const sectionRef = useRef(null); + const panelTitle = usePanelTitle(); const removalFocus = useListRemovalFocus({ ids: wallets.map(wallet => wallet.id), onRemove: remove, fallback: () => sectionRef.current?.querySelector('button:not([disabled])') ?? sectionRef.current ?? - fallbackFocus?.() ?? - null, + panelTitle(), }); // TODO: Share the action lock during removal while preserving errors for the confirmation dialog. const removeWallet = useConfirmController({ diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx index 0bd90002a5b..e8c8c6dad5f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx @@ -9,10 +9,9 @@ import { UserProfileWeb3WalletsSectionView } from './user-profile-web3-wallets-s export interface UserProfileWeb3WalletsSectionProps { fallback?: ReactNode; - fallbackFocus?: () => HTMLElement | null; } -export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserProfileWeb3WalletsSectionProps) { +export function UserProfileWeb3WalletsSection({ fallback }: UserProfileWeb3WalletsSectionProps) { const model = useUserProfileWeb3WalletsModel(); if (model.status === 'loading') { return fallback ?? null; @@ -24,23 +23,15 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP ); } -function Web3Wallets({ - model, - fallbackFocus, -}: { - model: ReadyWeb3WalletsModel; - fallbackFocus?: () => HTMLElement | null; -}) { +function Web3Wallets({ model }: { model: ReadyWeb3WalletsModel }) { const [removeWallet, reverification] = useReverificationFlow((id: string) => model.remove?.(id)); const controller = useUserProfileWeb3WalletsController({ ...model, remove: model.remove ? removeWallet : undefined, - fallbackFocus, reverification, }); return ( diff --git a/packages/mosaic/src/features/user-profile/user-profile.provider.tsx b/packages/mosaic/src/features/user-profile/user-profile.provider.tsx new file mode 100644 index 00000000000..63316be5304 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile.provider.tsx @@ -0,0 +1,22 @@ +import { createContext, type ReactNode, useContext } from 'react'; + +import type { AdditionalOAuthScopes } from './user-profile-connected-accounts-section/user-profile-connected-accounts-section.model'; + +export interface UserProfileOptions { + additionalOAuthScopes?: AdditionalOAuthScopes; + mode?: 'modal' | 'mounted'; +} + +export interface UserProfileProviderProps extends UserProfileOptions { + children: ReactNode; +} + +const UserProfileContext = createContext({}); + +export function UserProfileProvider({ additionalOAuthScopes, mode, children }: UserProfileProviderProps) { + return {children}; +} + +export function useUserProfileOptions(): UserProfileOptions { + return useContext(UserProfileContext); +} diff --git a/packages/swingset/src/app/(clerk)/live/organization-danger/page.tsx b/packages/swingset/src/app/(clerk)/live/organization-danger/page.tsx index ad5b8e6467f..894c99a83b9 100644 --- a/packages/swingset/src/app/(clerk)/live/organization-danger/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/organization-danger/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { OrganizationProfileProvider } from '@clerk/mosaic/features/organization-profile/organization-profile.provider'; import { OrganizationProfileDangerSection } from '@clerk/mosaic/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section'; import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; import { useOrganization, useUser } from '@clerk/nextjs'; @@ -34,7 +35,11 @@ export default function OrganizationDangerLivePage() { {isLoaded && isSignedIn && !organization ? (

Pick an active organization to see its danger zone.

) : null} - {isLoaded && isSignedIn ? : null} + {isLoaded && isSignedIn ? ( + + + + ) : null}
); diff --git a/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts b/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts index 4cf8fcbe324..a52995405e6 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts @@ -24,12 +24,10 @@ export function useConnectedAccountsFixture({ initialAccounts = [connectedAccount], providers: initialProviders = connectionProviders, removalState, - fallbackFocus, }: { initialAccounts?: UserProfileConnectedAccount[]; providers?: UserProfileConnectionProvider[]; removalState?: 'pending' | 'error'; - fallbackFocus?: () => HTMLElement | null; } = {}) { const [hasRemovalFailed, setHasRemovalFailed] = useState(false); const seed = useChaosFixture(initialAccounts, items => @@ -48,7 +46,6 @@ export function useConnectedAccountsFixture({ return useUserProfileConnectedAccountsController({ accounts, availableProviders, - fallbackFocus, // eslint-disable-next-line @typescript-eslint/require-await onConnect: async (id: string) => { const provider = providers.find(item => item.id === id); diff --git a/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts index 975c597aef2..2d2532af28c 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts @@ -46,13 +46,11 @@ export function useWeb3WalletsFixture({ availableProviders = providers, primaryError = false, removalState, - fallbackFocus, }: { initialWallets?: DemoWallet[]; availableProviders?: UserProfileWeb3Provider[]; primaryError?: boolean; removalState?: 'pending' | 'error'; - fallbackFocus?: () => HTMLElement | null; } = {}) { const seedWallets = useChaosFixture(initialWallets, items => items.map(wallet => ({ ...wallet, provider: chaosText(wallet.provider) })), @@ -70,7 +68,6 @@ export function useWeb3WalletsFixture({ availableProviders: connectionProviders.filter( provider => !wallets.some(wallet => wallet.providerId === provider.id && wallet.isVerified), ), - fallbackFocus, // eslint-disable-next-line @typescript-eslint/require-await connect: async (id: Web3Strategy) => { const provider = connectionProviders.find(item => item.id === id); diff --git a/packages/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx index a42186b59db..8b9901e44e6 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile.tsx @@ -18,7 +18,7 @@ import { UserProfilePasswordSectionView } from '@clerk/mosaic/features/user-prof import { UserProfilePhoneSectionView } from '@clerk/mosaic/features/user-profile/user-profile-phone-section/user-profile-phone-section.view'; import { UserProfileProfileSectionView } from '@clerk/mosaic/features/user-profile/user-profile-profile-section/user-profile-profile-section.view'; import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view'; -import { useRef, useState } from 'react'; +import { useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; import { chaosEmail, chaosRows, chaosText } from '@/lib/chaos'; @@ -64,9 +64,8 @@ const examplePaymentMethods: UserProfilePaymentMethod[] = [ * stories that need a realistic profile surface without being about it. */ export function useUserProfileFixture() { - const titleRef = useRef(null); - const connections = useConnectedAccountsFixture({ fallbackFocus: () => titleRef.current }); - const wallets = useWeb3WalletsFixture({ fallbackFocus: () => titleRef.current }); + const connections = useConnectedAccountsFixture(); + const wallets = useWeb3WalletsFixture(); const profile = useUserProfileProfileFixture(); const editPassword = useUserProfileEditPasswordFixture(); const mfa = useUserProfileMfaExample(); @@ -108,7 +107,6 @@ export function useUserProfileFixture() { const apiKeys = useAPIKeysTableFixture(); const pages: UserProfileViewProps['pages'] = { account: { - titleRef, children: ( <> diff --git a/packages/swingset/src/stories/organization-profile-danger-section.mdx b/packages/swingset/src/stories/organization-profile-danger-section.mdx index 85887f130b9..2328528110c 100644 --- a/packages/swingset/src/stories/organization-profile-danger-section.mdx +++ b/packages/swingset/src/stories/organization-profile-danger-section.mdx @@ -5,7 +5,8 @@ import * as Stories from './organization-profile-danger-section.stories'; Leaving and deleting the active organization. Both ask for the organization's name to be typed out. Leaving is offered to every member. Deleting needs the `org:sys_profile:delete` permission and admin delete turned on for the instance. After either one, the section refreshes the user's organization -lists and navigates to `afterLeaveOrganizationUrl`, falling back to the instance's setting. +lists and navigates to the `afterLeaveOrganizationUrl` set on `OrganizationProfileProvider`, falling back to the +instance's setting. Below `26rem` of group width, through `Section.Item`'s `wrap`, each action wraps under its description. diff --git a/packages/swingset/src/stories/organization-profile-general-panel.mdx b/packages/swingset/src/stories/organization-profile-general-panel.mdx index d556647a7ab..85678425b5f 100644 --- a/packages/swingset/src/stories/organization-profile-general-panel.mdx +++ b/packages/swingset/src/stories/organization-profile-general-panel.mdx @@ -34,6 +34,17 @@ import { OrganizationProfileProfileSection } from '@clerk/mosaic/features/organi ; ``` +Wrap the panel in `OrganizationProfileProvider` to set where the user goes after leaving or deleting the organization. + +```tsx +import { OrganizationProfileGeneralPanel } from '@clerk/mosaic/features/organization-profile/organization-profile-general-panel'; +import { OrganizationProfileProvider } from '@clerk/mosaic/features/organization-profile/organization-profile.provider'; + + + +; +``` + ## Without the danger zone The panel shows only the organization details, read-only. diff --git a/packages/swingset/src/stories/user-profile-profile-panel.mdx b/packages/swingset/src/stories/user-profile-profile-panel.mdx index 00135378f8e..278ebedae37 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.mdx +++ b/packages/swingset/src/stories/user-profile-profile-panel.mdx @@ -35,3 +35,14 @@ import { UserProfileProfileSection } from '@clerk/mosaic/features/user-profile/u ; ``` + +Wrap the panel in `UserProfileProvider` to set `additionalOAuthScopes` for connected accounts, or `mode='modal'` when the panel renders inside a modal. + +```tsx +import { UserProfileProfilePanel } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel'; +import { UserProfileProvider } from '@clerk/mosaic/features/user-profile/user-profile.provider'; + + + +; +``` diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index 42ed59136fc..ec93ff1c53d 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -4,7 +4,6 @@ import { UserProfilePhoneSectionView } from '@clerk/mosaic/features/user-profile import { UserProfileProfilePanelView } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; import { UserProfileProfileSectionView } from '@clerk/mosaic/features/user-profile/user-profile-profile-section/user-profile-profile-section.view'; import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view'; -import { useRef } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -27,15 +26,14 @@ export const meta: StoryMeta = { }; export function Default(_args: Record) { - const titleRef = useRef(null); - const connections = useConnectedAccountsFixture({ fallbackFocus: () => titleRef.current }); - const wallets = useWeb3WalletsFixture({ fallbackFocus: () => titleRef.current }); + const connections = useConnectedAccountsFixture(); + const wallets = useWeb3WalletsFixture(); const profile = useUserProfileProfileFixture(); const emails = useUserProfileEmailsFixture({ username: profile.username }); const phones = useUserProfilePhonesFixture(); return ( - + From 6e5ce3f2f7bee85fa90c36ddb41ff93a9eb8314c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 9 Oct 2026 19:35:32 -0400 Subject: [PATCH 2/3] docs(swingset): document the panel title focus target --- packages/swingset/src/stories/panel.component.mdx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/swingset/src/stories/panel.component.mdx b/packages/swingset/src/stories/panel.component.mdx index 59ac18f0b67..1be2a4f1c0e 100644 --- a/packages/swingset/src/stories/panel.component.mdx +++ b/packages/swingset/src/stories/panel.component.mdx @@ -65,6 +65,19 @@ Every part takes `xstyle`, `ref`, and `render` along with its element's props. | Inside `Profile.ContentPanel` | `h3` | `h4` | | Inside `HeadingLevelProvider level={4}` | `h4` | `h5` | +## Focus + +`Panel.Title` takes `tabIndex={-1}`, so it can receive focus without joining the tab order. Content +inside `Panel.Root` reaches the title with `usePanelTitle()`, which returns a function that gives +the title element. Inside a profile page, that is the profile's page title. + +```tsx +import { usePanelTitle } from '@clerk/mosaic/components/panel'; + +const panelTitle = usePanelTitle(); +panelTitle()?.focus(); +``` + ## Styling Each part carries a stable class and reflects no state, so target the classes directly: From 0c7501449a40d7d2fd6ad92281f691456cb2669e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 15 Oct 2026 15:34:40 -0400 Subject: [PATCH 3/3] fix(swingset): redirect to /live after leaving from the live general panel --- .../src/app/(clerk)/live/organization-general/page.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/swingset/src/app/(clerk)/live/organization-general/page.tsx b/packages/swingset/src/app/(clerk)/live/organization-general/page.tsx index 435c51857fb..eadd0f31a94 100644 --- a/packages/swingset/src/app/(clerk)/live/organization-general/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/organization-general/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { OrganizationProfileProvider } from '@clerk/mosaic/features/organization-profile/organization-profile.provider'; import { OrganizationProfileGeneralPanel } from '@clerk/mosaic/features/organization-profile/organization-profile-general-panel'; import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; import { useOrganization, useUser } from '@clerk/nextjs'; @@ -27,7 +28,11 @@ export default function OrganizationGeneralLivePage() { {isLoaded && isSignedIn && !organization ? (

Pick an active organization to see its general panel.

) : null} - {isLoaded && isSignedIn ? : null} + {isLoaded && isSignedIn ? ( + + + + ) : null} );