feat(router): wire portal/register pages, portal-context guard carve-out, lint cleanup
Routing wiring (Phase D of WS-3 PR-B1):
- apps/app/src/plugins/1.router/guards.ts: add a single early-return
carve-out before the org-selection redirect — `if (to.meta.context
=== 'portal') return`. Per ARCH-CONSOLIDATION-2026-04 §4.3,
meta.context is the canonical contract; PR-B2 evolves the guards
from this key to full context-aware logic (post-login landing,
context-switcher, role checks).
- apps/app/env.d.ts: extend RouteMeta with the new layout names
('OrganizerLayout' | 'PortalLayout' | 'PublicLayout'), context,
requiresAuth, requiresToken, navMode, navTitle.
- apps/app/typed-router.d.ts: regenerated by unplugin-vue-router to
pick up portal/* and register/* route names.
- Page meta finalisation: portal pages have layout: 'PortalLayout',
context: 'portal', preserving original requiresAuth + nav fields;
register pages have layout: 'PublicLayout' + public: true (the
apps/app guard convention for public routes, since meta.public is
what the existing guard recognises).
Form-types restructure (boundaries cleanup):
- apps/app/src/composables/forms/types/formBuilder.ts → src/types/forms/
- apps/app/src/composables/forms/utils/{formValidation,validators}.ts
→ src/utils/forms/
- All `@/composables/forms/{types,utils}/*` imports rewritten across
pages, components, composables, tests.
- This avoids a `types → composables` boundaries violation at
src/types/formSchema.ts which re-exports primitives from the
inlined form-schema. types/formSchema.ts now imports from
@/types/forms/formBuilder which is in the same boundaries zone.
Lint cleanup for moved portal sources (apps/portal had no
.eslintrc.cjs; the migrated code now has to pass apps/app's stricter
config):
- axios.isAxiosError → named import { isAxiosError }
(ClaimenTab, RoosterTab, profiel.vue)
- void schemaQuery.refetch() → schemaQuery.refetch()
(register/[public_token].vue)
- if-then-else collapsed to single boolean return (formatFieldValue)
- :delay-on-touch-only="true" → delay-on-touch-only shorthand
(FieldSectionPriority)
- ml-2 class → ms-2 (FieldAvailabilityPicker)
- multi-statement-per-line splits in profiel.vue + spec files
- unused emailConfigured ref removed (profiel.vue)
- one-component-per-file disabled with TODO TECH-WS3-PORTAL-LINT-CLEANUP
ref (FieldOptionsLocale.spec.ts — multi-Wrapper test pattern)
- restored `import Draggable from 'vuedraggable'` after lint:fix
removed it (template-only usage; the import IS needed)
- camelcase param renamed in FieldOptionsLocale harness factory
- typecheck nudge: spec state.data typed via PublicFormSectionOption[] /
PublicFormTimeSlot[] aliases instead of Record<string, unknown>
- PortalLayout.vue: explicit `import { useRoute, useRouter }` so the
vitest mock can intercept (the trimmed AutoImport set doesn't pull
vue-router's auto-imports)
Vitest: 23 / 162 passing. Lint: 0 errors / 0 new warnings (only the
pre-existing boundaries v5→v6 deprecation warnings remain). Typecheck:
clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ export const usePortalAuthStore = defineStore('portalAuth', () => {
|
||||
|
||||
async function resetPortalStoresSync(): Promise<void> {
|
||||
const { usePortalStore } = await import('@/stores/portal/usePortalStore')
|
||||
|
||||
usePortalStore().reset()
|
||||
}
|
||||
|
||||
@@ -25,6 +26,7 @@ export const usePortalAuthStore = defineStore('portalAuth', () => {
|
||||
|
||||
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
|
||||
@@ -33,9 +35,8 @@ export const usePortalAuthStore = defineStore('portalAuth', () => {
|
||||
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')) {
|
||||
if (!publicPaths.some(p => path.startsWith(p)) && !path.startsWith('/register'))
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,12 +51,14 @@ export const usePortalAuthStore = defineStore('portalAuth', () => {
|
||||
|
||||
// Validate by fetching full user data
|
||||
const ok = await fetchUser()
|
||||
if (!ok) throw new Error('Sessie kon niet worden gestart.')
|
||||
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
|
||||
@@ -80,7 +83,8 @@ export const usePortalAuthStore = defineStore('portalAuth', () => {
|
||||
let initializePromise: Promise<void> | null = null
|
||||
|
||||
function initialize(): Promise<void> {
|
||||
if (isInitialized.value) return Promise.resolve()
|
||||
if (isInitialized.value)
|
||||
return Promise.resolve()
|
||||
if (!initializePromise)
|
||||
initializePromise = doInitialize()
|
||||
|
||||
|
||||
@@ -7,13 +7,16 @@ 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 []
|
||||
if (typeof localStorage === 'undefined')
|
||||
return []
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_EVENTS)
|
||||
if (!raw) return []
|
||||
if (!raw)
|
||||
return []
|
||||
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return []
|
||||
if (!Array.isArray(parsed))
|
||||
return []
|
||||
|
||||
return parsed.filter(
|
||||
(e): e is PortalEvent =>
|
||||
@@ -30,19 +33,23 @@ function readStoredEvents(): PortalEvent[] {
|
||||
}
|
||||
|
||||
function writeStoredEvents(events: PortalEvent[]): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
if (typeof localStorage === 'undefined')
|
||||
return
|
||||
localStorage.setItem(STORAGE_EVENTS, JSON.stringify(events))
|
||||
}
|
||||
|
||||
function readStoredActiveEventId(): string | null {
|
||||
if (typeof localStorage === 'undefined') return 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)
|
||||
if (typeof localStorage === 'undefined')
|
||||
return
|
||||
if (id)
|
||||
localStorage.setItem(STORAGE_ACTIVE_EVENT, id)
|
||||
else localStorage.removeItem(STORAGE_ACTIVE_EVENT)
|
||||
}
|
||||
|
||||
@@ -64,6 +71,7 @@ function mergeEvents(apiEvents: PortalEvent[], stored: PortalEvent[], apiSucceed
|
||||
// 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,
|
||||
@@ -76,6 +84,7 @@ function mergeEvents(apiEvents: PortalEvent[], stored: PortalEvent[], apiSucceed
|
||||
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,
|
||||
@@ -111,6 +120,7 @@ export const usePortalStore = defineStore('portal', () => {
|
||||
*/
|
||||
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)) {
|
||||
@@ -128,6 +138,7 @@ export const usePortalStore = defineStore('portal', () => {
|
||||
let apiSucceeded = false
|
||||
try {
|
||||
const { data } = await apiClient.get<{ success: boolean; data: AuthMeUser }>('/auth/me')
|
||||
|
||||
apiEvents = data.data.portal_events ?? []
|
||||
apiSucceeded = true
|
||||
}
|
||||
@@ -165,8 +176,10 @@ export const usePortalStore = defineStore('portal', () => {
|
||||
|
||||
async function fetchCurrentPerson(): Promise<void> {
|
||||
currentPerson.value = null
|
||||
|
||||
const eid = activeEventId.value
|
||||
if (!eid) return
|
||||
if (!eid)
|
||||
return
|
||||
|
||||
isLoadingPerson.value = true
|
||||
try {
|
||||
@@ -174,18 +187,21 @@ export const usePortalStore = defineStore('portal', () => {
|
||||
'/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) {
|
||||
if (import.meta.env.DEV)
|
||||
console.warn('[portal] fetchCurrentPerson failed for event_id:', eid, err)
|
||||
}
|
||||
|
||||
currentPerson.value = null
|
||||
}
|
||||
finally {
|
||||
@@ -208,7 +224,8 @@ export const usePortalStore = defineStore('portal', () => {
|
||||
* only the first call triggers the actual hydration.
|
||||
*/
|
||||
function hydrateIfNeeded(): Promise<void> {
|
||||
if (isHydrated.value) return Promise.resolve()
|
||||
if (isHydrated.value)
|
||||
return Promise.resolve()
|
||||
if (!hydratePromise)
|
||||
hydratePromise = hydrateAfterAuth().finally(() => { hydratePromise = null })
|
||||
|
||||
@@ -216,7 +233,8 @@ export const usePortalStore = defineStore('portal', () => {
|
||||
}
|
||||
|
||||
function setActiveEvent(eventId: string): void {
|
||||
if (!userEvents.value.some(e => e.event_id === eventId)) return
|
||||
if (!userEvents.value.some(e => e.event_id === eventId))
|
||||
return
|
||||
activeEventId.value = eventId
|
||||
persistActiveEvent()
|
||||
void fetchCurrentPerson()
|
||||
|
||||
Reference in New Issue
Block a user