diff --git a/client/src/components/Settings/AboutTab.tsx b/client/src/components/Settings/AboutTab.tsx new file mode 100644 index 0000000..4d6485f --- /dev/null +++ b/client/src/components/Settings/AboutTab.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import { Info } from 'lucide-react' +import { useTranslation } from '../../i18n' +import Section from './Section' + +interface Props { + appVersion: string +} + +export default function AboutTab({ appVersion }: Props): React.ReactElement { + const { t } = useTranslation() + + return ( +
+
+
+ TREK + v{appVersion} +
+ e.currentTarget.style.background = '#5865F220'} + onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-tertiary)'} + title="Discord"> + + +
+
+ ) +} diff --git a/client/src/components/Settings/AccountTab.tsx b/client/src/components/Settings/AccountTab.tsx new file mode 100644 index 0000000..b8a45ff --- /dev/null +++ b/client/src/components/Settings/AccountTab.tsx @@ -0,0 +1,598 @@ +import React, { useState, useEffect } from 'react' +import { User, Save, Lock, KeyRound, AlertTriangle, Shield, Camera, Trash2, Copy, Download, Printer } from 'lucide-react' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { useTranslation } from '../../i18n' +import { useAuthStore } from '../../store/authStore' +import { useToast } from '../shared/Toast' +import { authApi, adminApi } from '../../api/client' +import { getApiErrorMessage } from '../../types' +import type { UserWithOidc } from '../../types' +import Section from './Section' + +const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending' + +export default function AccountTab(): React.ReactElement { + const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa } = useAuthStore() + const [searchParams] = useSearchParams() + const navigate = useNavigate() + const { t } = useTranslation() + const toast = useToast() + const avatarInputRef = React.useRef(null) + + const [saving, setSaving] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + // Profile + const [username, setUsername] = useState(user?.username || '') + const [email, setEmail] = useState(user?.email || '') + + useEffect(() => { + setUsername(user?.username || '') + setEmail(user?.email || '') + }, [user]) + + // Password + 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(() => {}) + }, []) + + // MFA + 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 [backupCodes, setBackupCodes] = useState(null) + + const mfaRequiredByPolicy = + !demoMode && + !user?.mfa_enabled && + (searchParams.get('mfa') === 'required' || appRequireMfa) + + const backupCodesText = backupCodes?.join('\n') || '' + + 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 = () => { + sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY) + setBackupCodes(null) + } + + const copyBackupCodes = async () => { + if (!backupCodesText) return + try { + await navigator.clipboard.writeText(backupCodesText) + toast.success(t('settings.mfa.backupCopied')) + } catch { + toast.error(t('common.error')) + } + } + + const downloadBackupCodes = () => { + 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 = () => { + 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() + } + + const handleAvatarUpload = async (e: React.ChangeEvent) => { + 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 () => { + try { + await deleteAvatar() + toast.success(t('settings.avatarRemoved')) + } catch { + toast.error(t('settings.avatarError')) + } + } + + const saveProfile = async () => { + setSaving(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(false) + } + } + + return ( + <> +
+
+ + 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')}

+
+ + + + +
+
+ )} + + )} +
+
+ + {/* Avatar */} +
+
+ {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 Blocked */} + {showDeleteConfirm === 'blocked' && ( +
setShowDeleteConfirm(false)}> +
e.stopPropagation()}> +
+
+ +
+

{t('settings.deleteBlockedTitle')}

+
+

+ {t('settings.deleteBlockedMessage')} +

+
+ +
+
+
+ )} + + {/* Delete Account Confirm */} + {showDeleteConfirm === true && ( +
setShowDeleteConfirm(false)}> +
e.stopPropagation()}> +
+
+ +
+

{t('settings.deleteAccountTitle')}

+
+

+ {t('settings.deleteAccountWarning')} +

+
+ + +
+
+
+ )} + + ) +} diff --git a/client/src/components/Settings/DisplaySettingsTab.tsx b/client/src/components/Settings/DisplaySettingsTab.tsx new file mode 100644 index 0000000..e09b45d --- /dev/null +++ b/client/src/components/Settings/DisplaySettingsTab.tsx @@ -0,0 +1,206 @@ +import React, { useState, useEffect } from 'react' +import { Palette, Sun, Moon, Monitor } from 'lucide-react' +import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n' +import { useSettingsStore } from '../../store/settingsStore' +import { useToast } from '../shared/Toast' +import Section from './Section' + +export default function DisplaySettingsTab(): React.ReactElement { + const { settings, updateSetting } = useSettingsStore() + const { t } = useTranslation() + const toast = useToast() + const [tempUnit, setTempUnit] = useState(settings.temperature_unit || 'celsius') + + useEffect(() => { + setTempUnit(settings.temperature_unit || 'celsius') + }, [settings.temperature_unit]) + + return ( +
+ {/* Color Mode */} +
+ +
+ {[ + { 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 ( + + ) + })} +
+
+ + {/* Language */} +
+ +
+ {SUPPORTED_LANGUAGES.map(opt => ( + + ))} +
+
+ + {/* Temperature */} +
+ +
+ {[ + { value: 'celsius', label: '°C Celsius' }, + { value: 'fahrenheit', label: '°F Fahrenheit' }, + ].map(opt => ( + + ))} +
+
+ + {/* Time Format */} +
+ +
+ {[ + { 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 => ( + + ))} +
+
+
+ ) +} diff --git a/client/src/components/Settings/IntegrationsTab.tsx b/client/src/components/Settings/IntegrationsTab.tsx new file mode 100644 index 0000000..3d9aa31 --- /dev/null +++ b/client/src/components/Settings/IntegrationsTab.tsx @@ -0,0 +1,343 @@ +import React, { useState, useEffect } from 'react' +import { Camera, Terminal, Save, Check, Copy, Plus, Trash2 } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { useToast } from '../shared/Toast' +import { authApi } from '../../api/client' +import apiClient from '../../api/client' +import { useAddonStore } from '../../store/addonStore' +import Section from './Section' + +interface McpToken { + id: number + name: string + token_prefix: string + created_at: string + last_used_at: string | null +} + +export default function IntegrationsTab(): React.ReactElement { + const { t, locale } = useTranslation() + const toast = useToast() + const { isEnabled: addonEnabled } = useAddonStore() + const memoriesEnabled = addonEnabled('memories') + const mcpEnabled = addonEnabled('mcp') + + // Immich state + const [immichUrl, setImmichUrl] = useState('') + const [immichApiKey, setImmichApiKey] = useState('') + const [immichConnected, setImmichConnected] = useState(false) + const [immichTesting, setImmichTesting] = useState(false) + const [immichSaving, setImmichSaving] = useState(false) + const [immichTestPassed, setImmichTestPassed] = useState(false) + + useEffect(() => { + if (memoriesEnabled) { + apiClient.get('/integrations/immich/settings').then(r => { + setImmichUrl(r.data.immich_url || '') + setImmichConnected(r.data.connected) + }).catch(() => {}) + } + }, [memoriesEnabled]) + + const handleSaveImmich = async () => { + setImmichSaving(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 { + setImmichSaving(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 state + 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) + + const mcpEndpoint = `${window.location.origin}/mcp` + const mcpJsonConfig = `{ + "mcpServers": { + "trek": { + "command": "npx", + "args": [ + "mcp-remote", + "${mcpEndpoint}", + "--header", + "Authorization: Bearer " + ] + } + } +}` + + useEffect(() => { + if (mcpEnabled) { + authApi.mcpTokens.list().then(d => setMcpTokens(d.tokens || [])).catch(() => {}) + } + }, [mcpEnabled]) + + 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) + }) + } + + return ( + <> + {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')} + + )} +
+
+
+ )} + + {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')}

+
+ + +
+
+
+ )} + + ) +} diff --git a/client/src/components/Settings/MapSettingsTab.tsx b/client/src/components/Settings/MapSettingsTab.tsx new file mode 100644 index 0000000..807b580 --- /dev/null +++ b/client/src/components/Settings/MapSettingsTab.tsx @@ -0,0 +1,162 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react' +import { Map, Save } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { useSettingsStore } from '../../store/settingsStore' +import { useToast } from '../shared/Toast' +import CustomSelect from '../shared/CustomSelect' +import { MapView } from '../Map/MapView' +import Section from './Section' +import type { Place } from '../../types' + +interface MapPreset { + name: string + url: string +} + +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' }, +] + +export default function MapSettingsTab(): React.ReactElement { + const { settings, updateSettings } = useSettingsStore() + const { t } = useTranslation() + const toast = useToast() + const [saving, setSaving] = useState(false) + 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) + + useEffect(() => { + setMapTileUrl(settings.map_tile_url || '') + setDefaultLat(settings.default_lat || 48.8566) + setDefaultLng(settings.default_lng || 2.3522) + setDefaultZoom(settings.default_zoom || 10) + }, [settings]) + + const handleMapClick = useCallback((mapInfo) => { + setDefaultLat(mapInfo.latlng.lat) + setDefaultLng(mapInfo.latlng.lng) + }, []) + + const mapPlaces = useMemo((): Place[] => [{ + 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(), + }], [defaultLat, defaultLng]) + + const saveMapSettings = async (): Promise => { + setSaving(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(false) + } + } + + return ( +
+
+ + { 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" + /> +
+
+ +
+
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + {React.createElement(MapView as any, { + places: mapPlaces, + dayPlaces: [], + route: null, + routeSegments: null, + selectedPlaceId: null, + onMarkerClick: null, + onMapClick: handleMapClick, + onMapContextMenu: null, + center: [settings.default_lat, settings.default_lng], + zoom: defaultZoom, + tileUrl: mapTileUrl, + fitKey: null, + dayOrderMap: [], + leftWidth: 0, + rightWidth: 0, + hasInspector: false, + })} +
+
+ + +
+ ) +} diff --git a/client/src/components/Settings/NotificationsTab.tsx b/client/src/components/Settings/NotificationsTab.tsx new file mode 100644 index 0000000..443f203 --- /dev/null +++ b/client/src/components/Settings/NotificationsTab.tsx @@ -0,0 +1,187 @@ +import React, { useState, useEffect } from 'react' +import { Lock } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { notificationsApi, settingsApi } from '../../api/client' +import { useToast } from '../shared/Toast' +import ToggleSwitch from './ToggleSwitch' +import Section from './Section' + +interface PreferencesMatrix { + preferences: Record> + available_channels: { email: boolean; webhook: boolean; inapp: boolean } + event_types: string[] + implemented_combos: Record +} + +const CHANNEL_LABEL_KEYS: Record = { + email: 'settings.notificationPreferences.email', + webhook: 'settings.notificationPreferences.webhook', + inapp: 'settings.notificationPreferences.inapp', +} + +const EVENT_LABEL_KEYS: Record = { + trip_invite: 'settings.notifyTripInvite', + booking_change: 'settings.notifyBookingChange', + trip_reminder: 'settings.notifyTripReminder', + vacay_invite: 'settings.notifyVacayInvite', + photos_shared: 'settings.notifyPhotosShared', + collab_message: 'settings.notifyCollabMessage', + packing_tagged: 'settings.notifyPackingTagged', + version_available: 'settings.notifyVersionAvailable', +} + +export default function NotificationsTab(): React.ReactElement { + const { t } = useTranslation() + const toast = useToast() + const [matrix, setMatrix] = useState(null) + const [saving, setSaving] = useState(false) + const [webhookUrl, setWebhookUrl] = useState('') + const [webhookSaving, setWebhookSaving] = useState(false) + const [webhookTesting, setWebhookTesting] = useState(false) + + useEffect(() => { + notificationsApi.getPreferences().then((data: PreferencesMatrix) => setMatrix(data)).catch(() => {}) + settingsApi.get().then((data: { settings: Record }) => { + setWebhookUrl((data.settings?.webhook_url as string) || '') + }).catch(() => {}) + }, []) + + const visibleChannels = matrix + ? (['email', 'webhook', 'inapp'] as const).filter(ch => { + if (!matrix.available_channels[ch]) return false + return matrix.event_types.some(evt => matrix.implemented_combos[evt]?.includes(ch)) + }) + : [] + + const toggle = async (eventType: string, channel: string) => { + if (!matrix) return + const current = matrix.preferences[eventType]?.[channel] ?? true + const updated = { + ...matrix.preferences, + [eventType]: { ...matrix.preferences[eventType], [channel]: !current }, + } + setMatrix(m => m ? { ...m, preferences: updated } : m) + setSaving(true) + try { + await notificationsApi.updatePreferences(updated) + } catch { + setMatrix(m => m ? { ...m, preferences: matrix.preferences } : m) + } finally { + setSaving(false) + } + } + + const saveWebhookUrl = async () => { + setWebhookSaving(true) + try { + await settingsApi.set('webhook_url', webhookUrl) + toast.success(t('settings.webhookUrl.saved')) + } catch { + toast.error(t('common.error')) + } finally { + setWebhookSaving(false) + } + } + + const testWebhookUrl = async () => { + if (!webhookUrl) return + setWebhookTesting(true) + try { + const result = await notificationsApi.testWebhook(webhookUrl) + if (result.success) toast.success(t('settings.webhookUrl.testSuccess')) + else toast.error(result.error || t('settings.webhookUrl.testFailed')) + } catch { + toast.error(t('settings.webhookUrl.testFailed')) + } finally { + setWebhookTesting(false) + } + } + + const renderContent = () => { + if (!matrix) return

Loading…

+ + if (visibleChannels.length === 0) { + return ( +

+ {t('settings.notificationPreferences.noChannels')} +

+ ) + } + + return ( +
+ {saving &&

Saving…

} + {matrix.available_channels.webhook && ( +
+ +

{t('settings.webhookUrl.hint')}

+
+ setWebhookUrl(e.target.value)} + placeholder={t('settings.webhookUrl.placeholder')} + style={{ flex: 1, fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)' }} + /> + + +
+
+ )} + {/* Header row */} +
'64px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}> + + {visibleChannels.map(ch => ( + + {t(CHANNEL_LABEL_KEYS[ch]) || ch} + + ))} +
+ {/* Event rows */} + {matrix.event_types.map(eventType => { + const implementedForEvent = matrix.implemented_combos[eventType] ?? [] + const relevantChannels = visibleChannels.filter(ch => implementedForEvent.includes(ch)) + if (relevantChannels.length === 0) return null + return ( +
'64px').join(' ')}`, gap: 4, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--border-primary)' }}> + + {t(EVENT_LABEL_KEYS[eventType]) || eventType} + + {visibleChannels.map(ch => { + if (!implementedForEvent.includes(ch)) { + return + } + const isOn = matrix.preferences[eventType]?.[ch] ?? true + return ( +
+ toggle(eventType, ch)} /> +
+ ) + })} +
+ ) + })} +
+ ) + } + + return ( +
+ {renderContent()} +
+ ) +} diff --git a/client/src/components/Settings/Section.tsx b/client/src/components/Settings/Section.tsx new file mode 100644 index 0000000..100657d --- /dev/null +++ b/client/src/components/Settings/Section.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import type { LucideIcon } from 'lucide-react' + +interface SectionProps { + title: string + icon: LucideIcon + children: React.ReactNode +} + +export default function Section({ title, icon: Icon, children }: SectionProps): React.ReactElement { + return ( +
+
+ +

{title}

+
+
+ {children} +
+
+ ) +} diff --git a/client/src/components/Settings/ToggleSwitch.tsx b/client/src/components/Settings/ToggleSwitch.tsx new file mode 100644 index 0000000..562d723 --- /dev/null +++ b/client/src/components/Settings/ToggleSwitch.tsx @@ -0,0 +1,18 @@ +import React from 'react' + +export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) { + return ( + + ) +} diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index bb65525..31eee43 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -132,6 +132,12 @@ const ar: Record = { // Settings 'settings.title': 'الإعدادات', 'settings.subtitle': 'ضبط إعداداتك الشخصية', + 'settings.tabs.display': 'العرض', + 'settings.tabs.map': 'الخريطة', + 'settings.tabs.notifications': 'الإشعارات', + 'settings.tabs.integrations': 'التكاملات', + 'settings.tabs.account': 'الحساب', + 'settings.tabs.about': 'حول', 'settings.map': 'الخريطة', 'settings.mapTemplate': 'قالب الخريطة', 'settings.mapTemplatePlaceholder.select': 'اختر قالبًا...', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 7123387..6964576 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -127,6 +127,12 @@ const br: Record = { // Settings 'settings.title': 'Configurações', 'settings.subtitle': 'Ajuste suas preferências pessoais', + 'settings.tabs.display': 'Exibição', + 'settings.tabs.map': 'Mapa', + 'settings.tabs.notifications': 'Notificações', + 'settings.tabs.integrations': 'Integrações', + 'settings.tabs.account': 'Conta', + 'settings.tabs.about': 'Sobre', 'settings.map': 'Mapa', 'settings.mapTemplate': 'Modelo de mapa', 'settings.mapTemplatePlaceholder.select': 'Selecione o modelo...', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 6f6608b..88a8095 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -128,6 +128,12 @@ const cs: Record = { // Nastavení (Settings) 'settings.title': 'Nastavení', 'settings.subtitle': 'Upravte své osobní nastavení', + 'settings.tabs.display': 'Zobrazení', + 'settings.tabs.map': 'Mapa', + 'settings.tabs.notifications': 'Oznámení', + 'settings.tabs.integrations': 'Integrace', + 'settings.tabs.account': 'Účet', + 'settings.tabs.about': 'O aplikaci', 'settings.map': 'Mapy', 'settings.mapTemplate': 'Šablona mapy', 'settings.mapTemplatePlaceholder.select': 'Vyberte šablonu...', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 394b5d8..3554d2e 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -127,6 +127,12 @@ const de: Record = { // Settings 'settings.title': 'Einstellungen', 'settings.subtitle': 'Konfigurieren Sie Ihre persönlichen Einstellungen', + 'settings.tabs.display': 'Anzeige', + 'settings.tabs.map': 'Karte', + 'settings.tabs.notifications': 'Benachrichtigungen', + 'settings.tabs.integrations': 'Integrationen', + 'settings.tabs.account': 'Konto', + 'settings.tabs.about': 'Über', 'settings.map': 'Karte', 'settings.mapTemplate': 'Karten-Vorlage', 'settings.mapTemplatePlaceholder.select': 'Vorlage auswählen...', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index fdc4276..28ebcde 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -127,6 +127,12 @@ const en: Record = { // Settings 'settings.title': 'Settings', 'settings.subtitle': 'Configure your personal settings', + 'settings.tabs.display': 'Display', + 'settings.tabs.map': 'Map', + 'settings.tabs.notifications': 'Notifications', + 'settings.tabs.integrations': 'Integrations', + 'settings.tabs.account': 'Account', + 'settings.tabs.about': 'About', 'settings.map': 'Map', 'settings.mapTemplate': 'Map Template', 'settings.mapTemplatePlaceholder.select': 'Select template...', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 2f07691..2892886 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -128,6 +128,12 @@ const es: Record = { // Settings 'settings.title': 'Ajustes', 'settings.subtitle': 'Configura tus ajustes personales', + 'settings.tabs.display': 'Pantalla', + 'settings.tabs.map': 'Mapa', + 'settings.tabs.notifications': 'Notificaciones', + 'settings.tabs.integrations': 'Integraciones', + 'settings.tabs.account': 'Cuenta', + 'settings.tabs.about': 'Acerca de', 'settings.map': 'Mapa', 'settings.mapTemplate': 'Plantilla del mapa', 'settings.mapTemplatePlaceholder.select': 'Seleccionar plantilla...', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index bbc2683..6f1100c 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -127,6 +127,12 @@ const fr: Record = { // Settings 'settings.title': 'Paramètres', 'settings.subtitle': 'Configurez vos paramètres personnels', + 'settings.tabs.display': 'Affichage', + 'settings.tabs.map': 'Carte', + 'settings.tabs.notifications': 'Notifications', + 'settings.tabs.integrations': 'Intégrations', + 'settings.tabs.account': 'Compte', + 'settings.tabs.about': 'À propos', 'settings.map': 'Carte', 'settings.mapTemplate': 'Modèle de carte', 'settings.mapTemplatePlaceholder.select': 'Sélectionner un modèle…', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index a631658..1b91497 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -127,6 +127,12 @@ const hu: Record = { // Beállítások 'settings.title': 'Beállítások', 'settings.subtitle': 'Személyes beállítások konfigurálása', + 'settings.tabs.display': 'Megjelenés', + 'settings.tabs.map': 'Térkép', + 'settings.tabs.notifications': 'Értesítések', + 'settings.tabs.integrations': 'Integrációk', + 'settings.tabs.account': 'Fiók', + 'settings.tabs.about': 'Névjegy', 'settings.map': 'Térkép', 'settings.mapTemplate': 'Térkép sablon', 'settings.mapTemplatePlaceholder.select': 'Sablon kiválasztása...', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index a2e1819..d7113b6 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -127,6 +127,12 @@ const it: Record = { // Settings 'settings.title': 'Impostazioni', 'settings.subtitle': 'Configura le tue impostazioni personali', + 'settings.tabs.display': 'Visualizzazione', + 'settings.tabs.map': 'Mappa', + 'settings.tabs.notifications': 'Notifiche', + 'settings.tabs.integrations': 'Integrazioni', + 'settings.tabs.account': 'Account', + 'settings.tabs.about': 'Informazioni', 'settings.map': 'Mappa', 'settings.mapTemplate': 'Modello Mappa', 'settings.mapTemplatePlaceholder.select': 'Seleziona modello...', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 098d0d2..0b3f85d 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -127,6 +127,12 @@ const nl: Record = { // Settings 'settings.title': 'Instellingen', 'settings.subtitle': 'Configureer je persoonlijke instellingen', + 'settings.tabs.display': 'Weergave', + 'settings.tabs.map': 'Kaart', + 'settings.tabs.notifications': 'Meldingen', + 'settings.tabs.integrations': 'Integraties', + 'settings.tabs.account': 'Account', + 'settings.tabs.about': 'Over', 'settings.map': 'Kaart', 'settings.mapTemplate': 'Kaartsjabloon', 'settings.mapTemplatePlaceholder.select': 'Selecteer sjabloon...', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index ca58b11..e5fe34b 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -113,6 +113,12 @@ const pl: Record = { // Settings 'settings.title': 'Ustawienia', 'settings.subtitle': 'Skonfiguruj swoje ustawienia', + 'settings.tabs.display': 'Wygląd', + 'settings.tabs.map': 'Mapa', + 'settings.tabs.notifications': 'Powiadomienia', + 'settings.tabs.integrations': 'Integracje', + 'settings.tabs.account': 'Konto', + 'settings.tabs.about': 'O aplikacji', 'settings.map': 'Mapa', 'settings.mapTemplate': 'Szablon mapy', 'settings.mapTemplatePlaceholder.select': 'Wybierz szablon...', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 118e8de..5d0ae45 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -127,6 +127,12 @@ const ru: Record = { // Settings 'settings.title': 'Настройки', 'settings.subtitle': 'Настройте свои персональные параметры', + 'settings.tabs.display': 'Дисплей', + 'settings.tabs.map': 'Карта', + 'settings.tabs.notifications': 'Уведомления', + 'settings.tabs.integrations': 'Интеграции', + 'settings.tabs.account': 'Аккаунт', + 'settings.tabs.about': 'О приложении', 'settings.map': 'Карта', 'settings.mapTemplate': 'Шаблон карты', 'settings.mapTemplatePlaceholder.select': 'Выберите шаблон...', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 1ba8d42..3d256e3 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -127,6 +127,12 @@ const zh: Record = { // Settings 'settings.title': '设置', 'settings.subtitle': '配置你的个人设置', + 'settings.tabs.display': '显示', + 'settings.tabs.map': '地图', + 'settings.tabs.notifications': '通知', + 'settings.tabs.integrations': '集成', + 'settings.tabs.account': '账户', + 'settings.tabs.about': '关于', 'settings.map': '地图', 'settings.mapTemplate': '地图模板', 'settings.mapTemplatePlaceholder.select': '选择模板...', diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index c8e126e..57ea4e6 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -1,1551 +1,91 @@ -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, notificationsApi, settingsApi } from '../api/client' -import apiClient from '../api/client' +import React, { useState, useEffect } from 'react' +import { useSearchParams } from 'react-router-dom' +import { Settings } from 'lucide-react' +import { useTranslation } from '../i18n' +import { authApi } 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 ( - - ) -} - -interface PreferencesMatrix { - preferences: Record> - available_channels: { email: boolean; webhook: boolean; inapp: boolean } - event_types: string[] - implemented_combos: Record -} - -const CHANNEL_LABEL_KEYS: Record = { - email: 'settings.notificationPreferences.email', - webhook: 'settings.notificationPreferences.webhook', - inapp: 'settings.notificationPreferences.inapp', -} - -const EVENT_LABEL_KEYS: Record = { - trip_invite: 'settings.notifyTripInvite', - booking_change: 'settings.notifyBookingChange', - trip_reminder: 'settings.notifyTripReminder', - vacay_invite: 'settings.notifyVacayInvite', - photos_shared: 'settings.notifyPhotosShared', - collab_message: 'settings.notifyCollabMessage', - packing_tagged: 'settings.notifyPackingTagged', - version_available: 'settings.notifyVersionAvailable', -} - -function NotificationPreferences({ t }: { t: any; memoriesEnabled: boolean }) { - const [matrix, setMatrix] = useState(null) - const [saving, setSaving] = useState(false) - const [webhookUrl, setWebhookUrl] = useState('') - const [webhookSaving, setWebhookSaving] = useState(false) - const [webhookTesting, setWebhookTesting] = useState(false) - const toast = useToast() - - useEffect(() => { - notificationsApi.getPreferences().then((data: PreferencesMatrix) => setMatrix(data)).catch(() => {}) - settingsApi.get().then((data: { settings: Record }) => { - setWebhookUrl((data.settings?.webhook_url as string) || '') - }).catch(() => {}) - }, []) - - if (!matrix) return

Loading…

- - // Which channels are both available AND have at least one implemented event - const visibleChannels = (['email', 'webhook', 'inapp'] as const).filter(ch => { - if (!matrix.available_channels[ch]) return false - return matrix.event_types.some(evt => matrix.implemented_combos[evt]?.includes(ch)) - }) - - if (visibleChannels.length === 0) { - return ( -

- {t('settings.notificationPreferences.noChannels')} -

- ) - } - - const toggle = async (eventType: string, channel: string) => { - const current = matrix.preferences[eventType]?.[channel] ?? true - const updated = { - ...matrix.preferences, - [eventType]: { ...matrix.preferences[eventType], [channel]: !current }, - } - setMatrix(m => m ? { ...m, preferences: updated } : m) - setSaving(true) - try { - await notificationsApi.updatePreferences(updated) - } catch { - // Revert on failure - setMatrix(m => m ? { ...m, preferences: matrix.preferences } : m) - } finally { - setSaving(false) - } - } - - const saveWebhookUrl = async () => { - setWebhookSaving(true) - try { - await settingsApi.set('webhook_url', webhookUrl) - toast.success(t('settings.webhookUrl.saved')) - } catch { - toast.error(t('common.error')) - } finally { - setWebhookSaving(false) - } - } - - const testWebhookUrl = async () => { - if (!webhookUrl) return - setWebhookTesting(true) - try { - const result = await notificationsApi.testWebhook(webhookUrl) - if (result.success) toast.success(t('settings.webhookUrl.testSuccess')) - else toast.error(result.error || t('settings.webhookUrl.testFailed')) - } catch { - toast.error(t('settings.webhookUrl.testFailed')) - } finally { - setWebhookTesting(false) - } - } - - return ( -
- {saving &&

Saving…

} - {/* Webhook URL configuration */} - {matrix.available_channels.webhook && ( -
- -

{t('settings.webhookUrl.hint')}

-
- setWebhookUrl(e.target.value)} - placeholder={t('settings.webhookUrl.placeholder')} - style={{ flex: 1, fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)' }} - /> - - -
-
- )} - {/* Header row */} -
'64px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}> - - {visibleChannels.map(ch => ( - - {t(CHANNEL_LABEL_KEYS[ch]) || ch} - - ))} -
- {/* Event rows */} - {matrix.event_types.map(eventType => { - const implementedForEvent = matrix.implemented_combos[eventType] ?? [] - const relevantChannels = visibleChannels.filter(ch => implementedForEvent.includes(ch)) - if (relevantChannels.length === 0) return null - return ( -
'64px').join(' ')}`, gap: 4, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--border-primary)' }}> - - {t(EVENT_LABEL_KEYS[eventType]) || eventType} - - {visibleChannels.map(ch => { - if (!implementedForEvent.includes(ch)) { - return - } - const isOn = matrix.preferences[eventType]?.[ch] ?? true - return ( -
- toggle(eventType, ch)} /> -
- ) - })} -
- ) - })} -
- ) -} +import Navbar from '../components/Layout/Navbar' +import DisplaySettingsTab from '../components/Settings/DisplaySettingsTab' +import MapSettingsTab from '../components/Settings/MapSettingsTab' +import NotificationsTab from '../components/Settings/NotificationsTab' +import IntegrationsTab from '../components/Settings/IntegrationsTab' +import AccountTab from '../components/Settings/AccountTab' +import AboutTab from '../components/Settings/AboutTab' export default function SettingsPage(): React.ReactElement { - const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa } = useAuthStore() + const { t } = useTranslation() 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 hasIntegrations = memoriesEnabled || mcpEnabled - const handleMapClick = useCallback((mapInfo) => { - setDefaultLat(mapInfo.latlng.lat) - setDefaultLng(mapInfo.latlng.lng) - }, []) + const [appVersion, setAppVersion] = useState(null) + const [activeTab, setActiveTab] = useState('display') useEffect(() => { loadAddons() + authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {}) }, []) + // Auto-switch to account tab when MFA is required useEffect(() => { - if (memoriesEnabled) { - apiClient.get('/integrations/immich/settings').then(r2 => { - setImmichUrl(r2.data.immich_url || '') - setImmichConnected(r2.data.connected) - }).catch(() => {}) + if (searchParams.get('mfa') === 'required') { + setActiveTab('account') } - }, [memoriesEnabled]) + }, [searchParams]) - 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 })) - } - } + const TABS = [ + { id: 'display', label: t('settings.tabs.display') }, + { id: 'map', label: t('settings.tabs.map') }, + { id: 'notifications', label: t('settings.tabs.notifications') }, + ...(hasIntegrations ? [{ id: 'integrations', label: t('settings.tabs.integrations') }] : []), + { id: 'account', label: t('settings.tabs.account') }, + ...(appVersion ? [{ id: 'about', label: t('settings.tabs.about') }] : []), + ] return (
-
- -
-

{t('settings.title')}

-

{t('settings.subtitle')}

+
+ {/* Header */} +
+
+ +
+
+

{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(/\/+$/, '')} -

- )} -
-
- -
+ {/* Tab bar */} +
+ {TABS.map(tab => ( - -
-
- - {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')} -

-
- - -
-
-
- )} + ))}
+ + {/* Tab content */} + {activeTab === 'display' && } + {activeTab === 'map' && } + {activeTab === 'notifications' && } + {activeTab === 'integrations' && hasIntegrations && } + {activeTab === 'account' && } + {activeTab === 'about' && appVersion && }