Skip to content
Draft
2 changes: 2 additions & 0 deletions .changeset/user-profile-security-panel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
5 changes: 5 additions & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
user: {
'user-button': dynamic(() => import('../stories/user-button.mdx')),
'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')),
'user-profile-security-panel': dynamic(() => import('../stories/user-profile-security-panel.mdx')),
'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')),
'user-profile-password-section': dynamic(() => import('../stories/user-profile-password-section.mdx')),
'user-profile-passkeys-section': dynamic(() => import('../stories/user-profile-passkeys-section.mdx')),
'user-profile-mfa-section': dynamic(() => import('../stories/user-profile-mfa-section.mdx')),
'user-profile-active-devices-section': dynamic(() => import('../stories/user-profile-active-devices-section.mdx')),
'user-profile-connected-accounts-section': dynamic(
() => import('../stories/user-profile-connected-accounts-section.mdx'),
),
Expand Down
124 changes: 96 additions & 28 deletions packages/swingset/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,79 @@ import {
SidebarRail,
} from '@/components/ui/sidebar';
import { getSidebarGroups } from '@/lib/registry';
import type { StoryModule } from '@/lib/types';

const groups = getSidebarGroups();

type SidebarEntry = { mod: StoryModule; componentSlug: string };

function getNavigationFamilies(components: SidebarEntry[]) {
const families = new Map<string, Map<string, SidebarEntry[]>>();

for (const component of components) {
const family = component.mod.meta.navigation?.family ?? '';
const category = component.mod.meta.navigation?.category ?? '';
const categories = families.get(family) ?? new Map<string, SidebarEntry[]>();
const entries = categories.get(category) ?? [];

entries.push(component);
categories.set(category, entries);
families.set(family, categories);
}

return Array.from(families, ([family, categories]) => ({
family,
categories: Array.from(categories, ([category, components]) => ({
category,
components: components.sort(
(a, b) =>
(a.mod.meta.navigation?.order ?? Number.MAX_SAFE_INTEGER) -
(b.mod.meta.navigation?.order ?? Number.MAX_SAFE_INTEGER),
),
})),
}));
}

function SidebarEntryLink({
entry,
groupSlug,
pathname,
}: {
entry: SidebarEntry;
groupSlug: string;
pathname: string;
}) {
const { mod, componentSlug } = entry;
const href = `/${groupSlug}/${componentSlug}`;
const usage = mod.meta.label
? mod.meta.label
: mod.meta.group === 'Hooks'
? `${mod.meta.title}()`
: mod.meta.group === 'Styles'
? mod.meta.title
: `<${mod.meta.title} />`;

return (
<SidebarMenuItem>
<SidebarMenuButton
className='h-auto items-start py-1 text-xs leading-relaxed'
isActive={pathname === href}
render={<Link href={href} />}
>
<span
className={
mod.meta.label
? 'whitespace-normal text-[11px] leading-relaxed'
: 'whitespace-normal! break-all font-mono text-[10px] leading-relaxed'
}
>
{usage}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
}

export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const pathname = usePathname();

Expand Down Expand Up @@ -68,34 +138,32 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
{group}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{components.map(({ mod, componentSlug }) => {
const href = `/${groupSlug}/${componentSlug}`;
// How an entry is USED differs by layer, so the label follows the layer rather
// than a guess at the title: hooks are called, atomic styles are a set of
// exports with no single call form worth privileging, and everything else is a
// component rendered as JSX.
const usage =
mod.meta.group === 'Hooks'
? `${mod.meta.title}()`
: mod.meta.group === 'Styles'
? mod.meta.title
: `<${mod.meta.title} />`;
return (
<SidebarMenuItem key={mod.meta.title}>
<SidebarMenuButton
className='h-auto items-start py-1 text-xs leading-relaxed'
isActive={pathname === href}
render={<Link href={href} />}
>
<span className='whitespace-normal! break-all font-mono text-[10px] leading-relaxed'>
{usage}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
{getNavigationFamilies(components).map(({ family, categories }) => (
<div key={family || group}>
{family ? (
<div className='text-sidebar-foreground/80 px-2 pb-1 pt-3 text-[11px] font-semibold'>{family}</div>
) : null}
{categories.map(({ category, components }) => (
<div key={category || group}>
{category ? (
<div className='text-sidebar-foreground/45 px-3 pb-1 pt-2 text-[9px] font-semibold uppercase tracking-wider'>
{category}
</div>
) : null}
<SidebarMenu className={category ? 'px-1' : undefined}>
{components.map(entry => (
<SidebarEntryLink
key={entry.mod.meta.title}
entry={entry}
groupSlug={groupSlug}
pathname={pathname}
/>
))}
</SidebarMenu>
</div>
))}
</div>
))}
</SidebarGroupContent>
</SidebarGroup>
))}
Expand Down
49 changes: 49 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ import {
Default as UserProfileAccountSectionDefault,
meta as userProfileAccountSectionMeta,
} from '../stories/user-profile-account-section.stories';
import {
Default as UserProfileActiveDevicesSectionDefault,
meta as userProfileActiveDevicesSectionMeta,
} from '../stories/user-profile-active-devices-section.stories';
import {
Default as UserProfileConnectedAccountsSectionDefault,
meta as userProfileConnectedAccountsSectionMeta,
Expand All @@ -108,10 +112,28 @@ import {
Default as UserProfileDeleteSectionDefault,
meta as userProfileDeleteSectionMeta,
} from '../stories/user-profile-delete-section.stories';
import {
Default as UserProfileMfaSectionDefault,
Empty as UserProfileMfaSectionEmpty,
meta as userProfileMfaSectionMeta,
} from '../stories/user-profile-mfa-section.stories';
import {
Default as UserProfilePasskeysSectionDefault,
Empty as UserProfilePasskeysSectionEmpty,
meta as userProfilePasskeysSectionMeta,
} from '../stories/user-profile-passkeys-section.stories';
import {
Default as UserProfilePasswordSectionDefault,
meta as userProfilePasswordSectionMeta,
} from '../stories/user-profile-password-section.stories';
import {
Default as UserProfileProfilePanelDefault,
meta as userProfileProfilePanelMeta,
} from '../stories/user-profile-profile-panel.stories';
import {
Default as UserProfileSecurityPanelDefault,
meta as userProfileSecurityPanelMeta,
} from '../stories/user-profile-security-panel.stories';
import {
Default as UserProfileWeb3WalletsSectionDefault,
meta as userProfileWeb3WalletsSectionMeta,
Expand Down Expand Up @@ -232,6 +254,28 @@ const userProfileProfilePanelModule: StoryModule = {
meta: userProfileProfilePanelMeta,
Default: UserProfileProfilePanelDefault,
};
const userProfileSecurityPanelModule: StoryModule = {
meta: userProfileSecurityPanelMeta,
Default: UserProfileSecurityPanelDefault,
};
const userProfilePasswordSectionModule: StoryModule = {
meta: userProfilePasswordSectionMeta,
Default: UserProfilePasswordSectionDefault,
};
const userProfilePasskeysSectionModule: StoryModule = {
meta: userProfilePasskeysSectionMeta,
Default: UserProfilePasskeysSectionDefault,
Empty: UserProfilePasskeysSectionEmpty,
};
const userProfileMfaSectionModule: StoryModule = {
meta: userProfileMfaSectionMeta,
Default: UserProfileMfaSectionDefault,
Empty: UserProfileMfaSectionEmpty,
};
const userProfileActiveDevicesSectionModule: StoryModule = {
meta: userProfileActiveDevicesSectionMeta,
Default: UserProfileActiveDevicesSectionDefault,
};
const userProfileConnectedAccountsSectionModule: StoryModule = {
meta: userProfileConnectedAccountsSectionMeta,
Default: UserProfileConnectedAccountsSectionDefault,
Expand All @@ -249,7 +293,12 @@ export const registry: StoryModule[] = [
// User
userButtonModule,
userProfileProfilePanelModule,
userProfileSecurityPanelModule,
userProfileAccountSectionModule,
userProfilePasswordSectionModule,
userProfilePasskeysSectionModule,
userProfileMfaSectionModule,
userProfileActiveDevicesSectionModule,
userProfileConnectedAccountsSectionModule,
userProfileWeb3WalletsSectionModule,
userProfileDeleteSectionModule,
Expand Down
5 changes: 5 additions & 0 deletions packages/swingset/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ export interface StoryMeta {
* (which still drives the slug and the `<Title />` tag).
*/
label?: string;
navigation?: {
family?: string;
category?: string;
order?: number;
};
/**
* Path to the file that exports the documented component, relative to the monorepo
* root (e.g. `packages/ui/src/mosaic/components/button.tsx`). Rendered as a "View
Expand Down
2 changes: 2 additions & 0 deletions packages/swingset/src/stories/user-button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export { default as __source } from './user-button.stories?raw';
export const meta: StoryMeta = {
group: 'User',
title: 'UserButton',
label: 'User button',
navigation: { family: 'User button', category: 'Compositions', order: 10 },
source: 'packages/ui/src/mosaic/user-button/user-button.view.tsx',
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type {
UserProfileEmail,
UserProfilePhone,
} from '@clerk/ui/mosaic/user-profile/user-profile-account-section.view';
import { UserProfileAccountSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-account-section.view';
import { useState } from 'react';

import type { StoryMeta } from '@/lib/types';

Expand All @@ -7,25 +12,48 @@ export { default as __source } from './user-profile-account-section.stories?raw'
export const meta: StoryMeta = {
group: 'User',
title: 'UserProfileAccountSection',
label: 'Account',
navigation: { family: 'User profile', category: 'Sections', order: 10 },
source: 'packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx',
};

export function Default() {
const [emails, setEmails] = useState<UserProfileEmail[]>([
{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true },
{ id: 'email_2', value: 'item2@clerk.dev', isVerified: true },
]);
const [phones, setPhones] = useState<UserProfilePhone[]>([
{ id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true },
]);

return (
<UserProfileAccountSectionView
emails={[
{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true },
{ id: 'email_2', value: 'item2@clerk.dev', isVerified: true },
]}
emails={emails}
imageUrl='https://avatars.githubusercontent.com/u/51144033?v=4'
name='Preston Booth'
phones={[{ id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }]}
phones={phones}
username='prestonxyz'
onAddEmail={() => undefined}
onAddPhone={() => undefined}
onAddEmail={() =>
setEmails(current => [
...current,
{ id: `email_${Date.now()}`, value: `item${current.length + 1}@clerk.dev`, isVerified: true },
])
}
onAddPhone={() =>
setPhones(current => [
...current,
{
id: `phone_${Date.now()}`,
value: `+1 801-555-${String(current.length + 1).padStart(4, '0')}`,
isVerified: true,
},
])
}
onEditProfilePicture={() => undefined}
onManageEmail={() => undefined}
onManagePhone={() => undefined}
onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))}
onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))}
onNameChange={() => undefined}
onUsernameChange={() => undefined}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as Stories from './user-profile-active-devices-section.stories';

# UserProfileActiveDevicesSection

The current device and other active sessions composed with `Section`.

<Story
name='Default'
storyModule={Stories}
composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]}
/>
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { UserProfileDevice } from '@clerk/ui/mosaic/user-profile/user-profile-active-devices-section.view';
import { UserProfileActiveDevicesSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-active-devices-section.view';
import { useState } from 'react';

import type { StoryMeta } from '@/lib/types';

export { default as __source } from './user-profile-active-devices-section.stories?raw';

export const meta: StoryMeta = {
group: 'User',
title: 'UserProfileActiveDevicesSection',
label: 'Active devices',
navigation: { family: 'User profile', category: 'Sections', order: 50 },
source: 'packages/ui/src/mosaic/user-profile/user-profile-active-devices-section.view.tsx',
};

export function Default() {
const [devices, setDevices] = useState<UserProfileDevice[]>([
{
id: 'current',
name: 'Safari on macOS',
description: 'Salt Lake City, UT, United States',
type: 'desktop',
isCurrent: true,
},
{
id: 'mobile',
name: 'Safari on iOS',
description: 'Last seen 2 weeks ago · Orem, UT, United States',
type: 'mobile',
},
{
id: 'desktop',
name: 'Clerk App on macOS',
description: 'Last seen May 14th, 2026 · San Francisco, CA, United States',
type: 'desktop',
},
]);

return (
<UserProfileActiveDevicesSectionView
devices={devices}
onManageDevice={() => undefined}
onSignOutAllOtherDevices={() => setDevices(current => current.filter(device => device.isCurrent))}
onSignOutDevice={id => setDevices(current => current.filter(device => device.id !== id))}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export { default as __source } from './user-profile-connected-accounts-section.s
export const meta: StoryMeta = {
group: 'User',
title: 'UserProfileConnectedAccountsSection',
label: 'Connected accounts',
navigation: { family: 'User profile', category: 'Sections', order: 60 },
source: 'packages/ui/src/mosaic/user-profile/user-profile-connected-accounts-section.view.tsx',
};

Expand Down
Loading
Loading