feat: Bao/Iris-Statusrechte + Bao→Iris-Notifications + Agent-Workflow-Übersicht
CI - Build & Test / Backend (.NET) (push) Successful in 29s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 19s
CI - Build & Test / Security Check (push) Successful in 4s

- 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:
2026-06-20 18:42:51 +02:00
parent a516353ae8
commit 83e072bc27
21 changed files with 1690 additions and 80 deletions
+86 -3
View File
@@ -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
}
}
+76
View File
@@ -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