fix: auth race condition on refresh, section edit dialog, time slot duplicate, autocomplete disable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 11:16:22 +02:00
parent 03545c570c
commit 37fecf7181
15 changed files with 733 additions and 168 deletions

View File

@@ -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<Organisation[]>([])
const appRoles = ref<string[]>([])
const permissions = ref<string[]>([])
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<void> | null = null
function initialize(): Promise<void> {
if (isInitialized.value) return Promise.resolve()
if (!initializePromise) {
initializePromise = doInitialize()
}
return initializePromise
}
async function doInitialize(): Promise<void> {
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,
}
})

View File

@@ -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<NotificationType>('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 }
})

View File

@@ -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<string | null>(localStorage.getItem(ACTIVE_ORG_KEY))
const activeEventId = ref<string | null>(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,