import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import apiClient, { adminApi, authApi } from '../../api/client' import { useAuthStore } from '../../store/authStore' import { useSettingsStore } from '../../store/settingsStore' import { useAddonStore } from '../../store/addonStore' import { useTranslation } from '../../i18n' import { getApiErrorMessage } from '../../types' import { useToast } from '../../components/shared/Toast' import type { AdminUser, AdminStats, OidcConfig, UpdateInfo } from './adminModel' /** * Admin page logic — owns every admin data slice (users, stats, invites, auth * toggles, OIDC, feature flags, API keys, SMTP, version/update) plus the CRUD * and toggle handlers. AdminPage stays a wiring container that builds the * (t-dependent) tab list and renders the tab panels around this state. * Behaviour is identical to the previous in-component logic. */ export function useAdmin() { const { demoMode, serverTimezone } = useAuthStore() const { t } = useTranslation() const hour12 = useSettingsStore(s => s.settings.time_format) === '12h' const mcpEnabled = useAddonStore(s => s.isEnabled('mcp')) const devMode = useAuthStore(s => s.devMode) 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) // Passkey (WebAuthn) login const [passkeyLogin, setPasskeyLogin] = useState(false) const [passkeyConfigured, setPasskeyConfigured] = useState(false) const [webauthnRpId, setWebauthnRpId] = useState('') const [webauthnOrigins, setWebauthnOrigins] = useState('') const [savingWebauthn, setSavingWebauthn] = 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 || {}) if (r.data?.webauthn_rp_id) setWebauthnRpId(r.data.webauthn_rp_id) if (r.data?.webauthn_origins) setWebauthnOrigins(r.data.webauthn_origins) 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) setPasskeyLogin(!!config.passkey_login) setPasskeyConfigured(!!config.passkey_configured) 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 handleSaveWebauthn = async () => { setSavingWebauthn(true) try { await authApi.updateAppSettings({ webauthn_rp_id: webauthnRpId.trim(), webauthn_origins: webauthnOrigins.trim(), }) // Re-read app-config so passkey_configured reflects the new RP ID. await loadAppConfig() toast.success(t('common.saved')) } catch (err: unknown) { toast.error(getApiErrorMessage(err, t('common.error'))) } finally { setSavingWebauthn(false) } } 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 { // store-derived demoMode, serverTimezone, hour12, mcpEnabled, devMode, currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, logout, navigate, toast, // state + setters activeTab, setActiveTab, users, setUsers, stats, isLoading, editingUser, setEditingUser, editForm, setEditForm, showCreateUser, setShowCreateUser, createForm, setCreateForm, bagTrackingEnabled, setBagTrackingEnabled, placesPhotosEnabled, setPlacesPhotosEnabledState, placesAutocompleteEnabled, setPlacesAutocompleteEnabledState, placesDetailsEnabled, setPlacesDetailsEnabledState, collabFeatures, setCollabFeatures, oidcConfig, setOidcConfig, savingOidc, setSavingOidc, passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration, oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration, envOverrideOidcOnly, setEnvOverrideOidcOnly, oidcConfigured, setOidcConfigured, requireMfa, setRequireMfa, passkeyLogin, setPasskeyLogin, passkeyConfigured, webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn, invites, setInvites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm, allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes, smtpValues, setSmtpValues, smtpLoaded, mapsKey, setMapsKey, weatherKey, setWeatherKey, showKeys, setShowKeys, savingKeys, validating, validation, updateInfo, setUpdateInfo, showUpdateModal, setShowUpdateModal, showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt, // handlers loadData, loadAppConfig, loadApiKeys, handleToggleAuthSetting, handleToggleRequireMfa, toggleKey, handleSaveApiKeys, handleValidateKeys, handleValidateKey, handleCreateUser, handleCreateInvite, handleDeleteInvite, copyInviteLink, handleEditUser, handleSaveUser, handleDeleteUser, } }