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:
@@ -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', '[]')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user