import { useCallback, useEffect, useRef, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router' 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 { managedAdminTabs } from '../../managed' import { mergeSavedUser, type AdminUser, type AdminStats, type OidcConfig, type UpdateInfo } from './adminModel' import type { TransitProvider, TransitKeySource } from '@trek/shared' import { passwordErrorKey } from '../../utils/passwordError' /** * Every tab id AdminPage can render a panel for, whatever this install offers. * * ?tab= is user input and was taken as read: an id with no panel behind it — * a typo, or a link from an install that has tabs this one does not — opened * the admin page on an empty content area with nothing selected in the * sidebar. Labels and icons stay in AdminPage; this is only the vocabulary. */ const ADMIN_TAB_IDS = [ 'users', 'defaults', 'config', 'security', 'services', 'addons', 'plugins', 'storage', 'notifications', 'mcp-tokens', 'github', 'backup', 'audit', 'dev-notifications', ] /** * Tab ids that were renamed, so a bookmark, a wiki page or a mail that names the * old one still lands. "Settings" was one tab for sign-in, services and the * danger zone; it opens on security, where the danger zone went too. */ const ADMIN_TAB_ALIASES: Record = { settings: 'security' } /** * The ones a managed install deliberately does not offer. * * The sidebar entries are built conditionally, but the panels are not, so * ?tab=backup opened a backup schedule that competes with the real one on a * hosted instance. Checked in an effect rather than in the initial state * because `managed` arrives with /app-config, after the first render. */ const MANAGED_HIDDEN = ['storage', 'github', 'backup', 'plugins'] /** How often the open users list asks again who is online and active. */ const USERS_REFRESH_MS = 30_000 /** * 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 managed = useAuthStore(s => s.managed) // Whether a key is actually behind a provider is app-config's answer, not the // form's: on a managed install the fields below stay empty by design, and an // operator key set through the environment never reaches them either. const hasMapsKey = useAuthStore(s => s.hasMapsKey) const hasAmapKey = useAuthStore(s => s.hasAmapKey) // ?tab= makes a section linkable: a support reply, an onboarding mail or a // bookmark can point at the one panel it is about instead of at the top of a // page with eleven of them. Read once for the initial value and written back // on every change, so the address bar keeps saying where the reader is. const [searchParams, setSearchParams] = useSearchParams() const [activeTab, setActiveTabState] = useState(() => { const raw = searchParams.get('tab') const asked = raw ? (ADMIN_TAB_ALIASES[raw] ?? raw) : null const known = [...ADMIN_TAB_IDS, ...managedAdminTabs.map(tab => tab.id)] return asked && known.includes(asked) ? asked : 'users' }) const setActiveTab = useCallback((tab: string) => { setActiveTabState(tab) // replace, not push: eleven tabs would otherwise fill the back button with // a history of a single page. setSearchParams(prev => { const next = new URLSearchParams(prev) if (tab === 'users') next.delete('tab') else next.set('tab', tab) return next }, { replace: true }) }, [setSearchParams]) useEffect(() => { if (managed && MANAGED_HIDDEN.includes(activeTab)) setActiveTab('users') }, [managed, activeTab, setActiveTab]) 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) const [placesEnrichEnabled, setPlacesEnrichEnabledState] = useState(true) useEffect(() => { adminApi.getPlacesDetails().then(d => setPlacesDetailsEnabledState(d.enabled)).catch(() => {}) }, []) useEffect(() => { adminApi.getPlacesEnrich().then(d => setPlacesEnrichEnabledState(d.enabled)).catch(() => {}) }, []) // Search and suggestions from Google alone. Admin-only state: the search itself // reads the switch on the server, so nothing else in the client needs it. const [placesGoogleOnly, setPlacesGoogleOnlyState] = useState(false) useEffect(() => { adminApi.getPlacesGoogleOnly().then(d => setPlacesGoogleOnlyState(d.enabled)).catch(() => {}) }, []) // Transit backend (#1699). googleKeySource says where a Google key would come // from for this admin — null means picking Google changes nothing, since the // request-time fallback to Transitous is silent by design. const [transitProvider, setTransitProviderState] = useState('transitous') const [transitGoogleKeySource, setTransitGoogleKeySource] = useState(null) useEffect(() => { adminApi.getTransitProvider() .then(d => { setTransitProviderState(d.provider); setTransitGoogleKeySource(d.googleKeySource) }) .catch(() => {}) }, []) // Place shadow log — off unless an admin turns it on, so the initial state is // false rather than the true the four switches above start from. // The index switch. Read fail-open like the server does, so the state shown // before the request lands matches what an unset row actually means. const [placeShadowEnabled, setPlaceShadowEnabledState] = useState(false) useEffect(() => { adminApi.getPlaceShadow().then(d => setPlaceShadowEnabledState(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 [oidcAutoRedirect, setOidcAutoRedirect] = useState(true) // Live flight status (flight_live_enabled): on until an admin stores 'false'; the server leaves a never-set key out. const [flightLive, setFlightLive] = useState(true) // FLIGHT_LIVE_ENABLED set on the server holds the switch; the form shows its value, locked. const [flightLiveLocked, setFlightLiveLocked] = useState(false) 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 [inviteTrips, setInviteTrips] = useState<{ id: number; title: string }[]>([]) const [showCreateInvite, setShowCreateInvite] = useState(false) // The invite whose delete waits for the admin's answer in the shell's confirm. const [inviteToDelete, setInviteToDelete] = useState(null) const [inviteForm, setInviteForm] = useState<{ max_uses: number; expires_in_days: number | ''; trip_id: number | '' }>({ max_uses: 1, expires_in_days: 7, trip_id: '' }) // 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) setFlightLive(String(r.data?.flight_live_enabled ?? 'true') !== 'false') setFlightLiveLocked(r.data?.env_override_flight_live_enabled != null) setSmtpLoaded(true) }).catch(() => setSmtpLoaded(true)) }, []) // API Keys const [mapsKey, setMapsKey] = useState('') const [weatherKey, setWeatherKey] = useState('') const [unsplashKey, setUnsplashKey] = useState('') const [amapKey, setAmapKey] = useState('') // Keys the operator set in an environment variable: key name → variable (#1881). const [envKeys, setEnvKeys] = useState>({}) /** * Which provider answers place search. Not a key, so it saves through * updateAppSettings rather than with the keys — and it is saved on change * rather than with the Save button, because it is one choice from a list and * the effect is immediate everywhere. */ const [placesProvider, setPlacesProvider] = useState('auto') const [savingPlacesProvider, setSavingPlacesProvider] = useState(false) 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, setPlacesEnrichEnabled, setPlaceShadowEnabled, logout } = useAuthStore() // The store's copy is what the search screens gate the "search Google // instead" line on, so a saved choice lands there the way a saved key does. const setStorePlacesProvider = useAuthStore(s => s.setPlacesProvider) const navigate = useNavigate() const toast = useToast() const [showRotateJwtModal, setShowRotateJwtModal] = useState(false) const [rotatingJwt, setRotatingJwt] = useState(false) useEffect(() => { void loadData() void loadAppConfig() void loadApiKeys() // Skipped rather than caught when the route is closed to us: the request // still reaches the network, still answers 403, and still prints a red line // in the console of every admin who opens this page. Swallowing the promise // hides it from the code, not from the reader. if (!managed) adminApi.getOidc().then(setOidcConfig).catch(() => {}) // The operator decides when this instance upgrades, so there is nothing to // check and nothing the admin could act on. if (managed) return adminApi.checkVersion().then(data => { if (data.update_available) setUpdateInfo(data) }).catch(() => {}) }, []) const loadData = async () => { setIsLoading(true) try { const [usersData, statsData, invitesData, inviteTripsData] = await Promise.all([ adminApi.users(), adminApi.stats(), adminApi.listInvites().catch(() => ({ invites: [] })), adminApi.listInviteTrips().catch(() => ({ trips: [] })), ]) setUsers(usersData.users) setStats(statsData) setInvites(invitesData.invites || []) setInviteTrips(inviteTripsData.trips || []) } catch (err: unknown) { toast.error(t('admin.toast.loadError')) } finally { setIsLoading(false) } } // Who is online and when someone was last active change while the list is // open; it was read once and went stale until a reload. Refreshed quietly // while the users tab is in view, and at once when the page comes back. // Bumped by every local change to the list; a refresh asked before one is out of date. const usersWrites = useRef(0) useEffect(() => { if (activeTab !== 'users') return let cancelled = false const refresh = async () => { if (document.visibilityState !== 'visible') return const asked = usersWrites.current try { const data = await adminApi.users() if (!cancelled && asked === usersWrites.current) setUsers(data.users) } catch { /* the list on screen stays; the next tick asks again */ } } const timer = window.setInterval(() => { void refresh() }, USERS_REFRESH_MS) const onVisible = () => { void refresh() } document.addEventListener('visibilitychange', onVisible) return () => { cancelled = true window.clearInterval(timer) document.removeEventListener('visibilitychange', onVisible) } }, [activeTab]) 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) setOidcAutoRedirect(config.oidc_auto_redirect ?? 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) if (config.places_provider) setPlacesProvider(config.places_provider) } 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 || '') setUnsplashKey(data.settings?.unsplash_api_key || '') setAmapKey(data.settings?.amap_api_key || '') setEnvKeys(data.settings?.env_keys ?? {}) } 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] })) } // A key the environment sets leaves its field empty, and saving that '' would // clear the stored value the install falls back to once the variable is gone. // So those names stay out of every save. const apiKeysBody = () => Object.fromEntries( Object.entries({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey, amap_api_key: amapKey }) .filter(([name]) => !envKeys[name]), ) /** A key field's lock: read-only, naming the variable, while the environment sets that key. */ const keyInputProps = (field: 'maps' | 'unsplash' | 'amap') => { const variable = envKeys[`${field}_api_key`] return { disabled: !!variable, placeholder: variable ? t('admin.keyFromEnv', { name: variable }) : t('settings.keyPlaceholder'), } } // Test probes the key a search resolves to, so one from the environment is // testable with the field left empty. const mapsKeyTestable = !!(mapsKey || envKeys.maps_api_key) const handleSaveApiKeys = async () => { setSavingKeys(true) try { await updateApiKeys(apiKeysBody()) 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(apiKeysBody()) 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(apiKeysBody()) 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 handleTogglePlacesGoogleOnly = async () => { const next = !placesGoogleOnly setPlacesGoogleOnlyState(next) try { await adminApi.updatePlacesGoogleOnly(next) } catch (err: unknown) { setPlacesGoogleOnlyState(!next) toast.error(getApiErrorMessage(err, t('common.error'))) } } const handleSavePlacesProvider = async (value: string) => { const previous = placesProvider setPlacesProvider(value) setSavingPlacesProvider(true) try { await authApi.updateAppSettings({ places_provider: value }) setStorePlacesProvider(value) toast.success(t('admin.placesProvider.saved')) } catch (err: unknown) { setPlacesProvider(previous) toast.error(getApiErrorMessage(err, t('common.error'))) } finally { setSavingPlacesProvider(false) } } const handleCreateUser = async () => { if (!createForm.username.trim() || !createForm.email.trim() || !createForm.password.trim()) { toast.error(t('admin.toast.fieldsRequired')) return } const weak = passwordErrorKey(createForm.password.trim()) if (weak) { toast.error(t(weak)) return } try { const data = await adminApi.createUser(createForm) usersWrites.current += 1 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, trip_id: inviteForm.trip_id === '' ? null : inviteForm.trip_id, }) setInvites(prev => [data.invite, ...prev]) setShowCreateInvite(false) setInviteForm({ max_uses: 1, expires_in_days: 7, trip_id: '' }) // Copy link to clipboard const link = `${window.location.origin}/register?invite=${data.invite.token}` void 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) => { setInviteToDelete(null) 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}` void 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 () => { if (!editingUser) return 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()) { const weak = passwordErrorKey(editForm.password.trim()) if (weak) { toast.error(t(weak)) return } payload.password = editForm.password.trim() } const data = await adminApi.updateUser(editingUser.id, payload) usersWrites.current += 1 setUsers(prev => prev.map(u => u.id === editingUser.id ? mergeSavedUser(u, data.user) : u)) setEditingUser(null) toast.success(t('admin.toast.userUpdated')) } catch (err: unknown) { toast.error(getApiErrorMessage(err, t('admin.toast.updateError'))) } } // `confirmed` means the caller already asked in its own ConfirmDialog (the // desktop users tab); without it the browser's confirm asks, as on mobile. const handleDeleteUser = async (user, opts: { confirmed?: boolean } = {}) => { if (user.id === currentUser?.id) { toast.error(t('admin.toast.cannotDeleteSelf')) return } if (!opts.confirmed && !confirm(t('admin.deleteUser', { name: user.username }))) return try { await adminApi.deleteUser(user.id) usersWrites.current += 1 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'))) } } // The admin shell's own actions, the same on desktop and phone. const saveDemoBaseline = async () => { try { await adminApi.saveDemoBaseline() toast.success(t('admin.demoBaseline.saved')) } catch (e: unknown) { toast.error((e as { response?: { data?: { error?: string } } }).response?.data?.error || t('admin.demoBaseline.failed')) } } const toggleBagTracking = async () => { const next = !bagTrackingEnabled setBagTrackingEnabled(next) try { await adminApi.updateBagTracking(next) } catch { setBagTrackingEnabled(!next) } } const toggleCollabFeature = async (key: string) => { const previous = (collabFeatures as Record)[key] setCollabFeatures({ ...collabFeatures, [key]: !previous }) try { await adminApi.updateCollabFeatures({ [key]: !previous }) } catch { // Only this key rolls back: a slower request must not undo a toggle the // admin made in the meantime. setCollabFeatures(prev => ({ ...prev, [key]: previous })) } } return { // store-derived demoMode, serverTimezone, hour12, mcpEnabled, devMode, managed, currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled, 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, placesEnrichEnabled, setPlacesEnrichEnabledState, placesGoogleOnly, handleTogglePlacesGoogleOnly, transitProvider, setTransitProviderState, transitGoogleKeySource, setTransitGoogleKeySource, placeShadowEnabled, setPlaceShadowEnabledState, collabFeatures, setCollabFeatures, oidcConfig, setOidcConfig, savingOidc, setSavingOidc, passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration, oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration, oidcAutoRedirect, setOidcAutoRedirect, flightLive, setFlightLive, flightLiveLocked, envOverrideOidcOnly, setEnvOverrideOidcOnly, oidcConfigured, setOidcConfigured, requireMfa, setRequireMfa, passkeyLogin, setPasskeyLogin, passkeyConfigured, webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn, invites, setInvites, inviteTrips, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm, inviteToDelete, setInviteToDelete, allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes, smtpValues, setSmtpValues, smtpLoaded, mapsKey, setMapsKey, weatherKey, setWeatherKey, unsplashKey, setUnsplashKey, amapKey, setAmapKey, hasMapsKey, hasAmapKey, keyInputProps, mapsKeyTestable, placesProvider, savingPlacesProvider, handleSavePlacesProvider, 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, saveDemoBaseline, toggleBagTracking, toggleCollabFeature, } }