Files
nexus/frontend/src/stores/auth.ts
T
AzuTear f5552218bc
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s
feat: ship agent-first mission control v0.2.57
2026-07-31 22:39:47 +02:00

197 lines
6.3 KiB
TypeScript

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<boolean> | 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<void> {
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<string, unknown>
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<boolean> {
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
}
}