import React, { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import apiClient, { adminApi, authApi, notificationsApi } from '../api/client' import DevNotificationsPanel from '../components/Admin/DevNotificationsPanel' import DefaultUserSettingsTab from '../components/Admin/DefaultUserSettingsTab' import { useAuthStore } from '../store/authStore' import { useSettingsStore } from '../store/settingsStore' import { useAddonStore } from '../store/addonStore' import { useTranslation } from '../i18n' import { getApiErrorMessage } from '../types' import Navbar from '../components/Layout/Navbar' import Modal from '../components/shared/Modal' import { useToast } from '../components/shared/Toast' import CategoryManager from '../components/Admin/CategoryManager' import BackupPanel from '../components/Admin/BackupPanel' import GitHubPanel from '../components/Admin/GitHubPanel' import AddonManager from '../components/Admin/AddonManager' import PackingTemplateManager from '../components/Admin/PackingTemplateManager' import AuditLogPanel from '../components/Admin/AuditLogPanel' import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel' import PermissionsPanel from '../components/Admin/PermissionsPanel' import { Users, Map, Briefcase, Shield, Trash2, Edit2, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle } from 'lucide-react' import CustomSelect from '../components/shared/CustomSelect' interface AdminUser { id: number username: string email: string role: 'admin' | 'user' created_at: string last_login?: string | null online?: boolean oidc_issuer?: string | null avatar_url?: string | null } interface AdminStats { totalUsers: number totalTrips: number totalPlaces: number totalFiles: number } interface OidcConfig { issuer: string client_id: string client_secret: string client_secret_set: boolean display_name: string discovery_url: string } interface UpdateInfo { update_available: boolean latest: string current: string release_url?: string is_docker?: boolean is_prerelease?: boolean } const ADMIN_EVENT_LABEL_KEYS: Record = { version_available: 'settings.notifyVersionAvailable', } const ADMIN_CHANNEL_LABEL_KEYS: Record = { inapp: 'settings.notificationPreferences.inapp', email: 'settings.notificationPreferences.email', webhook: 'settings.notificationPreferences.webhook', ntfy: 'settings.notificationPreferences.ntfy', } function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast: ReturnType }) { const [matrix, setMatrix] = useState(null) const [saving, setSaving] = useState(false) useEffect(() => { adminApi.getNotificationPreferences().then((data: any) => setMatrix(data)).catch(() => {}) }, []) if (!matrix) return

Loading…

const visibleChannels = (['inapp', 'email', 'webhook', 'ntfy'] as const).filter(ch => { if (!matrix.available_channels[ch]) return false return matrix.event_types.some((evt: string) => matrix.implemented_combos[evt]?.includes(ch)) }) 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: any) => m ? { ...m, preferences: updated } : m) setSaving(true) try { await adminApi.updateNotificationPreferences(updated) } catch { setMatrix((m: any) => m ? { ...m, preferences: matrix.preferences } : m) toast.error(t('common.error')) } finally { setSaving(false) } } if (matrix.event_types.length === 0) { return (

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

) } return (

{t('admin.tabs.notifications')}

{t('admin.notifications.adminNotificationsHint')}

{saving &&

Saving…

} {/* Header row */}
'80px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}> {visibleChannels.map(ch => ( {t(ADMIN_CHANNEL_LABEL_KEYS[ch]) || ch} ))}
{/* Event rows */} {matrix.event_types.map((eventType: string) => { const implementedForEvent = matrix.implemented_combos[eventType] ?? [] return (
'80px').join(' ')}`, gap: 4, alignItems: 'center', padding: '8px 0', borderBottom: '1px solid var(--border-primary)' }}> {t(ADMIN_EVENT_LABEL_KEYS[eventType]) || eventType} {visibleChannels.map(ch => { if (!implementedForEvent.includes(ch)) { return } const isOn = matrix.preferences[eventType]?.[ch] ?? true return (
) })}
) })}
) } export default function AdminPage(): React.ReactElement { const { demoMode, serverTimezone } = useAuthStore() const { t, locale } = useTranslation() const hour12 = useSettingsStore(s => s.settings.time_format) === '12h' const mcpEnabled = useAddonStore(s => s.isEnabled('mcp')) const devMode = useAuthStore(s => s.devMode) const TABS = [ { id: 'users', label: t('admin.tabs.users') }, { id: 'config', label: t('admin.tabs.config') }, { id: 'defaults', label: t('admin.tabs.defaults') }, { id: 'addons', label: t('admin.tabs.addons') }, { id: 'settings', label: t('admin.tabs.settings') }, { id: 'notifications', label: t('admin.tabs.notifications') }, { id: 'backup', label: t('admin.tabs.backup') }, { id: 'audit', label: t('admin.tabs.audit') }, ...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens') }] : []), { id: 'github', label: t('admin.tabs.github') }, ...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications' }] : []), ] const [activeTab, setActiveTab] = useState('users') const [users, setUsers] = useState([]) const [stats, setStats] = useState(null) const [isLoading, setIsLoading] = useState(true) const [editingUser, setEditingUser] = useState(null) const [editForm, setEditForm] = useState<{ username: string; email: string; role: string; password: string }>({ username: '', email: '', role: 'user', password: '' }) const [showCreateUser, setShowCreateUser] = useState(false) const [createForm, setCreateForm] = useState<{ username: string; email: string; password: string; role: string }>({ username: '', email: '', password: '', role: 'user' }) // Bag tracking const [bagTrackingEnabled, setBagTrackingEnabled] = useState(false) useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, []) // Places photos const [placesPhotosEnabled, setPlacesPhotosEnabledState] = useState(true) useEffect(() => { adminApi.getPlacesPhotos().then(d => setPlacesPhotosEnabledState(d.enabled)).catch(() => {}) }, []) // Places autocomplete const [placesAutocompleteEnabled, setPlacesAutocompleteEnabledState] = useState(true) useEffect(() => { adminApi.getPlacesAutocomplete().then(d => setPlacesAutocompleteEnabledState(d.enabled)).catch(() => {}) }, []) // Places details const [placesDetailsEnabled, setPlacesDetailsEnabledState] = useState(true) useEffect(() => { adminApi.getPlacesDetails().then(d => setPlacesDetailsEnabledState(d.enabled)).catch(() => {}) }, []) // Collab features const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true }) useEffect(() => { adminApi.getCollabFeatures().then(d => setCollabFeatures(d)).catch(() => {}) }, []) // OIDC config const [oidcConfig, setOidcConfig] = useState({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', discovery_url: '' }) const [savingOidc, setSavingOidc] = useState(false) // Auth toggles const [passwordLogin, setPasswordLogin] = useState(true) const [passwordRegistration, setPasswordRegistration] = useState(true) const [oidcLogin, setOidcLogin] = useState(true) const [oidcRegistration, setOidcRegistration] = useState(true) const [envOverrideOidcOnly, setEnvOverrideOidcOnly] = useState(false) const [oidcConfigured, setOidcConfigured] = useState(false) const [requireMfa, setRequireMfa] = useState(false) // Invite links const [invites, setInvites] = useState([]) const [showCreateInvite, setShowCreateInvite] = useState(false) const [inviteForm, setInviteForm] = useState<{ max_uses: number; expires_in_days: number | '' }>({ max_uses: 1, expires_in_days: 7 }) // File types const [allowedFileTypes, setAllowedFileTypes] = useState('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv') const [savingFileTypes, setSavingFileTypes] = useState(false) // SMTP settings const [smtpValues, setSmtpValues] = useState>({}) const [smtpLoaded, setSmtpLoaded] = useState(false) useEffect(() => { apiClient.get('/auth/app-settings').then(r => { setSmtpValues(r.data || {}) setSmtpLoaded(true) }).catch(() => setSmtpLoaded(true)) }, []) // API Keys const [mapsKey, setMapsKey] = useState('') const [weatherKey, setWeatherKey] = useState('') const [showKeys, setShowKeys] = useState>({}) const [savingKeys, setSavingKeys] = useState(false) const [validating, setValidating] = useState>({}) const [validation, setValidation] = useState>({}) // Version check & update const [updateInfo, setUpdateInfo] = useState(null) const [showUpdateModal, setShowUpdateModal] = useState(false) const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, logout } = useAuthStore() const navigate = useNavigate() const toast = useToast() const [showRotateJwtModal, setShowRotateJwtModal] = useState(false) const [rotatingJwt, setRotatingJwt] = useState(false) useEffect(() => { loadData() loadAppConfig() loadApiKeys() adminApi.getOidc().then(setOidcConfig).catch(() => {}) adminApi.checkVersion().then(data => { if (data.update_available) setUpdateInfo(data) }).catch(() => {}) }, []) const loadData = async () => { setIsLoading(true) try { const [usersData, statsData, invitesData] = await Promise.all([ adminApi.users(), adminApi.stats(), adminApi.listInvites().catch(() => ({ invites: [] })), ]) setUsers(usersData.users) setStats(statsData) setInvites(invitesData.invites || []) } catch (err: unknown) { toast.error(t('admin.toast.loadError')) } finally { setIsLoading(false) } } const loadAppConfig = async () => { try { const config = await authApi.getAppConfig() setPasswordLogin(config.password_login ?? true) setPasswordRegistration(config.password_registration ?? config.allow_registration ?? true) setOidcLogin(config.oidc_login ?? true) setOidcRegistration(config.oidc_registration ?? config.allow_registration ?? true) setEnvOverrideOidcOnly(config.env_override_oidc_only ?? false) setOidcConfigured(config.oidc_configured ?? false) if (config.require_mfa !== undefined) setRequireMfa(!!config.require_mfa) if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types) } catch (err: unknown) { // ignore } } const loadApiKeys = async () => { try { const data = await authApi.getSettings() setMapsKey(data.settings?.maps_api_key || '') setWeatherKey(data.settings?.openweather_api_key || '') } catch (err: unknown) { // ignore } } const handleToggleAuthSetting = async (key: string, value: boolean, setter: (v: boolean) => void) => { setter(value) try { await authApi.updateAppSettings({ [key]: value }) } catch (err: unknown) { setter(!value) toast.error(getApiErrorMessage(err, t('common.error'))) } } const handleToggleRequireMfa = async (value: boolean) => { setRequireMfa(value) try { await authApi.updateAppSettings({ require_mfa: value }) setAppRequireMfa(value) toast.success(t('common.saved')) } catch (err: unknown) { setRequireMfa(!value) toast.error(getApiErrorMessage(err, t('common.error'))) } } const toggleKey = (key) => { setShowKeys(prev => ({ ...prev, [key]: !prev[key] })) } const handleSaveApiKeys = async () => { setSavingKeys(true) try { await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, }) toast.success(t('admin.keySaved')) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } finally { setSavingKeys(false) } } const handleValidateKeys = async () => { setValidating({ maps: true, weather: true }) try { // Save first so validation uses the current values await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey }) const result = await authApi.validateKeys() setValidation(result) } catch (err: unknown) { toast.error(t('common.error')) } finally { setValidating({}) } } const handleValidateKey = async (keyType) => { setValidating(prev => ({ ...prev, [keyType]: true })) try { // Save first so validation uses the current values await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey }) const result = await authApi.validateKeys() setValidation(prev => ({ ...prev, [keyType]: result[keyType] })) } catch (err: unknown) { toast.error(t('common.error')) } finally { setValidating(prev => ({ ...prev, [keyType]: false })) } } const handleCreateUser = async () => { if (!createForm.username.trim() || !createForm.email.trim() || !createForm.password.trim()) { toast.error(t('admin.toast.fieldsRequired')) return } if (createForm.password.trim().length < 8) { toast.error(t('settings.passwordTooShort')) return } try { const data = await adminApi.createUser(createForm) setUsers(prev => [data.user, ...prev]) setShowCreateUser(false) setCreateForm({ username: '', email: '', password: '', role: 'user' }) toast.success(t('admin.toast.userCreated')) } catch (err: unknown) { toast.error(getApiErrorMessage(err, t('admin.toast.createError'))) } } const handleCreateInvite = async () => { try { const data = await adminApi.createInvite({ max_uses: inviteForm.max_uses, expires_in_days: inviteForm.expires_in_days || undefined, }) setInvites(prev => [data.invite, ...prev]) setShowCreateInvite(false) setInviteForm({ max_uses: 1, expires_in_days: 7 }) // Copy link to clipboard const link = `${window.location.origin}/register?invite=${data.invite.token}` navigator.clipboard.writeText(link).then(() => toast.success(t('admin.invite.copied'))) } catch (err: unknown) { toast.error(getApiErrorMessage(err, t('admin.invite.createError'))) } } const handleDeleteInvite = async (id: number) => { try { await adminApi.deleteInvite(id) setInvites(prev => prev.filter(i => i.id !== id)) toast.success(t('admin.invite.deleted')) } catch { toast.error(t('admin.invite.deleteError')) } } const copyInviteLink = (token: string) => { const link = `${window.location.origin}/register?invite=${token}` navigator.clipboard.writeText(link).then(() => toast.success(t('admin.invite.copied'))) } const handleEditUser = (user) => { setEditingUser(user) setEditForm({ username: user.username, email: user.email, role: user.role, password: '' }) } const handleSaveUser = async () => { try { const payload: { username?: string; email?: string; role: string; password?: string } = { username: editForm.username.trim() || undefined, email: editForm.email.trim() || undefined, role: editForm.role, } if (editForm.password.trim()) { if (editForm.password.trim().length < 8) { toast.error(t('settings.passwordTooShort')) return } payload.password = editForm.password.trim() } const data = await adminApi.updateUser(editingUser.id, payload) setUsers(prev => prev.map(u => u.id === editingUser.id ? data.user : u)) setEditingUser(null) toast.success(t('admin.toast.userUpdated')) } catch (err: unknown) { toast.error(getApiErrorMessage(err, t('admin.toast.updateError'))) } } const handleDeleteUser = async (user) => { if (user.id === currentUser?.id) { toast.error(t('admin.toast.cannotDeleteSelf')) return } if (!confirm(t('admin.deleteUser', { name: user.username }))) return try { await adminApi.deleteUser(user.id) setUsers(prev => prev.filter(u => u.id !== user.id)) toast.success(t('admin.toast.userDeleted')) } catch (err: unknown) { toast.error(getApiErrorMessage(err, t('admin.toast.deleteError'))) } } return (
{/* Header */}

{t('admin.title')}

{t('admin.subtitle')}

{/* Update Banner */} {updateInfo && (

{t('admin.update.available')}

{t('admin.update.text').replace('{version}', `v${updateInfo.latest}`).replace('{current}', `v${updateInfo.current}`)}

{updateInfo.release_url && ( {t('admin.update.button')} )}
)} {/* Demo Baseline Button */} {demoMode && (

Demo Baseline

Save current state as the hourly reset point. All admin trips and settings will be preserved.

)} {/* Stats */} {stats && (
{[ { label: t('admin.stats.users'), value: stats.totalUsers, icon: Users }, { label: t('admin.stats.trips'), value: stats.totalTrips, icon: Briefcase }, { label: t('admin.stats.places'), value: stats.totalPlaces, icon: Map }, { label: t('admin.stats.files'), value: stats.totalFiles || 0, icon: FileText }, ].map(({ label, value, icon: Icon }) => (

{value}

{label}

))}
)} {/* Tabs */}
{TABS.map(tab => ( ))}
{/* Tab content */} {activeTab === 'users' && (

{t('admin.tabs.users')}

{users.length} {t('admin.stats.users')}

{isLoading ? (
) : (
{users.map(u => ( ))}
{t('admin.table.user')} {t('admin.table.email')} {t('admin.table.role')} {t('admin.table.created')} {t('admin.table.lastLogin')} {t('admin.table.actions')}
{u.avatar_url ? ( {u.username} ) : (
{u.username.charAt(0).toUpperCase()}
)}

{u.username}

{u.id === currentUser?.id && ( {t('admin.you')} )}
{u.email} {u.role === 'admin' && } {u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleUser')} {new Date(u.created_at).toLocaleDateString(locale, { timeZone: serverTimezone })} {u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12, timeZone: serverTimezone }) : '—'}
)}
)} {/* Invite Links (inside users tab) */} {activeTab === 'users' && (

{t('admin.invite.title')}

{t('admin.invite.subtitle')}

{invites.length === 0 ? (
{t('admin.invite.empty')}
) : (
{invites.map(inv => { const isExpired = inv.expires_at && new Date(inv.expires_at) < new Date() const isUsedUp = inv.max_uses > 0 && inv.used_count >= inv.max_uses const isActive = !isExpired && !isUsedUp return (
{inv.token.slice(0, 12)}... {isUsedUp ? t('admin.invite.usedUp') : isExpired ? t('admin.invite.expired') : t('admin.invite.active')}
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')} {inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale, { timeZone: serverTimezone })}`} {` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
{isActive && ( )}
) })}
)}
)} {activeTab === 'users' &&
} {/* Create Invite Modal */} setShowCreateInvite(false)} title={t('admin.invite.create')} size="sm">
{[1, 2, 3, 4, 5, 0].map(n => ( ))}
{[ { value: 1, label: '1d' }, { value: 3, label: '3d' }, { value: 7, label: '7d' }, { value: 14, label: '14d' }, { value: '', label: '∞' }, ].map(opt => ( ))}
{activeTab === 'config' && (
)} {activeTab === 'addons' && (
{ const next = !bagTrackingEnabled setBagTrackingEnabled(next) try { await adminApi.updateBagTracking(next) } catch { setBagTrackingEnabled(!next) } }} collabFeatures={collabFeatures} onToggleCollabFeature={async (key: string) => { const next = { ...collabFeatures, [key]: !collabFeatures[key] } setCollabFeatures(next) try { await adminApi.updateCollabFeatures({ [key]: next[key] }) } catch { setCollabFeatures(collabFeatures) } }} />
)} {activeTab === 'settings' && (
{/* Authentication Methods */}

{t('admin.authMethods')}

{envOverrideOidcOnly && (

{t('admin.envOverrideHint')}

)} {/* Password Login */}

{t('admin.passwordLogin')}

{t('admin.passwordLoginHint')}

{/* Password Registration */}

{t('admin.passwordRegistration')}

{t('admin.passwordRegistrationHint')}

{/* SSO Login (only when OIDC configured) */} {oidcConfigured && (

{t('admin.oidcLogin')}

{t('admin.oidcLoginHint')}

)} {/* SSO Registration (only when OIDC configured) */} {oidcConfigured && (

{t('admin.oidcRegistration')}

{t('admin.oidcRegistrationHint')}

)}
{/* Require 2FA for all users */}

{t('admin.requireMfa')}

{t('admin.requireMfa')}

{t('admin.requireMfaHint')}

{/* Allowed File Types */}

{t('admin.fileTypes')}

{t('admin.fileTypesHint')}

setAllowedFileTypes(e.target.value)} placeholder="jpg,png,pdf,doc,docx,xls,xlsx,txt,csv" 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('admin.fileTypesFormat')}

{/* API Keys */}

{t('admin.apiKeys')}

{t('admin.apiKeysHint')}

{/* Google Maps Key */}
setMapsKey(e.target.value)} placeholder={t('settings.keyPlaceholder')} className="w-full pr-10 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" />

{t('admin.mapsKeyHintLong')}

{validation.maps === true && (

{t('admin.keyValid')}

)} {validation.maps === false && (

{t('admin.keyInvalid')}

)}
{/* Place Photos Toggle */}

{t('admin.placesPhotos.title')}

{t('admin.placesPhotos.subtitle')}

{/* Place Autocomplete Toggle */}

{t('admin.placesAutocomplete.title')}

{t('admin.placesAutocomplete.subtitle')}

{/* Place Details Toggle */}

{t('admin.placesDetails.title')}

{t('admin.placesDetails.subtitle')}

{/* Open-Meteo Weather Info */}
{t('admin.weather.title')}
{t('admin.weather.badge')}

{t('admin.weather.description')}

{t('admin.weather.locationHint')}

{t('admin.weather.forecast')}

{t('admin.weather.forecastDesc')}

{t('admin.weather.climate')}

{t('admin.weather.climateDesc')}

{t('admin.weather.requests')}

{t('admin.weather.requestsDesc')}

{/* OIDC / SSO Configuration */}

{t('admin.oidcTitle')}

{t('admin.oidcSubtitle')}

setOidcConfig(c => ({ ...c, display_name: e.target.value }))} placeholder='z.B. Google, Authentik, Keycloak' 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" />
setOidcConfig(c => ({ ...c, issuer: e.target.value }))} placeholder='https://accounts.google.com' 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('admin.oidcIssuerHint')}

setOidcConfig(c => ({ ...c, discovery_url: e.target.value }))} placeholder='https://auth.example.com/application/o/trek/.well-known/openid-configuration' 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" />

Override the auto-constructed discovery URL. Required for providers like Authentik where the endpoint is not at {'/.well-known/openid-configuration'}.

setOidcConfig(c => ({ ...c, client_id: 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" />
setOidcConfig(c => ({ ...c, client_secret: e.target.value }))} placeholder={oidcConfig.client_secret_set ? '••••••••' : ''} 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" />
{/* Danger Zone */}

Danger Zone

Rotate JWT Secret

Generate a new JWT signing secret. All active sessions will be invalidated immediately.

)} {activeTab === 'notifications' && (() => { // Derive active channels from smtpValues.notification_channels (plural) // with fallback to notification_channel (singular) for existing installs const rawChannels = smtpValues.notification_channels ?? smtpValues.notification_channel ?? 'none' const activeChans = rawChannels === 'none' ? [] : rawChannels.split(',').map((c: string) => c.trim()) const emailActive = activeChans.includes('email') const webhookActive = activeChans.includes('webhook') const ntfyActive = activeChans.includes('ntfy') const tripRemindersActive = smtpValues.notify_trip_reminder !== 'false' const setChannels = async (email: boolean, webhook: boolean, ntfy: boolean) => { const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy'].filter(Boolean).join(',') || 'none' setSmtpValues(prev => ({ ...prev, notification_channels: chans })) try { await authApi.updateAppSettings({ notification_channels: chans }) } catch { // Revert state on failure const reverted = [emailActive && 'email', webhookActive && 'webhook', ntfyActive && 'ntfy'].filter(Boolean).join(',') || 'none' setSmtpValues(prev => ({ ...prev, notification_channels: reverted })) toast.error(t('common.error')) } } const smtpConfigured = !!(smtpValues.smtp_host?.trim()) const saveNotifications = async () => { // Saves credentials only — channel activation is auto-saved by the toggle const notifKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify'] const payload: Record = {} for (const k of notifKeys) { if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] } try { await authApi.updateAppSettings(payload) toast.success(t('admin.notifications.saved')) authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => { if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled) }).catch(() => {}) } catch { toast.error(t('common.error')) } } return (<>
{/* Email Panel */}

{t('admin.notifications.emailPanel.title')}

{t('admin.smtp.hint')}

{smtpLoaded && [ { key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' }, { key: 'smtp_port', label: 'SMTP Port', placeholder: '587' }, { key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' }, { key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' }, { key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' }, ].map(field => (
setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))} placeholder={field.placeholder} 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" />
))}
Skip TLS certificate check

Enable for self-signed certificates on local mail servers

{/* Webhook Panel */}

{t('admin.notifications.webhookPanel.title')}

{t('admin.webhook.hint')}

{/* Ntfy Panel */}

{t('admin.notifications.ntfy')}

{t('admin.ntfy.hint') || 'Allow users to configure their own ntfy topics for push notifications.'}

{/* In-App Panel */}

{t('admin.notifications.inappPanel.title')}

{t('admin.notifications.inappPanel.hint')}

{/* Trip Reminders Toggle */}

{t('admin.notifications.tripReminders.title')}

{t('admin.notifications.tripReminders.hint')}

{/* Admin Webhook Panel */}

{t('admin.notifications.adminWebhookPanel.title')}

{t('admin.notifications.adminWebhookPanel.hint')}

{smtpLoaded && (
setSmtpValues(prev => ({ ...prev, admin_webhook_url: e.target.value }))} placeholder={smtpValues.admin_webhook_url === '••••••••' ? '••••••••' : 'https://discord.com/api/webhooks/...'} 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" />
)}
{/* Admin Ntfy Panel */}

{t('admin.notifications.adminNtfyPanel.title')}

{t('admin.notifications.adminNtfyPanel.hint')}

{smtpLoaded && ( <>
setSmtpValues(prev => ({ ...prev, admin_ntfy_server: e.target.value }))} placeholder={t('admin.notifications.adminNtfyPanel.serverPlaceholder')} 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('admin.notifications.adminNtfyPanel.serverHint')}

setSmtpValues(prev => ({ ...prev, admin_ntfy_topic: e.target.value }))} placeholder={t('admin.notifications.adminNtfyPanel.topicPlaceholder')} 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" />
setSmtpValues(prev => ({ ...prev, admin_ntfy_token: e.target.value }))} placeholder={smtpValues.admin_ntfy_token === '••••••••' ? '••••••••' : ''} className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" /> {smtpValues.admin_ntfy_token === '••••••••' && ( )}
)}
) })()} {activeTab === 'backup' && } {activeTab === 'audit' && } {activeTab === 'mcp-tokens' && } {activeTab === 'github' && } {activeTab === 'defaults' && } {activeTab === 'dev-notifications' && }
{/* Create user modal */} setShowCreateUser(false)} title={t('admin.createUser')} size="sm" footer={
} >
setCreateForm(f => ({ ...f, username: e.target.value }))} placeholder={t('settings.username')} className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm" />
setCreateForm(f => ({ ...f, email: e.target.value }))} placeholder={t('common.email')} className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm" />
setCreateForm(f => ({ ...f, password: e.target.value }))} placeholder={t('common.password')} className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm" />
setCreateForm(f => ({ ...f, role: value }))} options={[ { value: 'user', label: t('settings.roleUser') }, { value: 'admin', label: t('settings.roleAdmin') }, ]} />
{/* Edit user modal */} setEditingUser(null)} title={t('admin.editUser')} size="sm" footer={
} > {editingUser && (
setEditForm(f => ({ ...f, username: e.target.value }))} className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm" />
setEditForm(f => ({ ...f, email: e.target.value }))} className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm" />
setEditForm(f => ({ ...f, password: e.target.value }))} placeholder={t('admin.newPasswordPlaceholder')} className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm" />
setEditForm(f => ({ ...f, role: value }))} options={[ { value: 'user', label: t('settings.roleUser') }, { value: 'admin', label: t('settings.roleAdmin') }, ]} />
)}
{/* Update instructions popup */} {showUpdateModal && (
setShowUpdateModal(false)} >
e.stopPropagation()} style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }} className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700" >

{t('admin.update.howTo')}

v{updateInfo?.current} → v{updateInfo?.latest}

{t('admin.update.dockerText').replace('{version}', `v${updateInfo?.latest ?? ''}`)}

{`docker pull mauriceboe/trek:latest docker stop trek && docker rm trek docker run -d --name trek \\ -p 3000:3000 \\ -v /opt/trek/data:/app/data \\ -v /opt/trek/uploads:/app/uploads \\ --restart unless-stopped \\ mauriceboe/trek:latest`}
{t('admin.update.dataInfo')}
{updateInfo?.release_url && ( )}
)} {/* Rotate JWT Secret confirmation modal */} setShowRotateJwtModal(false)} title="Rotate JWT Secret" size="sm" footer={
} >

Warning, this will invalidate all sessions and log you out.

A new JWT secret will be generated immediately. Every logged-in user — including you — will be signed out and will need to log in again.

) }