style(app): apply eslint --fix to Tier 2 (TypeScript plumbing)

WS-3 session 1b-i Tier 2.

Scope: composables, lib, stores, plugins, types, utils, navigation,
main.ts. Mechanical fixes only — predominantly newline-before-return,
arrow-parens, antfu/if-newline, padding-line-between-statements, plus
one unicorn/prefer-includes (.some(p => x === p) → .includes(x))
in router guards.

Excludes (per session prompt):
- apps/app/vite.config.ts (Tier 3)
- apps/app/themeConfig.ts (Tier 3)
- apps/app/vitest.config.ts (Tier 3)
- All .vue files (already in Tier 1)

Hand-reviewed diffs for the three auth/router-critical files before
committing:
- src/lib/axios.ts: reviewed clean. Pure mechanical (quote-props on
  Accept header, curly-strip on single-statement ifs, one blank line
  before impersonationStore.clearState()). No type-import changes,
  no logic touched.
- src/stores/useAuthStore.ts: reviewed clean. curly-strip + padding
  before returns. The initialize()/doInitialize() race-condition guard
  on isInitialized is preserved verbatim.
- src/plugins/1.router/guards.ts: reviewed clean. if-newline reformat
  + one .some() → .includes() rewrite that's behaviorally identical
  for primitive equality on the guestOnlyPaths string array.

Tests + typecheck verified green post-fix:
- apps/app vitest: 49 passed (unchanged)
- apps/app vue-tsc: clean (unchanged)

Lint baseline progression:
- Pre-Tier-2: 422 problems (post-Tier-1)
- Post-Tier-2: 246 problems

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 11:06:46 +02:00
parent 47bd533179
commit a7eaf0f948
28 changed files with 189 additions and 97 deletions

View File

@@ -42,6 +42,7 @@ export function useUpdateProfile() {
'/me/profile',
payload,
)
return data
},
onSuccess: () => {
@@ -57,6 +58,7 @@ export function useChangePassword() {
'/me/change-password',
payload,
)
return data
},
})
@@ -69,6 +71,7 @@ export function useChangeEmail() {
'/me/change-email',
payload,
)
return data
},
})
@@ -81,6 +84,7 @@ export function useAdminChangeEmail(orgId: Ref<string>) {
`/organisations/${orgId.value}/members/${userId}/change-email`,
{ new_email: newEmail },
)
return data
},
})

View File

@@ -39,6 +39,7 @@ export function useAdminOrganisations(params: Ref<Record<string, string | number
'/admin/organisations',
{ params: params.value },
)
return data
},
})
@@ -51,6 +52,7 @@ export function useAdminOrganisation(id: Ref<string>) {
const { data } = await apiClient.get<ApiResponse<AdminOrganisationDetail>>(
`/admin/organisations/${id.value}`,
)
return data.data
},
enabled: () => !!id.value,
@@ -66,6 +68,7 @@ export function useUpdateAdminOrganisation() {
`/admin/organisations/${id}`,
payload,
)
return data.data
},
onSuccess: () => {
@@ -83,6 +86,7 @@ export function useCreateOrganisation() {
'/organisations',
payload,
)
return data.data
},
onSuccess: () => {
@@ -114,6 +118,7 @@ export function useAdminUsers(params: Ref<Record<string, string | number | undef
'/admin/users',
{ params: params.value },
)
return data
},
})
@@ -126,6 +131,7 @@ export function useAdminUser(id: Ref<string>) {
const { data } = await apiClient.get<ApiResponse<AdminUser>>(
`/admin/users/${id.value}`,
)
return data.data
},
enabled: () => !!id.value,
@@ -141,6 +147,7 @@ export function useUpdateAdminUser() {
`/admin/users/${id}`,
payload,
)
return data.data
},
onSuccess: () => {
@@ -171,6 +178,7 @@ export function usePlatformStats() {
const { data } = await apiClient.get<{ data: PlatformStats }>(
'/admin/stats',
)
return data.data
},
})
@@ -186,6 +194,7 @@ export function useAdminActivityLog(params: Ref<Record<string, string | number |
'/admin/activity-log',
{ params: params.value },
)
return data
},
})

View File

@@ -10,9 +10,10 @@ export function useLogin() {
return useMutation({
mutationFn: async (credentials: LoginCredentials) => {
const { data } = await apiClient.post<LoginResponse>('/auth/login', credentials)
return data
},
onSuccess: (data) => {
onSuccess: data => {
// Token is set automatically via httpOnly Set-Cookie header
authStore.setUser(data.data.user)
queryClient.setQueryData(['auth', 'me'], data.data.user)
@@ -27,12 +28,14 @@ export function useMe() {
queryKey: ['auth', 'me'],
queryFn: async () => {
const { data } = await apiClient.get<{ success: boolean; data: MeResponse }>('/auth/me')
return data.data
},
staleTime: Infinity,
staleTime: Number.POSITIVE_INFINITY,
enabled: () => authStore.isAuthenticated,
select: (data) => {
select: data => {
authStore.setUser(data)
return data
},
})

View File

@@ -20,7 +20,7 @@ export function useCompanies(orgId: Ref<string>) {
return data.data
},
enabled: () => !!orgId.value,
staleTime: Infinity,
staleTime: Number.POSITIVE_INFINITY,
})
}

View File

@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { Ref } from 'vue'
import { apiClient } from '@/lib/axios'
import type { CrowdList, CreateCrowdListDto, UpdateCrowdListDto } from '@/types/crowdList'
import type { CreateCrowdListDto, CrowdList, UpdateCrowdListDto } from '@/types/crowdList'
import type { Person } from '@/types/person'
interface ApiResponse<T> {

View File

@@ -31,7 +31,7 @@ export function useCrowdTypeList(orgId: Ref<string>) {
return data.data
},
enabled: () => !!orgId.value,
staleTime: Infinity,
staleTime: Number.POSITIVE_INFINITY,
})
}
@@ -44,6 +44,7 @@ export function useCreateCrowdType(orgId: Ref<string>) {
`/organisations/${orgId.value}/crowd-types`,
payload,
)
return data.data
},
onSuccess: () => {
@@ -61,6 +62,7 @@ export function useUpdateCrowdType(orgId: Ref<string>) {
`/organisations/${orgId.value}/crowd-types/${id}`,
payload,
)
return data.data
},
onSuccess: () => {

View File

@@ -145,12 +145,18 @@ export function useEmailLogs(orgId: Ref<string>, filters: Ref<EmailLogFilters>)
per_page: filters.value.perPage,
}
if (filters.value.search) params.search = filters.value.search
if (filters.value.status) params.status = filters.value.status
if (filters.value.templateType) params.template_type = filters.value.templateType
if (filters.value.eventId) params.event_id = filters.value.eventId
if (filters.value.from) params.from = filters.value.from
if (filters.value.to) params.to = filters.value.to
if (filters.value.search)
params.search = filters.value.search
if (filters.value.status)
params.status = filters.value.status
if (filters.value.templateType)
params.template_type = filters.value.templateType
if (filters.value.eventId)
params.event_id = filters.value.eventId
if (filters.value.from)
params.from = filters.value.from
if (filters.value.to)
params.to = filters.value.to
const { data } = await apiClient.get<ApiResponse<PaginatedResponse<EmailLog>>>(
`/organisations/${orgId.value}/email-logs`,

View File

@@ -34,6 +34,7 @@ export function useEventList(orgId: Ref<string>) {
`/organisations/${orgId.value}/events`,
{ params: { include_children: true } },
)
return data.data
},
enabled: () => !!orgId.value,
@@ -47,6 +48,7 @@ export function useEventDetail(orgId: Ref<string>, id: Ref<string>) {
const { data } = await apiClient.get<ApiResponse<EventItem>>(
`/organisations/${orgId.value}/events/${id.value}`,
)
return data.data
},
enabled: () => !!orgId.value && !!id.value,
@@ -60,6 +62,7 @@ export function useEventChildren(orgId: Ref<string>, eventId: Ref<string>) {
const { data } = await apiClient.get<PaginatedResponse<EventItem>>(
`/organisations/${orgId.value}/events/${eventId.value}/children`,
)
return data.data
},
enabled: () => !!orgId.value && !!eventId.value,
@@ -75,6 +78,7 @@ export function useCreateEvent(orgId: Ref<string>) {
`/organisations/${orgId.value}/events`,
payload,
)
return data.data
},
onSuccess: () => {
@@ -92,6 +96,7 @@ export function useCreateSubEvent(orgId: Ref<string>, parentEventId: Ref<string>
`/organisations/${orgId.value}/events`,
payload,
)
return data.data
},
onSuccess: () => {
@@ -124,6 +129,7 @@ export function useUpdateEvent(orgId: Ref<string>, id: Ref<string>) {
`/organisations/${orgId.value}/events/${id.value}`,
payload,
)
return data.data
},
onSuccess: () => {
@@ -142,6 +148,7 @@ export function useTransitionEventStatus(orgId: Ref<string>, eventId: Ref<string
`/organisations/${orgId.value}/events/${eventId.value}/transition`,
{ status },
)
return data.data
},
onSuccess: () => {
@@ -158,6 +165,7 @@ export function useUploadEventImage(orgId: Ref<string>, eventId: Ref<string>) {
return useMutation({
mutationFn: async ({ file, type }: { file: File; type: 'banner' | 'logo' }) => {
const formData = new FormData()
formData.append('image', file)
formData.append('type', type)
@@ -182,6 +190,7 @@ export function useEventStats(orgId: Ref<string>, eventId: Ref<string>) {
const { data } = await apiClient.get<{ data: EventStats }>(
`/organisations/${orgId.value}/events/${eventId.value}/stats`,
)
return data.data
},
enabled: () => !!orgId.value && !!eventId.value,

View File

@@ -53,6 +53,7 @@ export function useMembersList(scope: MemberScope, orgId: Ref<string>) {
const url = endpointList(scope, orgId.value)
if (scope === 'platform') {
const { data } = await apiClient.get<ApiResponse<{ members: Member[] }>>(url)
return {
data: data.data.members,
meta: {
@@ -63,6 +64,7 @@ export function useMembersList(scope: MemberScope, orgId: Ref<string>) {
} satisfies MemberListResponse
}
const { data } = await apiClient.get<MemberListResponse>(url)
return data
},
enabled: () => !!orgId.value,
@@ -75,6 +77,7 @@ export function useInviteMember(scope: MemberScope, orgId: Ref<string>) {
return useMutation({
mutationFn: async (payload: InviteMemberPayload) => {
const { data } = await apiClient.post<ApiResponse<Member>>(endpointInvite(scope, orgId.value), payload)
return data.data
},
onSuccess: () => invalidateScopedKeys(scope, orgId, queryClient),
@@ -90,6 +93,7 @@ export function useUpdateMemberRole(scope: MemberScope, orgId: Ref<string>) {
endpointMember(scope, orgId.value, userId),
{ role },
)
return data.data
},
onSuccess: () => invalidateScopedKeys(scope, orgId, queryClient),
@@ -125,6 +129,7 @@ export function useInvitationDetail(token: Ref<string>) {
queryKey: ['invitation', token],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<InvitationDetailResponse>>(`/invitations/${token.value}`)
return data.data
},
enabled: () => !!token.value,
@@ -136,6 +141,7 @@ export function useAcceptInvitation() {
return useMutation({
mutationFn: async ({ token, ...payload }: AcceptInvitationPayload & { token: string }) => {
const { data } = await apiClient.post<AcceptInvitationResponse>(`/invitations/${token}/accept`, payload)
return data
},
})

View File

@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, type Ref } from 'vue'
import { type Ref, computed } from 'vue'
import { apiClient } from '@/lib/axios'
import { useAuthStore } from '@/stores/useAuthStore'
import type {
@@ -30,6 +30,7 @@ export function useOrganisationList() {
queryKey: ['organisations'],
queryFn: async () => {
const { data } = await apiClient.get<PaginatedResponse<Organisation>>('/organisations')
return data.data
},
})
@@ -40,6 +41,7 @@ export function useOrganisationDetail(id: Ref<string>) {
queryKey: ['organisations', id],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<Organisation>>(`/organisations/${id.value}`)
return data.data
},
enabled: () => !!id.value,
@@ -54,6 +56,7 @@ export function useMyOrganisation() {
queryKey: ['organisations', id],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<Organisation>>(`/organisations/${id.value}`)
return data.data
},
enabled: () => !!id.value,
@@ -66,6 +69,7 @@ export function useUpdateOrganisation() {
return useMutation({
mutationFn: async ({ id, ...payload }: UpdateOrganisationPayload & { id: string }) => {
const { data } = await apiClient.put<ApiResponse<Organisation>>(`/organisations/${id}`, payload)
return data.data
},
onSuccess: (_data, variables) => {
@@ -81,6 +85,7 @@ export function useOrganisationDashboardStats(id: Ref<string>) {
queryKey: ['organisation-dashboard-stats', id],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<OrganisationDashboardStats>>(`/organisations/${id.value}/dashboard-stats`)
return data.data
},
enabled: () => !!id.value,

View File

@@ -20,7 +20,7 @@ export function usePersonTags(orgId: Ref<string>) {
return data.data
},
enabled: () => !!orgId.value,
staleTime: Infinity,
staleTime: Number.POSITIVE_INFINITY,
})
}
@@ -35,7 +35,7 @@ export function usePersonTagCategories(orgId: Ref<string>) {
return data.data
},
enabled: () => !!orgId.value,
staleTime: Infinity,
staleTime: Number.POSITIVE_INFINITY,
})
}

View File

@@ -20,7 +20,7 @@ export function useRegistrationFieldTemplates(orgId: Ref<string>) {
return data.data
},
enabled: () => !!orgId.value,
staleTime: Infinity,
staleTime: Number.POSITIVE_INFINITY,
})
}

View File

@@ -24,7 +24,7 @@ export function useRegistrationFormFields(orgId: Ref<string>, eventId: Ref<strin
return data.data
},
enabled: () => !!orgId.value && !!eventId.value,
staleTime: Infinity,
staleTime: Number.POSITIVE_INFINITY,
})
}
@@ -87,22 +87,23 @@ export function useReorderRegistrationFormFields(orgId: Ref<string>, eventId: Re
ids: orderedIds,
})
},
onMutate: async (orderedIds) => {
onMutate: async orderedIds => {
await queryClient.cancelQueries({ queryKey: ['registration-form-fields', eventId.value] })
previousFields = queryClient.getQueryData<RegistrationFormField[]>(['registration-form-fields', eventId.value])
if (previousFields) {
const byId = new Map(previousFields.map(f => [f.id, f]))
const reordered = orderedIds
.map(id => byId.get(id))
.filter((f): f is RegistrationFormField => !!f)
queryClient.setQueryData(['registration-form-fields', eventId.value], reordered)
}
},
onError: () => {
if (previousFields) {
if (previousFields)
queryClient.setQueryData(['registration-form-fields', eventId.value], previousFields)
}
},
})
}

View File

@@ -111,24 +111,26 @@ export function useReorderSections(orgId: Ref<string>, eventId: Ref<string>) {
sections: orderedIds,
})
},
onMutate: async (orderedIds) => {
onMutate: async orderedIds => {
await queryClient.cancelQueries({ queryKey: ['sections', eventId.value] })
previousSections = queryClient.getQueryData<FestivalSection[]>(['sections', eventId.value])
// Optimistically update query cache so watch doesn't snap back
if (previousSections) {
const byId = new Map(previousSections.map(s => [s.id, s]))
const reordered = orderedIds
.map(id => byId.get(id))
.filter((s): s is FestivalSection => !!s)
queryClient.setQueryData(['sections', eventId.value], reordered)
}
},
onError: () => {
if (previousSections) {
if (previousSections)
queryClient.setQueryData(['sections', eventId.value], previousSections)
}
},
// No onSuccess invalidation — query cache and v-model are already in sync
})
}

View File

@@ -37,10 +37,14 @@ export function useShiftAssignmentList(
queryKey: ['shift-assignments', eventId, filters],
queryFn: async () => {
const params: Record<string, string> = {}
if (filters?.value?.shift_id) params.shift_id = filters.value.shift_id
if (filters?.value?.person_id) params.person_id = filters.value.person_id
if (filters?.value?.section_id) params.section_id = filters.value.section_id
if (filters?.value?.status) params.status = filters.value.status
if (filters?.value?.shift_id)
params.shift_id = filters.value.shift_id
if (filters?.value?.person_id)
params.person_id = filters.value.person_id
if (filters?.value?.section_id)
params.section_id = filters.value.section_id
if (filters?.value?.status)
params.status = filters.value.status
const { data } = await apiClient.get<PaginatedResponse<ShiftAssignment>>(
`/organisations/${orgId.value}/events/${eventId.value}/shift-assignments`,

View File

@@ -21,8 +21,10 @@ export function useTimeSlotList(orgId: Ref<string>, eventId: Ref<string>, option
queryKey: ['time-slots', eventId, includeParent, includeChildren],
queryFn: async () => {
const params: Record<string, string> = {}
if (includeParent?.value) params.include_parent = 'true'
if (includeChildren?.value) params.include_children = 'true'
if (includeParent?.value)
params.include_parent = 'true'
if (includeChildren?.value)
params.include_children = 'true'
const { data } = await apiClient.get<PaginatedResponse<TimeSlot>>(
`/organisations/${orgId.value}/events/${eventId.value}/time-slots`,

View File

@@ -1,4 +1,4 @@
import { computed, type Ref } from 'vue'
import { type Ref, computed } from 'vue'
import type { EventItem } from '@/types/event'
import type { FestivalSection } from '@/types/section'
import type { TimeSlot } from '@/types/timeSlot'
@@ -24,20 +24,24 @@ export function useTimeSlotDropdown(
section: Ref<FestivalSection | null | undefined>,
) {
const scenario = computed<DropdownScenario>(() => {
if (!event.value) return 'flat'
if (!event.value)
return 'flat'
const isSubEvent = !!event.value.parent_event_id
const hasChildren = event.value.has_children
const isCrossEvent = section.value?.type === 'cross_event'
// Flat event — no hierarchy
if (!isSubEvent && !hasChildren) return 'flat'
if (!isSubEvent && !hasChildren)
return 'flat'
// Cross_event section — needs all time slots
if (isCrossEvent) return 'cross_event'
if (isCrossEvent)
return 'cross_event'
// Standard section on sub-event — own + parent
if (isSubEvent) return 'sub_event_standard'
if (isSubEvent)
return 'sub_event_standard'
// Standard section on festival level — own only
return 'festival_standard'
@@ -61,12 +65,12 @@ export function useTimeSlotDropdown(
case 'cross_event':
return {
main: `${sectionName} is een festival-brede sectie — actief bij elk programmaonderdeel. Je kunt tijdsloten kiezen van ${festivalName} en van alle programmaonderdelen.`,
tip: `Plan diensten per programmaonderdeel (bijv. een showavond) of festival-breed (bijv. opbouw, nachtsecurity).`,
tip: 'Plan diensten per programmaonderdeel (bijv. een showavond) of festival-breed (bijv. opbouw, nachtsecurity).',
}
case 'festival_standard':
return {
main: `Kies een tijdslot van ${eventName} voor deze dienst.`,
tip: `Alleen tijdsloten op festivalniveau zijn beschikbaar voor deze sectie.`,
tip: 'Alleen tijdsloten op festivalniveau zijn beschikbaar voor deze sectie.',
}
default:
return null
@@ -93,9 +97,8 @@ export function useTimeSlotDropdown(
* in the template by comparing adjacent items' groupName.
*/
function sortedItems(timeSlots: TimeSlot[]): TimeSlotDropdownItem[] {
if (scenario.value === 'flat') {
if (scenario.value === 'flat')
return timeSlots.map(ts => toDropdownItem(ts, false, ''))
}
// Classify each slot into a group and determine isOwn per group
const groups = new Map<string, { slots: TimeSlot[]; isOwn: boolean }>()
@@ -105,6 +108,7 @@ export function useTimeSlotDropdown(
const isOwn = scenario.value === 'sub_event_standard'
? ts.source === 'sub_event'
: ts.source === 'own'
groups.set(key, { slots: [], isOwn })
}
groups.get(key)!.slots.push(ts)
@@ -112,17 +116,19 @@ export function useTimeSlotDropdown(
// Own group first, then others alphabetically
const sorted = [...groups.entries()].sort(([nameA, a], [nameB, b]) => {
if (a.isOwn && !b.isOwn) return -1
if (!a.isOwn && b.isOwn) return 1
if (a.isOwn && !b.isOwn)
return -1
if (!a.isOwn && b.isOwn)
return 1
return nameA.localeCompare(nameB)
})
const items: TimeSlotDropdownItem[] = []
for (const [groupName, { slots, isOwn }] of sorted) {
const isDimmed = scenario.value === 'sub_event_standard' && !isOwn
for (const ts of slots) {
for (const ts of slots)
items.push(toDropdownItem(ts, isDimmed, groupName))
}
}
return items
@@ -140,6 +146,7 @@ export function useTimeSlotDropdown(
function toDropdownItem(ts: TimeSlot, isDimmed: boolean, groupName: string): TimeSlotDropdownItem {
const timeRange = `${ts.start_time} ${ts.end_time}`
return {
id: ts.id,
name: ts.name,

View File

@@ -4,10 +4,12 @@ import { isAxiosError } from 'axios'
* Human-readable message from Laravel API validation / exception responses.
*/
export function getApiErrorMessage(error: unknown, fallback: string): string {
if (!isAxiosError(error)) return fallback
if (!isAxiosError(error))
return fallback
const data = error.response?.data as Record<string, unknown> | undefined
if (!data) return fallback
if (!data)
return fallback
const errors = data.errors
if (errors && typeof errors === 'object' && errors !== null && !Array.isArray(errors)) {
@@ -15,12 +17,14 @@ export function getApiErrorMessage(error: unknown, fallback: string): string {
if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'string')
return value[0]
if (typeof value === 'string') return value
if (typeof value === 'string')
return value
}
}
const message = data.message
if (typeof message === 'string' && message.trim() !== '') return message
if (typeof message === 'string' && message.trim() !== '')
return message
return fallback
}

View File

@@ -7,7 +7,7 @@ const apiClient: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL,
withCredentials: true,
headers: {
Accept: 'application/json',
'Accept': 'application/json',
'Content-Type': 'application/json',
},
timeout: 30000,
@@ -17,9 +17,8 @@ apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const orgStore = useOrganisationStore()
if (orgStore.activeOrganisationId) {
if (orgStore.activeOrganisationId)
config.headers['X-Organisation-Id'] = orgStore.activeOrganisationId
}
// Add impersonation header when active
// Lazy import to avoid circular dependency with store
@@ -27,18 +26,16 @@ apiClient.interceptors.request.use(
if (impersonationData) {
try {
const parsed = JSON.parse(impersonationData) as { targetUserId?: string }
if (parsed.targetUserId) {
if (parsed.targetUserId)
config.headers['X-Impersonate-User'] = parsed.targetUserId
}
}
catch {
// Invalid data — ignore
}
}
if (import.meta.env.DEV) {
if (import.meta.env.DEV)
console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data)
}
return config
},
@@ -47,16 +44,14 @@ apiClient.interceptors.request.use(
apiClient.interceptors.response.use(
response => {
if (import.meta.env.DEV) {
if (import.meta.env.DEV)
console.log(`${response.status} ${response.config.url}`, response.data)
}
return response
},
error => {
if (import.meta.env.DEV) {
if (import.meta.env.DEV)
console.error(`${error.response?.status} ${error.config?.url}`, error.response?.data)
}
const status = error.response?.status
const notificationStore = useNotificationStore()
@@ -65,6 +60,7 @@ apiClient.interceptors.response.use(
if (status === 403 && error.response?.data?.impersonation_ended) {
import('@/stores/useImpersonationStore').then(({ useImpersonationStore }) => {
const impersonationStore = useImpersonationStore()
impersonationStore.clearState()
window.location.href = '/platform'
})
@@ -76,9 +72,8 @@ apiClient.interceptors.response.use(
// Lazy import to avoid circular dependency
import('@/stores/useAuthStore').then(({ useAuthStore }) => {
const authStore = useAuthStore()
if (authStore.isInitialized) {
if (authStore.isInitialized)
authStore.handleUnauthorized()
}
})
}
else if (status === 403) {
@@ -90,9 +85,8 @@ apiClient.interceptors.response.use(
else if (status === 422) {
// Show validation message to user; still reject so component onError handlers can react
const message = error.response?.data?.message
if (message && typeof message === 'string') {
if (message && typeof message === 'string')
notificationStore.show(message, 'error')
}
}
else if (status === 503) {
notificationStore.show('Service temporarily unavailable. Please try again later.', 'error')

View File

@@ -4,12 +4,18 @@
*/
export function dutchPlural(word: string): string {
// Words ending in -ie: add -s (editie → edities, locatie → locaties)
if (word.endsWith('ie')) return `${word}s`
if (word.endsWith('ie'))
return `${word}s`
// Words ending in -e: add -s (ronde → rondes)
if (word.endsWith('e')) return `${word}s`
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`
if (match)
return `${match[1]}${match[2]}${match[3]}en`
// Default: add -en (dag → dagen)
return `${word}en`
}

View File

@@ -7,9 +7,8 @@ export function setupGuards(router: Router) {
const authStore = useAuthStore()
// Wait for initialization to complete (only blocks on first navigation)
if (!authStore.isInitialized) {
if (!authStore.isInitialized)
await authStore.initialize()
}
if (import.meta.env.DEV) {
console.log('🔒 Router Guard:', {
@@ -24,40 +23,55 @@ export function setupGuards(router: Router) {
// Allow public routes (login, auth pages, 404) — but redirect authenticated users away from login
if (isPublic) {
const guestOnlyPaths = ['/login', '/forgot-password', '/reset-password', '/verify-email-change']
if (authStore.isAuthenticated && guestOnlyPaths.some(p => to.path === p)) {
if (import.meta.env.DEV) console.log('🔄 Redirecting logged-in user away from login page')
if (authStore.isAuthenticated && guestOnlyPaths.includes(to.path)) {
if (import.meta.env.DEV)
console.log('🔄 Redirecting logged-in user away from login page')
return { name: 'dashboard' }
}
if (import.meta.env.DEV) console.log('✅ Public route, allowing access')
if (import.meta.env.DEV)
console.log('✅ Public route, allowing access')
return
}
// Routes that opt out of auth (e.g. invitations)
if (to.meta.requiresAuth === false) {
if (import.meta.env.DEV) console.log('✅ Route does not require auth')
if (import.meta.env.DEV)
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')
if (import.meta.env.DEV)
console.log('🚫 Not authenticated, redirecting to login')
return { path: '/login', query: { to: to.fullPath } }
}
// 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')
if (import.meta.env.DEV)
console.log('🔒 MFA setup required, redirecting to security settings')
return { path: '/account-settings', query: { tab: 'security' } }
}
// Platform admin routes — require super_admin role
if (to.path.startsWith('/platform')) {
if (!authStore.isSuperAdmin) {
if (import.meta.env.DEV) console.log('🚫 Not a super admin, redirecting to dashboard')
if (import.meta.env.DEV)
console.log('🚫 Not a super admin, redirecting to dashboard')
return { name: 'dashboard' }
}
// Platform routes don't require organisation selection
if (import.meta.env.DEV) console.log('✅ Super admin access to platform route')
if (import.meta.env.DEV)
console.log('✅ Super admin access to platform route')
return
}
@@ -66,16 +80,21 @@ export function setupGuards(router: Router) {
const isSelectOrgPage = to.path === '/select-organisation'
if (isSelectOrgPage) {
if (import.meta.env.DEV) console.log('✅ Organisation selection page')
if (import.meta.env.DEV)
console.log('✅ Organisation selection page')
return
}
// If user has organisations but none selected → redirect to selection
if (authStore.organisations.length > 0 && !orgStore.hasOrganisation) {
if (import.meta.env.DEV) console.log('🔄 No organisation selected, redirecting')
if (import.meta.env.DEV)
console.log('🔄 No organisation selected, redirecting')
return { path: '/select-organisation' }
}
if (import.meta.env.DEV) console.log('✅ Access granted')
if (import.meta.env.DEV)
console.log('✅ Access granted')
})
}

View File

@@ -18,6 +18,7 @@ export const useAuthStore = defineStore('auth', () => {
const currentOrganisation = computed(() => {
const orgStore = useOrganisationStore()
return organisations.value.find(o => o.id === orgStore.activeOrganisationId)
?? organisations.value[0]
?? null
@@ -43,13 +44,13 @@ export const useAuthStore = defineStore('auth', () => {
// Auto-select first organisation if none is active
const orgStore = useOrganisationStore()
if (!orgStore.activeOrganisationId && me.organisations.length > 0) {
if (!orgStore.activeOrganisationId && me.organisations.length > 0)
orgStore.setActiveOrganisation(me.organisations[0].id)
}
}
function setActiveOrganisation(id: string) {
const orgStore = useOrganisationStore()
orgStore.setActiveOrganisation(id)
}
@@ -61,19 +62,20 @@ export const useAuthStore = defineStore('auth', () => {
mfaSetupRequired.value = false
const orgStore = useOrganisationStore()
orgStore.clear()
}
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
// a loading state that never resolves.
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
if (typeof window !== 'undefined' && window.location.pathname !== '/login')
window.location.href = '/login'
}
}
async function logout() {
@@ -95,6 +97,7 @@ export const useAuthStore = defineStore('auth', () => {
async function refreshUser(): Promise<void> {
try {
const { data } = await apiClient.get<{ success: boolean; data: MeResponse }>('/auth/me')
setUser(data.data)
}
catch {
@@ -110,16 +113,18 @@ export const useAuthStore = defineStore('auth', () => {
let initializePromise: Promise<void> | null = null
function initialize(): Promise<void> {
if (isInitialized.value) return Promise.resolve()
if (!initializePromise) {
if (isInitialized.value)
return Promise.resolve()
if (!initializePromise)
initializePromise = doInitialize()
}
return initializePromise
}
async function doInitialize(): Promise<void> {
try {
const { data } = await apiClient.get<{ success: boolean; data: MeResponse }>('/auth/me')
setUser(data.data)
}
catch {

View File

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { apiClient } from '@/lib/axios'
import type { AdminUser, ImpersonationSession, ImpersonationStartResponse, ImpersonationStatusResponse, StartImpersonationPayload } from '@/types/admin'
import type { AdminUser, ImpersonationStartResponse, ImpersonationStatusResponse, StartImpersonationPayload } from '@/types/admin'
const SESSION_STORAGE_KEY = 'crewli_impersonation'
const BROADCAST_CHANNEL_NAME = 'crewli_impersonation_sync'
@@ -27,12 +27,11 @@ export const useImpersonationStore = defineStore('impersonation', () => {
const expiresAt = computed(() => state.value?.expiresAt ? new Date(state.value.expiresAt) : null)
function persistState(): void {
if (state.value) {
if (state.value)
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(state.value))
}
else {
else
sessionStorage.removeItem(SESSION_STORAGE_KEY)
}
}
async function start(
@@ -69,12 +68,12 @@ export const useImpersonationStore = defineStore('impersonation', () => {
// Call stop WITHOUT the X-Impersonate-User header
// The interceptor won't add it because we clear state first
const currentState = state.value
state.value = null
persistState()
if (currentState) {
if (currentState)
await apiClient.post('/admin/stop-impersonation')
}
}
catch {
// Even if API call fails, state is already cleared
@@ -130,7 +129,8 @@ export const useImpersonationStore = defineStore('impersonation', () => {
}
function listenForBroadcasts(): void {
if (broadcastChannel) return
if (broadcastChannel)
return
try {
broadcastChannel = new BroadcastChannel(BROADCAST_CHANNEL_NAME)

View File

@@ -25,7 +25,8 @@ export const useShiftDetailStore = defineStore('shiftDetail', () => {
function toggleAssignmentSelection(id: string) {
const idx = selectedAssignmentIds.value.indexOf(id)
if (idx === -1) selectedAssignmentIds.value.push(id)
if (idx === -1)
selectedAssignmentIds.value.push(id)
else selectedAssignmentIds.value.splice(idx, 1)
}

View File

@@ -1,3 +1,5 @@
import type { Member } from './member'
export type BillingStatus = 'trial' | 'active' | 'suspended' | 'cancelled'
export interface AdminOrganisation {
@@ -97,8 +99,6 @@ export interface StartImpersonationPayload {
mfa_method: 'totp' | 'email' | 'backup_code'
}
import type { Member } from './member'
export interface AdminOrganisationDetail {
organisation: AdminOrganisation
members: Member[]

View File

@@ -16,6 +16,7 @@ export interface EventItem {
name: string
slug: string
status: EventStatus
/** Valid next statuses from the API state machine (detail + transition responses). */
allowed_transitions?: EventStatus[]
event_type: EventTypeEnum

View File

@@ -78,6 +78,7 @@ export interface FormSchemaSummary {
is_published: boolean
version: number
updated_at: string | null
// Both counts are always present in the index response because the
// controller calls ->withCount(['fields', 'submissions']).
submissions_count: number
@@ -121,8 +122,10 @@ export interface FormSchema {
fields_count: number
submissions_count: number | null
has_submissions: boolean | null
// TODO(PR-b3): replace with FormField[] once organizer field types land
fields: unknown[]
// TODO(PR-b3): replace with FormSchemaSection[] once organizer section types land
sections: unknown[]
created_at: string | null

View File

@@ -1,4 +1,4 @@
import type { TimeSlot, PersonType, TimeSlotSource } from '@/types/timeSlot'
import type { PersonType, TimeSlot, TimeSlotSource } from '@/types/timeSlot'
export type { TimeSlot, PersonType, TimeSlotSource }
export type { CreateTimeSlotPayload, UpdateTimeSlotPayload } from '@/types/timeSlot'
@@ -71,7 +71,6 @@ export interface CreateSectionPayload {
export interface UpdateSectionPayload extends Partial<CreateSectionPayload> {}
export interface CreateShiftPayload {
time_slot_id: string
location_id?: string