From eb7f3eb057b95a992434490e9a51c5c5afbfa0a2 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Tue, 5 May 2026 21:57:40 +0200 Subject: [PATCH] fix(portal): consume portal events from useAuthStore instead of duplicate /auth/me fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../portal/__tests__/usePortalStore.spec.ts | 109 ++++++++++++++++++ apps/app/src/stores/portal/usePortalStore.ts | 53 ++++++--- apps/app/src/stores/useAuthStore.ts | 5 + apps/app/src/types/auth.ts | 2 + apps/app/src/types/portal.ts | 37 +----- 5 files changed, 154 insertions(+), 52 deletions(-) diff --git a/apps/app/src/stores/portal/__tests__/usePortalStore.spec.ts b/apps/app/src/stores/portal/__tests__/usePortalStore.spec.ts index 050d8b46..3d5dd38c 100644 --- a/apps/app/src/stores/portal/__tests__/usePortalStore.spec.ts +++ b/apps/app/src/stores/portal/__tests__/usePortalStore.spec.ts @@ -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', '[]') diff --git a/apps/app/src/stores/portal/usePortalStore.ts b/apps/app/src/stores/portal/usePortalStore.ts index 58107503..3d4050df 100644 --- a/apps/app/src/stores/portal/usePortalStore.ts +++ b/apps/app/src/stores/portal/usePortalStore.ts @@ -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 { + /** + * 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 | null = null async function hydrateAfterAuth(): Promise { - 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 diff --git a/apps/app/src/stores/useAuthStore.ts b/apps/app/src/stores/useAuthStore.ts index 493f753b..37eb526c 100644 --- a/apps/app/src/stores/useAuthStore.ts +++ b/apps/app/src/stores/useAuthStore.ts @@ -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([]) const appRoles = ref([]) const permissions = ref([]) + const portalEvents = ref([]) 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, diff --git a/apps/app/src/types/auth.ts b/apps/app/src/types/auth.ts index e6d3adff..898435ac 100644 --- a/apps/app/src/types/auth.ts +++ b/apps/app/src/types/auth.ts @@ -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 diff --git a/apps/app/src/types/portal.ts b/apps/app/src/types/portal.ts index 00450188..547e04d7 100644 --- a/apps/app/src/types/portal.ts +++ b/apps/app/src/types/portal.ts @@ -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