From b0b85fff3a34405411c8c83ceea3ede0268ae7cc Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Sun, 5 Apr 2026 11:08:58 +0200 Subject: [PATCH] fix for settings page --- .../components/Settings/IntegrationsTab.tsx | 108 +- .../Settings/PhotoProvidersSection.tsx | 248 +++ client/src/pages/SettingsPage.tsx | 1348 +---------------- .../src/services/memories/unifiedService.ts | 11 +- 4 files changed, 264 insertions(+), 1451 deletions(-) create mode 100644 client/src/components/Settings/PhotoProvidersSection.tsx diff --git a/client/src/components/Settings/IntegrationsTab.tsx b/client/src/components/Settings/IntegrationsTab.tsx index 3d9aa31..f56dbcd 100644 --- a/client/src/components/Settings/IntegrationsTab.tsx +++ b/client/src/components/Settings/IntegrationsTab.tsx @@ -1,11 +1,12 @@ -import React, { useState, useEffect } from 'react' -import { Camera, Terminal, Save, Check, Copy, Plus, Trash2 } from 'lucide-react' +import Section from './Section' +import React, { useEffect, useState } from 'react' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' +import { Trash2, Copy, Terminal, Plus, Check } from 'lucide-react' import { authApi } from '../../api/client' -import apiClient from '../../api/client' import { useAddonStore } from '../../store/addonStore' -import Section from './Section' +import PhotoProvidersSection from './PhotoProvidersSection' + interface McpToken { id: number @@ -18,65 +19,12 @@ interface McpToken { export default function IntegrationsTab(): React.ReactElement { const { t, locale } = useTranslation() const toast = useToast() - const { isEnabled: addonEnabled } = useAddonStore() - const memoriesEnabled = addonEnabled('memories') + const { isEnabled: addonEnabled, loadAddons } = useAddonStore() 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) - } - } + loadAddons() + }, [loadAddons]) // MCP state const [mcpTokens, setMcpTokens] = useState([]) @@ -143,45 +91,7 @@ export default function IntegrationsTab(): React.ReactElement { 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 */} diff --git a/client/src/components/Settings/PhotoProvidersSection.tsx b/client/src/components/Settings/PhotoProvidersSection.tsx new file mode 100644 index 0000000..4c00e29 --- /dev/null +++ b/client/src/components/Settings/PhotoProvidersSection.tsx @@ -0,0 +1,248 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { Camera, Save } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { useToast } from '../../components/shared/Toast' +import apiClient from '../../api/client' +import { useAddonStore } from '../../store/addonStore' +import Section from './Section' + +interface ProviderField { + key: string + label: string + input_type: string + placeholder?: string | null + required: boolean + secret: boolean + settings_key?: string | null + payload_key?: string | null + sort_order: number +} + +interface PhotoProviderAddon { + id: string + name: string + type: string + enabled: boolean + config?: Record + fields?: ProviderField[] +} + +interface ProviderConfig { + settings_get?: string + settings_put?: string + status_get?: string + test_get?: string + test_post?: string +} + +const getProviderConfig = (provider: PhotoProviderAddon): ProviderConfig => { + const raw = provider.config || {} + return { + settings_get: typeof raw.settings_get === 'string' ? raw.settings_get : undefined, + settings_put: typeof raw.settings_put === 'string' ? raw.settings_put : undefined, + status_get: typeof raw.status_get === 'string' ? raw.status_get : undefined, + test_get: typeof raw.test_get === 'string' ? raw.test_get : undefined, + test_post: typeof raw.test_post === 'string' ? raw.test_post : undefined, + } +} + +const getProviderFields = (provider: PhotoProviderAddon): ProviderField[] => { + return [...(provider.fields || [])].sort((a, b) => a.sort_order - b.sort_order) +} + +export default function PhotoProvidersSection(): React.ReactElement { + const { t } = useTranslation() + const toast = useToast() + const { isEnabled: addonEnabled, addons } = useAddonStore() + const memoriesEnabled = addonEnabled('memories') + + const [saving, setSaving] = useState>({}) + const [providerValues, setProviderValues] = useState>>({}) + const [providerConnected, setProviderConnected] = useState>({}) + const [providerTesting, setProviderTesting] = useState>({}) + + const activePhotoProviders = useMemo( + () => addons.filter(a => a.type === 'photo_provider' && a.enabled) as PhotoProviderAddon[], + [addons], + ) + + const buildProviderPayload = (provider: PhotoProviderAddon): Record => { + const values = providerValues[provider.id] || {} + const payload: Record = {} + for (const field of getProviderFields(provider)) { + const payloadKey = field.payload_key || field.settings_key || field.key + const value = (values[field.key] || '').trim() + if (field.secret && !value) continue + payload[payloadKey] = value + } + return payload + } + + const refreshProviderConnection = async (provider: PhotoProviderAddon) => { + const cfg = getProviderConfig(provider) + const statusPath = cfg.status_get + if (!statusPath) return + try { + const res = await apiClient.get(statusPath) + setProviderConnected(prev => ({ ...prev, [provider.id]: !!res.data?.connected })) + } catch { + setProviderConnected(prev => ({ ...prev, [provider.id]: false })) + } + } + + const activeProviderSignature = useMemo( + () => activePhotoProviders.map(provider => provider.id).join('|'), + [activePhotoProviders], + ) + + useEffect(() => { + let isCancelled = false + + for (const provider of activePhotoProviders) { + const cfg = getProviderConfig(provider) + const fields = getProviderFields(provider) + + if (cfg.settings_get) { + apiClient.get(cfg.settings_get).then(res => { + if (isCancelled) return + + const nextValues: Record = {} + for (const field of fields) { + // Do not prefill secret fields; user can overwrite only when needed. + if (field.secret) continue + const sourceKey = field.settings_key || field.payload_key || field.key + const rawValue = (res.data as Record)[sourceKey] + nextValues[field.key] = typeof rawValue === 'string' ? rawValue : rawValue != null ? String(rawValue) : '' + } + setProviderValues(prev => ({ + ...prev, + [provider.id]: { ...(prev[provider.id] || {}), ...nextValues }, + })) + if (typeof res.data?.connected === 'boolean') { + setProviderConnected(prev => ({ ...prev, [provider.id]: !!res.data.connected })) + } + }).catch(() => { }) + } + + refreshProviderConnection(provider).catch(() => { }) + } + + return () => { + isCancelled = true + } + }, [activePhotoProviders, activeProviderSignature]) + + const handleProviderFieldChange = (providerId: string, key: string, value: string) => { + setProviderValues(prev => ({ + ...prev, + [providerId]: { ...(prev[providerId] || {}), [key]: value }, + })) + } + + const isProviderSaveDisabled = (provider: PhotoProviderAddon): boolean => { + const values = providerValues[provider.id] || {} + return getProviderFields(provider).some(field => { + if (!field.required) return false + return !(values[field.key] || '').trim() + }) + } + + const handleSaveProvider = async (provider: PhotoProviderAddon) => { + const cfg = getProviderConfig(provider) + if (!cfg.settings_put) return + setSaving(s => ({ ...s, [provider.id]: true })) + try { + await apiClient.put(cfg.settings_put, buildProviderPayload(provider)) + await refreshProviderConnection(provider) + toast.success(t('memories.saved', { provider_name: provider.name })) + } catch { + toast.error(t('memories.saveError', { provider_name: provider.name })) + } finally { + setSaving(s => ({ ...s, [provider.id]: false })) + } + } + + const handleTestProvider = async (provider: PhotoProviderAddon) => { + const cfg = getProviderConfig(provider) + const testPath = cfg.test_post || cfg.test_get || cfg.status_get + if (!testPath) return + setProviderTesting(prev => ({ ...prev, [provider.id]: true })) + try { + const payload = buildProviderPayload(provider) + const res = cfg.test_post ? await apiClient.post(testPath, payload) : await apiClient.get(testPath) + const ok = !!res.data?.connected + setProviderConnected(prev => ({ ...prev, [provider.id]: ok })) + if (ok) { + toast.success(t('memories.connectionSuccess', { provider_name: provider.name })) + } else { + toast.error(`${t('memories.connectionError', { provider_name: provider.name })} ${res.data?.error ? `: ${String(res.data.error)}` : ''}`) + } + } catch { + toast.error(t('memories.connectionError', { provider_name: provider.name })) + } finally { + setProviderTesting(prev => ({ ...prev, [provider.id]: false })) + } + } + + const renderPhotoProviderSection = (provider: PhotoProviderAddon): React.ReactElement => { + const fields = getProviderFields(provider) + const cfg = getProviderConfig(provider) + const values = providerValues[provider.id] || {} + const connected = !!providerConnected[provider.id] + const testing = !!providerTesting[provider.id] + const canSave = !!cfg.settings_put + const canTest = !!(cfg.test_post || cfg.test_get || cfg.status_get) + + return ( +
+
+ {fields.map(field => ( +
+ + handleProviderFieldChange(provider.id, field.key, e.target.value)} + placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')} + 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" + /> +
+ ))} +
+ + + {connected && ( + + + {t('memories.connected')} + + )} +
+
+
+ ) + } + + if (!memoriesEnabled) { + return <> + } + + return <>{activePhotoProviders.map(provider => renderPhotoProviderSection(provider))} +} diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 456ccb4..6805545 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -4,11 +4,6 @@ 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' import Navbar from '../components/Layout/Navbar' import DisplaySettingsTab from '../components/Settings/DisplaySettingsTab' import MapSettingsTab from '../components/Settings/MapSettingsTab' @@ -17,588 +12,22 @@ import IntegrationsTab from '../components/Settings/IntegrationsTab' import AccountTab from '../components/Settings/AccountTab' import AboutTab from '../components/Settings/AboutTab' -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 -} - -interface ProviderField { - key: string - label: string - input_type: string - placeholder?: string | null - required: boolean - secret: boolean - settings_key?: string | null - payload_key?: string | null - sort_order: number -} - -interface PhotoProviderAddon { - id: string - name: string - type: string - enabled: boolean - config?: Record - fields?: ProviderField[] -} - -interface ProviderConfig { - settings_get?: string - settings_put?: string - status_get?: string - test_get?: string - test_post?: 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' }, -] - -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 { t } = useTranslation() const [searchParams] = useSearchParams() - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) - const avatarInputRef = React.useRef(null) - const { settings, updateSetting, updateSettings } = useSettingsStore() - const { isEnabled: addonEnabled, loadAddons, addons } = useAddonStore() - const { t, locale } = useTranslation() - const toast = useToast() - const navigate = useNavigate() + const { isEnabled: addonEnabled, loadAddons } = useAddonStore() const memoriesEnabled = addonEnabled('memories') const mcpEnabled = addonEnabled('mcp') - const [appVersion, setAppVersion] = useState(null) - useEffect(() => { - authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {}) - }, []) - const activePhotoProviders = addons.filter(a => a.type === 'photo_provider' && a.enabled) - const [providerValues, setProviderValues] = useState>>({}) - const [providerConnected, setProviderConnected] = useState>({}) - const [providerTesting, setProviderTesting] = useState>({}) const hasIntegrations = memoriesEnabled || mcpEnabled + + const [appVersion, setAppVersion] = useState(null) const [activeTab, setActiveTab] = useState('display') useEffect(() => { loadAddons() authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {}) }, []) - const getProviderConfig = (provider: PhotoProviderAddon): ProviderConfig => { - const raw = provider.config || {} - return { - settings_get: typeof raw.settings_get === 'string' ? raw.settings_get : undefined, - settings_put: typeof raw.settings_put === 'string' ? raw.settings_put : undefined, - status_get: typeof raw.status_get === 'string' ? raw.status_get : undefined, - test_get: typeof raw.test_get === 'string' ? raw.test_get : undefined, - test_post: typeof raw.test_post === 'string' ? raw.test_post : undefined, - } - } - - const getProviderFields = (provider: PhotoProviderAddon): ProviderField[] => { - return [...(provider.fields || [])].sort((a, b) => a.sort_order - b.sort_order) - } - - const buildProviderPayload = (provider: PhotoProviderAddon): Record => { - const values = providerValues[provider.id] || {} - const payload: Record = {} - for (const field of getProviderFields(provider)) { - const payloadKey = field.payload_key || field.settings_key || field.key - const value = (values[field.key] || '').trim() - if (field.secret && !value) continue - payload[payloadKey] = value - } - return payload - } - - const refreshProviderConnection = async (provider: PhotoProviderAddon) => { - const cfg = getProviderConfig(provider) - const statusPath = cfg.status_get - if (!statusPath) return - try { - const res = await apiClient.get(statusPath) - setProviderConnected(prev => ({ ...prev, [provider.id]: !!res.data?.connected })) - } catch { - setProviderConnected(prev => ({ ...prev, [provider.id]: false })) - } - } - - const activeProviderSignature = activePhotoProviders.map(p => p.id).join('|') - - useEffect(() => { - for (const provider of activePhotoProviders as PhotoProviderAddon[]) { - const cfg = getProviderConfig(provider) - const fields = getProviderFields(provider) - if (cfg.settings_get) { - apiClient.get(cfg.settings_get).then(res => { - const nextValues: Record = {} - for (const field of fields) { - // Don't populate secret fields into state - they should remain empty until user edits - if (field.secret) continue - const sourceKey = field.settings_key || field.payload_key || field.key - const rawValue = (res.data as Record)[sourceKey] - nextValues[field.key] = typeof rawValue === 'string' ? rawValue : rawValue != null ? String(rawValue) : '' - } - setProviderValues(prev => ({ - ...prev, - [provider.id]: { ...(prev[provider.id] || {}), ...nextValues }, - })) - if (typeof res.data?.connected === 'boolean') { - setProviderConnected(prev => ({ ...prev, [provider.id]: !!res.data.connected })) - } - }).catch(() => {}) - } - refreshProviderConnection(provider).catch(() => {}) - } - }, [activeProviderSignature]) - - const handleProviderFieldChange = (providerId: string, key: string, value: string) => { - setProviderValues(prev => ({ - ...prev, - [providerId]: { ...(prev[providerId] || {}), [key]: value }, - })) - } - - const isProviderSaveDisabled = (provider: PhotoProviderAddon): boolean => { - const values = providerValues[provider.id] || {} - return getProviderFields(provider).some(field => { - if (!field.required) return false - return !(values[field.key] || '').trim() - }) - } - - const handleSaveProvider = async (provider: PhotoProviderAddon) => { - const cfg = getProviderConfig(provider) - if (!cfg.settings_put) return - setSaving(s => ({ ...s, [provider.id]: true })) - try { - await apiClient.put(cfg.settings_put, buildProviderPayload(provider)) - await refreshProviderConnection(provider) - toast.success(t('memories.saved', { provider_name: provider.name })) - } catch { - toast.error(t('memories.saveError', { provider_name: provider.name })) - } finally { - setSaving(s => ({ ...s, [provider.id]: false })) - } - } - - const handleTestProvider = async (provider: PhotoProviderAddon) => { - const cfg = getProviderConfig(provider) - const testPath = cfg.test_post || cfg.test_get || cfg.status_get - if (!testPath) return - setProviderTesting(prev => ({ ...prev, [provider.id]: true })) - try { - const payload = buildProviderPayload(provider) - const res = cfg.test_post ? await apiClient.post(testPath, payload) : await apiClient.get(testPath) - const ok = !!res.data?.connected - setProviderConnected(prev => ({ ...prev, [provider.id]: ok })) - if (ok) { - toast.success(t('memories.connectionSuccess', { provider_name: provider.name })) - } else { - toast.error(`${t('memories.connectionError', { provider_name: provider.name })} ${res.data?.error ? `: ${String(res.data.error)}` : ''}`) - } - } catch { - toast.error(t('memories.connectionError', { provider_name: provider.name })) - } finally { - setProviderTesting(prev => ({ ...prev, [provider.id]: 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 " - ] - } - } -}` - - const renderPhotoProviderSection = (provider: PhotoProviderAddon): React.ReactElement => { - const fields = getProviderFields(provider) - const cfg = getProviderConfig(provider) - const values = providerValues[provider.id] || {} - const connected = !!providerConnected[provider.id] - const testing = !!providerTesting[provider.id] - const canSave = !!cfg.settings_put - const canTest = !!(cfg.test_post || cfg.test_get || cfg.status_get) - - return ( -
-
- {fields.map(field => ( -
- - handleProviderFieldChange(provider.id, field.key, e.target.value)} - placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')} - 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" - /> -
- ))} -
- - - {connected && ( - - - {t('memories.connected')} - - )} -
-
-
- ) - } - - // 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 })) - } - } // Auto-switch to account tab when MFA is required useEffect(() => { @@ -633,775 +62,6 @@ export default function SettingsPage(): React.ReactElement { -
- - {/* 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 */} -
- -
- - {activePhotoProviders.map(provider => renderPhotoProviderSection(provider as PhotoProviderAddon))} - - {/* 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 => ( @@ -1430,4 +90,4 @@ export default function SettingsPage(): React.ReactElement {
) -} +} \ No newline at end of file diff --git a/server/src/services/memories/unifiedService.ts b/server/src/services/memories/unifiedService.ts index 5743b79..f7d3d1b 100644 --- a/server/src/services/memories/unifiedService.ts +++ b/server/src/services/memories/unifiedService.ts @@ -1,5 +1,5 @@ import { db, canAccessTrip } from '../../db/database'; -import { notifyTripMembers } from '../notifications'; +import { send } from '../notificationService'; import { broadcast } from '../../websocket'; import { ServiceResult, @@ -290,17 +290,12 @@ async function _notifySharedTripPhotos( if (added <= 0) return fail('No photos shared, skipping notifications', 200); try { - const actorRow = db.prepare('SELECT username FROM users WHERE id = ?').get(actorUserId) as { username: string | null }; + const actorRow = db.prepare('SELECT username, email FROM users WHERE id = ?').get(actorUserId) as { username: string | null, email: string | null }; const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; - //send({ event: 'photos_shared', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, count: String(added), tripId: String(tripId) } }).catch(() => {}); - await notifyTripMembers(Number(tripId), actorUserId, 'photos_shared', { - trip: tripInfo?.title || 'Untitled', - actor: actorRow?.username || 'Unknown', - count: String(added), - }); + send({ event: 'photos_shared', actorId: actorUserId, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: actorRow?.email || 'Unknown', count: String(added), tripId: String(tripId) } }).catch(() => {}); return success(undefined); } catch { return fail('Failed to send notifications', 500);