import React, { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { adminApi, authApi } from '../api/client' import { useAuthStore } from '../store/authStore' import { useSettingsStore } from '../store/settingsStore' 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 { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun, Link2, Copy, Plus } 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 } 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 oidc_only: boolean } interface UpdateInfo { update_available: boolean latest: string current: string release_url?: string is_docker?: boolean } export default function AdminPage(): React.ReactElement { const { demoMode } = useAuthStore() const { t, locale } = useTranslation() const hour12 = useSettingsStore(s => s.settings.time_format) === '12h' const TABS = [ { id: 'users', label: t('admin.tabs.users') }, { id: 'categories', label: t('admin.tabs.categories') }, { id: 'templates', label: t('admin.tabs.templates') }, { id: 'addons', label: t('admin.tabs.addons') }, { id: 'settings', label: t('admin.tabs.settings') }, { id: 'backup', label: t('admin.tabs.backup') }, { id: 'github', label: t('admin.tabs.github') }, ] 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' }) // OIDC config const [oidcConfig, setOidcConfig] = useState({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false }) const [savingOidc, setSavingOidc] = useState(false) // Registration toggle const [allowRegistration, setAllowRegistration] = useState(true) // 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) // 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 [updating, setUpdating] = useState(false) const [updateResult, setUpdateResult] = useState<'success' | 'error' | null>(null) const { user: currentUser, updateApiKeys } = useAuthStore() const navigate = useNavigate() const toast = useToast() 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() setAllowRegistration(config.allow_registration) 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 handleInstallUpdate = async () => { setUpdating(true) setUpdateResult(null) try { await adminApi.installUpdate() setUpdateResult('success') // Server is restarting — poll until it comes back, then reload const poll = setInterval(async () => { try { await authApi.getAppConfig() clearInterval(poll) window.location.reload() } catch { /* still restarting */ } }, 2000) } catch { setUpdateResult('error') setUpdating(false) } } const handleToggleRegistration = async (value) => { setAllowRegistration(value) try { await authApi.updateAppSettings({ allow_registration: value }) } catch (err: unknown) { setAllowRegistration(!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 } 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()) 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 */}

Administration

{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')} )} {updateInfo.is_docker ? ( ) : ( )}
)} {/* 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.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)} {u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
)}
)} {/* 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)}`} {` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
{isActive && ( )}
) })}
)}
)} {/* 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 === 'categories' && } {activeTab === 'templates' && } {activeTab === 'addons' && } {activeTab === 'settings' && (
{/* Registration Toggle */}

{t('admin.allowRegistration')}

{t('admin.allowRegistration')}

{t('admin.allowRegistrationHint')}

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

)}
{/* 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, 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" />
{/* OIDC-only mode toggle */}

{t('admin.oidcOnlyMode')}

{t('admin.oidcOnlyModeHint')}

)} {activeTab === 'backup' && } {activeTab === 'github' && }
{/* 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 confirmation popup — matches backup restore style */} {showUpdateModal && (
{ if (!updating) 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" > {updateResult === 'success' ? ( <>

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

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

) : updateResult === 'error' ? ( <>

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

) : ( <> {/* Red header */}

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

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

{/* Body */}
{updateInfo?.is_docker ? ( <>

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

{`docker pull mauriceboe/nomad:latest docker stop nomad && docker rm nomad docker run -d --name nomad \\ -p 3000:3000 \\ -v /opt/nomad/data:/app/data \\ -v /opt/nomad/uploads:/app/uploads \\ --restart unless-stopped \\ mauriceboe/nomad:latest`}
{t('admin.update.dataInfo')}
) : ( <>

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

{t('admin.update.dataInfo')}
{t('admin.update.backupHint')}{' '}
{t('admin.update.warning')}
)}
{/* Footer */}
{!updateInfo?.is_docker && ( )}
)}
)}
) }