feat(approvals): UI für Freigaben — Review-Queue mit Approve/Reject (Modul 1/16)

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
This commit is contained in:
Agent Zero
2026-09-08 23:26:56 +02:00
parent 4eb05d96ad
commit ecc7a24c1c
8 changed files with 708 additions and 3 deletions
+333
View File
@@ -0,0 +1,333 @@
/**
* Approvals page — human review queue for approval requests.
*
* Producers: workflows, AI agents, users. This page is where humans
* approve or reject pending requests (with optional comment).
*
* Features:
* - Status tabs: pending / all / approved / rejected / expired
* - Request cards: action, entity, requester, timestamps, metadata
* - Approve / reject with optional comment (modal)
* - Permission-aware action buttons (approvals:approve)
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import {
CheckCircle2,
XCircle,
Clock,
Inbox,
RefreshCw,
User,
Bot,
Server,
AlertTriangle,
} from 'lucide-react';
import {
useApprovals,
useApproveApproval,
useRejectApproval,
type ApprovalRequest,
type ApprovalStatus,
} from '@/api/approvals';
import { usePermission } from '@/hooks/usePermission';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
const STATUS_TABS: { key: ApprovalStatus | 'all'; labelKey: string }[] = [
{ key: 'pending', labelKey: 'approvals.statusPending' },
{ key: 'all', labelKey: 'approvals.statusAll' },
{ key: 'approved', labelKey: 'approvals.statusApproved' },
{ key: 'rejected', labelKey: 'approvals.statusRejected' },
{ key: 'expired', labelKey: 'approvals.statusExpired' },
];
const STATUS_BADGE: Record<ApprovalStatus, string> = {
pending: 'bg-warning-100 text-warning-800 dark:bg-warning-900/30 dark:text-warning-300',
approved: 'bg-success-100 text-success-800 dark:bg-success-900/30 dark:text-success-300',
rejected: 'bg-danger-100 text-danger-800 dark:bg-danger-900/30 dark:text-danger-300',
expired: 'bg-secondary-200 text-secondary-700 dark:bg-secondary-800 dark:text-secondary-300',
};
function RequesterIcon({ type }: { type: ApprovalRequest['requested_by_type'] }) {
if (type === 'agent') return <Bot className="w-3.5 h-3.5" aria-hidden="true" />;
if (type === 'system') return <Server className="w-3.5 h-3.5" aria-hidden="true" />;
return <User className="w-3.5 h-3.5" aria-hidden="true" />;
}
function formatDateTime(iso: string | null): string {
if (!iso) return '—';
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
function ApprovalCard({
req,
onApprove,
onReject,
canDecide,
isDeciding,
}: {
req: ApprovalRequest;
onApprove: (comment: string | null) => void;
onReject: (comment: string | null) => void;
canDecide: boolean;
isDeciding: boolean;
}) {
const { t } = useTranslation();
const metaEntries = Object.entries(req.metadata || {}).slice(0, 4);
return (
<Card className="p-4 space-y-3" data-testid={`approval-card-${req.id}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span
className={clsx(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
STATUS_BADGE[req.status],
)}
data-testid={`approval-status-${req.id}`}
>
{t(`approvals.status_${req.status === 'pending' ? 'Pending' : req.status === 'approved' ? 'Approved' : req.status === 'rejected' ? 'Rejected' : 'Expired'}`)}
</span>
<span className="inline-flex items-center gap-1 text-xs text-secondary-500">
<RequesterIcon type={req.requested_by_type} />
{t(`approvals.requester${req.requested_by_type === 'user' ? 'User' : req.requested_by_type === 'agent' ? 'Agent' : 'System'}`)}
</span>
</div>
<p className="mt-2 text-sm font-medium text-secondary-900 dark:text-secondary-100 break-words">
{req.action}
</p>
<p className="text-xs text-secondary-500 mt-1">
{t('approvals.entity')}: <span className="font-mono">{req.entity_type}</span>
{' · '}
<span className="font-mono" title={req.entity_id}>{req.entity_id.slice(0, 8)}</span>
</p>
</div>
{req.status === 'pending' && canDecide && (
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="primary"
size="sm"
onClick={() => onApprove(null)}
disabled={isDeciding}
data-testid={`approve-btn-${req.id}`}
aria-label={t('approvals.approve')}
>
<CheckCircle2 className="w-4 h-4" aria-hidden="true" />
{t('approvals.approve')}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => onReject(null)}
disabled={isDeciding}
data-testid={`reject-btn-${req.id}`}
aria-label={t('approvals.reject')}
>
<XCircle className="w-4 h-4" aria-hidden="true" />
{t('approvals.reject')}
</Button>
</div>
)}
</div>
{metaEntries.length > 0 && (
<dl className="text-xs text-secondary-600 dark:text-secondary-400 grid grid-cols-2 gap-x-3 gap-y-1 border-t border-secondary-200 dark:border-secondary-700 pt-2">
{metaEntries.map(([k, v]) => (
<div key={k} className="truncate">
<dt className="inline font-medium">{k}:</dt>{' '}
<dd className="inline break-all">{typeof v === 'object' ? JSON.stringify(v) : String(v)}</dd>
</div>
))}
</dl>
)}
<div className="flex items-center gap-4 text-xs text-secondary-400 border-t border-secondary-200 dark:border-secondary-700 pt-2">
<span className="inline-flex items-center gap-1">
<Clock className="w-3 h-3" aria-hidden="true" />
{t('approvals.created')}: {formatDateTime(req.created_at)}
</span>
{req.resolved_at && (
<span>
{t('approvals.resolved')}: {formatDateTime(req.resolved_at)}
</span>
)}
{req.expires_at && (
<span>
{t('approvals.expires')}: {formatDateTime(req.expires_at)}
</span>
)}
</div>
{req.comment && (
<p className="text-xs text-secondary-600 dark:text-secondary-300 bg-secondary-50 dark:bg-secondary-800/50 rounded-md px-3 py-2">
<span className="font-medium">{t('approvals.comment')}:</span> {req.comment}
</p>
)}
</Card>
);
}
export function ApprovalsPage() {
const { t } = useTranslation();
const { hasPermission } = usePermission();
const canDecide = hasPermission('approvals:approve');
const [tab, setTab] = useState<ApprovalStatus | 'all'>('pending');
const [commentTarget, setCommentTarget] = useState<
{ id: string; decision: 'approve' | 'reject' } | null
>(null);
const [comment, setComment] = useState('');
const params = tab === 'all' ? {} : { status: tab };
const { data, isLoading, isError, refetch, isFetching } = useApprovals(params);
const approveMut = useApproveApproval();
const rejectMut = useRejectApproval();
const items = data?.items ?? [];
const isDeciding = approveMut.isPending || rejectMut.isPending;
const handleDecide = () => {
if (!commentTarget) return;
const payload = { comment: comment.trim() || undefined };
if (commentTarget.decision === 'approve') {
approveMut.mutate({ requestId: commentTarget.id, payload });
} else {
rejectMut.mutate({ requestId: commentTarget.id, payload });
}
setCommentTarget(null);
setComment('');
};
const openComment = (id: string, decision: 'approve' | 'reject') => {
setComment('');
setCommentTarget({ id, decision });
};
return (
<div className="space-y-4 max-w-4xl mx-auto" data-testid="approvals-page">
<div className="flex items-center justify-between gap-3">
<h1 className="text-xl font-semibold text-secondary-900 dark:text-secondary-100">
{t('approvals.title')}
</h1>
<Button
variant="ghost"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
aria-label={t('common.refresh')}
data-testid="approvals-refresh"
>
<RefreshCw className={clsx('w-4 h-4', isFetching && 'animate-spin')} aria-hidden="true" />
</Button>
</div>
{/* Status tabs */}
<div
className="flex items-center gap-1 border-b border-secondary-200 dark:border-secondary-700 overflow-x-auto"
role="tablist"
aria-label={t('approvals.title')}
>
{STATUS_TABS.map(({ key, labelKey }) => (
<button
key={key}
role="tab"
aria-selected={tab === key}
onClick={() => setTab(key)}
className={clsx(
'px-3 py-2 text-sm border-b-2 -mb-px transition-colors min-h-touch whitespace-nowrap',
tab === key
? 'border-primary-500 text-primary-600 dark:text-primary-400 font-medium'
: 'border-transparent text-secondary-500 hover:text-secondary-700 dark:hover:text-secondary-300',
)}
data-testid={`approvals-tab-${key}`}
>
{t(labelKey)}
</button>
))}
</div>
{/* List */}
{isLoading ? (
<div className="flex items-center justify-center py-16">
<RefreshCw className="w-6 h-6 animate-spin text-secondary-400" aria-hidden="true" />
</div>
) : isError ? (
<Card className="p-8 text-center">
<AlertTriangle className="w-8 h-8 mx-auto text-danger-500" aria-hidden="true" />
<p className="mt-2 text-sm text-secondary-600">{t('common.loadError')}</p>
</Card>
) : items.length === 0 ? (
<Card className="p-12 text-center">
<Inbox className="w-10 h-10 mx-auto text-secondary-300" aria-hidden="true" />
<p className="mt-3 text-sm text-secondary-500" data-testid="approvals-empty">
{t('approvals.empty')}
</p>
</Card>
) : (
<div className="space-y-3">
{items.map((req) => (
<ApprovalCard
key={req.id}
req={req}
canDecide={canDecide}
isDeciding={isDeciding}
onApprove={(c) => openComment(req.id, 'approve')}
onReject={(c) => openComment(req.id, 'reject')}
/>
))}
</div>
)}
{/* Comment modal (optional comment on decision) */}
<Modal
open={!!commentTarget}
onClose={() => { setCommentTarget(null); setComment(''); }}
title={
commentTarget?.decision === 'approve'
? t('approvals.approveWithTitle')
: t('approvals.rejectWithTitle')
}
>
<div className="space-y-4">
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300">
{t('approvals.commentOptional')}
</label>
<textarea
className="w-full rounded-md border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-800 px-3 py-2 text-sm min-h-[80px]"
value={comment}
onChange={(e) => setComment(e.target.value)}
maxLength={2000}
data-testid="approval-comment-input"
aria-label={t('approvals.commentOptional')}
/>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
onClick={() => { setCommentTarget(null); setComment(''); }}
>
{t('common.cancel')}
</Button>
<Button
variant={commentTarget?.decision === 'approve' ? 'primary' : 'danger'}
onClick={handleDecide}
disabled={isDeciding}
data-testid="approval-comment-confirm"
>
{commentTarget?.decision === 'approve' ? t('approvals.approve') : t('approvals.reject')}
</Button>
</div>
</div>
</Modal>
</div>
);
}