feat: Bao/Iris-Statusrechte + Bao→Iris-Notifications + Agent-Workflow-Übersicht
- Bao darf jetzt Status ändern (neben Iris), Sub-Agents weiterhin nicht - CanEditContent für Inhaltsbearbeitung durch alle bekannten Caller - Bao-Content-Änderungen triggern task_content_changed-Notification an Iris - Bao-Status-Änderungen triggern task_status_changed-Notification an Iris - Iris-Status-Änderungen triggern task_status_changed-Notification an Bao - Neue WorkTask-Felder: IsAgentTask (bool), ExpectedFrom (string) - Agent-Workflow-API: CreateAgentTask, WaitingTasks, AgentOverview - Frontend: Agent-Task-Badge, Iris-Overview-Panel, isBao-Getter - Login-Rate-Limiter mit strukturiertem JSON-Fehlermeldungs-Body - Volume-Name: nexus-postgres → postgres-data (Standardisierung)
This commit is contained in:
@@ -13,6 +13,12 @@ interface AuthPayload {
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
interface LoginErrorInfo {
|
||||
message: string
|
||||
remaining: number
|
||||
retryAfterSeconds: number
|
||||
}
|
||||
|
||||
let refreshInFlight: Promise<boolean> | null = null
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
@@ -22,28 +28,51 @@ export const useAuthStore = defineStore('auth', {
|
||||
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),
|
||||
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() {
|
||||
if (this.initialized) return this.isAuthenticated
|
||||
this.initialized = true
|
||||
return this.refresh()
|
||||
},
|
||||
async login(email: string, password: string) {
|
||||
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',
|
||||
@@ -52,9 +81,50 @@ export const useAuthStore = defineStore('auth', {
|
||||
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) {
|
||||
if (response.status === 429) throw new Error('Too many attempts. Please wait one minute.')
|
||||
throw new Error('Invalid email or password.')
|
||||
// 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, 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)
|
||||
@@ -101,3 +171,16 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface DashboardTaskDto {
|
||||
dueDate?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
isAgentTask?: boolean
|
||||
expectedFrom?: string | null
|
||||
}
|
||||
|
||||
export interface BoardGroup {
|
||||
@@ -36,6 +38,14 @@ export interface BoardGroup {
|
||||
blocked: DashboardTaskDto[]
|
||||
}
|
||||
|
||||
export interface AgentWorkflowOverview {
|
||||
waitingForBao: DashboardTaskDto[]
|
||||
waitingForIris: DashboardTaskDto[]
|
||||
waitingForOthers: DashboardTaskDto[]
|
||||
staleTasks: DashboardTaskDto[]
|
||||
staleThreshold: string
|
||||
}
|
||||
|
||||
/* ── State Mapping ────────────────────────────────── */
|
||||
|
||||
function mapPriority(priority: string): TaskItem['priority'] {
|
||||
@@ -92,10 +102,28 @@ export const useTaskStore = defineStore('tasks', {
|
||||
} as BoardGroup,
|
||||
boardLoading: false,
|
||||
boardError: null as string | null,
|
||||
|
||||
// Agent Workflow Overview (for Iris)
|
||||
agentOverview: null as AgentWorkflowOverview | null,
|
||||
agentOverviewLoading: false,
|
||||
agentOverviewError: null as string | null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
taskList: (state) => state.tasks,
|
||||
|
||||
// Iris helpers
|
||||
waitingForIrisTasks: (state) => state.agentOverview?.waitingForIris ?? [],
|
||||
waitingForBaoTasks: (state) => state.agentOverview?.waitingForBao ?? [],
|
||||
waitingForOthersTasks: (state) => state.agentOverview?.waitingForOthers ?? [],
|
||||
staleTasksList: (state) => state.agentOverview?.staleTasks ?? [],
|
||||
agentTaskCount: (state) => {
|
||||
if (!state.agentOverview) return 0
|
||||
return state.agentOverview.waitingForBao.length +
|
||||
state.agentOverview.waitingForIris.length +
|
||||
state.agentOverview.waitingForOthers.length +
|
||||
state.agentOverview.staleTasks.length
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
@@ -267,6 +295,54 @@ export const useTaskStore = defineStore('tasks', {
|
||||
}
|
||||
},
|
||||
|
||||
/* ── API: Fetch agent workflow overview ──────── */
|
||||
async fetchAgentOverview(staleHours = 2) {
|
||||
this.agentOverviewLoading = true
|
||||
try {
|
||||
const res = await apiFetch(`/api/dashboard/tasks/agent-overview?staleHours=${staleHours}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data: AgentWorkflowOverview = await res.json()
|
||||
this.agentOverview = data
|
||||
this.agentOverviewError = null
|
||||
} catch (err) {
|
||||
console.warn('[TaskStore] fetchAgentOverview failed', err)
|
||||
this.agentOverviewError = 'Agent overview could not be loaded'
|
||||
} finally {
|
||||
this.agentOverviewLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
/* ── API: Create agent task ───────────────────── */
|
||||
async createAgentTask(data: {
|
||||
title: string
|
||||
detail?: string | null
|
||||
source?: string
|
||||
priority?: string
|
||||
assignedTo?: string
|
||||
expectedFrom?: string
|
||||
}) {
|
||||
try {
|
||||
const res = await apiFetch('/api/dashboard/tasks/agent', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: data.title,
|
||||
detail: data.detail ?? null,
|
||||
source: data.source ?? 'iris',
|
||||
priority: data.priority ?? 'Medium',
|
||||
assignedTo: data.assignedTo ?? null,
|
||||
expectedFrom: data.expectedFrom ?? null,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
await this.fetchBoard()
|
||||
await this.fetchAgentOverview()
|
||||
return await res.json() as DashboardTaskDto
|
||||
} catch (err) {
|
||||
console.warn('[TaskStore] createAgentTask failed', err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
/* ── Polling ──────────────────────────────────── */
|
||||
startPolling() {
|
||||
if (this.refreshInterval) return
|
||||
|
||||
Reference in New Issue
Block a user