refactor(portal): move stores and rename portal auth store

- apps/portal/src/stores/useAuthStore.ts →
  apps/app/src/stores/portal/usePortalAuthStore.ts. The export and
  defineStore id are renamed (useAuthStore → usePortalAuthStore,
  'auth' → 'portalAuth') so it can coexist with the organizer's
  apps/app/src/stores/useAuthStore. Lazy import inside
  resetPortalStoresSync() updated to the new path.
- apps/portal/src/stores/usePortalStore.ts →
  apps/app/src/stores/portal/usePortalStore.ts (no name change —
  apps/app does not have a usePortalStore).

All call sites in moved pages/components now import from
@/stores/portal/{usePortalStore,usePortalAuthStore} and call
usePortalAuthStore() instead of useAuthStore().

PR-B2 will merge this back into a single context-aware auth store.

Also includes the C.1 page meta-block updates (layout: 'PortalLayout'
| 'PublicLayout', context: 'portal') that were left unstaged after
the page-rename commit picked up only the path change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 19:06:08 +02:00
parent 98ec51fcbd
commit 4fe1a0c517
15 changed files with 44 additions and 40 deletions

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalAuthStore } from '@/stores/portal/usePortalAuthStore'
const authStore = useAuthStore()
const authStore = usePortalAuthStore()
const router = useRouter()
const userInitials = computed(() => {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { usePortalStore } from '@/stores/usePortalStore'
import { usePortalStore } from '@/stores/portal/usePortalStore'
defineProps<{
eventId: string

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import StatusCard from '@/components/portal/StatusCard.vue'
import { usePortalStore } from '@/stores/usePortalStore'
import { usePortalStore } from '@/stores/portal/usePortalStore'
import { useMyShifts } from '@/composables/api/usePortalShifts'
import type { PortalPersonPayload } from '@/types/portal'

View File

@@ -2,9 +2,10 @@
definePage({
name: 'artist-advance',
meta: {
layout: 'portal',
layout: 'PortalLayout',
requiresAuth: false,
requiresToken: true,
context: 'portal',
},
})

View File

@@ -1,15 +1,16 @@
<script setup lang="ts">
import OverzichtTab from '@/components/event/OverzichtTab.vue'
import RoosterTab from '@/components/event/RoosterTab.vue'
import ClaimenTab from '@/components/event/ClaimenTab.vue'
import InformatieTab from '@/components/event/InformatieTab.vue'
import { usePortalStore } from '@/stores/usePortalStore'
import OverzichtTab from '@/components/portal/event/OverzichtTab.vue'
import RoosterTab from '@/components/portal/event/RoosterTab.vue'
import ClaimenTab from '@/components/portal/event/ClaimenTab.vue'
import InformatieTab from '@/components/portal/event/InformatieTab.vue'
import { usePortalStore } from '@/stores/portal/usePortalStore'
definePage({
name: 'portal-event-detail',
meta: {
layout: 'portal',
layout: 'PortalLayout',
requiresAuth: true,
context: 'portal',
navMode: 'event',
},
})

View File

@@ -1,12 +1,13 @@
<script setup lang="ts">
import EventCard from '@/components/portal/EventCard.vue'
import { usePortalStore } from '@/stores/usePortalStore'
import { usePortalStore } from '@/stores/portal/usePortalStore'
definePage({
name: 'portal-evenementen',
meta: {
layout: 'portal',
layout: 'PortalLayout',
requiresAuth: true,
context: 'portal',
navMode: 'platform',
navTitle: 'Mijn evenementen',
},

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalStore } from '@/stores/usePortalStore'
import { usePortalAuthStore } from '@/stores/portal/usePortalAuthStore'
import { usePortalStore } from '@/stores/portal/usePortalStore'
import { useUpdateProfile, useUpdatePassword } from '@/composables/api/usePortalProfile'
import {
useMfaStatus,
@@ -19,14 +19,15 @@ import type { ApiErrorResponse } from '@/types/api'
definePage({
name: 'portal-profiel',
meta: {
layout: 'portal',
layout: 'PortalLayout',
requiresAuth: true,
context: 'portal',
navMode: 'platform',
navTitle: 'Mijn profiel',
},
})
const authStore = useAuthStore()
const authStore = usePortalAuthStore()
const portal = usePortalStore()
const router = useRouter()
const route = useRoute()

View File

@@ -2,8 +2,9 @@
definePage({
name: 'volunteer-register-info',
meta: {
layout: 'blank',
layout: 'PortalLayout',
requiresAuth: false,
context: 'portal',
},
})
</script>

View File

@@ -1,17 +1,18 @@
<script setup lang="ts">
import { useAllMyShifts } from '@/composables/api/usePortalShifts'
import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalAuthStore } from '@/stores/portal/usePortalAuthStore'
import type { AllMyShiftsAssignment } from '@/types/portal-shift'
definePage({
name: 'portal-shifts',
meta: {
layout: 'portal',
layout: 'PortalLayout',
requiresAuth: true,
context: 'portal',
},
})
const auth = useAuthStore()
const auth = usePortalAuthStore()
const { data: eventGroups, isLoading, isError, refetch } = useAllMyShifts()
const statusConfig: Record<string, { label: string; color: string }> = {

View File

@@ -9,8 +9,9 @@ import { apiClient } from '@/lib/axios'
definePage({
name: 'set-password',
meta: {
layout: 'blank',
layout: 'PortalLayout',
requiresAuth: false,
context: 'portal',
},
})

View File

@@ -1,25 +1,24 @@
<script setup lang="ts">
import { emailValidator } from '@core/utils/validators'
import FieldRenderer from '@/components/public-form/FieldRenderer.vue'
import FormConfirmation from '@/components/public-form/FormConfirmation.vue'
import FormErrorState from '@/components/public-form/FormErrorState.vue'
import FormStepper from '@/components/public-form/FormStepper.vue'
import SubmitterDetails from '@/components/public-form/SubmitterDetails.vue'
import FieldRenderer from '@/components/shared/public-form/FieldRenderer.vue'
import FormConfirmation from '@/components/shared/public-form/FormConfirmation.vue'
import FormErrorState from '@/components/shared/public-form/FormErrorState.vue'
import FormStepper from '@/components/shared/public-form/FormStepper.vue'
import SubmitterDetails from '@/components/shared/public-form/SubmitterDetails.vue'
import { extractErrorBody, useFetchPublicFormSchema } from '@/composables/api/usePublicForm'
import { usePublicFormSections } from '@/composables/api/usePublicFormSections'
import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots'
import { useFormDraft } from '@/composables/useFormDraft'
import { isStepValid, useFormSteps } from '@form-schema/composables/useFormSteps'
import { formatFieldValue } from '@form-schema/composables/formatFieldValue'
import { isStepValid, useFormSteps } from '@/composables/forms/composables/useFormSteps'
import { formatFieldValue } from '@/composables/forms/composables/formatFieldValue'
import { providePublicFormLocale, providePublicFormToken } from '@/composables/publicFormInjection'
import { FormFieldType } from '@form-schema/types/formBuilder'
import type { FormErrorCode, PublicFormField } from '@form-schema/types/formBuilder'
import { FormFieldType } from '@/composables/forms/types/formBuilder'
import type { FormErrorCode, PublicFormField } from '@/composables/forms/types/formBuilder'
definePage({
name: 'public-form-register',
meta: {
layout: 'blank',
requiresAuth: false,
layout: 'PublicLayout',
},
})

View File

@@ -1,17 +1,15 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalAuthStore } from '@/stores/portal/usePortalAuthStore'
definePage({
name: 'register-success',
meta: {
layout: 'portal',
requiresAuth: false,
navMode: 'platform',
layout: 'PublicLayout',
},
})
const route = useRoute('register-success')
const authStore = useAuthStore()
const authStore = usePortalAuthStore()
const eventName = computed(() => (route.query.event as string) || 'het evenement')
const bannerUrl = computed(() => (route.query.banner as string) || null)

View File

@@ -0,0 +1,110 @@
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,
}
})

View File

@@ -0,0 +1,254 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { apiClient } from '@/lib/axios'
import type { AuthMeUser, PortalEvent, PortalPersonPayload } from '@/types/portal'
const STORAGE_EVENTS = 'crewli_portal_user_events_v1'
const STORAGE_ACTIVE_EVENT = 'crewli_portal_active_event_id_v1'
function readStoredEvents(): PortalEvent[] {
if (typeof localStorage === 'undefined') return []
try {
const raw = localStorage.getItem(STORAGE_EVENTS)
if (!raw) return []
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed.filter(
(e): e is PortalEvent =>
typeof e === 'object'
&& e !== null
&& 'event_id' in e
&& 'event_name' in e
&& 'person_status' in e,
)
}
catch {
return []
}
}
function writeStoredEvents(events: PortalEvent[]): void {
if (typeof localStorage === 'undefined') return
localStorage.setItem(STORAGE_EVENTS, JSON.stringify(events))
}
function readStoredActiveEventId(): string | null {
if (typeof localStorage === 'undefined') return null
return localStorage.getItem(STORAGE_ACTIVE_EVENT)
}
function writeStoredActiveEventId(id: string | null): void {
if (typeof localStorage === 'undefined') return
if (id) localStorage.setItem(STORAGE_ACTIVE_EVENT, id)
else localStorage.removeItem(STORAGE_ACTIVE_EVENT)
}
/**
* Merge API events with locally stored events.
*
* When the API call succeeded (`apiSucceeded = true`), the API is the source
* of truth: stored events that are NOT confirmed by the API are dropped.
* This prevents stale localStorage entries from showing events the user no
* longer has access to (e.g. after user_id was cleared).
*
* When the API call failed (`apiSucceeded = false`), we fall back to stored
* events as a best-effort cache.
*/
function mergeEvents(apiEvents: PortalEvent[], stored: PortalEvent[], apiSucceeded: boolean): PortalEvent[] {
const map = new Map<string, PortalEvent>()
if (apiSucceeded) {
// API is source of truth — start with API events only
for (const e of apiEvents) {
const prev = stored.find(s => s.event_id === e.event_id)
map.set(e.event_id, {
...prev,
...e,
organisation_name: e.organisation_name || prev?.organisation_name || '',
})
}
}
else {
// API failed — merge stored + whatever API returned (likely empty)
for (const e of stored) map.set(e.event_id, { ...e })
for (const e of apiEvents) {
const prev = map.get(e.event_id)
map.set(e.event_id, {
...prev,
...e,
organisation_name: e.organisation_name || prev?.organisation_name || '',
})
}
}
return Array.from(map.values()).sort((a, b) => b.start_date.localeCompare(a.start_date))
}
export const usePortalStore = defineStore('portal', () => {
const activeEventId = ref<string | null>(readStoredActiveEventId())
const userEvents = ref<PortalEvent[]>([])
const currentPerson = ref<PortalPersonPayload | null>(null)
const isLoadingEvents = ref(false)
const isLoadingPerson = ref(false)
const loadError = ref<string | null>(null)
const activeEvent = computed(() => userEvents.value.find(e => e.event_id === activeEventId.value) ?? null)
function persistActiveEvent(): void {
writeStoredActiveEventId(activeEventId.value)
}
function persistEvents(): void {
writeStoredEvents(userEvents.value)
}
/**
* 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.
*/
function savePendingEventFromRegistration(event: PortalEvent): void {
const merged = mergeEvents([], [...readStoredEvents(), ...userEvents.value, event], false)
userEvents.value = merged
persistEvents()
if (!activeEventId.value || !merged.some(e => e.event_id === activeEventId.value)) {
activeEventId.value = event.event_id
persistActiveEvent()
}
}
async function loadUserEventsFromApiAndStorage(): Promise<void> {
isLoadingEvents.value = true
loadError.value = null
try {
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)
persistEvents()
}
catch (e) {
loadError.value = 'Kon je evenementen niet laden.'
userEvents.value = readStoredEvents()
}
finally {
isLoadingEvents.value = false
}
}
function resolveActiveEventId(): void {
if (userEvents.value.length === 0) {
activeEventId.value = null
persistActiveEvent()
return
}
const current = activeEventId.value
if (current && userEvents.value.some(e => e.event_id === current)) {
persistActiveEvent()
return
}
activeEventId.value = userEvents.value[0]!.event_id
persistActiveEvent()
}
async function fetchCurrentPerson(): Promise<void> {
currentPerson.value = null
const eid = activeEventId.value
if (!eid) return
isLoadingPerson.value = true
try {
const { data } = await apiClient.get<{ success: boolean; data: PortalPersonPayload }>(
'/portal/me',
{ params: { event_id: eid } },
)
currentPerson.value = data.data
const status = data.data.status
const pid = data.data.id
userEvents.value = userEvents.value.map(row =>
row.event_id === eid ? { ...row, person_id: pid, person_status: status } : row,
)
persistEvents()
}
catch (err) {
if (import.meta.env.DEV) {
console.warn('[portal] fetchCurrentPerson failed for event_id:', eid, err)
}
currentPerson.value = null
}
finally {
isLoadingPerson.value = false
}
}
const isHydrated = ref(false)
let hydratePromise: Promise<void> | null = null
async function hydrateAfterAuth(): Promise<void> {
await loadUserEventsFromApiAndStorage()
resolveActiveEventId()
await fetchCurrentPerson()
isHydrated.value = true
}
/**
* Hydrate portal data if not already done. Safe to call multiple times —
* only the first call triggers the actual hydration.
*/
function hydrateIfNeeded(): Promise<void> {
if (isHydrated.value) return Promise.resolve()
if (!hydratePromise)
hydratePromise = hydrateAfterAuth().finally(() => { hydratePromise = null })
return hydratePromise
}
function setActiveEvent(eventId: string): void {
if (!userEvents.value.some(e => e.event_id === eventId)) return
activeEventId.value = eventId
persistActiveEvent()
void fetchCurrentPerson()
}
function reset(): void {
activeEventId.value = null
userEvents.value = []
currentPerson.value = null
loadError.value = null
isHydrated.value = false
hydratePromise = null
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(STORAGE_EVENTS)
localStorage.removeItem(STORAGE_ACTIVE_EVENT)
}
}
return {
activeEventId,
userEvents,
currentPerson,
activeEvent,
isLoadingEvents,
isLoadingPerson,
isHydrated,
loadError,
savePendingEventFromRegistration,
hydrateAfterAuth,
hydrateIfNeeded,
setActiveEvent,
fetchCurrentPerson,
reset,
}
})