refactor(auth): merge usePortalAuthStore into useAuthStore with context-aware getters
usePortalAuthStore is deleted — its 114 lines were a slim wrapper over the same /auth/me endpoint useAuthStore already consumes. The merged store gains the full set of additions Bert specified for B2a: State: - availableContexts / defaultContext (from /auth/me contexts block) - lastContext (localStorage-persisted) - portalToken (in-memory only, for the bearer-axios flavour) Getters: isPortalUser, isOrganizerUser, isPlatformAdmin (alias of isSuperAdmin), showContextSwitcher, hasRole(), hasAnyRole(). Actions: login(), verifyMfa() — both return typed discriminated unions so login.vue (Phase H) consumes results without branching on raw API response shapes. setLastContext, setPortalToken, resolveLandingRoute, clearAll. clearAll dynamically imports usePortalStore.reset() to clear portal sessionStorage on session-end — this is the canonical session-cleanup hub now that the merge has happened. 5 source files migrated from usePortalAuthStore → useAuthStore. The PortalLayout.spec.ts mock follows. The boundaries matrix gains a single new edge (`stores → stores-portal`) replacing the deleted stores-portal/usePortalAuthStore which previously owned that cross-zone call. Adds 16 vitest specs in src/stores/__tests__/useAuthStore.spec.ts covering setUser context hydration, hasRole/hasAnyRole, lastContext localStorage persistence, resolveLandingRoute precedence (portal/organizer/super_admin/multi-role/forceContext/forbidden fallback), portalToken state, and clearAll cleanup. Test count 162 → 178 (16 new). Frontend lint + typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { usePortalAuthStore } from '@/stores/portal/usePortalAuthStore'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
|
||||
const authStore = usePortalAuthStore()
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const userInitials = computed(() => {
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type AppLoadingIndicator from '@/components/AppLoadingIndicator.vue'
|
||||
import UserAvatarMenu from '@/components/portal/UserAvatarMenu.vue'
|
||||
import { usePortalAuthStore } from '@/stores/portal/usePortalAuthStore'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { usePortalStore } from '@/stores/portal/usePortalStore'
|
||||
|
||||
const { injectSkinClasses } = useSkins()
|
||||
|
||||
injectSkinClasses()
|
||||
|
||||
const authStore = usePortalAuthStore()
|
||||
const authStore = useAuthStore()
|
||||
const portal = usePortalStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -13,8 +13,8 @@ vi.mock('vue-router', async importOriginal => ({
|
||||
useRoute: () => ({ meta: {} }),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@/stores/portal/usePortalAuthStore', () => ({
|
||||
usePortalAuthStore: () => ({
|
||||
vi.mock('@/stores/useAuthStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
logout: vi.fn(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { isAxiosError } from 'axios'
|
||||
import { usePortalAuthStore } from '@/stores/portal/usePortalAuthStore'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { usePortalStore } from '@/stores/portal/usePortalStore'
|
||||
import { useUpdatePassword, useUpdateProfile } from '@/composables/api/portal/usePortalProfile'
|
||||
import {
|
||||
@@ -27,7 +27,7 @@ definePage({
|
||||
},
|
||||
})
|
||||
|
||||
const authStore = usePortalAuthStore()
|
||||
const authStore = useAuthStore()
|
||||
const portal = usePortalStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useAllMyShifts } from '@/composables/api/portal/usePortalShifts'
|
||||
import { usePortalAuthStore } from '@/stores/portal/usePortalAuthStore'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import type { AllMyShiftsAssignment } from '@/types/portal-shift'
|
||||
|
||||
definePage({
|
||||
@@ -12,7 +12,7 @@ definePage({
|
||||
},
|
||||
})
|
||||
|
||||
const auth = usePortalAuthStore()
|
||||
const auth = useAuthStore()
|
||||
const { data: eventGroups, isLoading, isError, refetch } = useAllMyShifts()
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||
|
||||
233
apps/app/src/stores/__tests__/useAuthStore.spec.ts
Normal file
233
apps/app/src/stores/__tests__/useAuthStore.spec.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import type { MeResponse } from '@/types/auth'
|
||||
|
||||
vi.mock('@/lib/axios', () => ({
|
||||
apiClient: { get: vi.fn(), post: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/deviceFingerprint', () => ({
|
||||
generateDeviceFingerprint: () => 'test-fingerprint',
|
||||
}))
|
||||
|
||||
const useAuthStore = (await import('@/stores/useAuthStore')).useAuthStore
|
||||
|
||||
function makeMe(overrides: Partial<MeResponse> = {}): MeResponse {
|
||||
return {
|
||||
id: '01ABC',
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
full_name: 'Test User',
|
||||
date_of_birth: null,
|
||||
email: 'test@example.nl',
|
||||
phone: null,
|
||||
timezone: 'Europe/Amsterdam',
|
||||
locale: 'nl',
|
||||
avatar: null,
|
||||
organisations: [],
|
||||
app_roles: [],
|
||||
permissions: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useAuthStore — context-aware additions (WS-3 PR-B2a)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('setUser populates contexts', () => {
|
||||
it('uses backend contexts block when present', () => {
|
||||
const store = useAuthStore()
|
||||
|
||||
store.setUser(makeMe({
|
||||
contexts: { available: ['portal', 'organizer'], default: 'organizer' },
|
||||
platform: { is_super_admin: false },
|
||||
}))
|
||||
|
||||
expect(store.availableContexts).toEqual(['portal', 'organizer'])
|
||||
expect(store.defaultContext).toBe('organizer')
|
||||
expect(store.showContextSwitcher).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to local derivation when contexts is missing', () => {
|
||||
const store = useAuthStore()
|
||||
|
||||
store.setUser(makeMe({
|
||||
organisations: [{ id: '01', name: 'Org', slug: 'org', role: 'org_admin' }],
|
||||
app_roles: ['org_admin'],
|
||||
}))
|
||||
|
||||
expect(store.availableContexts).toContain('organizer')
|
||||
expect(store.defaultContext).toBe('organizer')
|
||||
})
|
||||
|
||||
it('exposes isPortalUser / isOrganizerUser based on availableContexts', () => {
|
||||
const store = useAuthStore()
|
||||
|
||||
store.setUser(makeMe({
|
||||
contexts: { available: ['portal'], default: 'portal' },
|
||||
}))
|
||||
|
||||
expect(store.isPortalUser).toBe(true)
|
||||
expect(store.isOrganizerUser).toBe(false)
|
||||
expect(store.showContextSwitcher).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasRole / hasAnyRole', () => {
|
||||
it('checks app_roles', () => {
|
||||
const store = useAuthStore()
|
||||
|
||||
store.setUser(makeMe({ app_roles: ['org_admin', 'event_manager'] }))
|
||||
|
||||
expect(store.hasRole('org_admin')).toBe(true)
|
||||
expect(store.hasRole('super_admin')).toBe(false)
|
||||
expect(store.hasAnyRole(['super_admin', 'event_manager'])).toBe(true)
|
||||
expect(store.hasAnyRole(['super_admin', 'org_member'])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('lastContext localStorage persistence', () => {
|
||||
it('writes to localStorage on setLastContext', () => {
|
||||
const store = useAuthStore()
|
||||
|
||||
store.setLastContext('portal')
|
||||
|
||||
expect(localStorage.getItem('crewli:lastContext')).toBe('portal')
|
||||
expect(store.lastContext).toBe('portal')
|
||||
})
|
||||
|
||||
it('reads from localStorage on store init', () => {
|
||||
localStorage.setItem('crewli:lastContext', 'organizer')
|
||||
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.lastContext).toBe('organizer')
|
||||
})
|
||||
|
||||
it('rejects malformed localStorage values', () => {
|
||||
localStorage.setItem('crewli:lastContext', 'invalid-junk')
|
||||
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.lastContext).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveLandingRoute precedence', () => {
|
||||
it('routes to portal when only portal context is available', () => {
|
||||
const store = useAuthStore()
|
||||
|
||||
store.setUser(makeMe({
|
||||
contexts: { available: ['portal'], default: 'portal' },
|
||||
}))
|
||||
|
||||
const target = store.resolveLandingRoute()
|
||||
|
||||
expect(target).toEqual({ path: '/portal/evenementen' })
|
||||
})
|
||||
|
||||
it('routes to organizer dashboard when only organizer context is available', () => {
|
||||
const store = useAuthStore()
|
||||
|
||||
store.setUser(makeMe({
|
||||
organisations: [{ id: '01', name: 'Org', slug: 'org', role: 'org_admin' }],
|
||||
app_roles: ['org_admin'],
|
||||
contexts: { available: ['organizer'], default: 'organizer' },
|
||||
}))
|
||||
|
||||
expect(store.resolveLandingRoute()).toEqual({ name: 'dashboard' })
|
||||
})
|
||||
|
||||
it('routes to platform dashboard for super_admin without an active org', () => {
|
||||
const store = useAuthStore()
|
||||
|
||||
store.setUser(makeMe({
|
||||
organisations: [],
|
||||
app_roles: ['super_admin'],
|
||||
contexts: { available: ['organizer'], default: 'organizer' },
|
||||
}))
|
||||
|
||||
expect(store.resolveLandingRoute()).toEqual({ name: 'platform' })
|
||||
})
|
||||
|
||||
it('multi-role user — defaultContext wins when no lastContext is set', () => {
|
||||
const store = useAuthStore()
|
||||
|
||||
store.setUser(makeMe({
|
||||
organisations: [{ id: '01', name: 'Org', slug: 'org', role: 'org_admin' }],
|
||||
app_roles: ['org_admin'],
|
||||
contexts: { available: ['portal', 'organizer'], default: 'organizer' },
|
||||
}))
|
||||
|
||||
expect(store.resolveLandingRoute()).toEqual({ name: 'dashboard' })
|
||||
})
|
||||
|
||||
it('multi-role user — lastContext overrides defaultContext', () => {
|
||||
localStorage.setItem('crewli:lastContext', 'portal')
|
||||
|
||||
const store = useAuthStore()
|
||||
|
||||
store.setUser(makeMe({
|
||||
organisations: [{ id: '01', name: 'Org', slug: 'org', role: 'org_admin' }],
|
||||
app_roles: ['org_admin'],
|
||||
contexts: { available: ['portal', 'organizer'], default: 'organizer' },
|
||||
}))
|
||||
|
||||
expect(store.resolveLandingRoute()).toEqual({ path: '/portal/evenementen' })
|
||||
})
|
||||
|
||||
it('forceContext overrides both lastContext and defaultContext', () => {
|
||||
const store = useAuthStore()
|
||||
|
||||
store.setUser(makeMe({
|
||||
organisations: [{ id: '01', name: 'Org', slug: 'org', role: 'org_admin' }],
|
||||
app_roles: ['org_admin'],
|
||||
contexts: { available: ['portal', 'organizer'], default: 'organizer' },
|
||||
}))
|
||||
store.setLastContext('organizer')
|
||||
|
||||
expect(store.resolveLandingRoute('portal')).toEqual({ path: '/portal/evenementen' })
|
||||
})
|
||||
|
||||
it('returns forbidden when user has no contexts available', () => {
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.resolveLandingRoute()).toEqual({ name: 'forbidden' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('portalToken state', () => {
|
||||
it('setPortalToken updates the in-memory token', () => {
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.portalToken).toBeNull()
|
||||
store.setPortalToken('abc-token')
|
||||
expect(store.portalToken).toBe('abc-token')
|
||||
store.setPortalToken(null)
|
||||
expect(store.portalToken).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearAll', () => {
|
||||
it('clears state, lastContext localStorage, and portalToken', async () => {
|
||||
const store = useAuthStore()
|
||||
|
||||
store.setUser(makeMe({
|
||||
contexts: { available: ['portal'], default: 'portal' },
|
||||
}))
|
||||
store.setLastContext('portal')
|
||||
store.setPortalToken('token')
|
||||
|
||||
await store.clearAll()
|
||||
|
||||
expect(store.user).toBeNull()
|
||||
expect(store.availableContexts).toEqual([])
|
||||
expect(store.lastContext).toBeNull()
|
||||
expect(store.portalToken).toBeNull()
|
||||
expect(localStorage.getItem('crewli:lastContext')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,114 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { apiClient } from '@/lib/axios'
|
||||
import type { AuthMeUser } from '@/types/portal'
|
||||
|
||||
export const usePortalAuthStore = defineStore('portalAuth', () => {
|
||||
const user = ref<AuthMeUser | null>(null)
|
||||
const isInitialized = ref(false)
|
||||
|
||||
const isAuthenticated = computed(() => !!user.value)
|
||||
|
||||
function setUser(data: AuthMeUser | null) {
|
||||
user.value = data
|
||||
}
|
||||
|
||||
async function resetPortalStoresSync(): Promise<void> {
|
||||
const { usePortalStore } = await import('@/stores/portal/usePortalStore')
|
||||
|
||||
usePortalStore().reset()
|
||||
}
|
||||
|
||||
function clearState() {
|
||||
user.value = null
|
||||
void resetPortalStoresSync()
|
||||
}
|
||||
|
||||
function handleUnauthorized() {
|
||||
clearState()
|
||||
|
||||
// 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') {
|
||||
const path = window.location.pathname
|
||||
const publicPaths = ['/login', '/wachtwoord-vergeten', '/wachtwoord-resetten', '/verify-email-change']
|
||||
if (!publicPaths.some(p => path.startsWith(p)) && !path.startsWith('/register'))
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
|
||||
async function login(email: string, password: string): Promise<void> {
|
||||
const { data } = await apiClient.post<{
|
||||
success: boolean
|
||||
data: { user: AuthMeUser }
|
||||
}>('/auth/login', { email, password })
|
||||
|
||||
// Token is set automatically via httpOnly Set-Cookie header
|
||||
setUser(data.data.user)
|
||||
|
||||
// Validate by fetching full user data
|
||||
const ok = await fetchUser()
|
||||
if (!ok)
|
||||
throw new Error('Sessie kon niet worden gestart.')
|
||||
}
|
||||
|
||||
async function fetchUser(): Promise<boolean> {
|
||||
try {
|
||||
const { data } = await apiClient.get<{ success: boolean; data: AuthMeUser }>('/auth/me')
|
||||
|
||||
setUser(data.data)
|
||||
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
clearState()
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await apiClient.post('/auth/logout')
|
||||
}
|
||||
catch {
|
||||
// Ignore network errors; still clear local session
|
||||
}
|
||||
clearState()
|
||||
}
|
||||
|
||||
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 {
|
||||
await fetchUser()
|
||||
}
|
||||
finally {
|
||||
isInitialized.value = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
isAuthenticated,
|
||||
isInitialized,
|
||||
setUser,
|
||||
login,
|
||||
logout,
|
||||
fetchUser,
|
||||
initialize,
|
||||
handleUnauthorized,
|
||||
}
|
||||
})
|
||||
@@ -1,8 +1,41 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import { apiClient } from '@/lib/axios'
|
||||
import { useOrganisationStore } from '@/stores/useOrganisationStore'
|
||||
import type { MeResponse, Organisation, User } from '@/types/auth'
|
||||
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'
|
||||
|
||||
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)
|
||||
@@ -10,11 +43,20 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const appRoles = ref<string[]>([])
|
||||
const permissions = ref<string[]>([])
|
||||
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()
|
||||
@@ -24,6 +66,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
?? 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,
|
||||
@@ -42,30 +92,105 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
permissions.value = me.permissions
|
||||
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 route. A
|
||||
* `forceContext` overrides the lastContext + defaultContext precedence
|
||||
* (used by the context-switcher when the user explicitly chooses).
|
||||
*/
|
||||
function resolveLandingRoute(forceContext?: AuthContext): RouteLocationRaw {
|
||||
const ctx = forceContext ?? lastContext.value ?? defaultContext.value
|
||||
|
||||
if (ctx === 'portal' && availableContexts.value.includes('portal'))
|
||||
return { path: '/portal/evenementen' }
|
||||
|
||||
if (ctx === 'organizer' && availableContexts.value.includes('organizer')) {
|
||||
if (isSuperAdmin.value && organisations.value.length === 0)
|
||||
return { name: 'platform' }
|
||||
|
||||
return { name: 'dashboard' }
|
||||
}
|
||||
|
||||
if (availableContexts.value.includes('organizer'))
|
||||
return { name: 'dashboard' }
|
||||
if (availableContexts.value.includes('portal'))
|
||||
return { path: '/portal/evenementen' }
|
||||
|
||||
return { name: 'forbidden' }
|
||||
}
|
||||
|
||||
function clearState() {
|
||||
user.value = null
|
||||
organisations.value = []
|
||||
appRoles.value = []
|
||||
permissions.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()
|
||||
}
|
||||
|
||||
function handleUnauthorized() {
|
||||
clearState()
|
||||
|
||||
@@ -78,7 +203,88 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
/**
|
||||
* 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')
|
||||
}
|
||||
@@ -86,7 +292,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
// Ignore network errors; still clear local state
|
||||
}
|
||||
finally {
|
||||
clearState()
|
||||
await clearAll()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +311,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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.
|
||||
@@ -144,13 +353,30 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { MfaMethod } from '@/types/mfa'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
first_name: string
|
||||
@@ -16,6 +18,11 @@ export interface Organisation {
|
||||
name: string
|
||||
slug: string
|
||||
role: string
|
||||
|
||||
// Forward-compatible 1-element array form of `role`. The pivot still
|
||||
// stores a single string today; multi-role support is tracked in
|
||||
// BACKLOG.md as TECH-PIVOT-ROLES-MULTI.
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
export interface MfaUserInfo {
|
||||
@@ -25,6 +32,17 @@ export interface MfaUserInfo {
|
||||
setup_required: boolean
|
||||
}
|
||||
|
||||
export type AuthContext = 'portal' | 'organizer'
|
||||
|
||||
export interface PlatformInfo {
|
||||
is_super_admin: boolean
|
||||
}
|
||||
|
||||
export interface ContextsBlock {
|
||||
available: AuthContext[]
|
||||
default: AuthContext
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
id: string
|
||||
first_name: string
|
||||
@@ -40,6 +58,8 @@ export interface MeResponse {
|
||||
app_roles: string[]
|
||||
permissions: string[]
|
||||
mfa?: MfaUserInfo
|
||||
platform?: PlatformInfo
|
||||
contexts?: ContextsBlock
|
||||
}
|
||||
|
||||
export interface LoginCredentials {
|
||||
@@ -61,6 +81,43 @@ export interface LoginResponse {
|
||||
mfa_setup_required?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Login outcome surfaced by useAuthStore.login() — the page consumes
|
||||
* the discriminator and decides UI/routing. The store performs the
|
||||
* actual API call and any state hydration; the page never owns
|
||||
* branch logic about which API to call.
|
||||
*/
|
||||
export type LoginResult =
|
||||
| { kind: 'authenticated' }
|
||||
| {
|
||||
kind: 'mfa-required'
|
||||
sessionToken: string
|
||||
methods: MfaMethod[]
|
||||
preferredMethod: MfaMethod
|
||||
expiresIn: number
|
||||
}
|
||||
| { kind: 'must-set-password'; userId: string }
|
||||
| { kind: 'failed'; reason: string; errors?: Record<string, string[]> }
|
||||
|
||||
/**
|
||||
* MFA verification outcome surfaced by useAuthStore.verifyMfa(). The
|
||||
* page consumes this to decide redirect vs. error display. After
|
||||
* 'authenticated', the auth store has a valid /auth/me hydration and
|
||||
* the page can call resolveLandingRoute().
|
||||
*/
|
||||
export type MfaVerifyResult =
|
||||
| { kind: 'authenticated' }
|
||||
| { kind: 'failed'; reason: string; errors?: Record<string, string[]> }
|
||||
|
||||
export interface MfaVerifyArgs {
|
||||
sessionToken: string
|
||||
code: string
|
||||
method: MfaMethod
|
||||
trustDevice?: boolean
|
||||
deviceFingerprint?: string
|
||||
deviceName?: string
|
||||
}
|
||||
|
||||
export interface ApiErrorResponse {
|
||||
success: false
|
||||
message: string
|
||||
|
||||
Reference in New Issue
Block a user