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', () => {
|
describe('integration with useAuthStore', () => {
|
||||||
it('useAuthStore.clearAll() resets portal sessionStorage', async () => {
|
it('useAuthStore.clearAll() resets portal sessionStorage', async () => {
|
||||||
sessionStorage.setItem('crewli:portal:events', '[]')
|
sessionStorage.setItem('crewli:portal:events', '[]')
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { apiClient } from '@/lib/axios'
|
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
|
// A13-8 (SECURITY_AUDIT.md) — portal session state lives in
|
||||||
// sessionStorage, not localStorage. Tab-close clears the session;
|
// 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.
|
* Call after successful public registration so the volunteer sees the
|
||||||
* TODO: replace with `portal_events` from GET /auth/me when the API exposes it.
|
* 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 {
|
function savePendingEventFromRegistration(event: PortalEvent): void {
|
||||||
const merged = mergeEvents([], [...readStoredEvents(), ...userEvents.value, event], false)
|
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
|
isLoadingEvents.value = true
|
||||||
loadError.value = null
|
loadError.value = null
|
||||||
try {
|
try {
|
||||||
|
const authStore = useAuthStore()
|
||||||
const stored = readStoredEvents()
|
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 ?? []
|
// When auth has hydrated successfully, /auth/me's portal_events
|
||||||
apiSucceeded = true
|
// is canonical; otherwise we fall back to sessionStorage cache.
|
||||||
}
|
const apiSucceeded = authStore.isInitialized && authStore.isAuthenticated
|
||||||
catch {
|
|
||||||
// /auth/me failed — still show locally stored registrations
|
userEvents.value = mergeEvents(authStore.portalEvents, stored, apiSucceeded)
|
||||||
}
|
|
||||||
userEvents.value = mergeEvents(apiEvents, stored, apiSucceeded)
|
|
||||||
persistEvents()
|
persistEvents()
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch {
|
||||||
loadError.value = 'Kon je evenementen niet laden.'
|
loadError.value = 'Kon je evenementen niet laden.'
|
||||||
userEvents.value = readStoredEvents()
|
userEvents.value = readStoredEvents()
|
||||||
}
|
}
|
||||||
@@ -231,7 +235,7 @@ export const usePortalStore = defineStore('portal', () => {
|
|||||||
let hydratePromise: Promise<void> | null = null
|
let hydratePromise: Promise<void> | null = null
|
||||||
|
|
||||||
async function hydrateAfterAuth(): Promise<void> {
|
async function hydrateAfterAuth(): Promise<void> {
|
||||||
await loadUserEventsFromApiAndStorage()
|
syncEventsFromAuthStore()
|
||||||
resolveActiveEventId()
|
resolveActiveEventId()
|
||||||
await fetchCurrentPerson()
|
await fetchCurrentPerson()
|
||||||
isHydrated.value = true
|
isHydrated.value = true
|
||||||
@@ -250,6 +254,19 @@ export const usePortalStore = defineStore('portal', () => {
|
|||||||
return hydratePromise
|
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 {
|
function setActiveEvent(eventId: string): void {
|
||||||
if (!userEvents.value.some(e => e.event_id === eventId))
|
if (!userEvents.value.some(e => e.event_id === eventId))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
Organisation,
|
Organisation,
|
||||||
User,
|
User,
|
||||||
} from '@/types/auth'
|
} from '@/types/auth'
|
||||||
|
import type { PortalEvent } from '@/types/portal'
|
||||||
|
|
||||||
const LAST_CONTEXT_STORAGE_KEY = 'crewli:lastContext'
|
const LAST_CONTEXT_STORAGE_KEY = 'crewli:lastContext'
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const organisations = ref<Organisation[]>([])
|
const organisations = ref<Organisation[]>([])
|
||||||
const appRoles = ref<string[]>([])
|
const appRoles = ref<string[]>([])
|
||||||
const permissions = ref<string[]>([])
|
const permissions = ref<string[]>([])
|
||||||
|
const portalEvents = ref<PortalEvent[]>([])
|
||||||
const isInitialized = ref(false)
|
const isInitialized = ref(false)
|
||||||
const mfaSetupRequired = ref(false)
|
const mfaSetupRequired = ref(false)
|
||||||
|
|
||||||
@@ -89,6 +91,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
organisations.value = me.organisations
|
organisations.value = me.organisations
|
||||||
appRoles.value = me.app_roles
|
appRoles.value = me.app_roles
|
||||||
permissions.value = me.permissions
|
permissions.value = me.permissions
|
||||||
|
portalEvents.value = me.portal_events ?? []
|
||||||
mfaSetupRequired.value = me.mfa?.setup_required ?? false
|
mfaSetupRequired.value = me.mfa?.setup_required ?? false
|
||||||
|
|
||||||
// Context block — additive in B2a; falls back to derivation when the
|
// Context block — additive in B2a; falls back to derivation when the
|
||||||
@@ -167,6 +170,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
organisations.value = []
|
organisations.value = []
|
||||||
appRoles.value = []
|
appRoles.value = []
|
||||||
permissions.value = []
|
permissions.value = []
|
||||||
|
portalEvents.value = []
|
||||||
mfaSetupRequired.value = false
|
mfaSetupRequired.value = false
|
||||||
availableContexts.value = []
|
availableContexts.value = []
|
||||||
defaultContext.value = 'portal'
|
defaultContext.value = 'portal'
|
||||||
@@ -355,6 +359,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
organisations,
|
organisations,
|
||||||
appRoles,
|
appRoles,
|
||||||
permissions,
|
permissions,
|
||||||
|
portalEvents,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
isSuperAdmin,
|
isSuperAdmin,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { MfaMethod } from '@/types/mfa'
|
import type { MfaMethod } from '@/types/mfa'
|
||||||
|
import type { PortalEvent } from '@/types/portal'
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
@@ -57,6 +58,7 @@ export interface MeResponse {
|
|||||||
organisations: Organisation[]
|
organisations: Organisation[]
|
||||||
app_roles: string[]
|
app_roles: string[]
|
||||||
permissions: string[]
|
permissions: string[]
|
||||||
|
portal_events?: PortalEvent[]
|
||||||
mfa?: MfaUserInfo
|
mfa?: MfaUserInfo
|
||||||
platform?: PlatformInfo
|
platform?: PlatformInfo
|
||||||
contexts?: ContextsBlock
|
contexts?: ContextsBlock
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Volunteer-facing event context for the portal.
|
* Volunteer-facing event context for the portal.
|
||||||
* Populated from GET /auth/me when the API adds `portal_events`, merged with
|
* Sourced from /auth/me's `portal_events` block (held by useAuthStore)
|
||||||
* locally stored events (e.g. after public registration).
|
* and merged with sessionStorage entries (e.g. after public registration).
|
||||||
*/
|
*/
|
||||||
export interface PortalEvent {
|
export interface PortalEvent {
|
||||||
event_id: string
|
event_id: string
|
||||||
event_name: string
|
event_name: string
|
||||||
organisation_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
|
organisation_id?: string
|
||||||
person_id?: string | null
|
person_id?: string | null
|
||||||
person_status: string
|
person_status: string
|
||||||
@@ -16,37 +16,6 @@ export interface PortalEvent {
|
|||||||
end_date: string
|
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) */
|
/** GET /portal/me?event_id= — person payload (subset used by dashboard) */
|
||||||
export interface PortalPersonPayload {
|
export interface PortalPersonPayload {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
Reference in New Issue
Block a user