+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Annuleren
+
+
+ Opslaan
+
+
+
+
+
+
diff --git a/apps/app/src/lib/axios.ts b/apps/app/src/lib/axios.ts
index 4d98bbc..8593154 100644
--- a/apps/app/src/lib/axios.ts
+++ b/apps/app/src/lib/axios.ts
@@ -1,6 +1,8 @@
import axios from 'axios'
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '@/stores/useAuthStore'
+import { useNotificationStore } from '@/stores/useNotificationStore'
+import { useOrganisationStore } from '@/stores/useOrganisationStore'
const apiClient: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL,
@@ -15,11 +17,16 @@ const apiClient: AxiosInstance = axios.create({
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const authStore = useAuthStore()
+ const orgStore = useOrganisationStore()
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`
}
+ if (orgStore.activeOrganisationId) {
+ config.headers['X-Organisation-Id'] = orgStore.activeOrganisationId
+ }
+
if (import.meta.env.DEV) {
console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data)
}
@@ -42,15 +49,40 @@ apiClient.interceptors.response.use(
console.error(`❌ ${error.response?.status} ${error.config?.url}`, error.response?.data)
}
- if (error.response?.status === 401) {
+ const status = error.response?.status
+ const notificationStore = useNotificationStore()
+
+ if (status === 401) {
const authStore = useAuthStore()
- authStore.logout()
+ // During initialization, let initialize() handle the 401 — skip redirect
+ if (authStore.isInitialized) {
+ authStore.logout()
- if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
- window.location.href = '/login'
+ if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
+ window.location.href = '/login'
+ }
}
}
+ else if (status === 403) {
+ notificationStore.show('You don\'t have permission for this action.', 'error')
+ }
+ else if (status === 404) {
+ notificationStore.show('The requested item was not found.', 'warning')
+ }
+ else if (status === 422) {
+ // Validation errors — pass through to calling component
+ }
+ else if (status === 503) {
+ notificationStore.show('Service temporarily unavailable. Please try again later.', 'error')
+ }
+ else if (status && status >= 500) {
+ notificationStore.show('An unexpected error occurred. Please try again later.', 'error')
+ }
+ else if (!error.response) {
+ // Network error — no response received
+ notificationStore.show('Unable to connect to the server. Check your internet connection.', 'error')
+ }
return Promise.reject(error)
},
diff --git a/apps/app/src/lib/dutch-plural.ts b/apps/app/src/lib/dutch-plural.ts
new file mode 100644
index 0000000..2544d90
--- /dev/null
+++ b/apps/app/src/lib/dutch-plural.ts
@@ -0,0 +1,15 @@
+/**
+ * Basic Dutch pluralisation for configurable event labels.
+ * Covers the known sub_event_label values: Dag, Programmaonderdeel, Editie, Locatie, Ronde.
+ */
+export function dutchPlural(word: string): string {
+ // Words ending in -ie: add -s (editie → edities, locatie → locaties)
+ if (word.endsWith('ie')) return `${word}s`
+ // Words ending in -e: add -s (ronde → rondes)
+ if (word.endsWith('e')) return `${word}s`
+ // Double vowel before final consonant(s): single vowel + en (onderdeel → onderdelen)
+ const match = word.match(/^(.*)([aeiou])\2([^aeiou]+)$/i)
+ if (match) return `${match[1]}${match[2]}${match[3]}en`
+ // Default: add -en (dag → dagen)
+ return `${word}en`
+}
diff --git a/apps/app/src/pages/select-organisation.vue b/apps/app/src/pages/select-organisation.vue
new file mode 100644
index 0000000..3b5cef0
--- /dev/null
+++ b/apps/app/src/pages/select-organisation.vue
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+ Kies je organisatie
+
+
+ Selecteer de organisatie waarmee je wilt werken.
+
+
+
+
+
+ Je bent nog niet gekoppeld aan een organisatie. Neem contact op met je beheerder.
+
+
+
+
+
+
+
+
+ {{ org.name.charAt(0).toUpperCase() }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app/src/plugins/1.router/guards.ts b/apps/app/src/plugins/1.router/guards.ts
index 332f89d..9d11b13 100644
--- a/apps/app/src/plugins/1.router/guards.ts
+++ b/apps/app/src/plugins/1.router/guards.ts
@@ -1,19 +1,48 @@
import type { Router } from 'vue-router'
import { useAuthStore } from '@/stores/useAuthStore'
+import { useOrganisationStore } from '@/stores/useOrganisationStore'
export function setupGuards(router: Router) {
- router.beforeEach((to) => {
+ router.beforeEach(async (to) => {
const authStore = useAuthStore()
- const isPublic = to.meta.public === true
- // Guest-only pages (login): redirect to home if already authenticated
- if (isPublic && authStore.isAuthenticated) {
- return { name: 'dashboard' }
+ // Wait for initialization to complete (only blocks on first navigation)
+ if (!authStore.isInitialized) {
+ await authStore.initialize()
}
- // Protected pages: redirect to login if not authenticated
- if (!isPublic && !authStore.isAuthenticated && to.meta.requiresAuth !== false) {
+ const isPublic = to.meta.public === true
+
+ // Allow public routes (login, 404) — but redirect authenticated users away from login
+ if (isPublic) {
+ if (authStore.isAuthenticated && to.path === '/login') {
+ return { name: 'dashboard' }
+ }
+ return
+ }
+
+ // Routes that opt out of auth (e.g. invitations)
+ if (to.meta.requiresAuth === false) {
+ return
+ }
+
+ // Not authenticated → redirect to login with return URL
+ if (!authStore.isAuthenticated) {
return { path: '/login', query: { to: to.fullPath } }
}
+
+ // Authenticated — check organisation selection for routes that need it
+ const orgStore = useOrganisationStore()
+ const isSelectOrgPage = to.path === '/select-organisation'
+
+ if (isSelectOrgPage) {
+ // Already on the org selection page — allow
+ return
+ }
+
+ // If user has organisations but none selected → redirect to selection
+ if (authStore.organisations.length > 0 && !orgStore.hasOrganisation) {
+ return { path: '/select-organisation' }
+ }
})
}
diff --git a/apps/app/src/stores/useAuthStore.ts b/apps/app/src/stores/useAuthStore.ts
index 8ae15d9..7c8bc30 100644
--- a/apps/app/src/stores/useAuthStore.ts
+++ b/apps/app/src/stores/useAuthStore.ts
@@ -1,5 +1,6 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
+import { apiClient } from '@/lib/axios'
import { useOrganisationStore } from '@/stores/useOrganisationStore'
import type { MeResponse, Organisation, User } from '@/types/auth'
@@ -11,8 +12,10 @@ export const useAuthStore = defineStore('auth', () => {
const organisations = ref([])
const appRoles = ref([])
const permissions = ref([])
+ const isInitialized = ref(false)
- const isAuthenticated = computed(() => !!token.value)
+ // Requires both a token AND a validated user — token alone is not enough
+ const isAuthenticated = computed(() => !!token.value && !!user.value)
const isSuperAdmin = computed(() => appRoles.value?.includes('super_admin') ?? false)
const currentOrganisation = computed(() => {
@@ -64,6 +67,40 @@ export const useAuthStore = defineStore('auth', () => {
orgStore.clear()
}
+ /**
+ * Called once on app startup. If a token exists in localStorage,
+ * validates it by calling GET /auth/me. On 401, clears everything.
+ * Safe to call multiple times — subsequent calls return the same promise.
+ */
+ let initializePromise: Promise | null = null
+
+ function initialize(): Promise {
+ if (isInitialized.value) return Promise.resolve()
+ if (!initializePromise) {
+ initializePromise = doInitialize()
+ }
+ return initializePromise
+ }
+
+ async function doInitialize(): Promise {
+ if (!token.value) {
+ isInitialized.value = true
+ return
+ }
+
+ try {
+ const { data } = await apiClient.get<{ success: boolean; data: MeResponse }>('/auth/me')
+ setUser(data.data)
+ }
+ catch {
+ // Token invalid/expired — clear everything
+ logout()
+ }
+ finally {
+ isInitialized.value = true
+ }
+ }
+
return {
token,
user,
@@ -71,11 +108,13 @@ export const useAuthStore = defineStore('auth', () => {
appRoles,
permissions,
isAuthenticated,
+ isInitialized,
isSuperAdmin,
currentOrganisation,
setToken,
setUser,
setActiveOrganisation,
logout,
+ initialize,
}
})
diff --git a/apps/app/src/stores/useNotificationStore.ts b/apps/app/src/stores/useNotificationStore.ts
new file mode 100644
index 0000000..bb39e84
--- /dev/null
+++ b/apps/app/src/stores/useNotificationStore.ts
@@ -0,0 +1,24 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+
+export type NotificationType = 'success' | 'error' | 'warning' | 'info'
+
+export const useNotificationStore = defineStore('notification', () => {
+ const visible = ref(false)
+ const message = ref('')
+ const type = ref('info')
+ const timeout = ref(5000)
+
+ function show(msg: string, color: NotificationType = 'error', duration = 5000) {
+ message.value = msg
+ type.value = color
+ timeout.value = duration
+ visible.value = true
+ }
+
+ function hide() {
+ visible.value = false
+ }
+
+ return { visible, message, type, timeout, show, hide }
+})
diff --git a/apps/app/src/stores/useOrganisationStore.ts b/apps/app/src/stores/useOrganisationStore.ts
index c829b73..0ab74a5 100644
--- a/apps/app/src/stores/useOrganisationStore.ts
+++ b/apps/app/src/stores/useOrganisationStore.ts
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
-import { ref } from 'vue'
+import { computed, ref } from 'vue'
const ACTIVE_ORG_KEY = 'crewli_active_org'
const ACTIVE_EVENT_KEY = 'crewli_active_event'
@@ -7,6 +7,7 @@ const ACTIVE_EVENT_KEY = 'crewli_active_event'
export const useOrganisationStore = defineStore('organisation', () => {
const activeOrganisationId = ref(localStorage.getItem(ACTIVE_ORG_KEY))
const activeEventId = ref(localStorage.getItem(ACTIVE_EVENT_KEY))
+ const hasOrganisation = computed(() => !!activeOrganisationId.value)
function setActiveOrganisation(id: string) {
activeOrganisationId.value = id
@@ -28,6 +29,7 @@ export const useOrganisationStore = defineStore('organisation', () => {
return {
activeOrganisationId,
activeEventId,
+ hasOrganisation,
setActiveOrganisation,
setActiveEvent,
clear,