- Restyle organizer auth pages: Dutch text, remove placeholder social login - Restyle portal auth pages to Vuexy v1 centered card pattern with decorative shapes - MFA challenge card component with VOtpInput, method tabs, backup code input, trusted device checkbox, and session countdown timer - Login pages handle mfa_required response with device fingerprint header - Security settings page with TOTP setup (QR code), email setup, disable MFA, backup codes regeneration, and trusted devices management - Portal profile page includes MFA security section - Admin user detail page shows MFA status with reset button - MFA enforcement route guard redirects to security settings when required - Device fingerprint utility for trusted device identification - MFA types, composables with TanStack Query for both apps Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
135 lines
3.7 KiB
TypeScript
135 lines
3.7 KiB
TypeScript
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'
|
|
|
|
export const useAuthStore = defineStore('auth', () => {
|
|
const user = ref<User | null>(null)
|
|
const organisations = ref<Organisation[]>([])
|
|
const appRoles = ref<string[]>([])
|
|
const permissions = ref<string[]>([])
|
|
const isInitialized = ref(false)
|
|
|
|
const mfaSetupRequired = ref(false)
|
|
|
|
const isAuthenticated = computed(() => !!user.value)
|
|
const isSuperAdmin = computed(() => appRoles.value?.includes('super_admin') ?? false)
|
|
|
|
const currentOrganisation = computed(() => {
|
|
const orgStore = useOrganisationStore()
|
|
return organisations.value.find(o => o.id === orgStore.activeOrganisationId)
|
|
?? organisations.value[0]
|
|
?? null
|
|
})
|
|
|
|
function setUser(me: MeResponse) {
|
|
user.value = {
|
|
id: me.id,
|
|
first_name: me.first_name,
|
|
last_name: me.last_name,
|
|
full_name: me.full_name,
|
|
email: me.email,
|
|
timezone: me.timezone,
|
|
locale: me.locale,
|
|
avatar: me.avatar,
|
|
}
|
|
organisations.value = me.organisations
|
|
appRoles.value = me.app_roles
|
|
permissions.value = me.permissions
|
|
mfaSetupRequired.value = me.mfa?.setup_required ?? false
|
|
|
|
// Auto-select first organisation if none is active
|
|
const orgStore = useOrganisationStore()
|
|
if (!orgStore.activeOrganisationId && me.organisations.length > 0) {
|
|
orgStore.setActiveOrganisation(me.organisations[0].id)
|
|
}
|
|
}
|
|
|
|
function setActiveOrganisation(id: string) {
|
|
const orgStore = useOrganisationStore()
|
|
orgStore.setActiveOrganisation(id)
|
|
}
|
|
|
|
function clearState() {
|
|
user.value = null
|
|
organisations.value = []
|
|
appRoles.value = []
|
|
permissions.value = []
|
|
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') {
|
|
window.location.href = '/login'
|
|
}
|
|
}
|
|
|
|
async function logout() {
|
|
try {
|
|
await apiClient.post('/auth/logout')
|
|
}
|
|
catch {
|
|
// Ignore network errors; still clear local state
|
|
}
|
|
finally {
|
|
clearState()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Called once on app startup. Validates the httpOnly cookie 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> {
|
|
try {
|
|
const { data } = await apiClient.get<{ success: boolean; data: MeResponse }>('/auth/me')
|
|
setUser(data.data)
|
|
}
|
|
catch {
|
|
// Cookie invalid/expired or not present — clear everything
|
|
clearState()
|
|
}
|
|
finally {
|
|
isInitialized.value = true
|
|
}
|
|
}
|
|
|
|
return {
|
|
user,
|
|
organisations,
|
|
appRoles,
|
|
permissions,
|
|
isAuthenticated,
|
|
isInitialized,
|
|
isSuperAdmin,
|
|
currentOrganisation,
|
|
mfaSetupRequired,
|
|
setUser,
|
|
setActiveOrganisation,
|
|
logout,
|
|
handleUnauthorized,
|
|
initialize,
|
|
}
|
|
})
|