Files
summercms/admin/src/state/useAuth.ts
Jakub Zych 21b0d39de0 feat(quick-261016-0fm): manual theme switch on the rail
- 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
2026-10-16 09:54:02 +02:00

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,
}
}