197 lines
6.3 KiB
TypeScript
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
|
|
}
|
|
}
|