Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
<Billing
scope={organizationId ? 'organization' : 'account'}
organizationId={organizationId ?? undefined}
governingWorkspaceName={hostContext.workspace.name}
creditUsageHref={`/workspace/${hostContext.workspace.id}/settings/billing/credit-usage`}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export function CreditUsageView({ backHref = '/account/settings/billing' }: Cred
return (
<SettingsPanel
back={{
text: 'Billing',
text: 'Subscription',
icon: ArrowLeft,
onSelect: () => router.push(backHref),
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function CreditUsageLoading({ backHref }: CreditUsageLoadingProps) {
return (
<SettingsPanel
back={{
text: 'Billing',
text: 'Subscription',
icon: ArrowLeft,
onSelect: () => router.push(backHref),
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,12 @@ vi.mock(
)

vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({
SettingsPanel: ({ children }: { children: ReactNode }) => <main>{children}</main>,
SettingsPanel: ({ children, description }: { children: ReactNode; description?: string }) => (
<main>
{description && <p>{description}</p>}
{children}
</main>
),
}))

vi.mock(
Expand Down Expand Up @@ -259,7 +264,13 @@ describe('Billing payer scope', () => {

it('uses the target organization DTO for annual, canceled, credit, cap, and link state', async () => {
await act(async () => {
root.render(<Billing scope='organization' organizationId='org-target' />)
root.render(
<Billing
scope='organization'
organizationId='org-target'
governingWorkspaceName='Production'
/>
)
})

expect(mockUseSubscriptionData).toHaveBeenCalledWith(
Expand All @@ -270,6 +281,9 @@ describe('Billing payer scope', () => {
container.querySelector('a[href="/workspace/organization-workspace/upgrade"]')?.textContent
).toBe('Explore organization plans')
expect(container.textContent).toContain('Organization Max for Teams plan')
expect(container.textContent).toContain(
'Target organization’s subscription governs Production.'
)
expect(container.textContent).toContain('billed annually')
expect(container.textContent).toContain('Access until')
expect(container.textContent).toContain('Subscription canceled')
Expand All @@ -293,13 +307,35 @@ describe('Billing payer scope', () => {

it('uses a guaranteed personal payer workspace for account upgrades', async () => {
await act(async () => {
root.render(<Billing scope='account' />)
root.render(<Billing scope='account' governingWorkspaceName='Personal workspace' />)
})

expect(
container.querySelector('a[href="/workspace/personal-workspace/upgrade"]')?.textContent
).toBe('Explore personal plans')
expect(container.textContent).toContain('Personal Pro plan')
expect(container.textContent).toContain(
'Your personal subscription governs Personal workspace.'
)
})

it('does not show a governing subscription description for a free personal workspace', async () => {
mockPersonalQuery.current = {
data: {
success: true,
context: 'user',
data: { ...PERSONAL_DATA, plan: 'free', status: 'active' },
},
isLoading: false,
refetch: vi.fn(),
}

await act(async () => {
root.render(<Billing scope='account' governingWorkspaceName='Free workspace' />)
})

expect(container.textContent).toContain('Personal Free plan')
expect(container.querySelector('main > p')).toBeNull()
})

it('renders an explicit free organization state without subscription controls', async () => {
Expand All @@ -319,12 +355,19 @@ describe('Billing payer scope', () => {
}

await act(async () => {
root.render(<Billing scope='organization' organizationId='org-target' />)
root.render(
<Billing
scope='organization'
organizationId='org-target'
governingWorkspaceName='Free organization workspace'
/>
)
})

expect(container.textContent).toContain('Organization Free plan')
expect(container.textContent).toContain('No active organization subscription')
expect(container.textContent).not.toContain('Payment method')
expect(container.querySelector('main > p')).toBeNull()
})

it('renders lapsed organization plans as ended rather than active', async () => {
Expand All @@ -342,12 +385,19 @@ describe('Billing payer scope', () => {
}

await act(async () => {
root.render(<Billing scope='organization' organizationId='org-target' />)
root.render(
<Billing
scope='organization'
organizationId='org-target'
governingWorkspaceName='Lapsed organization workspace'
/>
)
})

expect(container.textContent).toContain('Organization Max for Teams plan ended')
expect(container.textContent).toContain('Choose a new plan for this organization')
expect(container.textContent).not.toContain('Cancel subscription')
expect(container.querySelector('main > p')).toBeNull()
expect(
container.querySelector('a[href="/workspace/organization-workspace/upgrade"]')?.textContent
).toBe('Explore organization plans')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,15 @@ interface BillingProps {
scope: 'account' | 'organization'
organizationId?: string
creditUsageHref?: string
governingWorkspaceName?: string
}

export function Billing({ scope, organizationId, creditUsageHref }: BillingProps) {
export function Billing({
scope,
organizationId,
creditUsageHref,
governingWorkspaceName,
}: BillingProps) {
const router = useRouter()
const isOrganizationScope = scope === 'organization'

Expand Down Expand Up @@ -447,9 +453,16 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps
const explorePlansLabel = isOrganizationScope
? 'Explore organization plans'
: 'Explore personal plans'
const subscriptionOwner = isOrganizationScope
? `${organizationBilling?.organizationName ?? 'The organization'}’s subscription`
: 'Your personal subscription'
const settingsDescription =
governingWorkspaceName && subscription.isPaid
? `${subscriptionOwner} governs ${governingWorkspaceName}.`
: undefined

return (
<SettingsPanel>
<SettingsPanel description={settingsDescription}>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2.5'>
<div className='size-9 flex-shrink-0'>
Expand Down
104 changes: 71 additions & 33 deletions apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,50 +21,88 @@ import {
} from '@/app/workspace/[workspaceId]/settings/navigation'

describe('unified settings navigation', () => {
it('preserves the original settings groups', () => {
it('groups settings by the scope they affect', () => {
expect(sectionConfig).toEqual([
{ key: 'account', title: 'Account' },
{ key: 'tools', title: 'Tools' },
{ key: 'subscription', title: 'Subscription' },
{ key: 'system', title: 'System' },
{ key: 'desktop', title: 'Desktop' },
{ key: 'enterprise', title: 'Enterprise' },
{ key: 'superuser', title: 'Superuser' },
{ key: 'workspace', title: 'Workspace' },
{ key: 'organization', title: 'Organization' },
{ key: 'platform', title: 'Platform' },
])
})

it('keeps account, workspace, organization, and platform settings in one catalog', () => {
expect(allNavigationItems.map(({ id, label, section }) => ({ id, label, section }))).toEqual([
{ id: 'general', label: 'General', section: 'account' },
{ id: 'desktop', label: 'Desktop', section: 'desktop' },
{ id: 'browser', label: 'Browser', section: 'desktop' },
{ id: 'terminal', label: 'Terminal', section: 'desktop' },
{ id: 'access-control', label: 'Access control', section: 'enterprise' },
{ id: 'audit-logs', label: 'Audit logs', section: 'enterprise' },
{ id: 'forks', label: 'Workspace Forks', section: 'enterprise' },
{ id: 'billing', label: 'Billing', section: 'subscription' },
{ id: 'teammates', label: 'Teammates', section: 'subscription' },
{ id: 'organization', label: 'Organization', section: 'subscription' },
{ id: 'secrets', label: 'Secrets', section: 'account' },
{ id: 'custom-tools', label: 'Custom tools', section: 'tools' },
{ id: 'mcp', label: 'MCP tools', section: 'tools' },
{ id: 'apikeys', label: 'Sim API keys', section: 'system' },
{ id: 'workflow-mcp-servers', label: 'MCP servers', section: 'system' },
{ id: 'byok', label: 'BYOK', section: 'system' },
{ id: 'sandboxes', label: 'Sandboxes', section: 'system' },
{ id: 'inbox', label: 'Sim mailer', section: 'system' },
{ id: 'recently-deleted', label: 'Recently deleted', section: 'system' },
{ id: 'sso', label: 'Single sign-on', section: 'enterprise' },
{ id: 'sessions', label: 'Session policies', section: 'enterprise' },
{ id: 'data-retention', label: 'Data retention', section: 'enterprise' },
{ id: 'data-drains', label: 'Data drains', section: 'enterprise' },
{ id: 'whitelabeling', label: 'Whitelabeling', section: 'enterprise' },
{ id: 'custom-blocks', label: 'Custom blocks', section: 'enterprise' },
{ id: 'admin', label: 'Admin', section: 'superuser' },
{ id: 'mothership', label: 'Mothership', section: 'superuser' },
{ id: 'desktop', label: 'Desktop', section: 'account' },
{ id: 'browser', label: 'Browser', section: 'account' },
{ id: 'terminal', label: 'Terminal', section: 'account' },
{ id: 'access-control', label: 'Permission groups', section: 'organization' },
{ id: 'audit-logs', label: 'Audit logs', section: 'organization' },
{ id: 'forks', label: 'Workspace forks', section: 'organization' },
{ id: 'billing', label: 'Subscription', section: 'account' },
{ id: 'teammates', label: 'Teammates', section: 'workspace' },
{ id: 'organization', label: 'Members', section: 'organization' },
{ id: 'secrets', label: 'Secrets', section: 'workspace' },
{ id: 'custom-tools', label: 'Custom tools', section: 'workspace' },
{ id: 'mcp', label: 'MCP tools', section: 'workspace' },
{ id: 'apikeys', label: 'Sim API keys', section: 'workspace' },
{ id: 'workflow-mcp-servers', label: 'MCP servers', section: 'workspace' },
{ id: 'byok', label: 'BYOK', section: 'workspace' },
{ id: 'sandboxes', label: 'Sandboxes', section: 'workspace' },
{ id: 'inbox', label: 'Sim Mailer', section: 'workspace' },
{ id: 'recently-deleted', label: 'Recently deleted', section: 'workspace' },
{ id: 'sso', label: 'Single sign-on', section: 'organization' },
{ id: 'sessions', label: 'Session policies', section: 'organization' },
{ id: 'data-retention', label: 'Data retention', section: 'organization' },
{ id: 'data-drains', label: 'Data drains', section: 'organization' },
{ id: 'whitelabeling', label: 'White-labeling', section: 'organization' },
{ id: 'custom-blocks', label: 'Custom blocks', section: 'organization' },
{ id: 'admin', label: 'Admin', section: 'platform' },
{ id: 'mothership', label: 'Mothership', section: 'platform' },
])
})

it('orders each scope around its primary settings', () => {
const idsForSection = (section: (typeof sectionConfig)[number]['key']) =>
allNavigationItems
.filter((item) => item.section === section)
.sort((left, right) => left.order - right.order)
.map(({ id }) => id)

expect(idsForSection('account')).toEqual([
'general',
'billing',
'desktop',
'browser',
'terminal',
])
expect(idsForSection('workspace')).toEqual([
'teammates',
'secrets',
'mcp',
'custom-tools',
'byok',
'inbox',
'workflow-mcp-servers',
'apikeys',
'sandboxes',
'recently-deleted',
])
expect(idsForSection('organization')).toEqual([
'organization',
'custom-blocks',
'forks',
'access-control',
'audit-logs',
'whitelabeling',
'sso',
'sessions',
'data-retention',
'data-drains',
])
expect(idsForSection('platform')).toEqual(['admin', 'mothership'])
})

it('derives every unified item from exactly one registry entry', () => {
expect(allNavigationItems).toHaveLength(
SETTINGS_SECTION_REGISTRY.filter(({ unified }) => unified).length
Expand Down
9 changes: 3 additions & 6 deletions apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,9 @@ export const isBillingEnabled = SETTINGS_NAVIGATION_BILLING_ENABLED

export const sectionConfig: { key: NavigationSection; title: string }[] = [
{ key: 'account', title: 'Account' },
{ key: 'tools', title: 'Tools' },
{ key: 'subscription', title: 'Subscription' },
{ key: 'system', title: 'System' },
{ key: 'desktop', title: 'Desktop' },
{ key: 'enterprise', title: 'Enterprise' },
{ key: 'superuser', title: 'Superuser' },
{ key: 'workspace', title: 'Workspace' },
{ key: 'organization', title: 'Organization' },
{ key: 'platform', title: 'Platform' },
]

export const allNavigationItems: NavigationItem[] = buildUnifiedSettingsNavigation()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,9 @@ export function SettingsSidebar({
.map(({ key, title }) => ({
key,
title,
items: navigationItems.filter((item) => item.section === key),
items: navigationItems
.filter((item) => item.section === key)
.sort((left, right) => left.order - right.order),
}))
.filter(({ items }) => items.length > 0)
.map(({ key, title, items: sectionItems }, index) => (
Expand Down
34 changes: 32 additions & 2 deletions apps/sim/components/settings/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,15 +191,45 @@ describe('settings navigation boundaries', () => {
expect(organizationSso?.docsLink).toBe(unifiedSso?.docsLink)
})

it('keeps scope-specific labels only where the surface genuinely differs', () => {
it('uses scope-specific labels consistently across settings surfaces', () => {
const organizationMembers = ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === 'members')
const unifiedOrganization = buildUnifiedSettingsNavigation().find(
({ id }) => id === 'organization'
)

expect(organizationMembers?.label).toBe('Members')
expect(organizationMembers?.description).toBe('Manage organization members, roles, and seats.')
expect(unifiedOrganization?.label).toBe('Organization')
expect(unifiedOrganization?.label).toBe('Members')
})

it('keeps self-host settings on their standalone account projection', () => {
expect(
SELFHOST_SETTINGS_ITEMS.map(({ id, label, description, group }) => ({
id,
label,
description,
group,
}))
).toEqual([
{
id: 'general',
label: 'General',
description: 'Manage your profile, appearance, and preferences.',
group: 'account',
},
{
id: 'billing',
label: 'Subscription',
description: 'Manage your personal plan, usage, and invoices.',
group: 'account',
},
{
id: 'chat-keys',
label: 'Chat keys',
description: 'Manage the model-provider keys that power Chat.',
group: 'developer',
},
])
})

it('builds canonical settings hrefs across all three planes', () => {
Expand Down
Loading
Loading