mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-16 14:50:06 +08:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSelfHostSettingsHref } from '@/components/settings/navigation'
|
||||
|
||||
export default function SelfHostSettingsPage() {
|
||||
redirect(getSelfHostSettingsHref('general'))
|
||||
}
|
||||
+6
-17
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -390,7 +390,7 @@ export interface PostHogEventMap {
|
||||
}
|
||||
|
||||
settings_tab_viewed: {
|
||||
plane: 'account' | 'organization' | 'workspace'
|
||||
plane: 'account' | 'organization' | 'selfhost' | 'workspace'
|
||||
section: string
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user