// 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(null) const expiresIn = ref(null) let refreshTimer: ReturnType | 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 { 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 { 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 | 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 } 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): Promise { 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, } }