'use client' import { useState, useEffect, useCallback } from 'react' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { LanguageSwitcherSelect } from '@/components/ui/language-switcher' import { useMissionControl } from '@/store' import { useNavigateToPanel } from '@/lib/navigation' import { SecurityScanCard } from '@/components/onboarding/security-scan-card' import { Loader } from '@/components/ui/loader' import { clearOnboardingDismissedThisSession, clearOnboardingReplayFromStart } from '@/lib/onboarding-session' import { resolveCoordinatorDeliveryTarget, type CoordinatorAgentRecord } from '@/lib/coordinator-routing' import type { GatewaySession } from '@/lib/sessions' interface Setting { key: string value: string description: string category: string updated_by: string | null updated_at: number | null is_default: boolean } interface ApiKeyInfo { masked_key: string | null source: string last_rotated_at: number | null last_rotated_by: string | null } interface CoordinatorTargetAgent { name: string openclawId: string isDefault: boolean sessionKey: string | null configRaw: string } type CoordinatorSession = GatewaySession & { source?: string } const COORDINATOR_AGENT = (process.env.NEXT_PUBLIC_COORDINATOR_AGENT || 'coordinator').toLowerCase() function parseCoordinatorTargetAgents(rawAgents: any[]): CoordinatorTargetAgent[] { const out: CoordinatorTargetAgent[] = [] for (const raw of rawAgents || []) { const name = typeof raw?.name === 'string' ? raw.name.trim() : '' if (!name) continue const config = raw?.config && typeof raw.config === 'object' ? raw.config : {} const openclawIdRaw = typeof config.openclawId === 'string' && config.openclawId.trim() ? config.openclawId.trim() : name const openclawId = openclawIdRaw.toLowerCase().replace(/\s+/g, '-') out.push({ name, openclawId, isDefault: config.isDefault === true, sessionKey: typeof raw?.session_key === 'string' && raw.session_key.trim() ? raw.session_key.trim() : null, configRaw: JSON.stringify(config), }) } const unique = new Map() for (const agent of out) { const key = agent.openclawId || agent.name.toLowerCase() if (!unique.has(key)) unique.set(key, agent) } return Array.from(unique.values()).sort((a, b) => { if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1 return a.name.localeCompare(b.name) }) } const categoryLabels: Record = { general: { label: 'General', icon: '⚙', description: 'Core Mission Control settings' }, security: { label: 'Security', icon: '🔑', description: 'API key management and security settings' }, retention: { label: 'Data Retention', icon: '🗄', description: 'How long data is kept before cleanup' }, chat: { label: 'Chat', icon: '💬', description: 'Coordinator routing and chat behavior settings' }, gateway: { label: 'Gateway', icon: '🔌', description: 'OpenClaw gateway connection settings' }, profiles: { label: 'Security Profiles', icon: 'shield', description: 'Hook profile controls security scanning strictness' }, custom: { label: 'Custom', icon: '🔧', description: 'User-defined settings' }, } const categoryOrder = ['general', 'security', 'profiles', 'retention', 'chat', 'gateway', 'custom'] // Dropdown options for subscription plan settings const subscriptionDropdowns: Record = { 'subscription.plan_override': [ { label: 'Auto-detect', value: '' }, { label: 'Pro ($20/mo)', value: 'pro' }, { label: 'Max ($100/mo)', value: 'max' }, { label: 'Max 5x ($200/mo)', value: 'max_5x' }, { label: 'Team ($30/mo)', value: 'team' }, { label: 'Enterprise', value: 'enterprise' }, ], 'subscription.codex_plan': [ { label: 'None', value: '' }, { label: 'ChatGPT Free ($0/mo)', value: 'chatgpt' }, { label: 'Plus ($20/mo)', value: 'plus' }, { label: 'Pro ($200/mo)', value: 'pro' }, { label: 'Team ($30/mo)', value: 'team' }, ], } export function SettingsPanel() { const t = useTranslations('settings') const { currentUser, setShowOnboarding } = useMissionControl() const navigateToPanel = useNavigateToPanel() const [settings, setSettings] = useState([]) const [grouped, setGrouped] = useState>({}) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [saving, setSaving] = useState(false) const [feedback, setFeedback] = useState<{ ok: boolean; text: string } | null>(null) // Track edited values (key -> new value) const [edits, setEdits] = useState>({}) const [activeCategory, setActiveCategory] = useState('general') // API key management state const [apiKeyInfo, setApiKeyInfo] = useState(null) const [apiKeyLoading, setApiKeyLoading] = useState(false) const [newApiKey, setNewApiKey] = useState(null) const [rotateConfirm, setRotateConfirm] = useState(false) const [rotating, setRotating] = useState(false) const [keyCopied, setKeyCopied] = useState(false) const [showSecurityScan, setShowSecurityScan] = useState(false) const [hookProfile, setHookProfile] = useState('standard') const [hookProfileSaving, setHookProfileSaving] = useState(false) const [coordinatorTargetAgents, setCoordinatorTargetAgents] = useState([]) const [coordinatorSessions, setCoordinatorSessions] = useState([]) // Replay onboarding state const [replayingOnboarding, setReplayingOnboarding] = useState(false) // Hermes integration state const [hermesStatus, setHermesStatus] = useState<{ installed: boolean gatewayRunning: boolean hookInstalled: boolean activeSessions: number cronJobCount?: number memoryEntries?: number } | null>(null) const [hermesLoading, setHermesLoading] = useState(false) const [hermesHookAction, setHermesHookAction] = useState(false) // Backup state const [mcBackupRunning, setMcBackupRunning] = useState(false) const [gwBackupRunning, setGwBackupRunning] = useState(false) const showFeedback = (ok: boolean, text: string) => { setFeedback({ ok, text }) setTimeout(() => setFeedback(null), 3000) } const getCoordinatorResolutionPreview = useCallback((configuredTarget: string) => { const allAgents: CoordinatorAgentRecord[] = coordinatorTargetAgents.map(agent => ({ name: agent.name, session_key: agent.sessionKey, config: agent.configRaw, })) const directAgent = allAgents.find(agent => agent.name.toLowerCase() === COORDINATOR_AGENT) || null const gatewaySessions = coordinatorSessions.filter(session => (session.source || 'gateway') === 'gateway') const resolved = resolveCoordinatorDeliveryTarget({ to: COORDINATOR_AGENT, coordinatorAgent: COORDINATOR_AGENT, directAgent, allAgents, sessions: gatewaySessions, configuredCoordinatorTarget: configuredTarget || null, }) const viaLabel: Record = { configured: 'configured target', default: 'default agent', main_session: 'live :main session', direct: 'coordinator record', fallback: 'fallback', } const targetLabel = `${resolved.deliveryName}${resolved.openclawAgentId ? ` (${resolved.openclawAgentId})` : ''}` return `Resolves now to ${targetLabel} via ${viaLabel[resolved.resolvedBy] || resolved.resolvedBy}.` }, [coordinatorTargetAgents, coordinatorSessions]) const fetchSettings = useCallback(async () => { try { const res = await fetch('/api/settings') if (res.status === 401) { window.location.assign('/login?next=%2Fsettings') return } if (res.status === 403) { setError('Admin access required') return } if (!res.ok) { const data = await res.json().catch(() => ({})) setError(data.error || 'Failed to load settings') return } const data = await res.json() setSettings(data.settings || []) setGrouped(data.grouped || {}) // Load hook profile from settings const hpSetting = (data.settings || []).find((s: Setting) => s.key === 'hook_profile') if (hpSetting) setHookProfile(hpSetting.value) // Load agent options for coordinator routing dropdown try { const agentsRes = await fetch('/api/agents?limit=200') if (agentsRes.ok) { const agentsData = await agentsRes.json() setCoordinatorTargetAgents(parseCoordinatorTargetAgents(agentsData.agents || [])) } } catch { // non-critical } // Load live sessions to preview coordinator routing resolution try { const sessionsRes = await fetch('/api/sessions') if (sessionsRes.ok) { const sessionsData = await sessionsRes.json() const mapped: CoordinatorSession[] = Array.isArray(sessionsData.sessions) ? sessionsData.sessions.map((session: any) => ({ key: String(session?.key || ''), agent: String(session?.agent || ''), source: typeof session?.source === 'string' ? session.source : undefined, sessionId: String(session?.id || session?.key || ''), updatedAt: Number(session?.lastActivity || session?.startTime || 0), chatType: String(session?.kind || 'unknown'), channel: String(session?.channel || ''), model: String(session?.model || ''), totalTokens: 0, inputTokens: 0, outputTokens: 0, contextTokens: 0, active: Boolean(session?.active), })).filter((session: CoordinatorSession) => session.key && session.agent) : [] setCoordinatorSessions(mapped) } } catch { // non-critical } } catch { setError('Failed to load settings') } finally { setLoading(false) } }, []) const fetchApiKeyInfo = useCallback(async () => { setApiKeyLoading(true) try { const res = await fetch('/api/tokens/rotate') if (res.ok) { const data = await res.json() setApiKeyInfo(data) } } catch { // Silent — non-critical } finally { setApiKeyLoading(false) } }, []) const handleRotateKey = async () => { setRotating(true) try { const res = await fetch('/api/tokens/rotate', { method: 'POST' }) const data = await res.json() if (res.ok) { setNewApiKey(data.key) setRotateConfirm(false) setKeyCopied(false) showFeedback(true, 'API key rotated successfully') fetchApiKeyInfo() } else { showFeedback(false, data.error || 'Failed to rotate key') } } catch { showFeedback(false, 'Network error') } finally { setRotating(false) } } const handleCopyKey = async () => { if (!newApiKey) return try { await navigator.clipboard.writeText(newApiKey) setKeyCopied(true) setTimeout(() => setKeyCopied(false), 2000) } catch { // Fallback: select and copy const el = document.createElement('textarea') el.value = newApiKey document.body.appendChild(el) el.select() document.execCommand('copy') document.body.removeChild(el) setKeyCopied(true) setTimeout(() => setKeyCopied(false), 2000) } } const fetchHermesStatus = useCallback(async () => { try { const res = await fetch('/api/hermes') if (res.ok) { setHermesStatus(await res.json()) } } catch { /* non-critical */ } }, []) useEffect(() => { fetchSettings(); fetchApiKeyInfo(); fetchHermesStatus() }, [fetchSettings, fetchApiKeyInfo, fetchHermesStatus]) const handleEdit = (key: string, value: string) => { setEdits(prev => ({ ...prev, [key]: value })) } const hasChanges = Object.keys(edits).some(key => { const setting = settings.find(s => s.key === key) return setting && edits[key] !== setting.value }) const handleSave = async () => { // Filter only actual changes const changes: Record = {} for (const [key, value] of Object.entries(edits)) { const setting = settings.find(s => s.key === key) if (setting && value !== setting.value) { changes[key] = value } } if (Object.keys(changes).length === 0) return setSaving(true) try { const res = await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ settings: changes }), }) const data = await res.json() if (res.ok) { showFeedback(true, `Saved ${data.count} setting${data.count === 1 ? '' : 's'}`) setEdits({}) fetchSettings() } else { showFeedback(false, data.error || 'Failed to save') } } catch { showFeedback(false, 'Network error') } finally { setSaving(false) } } const handleReset = async (key: string) => { try { const res = await fetch(`/api/settings?key=${encodeURIComponent(key)}`, { method: 'DELETE' }) const data = await res.json() if (res.ok) { showFeedback(true, `Reset "${key}" to default`) setEdits(prev => { const next = { ...prev } delete next[key] return next }) fetchSettings() } else { showFeedback(false, data.error || 'Failed to reset') } } catch { showFeedback(false, 'Network error') } } const handleDiscard = () => { setEdits({}) } if (loading) { return } if (error) { return (
{error}
) } const categories = categoryOrder.filter(c => c === 'security' || c === 'profiles' || (grouped[c]?.length > 0)) return (
{/* Header */}

{t('title')}

{t('description')}

{hasChanges && ( )}
{/* Workspace Info */} {currentUser?.role === 'admin' && (
{t('workspaceManagementLabel')}{' '} {t('workspaceManagementDesc1')}{' '} {' '} {t('workspaceManagementDesc2')}
)} {/* Station Setup */} {currentUser?.role === 'admin' && (
{/* Security Scan */}

{t('security')}

{t('securityDescription')}

{showSecurityScan && (
)} {/* Backup Actions */}

{t('backups')}

{t('backupsDescription')}

{/* Replay Onboarding */}

{t('onboarding')}

{t('onboardingDescription')}

{/* Hermes Agent Integration */} {hermesStatus?.installed && (

Hermes Agent

{hermesStatus.gatewayRunning ? 'Gateway running' : 'Gateway offline'} {hermesStatus.activeSessions > 0 && ( {hermesStatus.activeSessions} active )} {(hermesStatus.cronJobCount ?? 0) > 0 && ( {hermesStatus.cronJobCount} cron )} {(hermesStatus.memoryEntries ?? 0) > 0 && ( {hermesStatus.memoryEntries} mem )}

{hermesStatus.hookInstalled ? 'MC hook installed — receiving telemetry from hermes-agent' : 'Install the MC hook for richer telemetry (agent status, session events)'}

)}
)} {/* Feedback */} {feedback && (
{feedback.text}
)} {/* Language */} {/* Category tabs */}
{categories.map(cat => { const meta = categoryLabels[cat] || { label: cat, icon: '📋', description: '' } const changedCount = (grouped[cat] || []).filter(s => edits[s.key] !== undefined && edits[s.key] !== s.value).length return ( ) })}
{/* Security: API Key Management */} {activeCategory === 'security' && (
API Key {apiKeyInfo?.source && ( {apiKeyInfo.source} )}

Used for programmatic access and agent authentication via X-Api-Key header or Bearer token.

{/* Current key display */}
{apiKeyLoading ? 'Loading...' : apiKeyInfo?.masked_key || 'No API key configured'}
{apiKeyInfo?.last_rotated_at && (
Last rotated by {apiKeyInfo.last_rotated_by} on{' '} {new Date(apiKeyInfo.last_rotated_at * 1000).toLocaleDateString()}{' '} at {new Date(apiKeyInfo.last_rotated_at * 1000).toLocaleTimeString()}
)} {/* Rotate confirmation */} {!rotateConfirm ? (
) : (

Are you sure? Rotating the API key will immediately invalidate the current key. All agents and integrations using the old key will lose access.

)} {/* New key display (shown once after rotation) */} {newApiKey && (

New API key generated. Copy it now -- it will not be shown again.

{newApiKey}
)}
)} {/* Security Profiles: Hook Profile Selector */} {activeCategory === 'profiles' && (

Hook Profile

Controls how aggressively security hooks scan tool calls and agent outputs.

{([ { value: 'minimal', label: 'Minimal', desc: 'Basic safety checks only. Best for trusted environments with low risk tolerance overhead.' }, { value: 'standard', label: 'Standard', desc: 'Balanced scanning for secrets, injections, and suspicious patterns. Recommended for most deployments.' }, { value: 'strict', label: 'Strict', desc: 'Full depth scanning with aggressive blocking. May increase latency. Best for sensitive or compliance-driven environments.' }, ] as const).map(profile => ( ))}
)} {/* Interface Mode (General tab) */} {activeCategory === 'general' && ( )} {/* Settings list for active category */}
{activeCategory !== 'security' && (grouped[activeCategory] || []).map(setting => { const currentValue = edits[setting.key] ?? setting.value const isChanged = edits[setting.key] !== undefined && edits[setting.key] !== setting.value const isBooleanish = setting.value === 'true' || setting.value === 'false' const isNumeric = /^\d+$/.test(setting.value) const coordinatorTargetOptions = setting.key === 'chat.coordinator_target_agent' ? [ { label: 'Auto (default/main-session fallback)', value: '' }, ...coordinatorTargetAgents.map(agent => ({ label: `${agent.name}${agent.isDefault ? ' (default)' : ''} — ${agent.openclawId}`, value: agent.openclawId, })), ] : null const dropdownOptions = coordinatorTargetOptions || subscriptionDropdowns[setting.key] const coordinatorPreview = setting.key === 'chat.coordinator_target_agent' ? getCoordinatorResolutionPreview(currentValue) : null const shortKey = setting.key.split('.').pop() || setting.key return (
{formatLabel(shortKey)} {setting.is_default && ( default )} {isChanged && ( modified )}

{setting.description}

{setting.key}

{dropdownOptions ? ( ) : isBooleanish ? ( ) : isNumeric ? ( handleEdit(setting.key, e.target.value)} className="w-24 px-2 py-1 text-sm text-right bg-background border border-border rounded-md focus:border-primary focus:outline-none font-mono" /> ) : ( handleEdit(setting.key, e.target.value)} className="w-48 px-2 py-1 text-sm bg-background border border-border rounded-md focus:border-primary focus:outline-none" /> )} {!setting.is_default && ( )}
{coordinatorPreview && (

{coordinatorPreview}

)}
{setting.updated_by && setting.updated_at && (
Last updated by {setting.updated_by} on {new Date(setting.updated_at * 1000).toLocaleDateString()}
)}
) })}
{/* Account / OAuth connection */} {/* Unsaved changes bar */} {hasChanges && (
{Object.keys(edits).filter(k => { const s = settings.find(s => s.key === k) return s && edits[k] !== s.value }).length} unsaved change(s)
)}
) } function InterfaceModeSelector() { const { interfaceMode, setInterfaceMode } = useMissionControl() const [saving, setSaving] = useState(false) const navigateToPanel = useNavigateToPanel() const handleChange = async (mode: 'essential' | 'full') => { setInterfaceMode(mode) setSaving(true) try { await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ settings: { 'general.interface_mode': mode } }), }) // If switching to essential and on a hidden panel, redirect if (mode === 'essential') { const essentialIds = new Set(['overview', 'agents', 'tasks', 'chat', 'activity', 'logs', 'settings']) const store = useMissionControl.getState() if (!essentialIds.has(store.activeTab)) { navigateToPanel('overview') } } } catch {} setSaving(false) } return (

Interface Mode

Controls how many panels appear in the sidebar.

{([ { value: 'essential' as const, label: 'Essential', desc: 'Focused view with core panels only — Overview, Agents, Tasks, Chat, Activity, Logs, Settings.' }, { value: 'full' as const, label: 'Full', desc: 'All panels and advanced features including Memory, Cron, Webhooks, Alerts, Audit, and more.' }, ]).map(option => ( ))}

You can also switch from the sidebar footer.

) } function LanguageSection() { const ts = useTranslations('settings') return (

{ts('language')}

{ts('languageDescription')}

) } /** Convert snake_case key to Title Case label */ function formatLabel(key: string): string { return key .replace(/_/g, ' ') .replace(/\b\w/g, c => c.toUpperCase()) } // --------------------------------------------------------------------------- // Account OAuth Section — shows Google connection status with disconnect option // --------------------------------------------------------------------------- function AccountOAuthSection() { const { currentUser } = useMissionControl() const [disconnecting, setDisconnecting] = useState(false) const [feedback, setFeedback] = useState<{ ok: boolean; text: string } | null>(null) if (!currentUser) return null const isGoogleConnected = currentUser.provider === 'google' const handleDisconnect = async () => { setDisconnecting(true) try { const res = await fetch('/api/auth/google/disconnect', { method: 'POST' }) const data = await res.json().catch(() => ({})) if (res.ok) { setFeedback({ ok: true, text: 'Google account disconnected. You can now sign in with username and password.' }) // Reload after a short delay so the user sees the feedback setTimeout(() => window.location.reload(), 1500) } else { setFeedback({ ok: false, text: data.error || 'Failed to disconnect' }) } } catch { setFeedback({ ok: false, text: 'Network error' }) } finally { setDisconnecting(false) } } return (

Account

{/* Google icon */}
Google {isGoogleConnected ? ( Connected ) : ( Not connected )}
{isGoogleConnected && currentUser.email ? (

{currentUser.email}

) : (

Link your Google account for OAuth sign-in

)}
{isGoogleConnected && ( )}
{feedback && (
{feedback.text}
)}
) }