feat(router): context-aware guards with meta-driven role/context resolution
Rewrites plugins/1.router/guards.ts per ARCH-CONSOLIDATION §4.3. The
B1 portal-context carve-out is removed; portal/organizer routing is
now declarative via meta.context, role gates via meta.requiresRole.
Guard pipeline:
1. Initialize auth store on first navigation
2. Public routes pass through (authenticated user on guest-only path
is bounced to resolveLandingRoute)
3. Auth required → /login?to=<path>
4. MFA setup gate → /account-settings?tab=security
5. requiresRole declarative check (replaces hardcoded /platform path
prefix + isSuperAdmin)
6. Context routing — portal returns early, organizer falls through
and sets lastContext
7. Org-selection check (organizer routes only)
Page meta updates (mechanical, idempotent):
- 4 portal pages: removed `requiresAuth: true` (auth is implicit)
- 4 pages: replaced `requiresAuth: false` with `meta.public: true`
(registreren, wachtwoord-instellen, advance/[token],
invitations/[token])
- 22 organizer pages: added `context: 'organizer'`
(account-settings, events/**, organisation/form-failures/**,
select-organisation, dashboard, events/index, members,
organisation/{index,companies,settings})
- 8 platform pages: added `context: 'organizer'` +
`requiresRole: 'super_admin'`
- 6 organizer pages had no definePage block — one was added with
`context: 'organizer'`
Adds plugins/1.router/__tests__/guards.spec.ts (11 tests) covering
public passthrough, unauthenticated redirect, portal/organizer
context branching, declarative requiresRole, org-selection
redirect, MFA gate.
Test count 178 → 189 (11 new). Lint + typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import NotificationsTab from '@/components/account-settings/NotificationsTab.vue
|
|||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
navActiveLink: 'account-settings',
|
navActiveLink: 'account-settings',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAuthStore } from '@/stores/useAuthStore'
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
|
||||||
|
definePage({
|
||||||
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import EventTabsNav from '@/components/events/EventTabsNav.vue'
|
|||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
navActiveLink: 'events',
|
navActiveLink: 'events',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import EventTabsNav from '@/components/events/EventTabsNav.vue'
|
|||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
navActiveLink: 'events',
|
navActiveLink: 'events',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { CrowdList } from '@/types/crowdList'
|
|||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
navActiveLink: 'events',
|
navActiveLink: 'events',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { EventItem, EventStatus } from '@/types/event'
|
|||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
navActiveLink: 'events',
|
navActiveLink: 'events',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { Person, PersonStatus } from '@/types/person'
|
|||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
navActiveLink: 'events',
|
navActiveLink: 'events',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { EventItem, EventStatus } from '@/types/event'
|
|||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
navActiveLink: 'events',
|
navActiveLink: 'events',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import SectionsShiftsPanel from '@/components/sections/SectionsShiftsPanel.vue'
|
|||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
navActiveLink: 'events',
|
navActiveLink: 'events',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { EventItem, UpdateEventPayload } from '@/types/event'
|
|||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
navActiveLink: 'events',
|
navActiveLink: 'events',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type { EventItem } from '@/types/event'
|
|||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
navActiveLink: 'events',
|
navActiveLink: 'events',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { EventItem } from '@/types/event'
|
|||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
navActiveLink: 'events',
|
navActiveLink: 'events',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import { useAuthStore } from '@/stores/useAuthStore'
|
|||||||
import CreateEventDialog from '@/components/events/CreateEventDialog.vue'
|
import CreateEventDialog from '@/components/events/CreateEventDialog.vue'
|
||||||
import type { EventItem, EventStatus } from '@/types/event'
|
import type { EventItem, EventStatus } from '@/types/event'
|
||||||
|
|
||||||
|
definePage({
|
||||||
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type { ApiErrorResponse } from '@/types/auth'
|
|||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
layout: 'blank',
|
layout: 'blank',
|
||||||
requiresAuth: false,
|
public: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ import { useOrganisationStore } from '@/stores/useOrganisationStore'
|
|||||||
import InviteMemberDialog from '@/components/members/InviteMemberDialog.vue'
|
import InviteMemberDialog from '@/components/members/InviteMemberDialog.vue'
|
||||||
import type { Member, OrganisationRole } from '@/types/member'
|
import type { Member, OrganisationRole } from '@/types/member'
|
||||||
|
|
||||||
|
definePage({
|
||||||
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const orgStore = useOrganisationStore()
|
const orgStore = useOrganisationStore()
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import { useOrganisationStore } from '@/stores/useOrganisationStore'
|
|||||||
import CompanyDialog from '@/components/organisation/CompanyDialog.vue'
|
import CompanyDialog from '@/components/organisation/CompanyDialog.vue'
|
||||||
import type { Company } from '@/types/organisation'
|
import type { Company } from '@/types/organisation'
|
||||||
|
|
||||||
|
definePage({
|
||||||
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const orgStore = useOrganisationStore()
|
const orgStore = useOrganisationStore()
|
||||||
|
|
||||||
const orgId = computed(() => orgStore.activeOrganisationId ?? '')
|
const orgId = computed(() => orgStore.activeOrganisationId ?? '')
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useOrganisationStore } from '@/stores/useOrganisationStore'
|
|||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
navActiveLink: 'organisation-form-failures',
|
navActiveLink: 'organisation-form-failures',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useOrganisationStore } from '@/stores/useOrganisationStore'
|
|||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
navActiveLink: 'organisation-form-failures',
|
navActiveLink: 'organisation-form-failures',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import { useAuthStore } from '@/stores/useAuthStore'
|
|||||||
import EditOrganisationDialog from '@/components/organisations/EditOrganisationDialog.vue'
|
import EditOrganisationDialog from '@/components/organisations/EditOrganisationDialog.vue'
|
||||||
import type { ActivityLogEntry, Organisation } from '@/types/organisation'
|
import type { ActivityLogEntry, Organisation } from '@/types/organisation'
|
||||||
|
|
||||||
|
definePage({
|
||||||
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ import SettingsEmailTemplates from '@/components/organisation/settings/SettingsE
|
|||||||
import SettingsEmailLog from '@/components/organisation/settings/SettingsEmailLog.vue'
|
import SettingsEmailLog from '@/components/organisation/settings/SettingsEmailLog.vue'
|
||||||
import DangerZoneTab from '@/components/organisation/settings/DangerZoneTab.vue'
|
import DangerZoneTab from '@/components/organisation/settings/DangerZoneTab.vue'
|
||||||
|
|
||||||
|
definePage({
|
||||||
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const orgStore = useOrganisationStore()
|
const orgStore = useOrganisationStore()
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useAdminActivityLog } from '@/composables/api/useAdmin'
|
|||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
|
requiresRole: 'super_admin',
|
||||||
navActiveLink: 'platform-activity-log',
|
navActiveLink: 'platform-activity-log',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import FormFailureDetail from '@/components/form-failures/FormFailureDetail.vue'
|
|||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
|
requiresRole: 'super_admin',
|
||||||
navActiveLink: 'platform-form-failures',
|
navActiveLink: 'platform-form-failures',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import FormFailuresTable from '@/components/form-failures/FormFailuresTable.vue'
|
|||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
|
requiresRole: 'super_admin',
|
||||||
navActiveLink: 'platform-form-failures',
|
navActiveLink: 'platform-form-failures',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { BillingStatus } from '@/types/admin'
|
|||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
|
requiresRole: 'super_admin',
|
||||||
navActiveLink: 'platform',
|
navActiveLink: 'platform',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import type { InviteMemberPayload, OrganisationRole } from '@/types/member'
|
|||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
|
requiresRole: 'super_admin',
|
||||||
navActiveLink: 'platform-organisations',
|
navActiveLink: 'platform-organisations',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { AdminOrganisation, BillingStatus, CreateOrganisationPayload } from
|
|||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
|
requiresRole: 'super_admin',
|
||||||
navActiveLink: 'platform-organisations',
|
navActiveLink: 'platform-organisations',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type { UpdateAdminUserPayload } from '@/types/admin'
|
|||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
|
requiresRole: 'super_admin',
|
||||||
navActiveLink: 'platform-users',
|
navActiveLink: 'platform-users',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type { AdminUser } from '@/types/admin'
|
|||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
|
requiresRole: 'super_admin',
|
||||||
navActiveLink: 'platform-users',
|
navActiveLink: 'platform-users',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ definePage({
|
|||||||
name: 'artist-advance',
|
name: 'artist-advance',
|
||||||
meta: {
|
meta: {
|
||||||
layout: 'PortalLayout',
|
layout: 'PortalLayout',
|
||||||
requiresAuth: false,
|
public: true,
|
||||||
requiresToken: true,
|
|
||||||
context: 'portal',
|
context: 'portal',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ definePage({
|
|||||||
name: 'portal-event-detail',
|
name: 'portal-event-detail',
|
||||||
meta: {
|
meta: {
|
||||||
layout: 'PortalLayout',
|
layout: 'PortalLayout',
|
||||||
requiresAuth: true,
|
|
||||||
context: 'portal',
|
context: 'portal',
|
||||||
navMode: 'event',
|
navMode: 'event',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ definePage({
|
|||||||
name: 'portal-evenementen',
|
name: 'portal-evenementen',
|
||||||
meta: {
|
meta: {
|
||||||
layout: 'PortalLayout',
|
layout: 'PortalLayout',
|
||||||
requiresAuth: true,
|
|
||||||
context: 'portal',
|
context: 'portal',
|
||||||
navMode: 'platform',
|
navMode: 'platform',
|
||||||
navTitle: 'Mijn evenementen',
|
navTitle: 'Mijn evenementen',
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ definePage({
|
|||||||
name: 'portal-profiel',
|
name: 'portal-profiel',
|
||||||
meta: {
|
meta: {
|
||||||
layout: 'PortalLayout',
|
layout: 'PortalLayout',
|
||||||
requiresAuth: true,
|
|
||||||
context: 'portal',
|
context: 'portal',
|
||||||
navMode: 'platform',
|
navMode: 'platform',
|
||||||
navTitle: 'Mijn profiel',
|
navTitle: 'Mijn profiel',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ definePage({
|
|||||||
name: 'volunteer-register-info',
|
name: 'volunteer-register-info',
|
||||||
meta: {
|
meta: {
|
||||||
layout: 'PortalLayout',
|
layout: 'PortalLayout',
|
||||||
requiresAuth: false,
|
public: true,
|
||||||
context: 'portal',
|
context: 'portal',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ definePage({
|
|||||||
name: 'portal-shifts',
|
name: 'portal-shifts',
|
||||||
meta: {
|
meta: {
|
||||||
layout: 'PortalLayout',
|
layout: 'PortalLayout',
|
||||||
requiresAuth: true,
|
|
||||||
context: 'portal',
|
context: 'portal',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ definePage({
|
|||||||
name: 'set-password',
|
name: 'set-password',
|
||||||
meta: {
|
meta: {
|
||||||
layout: 'PortalLayout',
|
layout: 'PortalLayout',
|
||||||
requiresAuth: false,
|
public: true,
|
||||||
context: 'portal',
|
context: 'portal',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useOrganisationStore } from '@/stores/useOrganisationStore'
|
|||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
meta: {
|
meta: {
|
||||||
|
context: 'organizer',
|
||||||
layout: 'blank',
|
layout: 'blank',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
269
apps/app/src/plugins/1.router/__tests__/guards.spec.ts
Normal file
269
apps/app/src/plugins/1.router/__tests__/guards.spec.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import type { NavigationGuard, RouteLocationNormalized, Router } from 'vue-router'
|
||||||
|
import type { MeResponse } from '@/types/auth'
|
||||||
|
|
||||||
|
vi.mock('@/lib/axios', () => ({
|
||||||
|
apiClient: { get: vi.fn(), post: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/deviceFingerprint', () => ({
|
||||||
|
generateDeviceFingerprint: () => 'test-fingerprint',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { setupGuards } = await import('@/plugins/1.router/guards')
|
||||||
|
const { useAuthStore } = await import('@/stores/useAuthStore')
|
||||||
|
const { useOrganisationStore } = await import('@/stores/useOrganisationStore')
|
||||||
|
|
||||||
|
function captureGuard(): NavigationGuard {
|
||||||
|
let guard: NavigationGuard | null = null
|
||||||
|
|
||||||
|
const router = {
|
||||||
|
beforeEach: (fn: NavigationGuard) => {
|
||||||
|
guard = fn
|
||||||
|
},
|
||||||
|
} as unknown as Router
|
||||||
|
|
||||||
|
setupGuards(router)
|
||||||
|
|
||||||
|
if (!guard)
|
||||||
|
throw new Error('guard not captured')
|
||||||
|
|
||||||
|
return guard
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRoute(overrides: Partial<RouteLocationNormalized> = {}): RouteLocationNormalized {
|
||||||
|
return {
|
||||||
|
path: '/',
|
||||||
|
fullPath: '/',
|
||||||
|
name: undefined,
|
||||||
|
meta: {},
|
||||||
|
params: {},
|
||||||
|
query: {},
|
||||||
|
hash: '',
|
||||||
|
matched: [],
|
||||||
|
redirectedFrom: undefined,
|
||||||
|
...overrides,
|
||||||
|
} as RouteLocationNormalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrate(me: Partial<MeResponse> = {}): void {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
auth.setUser({
|
||||||
|
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: [],
|
||||||
|
...me,
|
||||||
|
})
|
||||||
|
|
||||||
|
// initialize() is guarded by isInitialized; flip it directly so the
|
||||||
|
// guard's first-step initialize() short-circuits without touching
|
||||||
|
// the mocked apiClient.
|
||||||
|
;(auth as unknown as { isInitialized: boolean }).isInitialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('router guards (WS-3 PR-B2a)', () => {
|
||||||
|
let guard: NavigationGuard
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
localStorage.clear()
|
||||||
|
guard = captureGuard()
|
||||||
|
|
||||||
|
// Default: store starts uninitialized; tests opt-in via hydrate()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
;(auth as unknown as { isInitialized: boolean }).isInitialized = true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('public routes pass through', async () => {
|
||||||
|
const result = await guard(
|
||||||
|
makeRoute({ path: '/login', meta: { public: true } }),
|
||||||
|
makeRoute(),
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects unauthenticated user to login with `?to=` query', async () => {
|
||||||
|
const result = await guard(
|
||||||
|
makeRoute({ path: '/events', fullPath: '/events', meta: {} }),
|
||||||
|
makeRoute(),
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual({ name: 'login', query: { to: '/events' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('portal route passes when user has portal context', async () => {
|
||||||
|
hydrate({
|
||||||
|
contexts: { available: ['portal'], default: 'portal' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await guard(
|
||||||
|
makeRoute({ path: '/portal/evenementen', meta: { context: 'portal' } }),
|
||||||
|
makeRoute(),
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
expect(useAuthStore().lastContext).toBe('portal')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('portal route forbidden for organizer-only user', async () => {
|
||||||
|
hydrate({
|
||||||
|
organisations: [{ id: '01', name: 'Org', slug: 'org', role: 'org_admin' }],
|
||||||
|
app_roles: ['org_admin'],
|
||||||
|
contexts: { available: ['organizer'], default: 'organizer' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pre-select org so the guard doesn't redirect to select-organisation.
|
||||||
|
useOrganisationStore().setActiveOrganisation('01')
|
||||||
|
|
||||||
|
const result = await guard(
|
||||||
|
makeRoute({ path: '/portal/evenementen', meta: { context: 'portal' } }),
|
||||||
|
makeRoute(),
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual({ name: 'forbidden' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('organizer route sets lastContext to organizer for multi-role user', async () => {
|
||||||
|
hydrate({
|
||||||
|
organisations: [{ id: '01', name: 'Org', slug: 'org', role: 'org_admin' }],
|
||||||
|
app_roles: ['org_admin'],
|
||||||
|
contexts: { available: ['portal', 'organizer'], default: 'organizer' },
|
||||||
|
})
|
||||||
|
useOrganisationStore().setActiveOrganisation('01')
|
||||||
|
|
||||||
|
const result = await guard(
|
||||||
|
makeRoute({ path: '/events', meta: { context: 'organizer' } }),
|
||||||
|
makeRoute(),
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
expect(useAuthStore().lastContext).toBe('organizer')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('platform route forbidden for non-super_admin (declarative requiresRole)', async () => {
|
||||||
|
hydrate({
|
||||||
|
organisations: [{ id: '01', name: 'Org', slug: 'org', role: 'org_admin' }],
|
||||||
|
app_roles: ['org_admin'],
|
||||||
|
contexts: { available: ['organizer'], default: 'organizer' },
|
||||||
|
})
|
||||||
|
useOrganisationStore().setActiveOrganisation('01')
|
||||||
|
|
||||||
|
const result = await guard(
|
||||||
|
makeRoute({
|
||||||
|
path: '/platform/users',
|
||||||
|
meta: { context: 'organizer', requiresRole: 'super_admin' },
|
||||||
|
}),
|
||||||
|
makeRoute(),
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual({ name: 'forbidden' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('platform route allowed for super_admin', async () => {
|
||||||
|
hydrate({
|
||||||
|
organisations: [],
|
||||||
|
app_roles: ['super_admin'],
|
||||||
|
contexts: { available: ['organizer'], default: 'organizer' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await guard(
|
||||||
|
makeRoute({
|
||||||
|
path: '/platform/users',
|
||||||
|
meta: { context: 'organizer', requiresRole: 'super_admin' },
|
||||||
|
}),
|
||||||
|
makeRoute(),
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects to select-organisation when org user has no active org', async () => {
|
||||||
|
hydrate({
|
||||||
|
organisations: [
|
||||||
|
{ id: '01', name: 'Org A', slug: 'a', role: 'org_admin' },
|
||||||
|
{ id: '02', name: 'Org B', slug: 'b', role: 'org_admin' },
|
||||||
|
],
|
||||||
|
app_roles: ['org_admin'],
|
||||||
|
contexts: { available: ['organizer'], default: 'organizer' },
|
||||||
|
})
|
||||||
|
useOrganisationStore().clear()
|
||||||
|
|
||||||
|
const result = await guard(
|
||||||
|
makeRoute({ path: '/events', fullPath: '/events', meta: { context: 'organizer' } }),
|
||||||
|
makeRoute(),
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual({ path: '/select-organisation', query: { to: '/events' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not redirect portal-context route to select-organisation', async () => {
|
||||||
|
hydrate({
|
||||||
|
organisations: [{ id: '01', name: 'Org', slug: 'org', role: 'org_admin' }],
|
||||||
|
app_roles: ['org_admin'],
|
||||||
|
contexts: { available: ['portal', 'organizer'], default: 'organizer' },
|
||||||
|
})
|
||||||
|
useOrganisationStore().clear()
|
||||||
|
|
||||||
|
const result = await guard(
|
||||||
|
makeRoute({ path: '/portal/evenementen', meta: { context: 'portal' } }),
|
||||||
|
makeRoute(),
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects authenticated user away from login back to landing route', async () => {
|
||||||
|
hydrate({
|
||||||
|
contexts: { available: ['portal'], default: 'portal' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await guard(
|
||||||
|
makeRoute({ path: '/login', meta: { public: true } }),
|
||||||
|
makeRoute(),
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual({ path: '/portal/evenementen' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('MFA setup gate redirects to /account-settings when mfaSetupRequired', async () => {
|
||||||
|
hydrate({
|
||||||
|
organisations: [{ id: '01', name: 'Org', slug: 'org', role: 'org_admin' }],
|
||||||
|
app_roles: ['org_admin'],
|
||||||
|
mfa: { enabled: false, method: null, confirmed_at: null, setup_required: true },
|
||||||
|
contexts: { available: ['organizer'], default: 'organizer' },
|
||||||
|
})
|
||||||
|
useOrganisationStore().setActiveOrganisation('01')
|
||||||
|
|
||||||
|
const result = await guard(
|
||||||
|
makeRoute({ path: '/events', meta: { context: 'organizer' } }),
|
||||||
|
makeRoute(),
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual({ path: '/account-settings', query: { tab: 'security' } })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,109 +3,83 @@ import { useAuthStore } from '@/stores/useAuthStore'
|
|||||||
import { useOrganisationStore } from '@/stores/useOrganisationStore'
|
import { useOrganisationStore } from '@/stores/useOrganisationStore'
|
||||||
|
|
||||||
export function setupGuards(router: Router) {
|
export function setupGuards(router: Router) {
|
||||||
router.beforeEach(async (to, from) => {
|
router.beforeEach(async to => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
// Wait for initialization to complete (only blocks on first navigation)
|
// Step 1 — ensure store is initialized (only blocks on first navigation).
|
||||||
if (!authStore.isInitialized)
|
if (!authStore.isInitialized)
|
||||||
await authStore.initialize()
|
await authStore.initialize()
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.log('🔒 Router Guard:', {
|
console.log('🔒 Router Guard:', {
|
||||||
to: to.path,
|
to: to.path,
|
||||||
from: from.path,
|
|
||||||
isAuthenticated: authStore.isAuthenticated,
|
isAuthenticated: authStore.isAuthenticated,
|
||||||
|
context: to.meta.context,
|
||||||
|
requiresRole: to.meta.requiresRole,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPublic = to.meta.public === true
|
// Step 2 — public routes pass through. Authenticated users hitting a
|
||||||
|
// guest-only public page (login, password flows) get bounced back to
|
||||||
// Allow public routes (login, auth pages, 404) — but redirect authenticated users away from login
|
// their landing route to avoid the awkward "log in again" dead-end.
|
||||||
if (isPublic) {
|
if (to.meta.public === true) {
|
||||||
const guestOnlyPaths = ['/login', '/forgot-password', '/reset-password', '/verify-email-change']
|
const guestOnlyPaths = ['/login', '/forgot-password', '/reset-password', '/verify-email-change']
|
||||||
if (authStore.isAuthenticated && guestOnlyPaths.includes(to.path)) {
|
if (authStore.isAuthenticated && guestOnlyPaths.includes(to.path))
|
||||||
if (import.meta.env.DEV)
|
return authStore.resolveLandingRoute()
|
||||||
console.log('🔄 Redirecting logged-in user away from login page')
|
|
||||||
|
|
||||||
return { name: 'dashboard' }
|
return true
|
||||||
}
|
|
||||||
if (import.meta.env.DEV)
|
|
||||||
console.log('✅ Public route, allowing access')
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Routes that opt out of auth (e.g. invitations)
|
// Step 3 — auth required. Save target as `?to=` so we can resume after login.
|
||||||
if (to.meta.requiresAuth === false) {
|
if (!authStore.isAuthenticated)
|
||||||
if (import.meta.env.DEV)
|
return { name: 'login', query: { to: to.fullPath } }
|
||||||
console.log('✅ Route does not require auth')
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not authenticated → redirect to login with return URL
|
|
||||||
if (!authStore.isAuthenticated) {
|
|
||||||
if (import.meta.env.DEV)
|
|
||||||
console.log('🚫 Not authenticated, redirecting to login')
|
|
||||||
|
|
||||||
return { path: '/login', query: { to: to.fullPath } }
|
|
||||||
}
|
|
||||||
|
|
||||||
// WS-3 PR-B1 carve-out: portal-context routes (volunteers, artists,
|
|
||||||
// crew) don't participate in the organizer's MFA / platform-role /
|
|
||||||
// organisation-selection checks below. PR-B2 replaces this with full
|
|
||||||
// context-aware guards (per ARCH-CONSOLIDATION-2026-04 §4.3).
|
|
||||||
if (to.meta.context === 'portal') {
|
|
||||||
if (import.meta.env.DEV)
|
|
||||||
console.log('✅ Portal-context route, allowing access (B1 carve-out)')
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// MFA enforcement — redirect to security settings if MFA setup is required
|
|
||||||
if (authStore.mfaSetupRequired && to.path !== '/account-settings') {
|
|
||||||
if (import.meta.env.DEV)
|
|
||||||
console.log('🔒 MFA setup required, redirecting to security settings')
|
|
||||||
|
|
||||||
|
// Step 4 — MFA setup gate. The MFA-setup flow lives in account-settings
|
||||||
|
// under `?tab=security`; let users reach there to complete setup.
|
||||||
|
if (authStore.mfaSetupRequired && to.path !== '/account-settings')
|
||||||
return { path: '/account-settings', query: { tab: 'security' } }
|
return { path: '/account-settings', query: { tab: 'security' } }
|
||||||
|
|
||||||
|
// Step 5 — declarative role check (replaces the legacy hard-coded
|
||||||
|
// `path.startsWith('/platform') && !isSuperAdmin` branch).
|
||||||
|
if (to.meta.requiresRole) {
|
||||||
|
const required = Array.isArray(to.meta.requiresRole)
|
||||||
|
? to.meta.requiresRole as string[]
|
||||||
|
: [to.meta.requiresRole as string]
|
||||||
|
|
||||||
|
if (!authStore.hasAnyRole(required))
|
||||||
|
return { name: 'forbidden' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Platform admin routes — require super_admin role
|
// Step 6 — context routing. Portal-context routes don't participate
|
||||||
if (to.path.startsWith('/platform')) {
|
// in the org-selection check below; organizer-context falls through.
|
||||||
if (!authStore.isSuperAdmin) {
|
if (to.meta.context === 'portal') {
|
||||||
if (import.meta.env.DEV)
|
if (!authStore.availableContexts.includes('portal'))
|
||||||
console.log('🚫 Not a super admin, redirecting to dashboard')
|
return { name: 'forbidden' }
|
||||||
|
|
||||||
return { name: 'dashboard' }
|
authStore.setLastContext('portal')
|
||||||
}
|
|
||||||
|
|
||||||
// Platform routes don't require organisation selection
|
return true
|
||||||
if (import.meta.env.DEV)
|
|
||||||
console.log('✅ Super admin access to platform route')
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticated — check organisation selection for routes that need it
|
if (to.meta.context === 'organizer') {
|
||||||
|
if (!authStore.availableContexts.includes('organizer'))
|
||||||
|
return { name: 'forbidden' }
|
||||||
|
|
||||||
|
authStore.setLastContext('organizer')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7 — organizer-context org-selection. Skipped for portal routes
|
||||||
|
// (returned at Step 6), super_admin without orgs (no org to select),
|
||||||
|
// and the select-organisation page itself.
|
||||||
const orgStore = useOrganisationStore()
|
const orgStore = useOrganisationStore()
|
||||||
const isSelectOrgPage = to.path === '/select-organisation'
|
const isSelectOrgPage = to.path === '/select-organisation'
|
||||||
|
|
||||||
if (isSelectOrgPage) {
|
if (isSelectOrgPage)
|
||||||
if (import.meta.env.DEV)
|
return true
|
||||||
console.log('✅ Organisation selection page')
|
|
||||||
|
|
||||||
return
|
if (authStore.organisations.length > 0 && !orgStore.hasOrganisation)
|
||||||
}
|
return { path: '/select-organisation', query: { to: to.fullPath } }
|
||||||
|
|
||||||
// If user has organisations but none selected → redirect to selection
|
return true
|
||||||
if (authStore.organisations.length > 0 && !orgStore.hasOrganisation) {
|
|
||||||
if (import.meta.env.DEV)
|
|
||||||
console.log('🔄 No organisation selected, redirecting')
|
|
||||||
|
|
||||||
return { path: '/select-organisation' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.env.DEV)
|
|
||||||
console.log('✅ Access granted')
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user