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>
393 lines
12 KiB
TypeScript
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,
|
|
}
|
|
})
|