import React, { useState, useEffect } 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, Terminal, Copy, Plus, Check } from 'lucide-react' import { authApi, adminApi, notificationsApi } 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' interface MapPreset { name: string url: string } 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 NotificationPreferences({ t, memoriesEnabled }: { t: any; memoriesEnabled: boolean }) { const [prefs, setPrefs] = useState | null>(null) const [addons, setAddons] = useState>({}) useEffect(() => { notificationsApi.getPreferences().then(d => setPrefs(d.preferences)).catch(() => {}) }, []) useEffect(() => { apiClient.get('/addons').then(r => { const map: Record = {} for (const a of (r.data.addons || [])) map[a.id] = !!a.enabled setAddons(map) }).catch(() => {}) }, []) const toggle = async (key: string) => { if (!prefs) return const newVal = prefs[key] ? 0 : 1 setPrefs(prev => prev ? { ...prev, [key]: newVal } : prev) try { await notificationsApi.updatePreferences({ [key]: !!newVal }) } catch {} } if (!prefs) return

{t('common.loading')}

const options = [ { key: 'notify_trip_invite', label: t('settings.notifyTripInvite') }, { key: 'notify_booking_change', label: t('settings.notifyBookingChange') }, ...(addons.vacay ? [{ key: 'notify_vacay_invite', label: t('settings.notifyVacayInvite') }] : []), ...(memoriesEnabled ? [{ key: 'notify_photos_shared', label: t('settings.notifyPhotosShared') }] : []), ...(addons.collab ? [{ key: 'notify_collab_message', label: t('settings.notifyCollabMessage') }] : []), ...(addons.documents ? [{ key: 'notify_packing_tagged', label: t('settings.notifyPackingTagged') }] : []), { key: 'notify_webhook', label: t('settings.notifyWebhook') }, ] return (
{options.map(opt => (
{opt.label}
))}
) } 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 [immichUrl, setImmichUrl] = useState('') const [immichApiKey, setImmichApiKey] = useState('') const [immichConnected, setImmichConnected] = useState(false) const [immichTesting, setImmichTesting] = useState(false) useEffect(() => { loadAddons() }, []) useEffect(() => { if (memoriesEnabled) { apiClient.get('/integrations/immich/settings').then(r2 => { setImmichUrl(r2.data.immich_url || '') setImmichConnected(r2.data.connected) }).catch(() => {}) } }, [memoriesEnabled]) const handleSaveImmich = async () => { setSaving(s => ({ ...s, immich: true })) try { await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined }) toast.success(t('memories.saved')) // Test connection const res = await apiClient.get('/integrations/immich/status') setImmichConnected(res.data.connected) } catch { toast.error(t('memories.connectionError')) } finally { setSaving(s => ({ ...s, immich: false })) } } const handleTestImmich = async () => { setImmichTesting(true) try { const res = await apiClient.get('/integrations/immich/status') if (res.data.connected) { toast.success(`${t('memories.connectionSuccess')} — ${res.data.user?.name || ''}`) setImmichConnected(true) } else { toast.error(`${t('memories.connectionError')}: ${res.data.error}`) setImmichConnected(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) // 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) 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)} 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)} 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" />
)} )}
{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(/\/+$/, '')}

)}
{/* 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')}

)}
) }