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>
This commit is contained in:
2026-05-05 21:57:40 +02:00
parent 3019095a2a
commit eb7f3eb057
5 changed files with 154 additions and 52 deletions

View File

@@ -97,6 +97,115 @@ describe('usePortalStore — A13-8 sessionStorage migration (WS-3 PR-B2a)', () =
})
})
describe('userEvents sync from useAuthStore.portalEvents', () => {
function makeMe(overrides = {}) {
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,
}
}
it('reflects auth.portalEvents once auth has hydrated', () => {
const auth = useAuthStore()
auth.setUser(makeMe({
portal_events: [{
event_id: '01EVENT',
event_name: 'Festival',
organisation_name: 'Echt Feesten',
person_status: 'approved',
start_date: '2026-06-01',
end_date: '2026-06-02',
}],
}))
;(auth as unknown as { isInitialized: boolean }).isInitialized = true
const portal = usePortalStore()
expect(portal.userEvents).toHaveLength(1)
expect(portal.userEvents[0].event_id).toBe('01EVENT')
})
it('does NOT call apiClient.get(/auth/me) — duplicate fetch eliminated', async () => {
const { apiClient } = await import('@/lib/axios')
const getMock = apiClient.get as unknown as { mock: { calls: unknown[][] } }
getMock.mock.calls.length = 0
const auth = useAuthStore()
auth.setUser(makeMe({ portal_events: [] }))
;(auth as unknown as { isInitialized: boolean }).isInitialized = true
usePortalStore()
const authMeCalls = getMock.mock.calls.filter(args =>
typeof args[0] === 'string' && args[0].includes('/auth/me'),
)
expect(authMeCalls).toHaveLength(0)
})
it('falls back to sessionStorage when auth has not hydrated', () => {
sessionStorage.setItem('crewli:portal:events', JSON.stringify([{
event_id: '01CACHED',
event_name: 'Cached',
organisation_name: 'Cached Org',
person_status: 'approved',
start_date: '2026-06-01',
end_date: '2026-06-02',
}]))
// Auth not hydrated → apiSucceeded=false → sessionStorage entries
// are preserved as fallback cache.
const portal = usePortalStore()
expect(portal.userEvents).toHaveLength(1)
expect(portal.userEvents[0].event_id).toBe('01CACHED')
})
it('updates userEvents reactively when auth.portalEvents changes', async () => {
const auth = useAuthStore()
auth.setUser(makeMe({ portal_events: [] }))
;(auth as unknown as { isInitialized: boolean }).isInitialized = true
const portal = usePortalStore()
expect(portal.userEvents).toHaveLength(0)
auth.setUser(makeMe({
portal_events: [{
event_id: '01NEW',
event_name: 'New Event',
organisation_name: 'Org',
person_status: 'approved',
start_date: '2026-07-01',
end_date: '2026-07-02',
}],
}))
// Watcher is sync (default flush='pre'); the reactive update fires
// on the next microtask. Flush via Promise tick.
await Promise.resolve()
expect(portal.userEvents).toHaveLength(1)
expect(portal.userEvents[0].event_id).toBe('01NEW')
})
})
describe('integration with useAuthStore', () => {
it('useAuthStore.clearAll() resets portal sessionStorage', async () => {
sessionStorage.setItem('crewli:portal:events', '[]')

View File

@@ -1,7 +1,8 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { apiClient } from '@/lib/axios'
import type { AuthMeUser, PortalEvent, PortalPersonPayload } from '@/types/portal'
import { useAuthStore } from '@/stores/useAuthStore'
import type { PortalEvent, PortalPersonPayload } from '@/types/portal'
// A13-8 (SECURITY_AUDIT.md) — portal session state lives in
// sessionStorage, not localStorage. Tab-close clears the session;
@@ -133,8 +134,10 @@ export const usePortalStore = defineStore('portal', () => {
}
/**
* Call after successful public registration so the volunteer sees the event on the dashboard.
* TODO: replace with `portal_events` from GET /auth/me when the API exposes it.
* Call after successful public registration so the volunteer sees the
* event on the dashboard before /auth/me has had a chance to refresh.
* The sessionStorage entry bridges the gap until the next
* useAuthStore.refreshUser() picks up the new portal_events row.
*/
function savePendingEventFromRegistration(event: PortalEvent): void {
const merged = mergeEvents([], [...readStoredEvents(), ...userEvents.value, event], false)
@@ -147,26 +150,27 @@ export const usePortalStore = defineStore('portal', () => {
}
}
async function loadUserEventsFromApiAndStorage(): Promise<void> {
/**
* Sync userEvents from the unified /auth/me response (held in
* useAuthStore.portalEvents). Replaces the legacy duplicate
* apiClient.get('/auth/me') call — the auth store is now the single
* source of truth for portal_events.
*/
function syncEventsFromAuthStore(): void {
isLoadingEvents.value = true
loadError.value = null
try {
const authStore = useAuthStore()
const stored = readStoredEvents()
let apiEvents: PortalEvent[] = []
let apiSucceeded = false
try {
const { data } = await apiClient.get<{ success: boolean; data: AuthMeUser }>('/auth/me')
apiEvents = data.data.portal_events ?? []
apiSucceeded = true
}
catch {
// /auth/me failed — still show locally stored registrations
}
userEvents.value = mergeEvents(apiEvents, stored, apiSucceeded)
// When auth has hydrated successfully, /auth/me's portal_events
// is canonical; otherwise we fall back to sessionStorage cache.
const apiSucceeded = authStore.isInitialized && authStore.isAuthenticated
userEvents.value = mergeEvents(authStore.portalEvents, stored, apiSucceeded)
persistEvents()
}
catch (e) {
catch {
loadError.value = 'Kon je evenementen niet laden.'
userEvents.value = readStoredEvents()
}
@@ -231,7 +235,7 @@ export const usePortalStore = defineStore('portal', () => {
let hydratePromise: Promise<void> | null = null
async function hydrateAfterAuth(): Promise<void> {
await loadUserEventsFromApiAndStorage()
syncEventsFromAuthStore()
resolveActiveEventId()
await fetchCurrentPerson()
isHydrated.value = true
@@ -250,6 +254,19 @@ export const usePortalStore = defineStore('portal', () => {
return hydratePromise
}
// React to auth-store changes: as soon as useAuthStore.portalEvents
// updates (login, refresh, logout), keep userEvents in sync. This
// replaces the old "fetch /auth/me from inside the portal store"
// flow — the auth store is now the single source of truth and the
// portal store is a derived view + sessionStorage cache.
const authStore = useAuthStore()
watch(
() => [authStore.portalEvents, authStore.isInitialized, authStore.isAuthenticated] as const,
() => syncEventsFromAuthStore(),
{ immediate: true, deep: true },
)
function setActiveEvent(eventId: string): void {
if (!userEvents.value.some(e => e.event_id === eventId))
return

View File

@@ -16,6 +16,7 @@ import type {
Organisation,
User,
} from '@/types/auth'
import type { PortalEvent } from '@/types/portal'
const LAST_CONTEXT_STORAGE_KEY = 'crewli:lastContext'
@@ -41,6 +42,7 @@ export const useAuthStore = defineStore('auth', () => {
const organisations = ref<Organisation[]>([])
const appRoles = ref<string[]>([])
const permissions = ref<string[]>([])
const portalEvents = ref<PortalEvent[]>([])
const isInitialized = ref(false)
const mfaSetupRequired = ref(false)
@@ -89,6 +91,7 @@ export const useAuthStore = defineStore('auth', () => {
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
@@ -167,6 +170,7 @@ export const useAuthStore = defineStore('auth', () => {
organisations.value = []
appRoles.value = []
permissions.value = []
portalEvents.value = []
mfaSetupRequired.value = false
availableContexts.value = []
defaultContext.value = 'portal'
@@ -355,6 +359,7 @@ export const useAuthStore = defineStore('auth', () => {
organisations,
appRoles,
permissions,
portalEvents,
isAuthenticated,
isInitialized,
isSuperAdmin,

View File

@@ -1,4 +1,5 @@
import type { MfaMethod } from '@/types/mfa'
import type { PortalEvent } from '@/types/portal'
export interface User {
id: string
@@ -57,6 +58,7 @@ export interface MeResponse {
organisations: Organisation[]
app_roles: string[]
permissions: string[]
portal_events?: PortalEvent[]
mfa?: MfaUserInfo
platform?: PlatformInfo
contexts?: ContextsBlock

View File

@@ -1,14 +1,14 @@
/**
* Volunteer-facing event context for the portal.
* Populated from GET /auth/me when the API adds `portal_events`, merged with
* locally stored events (e.g. after public registration).
* Sourced from /auth/me's `portal_events` block (held by useAuthStore)
* and merged with sessionStorage entries (e.g. after public registration).
*/
export interface PortalEvent {
event_id: string
event_name: string
organisation_name: string
/** Present when the row was saved from the public registration flow */
// Present when the row was saved from the public registration flow.
organisation_id?: string
person_id?: string | null
person_status: string
@@ -16,37 +16,6 @@ export interface PortalEvent {
end_date: string
}
/** GET /auth/me — extend when backend adds portal_events */
export interface AuthMeUser {
id: string
first_name: string
last_name: string
full_name: string
email: string
timezone?: string
locale?: string
avatar?: string | null
email_verified_at?: string | null
organisations?: Array<{
id: string
name: string
slug: string
role: string
}>
app_roles?: string[]
/** Present on login (`UserResource`); `/auth/me` uses `app_roles` */
roles?: string[]
permissions?: string[]
portal_events?: PortalEvent[]
mfa?: {
enabled: boolean
method: 'totp' | 'email' | null
confirmed_at: string | null
setup_required: boolean
}
}
/** GET /portal/me?event_id= — person payload (subset used by dashboard) */
export interface PortalPersonPayload {
id: string