put the settings section in the url

Five settings pages moved from a `*_SELECTED` global to `/settings/:page/:section`. The
sidebar entry is a react-router `<NavLink>` rather than a button holding the key in its
onClick closure, so a section is linkable, cmd-clickable and gets its active state from the
router; each page renders one `SettingsRoute` guard that canonicalises both the bare route
and a section that does not exist.

Integrations needed more than the shared factory. It builds its own sidebar, and it kept the
Enterprise/Personal tab in a second global — which is why a deep link to a Personal section
could never have worked: the link set the section, the tab stayed on Enterprise, and the
content pane said "Select a section" about a section that existed. The tab is derived from
the section key now.

Also removes the `/settings/resources` menu item (audit M8) and its two locale keys: there
has never been such a route, so it bounced to the catch-all and out to `/`.
This commit is contained in:
2026-08-07 11:38:21 +00:00
parent 98ba61f135
commit 2502c33804
12 changed files with 155 additions and 99 deletions
+9
View File
@@ -30,11 +30,20 @@ export function App() {
<Dashboard.DashboardLayout>
<Routes>
<Route path="/" element={<Dashboard.HomeScreen />} />
{/* Route pairs, like /headscale and /photos below: the bare page route exists so the
existing links into it keep working, and its `SettingsRoute` guard redirects to the
first section. The section is the URL rather than a `*_SELECTED` global, so a settings
sub-page is linkable, bookmarkable and cmd-clickable. */}
<Route path="/settings/profile" element={<Dashboard.ProfileSettings />} />
<Route path="/settings/profile/:section" element={<Dashboard.ProfileSettings />} />
<Route path="/settings/ai" element={<Dashboard.AISettings />} />
<Route path="/settings/ai/:section" element={<Dashboard.AISettings />} />
<Route path="/settings/system" element={<Dashboard.SystemSettings />} />
<Route path="/settings/system/:section" element={<Dashboard.SystemSettings />} />
<Route path="/settings/integrations" element={<Dashboard.IntegrationsSettings />} />
<Route path="/settings/integrations/:section" element={<Dashboard.IntegrationsSettings />} />
<Route path="/settings/user-management" element={<Dashboard.UserManagementSettings />} />
<Route path="/settings/user-management/:section" element={<Dashboard.UserManagementSettings />} />
{/* A project group is a working directory, so it is spelled as a path, not ?cwd= — see
apps/ChatHistory/chat-routes.ts. The splat has to be last in a pattern, which is why
"new" comes before the group rather than after it. A session carries no group: the
@@ -1,7 +1,7 @@
import { Link } from 'react-router';
import * as Dropdown from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { User, LogOut, Settings, Package, Puzzle, Sun, Moon, Bot, UserCog } from 'lucide-react';
import { User, LogOut, Settings, Puzzle, Sun, Moon, Bot, UserCog } from 'lucide-react';
import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
import { useColorMode } from '@/components/ui/ThemeProvider';
@@ -47,12 +47,9 @@ export function UserMenu() {
{t('header.userMenu.systemSettings')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/resources">
<Package className="mr-2 h-4 w-4" />
{t('header.userMenu.resources')}
</Link>
</DropdownMenuItem>
{/* No Resources entry. `/settings/resources` has never been a route, so this item sent you to
the catch-all and out to `/` — a menu entry that looks like navigation and is a bounce.
(navigation-audit M8.) */}
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/ai">
<Bot className="mr-2 h-4 w-4" />
@@ -7,7 +7,7 @@ import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { useAuth } from 'hooks/useAuth';
import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
import { SettingsRoute, createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
import { useSettings } from 'state/useSettings';
import type { UserSettings } from 'state/useSettings';
import { useModels, useVisibleModels, modelKey, getProviderDisplayName, type ModelOption } from 'state/useModels';
@@ -462,6 +462,8 @@ function useAISettingsGroups(): SettingsSectionGroup[] {
}, []);
}
const BASE_PATH = '/settings/ai';
const layout: LayoutNode = {
type: 'group',
id: 'ai-root',
@@ -475,10 +477,10 @@ const layout: LayoutNode = {
export const AISettings = () => {
const groups = useAISettingsGroups();
const { Sidebar, Content } = useMemo(
const { Sidebar, Content, allSections } = useMemo(
() =>
createSettingsPanelComponents({
globalKey: 'AI_SETTINGS_SELECTED',
basePath: BASE_PATH,
sidebarIcon: Bot,
sidebarLabel: 'AI',
groups,
@@ -495,8 +497,10 @@ export const AISettings = () => {
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
<SettingsRoute basePath={BASE_PATH} sections={allSections}>
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
</SettingsRoute>
);
};
@@ -1,12 +1,12 @@
import { useMemo } from 'react';
import { useNavigate, useParams } from 'react-router';
import { Puzzle, KeyRound, UserCircle, Globe, Wrench, Mail, CalendarDays } from 'lucide-react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { useAuth } from 'hooks/useAuth';
import { useGlobal } from 'hooks/useGlobal';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { SettingsSidebar, SettingsContent, type SettingsSection } from '../SettingsPanel';
import { SettingsRoute, SettingsSidebar, SettingsContent, type SettingsSection } from '../SettingsPanel';
import { GoogleOAuthConfig } from './GoogleOAuthConfig';
// import { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now
import { BrowserRelay } from './BrowserRelay';
@@ -14,8 +14,7 @@ import { ApifyConfig } from './ApifyConfig';
import { EmailAccounts } from './EmailAccounts';
import { DavAppPasswords } from './DavAppPasswords';
const GLOBAL_KEY = 'INTEGRATIONS_SETTINGS_SELECTED';
const TAB_KEY = 'INTEGRATIONS_SETTINGS_TAB';
const BASE_PATH = '/settings/integrations';
const enterpriseSections: SettingsSection[] = [
{
@@ -67,10 +66,27 @@ const personalSections: SettingsSection[] = [
},
];
// The Enterprise/Personal tab is not a second thing to remember — it is a fact about which section is
// open, so it is derived from the URL rather than kept in its own global. That also fixes the deep link:
// with the tab held separately, `/settings/integrations/email-accounts` would have arrived with the tab
// still on Enterprise and the content pane showing "Select a section".
const allSections = [...enterpriseSections, ...personalSections];
const tabForSection = (section: string | undefined) =>
personalSections.some((s) => s.key === section) ? 'personal' : 'enterprise';
const IntegrationsSidebar = () => {
const [tab, setTab] = useGlobal<string>(TAB_KEY, 'enterprise');
const { section } = useParams<{ section?: string }>();
const navigate = useNavigate();
const tab = tabForSection(section);
const sections = tab === 'enterprise' ? enterpriseSections : personalSections;
// Switching tab is a navigation, so it lands on that tab's first section rather than leaving the
// content pane showing a section the sidebar no longer lists.
const setTab = (next: string) => {
const first = (next === 'enterprise' ? enterpriseSections : personalSections)[0];
if (first) navigate(`${BASE_PATH}/${first.key}`);
};
return (
<div className="flex flex-col h-full">
<div className="p-3 pb-2">
@@ -91,17 +107,14 @@ const IntegrationsSidebar = () => {
</TabsList>
</Tabs>
</div>
<SettingsSidebar globalKey={GLOBAL_KEY} icon={Puzzle} label="Integrations" sections={sections} hideHeader />
<SettingsSidebar basePath={BASE_PATH} icon={Puzzle} label="Integrations" sections={sections} hideHeader />
</div>
);
};
const IntegrationsContent = () => {
const [tab] = useGlobal<string>(TAB_KEY, 'enterprise');
const sections = tab === 'enterprise' ? enterpriseSections : personalSections;
return <SettingsContent globalKey={GLOBAL_KEY} sections={sections} />;
};
// Both tabs' sections, because the URL alone says which one is open and the pane no longer has to agree
// with the sidebar about the tab to find it.
const IntegrationsContent = () => <SettingsContent sections={allSections} />;
const layout: LayoutNode = {
type: 'group',
@@ -123,8 +136,10 @@ export const IntegrationsSettings = () => {
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
<SettingsRoute basePath={BASE_PATH} sections={allSections}>
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
</SettingsRoute>
);
};
@@ -3,14 +3,14 @@ import { User, Lock, Globe, LayoutGrid, Volume2 } from 'lucide-react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
import { SettingsRoute, createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
import { UserData } from './UserData';
import { ChangePassword } from './ChangePassword';
import { Languages } from './Languages';
import { DockSettings } from './DockSettings';
import { VoicePreference } from './VoicePreference';
const GLOBAL_KEY = 'PROFILE_SETTINGS_SELECTED';
const BASE_PATH = '/settings/profile';
const sections: SettingsSection[] = [
{ key: 'profile', icon: User, title: 'Profile', description: 'Avatar, name, and email', content: <UserData /> },
@@ -45,7 +45,7 @@ const sections: SettingsSection[] = [
];
const { Sidebar, Content } = createSettingsPanelComponents({
globalKey: GLOBAL_KEY,
basePath: BASE_PATH,
sidebarIcon: User,
sidebarLabel: 'Profile',
sections,
@@ -71,8 +71,10 @@ export const ProfileSettings = () => {
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
<SettingsRoute basePath={BASE_PATH} sections={sections}>
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
</SettingsRoute>
);
};
@@ -1,7 +1,7 @@
import type { ComponentType, ReactNode } from 'react';
import { useState } from 'react';
import { NavLink, Navigate, useParams } from 'react-router';
import type { LucideIcon } from 'lucide-react';
import { useGlobal } from 'hooks/useGlobal';
import { Input } from '@/components/ui/input';
export type SettingsSection = {
@@ -19,7 +19,7 @@ export type SettingsSectionGroup = {
};
type SettingsSidebarProps = {
globalKey: string;
basePath: string;
icon: LucideIcon;
label: string;
sections: SettingsSection[];
@@ -27,33 +27,33 @@ type SettingsSidebarProps = {
hideHeader?: boolean;
};
const SectionButton = ({
section,
isActive,
onClick,
}: {
section: SettingsSection;
isActive: boolean;
onClick: () => void;
}) => (
<button
key={section.key}
onClick={onClick}
className={`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
isActive ? 'bg-duck-teal/10 text-duck-dark dark:text-foreground' : 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
}`}
/**
* A real link, not a button with an onClick. The section is in the URL, so cmd-click opens it in a tab,
* the browser knows it has been visited, and `isActive` comes from the router instead of from a global
* that the content pane had to be told about separately.
*/
const SectionLink = ({ section, to }: { section: SettingsSection; to: string }) => (
<NavLink
to={to}
className={({ isActive }) =>
`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
isActive ? 'bg-duck-teal/10 text-duck-dark dark:text-foreground' : 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
}`
}
>
<section.icon className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{section.title}</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">{section.description}</div>
</div>
</button>
{({ isActive }) => (
<>
<section.icon className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{section.title}</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">{section.description}</div>
</div>
</>
)}
</NavLink>
);
export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections, groups, hideHeader }: SettingsSidebarProps) => {
const allSections = groups ? groups.flatMap((g) => g.sections) : sections;
const [selectedKey, setSelectedKey] = useGlobal<string | null>(globalKey, allSections[0]?.key ?? null);
export const SettingsSidebar = ({ basePath, icon: Icon, label, sections, groups, hideHeader }: SettingsSidebarProps) => {
const [search, setSearch] = useState('');
const query = search.toLowerCase();
@@ -87,23 +87,13 @@ export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections, groups
</span>
</div>
{filtered.map((s) => (
<SectionButton
key={s.key}
section={s}
isActive={selectedKey === s.key}
onClick={() => setSelectedKey(s.key)}
/>
<SectionLink key={s.key} section={s} to={`${basePath}/${s.key}`} />
))}
</div>
);
})
: sections.filter(matchesSearch).map((s) => (
<SectionButton
key={s.key}
section={s}
isActive={selectedKey === s.key}
onClick={() => setSelectedKey(s.key)}
/>
<SectionLink key={s.key} section={s} to={`${basePath}/${s.key}`} />
))}
</div>
</div>
@@ -111,12 +101,14 @@ export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections, groups
};
type SettingsContentProps = {
globalKey: string;
sections: SettingsSection[];
};
export const SettingsContent = ({ globalKey, sections }: SettingsContentProps) => {
const [selectedKey] = useGlobal<string | null>(globalKey, sections[0]?.key ?? null);
export const SettingsContent = ({ sections }: SettingsContentProps) => {
// The sidebar and this pane are two sibling panels that used to agree via a shared `useGlobal` key.
// They agree via the URL now, so neither has to be told about the other — and neither can be the one
// that seeds the slot, which is what made the old defaulting order matter (see §5.6).
const { section: selectedKey } = useParams<{ section?: string }>();
const section = sections.find((s) => s.key === selectedKey);
if (!section) {
@@ -139,19 +131,35 @@ export const SettingsContent = ({ globalKey, sections }: SettingsContentProps) =
);
};
/**
* The one guard each settings screen renders, per the route-pair convention: it canonicalises both the
* bare page route and a section that does not exist, so `/settings/ai` and `/settings/ai/nonsense` both
* land on the first section instead of on "Select a section".
*
* Place it after the screen's hooks — it returns early.
*/
type SettingsRouteProps = { basePath: string; sections: SettingsSection[]; children: ReactNode };
export const SettingsRoute = ({ basePath, sections, children }: SettingsRouteProps) => {
const { section } = useParams<{ section?: string }>();
const first = sections[0];
if (first && !sections.some((s) => s.key === section)) return <Navigate to={`${basePath}/${first.key}`} replace />;
return <>{children}</>;
};
type CreateSettingsPanelParams = {
globalKey: string;
basePath: string;
sidebarIcon: LucideIcon;
sidebarLabel: string;
sections?: SettingsSection[];
groups?: SettingsSectionGroup[];
};
export const createSettingsPanelComponents = ({ globalKey, sidebarIcon, sidebarLabel, sections = [], groups }: CreateSettingsPanelParams) => {
export const createSettingsPanelComponents = ({ basePath, sidebarIcon, sidebarLabel, sections = [], groups }: CreateSettingsPanelParams) => {
const allSections = groups ? groups.flatMap((g) => g.sections) : sections;
const Sidebar: ComponentType = () => (
<SettingsSidebar globalKey={globalKey} icon={sidebarIcon} label={sidebarLabel} sections={allSections} groups={groups} />
<SettingsSidebar basePath={basePath} icon={sidebarIcon} label={sidebarLabel} sections={allSections} groups={groups} />
);
const Content: ComponentType = () => <SettingsContent globalKey={globalKey} sections={allSections} />;
return { Sidebar, Content };
const Content: ComponentType = () => <SettingsContent sections={allSections} />;
return { Sidebar, Content, allSections };
};
@@ -3,7 +3,7 @@ import { Bot, Settings, Mail, Volume2, Mic, ScanText } from 'lucide-react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
import { SettingsRoute, createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
import { SMTPSection } from './ServerSettings/SMTPSection';
import { TTSSection } from './ServerSettings/TTSSection';
import { STTSection } from './ServerSettings/STTSection';
@@ -40,8 +40,10 @@ const groups: SettingsSectionGroup[] = [
},
];
const { Sidebar, Content } = createSettingsPanelComponents({
globalKey: 'SYSTEM_SETTINGS_SELECTED',
const BASE_PATH = '/settings/system';
const { Sidebar, Content, allSections } = createSettingsPanelComponents({
basePath: BASE_PATH,
sidebarIcon: Settings,
sidebarLabel: 'System',
groups,
@@ -73,8 +75,10 @@ export const SystemSettings = () => {
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={baseLayout} onLayoutChange={() => {}} components={panelComponents} />
</div>
<SettingsRoute basePath={BASE_PATH} sections={allSections}>
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={baseLayout} onLayoutChange={() => {}} components={panelComponents} />
</div>
</SettingsRoute>
);
};
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { Users, UserCog, ShieldCheck } from 'lucide-react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
import { SettingsRoute, createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
import { UsersSection } from './UsersSection';
import { PermissionsSection } from './PermissionsSection';
@@ -28,8 +28,10 @@ const sections: SettingsSection[] = [
},
];
const BASE_PATH = '/settings/user-management';
const { Sidebar, Content } = createSettingsPanelComponents({
globalKey: 'USER_MANAGEMENT_SELECTED',
basePath: BASE_PATH,
sidebarIcon: UserCog,
sidebarLabel: 'Users',
sections,
@@ -52,8 +54,10 @@ export const UserManagementSettings = () => {
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
<SettingsRoute basePath={BASE_PATH} sections={sections}>
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
</SettingsRoute>
);
};
-1
View File
@@ -52,7 +52,6 @@
"userMenu": {
"profile": "Profile",
"systemSettings": "System Settings",
"resources": "Resources",
"signOut": "Sign Out"
}
},
-1
View File
@@ -52,7 +52,6 @@
"userMenu": {
"profile": "Perfil",
"systemSettings": "Definições do Sistema",
"resources": "Recursos",
"signOut": "Terminar Sessão"
}
},