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
121 changes: 44 additions & 77 deletions packages/frontend/src/app/settings/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,53 +6,25 @@ import { useAuth } from '@/lib/auth-context';
import { AppShell } from '@/components/app-shell';
import { cn } from '@/lib/utils';

interface SidebarItem {
href: string;
label: string;
description: string;
icon: React.ComponentType<{ size?: number }>;
exact?: boolean;
adminOnly?: boolean;
/** Hidden in cloud. The backend answers 404 there regardless. */
selfHostedOnly?: boolean;
}

interface SidebarSection {
title?: string;
icon?: React.ComponentType<{ size?: number }>;
adminOnly?: boolean;
items: SidebarItem[];
}

const SIDEBAR_SECTIONS: SidebarSection[] = [
{
items: [
{ href: '/settings', label: 'Profile', description: 'Name, email, password', icon: GearIcon, exact: true },
{ href: '/settings/connections', label: 'Connections', description: 'What each AI client can reach', icon: PlugIcon },
],
},
{
title: 'Organization',
icon: BuildingIcon,
items: [
{ href: '/settings/organization', label: 'General', description: 'Workspace and new orgs', icon: BuildingIcon },
{ href: '/settings/users', label: 'Users', description: 'Members and invitations', icon: UsersIcon, adminOnly: true },
{ href: '/settings/roles', label: 'Roles', description: 'MCP tool access control', icon: ShieldIcon, adminOnly: true },
// Self-hosted only: SSO assumes the workspace owns its own directory,
// tenant and operator. The backend refuses these routes in cloud, so
// showing the entry there would lead an admin to a page that 404s.
{ href: '/settings/identity-providers', label: 'Single sign-on', description: 'Microsoft, Google, Okta', icon: FingerprintIcon, adminOnly: true, selfHostedOnly: true },
{ href: '/settings/license', label: 'License', description: 'Plan, billing, subscription', icon: KeyIcon, adminOnly: true },
{ href: '/settings/admin', label: 'Administration', description: 'SMTP, alerts, footer links', icon: WrenchIcon, adminOnly: true },
],
},
];
import { SIDEBAR_SECTIONS, getVisibleSettingsSections } from '@/lib/navigation';

const SETTINGS_ICONS: Record<string, React.ComponentType<{ size?: number }>> = {
'/settings': GearIcon,
'/settings/connections': PlugIcon,
'/settings/organization': BuildingIcon,
'/settings/users': UsersIcon,
'/settings/roles': ShieldIcon,
'/settings/identity-providers': FingerprintIcon,
'/settings/license': KeyIcon,
'/settings/admin': WrenchIcon,
};

export default function SettingsLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { user, deploymentMode } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const isCloud = deploymentMode === 'cloud';
const visibleSections = getVisibleSettingsSections(SIDEBAR_SECTIONS, { isAdmin, isCloud });

return (
<AppShell title="Settings">
Expand All @@ -61,42 +33,37 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
{/* Below lg this is a scrolling strip. The scrollbar is hidden, so the
soft trailing edge is what says there is more to the right. */}
<nav className="scrollbar-none scroll-fade-x -mx-4 flex gap-1 overflow-x-auto px-4 pb-2 sm:-mx-6 sm:px-6 lg:sticky lg:top-0 lg:mx-0 lg:flex-col lg:overflow-x-visible lg:px-0 lg:pb-0 lg:[mask-image:none]">
{SIDEBAR_SECTIONS.map((section, si) => {
if (section.adminOnly && !isAdmin) return null;

return (
<div key={si} className="contents lg:block">
{section.title && (
<div className="hidden items-center gap-1.5 px-2.5 pb-1 pt-3.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-[var(--text-3)] lg:flex">
{section.title}
</div>
)}
{section.items.map((item) => {
if (item.adminOnly && !isAdmin) return null;
if (item.selfHostedOnly && isCloud) return null;
const isActive = item.exact ? pathname === item.href : pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex flex-shrink-0 items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] transition-colors',
isActive
? 'bg-[var(--brand-tint)] font-medium text-[var(--brand)]'
: 'text-[var(--text-2)] hover:bg-[var(--surface-2)] hover:text-[var(--text)]'
)}
>
<item.icon size={15} />
<div className="min-w-0">
<div className="truncate">{item.label}</div>
<div className="hidden truncate text-[11px] text-[var(--text-3)] lg:block">{item.description}</div>
</div>
</Link>
);
})}
</div>
);
})}
{visibleSections.map((section, si) => (
<div key={si} className="contents lg:block">
{section.title && (
<div className="hidden items-center gap-1.5 px-2.5 pb-1 pt-3.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-[var(--text-3)] lg:flex">
{section.title}
</div>
)}
{section.items.map((item) => {
const isActive = item.exact ? pathname === item.href : pathname.startsWith(item.href);
const Icon = SETTINGS_ICONS[item.href];
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex flex-shrink-0 items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] transition-colors',
isActive
? 'bg-[var(--brand-tint)] font-medium text-[var(--brand)]'
: 'text-[var(--text-2)] hover:bg-[var(--surface-2)] hover:text-[var(--text)]'
)}
>
{Icon && <Icon size={15} />}
<div className="min-w-0">
<div className="truncate">{item.label}</div>
<div className="hidden truncate text-[11px] text-[var(--text-3)] lg:block">{item.description}</div>
</div>
</Link>
);
})}
</div>
))}
</nav>

{/* Main content */}
Expand Down
41 changes: 39 additions & 2 deletions packages/frontend/src/components/app-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
'use client';

import Link from 'next/link';
import { useState } from 'react';
import { useState, useSyncExternalStore } from 'react';
import { useTheme } from '@/lib/theme-context';
import { AppSidebar } from '@/components/app-sidebar';
import { CommandPalette, isMacPlatform } from '@/components/command-palette';
import { Footer } from '@/components/footer';
import { TrialBanner } from '@/components/trial-banner';
import { SubscriptionBanner } from '@/components/subscription-banner';
Expand Down Expand Up @@ -77,13 +78,22 @@ export function AppShell({
children,
}: AppShellProps) {
const [mobileOpen, setMobileOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const { resolvedTheme, setTheme } = useTheme();
const isDark = resolvedTheme === 'dark';

const isMac = useSyncExternalStore(
() => () => {},
() => isMacPlatform(),
() => true
);
const shortcutLabel = isMac ? '⌘K' : 'Ctrl K';

const headerTitle = title ?? breadcrumbs?.[breadcrumbs.length - 1]?.label;

return (
<div className="flex h-dvh items-stretch overflow-hidden">
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
<AppSidebar mobileOpen={mobileOpen} onClose={() => setMobileOpen(false)} />

<div className="flex min-h-0 min-w-0 flex-1 flex-col">
Expand Down Expand Up @@ -130,12 +140,39 @@ export function AppShell({
<div className="mt-px hidden truncate text-[12.5px] text-[var(--text-3)] md:block">{subtitle}</div>
)}
</div>
{/* Phones: the theme toggle rides the title row, so the second row
{/* Phones: the search button and theme toggle ride the title row, so the second row
belongs entirely to the page's own actions. */}
<button
type="button"
onClick={() => setPaletteOpen(true)}
aria-label="Open command palette"
title={`Command palette (${shortcutLabel})`}
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[9px] border border-[var(--border)] bg-[var(--surface)] text-[var(--text-2)] hover:bg-[var(--surface-2)] hover:text-[var(--text)] md:hidden"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
</button>
<ThemeToggle isDark={isDark} setTheme={setTheme} className="md:hidden" />
</div>

<div className="flex items-center gap-2.5 max-md:contents">
<button
type="button"
onClick={() => setPaletteOpen(true)}
aria-label={`Search (${shortcutLabel})`}
className="flex h-9 items-center gap-2 rounded-[9px] border border-[var(--border)] bg-[var(--surface)] px-2.5 text-[13px] text-[var(--text-3)] transition-colors hover:border-[var(--border-strong)] hover:text-[var(--text)] max-md:hidden"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<span className="text-[13px] text-[var(--text-2)]">Search...</span>
<kbd className="ml-1 rounded border border-[var(--border)] bg-[var(--surface-2)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--text-3)]">
{shortcutLabel}
</kbd>
</button>
<ThemeToggle isDark={isDark} setTheme={setTheme} className="max-md:hidden" />
{actions && (
<div className="flex min-w-0 flex-wrap items-center gap-2 max-md:grow max-md:justify-start md:justify-end">
Expand Down
55 changes: 19 additions & 36 deletions packages/frontend/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useAuth } from '@/lib/auth-context';
import { LogoIcon } from '@/components/logo-icon';
import { cn } from '@/lib/utils';
import { GithubStarButton } from '@/components/github-star-button';
import { NAV } from '@/lib/navigation';

/* ── Inline icons (match the redesign prototype) ── */
function I({ d, children, ...p }: { d?: string; children?: React.ReactNode } & React.SVGProps<SVGSVGElement>) {
Expand Down Expand Up @@ -39,38 +40,17 @@ const SettingsIcon = () => (
<I><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /><circle cx="12" cy="12" r="3" /></I>
);

type NavLink = { href: string; label: string; icon: () => React.ReactNode };
type NavGroup = { group: string; items: NavLink[] };

const NAV: NavGroup[] = [
{
group: 'Overview',
items: [
{ href: '/', label: 'Dashboard', icon: DashboardIcon },
{ href: '/analytics', label: 'Analytics', icon: AnalyticsIcon },
{ href: '/logs', label: 'Audit Log', icon: LogsIcon },
],
},
{
group: 'Build',
items: [
{ href: '/connectors', label: 'Connectors', icon: ConnectorsIcon },
{ href: '/connectors/store', label: 'Marketplace', icon: StoreIcon },
{ href: '/mcp-server', label: 'MCP Servers', icon: McpIcon },
],
},
{
group: 'Intelligence',
items: [
{ href: '/knowledge-graph', label: 'Knowledge Graph', icon: KgIcon },
{ href: '/knowledge-graph/skills', label: 'AI Skills', icon: SkillsIcon },
],
},
{
group: 'Get started',
items: [{ href: '/welcome', label: 'Setup & onboarding', icon: OnboardingIcon }],
},
];
const NAV_ICONS: Record<string, () => React.ReactNode> = {
'/': DashboardIcon,
'/analytics': AnalyticsIcon,
'/logs': LogsIcon,
'/connectors': ConnectorsIcon,
'/connectors/store': StoreIcon,
'/mcp-server': McpIcon,
'/knowledge-graph': KgIcon,
'/knowledge-graph/skills': SkillsIcon,
'/welcome': OnboardingIcon,
};

// All navigable hrefs, used to resolve the single active item: the longest
// href that prefix-matches the current path wins (so /connectors/store lights
Expand Down Expand Up @@ -126,7 +106,9 @@ export function AppSidebar({ mobileOpen, onClose }: { mobileOpen: boolean; onClo
<div className="px-2.5 pb-1 pt-2.5 text-[10.5px] font-semibold uppercase tracking-[0.06em] text-[var(--text-3)]">
{g.group}
</div>
{g.items.map((item) => (
{g.items.map((item) => {
const Icon = NAV_ICONS[item.href];
return (
<Link
key={item.href}
href={item.href}
Expand All @@ -138,10 +120,11 @@ export function AppSidebar({ mobileOpen, onClose }: { mobileOpen: boolean; onClo
: 'text-[var(--text-2)] hover:bg-[var(--surface-2)] hover:text-[var(--text)]'
)}
>
<item.icon />
{Icon && <Icon />}
{item.label}
</Link>
))}
);
})}
</div>
))}
</nav>
Expand All @@ -155,7 +138,7 @@ export function AppSidebar({ mobileOpen, onClose }: { mobileOpen: boolean; onClo
<div className="px-3 py-2 text-[10.5px] font-semibold uppercase tracking-[0.05em] text-[var(--text-3)]">
Switch workspace
</div>
{(orgs || []).map((org) => {
{(Array.isArray(orgs) ? orgs : []).map((org) => {
const isCurrent = org.id === user?.organizationId;
return (
<button
Expand Down
Loading
Loading