Files
TREK/client/src/store/authStore.ts
T
Maurice bfb3bcd63c feat(config): mark an install as centrally administered
Some installs are run by somebody other than the person who administers them.
The operator owns the credentials, the configuration and the upgrade schedule,
and the admin of the instance is a user of the result rather than the one who
set it up. Nothing in the app knows that today, so it keeps offering settings
whose values are not the admin's to change, and the honest answer to half of
them would be a 403 the UI never anticipated.

TREK_MANAGED says it once. It is a single boolean and the surfaces that care
read it themselves, so adding one never comes back to the config layer.

Runtime-toggled, in the same class as DEMO_MODE, rather than a registerAs
token: roughly sixty tests mutate env mid-lifetime, and a value frozen per
built app breaks all of them. `=== true` on the way in, so an unset, empty or
misspelled value means off — a managed install sets it deliberately, and a typo
must not quietly take settings away from an admin who never asked for it.

The guard keys on a decorator, not on a path. MfaPolicyGuard documents where
the two literal path lists it replaced ended up: they stopped being maintained,
and every endpoint added afterwards silently fell on the wrong side. A marker
on the handler cannot drift away from the handler. It carries a mandatory
reason, which is for the next reader of the source rather than for the client,
so the marks stay auditable.

It registers third, after the auth and MFA guards, so a stranger still gets the
401 those two would have given them instead of a 403 that would confirm the
route exists. And it checks the mode before it touches metadata, because it
runs on every request of every install and almost all of them are self-hosted.

One 403 body, shared by REST and MCP. DEMO_MODE grew two texts for the same
condition and neither can be changed now without hunting for the other.

The flag reaches the client through app-config so the UI can stop offering what
the server would refuse anyway, but it is deliberately not persisted the way
demo_mode is: two installs can share a browser profile, and a stale "this one
is managed" would take settings away on an install that never set it.

No route carries the marker yet. The per-domain marks follow. Unset, which is
every self-hosted install, nothing about this changes anything.
2026-08-15 11:45:11 +02:00

402 lines
15 KiB
TypeScript

import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { authApi } from '../api/client'
import { connect, disconnect } from '../api/websocket'
import type { User } from '../types'
import { getApiErrorMessage } from '../types'
import { tripSyncManager } from '../sync/tripSyncManager'
import { reopenForUser, deleteCurrentUserDb } from '../db/offlineDb'
import { setAuthed } from '../sync/authGate'
import { unregisterSyncTriggers } from '../sync/syncTriggers'
import { useSystemNoticeStore } from './systemNoticeStore.js'
import { clearAppearanceSnapshot } from '../theme/applyAppearance'
import { clearAllPluginSessions } from './pluginStore'
import { forgetStartDestination } from '../utils/startDestination'
interface AuthResponse {
user: User
token: string
}
export type LoginResult = AuthResponse | { mfa_required: true; mfa_token: string }
interface AvatarResponse {
avatar_url: string
}
interface AuthState {
user: User | null
isAuthenticated: boolean
isLoading: boolean
/** The auth check (loadUser) failed for a non-401 reason while we were online —
* the server was unreachable or erroring. Surfaced by the UI so a backend/IdP
* outage doesn't render as a blank, error-free page that looks like lost data.
* Transient, never persisted. #1283 */
authCheckFailed: boolean
/** The user pressed "log out" — as opposed to a session that simply ended.
* Read by ProtectedRoute: a deliberate sign-out should not leave a
* ?redirect= pointing back at the page they just left. Transient. */
loggingOut: boolean
error: string | null
/** The operator of this install owns its configuration, not the admin. */
managed: boolean
demoMode: boolean
devMode: boolean
isPrerelease: boolean
appVersion: string
hasMapsKey: boolean
serverTimezone: string
/** Server policy: all users must enable MFA */
appRequireMfa: boolean
tripRemindersEnabled: boolean
placesPhotosEnabled: boolean
placesAutocompleteEnabled: boolean
placesDetailsEnabled: boolean
placesEnrichEnabled: boolean
login: (email: string, password: string, rememberMe?: boolean) => Promise<LoginResult>
completeMfaLogin: (mfaToken: string, code: string, rememberMe?: boolean) => Promise<AuthResponse>
register: (username: string, email: string, password: string, invite_token?: string) => Promise<AuthResponse>
logout: () => Promise<void>
/** Pass `{ silent: true }` to refresh the user without toggling global isLoading (avoids unmounting protected routes). */
loadUser: (opts?: { silent?: boolean }) => Promise<void>
updateMapsKey: (key: string | null) => Promise<void>
updateApiKeys: (keys: Record<string, string | null>) => Promise<void>
updateProfile: (profileData: Partial<User>) => Promise<void>
uploadAvatar: (file: File) => Promise<AvatarResponse>
deleteAvatar: () => Promise<void>
setManaged: (val: boolean) => void
setDemoMode: (val: boolean) => void
setDevMode: (val: boolean) => void
setIsPrerelease: (val: boolean) => void
setAppVersion: (val: string) => void
setHasMapsKey: (val: boolean) => void
setServerTimezone: (tz: string) => void
setAppRequireMfa: (val: boolean) => void
setTripRemindersEnabled: (val: boolean) => void
setPlacesPhotosEnabled: (val: boolean) => void
setPlacesAutocompleteEnabled: (val: boolean) => void
setPlacesDetailsEnabled: (val: boolean) => void
setPlacesEnrichEnabled: (val: boolean) => void
demoLogin: () => Promise<AuthResponse>
}
// Sequence counter to prevent stale loadUser responses from overwriting fresh auth state
let authSequence = 0
/**
* Mark the session authenticated and point the offline DB at this user's scoped
* database before any background sync runs, so cached data never crosses users.
*/
async function onAuthSuccess(userId: number): Promise<void> {
setAuthed(true)
try {
await reopenForUser(userId)
} catch (err) {
console.error('[auth] failed to open user-scoped offline DB', err)
}
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
isAuthenticated: false,
isLoading: true,
authCheckFailed: false,
loggingOut: false,
error: null,
managed: false,
demoMode: localStorage.getItem('demo_mode') === 'true',
devMode: false,
isPrerelease: false,
appVersion: '',
hasMapsKey: false,
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
appRequireMfa: false,
tripRemindersEnabled: false,
placesPhotosEnabled: true,
placesAutocompleteEnabled: true,
placesDetailsEnabled: true,
placesEnrichEnabled: true,
login: async (email: string, password: string, rememberMe?: boolean) => {
authSequence++
set({ isLoading: true, error: null })
try {
const data = await authApi.login({ email, password, remember_me: rememberMe }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string }
if (data.mfa_required && data.mfa_token) {
set({ isLoading: false, error: null })
return { mfa_required: true as const, mfa_token: data.mfa_token }
}
set({
user: data.user,
isAuthenticated: true,
loggingOut: false,
isLoading: false,
error: null,
})
await onAuthSuccess(data.user.id)
connect()
tripSyncManager.syncAll().catch(console.error)
if (!data.user?.must_change_password) {
useSystemNoticeStore.getState().fetch()
}
return data as AuthResponse
} catch (err: unknown) {
const error = getApiErrorMessage(err, 'Login failed')
set({ isLoading: false, error })
throw new Error(error)
}
},
completeMfaLogin: async (mfaToken: string, code: string, rememberMe?: boolean) => {
authSequence++
set({ isLoading: true, error: null })
try {
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, ''), remember_me: rememberMe })
set({
user: data.user,
isAuthenticated: true,
loggingOut: false,
isLoading: false,
error: null,
})
await onAuthSuccess(data.user.id)
connect()
tripSyncManager.syncAll().catch(console.error)
if (!data.user?.must_change_password) {
useSystemNoticeStore.getState().fetch()
}
return data as AuthResponse
} catch (err: unknown) {
const error = getApiErrorMessage(err, 'Verification failed')
set({ isLoading: false, error })
throw new Error(error)
}
},
register: async (username: string, email: string, password: string, invite_token?: string) => {
authSequence++
set({ isLoading: true, error: null })
try {
const data = await authApi.register({ username, email, password, invite_token })
set({
user: data.user,
isAuthenticated: true,
loggingOut: false,
isLoading: false,
error: null,
})
await onAuthSuccess(data.user.id)
connect()
tripSyncManager.syncAll().catch(console.error)
useSystemNoticeStore.getState().fetch()
return data
} catch (err: unknown) {
const error = getApiErrorMessage(err, 'Registration failed')
set({ isLoading: false, error })
throw new Error(error)
}
},
logout: async () => {
// 1. Gate first so any in-flight flush/syncAll bails before we wipe the DB.
setAuthed(false)
// Flagged in the same update that drops the session: clearing isAuthenticated
// re-renders ProtectedRoute for whatever page is still on screen, and without
// this it would stamp a ?redirect= back to it — which then beats the user's
// startup destination on the next login.
set({ isAuthenticated: false, loggingOut: true })
// 2. Stop background sync triggers (30s interval, WS pre-reconnect hook, listeners).
unregisterSyncTriggers()
// 3. Tear down the live connection.
disconnect()
useSystemNoticeStore.getState().reset()
// Drop the per-device appearance snapshot so the next user on a shared
// browser doesn't get a pre-paint flash of this user's theme.
clearAppearanceSnapshot()
// Same reason for the brokered plugin session state: it is keyed by user id,
// but sessionStorage outlives a logout within the tab.
clearAllPluginSessions()
// And the startup-destination mirror, or the next account on this browser
// gets bounced into a trip it may not even be able to see.
forgetStartDestination()
// 4. Tell server to clear the httpOnly cookie (best-effort).
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
// 5. Clear service worker caches containing sensitive data.
if ('caches' in window) {
await Promise.all([
caches.delete('api-data').catch(() => {}),
caches.delete('user-uploads').catch(() => {}),
])
}
// 6. Delete this user's scoped IndexedDB and return to the anonymous DB.
await deleteCurrentUserDb().catch(console.error)
// 7. Finish clearing auth state.
set({
user: null,
isAuthenticated: false,
authCheckFailed: false,
error: null,
})
},
loadUser: async (opts?: { silent?: boolean }) => {
const seq = authSequence
const silent = !!opts?.silent
if (!silent) set({ isLoading: true })
try {
const data = await authApi.me()
if (seq !== authSequence) return // stale response — a login/register happened meanwhile
set({
user: data.user,
isAuthenticated: true,
loggingOut: false,
isLoading: false,
authCheckFailed: false,
})
await onAuthSuccess(data.user.id)
connect()
} catch (err: unknown) {
if (seq !== authSequence) return // stale response — ignore
const status = err && typeof err === 'object' && 'response' in err
? (err as { response?: { status?: number } }).response?.status
: undefined
if (status === 401) {
// Invalid/expired token — clear auth so the guard redirects to login.
set({
user: null,
isAuthenticated: false,
isLoading: false,
authCheckFailed: false,
})
} else if (status === undefined && typeof navigator !== 'undefined' && !navigator.onLine) {
// Genuinely offline — keep the persisted session so the PWA serves cached
// data without a scary error. This is the offline-first happy path.
set({ isLoading: false })
} else {
// Server erroring (5xx) or unreachable while we're online: keep the session
// (don't eject the user over a transient outage), but flag it so the UI can
// say "couldn't reach the server" instead of showing a blank, error-free
// page that looks like the user's trips were lost. #1283
set({ isLoading: false, authCheckFailed: true })
}
}
},
updateMapsKey: async (key: string | null) => {
try {
await authApi.updateMapsKey(key)
set((state) => ({
user: state.user ? { ...state.user, maps_api_key: key || null } : null,
hasMapsKey: !!key,
}))
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error saving API key'))
}
},
updateApiKeys: async (keys: Record<string, string | null>) => {
try {
const data = await authApi.updateApiKeys(keys)
set({ user: data.user })
if ('maps_api_key' in keys) {
set({ hasMapsKey: !!keys.maps_api_key })
}
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error saving API keys'))
}
},
updateProfile: async (profileData: Partial<User>) => {
try {
const data = await authApi.updateSettings(profileData)
set({ user: data.user })
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating profile'))
}
},
uploadAvatar: async (file: File) => {
const formData = new FormData()
formData.append('avatar', file)
const data = await authApi.uploadAvatar(formData)
set((state) => ({ user: state.user ? { ...state.user, avatar_url: data.avatar_url } : null }))
return data
},
deleteAvatar: async () => {
await authApi.deleteAvatar()
set((state) => ({ user: state.user ? { ...state.user, avatar_url: null } : null }))
},
// Not persisted, unlike demoMode above: two installs can share a browser
// profile, and a stale 'this one is managed' would then take settings away
// from an admin on an install that never set the flag. Re-read on every boot
// from app-config, which is one request the app makes anyway.
setManaged: (val: boolean) => set({ managed: val }),
setDemoMode: (val: boolean) => {
if (val) localStorage.setItem('demo_mode', 'true')
else localStorage.removeItem('demo_mode')
set({ demoMode: val })
},
setDevMode: (val: boolean) => set({ devMode: val }),
setIsPrerelease: (val: boolean) => set({ isPrerelease: val }),
setAppVersion: (val: string) => set({ appVersion: val }),
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }),
setPlacesPhotosEnabled: (val: boolean) => set({ placesPhotosEnabled: val }),
setPlacesAutocompleteEnabled: (val: boolean) => set({ placesAutocompleteEnabled: val }),
setPlacesDetailsEnabled: (val: boolean) => set({ placesDetailsEnabled: val }),
setPlacesEnrichEnabled: (val: boolean) => set({ placesEnrichEnabled: val }),
demoLogin: async () => {
authSequence++
set({ isLoading: true, error: null })
try {
const data = await authApi.demoLogin()
set({
user: data.user,
isAuthenticated: true,
loggingOut: false,
isLoading: false,
demoMode: true,
error: null,
})
await onAuthSuccess(data.user.id)
connect()
return data
} catch (err: unknown) {
const error = getApiErrorMessage(err, 'Demo login failed')
set({ isLoading: false, error })
throw new Error(error)
}
},
}),
{
name: 'trek_auth_snapshot',
// Only persist the minimal user snapshot needed to avoid redirecting to
// login when the PWA reopens offline. The JWT remains in the httpOnly
// cookie and is still validated by the server on every request.
// maps_api_key is intentionally excluded — it's an API key that should
// not sit in localStorage any longer than the active session requires.
partialize: (state) => ({
isAuthenticated: state.isAuthenticated,
user: state.user ? {
id: state.user.id,
username: state.user.username,
email: state.user.email,
role: state.user.role,
avatar_url: state.user.avatar_url,
mfa_enabled: state.user.mfa_enabled,
must_change_password: state.user.must_change_password,
} : null,
}),
}
))