Race condition: the axios 401 interceptor uses a dynamic import, so handleUnauthorized() fires AFTER doInitialize() sets isInitialized=true. handleUnauthorized() then reset isInitialized to false, leaving the app stuck on a loading spinner with no way to recover. Fix: remove isInitialized=false from handleUnauthorized() in all three apps. When handleUnauthorized() redirects via window.location.href, all JS state resets naturally. When it skips the redirect (already on a public page like /login), the app should render normally in an unauthenticated state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
116 lines
2.8 KiB
TypeScript
116 lines
2.8 KiB
TypeScript
import { defineStore } from 'pinia'
|
|
import { computed, ref } from 'vue'
|
|
import { apiClient } from '@/lib/axios'
|
|
import { getUserAbilityRules } from '@/utils/auth-ability'
|
|
import type { Rule } from '@/plugins/casl/ability'
|
|
import type { AuthUserCookie } from '@/composables/useOrganisationContext'
|
|
|
|
interface MeResponse {
|
|
id: string
|
|
first_name: string
|
|
last_name: string
|
|
full_name: string
|
|
email: string
|
|
timezone: string
|
|
locale: string
|
|
avatar: string | null
|
|
organisations: Array<{
|
|
id: string
|
|
name: string
|
|
slug: string
|
|
role: string
|
|
}>
|
|
app_roles: string[]
|
|
permissions: string[]
|
|
}
|
|
|
|
export const useAuthStore = defineStore('auth', () => {
|
|
const user = ref<AuthUserCookie | null>(null)
|
|
const abilityRules = ref<Rule[]>([])
|
|
const isInitialized = ref(false)
|
|
|
|
const isAuthenticated = computed(() => !!user.value)
|
|
|
|
function setUser(userData: AuthUserCookie, roles: string[]) {
|
|
user.value = userData
|
|
abilityRules.value = getUserAbilityRules(roles)
|
|
}
|
|
|
|
function clearState() {
|
|
user.value = null
|
|
abilityRules.value = []
|
|
}
|
|
|
|
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') {
|
|
const publicPaths = ['/login', '/forgot-password', '/reset-password', '/verify-email-change']
|
|
if (!publicPaths.some(p => window.location.pathname.startsWith(p))) {
|
|
window.location.href = '/login'
|
|
}
|
|
}
|
|
}
|
|
|
|
async function logout() {
|
|
try {
|
|
await apiClient.post('/auth/logout')
|
|
}
|
|
catch {
|
|
// Continue with logout even if API call fails
|
|
}
|
|
clearState()
|
|
}
|
|
|
|
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> {
|
|
try {
|
|
const { data } = await apiClient.get<{ success: boolean; data: MeResponse }>('/auth/me')
|
|
const me = data.data
|
|
const roles = me.app_roles ?? []
|
|
|
|
setUser(
|
|
{
|
|
id: me.id,
|
|
name: me.full_name,
|
|
email: me.email,
|
|
roles,
|
|
organisations: me.organisations,
|
|
},
|
|
roles,
|
|
)
|
|
}
|
|
catch {
|
|
clearState()
|
|
}
|
|
finally {
|
|
isInitialized.value = true
|
|
}
|
|
}
|
|
|
|
return {
|
|
user,
|
|
abilityRules,
|
|
isAuthenticated,
|
|
isInitialized,
|
|
setUser,
|
|
clearState,
|
|
logout,
|
|
handleUnauthorized,
|
|
initialize,
|
|
}
|
|
})
|