Spaces:
Runtime error
Runtime error
nyk
feat: full i18n — 1752 keys across 10 languages, all panels translated (#326)
b180108 unverified | '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<string, CoordinatorTargetAgent>() | |
| 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<string, { label: string; icon: string; description: string }> = { | |
| 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<string, { label: string; value: string }[]> = { | |
| '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<Setting[]>([]) | |
| const [grouped, setGrouped] = useState<Record<string, Setting[]>>({}) | |
| const [loading, setLoading] = useState(true) | |
| const [error, setError] = useState<string | null>(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<Record<string, string>>({}) | |
| const [activeCategory, setActiveCategory] = useState('general') | |
| // API key management state | |
| const [apiKeyInfo, setApiKeyInfo] = useState<ApiKeyInfo | null>(null) | |
| const [apiKeyLoading, setApiKeyLoading] = useState(false) | |
| const [newApiKey, setNewApiKey] = useState<string | null>(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<string>('standard') | |
| const [hookProfileSaving, setHookProfileSaving] = useState(false) | |
| const [coordinatorTargetAgents, setCoordinatorTargetAgents] = useState<CoordinatorTargetAgent[]>([]) | |
| const [coordinatorSessions, setCoordinatorSessions] = useState<CoordinatorSession[]>([]) | |
| // 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<string, string> = { | |
| 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<string, string> = {} | |
| 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 <Loader variant="panel" label="Loading settings" /> | |
| } | |
| if (error) { | |
| return ( | |
| <div className="p-6"> | |
| <div className="bg-destructive/10 text-destructive rounded-lg p-4 text-sm">{error}</div> | |
| </div> | |
| ) | |
| } | |
| const categories = categoryOrder.filter(c => c === 'security' || c === 'profiles' || (grouped[c]?.length > 0)) | |
| return ( | |
| <div className="p-4 md:p-6 max-w-4xl mx-auto space-y-6"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <h2 className="text-lg font-semibold text-foreground">{t('title')}</h2> | |
| <p className="text-xs text-muted-foreground mt-0.5">{t('description')}</p> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| {hasChanges && ( | |
| <Button | |
| onClick={handleDiscard} | |
| variant="outline" | |
| size="sm" | |
| > | |
| {t('discard')} | |
| </Button> | |
| )} | |
| <Button | |
| onClick={handleSave} | |
| disabled={!hasChanges || saving} | |
| variant={hasChanges ? 'default' : 'secondary'} | |
| size="sm" | |
| className={!hasChanges ? 'cursor-not-allowed' : ''} | |
| > | |
| {saving ? t('saving') : t('saveChanges')} | |
| </Button> | |
| </div> | |
| </div> | |
| {/* Workspace Info */} | |
| {currentUser?.role === 'admin' && ( | |
| <div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 text-xs text-blue-300"> | |
| <strong className="text-blue-200">{t('workspaceManagementLabel')}</strong>{' '} | |
| {t('workspaceManagementDesc1')}{' '} | |
| <Button | |
| onClick={() => navigateToPanel('super-admin')} | |
| variant="link" | |
| size="xs" | |
| className="text-blue-400 hover:text-blue-300 p-0 h-auto" | |
| > | |
| {t('superAdmin')} | |
| </Button>{' '} | |
| {t('workspaceManagementDesc2')} | |
| </div> | |
| )} | |
| {/* Station Setup */} | |
| {currentUser?.role === 'admin' && ( | |
| <div className="space-y-3"> | |
| {/* Security Scan */} | |
| <div className="flex items-center gap-3 p-3 bg-surface-1/50 border border-border/30 rounded-lg"> | |
| <div className="flex-1"> | |
| <p className="text-xs font-medium">{t('security')}</p> | |
| <p className="text-2xs text-muted-foreground">{t('securityDescription')}</p> | |
| </div> | |
| <Button | |
| variant="outline" | |
| size="xs" | |
| className="text-2xs" | |
| onClick={() => setShowSecurityScan(v => !v)} | |
| > | |
| {showSecurityScan ? t('hideScan') : t('securityScan')} | |
| </Button> | |
| </div> | |
| {showSecurityScan && ( | |
| <div className="p-4 bg-surface-1/30 border border-border/30 rounded-lg"> | |
| <SecurityScanCard /> | |
| </div> | |
| )} | |
| {/* Backup Actions */} | |
| <div className="flex items-center gap-3 p-3 bg-surface-1/50 border border-border/30 rounded-lg"> | |
| <div className="flex-1"> | |
| <p className="text-xs font-medium">{t('backups')}</p> | |
| <p className="text-2xs text-muted-foreground">{t('backupsDescription')}</p> | |
| </div> | |
| <Button | |
| variant="outline" | |
| size="xs" | |
| className="text-2xs" | |
| disabled={mcBackupRunning} | |
| onClick={async () => { | |
| setMcBackupRunning(true) | |
| try { | |
| const res = await fetch('/api/backup', { method: 'POST' }) | |
| const data = await res.json() | |
| if (res.ok) { | |
| showFeedback(true, `MC backup created (${(data.backup?.size / 1024).toFixed(0)} KB)`) | |
| } else { | |
| showFeedback(false, data.error || 'MC backup failed') | |
| } | |
| } catch { | |
| showFeedback(false, 'Network error') | |
| } finally { | |
| setMcBackupRunning(false) | |
| } | |
| }} | |
| > | |
| {mcBackupRunning ? t('backingUp') : t('backupMcDatabase')} | |
| </Button> | |
| <Button | |
| variant="outline" | |
| size="xs" | |
| className="text-2xs" | |
| disabled={gwBackupRunning} | |
| onClick={async () => { | |
| setGwBackupRunning(true) | |
| try { | |
| const res = await fetch('/api/backup?target=gateway', { method: 'POST' }) | |
| const data = await res.json() | |
| if (res.ok) { | |
| showFeedback(true, `Gateway backup created: ${data.output}`) | |
| } else { | |
| showFeedback(false, data.error || 'Gateway backup failed') | |
| } | |
| } catch { | |
| showFeedback(false, 'Network error') | |
| } finally { | |
| setGwBackupRunning(false) | |
| } | |
| }} | |
| > | |
| {gwBackupRunning ? t('backingUp') : t('backupGatewayState')} | |
| </Button> | |
| </div> | |
| {/* Replay Onboarding */} | |
| <div className="flex items-center gap-3 p-3 bg-surface-1/50 border border-border/30 rounded-lg"> | |
| <div className="flex-1"> | |
| <p className="text-xs font-medium">{t('onboarding')}</p> | |
| <p className="text-2xs text-muted-foreground">{t('onboardingDescription')}</p> | |
| </div> | |
| <Button | |
| variant="outline" | |
| size="xs" | |
| className="text-2xs" | |
| disabled={replayingOnboarding} | |
| onClick={async () => { | |
| setReplayingOnboarding(true) | |
| try { | |
| await fetch('/api/onboarding', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ action: 'reset' }), | |
| }) | |
| clearOnboardingDismissedThisSession() | |
| clearOnboardingReplayFromStart() | |
| setShowOnboarding(true) | |
| showFeedback(true, 'Onboarding reset — wizard will appear on next page load') | |
| } catch { | |
| showFeedback(false, 'Failed to reset onboarding') | |
| } finally { | |
| setReplayingOnboarding(false) | |
| } | |
| }} | |
| > | |
| {replayingOnboarding ? t('resetting') : t('replayOnboarding')} | |
| </Button> | |
| </div> | |
| {/* Hermes Agent Integration */} | |
| {hermesStatus?.installed && ( | |
| <div className="p-3 bg-surface-1/50 border border-border/30 rounded-lg space-y-2"> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex-1"> | |
| <div className="flex items-center gap-2"> | |
| <p className="text-xs font-medium">Hermes Agent</p> | |
| <span className={`text-2xs px-1.5 py-0.5 rounded ${ | |
| hermesStatus.gatewayRunning | |
| ? 'bg-green-500/15 text-green-400' | |
| : 'bg-muted text-muted-foreground' | |
| }`}> | |
| {hermesStatus.gatewayRunning ? 'Gateway running' : 'Gateway offline'} | |
| </span> | |
| {hermesStatus.activeSessions > 0 && ( | |
| <span className="text-2xs px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-400"> | |
| {hermesStatus.activeSessions} active | |
| </span> | |
| )} | |
| {(hermesStatus.cronJobCount ?? 0) > 0 && ( | |
| <span className="text-2xs px-1.5 py-0.5 rounded bg-purple-500/15 text-purple-400"> | |
| {hermesStatus.cronJobCount} cron | |
| </span> | |
| )} | |
| {(hermesStatus.memoryEntries ?? 0) > 0 && ( | |
| <span className="text-2xs px-1.5 py-0.5 rounded bg-purple-500/15 text-purple-400"> | |
| {hermesStatus.memoryEntries} mem | |
| </span> | |
| )} | |
| </div> | |
| <p className="text-2xs text-muted-foreground mt-0.5"> | |
| {hermesStatus.hookInstalled | |
| ? 'MC hook installed — receiving telemetry from hermes-agent' | |
| : 'Install the MC hook for richer telemetry (agent status, session events)'} | |
| </p> | |
| </div> | |
| <Button | |
| variant="outline" | |
| size="xs" | |
| className="text-2xs" | |
| disabled={hermesHookAction} | |
| onClick={async () => { | |
| setHermesHookAction(true) | |
| const action = hermesStatus.hookInstalled ? 'uninstall-hook' : 'install-hook' | |
| try { | |
| const res = await fetch('/api/hermes', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ action }), | |
| }) | |
| const data = await res.json() | |
| if (res.ok) { | |
| showFeedback(true, data.message || `Hook ${action === 'install-hook' ? 'installed' : 'uninstalled'}`) | |
| fetchHermesStatus() | |
| } else { | |
| showFeedback(false, data.error || 'Hook operation failed') | |
| } | |
| } catch { | |
| showFeedback(false, 'Network error') | |
| } finally { | |
| setHermesHookAction(false) | |
| } | |
| }} | |
| > | |
| {hermesHookAction | |
| ? 'Working...' | |
| : hermesStatus.hookInstalled | |
| ? 'Uninstall Hook' | |
| : 'Install MC Hook'} | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* Feedback */} | |
| {feedback && ( | |
| <div className={`rounded-lg p-3 text-xs font-medium ${ | |
| feedback.ok ? 'bg-green-500/10 text-green-400' : 'bg-destructive/10 text-destructive' | |
| }`}> | |
| {feedback.text} | |
| </div> | |
| )} | |
| {/* Language */} | |
| <LanguageSection /> | |
| {/* Category tabs */} | |
| <div className="flex gap-1 border-b border-border pb-px"> | |
| {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 ( | |
| <Button | |
| key={cat} | |
| onClick={() => setActiveCategory(cat)} | |
| variant="ghost" | |
| size="sm" | |
| className={`rounded-t-md rounded-b-none relative ${ | |
| activeCategory === cat | |
| ? 'bg-card text-foreground border border-border border-b-card -mb-px' | |
| : '' | |
| }`} | |
| > | |
| {meta.label} | |
| {changedCount > 0 && ( | |
| <span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 text-2xs rounded-full bg-primary text-primary-foreground"> | |
| {changedCount} | |
| </span> | |
| )} | |
| </Button> | |
| ) | |
| })} | |
| </div> | |
| {/* Security: API Key Management */} | |
| {activeCategory === 'security' && ( | |
| <div className="space-y-3"> | |
| <div className="bg-card border border-border rounded-lg p-4"> | |
| <div className="flex items-start justify-between gap-4"> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-sm font-medium text-foreground">API Key</span> | |
| {apiKeyInfo?.source && ( | |
| <span className="text-2xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground"> | |
| {apiKeyInfo.source} | |
| </span> | |
| )} | |
| </div> | |
| <p className="text-xs text-muted-foreground mt-0.5"> | |
| Used for programmatic access and agent authentication via X-Api-Key header or Bearer token. | |
| </p> | |
| </div> | |
| </div> | |
| {/* Current key display */} | |
| <div className="mt-3 flex items-center gap-2"> | |
| <code className="text-xs font-mono bg-background border border-border rounded px-2 py-1 text-muted-foreground"> | |
| {apiKeyLoading ? 'Loading...' : apiKeyInfo?.masked_key || 'No API key configured'} | |
| </code> | |
| </div> | |
| {apiKeyInfo?.last_rotated_at && ( | |
| <div className="text-2xs text-muted-foreground/50 mt-2"> | |
| 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()} | |
| </div> | |
| )} | |
| {/* Rotate confirmation */} | |
| {!rotateConfirm ? ( | |
| <div className="mt-3"> | |
| <Button | |
| onClick={() => setRotateConfirm(true)} | |
| variant="outline" | |
| size="sm" | |
| > | |
| Rotate Key | |
| </Button> | |
| </div> | |
| ) : ( | |
| <div className="mt-3 bg-amber-500/10 border border-amber-500/20 rounded-lg p-3"> | |
| <p className="text-xs text-amber-300 mb-2"> | |
| Are you sure? Rotating the API key will immediately invalidate the current key. | |
| All agents and integrations using the old key will lose access. | |
| </p> | |
| <div className="flex items-center gap-2"> | |
| <Button | |
| onClick={handleRotateKey} | |
| disabled={rotating} | |
| variant="default" | |
| size="sm" | |
| className="bg-amber-600 hover:bg-amber-700" | |
| > | |
| {rotating ? 'Rotating...' : 'Confirm Rotate'} | |
| </Button> | |
| <Button | |
| onClick={() => setRotateConfirm(false)} | |
| variant="ghost" | |
| size="sm" | |
| > | |
| Cancel | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| {/* New key display (shown once after rotation) */} | |
| {newApiKey && ( | |
| <div className="mt-3 bg-green-500/10 border border-green-500/20 rounded-lg p-3"> | |
| <p className="text-xs text-green-300 mb-2 font-medium"> | |
| New API key generated. Copy it now -- it will not be shown again. | |
| </p> | |
| <div className="flex items-center gap-2"> | |
| <code className="text-xs font-mono bg-background border border-border rounded px-2 py-1.5 text-foreground select-all flex-1 break-all"> | |
| {newApiKey} | |
| </code> | |
| <Button | |
| onClick={handleCopyKey} | |
| variant="outline" | |
| size="sm" | |
| className="shrink-0" | |
| > | |
| {keyCopied ? 'Copied!' : 'Copy'} | |
| </Button> | |
| </div> | |
| <div className="mt-2"> | |
| <Button | |
| onClick={() => setNewApiKey(null)} | |
| variant="ghost" | |
| size="xs" | |
| className="text-muted-foreground" | |
| > | |
| Dismiss | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {/* Security Profiles: Hook Profile Selector */} | |
| {activeCategory === 'profiles' && ( | |
| <div className="space-y-3"> | |
| <div className="bg-card border border-border rounded-lg p-4"> | |
| <h3 className="text-sm font-medium text-foreground mb-1">Hook Profile</h3> | |
| <p className="text-xs text-muted-foreground mb-4"> | |
| Controls how aggressively security hooks scan tool calls and agent outputs. | |
| </p> | |
| <div className="space-y-2"> | |
| {([ | |
| { 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 => ( | |
| <button | |
| key={profile.value} | |
| onClick={async () => { | |
| setHookProfile(profile.value) | |
| setHookProfileSaving(true) | |
| try { | |
| const res = await fetch('/api/settings', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ key: 'hook_profile', value: profile.value }), | |
| }) | |
| if (res.ok) { | |
| showFeedback(true, `Hook profile set to ${profile.label}`) | |
| } else { | |
| showFeedback(false, 'Failed to save hook profile') | |
| } | |
| } catch { | |
| showFeedback(false, 'Network error') | |
| } finally { | |
| setHookProfileSaving(false) | |
| } | |
| }} | |
| disabled={hookProfileSaving} | |
| className={`w-full text-left p-3 rounded-lg border transition-colors ${ | |
| hookProfile === profile.value | |
| ? 'border-primary bg-primary/5' | |
| : 'border-border hover:border-muted-foreground/30 bg-secondary' | |
| }`} | |
| > | |
| <div className="flex items-center gap-2 mb-1"> | |
| <div className={`w-3 h-3 rounded-full border-2 flex items-center justify-center ${ | |
| hookProfile === profile.value ? 'border-primary' : 'border-muted-foreground/50' | |
| }`}> | |
| {hookProfile === profile.value && ( | |
| <div className="w-1.5 h-1.5 rounded-full bg-primary" /> | |
| )} | |
| </div> | |
| <span className="text-sm font-medium text-foreground">{profile.label}</span> | |
| </div> | |
| <p className="text-xs text-muted-foreground ml-5">{profile.desc}</p> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Interface Mode (General tab) */} | |
| {activeCategory === 'general' && ( | |
| <InterfaceModeSelector /> | |
| )} | |
| {/* Settings list for active category */} | |
| <div className="space-y-3"> | |
| {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 ( | |
| <div | |
| key={setting.key} | |
| className={`bg-card border rounded-lg p-4 transition-colors ${ | |
| isChanged ? 'border-primary/50' : 'border-border' | |
| }`} | |
| > | |
| <div className="flex items-start justify-between gap-4"> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-sm font-medium text-foreground">{formatLabel(shortKey)}</span> | |
| {setting.is_default && ( | |
| <span className="text-2xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">default</span> | |
| )} | |
| {isChanged && ( | |
| <span className="text-2xs px-1.5 py-0.5 rounded bg-primary/15 text-primary">modified</span> | |
| )} | |
| </div> | |
| <p className="text-xs text-muted-foreground mt-0.5">{setting.description}</p> | |
| <p className="text-2xs text-muted-foreground/60 mt-1 font-mono">{setting.key}</p> | |
| </div> | |
| <div className="flex flex-col items-end gap-1 shrink-0"> | |
| <div className="flex items-center gap-2"> | |
| {dropdownOptions ? ( | |
| <select | |
| value={currentValue} | |
| onChange={e => handleEdit(setting.key, e.target.value)} | |
| className="w-64 px-2 py-1 text-sm bg-background border border-border rounded-md focus:border-primary focus:outline-none" | |
| > | |
| {dropdownOptions.map(opt => ( | |
| <option key={opt.value} value={opt.value}>{opt.label}</option> | |
| ))} | |
| {currentValue && !dropdownOptions.some(opt => opt.value === currentValue) && ( | |
| <option value={currentValue}>Custom: {currentValue}</option> | |
| )} | |
| </select> | |
| ) : isBooleanish ? ( | |
| <button | |
| onClick={() => handleEdit(setting.key, currentValue === 'true' ? 'false' : 'true')} | |
| className={`w-10 h-5 rounded-full relative transition-colors select-none ${ | |
| currentValue === 'true' ? 'bg-primary' : 'bg-muted' | |
| }`} | |
| > | |
| <span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${ | |
| currentValue === 'true' ? 'left-5' : 'left-0.5' | |
| }`} /> | |
| </button> | |
| ) : isNumeric ? ( | |
| <input | |
| type="number" | |
| value={currentValue} | |
| onChange={e => 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" | |
| /> | |
| ) : ( | |
| <input | |
| type="text" | |
| value={currentValue} | |
| onChange={e => 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 && ( | |
| <Button | |
| onClick={() => handleReset(setting.key)} | |
| title="Reset to default" | |
| variant="ghost" | |
| size="icon-xs" | |
| className="w-6 h-6" | |
| > | |
| <svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"> | |
| <path d="M2 8a6 6 0 1111.3-2.8" strokeLinecap="round" /> | |
| <path d="M14 2v3.5h-3.5" strokeLinecap="round" strokeLinejoin="round" /> | |
| </svg> | |
| </Button> | |
| )} | |
| </div> | |
| {coordinatorPreview && ( | |
| <p className="text-2xs text-muted-foreground max-w-72 text-right">{coordinatorPreview}</p> | |
| )} | |
| </div> | |
| </div> | |
| {setting.updated_by && setting.updated_at && ( | |
| <div className="text-2xs text-muted-foreground/50 mt-2"> | |
| Last updated by {setting.updated_by} on {new Date(setting.updated_at * 1000).toLocaleDateString()} | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| {/* Account / OAuth connection */} | |
| <AccountOAuthSection /> | |
| {/* Unsaved changes bar */} | |
| {hasChanges && ( | |
| <div className="fixed bottom-4 left-1/2 -translate-x-1/2 bg-card border border-border rounded-lg shadow-lg px-4 py-2.5 flex items-center gap-3 z-40"> | |
| <div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" /> | |
| <span className="text-xs text-foreground"> | |
| {Object.keys(edits).filter(k => { | |
| const s = settings.find(s => s.key === k) | |
| return s && edits[k] !== s.value | |
| }).length} unsaved change(s) | |
| </span> | |
| <Button | |
| onClick={handleDiscard} | |
| variant="ghost" | |
| size="xs" | |
| > | |
| {t('discard')} | |
| </Button> | |
| <Button | |
| onClick={handleSave} | |
| disabled={saving} | |
| size="xs" | |
| > | |
| {saving ? t('saving') : t('save')} | |
| </Button> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| 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 ( | |
| <div className="bg-card border border-border rounded-lg p-4"> | |
| <h3 className="text-sm font-medium text-foreground mb-1">Interface Mode</h3> | |
| <p className="text-xs text-muted-foreground mb-3"> | |
| Controls how many panels appear in the sidebar. | |
| </p> | |
| <div className="space-y-2"> | |
| {([ | |
| { 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 => ( | |
| <button | |
| key={option.value} | |
| onClick={() => handleChange(option.value)} | |
| disabled={saving} | |
| className={`w-full text-left p-3 rounded-lg border transition-colors ${ | |
| interfaceMode === option.value | |
| ? 'border-primary bg-primary/5' | |
| : 'border-border hover:border-muted-foreground/30 bg-secondary' | |
| }`} | |
| > | |
| <div className="flex items-center gap-2 mb-1"> | |
| <div className={`w-3 h-3 rounded-full border-2 flex items-center justify-center ${ | |
| interfaceMode === option.value ? 'border-primary' : 'border-muted-foreground/50' | |
| }`}> | |
| {interfaceMode === option.value && ( | |
| <div className="w-1.5 h-1.5 rounded-full bg-primary" /> | |
| )} | |
| </div> | |
| <span className="text-sm font-medium text-foreground">{option.label}</span> | |
| </div> | |
| <p className="text-xs text-muted-foreground ml-5">{option.desc}</p> | |
| </button> | |
| ))} | |
| </div> | |
| <p className="text-2xs text-muted-foreground/60 mt-2">You can also switch from the sidebar footer.</p> | |
| </div> | |
| ) | |
| } | |
| function LanguageSection() { | |
| const ts = useTranslations('settings') | |
| return ( | |
| <div className="bg-card border border-border rounded-lg p-4"> | |
| <div className="flex items-center justify-between gap-4"> | |
| <div> | |
| <p className="text-sm font-medium text-foreground">{ts('language')}</p> | |
| <p className="text-2xs text-muted-foreground mt-0.5">{ts('languageDescription')}</p> | |
| </div> | |
| <LanguageSwitcherSelect /> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| /** 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 ( | |
| <div className="space-y-3"> | |
| <div className="flex items-center gap-2 pt-2"> | |
| <h3 className="text-sm font-medium text-foreground">Account</h3> | |
| </div> | |
| <div className="bg-card border border-border rounded-lg p-4"> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-3"> | |
| {/* Google icon */} | |
| <div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${ | |
| isGoogleConnected ? 'bg-white' : 'bg-muted' | |
| }`}> | |
| <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none"> | |
| <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" /> | |
| <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" /> | |
| <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18A10.96 10.96 0 001 12c0 1.77.42 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05" /> | |
| <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" /> | |
| </svg> | |
| </div> | |
| <div> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-sm font-medium text-foreground">Google</span> | |
| {isGoogleConnected ? ( | |
| <span className="text-2xs px-1.5 py-0.5 rounded bg-green-500/15 text-green-400">Connected</span> | |
| ) : ( | |
| <span className="text-2xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">Not connected</span> | |
| )} | |
| </div> | |
| {isGoogleConnected && currentUser.email ? ( | |
| <p className="text-xs text-muted-foreground mt-0.5">{currentUser.email}</p> | |
| ) : ( | |
| <p className="text-xs text-muted-foreground mt-0.5">Link your Google account for OAuth sign-in</p> | |
| )} | |
| </div> | |
| </div> | |
| {isGoogleConnected && ( | |
| <Button | |
| onClick={handleDisconnect} | |
| disabled={disconnecting} | |
| variant="outline" | |
| size="sm" | |
| className="text-xs hover:text-destructive hover:border-destructive/50" | |
| > | |
| {disconnecting ? 'Disconnecting...' : 'Disconnect'} | |
| </Button> | |
| )} | |
| </div> | |
| {feedback && ( | |
| <div className={`mt-3 rounded-md p-2.5 text-xs font-medium ${ | |
| feedback.ok ? 'bg-green-500/10 text-green-400' : 'bg-destructive/10 text-destructive' | |
| }`}> | |
| {feedback.text} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ) | |
| } | |