- the rail's top button cycles system, light and dark and names the current choice; light and dark ignore prefers-color-scheme - usePreferences applies the cached theme before the first await of boot, saves the choice through PUT /account/preferences and reverts with a toast when the save fails; me() applies the stored preference
149 lines
4.7 KiB
TypeScript
149 lines
4.7 KiB
TypeScript
// Admin session state. The JWT travels in an HttpOnly cookie (D-19): the SPA
|
|
// keeps only the profile and the access lifetime, never a token.
|
|
import { readonly, ref } from 'vue'
|
|
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
|
|
import { api, onRefreshed, refreshSession } from '../api/client'
|
|
import type { AdminProfile } from '../api/types'
|
|
import { setListStateOwner } from './listState'
|
|
import { applyProfilePreferences } from './usePreferences'
|
|
import { setNavigation } from './useNavigation'
|
|
import { setSettings } from './useSettings'
|
|
|
|
const user = ref<AdminProfile | null>(null)
|
|
const expiresIn = ref<number | null>(null)
|
|
let refreshTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
/** Refreshes proactively at 80 percent of the access token lifetime. */
|
|
function scheduleRefresh(seconds: number | null): void {
|
|
cancelRefresh()
|
|
expiresIn.value = seconds
|
|
if (seconds === null || seconds <= 0) {
|
|
return
|
|
}
|
|
refreshTimer = setTimeout(() => {
|
|
refreshTimer = null
|
|
void refreshSession()
|
|
}, seconds * 800)
|
|
}
|
|
|
|
function cancelRefresh(): void {
|
|
if (refreshTimer !== null) {
|
|
clearTimeout(refreshTimer)
|
|
refreshTimer = null
|
|
}
|
|
}
|
|
|
|
onRefreshed(scheduleRefresh)
|
|
|
|
export const currentUser = readonly(user)
|
|
|
|
/** Logs in over cookie transport. Returns false on invalid credentials. */
|
|
export async function login(identifier: string, password: string): Promise<boolean> {
|
|
const { data, response } = await api.POST('/auth/login', {
|
|
body: { login: identifier, password },
|
|
})
|
|
if (!response.ok || !data) {
|
|
return false
|
|
}
|
|
scheduleRefresh(typeof data.data.expires_in === 'number' ? data.data.expires_in : null)
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Loads the signed-in admin; null when the session is missing or expired.
|
|
* The stored list queries follow the answer: a profile keeps only that
|
|
* admin's (another admin's are removed, T-16.1-03), no profile removes all.
|
|
* The profile's UI preferences (theme) are applied before it returns.
|
|
*/
|
|
export async function me(): Promise<AdminProfile | null> {
|
|
const { data, response } = await api.GET('/auth/me')
|
|
user.value = response.ok && data ? data.data : null
|
|
setListStateOwner(user.value?.id ?? null)
|
|
await applyProfilePreferences(user.value)
|
|
return user.value
|
|
}
|
|
|
|
/** First and last name initials, else the login's first two letters. */
|
|
export function profileInitials(user: Pick<AdminProfile, 'first_name' | 'last_name' | 'login'> | null): string {
|
|
if (!user) {
|
|
return ''
|
|
}
|
|
const parts = [user.first_name, user.last_name].map((part) => part.trim()).filter(Boolean)
|
|
if (parts.length > 0) {
|
|
return parts
|
|
.slice(0, 2)
|
|
.map((part) => part.charAt(0).toUpperCase())
|
|
.join('')
|
|
}
|
|
return user.login.slice(0, 2).toUpperCase()
|
|
}
|
|
|
|
/** Replaces the signed-in profile, e.g. with the one PUT /account returned. */
|
|
export function setCurrentUser(profile: AdminProfile): void {
|
|
user.value = profile
|
|
setListStateOwner(profile.id)
|
|
}
|
|
|
|
/**
|
|
* Forgets the session on this device: the refresh timer, the profile and
|
|
* every stored list query (search terms can hold personal data and never
|
|
* outlive the session, T-16.1-03).
|
|
*/
|
|
export function clearUser(): void {
|
|
cancelRefresh()
|
|
user.value = null
|
|
expiresIn.value = null
|
|
setListStateOwner(null)
|
|
}
|
|
|
|
/**
|
|
* The session ended on its own (expiry, or a 401 the refresh could not
|
|
* recover): clears the local session, stored list queries included, and
|
|
* sends the admin to the login screen with the current path as redirect.
|
|
*/
|
|
export function expireSession(router: {
|
|
currentRoute: { value: Pick<RouteLocationNormalizedLoaded, 'name' | 'fullPath'> }
|
|
push: Router['push']
|
|
}): void {
|
|
clearUser()
|
|
const current = router.currentRoute.value
|
|
if (current.name !== 'login') {
|
|
void router.push({ name: 'login', query: { redirect: current.fullPath } }).catch(() => undefined)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ends the session: POST /auth/logout (the server blacklists the token and
|
|
* expires the HttpOnly cookie), then clears the in-memory user, navigation,
|
|
* settings and stored list queries (so the next admin on this browser sees
|
|
* none of the previous searches) and routes to the login screen. The local state is cleared
|
|
* and the login screen shown even when the call fails (T-10-23).
|
|
*/
|
|
export async function logout(router?: Pick<Router, 'replace'>): Promise<void> {
|
|
try {
|
|
await api.POST('/auth/logout')
|
|
} catch {
|
|
// Network failure: the session still ends on this device.
|
|
} finally {
|
|
clearUser()
|
|
setNavigation([])
|
|
setSettings([])
|
|
}
|
|
if (router) {
|
|
await router.replace({ name: 'login' }).catch(() => undefined)
|
|
}
|
|
}
|
|
|
|
export function useAuth() {
|
|
return {
|
|
user: currentUser,
|
|
expiresIn: readonly(expiresIn),
|
|
login,
|
|
logout,
|
|
expireSession,
|
|
me,
|
|
setCurrentUser,
|
|
clearUser,
|
|
}
|
|
}
|