import React, { useState, useEffect, useCallback, useMemo } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import { useAuthStore } from '../store/authStore' import { useSettingsStore } from '../store/settingsStore' import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n' import Navbar from '../components/Layout/Navbar' import CustomSelect from '../components/shared/CustomSelect' import { useToast } from '../components/shared/Toast' import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound, AlertTriangle, Copy, Download, Printer, Terminal, Plus, Check, Info } from 'lucide-react' import { authApi, adminApi } from '../api/client' import apiClient from '../api/client' import { useAddonStore } from '../store/addonStore' import type { LucideIcon } from 'lucide-react' import type { UserWithOidc } from '../types' import { getApiErrorMessage } from '../types' import { MapView } from '../components/Map/MapView' import type { Place } from '../types' interface MapPreset { name: string url: string } const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending' interface McpToken { id: number name: string token_prefix: string created_at: string last_used_at: string | null } const MAP_PRESETS: MapPreset[] = [ { name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' }, { name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' }, { name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' }, { name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' }, { name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' }, ] interface SectionProps { title: string icon: LucideIcon children: React.ReactNode } function Section({ title, icon: Icon, children }: SectionProps): React.ReactElement { return (

{title}

{children}
) } function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) { return ( ) } function NotificationPreferences({ t }: { t: any; memoriesEnabled: boolean }) { const [notifChannel, setNotifChannel] = useState('none') useEffect(() => { authApi.getAppConfig?.().then((cfg: any) => { if (cfg?.notification_channel) setNotifChannel(cfg.notification_channel) }).catch(() => {}) }, []) if (notifChannel === 'none') { return (

{t('settings.notificationsDisabled')}

) } const channelLabel = notifChannel === 'email' ? (t('admin.notifications.email') || 'Email (SMTP)') : (t('admin.notifications.webhook') || 'Webhook') return (
{t('settings.notificationsActive')}: {channelLabel}

{t('settings.notificationsManagedByAdmin')}

) } export default function SettingsPage(): React.ReactElement { const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa } = useAuthStore() const [searchParams] = useSearchParams() const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const avatarInputRef = React.useRef(null) const { settings, updateSetting, updateSettings } = useSettingsStore() const { isEnabled: addonEnabled, loadAddons } = useAddonStore() const { t, locale } = useTranslation() const toast = useToast() const navigate = useNavigate() const [saving, setSaving] = useState>({}) // Addon gating (derived from store) const memoriesEnabled = addonEnabled('memories') const mcpEnabled = addonEnabled('mcp') const [appVersion, setAppVersion] = useState(null) useEffect(() => { authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {}) }, []) const [immichUrl, setImmichUrl] = useState('') const [immichApiKey, setImmichApiKey] = useState('') const [immichConnected, setImmichConnected] = useState(false) const [immichTesting, setImmichTesting] = useState(false) const handleMapClick = useCallback((mapInfo) => { setDefaultLat(mapInfo.latlng.lat) setDefaultLng(mapInfo.latlng.lng) }, []) useEffect(() => { loadAddons() }, []) useEffect(() => { if (memoriesEnabled) { apiClient.get('/integrations/immich/settings').then(r2 => { setImmichUrl(r2.data.immich_url || '') setImmichConnected(r2.data.connected) }).catch(() => {}) } }, [memoriesEnabled]) const [immichTestPassed, setImmichTestPassed] = useState(false) const handleSaveImmich = async () => { setSaving(s => ({ ...s, immich: true })) try { const saveRes = await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined }) if (saveRes.data.warning) toast.warning(saveRes.data.warning) toast.success(t('memories.saved')) const res = await apiClient.get('/integrations/immich/status') setImmichConnected(res.data.connected) setImmichTestPassed(false) } catch { toast.error(t('memories.connectionError')) } finally { setSaving(s => ({ ...s, immich: false })) } } const handleTestImmich = async () => { setImmichTesting(true) try { const res = await apiClient.post('/integrations/immich/test', { immich_url: immichUrl, immich_api_key: immichApiKey }) if (res.data.connected) { if (res.data.canonicalUrl) { setImmichUrl(res.data.canonicalUrl) toast.success(`${t('memories.connectionSuccess')} — ${res.data.user?.name || ''} (URL updated to ${res.data.canonicalUrl})`) } else { toast.success(`${t('memories.connectionSuccess')} — ${res.data.user?.name || ''}`) } setImmichTestPassed(true) } else { toast.error(`${t('memories.connectionError')}: ${res.data.error}`) setImmichTestPassed(false) } } catch { toast.error(t('memories.connectionError')) } finally { setImmichTesting(false) } } // MCP tokens const [mcpTokens, setMcpTokens] = useState([]) const [mcpModalOpen, setMcpModalOpen] = useState(false) const [mcpNewName, setMcpNewName] = useState('') const [mcpCreatedToken, setMcpCreatedToken] = useState(null) const [mcpCreating, setMcpCreating] = useState(false) const [mcpDeleteId, setMcpDeleteId] = useState(null) const [copiedKey, setCopiedKey] = useState(null) useEffect(() => { authApi.mcpTokens.list().then(d => setMcpTokens(d.tokens || [])).catch(() => {}) }, []) const handleCreateMcpToken = async () => { if (!mcpNewName.trim()) return setMcpCreating(true) try { const d = await authApi.mcpTokens.create(mcpNewName.trim()) setMcpCreatedToken(d.token.raw_token) setMcpNewName('') setMcpTokens(prev => [{ id: d.token.id, name: d.token.name, token_prefix: d.token.token_prefix, created_at: d.token.created_at, last_used_at: null }, ...prev]) } catch { toast.error(t('settings.mcp.toast.createError')) } finally { setMcpCreating(false) } } const handleDeleteMcpToken = async (id: number) => { try { await authApi.mcpTokens.delete(id) setMcpTokens(prev => prev.filter(tk => tk.id !== id)) setMcpDeleteId(null) toast.success(t('settings.mcp.toast.deleted')) } catch { toast.error(t('settings.mcp.toast.deleteError')) } } const handleCopy = (text: string, key: string) => { navigator.clipboard.writeText(text).then(() => { setCopiedKey(key) setTimeout(() => setCopiedKey(null), 2000) }) } const mcpEndpoint = `${window.location.origin}/mcp` const mcpJsonConfig = `{ "mcpServers": { "trek": { "command": "npx", "args": [ "mcp-remote", "${mcpEndpoint}", "--header", "Authorization: Bearer " ] } } }` // Map settings const [mapTileUrl, setMapTileUrl] = useState(settings.map_tile_url || '') const [defaultLat, setDefaultLat] = useState(settings.default_lat || 48.8566) const [defaultLng, setDefaultLng] = useState(settings.default_lng || 2.3522) const [defaultZoom, setDefaultZoom] = useState(settings.default_zoom || 10) const mapPlaces = useMemo(() => { // Add center location to map places let places: Place[] = [] places.push({ id: 1, trip_id: 1, name: "Default map center", description: "", lat: defaultLat as number, lng: defaultLng as number, address: "", category_id: 0, icon: null, price: null, image_url: null, google_place_id: null, osm_id: null, route_geometry: null, place_time: null, end_time: null, created_at: Date() }); return places }, [defaultLat, defaultLng]) // Display const [tempUnit, setTempUnit] = useState(settings.temperature_unit || 'celsius') // Account const [username, setUsername] = useState(user?.username || '') const [email, setEmail] = useState(user?.email || '') const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [oidcOnlyMode, setOidcOnlyMode] = useState(false) useEffect(() => { authApi.getAppConfig?.().then((config) => { if (config?.oidc_only_mode) setOidcOnlyMode(true) }).catch(() => {}) }, []) const [mfaQr, setMfaQr] = useState(null) const [mfaSecret, setMfaSecret] = useState(null) const [mfaSetupCode, setMfaSetupCode] = useState('') const [mfaDisablePwd, setMfaDisablePwd] = useState('') const [mfaDisableCode, setMfaDisableCode] = useState('') const [mfaLoading, setMfaLoading] = useState(false) const mfaRequiredByPolicy = !demoMode && !user?.mfa_enabled && (searchParams.get('mfa') === 'required' || appRequireMfa) const [backupCodes, setBackupCodes] = useState(null) const backupCodesText = backupCodes?.join('\n') || '' // Restore backup codes panel after refresh (loadUser silent fix + sessionStorage) useEffect(() => { if (!user?.mfa_enabled || backupCodes) return try { const raw = sessionStorage.getItem(MFA_BACKUP_SESSION_KEY) if (!raw) return const parsed = JSON.parse(raw) as unknown if (Array.isArray(parsed) && parsed.length > 0 && parsed.every((x) => typeof x === 'string')) { setBackupCodes(parsed) } } catch { sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY) } }, [user?.mfa_enabled, backupCodes]) const dismissBackupCodes = (): void => { sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY) setBackupCodes(null) } const copyBackupCodes = async (): Promise => { if (!backupCodesText) return try { await navigator.clipboard.writeText(backupCodesText) toast.success(t('settings.mfa.backupCopied')) } catch { toast.error(t('common.error')) } } const downloadBackupCodes = (): void => { if (!backupCodesText) return const blob = new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = 'trek-mfa-backup-codes.txt' document.body.appendChild(a) a.click() a.remove() URL.revokeObjectURL(url) } const printBackupCodes = (): void => { if (!backupCodesText) return const html = `TREK MFA Backup Codes

TREK MFA Backup Codes

${new Date().toLocaleString()}

${backupCodesText}
` const w = window.open('', '_blank', 'width=900,height=700') if (!w) return w.document.open() w.document.write(html) w.document.close() w.focus() w.print() } useEffect(() => { setMapTileUrl(settings.map_tile_url || '') setDefaultLat(settings.default_lat || 48.8566) setDefaultLng(settings.default_lng || 2.3522) setDefaultZoom(settings.default_zoom || 10) setTempUnit(settings.temperature_unit || 'celsius') }, [settings]) useEffect(() => { setUsername(user?.username || '') setEmail(user?.email || '') }, [user]) const saveMapSettings = async (): Promise => { setSaving(s => ({ ...s, map: true })) try { await updateSettings({ map_tile_url: mapTileUrl, default_lat: parseFloat(String(defaultLat)), default_lng: parseFloat(String(defaultLng)), default_zoom: parseInt(String(defaultZoom)), }) toast.success(t('settings.toast.mapSaved')) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') } finally { setSaving(s => ({ ...s, map: false })) } } const saveDisplay = async (): Promise => { setSaving(s => ({ ...s, display: true })) try { await updateSetting('temperature_unit', tempUnit) toast.success(t('settings.toast.displaySaved')) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') } finally { setSaving(s => ({ ...s, display: false })) } } const handleAvatarUpload = async (e: React.ChangeEvent): Promise => { const file = e.target.files?.[0] if (!file) return try { await uploadAvatar(file) toast.success(t('settings.avatarUploaded')) } catch { toast.error(t('settings.avatarError')) } if (avatarInputRef.current) avatarInputRef.current.value = '' } const handleAvatarRemove = async (): Promise => { try { await deleteAvatar() toast.success(t('settings.avatarRemoved')) } catch { toast.error(t('settings.avatarError')) } } const saveProfile = async (): Promise => { setSaving(s => ({ ...s, profile: true })) try { await updateProfile({ username, email }) toast.success(t('settings.toast.profileSaved')) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') } finally { setSaving(s => ({ ...s, profile: false })) } } return (

{t('settings.title')}

{t('settings.subtitle')}

{/* Map settings */}
{ if (value) setMapTileUrl(value) }} placeholder={t('settings.mapTemplatePlaceholder.select')} options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name, }))} size="sm" style={{ marginBottom: 8 }} /> ) => setMapTileUrl(e.target.value)} placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" />

{t('settings.mapDefaultHint')}

) => setDefaultLat(e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" />
) => setDefaultLng(e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" />
{/* Display */}
{/* Dark Mode Toggle */}
{[ { value: 'light', label: t('settings.light'), icon: Sun }, { value: 'dark', label: t('settings.dark'), icon: Moon }, { value: 'auto', label: t('settings.auto'), icon: Monitor }, ].map(opt => { const current = settings.dark_mode const isActive = current === opt.value || (opt.value === 'light' && current === false) || (opt.value === 'dark' && current === true) return ( ) })}
{/* Sprache */}
{SUPPORTED_LANGUAGES.map(opt => ( ))}
{/* Temperature */}
{[ { value: 'celsius', label: '°C Celsius' }, { value: 'fahrenheit', label: '°F Fahrenheit' }, ].map(opt => ( ))}
{/* Zeitformat */}
{[ { value: '24h', label: '24h (14:30)' }, { value: '12h', label: '12h (2:30 PM)' }, ].map(opt => ( ))}
{/* Route Calculation */}
{[ { value: true, label: t('settings.on') || 'On' }, { value: false, label: t('settings.off') || 'Off' }, ].map(opt => ( ))}
{/* Blur Booking Codes */}
{[ { value: true, label: t('settings.on') || 'On' }, { value: false, label: t('settings.off') || 'Off' }, ].map(opt => ( ))}
{/* Notifications */}
{/* Immich — only when Memories addon is enabled */} {memoriesEnabled && (
{ setImmichUrl(e.target.value); setImmichTestPassed(false) }} placeholder="https://immich.example.com" className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
{ setImmichApiKey(e.target.value); setImmichTestPassed(false) }} placeholder={immichConnected ? '••••••••' : 'API Key'} className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
{immichConnected && ( {t('memories.connected')} )}
)} {/* MCP Configuration — only when MCP addon is enabled */} {mcpEnabled &&
{/* Endpoint URL */}
{mcpEndpoint}
{/* JSON config box */}
                {mcpJsonConfig}
              

{t('settings.mcp.clientConfigHint')}

{/* Token list */}
{mcpTokens.length === 0 ? (

{t('settings.mcp.noTokens')}

) : (
{mcpTokens.map((token, i) => (

{token.name}

{token.token_prefix}... {t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)} {token.last_used_at && ( · {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)} )}

))}
)}
} {/* Create MCP Token modal */} {mcpModalOpen && (
{ if (e.target === e.currentTarget && !mcpCreatedToken) { setMcpModalOpen(false) } }}>
{!mcpCreatedToken ? ( <>

{t('settings.mcp.modal.createTitle')}

setMcpNewName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()} placeholder={t('settings.mcp.modal.tokenNamePlaceholder')} className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} autoFocus />
) : ( <>

{t('settings.mcp.modal.createdTitle')}

{t('settings.mcp.modal.createdWarning')}

                        {mcpCreatedToken}
                      
)}
)} {/* Delete MCP Token confirm */} {mcpDeleteId !== null && (
{ if (e.target === e.currentTarget) setMcpDeleteId(null) }}>

{t('settings.mcp.deleteTokenTitle')}

{t('settings.mcp.deleteTokenMessage')}

)} {/* Account */}
) => setUsername(e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" />
) => setEmail(e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" />
{/* Change Password */} {!oidcOnlyMode && (
) => setCurrentPassword(e.target.value)} placeholder={t('settings.currentPassword')} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" /> ) => setNewPassword(e.target.value)} placeholder={t('settings.newPassword')} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" /> ) => setConfirmPassword(e.target.value)} placeholder={t('settings.confirmPassword')} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" />
)} {/* MFA */}

{t('settings.mfa.title')}

{mfaRequiredByPolicy && (

{t('settings.mfa.requiredByPolicy')}

)}

{t('settings.mfa.description')}

{demoMode ? (

{t('settings.mfa.demoBlocked')}

) : ( <>

{user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}

{!user?.mfa_enabled && !mfaQr && ( )} {!user?.mfa_enabled && mfaQr && (

{t('settings.mfa.scanQr')}

{mfaSecret}
setMfaSetupCode(e.target.value.replace(/\D/g, '').slice(0, 8))} placeholder={t('settings.mfa.codePlaceholder')} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" />
)} {user?.mfa_enabled && (

{t('settings.mfa.disableTitle')}

{t('settings.mfa.disableHint')}

setMfaDisablePwd(e.target.value)} placeholder={t('settings.currentPassword')} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" /> setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))} placeholder={t('settings.mfa.codePlaceholder')} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" />
)} {backupCodes && backupCodes.length > 0 && (

{t('settings.mfa.backupTitle')}

{t('settings.mfa.backupDescription')}

{backupCodesText}

{t('settings.mfa.backupWarning')}

)} )}
{user?.avatar_url ? ( ) : (
{user?.username?.charAt(0).toUpperCase()}
)} {user?.avatar_url && ( )}
{user?.role === 'admin' ? <> {t('settings.roleAdmin')} : t('settings.roleUser')} {(user as UserWithOidc)?.oidc_issuer && ( SSO )}
{(user as UserWithOidc)?.oidc_issuer && (

{t('settings.oidcLinked')} {(user as UserWithOidc).oidc_issuer!.replace('https://', '').replace(/\/+$/, '')}

)}
{appVersion && (
)} {/* Delete Account Confirmation */} {showDeleteConfirm === 'blocked' && (
setShowDeleteConfirm(false)}>
) => e.stopPropagation()}>

{t('settings.deleteBlockedTitle')}

{t('settings.deleteBlockedMessage')}

)} {showDeleteConfirm === true && (
setShowDeleteConfirm(false)}>
) => e.stopPropagation()}>

{t('settings.deleteAccountTitle')}

{t('settings.deleteAccountWarning')}

)}
) }