feat(settings): self-host settings plane, Sim wordmark in sidebar (#5990)

* feat(settings): self-host settings plane, Sim wordmark in sidebar

Chat keys were only reachable at a standalone /account/settings/chat-keys
page that nothing linked to. They now live on a dedicated self-host plane
alongside the two other settings a self-hoster needs from the managed
service.

- new /selfhost/settings/{general,billing,chat-keys} plane, open to any
  signed-in user
- chat keys move off the account plane entirely; no isHosted gate
- registry `unified` projection is now optional (mirroring `planes`), so a
  section can opt out of the editor sidebar
- plane items resolve their own description and throw when one is missing,
  since a plane-only section has no unified projection to inherit from
- settings sidebar shows the Sim wordmark linking to /?home instead of a
  Back chip; drops the now-dead backHref prop
- `bun run setup` runs `bun install` first so a fresh clone is one command

* docs(readme): point Chat keys at the self-host settings plane

The account-plane URL stopped resolving when chat keys moved to
/selfhost/settings.

* improvement(settings): make the sidebar wordmark a plane attribute

Replacing the Back chip everywhere was too broad — account and
organization are reached from inside the app, so Back is right there.
Self-host is reached from outside it (the CLI wizard, the README), so it
leads with the brand mark instead.

SETTINGS_PLANE_CHROME declares that per plane, keyed on
StandaloneSettingsPlane so adding a plane forces the decision rather than
defaulting silently. It also absorbs the shell's parallel plane-label map.

* fix(settings): hide self-host Chat keys on non-hosted deployments

Chat keys are issued by the managed service and useCopilotKeys is
`enabled: isHosted` for that reason. Moving the section onto the self-host
plane dropped its hosted gate, so a self-hosted deployment rendered a
Chat keys nav item whose list could never populate.

Restores the gate on the plane that now owns the URL. sim.ai is unaffected
— the section stays visible there to every signed-in user, which is the
surface self-hosters are pointed at.
This commit is contained in:
Theodore Li
2026-07-27 20:01:26 -04:00
committed by GitHub
parent 23bc210bf8
commit 75b8b6f3e5
14 changed files with 309 additions and 112 deletions
+3 -10
View File
@@ -27,13 +27,12 @@
### Self-hosted
```bash
npx simstudio
git clone https://github.com/simstudioai/sim.git && cd sim
bun run setup
```
Open [http://localhost:3000](http://localhost:3000)
Docker must be installed and running. Use `-p, --port <port>` to run Sim on a different port, or `--no-pull` to skip pulling the latest Docker images.
<p align="center">
<img src="apps/sim/public/static/readme-platform.png" alt="The Sim platform — chat on the left, the visual workflow builder on the right" width="100%"/>
</p>
@@ -77,12 +76,6 @@ Docker must be installed and running. Use `-p, --port <port>` to run Sim on a di
**Requirements:** [Bun](https://bun.sh/) and [Docker](https://www.docker.com/).
```bash
git clone https://github.com/simstudioai/sim.git && cd sim
bun install
bun run setup
```
`bun run setup` is an interactive wizard: it provisions the database, generates secrets, writes your `.env` files, connects a Chat API key, and starts Sim the way you choose:
- **Local dev** — run from source to contribute or hack on Sim
@@ -110,7 +103,7 @@ Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https:
## Chat API Keys
Chat is a Sim-managed service. `bun run setup` connects a Chat API key for you — sign in when it opens your browser and the key is stored automatically. To view, create, or revoke keys later, go to [sim.ai/account/settings/chat-keys](https://sim.ai/account/settings/chat-keys).
Chat is a Sim-managed service. `bun run setup` connects a Chat API key for you — sign in when it opens your browser and the key is stored automatically. To view, create, or revoke keys later, go to [sim.ai/selfhost/settings/chat-keys](https://sim.ai/selfhost/settings/chat-keys).
## Environment Variables
@@ -1,23 +0,0 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getAccountSettingsHref } from '@/components/settings/navigation'
import { getSession } from '@/lib/auth'
import { isHosted } from '@/lib/core/config/env-flags'
import { ChatKeysView } from '@/app/account/settings/chat-keys/chat-keys-view'
export const metadata: Metadata = {
title: 'Chat keys - Account settings',
}
export default async function AccountChatKeysPage() {
const session = await getSession()
if (!session?.user) redirect('/login')
if (!isHosted) redirect(getAccountSettingsHref('general'))
return (
<Suspense fallback={null}>
<ChatKeysView />
</Suspense>
)
}
@@ -0,0 +1,56 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import {
getSelfHostSettingsHref,
getSettingsSectionMeta,
parseSettingsPathSection,
SELFHOST_SETTINGS_ITEMS,
} from '@/components/settings/navigation'
import { SelfHostSettingsRenderer } from '@/components/settings/selfhost-settings-renderer'
import { getSession } from '@/lib/auth'
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
interface SelfHostSettingsSectionPageProps {
params: Promise<{ section: string }>
}
export async function generateMetadata({
params,
}: SelfHostSettingsSectionPageProps): Promise<Metadata> {
const { section } = await params
const parsed = parseSettingsPathSection({
path: section,
items: SELFHOST_SETTINGS_ITEMS,
defaultSection: null,
})
const meta = parsed ? getSettingsSectionMeta('selfhost', parsed) : null
return { title: meta ? `${meta.label} - Self-host settings` : 'Self-host settings' }
}
export default async function SelfHostSettingsSectionPage({
params,
}: SelfHostSettingsSectionPageProps) {
const session = await getSession()
if (!session?.user) redirect('/login')
const { section } = await params
const parsed = parseSettingsPathSection({
path: section,
items: SELFHOST_SETTINGS_ITEMS,
defaultSection: null,
})
if (!parsed) notFound()
if (parsed === 'billing' && !isBillingEnabled) redirect(getSelfHostSettingsHref('general'))
if (parsed === 'chat-keys' && !isHosted) redirect(getSelfHostSettingsHref('general'))
/**
* Sections read URL query params via nuqs (which uses `useSearchParams`
* internally), so the renderer must sit under a Suspense boundary.
*/
return (
<Suspense fallback={null}>
<SelfHostSettingsRenderer section={parsed} />
</Suspense>
)
}
+10
View File
@@ -0,0 +1,10 @@
import { redirect } from 'next/navigation'
import { StandaloneSettingsShell } from '@/components/settings/standalone-settings-shell'
import { getSession } from '@/lib/auth'
export default async function SelfHostSettingsLayout({ children }: { children: React.ReactNode }) {
const session = await getSession()
if (!session?.user) redirect('/login')
return <StandaloneSettingsShell plane='selfhost'>{children}</StandaloneSettingsShell>
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from 'next/navigation'
import { getSelfHostSettingsHref } from '@/components/settings/navigation'
export default function SelfHostSettingsPage() {
redirect(getSelfHostSettingsHref('general'))
}
@@ -12,12 +12,9 @@ import {
ChipModalHeader,
SecretReveal,
} from '@sim/emcn'
import { ArrowLeft } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { formatDate } from '@sim/utils/formatting'
import { Plus } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { getAccountSettingsHref } from '@/components/settings/navigation'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
@@ -29,7 +26,7 @@ import {
useGenerateCopilotKey,
} from '@/hooks/queries/copilot-keys'
const logger = createLogger('ChatKeysSettings')
const logger = createLogger('CopilotSettings')
/** Formats a key's last-used timestamp, falling back to "Never" when unset. */
function formatLastUsed(dateString?: string | null): string {
@@ -38,11 +35,10 @@ function formatLastUsed(dateString?: string | null): string {
}
/**
* Standalone page for managing the Chat API keys used with the Chat feature.
* Provides functionality to create, view, and delete Chat API keys.
* Copilot Keys management component for handling API keys used with the Copilot feature.
* Provides functionality to create, view, and delete copilot API keys.
*/
export function ChatKeysView() {
const router = useRouter()
export function Copilot() {
const { data: keys = [], isLoading } = useCopilotKeys()
const generateKey = useGenerateCopilotKey()
const deleteKeyMutation = useDeleteCopilotKey()
@@ -88,7 +84,7 @@ export function ChatKeysView() {
setIsCreateDialogOpen(false)
}
} catch (error) {
logger.error('Failed to generate Chat API key', { error })
logger.error('Failed to generate copilot API key', { error })
setCreateError('Failed to create API key. Please check your connection and try again.')
}
}
@@ -102,7 +98,7 @@ export function ChatKeysView() {
await deleteKeyMutation.mutateAsync({ keyId: keyToDelete.id })
} catch (error) {
logger.error('Failed to delete Chat API key', { error })
logger.error('Failed to delete copilot API key', { error })
}
}
@@ -126,19 +122,12 @@ export function ChatKeysView() {
return (
<>
<SettingsPanel
back={{
text: 'Account',
icon: ArrowLeft,
onSelect: () => router.push(getAccountSettingsHref('general')),
}}
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search API keys...',
}}
actions={actions}
title='Chat keys'
description='Create and manage the API keys that power Chat.'
>
{isLoading ? null : showEmptyState ? (
<SettingsEmptyState>Click "Create API key" above to get started</SettingsEmptyState>
@@ -49,10 +49,12 @@ describe('unified settings navigation', () => {
})
it('derives every unified item from exactly one registry entry', () => {
expect(allNavigationItems).toHaveLength(SETTINGS_SECTION_REGISTRY.length)
expect(allNavigationItems).toHaveLength(
SETTINGS_SECTION_REGISTRY.filter(({ unified }) => unified).length
)
for (const item of allNavigationItems) {
expect(
SETTINGS_SECTION_REGISTRY.filter(({ unified }) => unified.id === item.id)
SETTINGS_SECTION_REGISTRY.filter(({ unified }) => unified?.id === item.id)
).toHaveLength(1)
}
})
@@ -17,6 +17,7 @@ import {
parseSettingsPathSection,
resolveOrganizationSectionAccess,
resolveWorkspaceNavigation,
SELFHOST_SETTINGS_ITEMS,
SETTINGS_SECTION_REGISTRY,
WORKSPACE_SETTINGS_ITEMS,
WORKSPACE_SETTINGS_PATH_ALIASES,
@@ -56,6 +57,7 @@ describe('settings navigation boundaries', () => {
'admin',
'mothership',
])
expect(SELFHOST_SETTINGS_ITEMS.map(({ id }) => id)).toEqual(['general', 'billing', 'chat-keys'])
expect(ORGANIZATION_SETTINGS_ITEMS.map(({ id }) => id)).toEqual([
'members',
'billing',
@@ -83,13 +85,18 @@ describe('settings navigation boundaries', () => {
})
it('has one registry source for every unified and plane item', () => {
const unifiedIds = SETTINGS_SECTION_REGISTRY.map(({ unified }) => unified.id)
const unifiedIds = SETTINGS_SECTION_REGISTRY.flatMap(({ unified }) =>
unified ? [unified.id] : []
)
const accountIds = SETTINGS_SECTION_REGISTRY.flatMap(({ planes }) =>
planes?.account ? [planes.account.id] : []
)
const organizationIds = SETTINGS_SECTION_REGISTRY.flatMap(({ planes }) =>
planes?.organization ? [planes.organization.id] : []
)
const selfHostIds = SETTINGS_SECTION_REGISTRY.flatMap(({ planes }) =>
planes?.selfhost ? [planes.selfhost.id] : []
)
const workspaceIds = SETTINGS_SECTION_REGISTRY.flatMap(({ planes }) =>
planes?.workspace ? [planes.workspace.id] : []
)
@@ -97,6 +104,7 @@ describe('settings navigation boundaries', () => {
expect(new Set(unifiedIds).size).toBe(unifiedIds.length)
expect(new Set(accountIds).size).toBe(accountIds.length)
expect(new Set(organizationIds).size).toBe(organizationIds.length)
expect(new Set(selfHostIds).size).toBe(selfHostIds.length)
expect(new Set(workspaceIds).size).toBe(workspaceIds.length)
expect([...unifiedIds].sort()).toEqual(
buildUnifiedSettingsNavigation()
@@ -107,6 +115,7 @@ describe('settings navigation boundaries', () => {
expect([...organizationIds].sort()).toEqual(
ORGANIZATION_SETTINGS_ITEMS.map(({ id }) => id).sort()
)
expect([...selfHostIds].sort()).toEqual(SELFHOST_SETTINGS_ITEMS.map(({ id }) => id).sort())
expect([...workspaceIds].sort()).toEqual(WORKSPACE_SETTINGS_ITEMS.map(({ id }) => id).sort())
})
@@ -179,9 +188,6 @@ describe('settings navigation boundaries', () => {
expect(parseAccountPath('/account/settings/apikeys', null)).toBe('api-keys')
expect(parseAccountPath('/account/settings/not-a-section', null)).toBeNull()
expect(parseAccountPath('/account/settings', 'general')).toBe('general')
// Chat keys is a real page but deliberately not a nav item — with a null
// default it must resolve to nothing so the sidebar highlights no sibling.
expect(parseAccountPath('/account/settings/chat-keys', null)).toBeNull()
})
it('parses canonical, aliased, and invalid organization settings paths', () => {
+93 -20
View File
@@ -26,10 +26,16 @@ import { McpIcon } from '@/components/icons'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/env-flags'
export type SettingsPlane = 'account' | 'organization' | 'workspace'
export type SettingsPlane = 'account' | 'organization' | 'selfhost' | 'workspace'
export type AccountSettingsSection = 'general' | 'billing' | 'api-keys' | 'admin' | 'mothership'
/**
* Settings a self-hoster needs from the managed service: their profile, what
* they pay for, and the Chat keys their own deployment authenticates with.
*/
export type SelfHostSettingsSection = 'general' | 'billing' | 'chat-keys'
export type OrganizationSettingsSection =
| 'members'
| 'billing'
@@ -57,6 +63,7 @@ export type WorkspaceSettingsSection =
export type SettingsSection =
| AccountSettingsSection
| OrganizationSettingsSection
| SelfHostSettingsSection
| WorkspaceSettingsSection
export type OrganizationSettingsRouteSection = OrganizationSettingsSection | 'unavailable'
@@ -132,6 +139,7 @@ interface UnifiedSettingsProjection
interface SettingsPlaneSectionMap {
account: AccountSettingsSection
organization: OrganizationSettingsSection
selfhost: SelfHostSettingsSection
workspace: WorkspaceSettingsSection
}
@@ -153,7 +161,8 @@ export interface SettingsSectionRegistryEntry {
label: string
icon: ComponentType<{ className?: string }>
docsLink?: string
unified: UnifiedSettingsProjection
/** Omit for sections that exist only on a standalone plane. */
unified?: UnifiedSettingsProjection
planes?: SettingsPlaneProjections
}
@@ -188,6 +197,13 @@ export function getAccountSettingsHref(
return withSettingsSearchParams(`/account/settings/${section}`, searchParams)
}
export function getSelfHostSettingsHref(
section: SelfHostSettingsSection,
searchParams?: SettingsHrefSearchParams
): string {
return withSettingsSearchParams(`/selfhost/settings/${section}`, searchParams)
}
export function getOrganizationSettingsHref(
organizationId: string,
section: OrganizationSettingsRouteSection,
@@ -267,6 +283,28 @@ export const ACCOUNT_SETTINGS_GROUPS = [
{ key: 'platform', title: 'Platform' },
] as const
/** Planes with their own standalone shell; the workspace plane renders inside the editor. */
export type StandaloneSettingsPlane = Exclude<SettingsPlane, 'workspace'>
/**
* Per-plane sidebar chrome. Self-host is reached from outside the app (the CLI
* wizard, the README), so it leads with the brand mark rather than a Back link
* into a workspace the visitor may not even be using.
*/
export const SETTINGS_PLANE_CHROME: Record<
StandaloneSettingsPlane,
{ label: string; showWordmark: boolean }
> = {
account: { label: 'Account', showWordmark: false },
organization: { label: 'Organization', showWordmark: false },
selfhost: { label: 'Self-host', showWordmark: true },
}
export const SELFHOST_SETTINGS_GROUPS = [
{ key: 'account', title: 'Account' },
{ key: 'developer', title: 'Developer' },
] as const
export const ORGANIZATION_SETTINGS_GROUPS = [
{ key: 'organization', title: 'Organization' },
{ key: 'security', title: 'Security' },
@@ -291,6 +329,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
},
planes: {
account: { id: 'general', group: 'account', order: 0 },
selfhost: { id: 'general', group: 'account', order: 0 },
},
},
{
@@ -354,6 +393,12 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
group: 'account',
order: 1,
},
selfhost: {
id: 'billing',
description: 'Manage your personal plan, usage, and invoices.',
group: 'account',
order: 1,
},
organization: {
id: 'billing',
description: 'Manage the organization plan, usage, and invoices.',
@@ -479,6 +524,18 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
workspace: { id: 'byok', group: 'workspace', order: 2 },
},
},
{
label: 'Chat keys',
icon: HexSimple,
planes: {
selfhost: {
id: 'chat-keys',
description: 'Manage the model-provider keys that power Chat.',
group: 'developer',
order: 2,
},
},
},
{
label: 'Sim mailer',
icon: Send,
@@ -634,15 +691,18 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
]
export function buildUnifiedSettingsNavigation(): UnifiedSettingsNavigationItem[] {
return SETTINGS_SECTION_REGISTRY.map(({ label, icon, docsLink, unified }) => {
return SETTINGS_SECTION_REGISTRY.flatMap(({ label, icon, docsLink, unified }) => {
if (!unified) return []
const { group, ...item } = unified
return {
...item,
label,
icon,
section: group,
...(docsLink ? { docsLink } : {}),
}
return [
{
...item,
label,
icon,
section: group,
...(docsLink ? { docsLink } : {}),
},
]
})
}
@@ -654,14 +714,22 @@ function buildPlaneSettingsItems<Plane extends SettingsPlane>(
return projection ? [{ entry, projection }] : []
})
.sort((left, right) => left.projection.order - right.projection.order)
.map(({ entry, projection }) => ({
id: projection.id,
label: projection.label ?? entry.label,
description: projection.description ?? entry.unified.description,
icon: entry.icon,
group: projection.group,
...(entry.docsLink ? { docsLink: entry.docsLink } : {}),
}))
.map(({ entry, projection }) => {
// A plane-only section carries no unified projection to inherit from, so
// its own description is the only source — missing one is a registry bug.
const description = projection.description ?? entry.unified?.description
if (!description) {
throw new Error(`Settings section "${projection.id}" is missing a description`)
}
return {
id: projection.id,
label: projection.label ?? entry.label,
description,
icon: entry.icon,
group: projection.group,
...(entry.docsLink ? { docsLink: entry.docsLink } : {}),
}
})
}
export const ACCOUNT_SETTINGS_ITEMS: SettingsNavigationItem<AccountSettingsSection>[] =
@@ -670,6 +738,9 @@ export const ACCOUNT_SETTINGS_ITEMS: SettingsNavigationItem<AccountSettingsSecti
export const ORGANIZATION_SETTINGS_ITEMS: SettingsNavigationItem<OrganizationSettingsSection>[] =
buildPlaneSettingsItems('organization')
export const SELFHOST_SETTINGS_ITEMS: SettingsNavigationItem<SelfHostSettingsSection>[] =
buildPlaneSettingsItems('selfhost')
export const WORKSPACE_SETTINGS_ITEMS: SettingsNavigationItem<WorkspaceSettingsSection>[] =
buildPlaneSettingsItems('workspace')
@@ -681,7 +752,7 @@ export const WORKSPACE_SETTINGS_ITEMS: SettingsNavigationItem<WorkspaceSettingsS
*/
export const ORGANIZATION_PLANE_UNIFIED_SECTIONS: ReadonlySet<UnifiedSettingsSection> = new Set(
SETTINGS_SECTION_REGISTRY.flatMap((entry) =>
entry.planes?.organization ? [entry.unified.id] : []
entry.planes?.organization && entry.unified ? [entry.unified.id] : []
)
)
@@ -834,7 +905,9 @@ export function getSettingsSectionMeta(
? ACCOUNT_SETTINGS_ITEMS
: plane === 'organization'
? ORGANIZATION_SETTINGS_ITEMS
: WORKSPACE_SETTINGS_ITEMS
: plane === 'selfhost'
? SELFHOST_SETTINGS_ITEMS
: WORKSPACE_SETTINGS_ITEMS
const item = catalog.find((candidate) => candidate.id === section)
return item ? { label: item.label, description: item.description, docsLink: item.docsLink } : null
}
@@ -0,0 +1,35 @@
'use client'
import { useEffect } from 'react'
import dynamic from 'next/dynamic'
import { usePostHog } from 'posthog-js/react'
import type { SelfHostSettingsSection } from '@/components/settings/navigation'
import { captureEvent } from '@/lib/posthog/client'
import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general'
const Billing = dynamic(() =>
import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then(
(module) => module.Billing
)
)
const ChatKeys = dynamic(() =>
import('@/app/workspace/[workspaceId]/settings/components/copilot/copilot').then(
(module) => module.Copilot
)
)
interface SelfHostSettingsRendererProps {
section: SelfHostSettingsSection
}
export function SelfHostSettingsRenderer({ section }: SelfHostSettingsRendererProps) {
const posthog = usePostHog()
useEffect(() => {
captureEvent(posthog, 'settings_tab_viewed', { plane: 'selfhost', section })
}, [posthog, section])
if (section === 'general') return <General />
if (section === 'billing') return <Billing scope='account' />
return <ChatKeys />
}
@@ -4,9 +4,24 @@ import { useEffect, useRef, useState } from 'react'
import { ChipConfirmModal, chipVariants, cn, Tooltip } from '@sim/emcn'
import { ChevronDown } from '@sim/emcn/icons'
import { useRouter } from 'next/navigation'
import type { SettingsNavigationItem, SettingsSection } from '@/components/settings/navigation'
import {
SETTINGS_PLANE_CHROME,
type SettingsNavigationItem,
type SettingsSection,
type StandaloneSettingsPlane,
} from '@/components/settings/navigation'
import { SimWordmark } from '@/app/(landing)/components/navbar/components'
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
/**
* The marketing landing page. `?home` is required: the proxy bounces a
* signed-in user off `/` to `/workspace` unless the param is present.
*/
const LANDING_HREF = '/?home'
/** Where the Back chip goes on planes that don't show the wordmark. */
const WORKSPACE_HREF = '/workspace'
interface SettingsNavigationGroup {
key: string
title: string
@@ -18,9 +33,8 @@ interface SidebarSettingsItem<Section extends SettingsSection>
}
interface SettingsSidebarProps<Section extends SettingsSection> {
/** `null` when the route is a nested page that is not itself a nav item — nothing highlights. */
activeSection: string | null
backHref: string
activeSection: string
plane: StandaloneSettingsPlane
groups: readonly SettingsNavigationGroup[]
hrefForSection: (section: Section) => string
items: readonly SidebarSettingsItem<Section>[]
@@ -48,7 +62,7 @@ function SidebarTooltip({
export function SettingsSidebar<Section extends SettingsSection>({
activeSection,
backHref,
plane,
groups,
hrefForSection,
items,
@@ -83,18 +97,30 @@ export function SettingsSidebar<Section extends SettingsSection>({
return (
<>
<div className='flex flex-shrink-0 flex-col gap-0.5 px-2 pb-1.5'>
<SidebarTooltip label='Back' enabled={showCollapsedTooltips}>
{/* Both stay buttons, not Links: leaving settings must run the unsaved-changes guard. */}
{SETTINGS_PLANE_CHROME[plane].showWordmark ? (
<button
type='button'
onClick={() => requestLeave(() => router.push(backHref))}
className={chipVariants({ fullWidth: true })}
aria-label='Sim home'
onClick={() => requestLeave(() => router.push(LANDING_HREF))}
className='flex h-[30px] flex-shrink-0 items-center px-2 transition-opacity hover:opacity-70'
>
<div className='flex size-[16px] flex-shrink-0 items-center justify-center text-[var(--text-icon)]'>
<ChevronDown className='size-[10px] rotate-90' />
</div>
<span className='sidebar-collapse-hide truncate text-[var(--text-body)]'>Back</span>
<SimWordmark />
</button>
</SidebarTooltip>
) : (
<SidebarTooltip label='Back' enabled={showCollapsedTooltips}>
<button
type='button'
onClick={() => requestLeave(() => router.push(WORKSPACE_HREF))}
className={chipVariants({ fullWidth: true })}
>
<div className='flex size-[16px] flex-shrink-0 items-center justify-center text-[var(--text-icon)]'>
<ChevronDown className='size-[10px] rotate-90' />
</div>
<span className='sidebar-collapse-hide truncate text-[var(--text-body)]'>Back</span>
</button>
</SidebarTooltip>
)}
</div>
<div
@@ -10,18 +10,22 @@ import {
getAccountSettingsHref,
getOrganizationSettingsFeatures,
getOrganizationSettingsHref,
getSelfHostSettingsHref,
isOrganizationSettingsSectionAvailable,
ORGANIZATION_SETTINGS_GROUPS,
ORGANIZATION_SETTINGS_ITEMS,
ORGANIZATION_SETTINGS_PATH_ALIASES,
parseSettingsPathSection,
resolveOrganizationSectionAccess,
SELFHOST_SETTINGS_GROUPS,
SELFHOST_SETTINGS_ITEMS,
SETTINGS_PLANE_CHROME,
} from '@/components/settings/navigation'
import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header'
import { SettingsSectionProvider } from '@/components/settings/settings-panel'
import { SettingsSidebar } from '@/components/settings/settings-sidebar'
import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
interface StandaloneSettingsShellBaseProps {
children: ReactNode
@@ -32,6 +36,10 @@ interface AccountSettingsShellProps extends StandaloneSettingsShellBaseProps {
isSuperUser?: boolean
}
interface SelfHostSettingsShellProps extends StandaloneSettingsShellBaseProps {
plane: 'selfhost'
}
interface OrganizationSettingsShellProps extends StandaloneSettingsShellBaseProps {
plane: 'organization'
organizationId: string
@@ -39,7 +47,10 @@ interface OrganizationSettingsShellProps extends StandaloneSettingsShellBaseProp
isOrganizationAdmin: boolean
}
type StandaloneSettingsShellProps = AccountSettingsShellProps | OrganizationSettingsShellProps
type StandaloneSettingsShellProps =
| AccountSettingsShellProps
| OrganizationSettingsShellProps
| SelfHostSettingsShellProps
export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) {
const { children, plane } = props
@@ -63,6 +74,19 @@ export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) {
isTargetOrganizationAdmin: isOrganizationAdmin,
}) !== 'unavailable' && isOrganizationSettingsSectionAvailable(item.id, organizationFeatures)
)
const selfHostItems = SELFHOST_SETTINGS_ITEMS.filter((item) => {
if (item.id === 'billing' && !isBillingEnabled) return false
// Chat keys are issued by the managed service, so there are none to list on
// a self-hosted deployment — useCopilotKeys is `enabled: isHosted` for the
// same reason. Self-hosters manage their keys on sim.ai.
if (item.id === 'chat-keys' && !isHosted) return false
return true
})
const selfHostSection = parseSettingsPathSection({
path: pathname,
items: SELFHOST_SETTINGS_ITEMS,
defaultSection: 'general',
})
const accountSection = parseSettingsPathSection({
path: pathname,
items: ACCOUNT_SETTINGS_ITEMS,
@@ -75,25 +99,25 @@ export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) {
defaultSection: 'members',
aliases: ORGANIZATION_SETTINGS_PATH_ALIASES,
})
const activeSection = plane === 'account' ? accountSection : organizationSection
/**
* The sidebar highlights only an exact nav match. A nested route that is not
* itself a nav item (e.g. /account/settings/chat-keys) would otherwise fall back
* to `defaultSection` and light up an unrelated sibling — reading as though the
* page lived inside it. The section above keeps its default for the title
* provider, which every page can still override.
*/
const accountSidebarSection = parseSettingsPathSection({
path: pathname,
items: ACCOUNT_SETTINGS_ITEMS,
defaultSection: null,
aliases: ACCOUNT_SETTINGS_PATH_ALIASES,
})
const activeSection =
plane === 'account'
? accountSection
: plane === 'selfhost'
? selfHostSection
: organizationSection
const sidebar =
plane === 'account' ? (
plane === 'selfhost' ? (
<SettingsSidebar
activeSection={accountSidebarSection}
backHref='/workspace'
activeSection={selfHostSection}
plane={plane}
groups={SELFHOST_SETTINGS_GROUPS}
hrefForSection={getSelfHostSettingsHref}
items={selfHostItems}
/>
) : plane === 'account' ? (
<SettingsSidebar
activeSection={accountSection}
plane={plane}
groups={ACCOUNT_SETTINGS_GROUPS}
hrefForSection={getAccountSettingsHref}
items={accountItems}
@@ -101,7 +125,7 @@ export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) {
) : (
<SettingsSidebar
activeSection={organizationSection}
backHref='/workspace'
plane={plane}
groups={ORGANIZATION_SETTINGS_GROUPS}
hrefForSection={(section) => getOrganizationSettingsHref(props.organizationId, section)}
items={organizationItems}
@@ -113,7 +137,7 @@ export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) {
<div className='flex h-screen w-full overflow-hidden bg-[var(--surface-1)] p-2'>
<aside
className='mr-2 flex w-[248px] flex-shrink-0 flex-col rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] pt-3'
aria-label={`${plane === 'account' ? 'Account' : 'Organization'} settings navigation`}
aria-label={`${SETTINGS_PLANE_CHROME[plane].label} settings navigation`}
>
{sidebar}
</aside>
+1 -1
View File
@@ -390,7 +390,7 @@ export interface PostHogEventMap {
}
settings_tab_viewed: {
plane: 'account' | 'organization' | 'workspace'
plane: 'account' | 'organization' | 'selfhost' | 'workspace'
section: string
}
+1 -1
View File
@@ -61,7 +61,7 @@
"library:covers:check": "bun run scripts/generate-library-covers.tsx --check",
"skills:sync": "bun run scripts/sync-skills.ts",
"skills:check": "bun run scripts/sync-skills.ts --check",
"setup": "bun run scripts/setup/index.ts setup",
"setup": "bun install && bun run scripts/setup/index.ts setup",
"sim": "bun run scripts/setup/index.ts",
"doctor": "bun run scripts/setup/index.ts doctor",
"agent-stream-docs:generate": "bun run scripts/sync-agent-stream-docs.ts",