diff --git a/api/bootstrap/app.php b/api/bootstrap/app.php index 42401be..3221a57 100644 --- a/api/bootstrap/app.php +++ b/api/bootstrap/app.php @@ -2,10 +2,15 @@ declare(strict_types=1); +use Illuminate\Auth\AuthenticationException; +use Illuminate\Database\QueryException; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; +use Illuminate\Validation\ValidationException; +use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; return Application::configure(basePath: dirname(__DIR__)) @@ -23,13 +28,63 @@ return Application::configure(basePath: dirname(__DIR__)) ]); }) ->withExceptions(function (Exceptions $exceptions): void { - // Return JSON for all API exceptions + // Database connection / query errors → 503 + $exceptions->render(function (QueryException|PDOException $e, Request $request) { + if ($request->expectsJson() || $request->is('api/*')) { + Log::error('Database error', [ + 'exception' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + $response = ['message' => 'Service temporarily unavailable. Please try again later.']; + + if (config('app.debug')) { + $response['debug'] = [ + 'exception' => get_class($e), + 'message' => $e->getMessage(), + ]; + } + + return response()->json($response, 503); + } + }); + + // 404 Not Found → friendly message $exceptions->render(function (NotFoundHttpException $e, Request $request) { - if ($request->is('api/*')) { + if ($request->expectsJson() || $request->is('api/*')) { return response()->json([ - 'success' => false, - 'message' => 'Resource not found', + 'message' => 'Resource not found.', ], 404); } }); + + // All other unhandled exceptions → 500 + // (ValidationException, AuthenticationException, and HttpException are handled by Laravel) + $exceptions->render(function (Throwable $e, Request $request) { + if ($request->expectsJson() || $request->is('api/*')) { + if ($e instanceof ValidationException + || $e instanceof AuthenticationException + || $e instanceof HttpException) { + return null; // Let Laravel handle these normally + } + + Log::error('Unhandled exception', [ + 'exception' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + $response = ['message' => 'An unexpected error occurred.']; + + if (config('app.debug')) { + $response['debug'] = [ + 'exception' => get_class($e), + 'message' => $e->getMessage(), + ]; + } + + return response()->json($response, 500); + } + }); })->create(); diff --git a/apps/app/src/App.vue b/apps/app/src/App.vue index 63f77e8..e92e9cf 100644 --- a/apps/app/src/App.vue +++ b/apps/app/src/App.vue @@ -4,27 +4,53 @@ import ScrollToTop from '@core/components/ScrollToTop.vue' import initCore from '@core/initCore' import { initConfigStore, useConfigStore } from '@core/stores/config' import { hexToRgb } from '@core/utils/colorConverter' -import { useMe } from '@/composables/api/useAuth' +import { useAuthStore } from '@/stores/useAuthStore' +import { useNotificationStore } from '@/stores/useNotificationStore' const { global } = useTheme() -// ℹ️ Sync current theme with initial loader theme initCore() initConfigStore() const configStore = useConfigStore() +const authStore = useAuthStore() +const notificationStore = useNotificationStore() -// Hydrate auth store on page load (token survives in localStorage, user data does not) -useMe() +// Validate stored token on app startup — must complete before rendering protected content +authStore.initialize() diff --git a/apps/app/src/components/members/EditMemberRoleDialog.vue b/apps/app/src/components/members/EditMemberRoleDialog.vue index 85a9cd6..a5b2766 100644 --- a/apps/app/src/components/members/EditMemberRoleDialog.vue +++ b/apps/app/src/components/members/EditMemberRoleDialog.vue @@ -72,22 +72,22 @@ function onSubmit() { max-width="500" > - - - -
- {{ member.name }} -
-
- {{ member.email }} -
-
-
+ + + + +
+ {{ member.name }} +
+
+ {{ member.email }} +
+
+
- - -
- - - - Annuleren - - - - - - Opslaan - - +
+ + + + Annuleren + + + + + + Opslaan + + +
diff --git a/apps/app/src/components/members/InviteMemberDialog.vue b/apps/app/src/components/members/InviteMemberDialog.vue index 78d56e0..09e3607 100644 --- a/apps/app/src/components/members/InviteMemberDialog.vue +++ b/apps/app/src/components/members/InviteMemberDialog.vue @@ -76,11 +76,11 @@ function onSubmit() { @after-leave="resetForm" > - - + + - - - - - - Annuleren - - - Uitnodigen - - + + + + + Annuleren + + + Uitnodigen + + + diff --git a/apps/app/src/components/organisations/EditOrganisationDialog.vue b/apps/app/src/components/organisations/EditOrganisationDialog.vue index 562fae2..eab6a92 100644 --- a/apps/app/src/components/organisations/EditOrganisationDialog.vue +++ b/apps/app/src/components/organisations/EditOrganisationDialog.vue @@ -55,36 +55,37 @@ function onSubmit() { max-width="450" > - - + + - - - - - - Annuleren - - - Opslaan - - + + + + + Annuleren + + + Opslaan + + + diff --git a/apps/app/src/components/sections/AssignShiftDialog.vue b/apps/app/src/components/sections/AssignShiftDialog.vue index cbd5287..386ecf1 100644 --- a/apps/app/src/components/sections/AssignShiftDialog.vue +++ b/apps/app/src/components/sections/AssignShiftDialog.vue @@ -116,6 +116,7 @@ function onSubmit() { :error-messages="errors.person_id" clearable no-data-text="Geen goedgekeurde personen gevonden" + autocomplete="one-time-code" > diff --git a/apps/app/src/components/sections/EditSectionDialog.vue b/apps/app/src/components/sections/EditSectionDialog.vue new file mode 100644 index 0000000..ed81da9 --- /dev/null +++ b/apps/app/src/components/sections/EditSectionDialog.vue @@ -0,0 +1,205 @@ + + + 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 @@ + + + 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,