Files
crewli-old/apps/app/src/stores/useAuthStore.ts
bert.hausmans eb7f3eb057 fix(portal): consume portal events from useAuthStore instead of duplicate /auth/me fetch
The auth-store merge made portal_events available on the unified
/auth/me response (held in useAuthStore.portalEvents). usePortalStore
now sources userEvents from the auth store, eliminating the duplicate
fetch that the legacy slim usePortalAuthStore had compensated for.

Changes:
- types/auth.ts: add portal_events?: PortalEvent[] to MeResponse
- useAuthStore: add portalEvents ref, populated in setUser from
  me.portal_events, cleared in clearState
- usePortalStore: replace loadUserEventsFromApiAndStorage (which
  fetched /auth/me) with syncEventsFromAuthStore (which reads
  authStore.portalEvents). A reactive watch keeps userEvents in sync
  whenever the auth store updates (login, refresh, logout). The
  sessionStorage merge stays as offline cache + post-registration
  bridge.
- types/portal.ts: drop the now-unused AuthMeUser type — MeResponse
  is the canonical shape post-merge.

Boundaries: usePortalStore (stores-portal) statically imports
useAuthStore (stores) — already allowed by the matrix
(stores-portal allow includes stores).

Adds 4 vitest specs covering: userEvents reflects auth.portalEvents,
no apiClient.get('/auth/me') call from the portal store,
sessionStorage fallback when auth has not hydrated, reactive update
on auth.portalEvents change.

Test count 205 → 209. Lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:57:40 +02:00

393 lines
12 KiB
TypeScript

import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { apiClient } from '@/lib/axios'
import { useOrganisationStore } from '@/stores/useOrganisationStore'
import { generateDeviceFingerprint } from '@/utils/deviceFingerprint'
import type { MfaMethod } from '@/types/mfa'
import type {
AuthContext,
ContextsBlock,
LoginCredentials,
LoginResponse,
LoginResult,
MeResponse,
MfaVerifyArgs,
MfaVerifyResult,
Organisation,
User,
} from '@/types/auth'
import type { PortalEvent } from '@/types/portal'
const LAST_CONTEXT_STORAGE_KEY = 'crewli:lastContext'
function readStoredLastContext(): AuthContext | null {
if (typeof localStorage === 'undefined')
return null
const raw = localStorage.getItem(LAST_CONTEXT_STORAGE_KEY)
return raw === 'portal' || raw === 'organizer' ? raw : null
}
function writeStoredLastContext(value: AuthContext | null): void {
if (typeof localStorage === 'undefined')
return
if (value)
localStorage.setItem(LAST_CONTEXT_STORAGE_KEY, value)
else localStorage.removeItem(LAST_CONTEXT_STORAGE_KEY)
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const organisations = ref<Organisation[]>([])
const appRoles = ref<string[]>([])
const permissions = ref<string[]>([])
const portalEvents = ref<PortalEvent[]>([])
const isInitialized = ref(false)
const mfaSetupRequired = ref(false)
const availableContexts = ref<AuthContext[]>([])
const defaultContext = ref<AuthContext>('portal')
const lastContext = ref<AuthContext | null>(readStoredLastContext())
const portalToken = ref<string | null>(null)
const isAuthenticated = computed(() => !!user.value)
const isSuperAdmin = computed(() => appRoles.value?.includes('super_admin') ?? false)
const isPlatformAdmin = isSuperAdmin
const isPortalUser = computed(() => availableContexts.value.includes('portal'))
const isOrganizerUser = computed(() => availableContexts.value.includes('organizer'))
const showContextSwitcher = computed(() => availableContexts.value.length >= 2)
const currentOrganisation = computed(() => {
const orgStore = useOrganisationStore()
return organisations.value.find(o => o.id === orgStore.activeOrganisationId)
?? organisations.value[0]
?? null
})
function hasRole(role: string): boolean {
return appRoles.value.includes(role)
}
function hasAnyRole(roles: string[]): boolean {
return roles.some(r => appRoles.value.includes(r))
}
function setUser(me: MeResponse) {
user.value = {
id: me.id,
first_name: me.first_name,
last_name: me.last_name,
full_name: me.full_name,
date_of_birth: me.date_of_birth,
email: me.email,
phone: me.phone,
timezone: me.timezone,
locale: me.locale,
avatar: me.avatar,
}
organisations.value = me.organisations
appRoles.value = me.app_roles
permissions.value = me.permissions
portalEvents.value = me.portal_events ?? []
mfaSetupRequired.value = me.mfa?.setup_required ?? false
// Context block — additive in B2a; falls back to derivation when the
// backend response predates the contexts enrichment (e.g. test
// fixtures that hand-craft a MeResponse).
const contexts: ContextsBlock = me.contexts ?? deriveContextsLocally(me)
availableContexts.value = contexts.available
defaultContext.value = contexts.default
// Auto-select first organisation if none is active
const orgStore = useOrganisationStore()
if (!orgStore.activeOrganisationId && me.organisations.length > 0)
orgStore.setActiveOrganisation(me.organisations[0].id)
}
function deriveContextsLocally(me: MeResponse): ContextsBlock {
const isSuper = me.app_roles?.includes('super_admin') ?? false
const hasOrgs = (me.organisations?.length ?? 0) > 0
const available: AuthContext[] = []
if (hasOrgs || isSuper)
available.push('organizer')
const fallbackDefault: AuthContext = (hasOrgs || isSuper) ? 'organizer' : 'portal'
return { available, default: fallbackDefault }
}
function setActiveOrganisation(id: string) {
const orgStore = useOrganisationStore()
orgStore.setActiveOrganisation(id)
}
function setLastContext(ctx: AuthContext) {
lastContext.value = ctx
writeStoredLastContext(ctx)
}
function setPortalToken(token: string | null) {
portalToken.value = token
}
/**
* Resolve the post-login or post-context-switch landing path. A
* `forceContext` overrides the lastContext + defaultContext precedence
* (used by the context-switcher when the user explicitly chooses).
*
* Returns a string path (not a RouteLocationRaw object) so consumers
* can pass it directly to the typed router without casting.
*/
function resolveLandingRoute(forceContext?: AuthContext): string {
const ctx = forceContext ?? lastContext.value ?? defaultContext.value
if (ctx === 'portal' && availableContexts.value.includes('portal'))
return '/portal/evenementen'
if (ctx === 'organizer' && availableContexts.value.includes('organizer')) {
if (isSuperAdmin.value && organisations.value.length === 0)
return '/platform'
return '/dashboard'
}
if (availableContexts.value.includes('organizer'))
return '/dashboard'
if (availableContexts.value.includes('portal'))
return '/portal/evenementen'
return '/forbidden'
}
function clearState() {
user.value = null
organisations.value = []
appRoles.value = []
permissions.value = []
portalEvents.value = []
mfaSetupRequired.value = false
availableContexts.value = []
defaultContext.value = 'portal'
portalToken.value = null
const orgStore = useOrganisationStore()
orgStore.clear()
}
/**
* Full reset including the lastContext localStorage preference and
* any portal sessionStorage state. Called on logout and 401.
* Cross-zone access to stores/portal is via dynamic import (the
* boundaries matrix forbids static `stores → stores-portal`).
*/
async function clearAll(): Promise<void> {
clearState()
lastContext.value = null
writeStoredLastContext(null)
const { usePortalStore } = await import('@/stores/portal/usePortalStore')
usePortalStore().reset()
}
async function handleUnauthorized(): Promise<void> {
// A13-8: clear portal sessionStorage on 401 in addition to in-memory
// state. clearAll() handles both via the dynamic-import seam to
// stores-portal.
await clearAll()
// Do NOT reset isInitialized — the full page reload (below) resets all JS state.
// Resetting it here causes a race condition: the async 401 interceptor fires
// after doInitialize() sets isInitialized=true, putting the app back into
// a loading state that never resolves.
if (typeof window !== 'undefined' && window.location.pathname !== '/login')
window.location.href = '/login'
}
/**
* Authenticate with email + password. Returns a discriminated union
* the caller pattern-matches on — login.vue does not branch on raw
* API response shapes, only on the LoginResult kind.
*/
async function login(credentials: LoginCredentials): Promise<LoginResult> {
try {
const fingerprint = generateDeviceFingerprint()
const { data } = await apiClient.post<LoginResponse>(
'/auth/login',
credentials,
{ headers: { 'X-Device-Fingerprint': fingerprint } },
)
if (data.mfa_required) {
return {
kind: 'mfa-required',
sessionToken: data.mfa_session_token ?? '',
methods: (data.methods ?? []) as MfaMethod[],
preferredMethod: (data.preferred_method ?? 'totp') as MfaMethod,
expiresIn: data.expires_in ?? 600,
}
}
// Cookie has been set; populate the store from the login response's
// user payload. Page checks `auth.mfaSetupRequired` for routing.
setUser(data.data.user)
return { kind: 'authenticated' }
}
catch (err: unknown) {
const errorData = (err as { response?: { status?: number; data?: { message?: string; errors?: Record<string, string[]> } } }).response
if (errorData?.status === 429)
return { kind: 'failed', reason: 'rate_limited' }
return {
kind: 'failed',
reason: errorData?.data?.message ?? 'invalid_credentials',
errors: errorData?.data?.errors,
}
}
}
/**
* Verify the MFA challenge. On success, refetches /auth/me to pick up
* any backend-side state changes (e.g. trusted device registration).
*/
async function verifyMfa(args: MfaVerifyArgs): Promise<MfaVerifyResult> {
try {
await apiClient.post('/auth/mfa/verify', {
mfa_session_token: args.sessionToken,
code: args.code,
method: args.method,
trust_device: args.trustDevice,
device_fingerprint: args.deviceFingerprint,
device_name: args.deviceName,
})
// Cookie set by backend on successful verify — refresh user from
// /auth/me. refreshUser() ignores errors silently; we re-throw
// here if hydration genuinely failed.
await refreshUser()
if (!user.value)
return { kind: 'failed', reason: 'hydration_failed' }
return { kind: 'authenticated' }
}
catch (err: unknown) {
const errorData = (err as { response?: { data?: { message?: string; errors?: Record<string, string[]> } } }).response?.data
return {
kind: 'failed',
reason: errorData?.message ?? 'invalid_code',
errors: errorData?.errors,
}
}
}
async function logout(): Promise<void> {
try {
await apiClient.post('/auth/logout')
}
catch {
// Ignore network errors; still clear local state
}
finally {
await clearAll()
}
}
/**
* Re-fetch /auth/me and update the store. Safe to call at any time
* after initialization (e.g. after MFA setup or profile update).
*/
async function refreshUser(): Promise<void> {
try {
const { data } = await apiClient.get<{ success: boolean; data: MeResponse }>('/auth/me')
setUser(data.data)
}
catch {
// Silently ignore — the existing state stays
}
}
/** Alias kept for callers migrated from usePortalAuthStore. */
const fetchUser = refreshUser
/**
* Called once on app startup. Validates the httpOnly cookie by calling
* GET /auth/me. On 401, clears everything.
* Safe to call multiple times — subsequent calls return the same promise.
*/
let initializePromise: Promise<void> | null = null
function initialize(): Promise<void> {
if (isInitialized.value)
return Promise.resolve()
if (!initializePromise)
initializePromise = doInitialize()
return initializePromise
}
async function doInitialize(): Promise<void> {
try {
const { data } = await apiClient.get<{ success: boolean; data: MeResponse }>('/auth/me')
setUser(data.data)
}
catch {
// Cookie invalid/expired or not present — clear everything
clearState()
}
finally {
isInitialized.value = true
}
}
return {
user,
organisations,
appRoles,
permissions,
portalEvents,
isAuthenticated,
isInitialized,
isSuperAdmin,
isPlatformAdmin,
isPortalUser,
isOrganizerUser,
showContextSwitcher,
availableContexts,
defaultContext,
lastContext,
portalToken,
currentOrganisation,
mfaSetupRequired,
setUser,
setActiveOrganisation,
setLastContext,
setPortalToken,
resolveLandingRoute,
hasRole,
hasAnyRole,
login,
verifyMfa,
logout,
handleUnauthorized,
initialize,
refreshUser,
fetchUser,
clearAll,
}
})