diff --git a/apps/app/.eslintrc.cjs b/apps/app/.eslintrc.cjs
index 8da0dda4..7ff5661b 100644
--- a/apps/app/.eslintrc.cjs
+++ b/apps/app/.eslintrc.cjs
@@ -238,7 +238,13 @@ module.exports = {
// previously owned the cross-zone call (WS-3 PR-B2a).
{ from: 'stores', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'stores-portal'] },
{ from: 'navigation', allow: ['types', 'utils', 'navigation'] },
- { from: 'components-shared', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'components-shared'] },
+
+ // components-shared may read app-wide stores (useAuthStore,
+ // useNotificationStore — not stores-portal) so canonical shared
+ // chrome (ContextSwitcher, future global indicators) can stay in
+ // components/shared/ without re-homing to components/layout/.
+ // Portal-specific state stays out of shared by design (WS-3 PR-B2a).
+ { from: 'components-shared', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'components-shared'] },
{ from: 'components-portal', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'stores-portal', 'components-shared', 'components-portal'] },
{ from: 'components-organizer', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'components-shared', 'components-organizer'] },
{ from: 'components', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'components', 'components-shared', 'components-organizer'] },
diff --git a/apps/app/src/components/shared/ContextSwitcher.vue b/apps/app/src/components/shared/ContextSwitcher.vue
new file mode 100644
index 00000000..ef46227b
--- /dev/null
+++ b/apps/app/src/components/shared/ContextSwitcher.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+ {{ currentContext === 'portal' ? 'Portal' : 'Organizer' }}
+
+
+
+
+
+
+
+
+ Wissel naar {{ otherContext === 'portal' ? 'portal' : 'organizer' }}
+
+
+
+
+
diff --git a/apps/app/src/components/shared/__tests__/ContextSwitcher.spec.ts b/apps/app/src/components/shared/__tests__/ContextSwitcher.spec.ts
new file mode 100644
index 00000000..acca214e
--- /dev/null
+++ b/apps/app/src/components/shared/__tests__/ContextSwitcher.spec.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+
+const mockSetLastContext = vi.fn()
+const mockResolveLandingRoute = vi.fn(() => ({ path: '/portal/evenementen' }))
+const mockPush = vi.fn()
+
+const authStoreState: Record = {
+ showContextSwitcher: true,
+ setLastContext: mockSetLastContext,
+ resolveLandingRoute: mockResolveLandingRoute,
+}
+
+vi.mock('@/stores/useAuthStore', () => ({
+ useAuthStore: () => authStoreState,
+}))
+
+vi.mock('vue-router', async importOriginal => ({
+ ...(await importOriginal()),
+ useRoute: () => ({ meta: { context: 'organizer' } }),
+ useRouter: () => ({ push: mockPush }),
+}))
+
+const ContextSwitcher = (await import('../ContextSwitcher.vue')).default
+
+const stubs = {
+ VMenu: { template: '
' },
+ VBtn: { template: '' },
+ VList: { template: '
' },
+ VListItem: {
+ props: ['dataTest'],
+ template: '
',
+ emits: ['click'],
+ },
+ VListItemTitle: { template: '' },
+ VIcon: true,
+}
+
+describe('ContextSwitcher', () => {
+ it('renders when showContextSwitcher is true', () => {
+ authStoreState.showContextSwitcher = true
+
+ const wrapper = mount(ContextSwitcher, { global: { stubs } })
+
+ expect(wrapper.find('[data-test="menu"]').exists()).toBe(true)
+ })
+
+ it('does not render when showContextSwitcher is false (single-context user)', () => {
+ authStoreState.showContextSwitcher = false
+
+ const wrapper = mount(ContextSwitcher, { global: { stubs } })
+
+ expect(wrapper.find('[data-test="menu"]').exists()).toBe(false)
+ })
+
+ it('clicking the alternative context calls setLastContext and router.push', async () => {
+ authStoreState.showContextSwitcher = true
+ mockSetLastContext.mockClear()
+ mockResolveLandingRoute.mockClear()
+ mockPush.mockClear()
+
+ const wrapper = mount(ContextSwitcher, { global: { stubs } })
+
+ // current context is 'organizer' (from mocked route.meta.context),
+ // so the dropdown shows 'switch-to-portal'.
+ await wrapper.find('[data-test="switch-to-portal"]').trigger('click')
+
+ expect(mockSetLastContext).toHaveBeenCalledWith('portal')
+ expect(mockResolveLandingRoute).toHaveBeenCalledWith('portal')
+ expect(mockPush).toHaveBeenCalledWith({ path: '/portal/evenementen' })
+ })
+})
diff --git a/apps/app/src/layouts/PortalLayout.vue b/apps/app/src/layouts/PortalLayout.vue
index 6c482b54..151a29ba 100644
--- a/apps/app/src/layouts/PortalLayout.vue
+++ b/apps/app/src/layouts/PortalLayout.vue
@@ -8,6 +8,7 @@
import { useRoute, useRouter } from 'vue-router'
import type AppLoadingIndicator from '@/components/AppLoadingIndicator.vue'
+import ContextSwitcher from '@/components/shared/ContextSwitcher.vue'
import UserAvatarMenu from '@/components/portal/UserAvatarMenu.vue'
import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalStore } from '@/stores/portal/usePortalStore'
@@ -138,8 +139,9 @@ async function logout() {
-
+
+
diff --git a/apps/app/src/layouts/components/DefaultLayoutWithVerticalNav.vue b/apps/app/src/layouts/components/DefaultLayoutWithVerticalNav.vue
index a80c309e..f6e09810 100644
--- a/apps/app/src/layouts/components/DefaultLayoutWithVerticalNav.vue
+++ b/apps/app/src/layouts/components/DefaultLayoutWithVerticalNav.vue
@@ -11,6 +11,7 @@ import NavSearchBar from '@/layouts/components/NavSearchBar.vue'
import NavbarShortcuts from '@/layouts/components/NavbarShortcuts.vue'
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
import UserProfile from '@/layouts/components/UserProfile.vue'
+import ContextSwitcher from '@/components/shared/ContextSwitcher.vue'
import OrganisationSwitcher from '@/components/layout/OrganisationSwitcher.vue'
// @layouts plugin
@@ -76,6 +77,7 @@ const navItems = computed(() => {
+