import { defineStore } from 'pinia' export interface AuthUser { id: string email: string displayName: string role: string } interface AuthPayload { accessToken: string expiresAt: string user: AuthUser } interface LoginErrorInfo { message: string remaining: number retryAfterSeconds: number } let refreshInFlight: Promise | null = null export const useAuthStore = defineStore('auth', { state: () => ({ accessToken: null as string | null, expiresAt: null as string | null, user: null as AuthUser | null, initialized: false, loading: false, /** Remaining login attempts in the current window (null = unknown) */ remainingAttempts: null as number | null, /** Seconds until rate-limit reset (0 = not rate-limited) */ retryAfterSeconds: 0, }), getters: { isAuthenticated: state => Boolean(state.accessToken && state.user), isOwner: state => state.user?.role.toLowerCase() === 'owner', isRateLimited: state => state.remainingAttempts === 0 && state.retryAfterSeconds > 0, /** Returns true if the current web-ui user is Iris (JWT user identity matches "iris"). */ isIris: state => { if (!state.user) return false const lower = state.user.email.toLowerCase() return lower.includes('iris') || state.user.displayName.toLowerCase().includes('iris') }, /** Returns true if the current web-ui user is Bao (JWT user identity matches "bao"). */ isBao: state => { if (!state.user) return false const lower = state.user.email.toLowerCase() return lower.includes('bao') || state.user.displayName.toLowerCase().includes('bao') }, }, actions: { applySession(payload: AuthPayload) { this.accessToken = payload.accessToken this.expiresAt = payload.expiresAt this.user = payload.user this.remainingAttempts = null this.retryAfterSeconds = 0 }, clearSession() { this.accessToken = null this.expiresAt = null this.user = null this.remainingAttempts = null this.retryAfterSeconds = 0 }, async initialize() { // Router guards can overlap during the initial navigation. Reuse the // active refresh instead of treating the not-yet-applied session as an // unauthenticated result. if (this.initialized) { return refreshInFlight ?? this.isAuthenticated } this.initialized = true return this.refresh() }, async login(email: string, password: string): Promise { this.loading = true this.remainingAttempts = null this.retryAfterSeconds = 0 try { const response = await fetch('/api/v1/auth/login', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }) // Try to parse remaining from headers const remainingHeader = response.headers.get('X-RateLimit-Remaining') if (remainingHeader !== null) { this.remainingAttempts = parseInt(remainingHeader, 10) } const resetHeader = response.headers.get('X-RateLimit-Reset') if (resetHeader !== null) { const resetTs = parseInt(resetHeader, 10) * 1000 this.retryAfterSeconds = Math.max(0, Math.ceil((resetTs - Date.now()) / 1000)) } if (!response.ok) { // Try to parse structured JSON body for rate-limit info let remaining = this.remainingAttempts let retryAfter = this.retryAfterSeconds try { const body = await response.json() as Record if (typeof body.remaining === 'number') remaining = body.remaining if (typeof body.retryAfterSeconds === 'number') retryAfter = body.retryAfterSeconds if (response.status === 429) { this.remainingAttempts = 0 this.retryAfterSeconds = retryAfter throw new LoginError(body.message as string || 'Too many attempts.', 0, retryAfter) } else if (response.status === 401) { this.remainingAttempts = remaining this.retryAfterSeconds = retryAfter throw new LoginError( body.message as string || 'Invalid email or password.', remaining ?? 4, retryAfter, ) } } catch (error) { if (error instanceof LoginError) throw error // Fallback for non-JSON error responses } if (response.status === 429) { this.remainingAttempts = 0 const retryAfterSec = this.retryAfterSeconds || 60 this.retryAfterSeconds = retryAfterSec throw new LoginError('Too many attempts. Please wait.', 0, retryAfterSec) } throw new LoginError('Invalid email or password.', this.remainingAttempts ?? 4, this.retryAfterSeconds) } this.applySession(await response.json() as AuthPayload) } finally { this.loading = false } }, async refresh(): Promise { if (refreshInFlight) return refreshInFlight refreshInFlight = (async () => { try { const response = await fetch('/api/v1/auth/refresh', { method: 'POST', credentials: 'include', }) if (!response.ok) { this.clearSession() return false } this.applySession(await response.json() as AuthPayload) return true } catch { this.clearSession() return false } finally { refreshInFlight = null } })() return refreshInFlight }, async logout() { try { await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include', headers: this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : undefined, }) } finally { this.clearSession() } }, }, }) /** Custom error carrying rate-limit metadata. */ class LoginError extends Error { remaining: number retryAfterSeconds: number constructor(message: string, remaining: number, retryAfterSeconds: number) { super(message) this.name = 'LoginError' this.remaining = remaining this.retryAfterSeconds = retryAfterSeconds } }