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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user