refactor(auth): merge usePortalAuthStore into useAuthStore with context-aware getters

usePortalAuthStore is deleted — its 114 lines were a slim wrapper over
the same /auth/me endpoint useAuthStore already consumes. The merged
store gains the full set of additions Bert specified for B2a:

State:
- availableContexts / defaultContext (from /auth/me contexts block)
- lastContext (localStorage-persisted)
- portalToken (in-memory only, for the bearer-axios flavour)

Getters: isPortalUser, isOrganizerUser, isPlatformAdmin (alias of
isSuperAdmin), showContextSwitcher, hasRole(), hasAnyRole().

Actions: login(), verifyMfa() — both return typed discriminated
unions so login.vue (Phase H) consumes results without branching on
raw API response shapes. setLastContext, setPortalToken,
resolveLandingRoute, clearAll. clearAll dynamically imports
usePortalStore.reset() to clear portal sessionStorage on session-end —
this is the canonical session-cleanup hub now that the merge has
happened.

5 source files migrated from usePortalAuthStore → useAuthStore. The
PortalLayout.spec.ts mock follows. The boundaries matrix gains a
single new edge (`stores → stores-portal`) replacing the deleted
stores-portal/usePortalAuthStore which previously owned that
cross-zone call.

Adds 16 vitest specs in src/stores/__tests__/useAuthStore.spec.ts
covering setUser context hydration, hasRole/hasAnyRole, lastContext
localStorage persistence, resolveLandingRoute precedence
(portal/organizer/super_admin/multi-role/forceContext/forbidden
fallback), portalToken state, and clearAll cleanup.

Test count 162 → 178 (16 new). Frontend lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 21:25:24 +02:00
parent 13d7b18257
commit f2b08ecb21
10 changed files with 537 additions and 129 deletions

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { usePortalAuthStore } from '@/stores/portal/usePortalAuthStore'
import { useAuthStore } from '@/stores/useAuthStore'
const authStore = usePortalAuthStore()
const authStore = useAuthStore()
const router = useRouter()
const userInitials = computed(() => {

View File

@@ -9,14 +9,14 @@
import { useRoute, useRouter } from 'vue-router'
import type AppLoadingIndicator from '@/components/AppLoadingIndicator.vue'
import UserAvatarMenu from '@/components/portal/UserAvatarMenu.vue'
import { usePortalAuthStore } from '@/stores/portal/usePortalAuthStore'
import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalStore } from '@/stores/portal/usePortalStore'
const { injectSkinClasses } = useSkins()
injectSkinClasses()
const authStore = usePortalAuthStore()
const authStore = useAuthStore()
const portal = usePortalStore()
const route = useRoute()
const router = useRouter()

View File

@@ -13,8 +13,8 @@ vi.mock('vue-router', async importOriginal => ({
useRoute: () => ({ meta: {} }),
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('@/stores/portal/usePortalAuthStore', () => ({
usePortalAuthStore: () => ({
vi.mock('@/stores/useAuthStore', () => ({
useAuthStore: () => ({
isAuthenticated: false,
user: null,
logout: vi.fn(),

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { isAxiosError } from 'axios'
import { usePortalAuthStore } from '@/stores/portal/usePortalAuthStore'
import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalStore } from '@/stores/portal/usePortalStore'
import { useUpdatePassword, useUpdateProfile } from '@/composables/api/portal/usePortalProfile'
import {
@@ -27,7 +27,7 @@ definePage({
},
})
const authStore = usePortalAuthStore()
const authStore = useAuthStore()
const portal = usePortalStore()
const router = useRouter()
const route = useRoute()

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useAllMyShifts } from '@/composables/api/portal/usePortalShifts'
import { usePortalAuthStore } from '@/stores/portal/usePortalAuthStore'
import { useAuthStore } from '@/stores/useAuthStore'
import type { AllMyShiftsAssignment } from '@/types/portal-shift'
definePage({
@@ -12,7 +12,7 @@ definePage({
},
})
const auth = usePortalAuthStore()
const auth = useAuthStore()
const { data: eventGroups, isLoading, isError, refetch } = useAllMyShifts()
const statusConfig: Record<string, { label: string; color: string }> = {

View File

@@ -0,0 +1,233 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import type { MeResponse } from '@/types/auth'
vi.mock('@/lib/axios', () => ({
apiClient: { get: vi.fn(), post: vi.fn() },
}))
vi.mock('@/utils/deviceFingerprint', () => ({
generateDeviceFingerprint: () => 'test-fingerprint',
}))
const useAuthStore = (await import('@/stores/useAuthStore')).useAuthStore
function makeMe(overrides: Partial<MeResponse> = {}): MeResponse {
return {
id: '01ABC',
first_name: 'Test',
last_name: 'User',
full_name: 'Test User',
date_of_birth: null,
email: 'test@example.nl',
phone: null,
timezone: 'Europe/Amsterdam',
locale: 'nl',
avatar: null,
organisations: [],
app_roles: [],
permissions: [],
...overrides,
}
}
describe('useAuthStore — context-aware additions (WS-3 PR-B2a)', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
describe('setUser populates contexts', () => {
it('uses backend contexts block when present', () => {
const store = useAuthStore()
store.setUser(makeMe({
contexts: { available: ['portal', 'organizer'], default: 'organizer' },
platform: { is_super_admin: false },
}))
expect(store.availableContexts).toEqual(['portal', 'organizer'])
expect(store.defaultContext).toBe('organizer')
expect(store.showContextSwitcher).toBe(true)
})
it('falls back to local derivation when contexts is missing', () => {
const store = useAuthStore()
store.setUser(makeMe({
organisations: [{ id: '01', name: 'Org', slug: 'org', role: 'org_admin' }],
app_roles: ['org_admin'],
}))
expect(store.availableContexts).toContain('organizer')
expect(store.defaultContext).toBe('organizer')
})
it('exposes isPortalUser / isOrganizerUser based on availableContexts', () => {
const store = useAuthStore()
store.setUser(makeMe({
contexts: { available: ['portal'], default: 'portal' },
}))
expect(store.isPortalUser).toBe(true)
expect(store.isOrganizerUser).toBe(false)
expect(store.showContextSwitcher).toBe(false)
})
})
describe('hasRole / hasAnyRole', () => {
it('checks app_roles', () => {
const store = useAuthStore()
store.setUser(makeMe({ app_roles: ['org_admin', 'event_manager'] }))
expect(store.hasRole('org_admin')).toBe(true)
expect(store.hasRole('super_admin')).toBe(false)
expect(store.hasAnyRole(['super_admin', 'event_manager'])).toBe(true)
expect(store.hasAnyRole(['super_admin', 'org_member'])).toBe(false)
})
})
describe('lastContext localStorage persistence', () => {
it('writes to localStorage on setLastContext', () => {
const store = useAuthStore()
store.setLastContext('portal')
expect(localStorage.getItem('crewli:lastContext')).toBe('portal')
expect(store.lastContext).toBe('portal')
})
it('reads from localStorage on store init', () => {
localStorage.setItem('crewli:lastContext', 'organizer')
const store = useAuthStore()
expect(store.lastContext).toBe('organizer')
})
it('rejects malformed localStorage values', () => {
localStorage.setItem('crewli:lastContext', 'invalid-junk')
const store = useAuthStore()
expect(store.lastContext).toBeNull()
})
})
describe('resolveLandingRoute precedence', () => {
it('routes to portal when only portal context is available', () => {
const store = useAuthStore()
store.setUser(makeMe({
contexts: { available: ['portal'], default: 'portal' },
}))
const target = store.resolveLandingRoute()
expect(target).toEqual({ path: '/portal/evenementen' })
})
it('routes to organizer dashboard when only organizer context is available', () => {
const store = useAuthStore()
store.setUser(makeMe({
organisations: [{ id: '01', name: 'Org', slug: 'org', role: 'org_admin' }],
app_roles: ['org_admin'],
contexts: { available: ['organizer'], default: 'organizer' },
}))
expect(store.resolveLandingRoute()).toEqual({ name: 'dashboard' })
})
it('routes to platform dashboard for super_admin without an active org', () => {
const store = useAuthStore()
store.setUser(makeMe({
organisations: [],
app_roles: ['super_admin'],
contexts: { available: ['organizer'], default: 'organizer' },
}))
expect(store.resolveLandingRoute()).toEqual({ name: 'platform' })
})
it('multi-role user — defaultContext wins when no lastContext is set', () => {
const store = useAuthStore()
store.setUser(makeMe({
organisations: [{ id: '01', name: 'Org', slug: 'org', role: 'org_admin' }],
app_roles: ['org_admin'],
contexts: { available: ['portal', 'organizer'], default: 'organizer' },
}))
expect(store.resolveLandingRoute()).toEqual({ name: 'dashboard' })
})
it('multi-role user — lastContext overrides defaultContext', () => {
localStorage.setItem('crewli:lastContext', 'portal')
const store = useAuthStore()
store.setUser(makeMe({
organisations: [{ id: '01', name: 'Org', slug: 'org', role: 'org_admin' }],
app_roles: ['org_admin'],
contexts: { available: ['portal', 'organizer'], default: 'organizer' },
}))
expect(store.resolveLandingRoute()).toEqual({ path: '/portal/evenementen' })
})
it('forceContext overrides both lastContext and defaultContext', () => {
const store = useAuthStore()
store.setUser(makeMe({
organisations: [{ id: '01', name: 'Org', slug: 'org', role: 'org_admin' }],
app_roles: ['org_admin'],
contexts: { available: ['portal', 'organizer'], default: 'organizer' },
}))
store.setLastContext('organizer')
expect(store.resolveLandingRoute('portal')).toEqual({ path: '/portal/evenementen' })
})
it('returns forbidden when user has no contexts available', () => {
const store = useAuthStore()
expect(store.resolveLandingRoute()).toEqual({ name: 'forbidden' })
})
})
describe('portalToken state', () => {
it('setPortalToken updates the in-memory token', () => {
const store = useAuthStore()
expect(store.portalToken).toBeNull()
store.setPortalToken('abc-token')
expect(store.portalToken).toBe('abc-token')
store.setPortalToken(null)
expect(store.portalToken).toBeNull()
})
})
describe('clearAll', () => {
it('clears state, lastContext localStorage, and portalToken', async () => {
const store = useAuthStore()
store.setUser(makeMe({
contexts: { available: ['portal'], default: 'portal' },
}))
store.setLastContext('portal')
store.setPortalToken('token')
await store.clearAll()
expect(store.user).toBeNull()
expect(store.availableContexts).toEqual([])
expect(store.lastContext).toBeNull()
expect(store.portalToken).toBeNull()
expect(localStorage.getItem('crewli:lastContext')).toBeNull()
})
})
})

View File

@@ -1,114 +0,0 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { apiClient } from '@/lib/axios'
import type { AuthMeUser } from '@/types/portal'
export const usePortalAuthStore = defineStore('portalAuth', () => {
const user = ref<AuthMeUser | null>(null)
const isInitialized = ref(false)
const isAuthenticated = computed(() => !!user.value)
function setUser(data: AuthMeUser | null) {
user.value = data
}
async function resetPortalStoresSync(): Promise<void> {
const { usePortalStore } = await import('@/stores/portal/usePortalStore')
usePortalStore().reset()
}
function clearState() {
user.value = null
void resetPortalStoresSync()
}
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 path = window.location.pathname
const publicPaths = ['/login', '/wachtwoord-vergeten', '/wachtwoord-resetten', '/verify-email-change']
if (!publicPaths.some(p => path.startsWith(p)) && !path.startsWith('/register'))
window.location.href = '/login'
}
}
async function login(email: string, password: string): Promise<void> {
const { data } = await apiClient.post<{
success: boolean
data: { user: AuthMeUser }
}>('/auth/login', { email, password })
// Token is set automatically via httpOnly Set-Cookie header
setUser(data.data.user)
// Validate by fetching full user data
const ok = await fetchUser()
if (!ok)
throw new Error('Sessie kon niet worden gestart.')
}
async function fetchUser(): Promise<boolean> {
try {
const { data } = await apiClient.get<{ success: boolean; data: AuthMeUser }>('/auth/me')
setUser(data.data)
return true
}
catch {
clearState()
return false
}
}
async function logout(): Promise<void> {
try {
await apiClient.post('/auth/logout')
}
catch {
// Ignore network errors; still clear local session
}
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 {
await fetchUser()
}
finally {
isInitialized.value = true
}
}
return {
user,
isAuthenticated,
isInitialized,
setUser,
login,
logout,
fetchUser,
initialize,
handleUnauthorized,
}
})

View File

@@ -1,8 +1,41 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { apiClient } from '@/lib/axios'
import { useOrganisationStore } from '@/stores/useOrganisationStore'
import type { MeResponse, Organisation, User } from '@/types/auth'
import { generateDeviceFingerprint } from '@/utils/deviceFingerprint'
import type { MfaMethod } from '@/types/mfa'
import type {
AuthContext,
ContextsBlock,
LoginCredentials,
LoginResponse,
LoginResult,
MeResponse,
MfaVerifyArgs,
MfaVerifyResult,
Organisation,
User,
} from '@/types/auth'
const LAST_CONTEXT_STORAGE_KEY = 'crewli:lastContext'
function readStoredLastContext(): AuthContext | null {
if (typeof localStorage === 'undefined')
return null
const raw = localStorage.getItem(LAST_CONTEXT_STORAGE_KEY)
return raw === 'portal' || raw === 'organizer' ? raw : null
}
function writeStoredLastContext(value: AuthContext | null): void {
if (typeof localStorage === 'undefined')
return
if (value)
localStorage.setItem(LAST_CONTEXT_STORAGE_KEY, value)
else localStorage.removeItem(LAST_CONTEXT_STORAGE_KEY)
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
@@ -10,11 +43,20 @@ export const useAuthStore = defineStore('auth', () => {
const appRoles = ref<string[]>([])
const permissions = ref<string[]>([])
const isInitialized = ref(false)
const mfaSetupRequired = ref(false)
const availableContexts = ref<AuthContext[]>([])
const defaultContext = ref<AuthContext>('portal')
const lastContext = ref<AuthContext | null>(readStoredLastContext())
const portalToken = ref<string | null>(null)
const isAuthenticated = computed(() => !!user.value)
const isSuperAdmin = computed(() => appRoles.value?.includes('super_admin') ?? false)
const isPlatformAdmin = isSuperAdmin
const isPortalUser = computed(() => availableContexts.value.includes('portal'))
const isOrganizerUser = computed(() => availableContexts.value.includes('organizer'))
const showContextSwitcher = computed(() => availableContexts.value.length >= 2)
const currentOrganisation = computed(() => {
const orgStore = useOrganisationStore()
@@ -24,6 +66,14 @@ export const useAuthStore = defineStore('auth', () => {
?? null
})
function hasRole(role: string): boolean {
return appRoles.value.includes(role)
}
function hasAnyRole(roles: string[]): boolean {
return roles.some(r => appRoles.value.includes(r))
}
function setUser(me: MeResponse) {
user.value = {
id: me.id,
@@ -42,30 +92,105 @@ export const useAuthStore = defineStore('auth', () => {
permissions.value = me.permissions
mfaSetupRequired.value = me.mfa?.setup_required ?? false
// Context block — additive in B2a; falls back to derivation when the
// backend response predates the contexts enrichment (e.g. test
// fixtures that hand-craft a MeResponse).
const contexts: ContextsBlock = me.contexts ?? deriveContextsLocally(me)
availableContexts.value = contexts.available
defaultContext.value = contexts.default
// 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 deriveContextsLocally(me: MeResponse): ContextsBlock {
const isSuper = me.app_roles?.includes('super_admin') ?? false
const hasOrgs = (me.organisations?.length ?? 0) > 0
const available: AuthContext[] = []
if (hasOrgs || isSuper)
available.push('organizer')
const fallbackDefault: AuthContext = (hasOrgs || isSuper) ? 'organizer' : 'portal'
return { available, default: fallbackDefault }
}
function setActiveOrganisation(id: string) {
const orgStore = useOrganisationStore()
orgStore.setActiveOrganisation(id)
}
function setLastContext(ctx: AuthContext) {
lastContext.value = ctx
writeStoredLastContext(ctx)
}
function setPortalToken(token: string | null) {
portalToken.value = token
}
/**
* Resolve the post-login or post-context-switch landing route. A
* `forceContext` overrides the lastContext + defaultContext precedence
* (used by the context-switcher when the user explicitly chooses).
*/
function resolveLandingRoute(forceContext?: AuthContext): RouteLocationRaw {
const ctx = forceContext ?? lastContext.value ?? defaultContext.value
if (ctx === 'portal' && availableContexts.value.includes('portal'))
return { path: '/portal/evenementen' }
if (ctx === 'organizer' && availableContexts.value.includes('organizer')) {
if (isSuperAdmin.value && organisations.value.length === 0)
return { name: 'platform' }
return { name: 'dashboard' }
}
if (availableContexts.value.includes('organizer'))
return { name: 'dashboard' }
if (availableContexts.value.includes('portal'))
return { path: '/portal/evenementen' }
return { name: 'forbidden' }
}
function clearState() {
user.value = null
organisations.value = []
appRoles.value = []
permissions.value = []
mfaSetupRequired.value = false
availableContexts.value = []
defaultContext.value = 'portal'
portalToken.value = null
const orgStore = useOrganisationStore()
orgStore.clear()
}
/**
* Full reset including the lastContext localStorage preference and
* any portal sessionStorage state. Called on logout and 401.
* Cross-zone access to stores/portal is via dynamic import (the
* boundaries matrix forbids static `stores → stores-portal`).
*/
async function clearAll(): Promise<void> {
clearState()
lastContext.value = null
writeStoredLastContext(null)
const { usePortalStore } = await import('@/stores/portal/usePortalStore')
usePortalStore().reset()
}
function handleUnauthorized() {
clearState()
@@ -78,7 +203,88 @@ export const useAuthStore = defineStore('auth', () => {
window.location.href = '/login'
}
async function logout() {
/**
* Authenticate with email + password. Returns a discriminated union
* the caller pattern-matches on — login.vue does not branch on raw
* API response shapes, only on the LoginResult kind.
*/
async function login(credentials: LoginCredentials): Promise<LoginResult> {
try {
const fingerprint = generateDeviceFingerprint()
const { data } = await apiClient.post<LoginResponse>(
'/auth/login',
credentials,
{ headers: { 'X-Device-Fingerprint': fingerprint } },
)
if (data.mfa_required) {
return {
kind: 'mfa-required',
sessionToken: data.mfa_session_token ?? '',
methods: (data.methods ?? []) as MfaMethod[],
preferredMethod: (data.preferred_method ?? 'totp') as MfaMethod,
expiresIn: data.expires_in ?? 600,
}
}
// Cookie has been set; populate the store from the login response's
// user payload. Page checks `auth.mfaSetupRequired` for routing.
setUser(data.data.user)
return { kind: 'authenticated' }
}
catch (err: unknown) {
const errorData = (err as { response?: { status?: number; data?: { message?: string; errors?: Record<string, string[]> } } }).response
if (errorData?.status === 429)
return { kind: 'failed', reason: 'rate_limited' }
return {
kind: 'failed',
reason: errorData?.data?.message ?? 'invalid_credentials',
errors: errorData?.data?.errors,
}
}
}
/**
* Verify the MFA challenge. On success, refetches /auth/me to pick up
* any backend-side state changes (e.g. trusted device registration).
*/
async function verifyMfa(args: MfaVerifyArgs): Promise<MfaVerifyResult> {
try {
await apiClient.post('/auth/mfa/verify', {
mfa_session_token: args.sessionToken,
code: args.code,
method: args.method,
trust_device: args.trustDevice,
device_fingerprint: args.deviceFingerprint,
device_name: args.deviceName,
})
// Cookie set by backend on successful verify — refresh user from
// /auth/me. refreshUser() ignores errors silently; we re-throw
// here if hydration genuinely failed.
await refreshUser()
if (!user.value)
return { kind: 'failed', reason: 'hydration_failed' }
return { kind: 'authenticated' }
}
catch (err: unknown) {
const errorData = (err as { response?: { data?: { message?: string; errors?: Record<string, string[]> } } }).response?.data
return {
kind: 'failed',
reason: errorData?.message ?? 'invalid_code',
errors: errorData?.errors,
}
}
}
async function logout(): Promise<void> {
try {
await apiClient.post('/auth/logout')
}
@@ -86,7 +292,7 @@ export const useAuthStore = defineStore('auth', () => {
// Ignore network errors; still clear local state
}
finally {
clearState()
await clearAll()
}
}
@@ -105,6 +311,9 @@ export const useAuthStore = defineStore('auth', () => {
}
}
/** Alias kept for callers migrated from usePortalAuthStore. */
const fetchUser = refreshUser
/**
* Called once on app startup. Validates the httpOnly cookie by calling
* GET /auth/me. On 401, clears everything.
@@ -144,13 +353,30 @@ export const useAuthStore = defineStore('auth', () => {
isAuthenticated,
isInitialized,
isSuperAdmin,
isPlatformAdmin,
isPortalUser,
isOrganizerUser,
showContextSwitcher,
availableContexts,
defaultContext,
lastContext,
portalToken,
currentOrganisation,
mfaSetupRequired,
setUser,
setActiveOrganisation,
setLastContext,
setPortalToken,
resolveLandingRoute,
hasRole,
hasAnyRole,
login,
verifyMfa,
logout,
handleUnauthorized,
initialize,
refreshUser,
fetchUser,
clearAll,
}
})

View File

@@ -1,3 +1,5 @@
import type { MfaMethod } from '@/types/mfa'
export interface User {
id: string
first_name: string
@@ -16,6 +18,11 @@ export interface Organisation {
name: string
slug: string
role: string
// Forward-compatible 1-element array form of `role`. The pivot still
// stores a single string today; multi-role support is tracked in
// BACKLOG.md as TECH-PIVOT-ROLES-MULTI.
roles?: string[]
}
export interface MfaUserInfo {
@@ -25,6 +32,17 @@ export interface MfaUserInfo {
setup_required: boolean
}
export type AuthContext = 'portal' | 'organizer'
export interface PlatformInfo {
is_super_admin: boolean
}
export interface ContextsBlock {
available: AuthContext[]
default: AuthContext
}
export interface MeResponse {
id: string
first_name: string
@@ -40,6 +58,8 @@ export interface MeResponse {
app_roles: string[]
permissions: string[]
mfa?: MfaUserInfo
platform?: PlatformInfo
contexts?: ContextsBlock
}
export interface LoginCredentials {
@@ -61,6 +81,43 @@ export interface LoginResponse {
mfa_setup_required?: boolean
}
/**
* Login outcome surfaced by useAuthStore.login() — the page consumes
* the discriminator and decides UI/routing. The store performs the
* actual API call and any state hydration; the page never owns
* branch logic about which API to call.
*/
export type LoginResult =
| { kind: 'authenticated' }
| {
kind: 'mfa-required'
sessionToken: string
methods: MfaMethod[]
preferredMethod: MfaMethod
expiresIn: number
}
| { kind: 'must-set-password'; userId: string }
| { kind: 'failed'; reason: string; errors?: Record<string, string[]> }
/**
* MFA verification outcome surfaced by useAuthStore.verifyMfa(). The
* page consumes this to decide redirect vs. error display. After
* 'authenticated', the auth store has a valid /auth/me hydration and
* the page can call resolveLandingRoute().
*/
export type MfaVerifyResult =
| { kind: 'authenticated' }
| { kind: 'failed'; reason: string; errors?: Record<string, string[]> }
export interface MfaVerifyArgs {
sessionToken: string
code: string
method: MfaMethod
trustDevice?: boolean
deviceFingerprint?: string
deviceName?: string
}
export interface ApiErrorResponse {
success: false
message: string