From ecc7a24c1c40d2d1b90e45a8acefd89071beb310 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Tue, 8 Sep 2026 23:26:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(approvals):=20UI=20f=C3=BCr=20Freigaben=20?= =?UTF-8?q?=E2=80=94=20Review-Queue=20mit=20Approve/Reject=20(Modul=201/16?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Phantom-Permission-Bug gefixt: approvals:read/write/approve fehlten in CORE_PERMISSIONS (Rollen konnten sie nie zugewiesen bekommen — gleiche Fehlerklasse wie dashboard:read in M2) Frontend: - api/approvals.ts: TanStack Hooks (list/detail/approve/reject/expire/create) - pages/Approvals.tsx: Review-Queue — Status-Tabs (Offen/Alle/Genehmigt/ Abgelehnt/Abgelaufen), Karten mit Aktion/Entity/Requester/Metadata, Approve/Reject mit Kommentar-Modal, Permission-Gating (approvals:approve) - Route /approvals (PermissionRoute approvals:read), Sidebar-Eintrag - i18n approvals.* + nav.approvals (de/en) Verifikation: Vitest 10/10 (Rendering, Tabs, Approve/Reject-Flow, Kommentar, Permission-Gating, Resolved-Zustände), RBAC-Regression 102/102, tsc clean, Build OK --- app/core/permission_registry.py | 3 + .../src/__tests__/pages/Approvals.test.tsx | 179 ++++++++++ frontend/src/api/approvals.ts | 133 +++++++ frontend/src/components/layout/Sidebar.tsx | 3 +- frontend/src/i18n/locales/de.json | 29 +- frontend/src/i18n/locales/en.json | 29 +- frontend/src/pages/Approvals.tsx | 333 ++++++++++++++++++ frontend/src/routes/index.tsx | 2 + 8 files changed, 708 insertions(+), 3 deletions(-) create mode 100644 frontend/src/__tests__/pages/Approvals.test.tsx create mode 100644 frontend/src/api/approvals.ts create mode 100644 frontend/src/pages/Approvals.tsx diff --git a/app/core/permission_registry.py b/app/core/permission_registry.py index 678eddc..3a95e4c 100644 --- a/app/core/permission_registry.py +++ b/app/core/permission_registry.py @@ -64,6 +64,9 @@ CORE_PERMISSIONS: list[dict[str, str]] = [ {"key": "workspaces:delete", "label": "Workspaces: Delete", "category": "core", "module": "workspaces"}, {"key": "workspaces:assign_users", "label": "Workspaces: Assign Users", "category": "core", "module": "workspaces"}, {"key": "workspaces:configure_modules", "label": "Workspaces: Configure Modules", "category": "core", "module": "workspaces"}, + {"key": "approvals:read", "label": "Approvals: Read", "category": "core", "module": "approvals"}, + {"key": "approvals:write", "label": "Approvals: Write", "category": "core", "module": "approvals"}, + {"key": "approvals:approve", "label": "Approvals: Approve/Reject", "category": "core", "module": "approvals"}, {"key": "dashboard:read", "label": "Dashboard: Read", "category": "core", "module": "dashboard"}, {"key": "dashboard:write", "label": "Dashboard: Write", "category": "core", "module": "dashboard"}, {"key": "system:admin", "label": "System: Admin (cross-tenant)", "category": "system", "module": "system"}, diff --git a/frontend/src/__tests__/pages/Approvals.test.tsx b/frontend/src/__tests__/pages/Approvals.test.tsx new file mode 100644 index 0000000..666964a --- /dev/null +++ b/frontend/src/__tests__/pages/Approvals.test.tsx @@ -0,0 +1,179 @@ +/** + * Approvals page tests — review queue UI for approval requests. + * + * Covers: rendering, status tabs, approve/reject flow with comment modal, + * permission gating (approvals:approve decides button visibility). + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { ApprovalsPage } from '@/pages/Approvals'; +import type { ApprovalRequest } from '@/api/approvals'; + +const approveMut = vi.fn().mockResolvedValue({}); +const rejectMut = vi.fn().mockResolvedValue({}); + +const makeApproval = (overrides: Partial = {}): ApprovalRequest => ({ + id: 'ap-1', + tenant_id: 't-1', + entity_type: 'contact', + entity_id: '11111111-1111-1111-1111-111111111111', + action: 'Kunde löschen', + requested_by: '22222222-2222-2222-2222-222222222222', + requested_by_type: 'agent', + approver_id: null, + approver_group: null, + status: 'pending', + comment: null, + created_at: '2026-09-01T10:00:00Z', + resolved_at: null, + expires_at: null, + metadata: { reason: 'cleanup' }, + ...overrides, +}); + +let mockItems: ApprovalRequest[] = []; +let mockCanApprove = true; + +vi.mock('@/api/approvals', () => ({ + useApprovals: () => ({ + data: { items: mockItems, total: mockItems.length }, + isLoading: false, + isError: false, + isFetching: false, + refetch: vi.fn(), + }), + useApproveApproval: () => ({ + mutate: approveMut, + isPending: false, + }), + useRejectApproval: () => ({ + mutate: rejectMut, + isPending: false, + }), +})); + +vi.mock('@/hooks/usePermission', () => ({ + usePermission: () => ({ + hasPermission: (perm: string) => mockCanApprove || perm !== 'approvals:approve', + }), +})); + +function renderPage() { + return render( + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockItems = []; + mockCanApprove = true; +}); + +describe('ApprovalsPage', () => { + it('renders the page with title and status tabs', () => { + renderPage(); + expect(screen.getByTestId('approvals-page')).toBeInTheDocument(); + expect(screen.getByTestId('approvals-tab-pending')).toBeInTheDocument(); + expect(screen.getByTestId('approvals-tab-all')).toBeInTheDocument(); + expect(screen.getByTestId('approvals-tab-approved')).toBeInTheDocument(); + expect(screen.getByTestId('approvals-tab-rejected')).toBeInTheDocument(); + expect(screen.getByTestId('approvals-tab-expired')).toBeInTheDocument(); + }); + + it('shows empty state when no approvals exist', () => { + renderPage(); + expect(screen.getByTestId('approvals-empty')).toBeInTheDocument(); + }); + + it('renders approval cards with action, entity and requester', () => { + mockItems = [makeApproval()]; + renderPage(); + expect(screen.getByText('Kunde löschen')).toBeInTheDocument(); + expect(screen.getByText(/contact/)).toBeInTheDocument(); + expect(screen.getByTestId('approval-card-ap-1')).toBeInTheDocument(); + }); + + it('shows approve/reject buttons for pending requests when user can decide', () => { + mockItems = [makeApproval()]; + renderPage(); + expect(screen.getByTestId('approve-btn-ap-1')).toBeInTheDocument(); + expect(screen.getByTestId('reject-btn-ap-1')).toBeInTheDocument(); + }); + + it('hides approve/reject buttons without approvals:approve permission', () => { + mockItems = [makeApproval()]; + mockCanApprove = false; + renderPage(); + expect(screen.queryByTestId('approve-btn-ap-1')).not.toBeInTheDocument(); + expect(screen.queryByTestId('reject-btn-ap-1')).not.toBeInTheDocument(); + }); + + it('opens comment modal on approve and submits the decision', async () => { + mockItems = [makeApproval()]; + renderPage(); + fireEvent.click(screen.getByTestId('approve-btn-ap-1')); + + const textarea = await screen.findByTestId('approval-comment-input'); + expect(textarea).toBeInTheDocument(); + + fireEvent.change(textarea, { target: { value: 'Sieht gut aus' } }); + fireEvent.click(screen.getByTestId('approval-comment-confirm')); + + await waitFor(() => + expect(approveMut).toHaveBeenCalledWith({ + requestId: 'ap-1', + payload: { comment: 'Sieht gut aus' }, + }), + ); + }); + + it('opens comment modal on reject and submits the decision', async () => { + mockItems = [makeApproval()]; + renderPage(); + fireEvent.click(screen.getByTestId('reject-btn-ap-1')); + + const textarea = await screen.findByTestId('approval-comment-input'); + fireEvent.change(textarea, { target: { value: 'Zu riskant' } }); + fireEvent.click(screen.getByTestId('approval-comment-confirm')); + + await waitFor(() => + expect(rejectMut).toHaveBeenCalledWith({ + requestId: 'ap-1', + payload: { comment: 'Zu riskant' }, + }), + ); + }); + + it('submits without comment when textarea is empty', async () => { + mockItems = [makeApproval()]; + renderPage(); + fireEvent.click(screen.getByTestId('approve-btn-ap-1')); + fireEvent.click(screen.getByTestId('approval-comment-confirm')); + + await waitFor(() => + expect(approveMut).toHaveBeenCalledWith({ + requestId: 'ap-1', + payload: { comment: undefined }, + }), + ); + }); + + it('does not show action buttons for resolved requests', () => { + mockItems = [makeApproval({ status: 'approved', resolved_at: '2026-09-02T10:00:00Z' })]; + renderPage(); + expect(screen.queryByTestId('approve-btn-ap-1')).not.toBeInTheDocument(); + expect(screen.queryByTestId('reject-btn-ap-1')).not.toBeInTheDocument(); + }); + + it('shows metadata entries on the card', () => { + mockItems = [makeApproval({ metadata: { reason: 'Datenbereinigung', priority: 'hoch' } })]; + renderPage(); + expect(screen.getByText(/Datenbereinigung/)).toBeInTheDocument(); + expect(screen.getByText(/hoch/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/api/approvals.ts b/frontend/src/api/approvals.ts new file mode 100644 index 0000000..0040fa0 --- /dev/null +++ b/frontend/src/api/approvals.ts @@ -0,0 +1,133 @@ +/** + * Approvals API client — approval requests from workflows, agents and + * users that need human review (approve / reject with comment). + * + * Backend: /api/v1/approvals (create, list, get, approve, reject, expire). + * Lifecycle: pending → approved | rejected | expired. + * Permissions: approvals:read / approvals:write / approvals:approve. + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiGet, apiPost } from '@/api/client'; + +export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'expired'; +export type RequesterType = 'user' | 'agent' | 'system'; + +export interface ApprovalRequest { + id: string; + tenant_id: string; + entity_type: string; + entity_id: string; + action: string; + requested_by: string; + requested_by_type: RequesterType; + approver_id: string | null; + approver_group: string | null; + status: ApprovalStatus; + comment: string | null; + created_at: string | null; + resolved_at: string | null; + expires_at: string | null; + metadata: Record; +} + +export interface ApprovalListResponse { + items: ApprovalRequest[]; + total: number; +} + +export interface ApprovalListParams { + status?: ApprovalStatus; + entity_type?: string; + entity_id?: string; + requested_by?: string; + limit?: number; + offset?: number; +} + +export interface ApprovalCreatePayload { + entity_type: string; + entity_id: string; + action: string; + requested_by?: string; + requested_by_type?: RequesterType; + approver_id?: string; + approver_group?: string; + expires_at?: string; + metadata?: Record; +} + +export interface ApprovalResolvePayload { + comment?: string; +} + +// ─── Query hooks ───────────────────────────────────────────── + +export function useApprovals(params: ApprovalListParams = {}) { + const searchParams = new URLSearchParams(); + if (params.status) searchParams.set('status', params.status); + if (params.entity_type) searchParams.set('entity_type', params.entity_type); + if (params.entity_id) searchParams.set('entity_id', params.entity_id); + if (params.requested_by) searchParams.set('requested_by', params.requested_by); + if (params.limit) searchParams.set('limit', String(params.limit)); + if (params.offset) searchParams.set('offset', String(params.offset)); + + const qs = searchParams.toString(); + return useQuery({ + queryKey: ['approvals', params.status, params.entity_type, params.entity_id, params.requested_by, params.limit, params.offset], + queryFn: () => apiGet(`/approvals${qs ? `?${qs}` : ''}`), + }); +} + +export function useApproval(requestId: string | null) { + return useQuery({ + queryKey: ['approvals', requestId], + queryFn: () => apiGet(`/approvals/${requestId}`), + enabled: !!requestId, + }); +} + +// ─── Mutation hooks ────────────────────────────────────────── + +export function useApproveApproval() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ requestId, payload }) => + apiPost(`/approvals/${requestId}/approve`, payload || {}), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['approvals'] }); + }, + }); +} + +export function useRejectApproval() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ requestId, payload }) => + apiPost(`/approvals/${requestId}/reject`, payload || {}), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['approvals'] }); + }, + }); +} + +export function useExpireApproval() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (requestId: string) => + apiPost(`/approvals/${requestId}/expire`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['approvals'] }); + }, + }); +} + +export function useCreateApproval() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data) => apiPost('/approvals', data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['approvals'] }); + }, + }); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 0a4caa6..7f52cee 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -3,7 +3,7 @@ import clsx from 'clsx'; import { NavLink, useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useUIStore } from '@/store/uiStore'; -import { ChevronRight, FileText, Home, Settings, Users, ArrowLeft } from 'lucide-react'; +import { ChevronRight, FileText, Home, Settings, Users, ArrowLeft , CheckCircle2 } from 'lucide-react'; import { usePluginStore } from '@/store/pluginStore'; // Curated icon map — avoids `import * as LucideIcons` which loads ALL icons and causes OOM in tests import { @@ -55,6 +55,7 @@ function getIcon(name: string): React.ReactNode { const singleItems: NavSingleItem[] = [ { to: '/dashboard', labelKey: 'nav.dashboard', icon: