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
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export function App() {
<Route path="/knowledge" element={<KnowledgeBase />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
<Route path="/settings/:tab" element={<Settings />} />

{/* Admin — super admin only */}
<Route path="/admin" element={<AdminGuard><AdminPanel /></AdminGuard>} />
Expand Down
15 changes: 14 additions & 1 deletion src/components/layout/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,20 @@ const pageTitles: Record<string, string> = {
'/knowledge': 'Knowledge Base',
'/marketplace': 'Marketplace',
'/analytics': 'Analytics',
'/settings': 'Settings',
'/settings': 'LLM Setup',
'/settings/api-keys': 'API Keys',
'/settings/webhooks': 'Webhooks',
'/settings/channels': 'Channels',
'/settings/members': 'Members',
'/settings/workspace': 'Workspace',
'/settings/billing': 'Billing',
'/settings/audit-log': 'Audit Log',
'/settings/automations': 'Automations',
'/settings/mcp-servers': 'MCP Servers',
'/settings/a2a': 'A2A Protocol',
'/settings/chat-widget': 'Chat Widget',
'/settings/license': 'License',
'/settings/profile': 'Profile',
}

export function TopBar({ onMenuToggle, isMobile }: TopBarProps) {
Expand Down
146 changes: 143 additions & 3 deletions src/layouts/RootLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@ import {
LogOut,
X,
Shield,
ChevronDown,
KeyRound,
Building2,
Webhook,
ScrollText,
CreditCard,
MessageSquareText,
ShieldCheck,
Brain,
Zap,
Plug,
Link2,
MessageCircle,
User,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth'
Expand All @@ -23,6 +37,7 @@ import { useIsMobile, useIsTablet } from '@/hooks/useMediaQuery'
import { TopBar } from '@/components/layout/TopBar'
import { MobileNav } from '@/components/layout/MobileNav'
import { useWorkspace } from '@/hooks/useWorkspace'
import { useEELicense } from '@/hooks/useEELicense'
import { PageSkeleton } from '@/components/ui/PageSkeleton'

const navItems = [
Expand All @@ -33,18 +48,63 @@ const navItems = [
{ to: '/knowledge', icon: BookOpen, label: 'Knowledge' },
{ to: '/marketplace', icon: Store, label: 'Marketplace' },
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
{ to: '/settings', icon: Settings, label: 'Settings' },
] as const

/** Settings sub-navigation items grouped by category */
const settingsSubNav: {
category?: string
items: { to: string; icon: typeof KeyRound; label: string; eeFeature?: string }[]
}[] = [
{
items: [
{ to: '/settings', icon: Brain, label: 'LLM Setup' },
{ to: '/settings/api-keys', icon: KeyRound, label: 'API Keys' },
{ to: '/settings/webhooks', icon: Webhook, label: 'Webhooks' },
],
},
{
category: 'Integrations',
items: [
{ to: '/settings/channels', icon: MessageSquareText, label: 'Channels', eeFeature: 'messaging_channels' },
{ to: '/settings/mcp-servers', icon: Plug, label: 'MCP Servers' },
{ to: '/settings/a2a', icon: Link2, label: 'A2A Protocol' },
{ to: '/settings/automations', icon: Zap, label: 'Automations' },
{ to: '/settings/chat-widget', icon: MessageCircle, label: 'Chat Widget', eeFeature: 'chat_widget' },
],
},
{
category: 'Workspace',
items: [
{ to: '/settings/members', icon: Users, label: 'Members', eeFeature: 'rbac' },
{ to: '/settings/workspace', icon: Building2, label: 'Workspace' },
{ to: '/settings/billing', icon: CreditCard, label: 'Billing' },
{ to: '/settings/audit-log', icon: ScrollText, label: 'Audit Log' },
{ to: '/settings/license', icon: ShieldCheck, label: 'License' },
{ to: '/settings/profile', icon: User, label: 'Profile' },
],
},
]

export function RootLayout() {
const { user, signOut } = useAuth()
const { isSuperAdmin } = useSuperAdmin()
const { workspace, isSuspended } = useWorkspace()
const { workspace, isSuspended, workspaceId } = useWorkspace()
const { hasFeature } = useEELicense(workspaceId ?? undefined)
const isMobile = useIsMobile()
const isTablet = useIsTablet()
const location = useLocation()
const [sidebarOpen, setSidebarOpen] = useState(false)

const isOnSettings = location.pathname === '/settings' || location.pathname.startsWith('/settings/')
const [settingsExpanded, setSettingsExpanded] = useState(isOnSettings)

// Auto-expand settings when navigating to a settings page
useEffect(() => {
if (isOnSettings) {
setSettingsExpanded(true)
}
}, [isOnSettings])

const metadata = (user?.user_metadata ?? {}) as Record<string, unknown>
const displayName = (typeof metadata.full_name === 'string' ? metadata.full_name : null)
?? (typeof metadata.name === 'string' ? metadata.name : null)
Expand Down Expand Up @@ -157,7 +217,7 @@ export function RootLayout() {
</div>

{/* Navigation */}
<nav className={cn('flex-1 space-y-1 py-4', collapsed ? 'px-2' : 'px-3')}>
<nav className={cn('flex-1 overflow-y-auto py-4', collapsed ? 'px-2' : 'px-3')}>
{allNavItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
Expand All @@ -178,6 +238,86 @@ export function RootLayout() {
{!collapsed && label}
</NavLink>
))}

{/* ─── Settings collapsible group ─── */}
{collapsed ? (
/* Collapsed: single settings icon */
<NavLink
to="/settings"
className={cn(
'flex items-center justify-center rounded-lg p-2.5 text-sm font-medium transition-colors',
isOnSettings
? 'bg-gray-800 text-gray-100'
: 'text-gray-400 hover:bg-gray-800/50 hover:text-gray-200',
)}
title="Settings"
>
<Settings className="h-5 w-5 shrink-0" />
</NavLink>
) : (
/* Expanded: collapsible settings section */
<div className="mt-1">
<button
type="button"
onClick={() => setSettingsExpanded(prev => !prev)}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isOnSettings
? 'bg-gray-800 text-gray-100'
: 'text-gray-400 hover:bg-gray-800/50 hover:text-gray-200',
)}
>
<Settings className="h-5 w-5 shrink-0" />
<span className="flex-1 text-left">Settings</span>
<ChevronDown
className={cn(
'h-4 w-4 shrink-0 transition-transform duration-200',
settingsExpanded && 'rotate-180',
)}
/>
</button>

{/* Sub-navigation items */}
<div
className={cn(
'overflow-hidden transition-all duration-200',
settingsExpanded ? 'max-h-[600px] opacity-100' : 'max-h-0 opacity-0',
)}
>
<div className="mt-1 space-y-0.5 pl-2">
{settingsSubNav.map((group, gi) => (
<div key={gi}>
{group.category && (
<div className="px-3 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-gray-600">
{group.category}
</div>
)}
{group.items
.filter(item => !item.eeFeature || hasFeature(item.eeFeature))
.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors',
isActive
? 'bg-gray-800/80 text-gray-200'
: 'text-gray-500 hover:bg-gray-800/40 hover:text-gray-300',
)
}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
{label}
</NavLink>
))}
</div>
))}
</div>
</div>
</div>
)}
</nav>

{/* User footer — hidden when collapsed */}
Expand Down
82 changes: 35 additions & 47 deletions src/pages/Settings.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 CrewForm

import { useState, useMemo } from 'react'
import { KeyRound, User, Building2, Webhook, Users, ScrollText, CreditCard, MessageSquareText, ShieldCheck, Brain, Zap, Plug, Link2, MessageCircle } from 'lucide-react'
import { useMemo } from 'react'
import { useParams } from 'react-router-dom'
import { ApiKeysSettings } from '@/components/settings/ApiKeysSettings'
import { RestApiKeysSettings } from '@/components/settings/RestApiKeysSettings'
import { WebhooksSettings } from '@/components/settings/WebhooksSettings'
Expand All @@ -24,58 +24,46 @@ import { cn } from '@/lib/utils'

type SettingsTab = 'llm-setup' | 'api-keys' | 'webhooks' | 'channels' | 'members' | 'workspace' | 'billing' | 'audit-log' | 'automations' | 'mcp-servers' | 'a2a' | 'chat-widget' | 'profile' | 'license'

const settingsTabs: { key: SettingsTab; label: string; icon: typeof KeyRound; eeFeature?: string }[] = [
{ key: 'llm-setup', label: 'LLM Setup', icon: Brain },
{ key: 'api-keys', label: 'API Keys', icon: KeyRound },
{ key: 'webhooks', label: 'Webhooks', icon: Webhook },
{ key: 'channels', label: 'Channels', icon: MessageSquareText, eeFeature: 'messaging_channels' },
{ key: 'members', label: 'Members', icon: Users, eeFeature: 'rbac' },
{ key: 'workspace', label: 'Workspace', icon: Building2 },
{ key: 'billing', label: 'Billing', icon: CreditCard },
{ key: 'audit-log', label: 'Audit Log', icon: ScrollText },
{ key: 'automations', label: 'Automations', icon: Zap },
{ key: 'mcp-servers', label: 'MCP Servers', icon: Plug },
{ key: 'a2a', label: 'A2A Protocol', icon: Link2 },
{ key: 'chat-widget', label: 'Chat Widget', icon: MessageCircle, eeFeature: 'chat_widget' },
{ key: 'license', label: 'License', icon: ShieldCheck },
{ key: 'profile', label: 'Profile', icon: User },
]
const validTabs = new Set<string>([
'llm-setup', 'api-keys', 'webhooks', 'channels', 'members', 'workspace',
'billing', 'audit-log', 'automations', 'mcp-servers', 'a2a', 'chat-widget',
'profile', 'license',
])

export function Settings() {
const { workspaceId } = useWorkspace()
const { hasFeature } = useEELicense(workspaceId ?? undefined)
const [activeTab, setActiveTab] = useState<SettingsTab>('llm-setup')

// Filter tabs based on EE license
const visibleTabs = useMemo(() =>
settingsTabs.filter(t => !t.eeFeature || hasFeature(t.eeFeature)),
[hasFeature],
)
const { tab: tabParam } = useParams<{ tab?: string }>()

// Resolve active tab from URL param, defaulting to llm-setup
const activeTab = useMemo<SettingsTab>(() => {
if (tabParam && validTabs.has(tabParam)) return tabParam as SettingsTab
return 'llm-setup'
}, [tabParam])

// EE feature gate check
const isTabGated = useMemo(() => {
const eeGates: Partial<Record<SettingsTab, string>> = {
channels: 'messaging_channels',
members: 'rbac',
'chat-widget': 'chat_widget',
}
const gate = eeGates[activeTab]
return gate ? !hasFeature(gate) : false
}, [activeTab, hasFeature])

if (isTabGated) {
return (
<div className="p-6 lg:p-8">
<div className="mx-auto max-w-2xl rounded-xl border border-border bg-surface-card p-8 text-center">
<p className="text-gray-400">This feature requires an Enterprise license.</p>
</div>
</div>
)
}

return (
<div className="p-6 lg:p-8">
<h1 className="mb-6 text-2xl font-semibold text-gray-100">Settings</h1>

{/* Tabs */}
<div className="mb-6 flex overflow-x-auto border-b border-border">
{visibleTabs.map(({ key, label, icon: Icon }) => (
<button
key={key}
type="button"
onClick={() => setActiveTab(key)}
className={cn(
'flex items-center gap-2 whitespace-nowrap border-b-2 px-4 py-2.5 text-sm font-medium transition-colors',
activeTab === key
? 'border-brand-primary text-gray-200'
: 'border-transparent text-gray-500 hover:text-gray-300',
)}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>

{/* Tab content */}
<div className={cn('mx-auto', activeTab === 'billing' ? 'max-w-4xl' : 'max-w-2xl')}>
{activeTab === 'llm-setup' && <ApiKeysSettings />}
Expand Down
Loading