mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
a876fb2634
* feat(auth): passkey (WebAuthn) login — server endpoints, schema + admin toggle Add @simplewebauthn/server registration and primary (discoverable) login ceremonies under /api/auth/passkey, a webauthn_credentials + single-use webauthn_challenges schema (migration), the instance-wide passkey_login toggle (default off) enforced before auth by a guard, and require_mfa satisfaction via a verified passkey. RP ID/origin come only from server config (webauthn_rp_id/origins -> APP_URL), never request headers. * feat(auth): passkey enrolment, login button + admin settings UI PasskeysSection in account settings (add/rename/remove with a current-password step-up), a 'Sign in with a passkey' button on the login page, the admin enable + RP-ID/origins controls, and a per-user admin reset action. * i18n(auth): passkey strings across all locales Add login/settings/admin passkey keys to en and all 19 translated locales.
388 lines
16 KiB
TypeScript
388 lines
16 KiB
TypeScript
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<string>('users')
|
|
const [users, setUsers] = useState<AdminUser[]>([])
|
|
const [stats, setStats] = useState<AdminStats | null>(null)
|
|
const [isLoading, setIsLoading] = useState<boolean>(true)
|
|
const [editingUser, setEditingUser] = useState<AdminUser | null>(null)
|
|
const [editForm, setEditForm] = useState<{ username: string; email: string; role: string; password: string }>({ username: '', email: '', role: 'user', password: '' })
|
|
const [showCreateUser, setShowCreateUser] = useState<boolean>(false)
|
|
const [createForm, setCreateForm] = useState<{ username: string; email: string; password: string; role: string }>({ username: '', email: '', password: '', role: 'user' })
|
|
|
|
// Bag tracking
|
|
const [bagTrackingEnabled, setBagTrackingEnabled] = useState<boolean>(false)
|
|
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
|
|
|
|
// Places photos
|
|
const [placesPhotosEnabled, setPlacesPhotosEnabledState] = useState<boolean>(true)
|
|
useEffect(() => { adminApi.getPlacesPhotos().then(d => setPlacesPhotosEnabledState(d.enabled)).catch(() => {}) }, [])
|
|
|
|
// Places autocomplete
|
|
const [placesAutocompleteEnabled, setPlacesAutocompleteEnabledState] = useState<boolean>(true)
|
|
useEffect(() => { adminApi.getPlacesAutocomplete().then(d => setPlacesAutocompleteEnabledState(d.enabled)).catch(() => {}) }, [])
|
|
|
|
// Places details
|
|
const [placesDetailsEnabled, setPlacesDetailsEnabledState] = useState<boolean>(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<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', discovery_url: '' })
|
|
const [savingOidc, setSavingOidc] = useState<boolean>(false)
|
|
|
|
// Auth toggles
|
|
const [passwordLogin, setPasswordLogin] = useState<boolean>(true)
|
|
const [passwordRegistration, setPasswordRegistration] = useState<boolean>(true)
|
|
const [oidcLogin, setOidcLogin] = useState<boolean>(true)
|
|
const [oidcRegistration, setOidcRegistration] = useState<boolean>(true)
|
|
const [envOverrideOidcOnly, setEnvOverrideOidcOnly] = useState<boolean>(false)
|
|
const [oidcConfigured, setOidcConfigured] = useState<boolean>(false)
|
|
const [requireMfa, setRequireMfa] = useState<boolean>(false)
|
|
|
|
// Passkey (WebAuthn) login
|
|
const [passkeyLogin, setPasskeyLogin] = useState<boolean>(false)
|
|
const [passkeyConfigured, setPasskeyConfigured] = useState<boolean>(false)
|
|
const [webauthnRpId, setWebauthnRpId] = useState<string>('')
|
|
const [webauthnOrigins, setWebauthnOrigins] = useState<string>('')
|
|
const [savingWebauthn, setSavingWebauthn] = useState<boolean>(false)
|
|
|
|
// Invite links
|
|
const [invites, setInvites] = useState<any[]>([])
|
|
const [showCreateInvite, setShowCreateInvite] = useState<boolean>(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<string>('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv')
|
|
const [savingFileTypes, setSavingFileTypes] = useState<boolean>(false)
|
|
|
|
// SMTP settings
|
|
const [smtpValues, setSmtpValues] = useState<Record<string, string>>({})
|
|
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<string>('')
|
|
const [weatherKey, setWeatherKey] = useState<string>('')
|
|
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({})
|
|
const [savingKeys, setSavingKeys] = useState<boolean>(false)
|
|
const [validating, setValidating] = useState<Record<string, boolean>>({})
|
|
const [validation, setValidation] = useState<Record<string, boolean | undefined>>({})
|
|
|
|
// Version check & update
|
|
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
|
|
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
|
|
|
|
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, logout } = useAuthStore()
|
|
const navigate = useNavigate()
|
|
const toast = useToast()
|
|
|
|
const [showRotateJwtModal, setShowRotateJwtModal] = useState<boolean>(false)
|
|
const [rotatingJwt, setRotatingJwt] = useState<boolean>(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,
|
|
}
|
|
}
|