feat(delegations): UI fuer Berechtigungs-Delegationen — Modul 2/16 des UI-Backlogs
Backend existierte vollstaendig (5 Endpoints: list/create/update/delete/ active-check, delegations:read/write seit Audit-Fix im Katalog), Frontend hatte 0% Abdeckung. Modul folgt dem Approvals-Muster (Modul 1): - api/delegations.ts: TanStack-Hooks (useDelegations mit direction-Filter, useActiveDelegations, create/update/delete-Mutations mit Cache-Invalidierung) - pages/Delegations.tsx: Richtungstabs (alle/von mir/an mich), Karten mit Phasen-Badges (aktiv/geplant/abgelaufen/inaktiv), Erstellen-Dialog mit Empfaenger-Picker (useUsers, sich selbst ausschliessend), Start/Ende- Datetime, Scope-Toggle (alle Berechtigungen), Aktivieren/Deaktivieren, Loeschen mit Confirm — Aktionen hinter delegations:write gegated - Route /delegations (PermissionRoute delegations:read) — als Core-Route bewusst statisch registriert (Phase-Q-Regel: nur Plugin-Routen laufen ueber Manifeste) Sidebar-Entry order 92 (ArrowRightLeft-Icon) - i18n delegations.* + nav.delegations de/en Verifikation: Vitest 9/9 (Rendering, Tabs, Phasen, Permission-Gating, Create-Flow, Toggle, Delete) · tsc exit 0 · production build exit 0.
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Delegations page — temporary permission handovers between users.
|
||||
*
|
||||
* Backend: /api/v1/delegations (UI-Backlog module 2/16).
|
||||
* Features:
|
||||
* - Direction tabs: all / from me (given) / to me (received)
|
||||
* - Create dialog: recipient picker (tenant users), start/end datetime,
|
||||
* scope (all permissions or none — full scope editor is out of scope
|
||||
* for this module; the backend validates the scope JSONB)
|
||||
* - Status badges: active / upcoming / expired / inactive
|
||||
* - Actions: activate/deactivate, delete (delegations:write)
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
UserPlus,
|
||||
Trash2,
|
||||
Power,
|
||||
PowerOff,
|
||||
Inbox,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useDelegations,
|
||||
useCreateDelegation,
|
||||
useUpdateDelegation,
|
||||
useDeleteDelegation,
|
||||
type Delegation,
|
||||
type DelegationDirection,
|
||||
} from '@/api/delegations';
|
||||
import { useUsers } from '@/api/users';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
|
||||
const DIRECTION_TABS: { key: DelegationDirection; labelKey: string }[] = [
|
||||
{ key: 'all', labelKey: 'delegations.tabAll' },
|
||||
{ key: 'from', labelKey: 'delegations.tabFrom' },
|
||||
{ key: 'to', labelKey: 'delegations.tabTo' },
|
||||
];
|
||||
|
||||
type DelegationPhase = 'active' | 'upcoming' | 'expired' | 'inactive';
|
||||
|
||||
function delegationPhase(d: Delegation, now: Date): DelegationPhase {
|
||||
if (!d.active) return 'inactive';
|
||||
if (!d.start_at || !d.end_at) return 'inactive';
|
||||
const start = new Date(d.start_at);
|
||||
const end = new Date(d.end_at);
|
||||
if (now < start) return 'upcoming';
|
||||
if (now >= end) return 'expired';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
const PHASE_BADGE: Record<DelegationPhase, string> = {
|
||||
active: 'bg-success-100 text-success-800 dark:bg-success-900/30 dark:text-success-300',
|
||||
upcoming: 'bg-primary-100 text-primary-800 dark:bg-primary-900/30 dark:text-primary-300',
|
||||
expired: 'bg-secondary-200 text-secondary-700 dark:bg-secondary-800 dark:text-secondary-300',
|
||||
inactive: 'bg-warning-100 text-warning-800 dark:bg-warning-900/30 dark:text-warning-300',
|
||||
};
|
||||
|
||||
function formatDateTime(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function DelegationCard({
|
||||
d,
|
||||
userName,
|
||||
currentUserId,
|
||||
canWrite,
|
||||
onToggleActive,
|
||||
onDelete,
|
||||
isMutating,
|
||||
}: {
|
||||
d: Delegation;
|
||||
userName: string;
|
||||
currentUserId: string;
|
||||
canWrite: boolean;
|
||||
onToggleActive: (d: Delegation, next: boolean) => void;
|
||||
onDelete: (d: Delegation) => void;
|
||||
isMutating: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const phase = delegationPhase(d, new Date());
|
||||
const isFromMe = d.from_user_id === currentUserId;
|
||||
|
||||
return (
|
||||
<Card className="p-4" data-testid={`delegation-card-${d.id}`}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<ArrowRightLeft className="w-4 h-4 text-primary-600 flex-shrink-0" aria-hidden="true" />
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{isFromMe
|
||||
? t('delegations.cardTo', { user: userName })
|
||||
: t('delegations.cardFrom', { user: userName })}
|
||||
</span>
|
||||
<span data-testid={`delegation-phase-${d.id}`}>
|
||||
<Badge className={PHASE_BADGE[phase]}>
|
||||
{t(`delegations.phase_${phase}`)}
|
||||
</Badge>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
{t('delegations.period')} {formatDateTime(d.start_at)} → {formatDateTime(d.end_at)}
|
||||
</div>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleActive(d, !d.active)}
|
||||
disabled={isMutating}
|
||||
aria-label={d.active ? t('delegations.deactivate') : t('delegations.activate')}
|
||||
data-testid={`delegation-toggle-${d.id}`}
|
||||
>
|
||||
{d.active
|
||||
? <PowerOff className="w-4 h-4" aria-hidden="true" />
|
||||
: <Power className="w-4 h-4" aria-hidden="true" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(d)}
|
||||
disabled={isMutating}
|
||||
aria-label={t('delegations.delete')}
|
||||
data-testid={`delegation-delete-${d.id}`}
|
||||
className="text-danger-600 hover:text-danger-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateDelegationDialog({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
currentUserId,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: { to_user_id: string; start_at: string; end_at: string; scope?: Record<string, unknown> | null }) => void;
|
||||
isSubmitting: boolean;
|
||||
currentUserId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [toUserId, setToUserId] = useState('');
|
||||
const [startAt, setStartAt] = useState('');
|
||||
const [endAt, setEndAt] = useState('');
|
||||
const [scopeAll, setScopeAll] = useState(true);
|
||||
const { data: usersData } = useUsers(1, 100);
|
||||
|
||||
const userOptions = useMemo(
|
||||
() =>
|
||||
(usersData?.items ?? [])
|
||||
.filter((u) => u.id !== currentUserId)
|
||||
.map((u) => ({ value: u.id, label: `${u.name} (${u.email})` })),
|
||||
[usersData, currentUserId],
|
||||
);
|
||||
|
||||
const valid = toUserId && startAt && endAt && new Date(endAt) > new Date(startAt);
|
||||
|
||||
const submit = () => {
|
||||
if (!valid) return;
|
||||
onSubmit({
|
||||
to_user_id: toUserId,
|
||||
start_at: new Date(startAt).toISOString(),
|
||||
end_at: new Date(endAt).toISOString(),
|
||||
scope: scopeAll ? { all: true } : null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('delegations.createTitle')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Select
|
||||
label={t('delegations.recipient')}
|
||||
options={userOptions}
|
||||
value={toUserId}
|
||||
onChange={(e) => setToUserId(e.target.value)}
|
||||
required
|
||||
placeholder={t('delegations.recipientPlaceholder')}
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label={t('delegations.startAt')}
|
||||
type="datetime-local"
|
||||
value={startAt}
|
||||
onChange={(e) => setStartAt(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label={t('delegations.endAt')}
|
||||
type="datetime-local"
|
||||
value={endAt}
|
||||
onChange={(e) => setEndAt(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-secondary-700 dark:text-secondary-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scopeAll}
|
||||
onChange={(e) => setScopeAll(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
{t('delegations.scopeAll')}
|
||||
</label>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button onClick={submit} disabled={!valid || isSubmitting} data-testid="delegation-create-submit">
|
||||
{t('delegations.createSubmit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function DelegationsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [direction, setDirection] = useState<DelegationDirection>('all');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const { data, isLoading, isError } = useDelegations(direction);
|
||||
const createMut = useCreateDelegation();
|
||||
const updateMut = useUpdateDelegation();
|
||||
const deleteMut = useDeleteDelegation();
|
||||
const { hasPermission } = usePermission();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { data: usersData } = useUsers(1, 100);
|
||||
|
||||
const canWrite = hasPermission('delegations:write');
|
||||
const isMutating = createMut.isPending || updateMut.isPending || deleteMut.isPending;
|
||||
|
||||
const userNameById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const u of usersData?.items ?? []) {
|
||||
map.set(u.id, u.name);
|
||||
}
|
||||
return map;
|
||||
}, [usersData]);
|
||||
|
||||
const resolveName = (id: string) => userNameById.get(id) ?? id.slice(0, 8);
|
||||
|
||||
const handleSubmit = (payload: { to_user_id: string; start_at: string; end_at: string; scope?: Record<string, unknown> | null }) => {
|
||||
createMut.mutate(payload, {
|
||||
onSuccess: () => setShowCreate(false),
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleActive = (d: Delegation, next: boolean) => {
|
||||
updateMut.mutate({ delegationId: d.id, payload: { active: next } });
|
||||
};
|
||||
|
||||
const handleDelete = (d: Delegation) => {
|
||||
if (window.confirm(t('delegations.deleteConfirm'))) {
|
||||
deleteMut.mutate(d.id);
|
||||
}
|
||||
};
|
||||
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-4 sm:p-6 space-y-4" data-testid="delegations-page">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
{t('delegations.title')}
|
||||
</h1>
|
||||
{canWrite && (
|
||||
<Button onClick={() => setShowCreate(true)} data-testid="delegation-create-open">
|
||||
<UserPlus className="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
{t('delegations.create')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 rounded-lg bg-secondary-100 dark:bg-secondary-800 p-1" role="tablist" aria-label={t('delegations.title')}>
|
||||
{DIRECTION_TABS.map(({ key, labelKey }) => (
|
||||
<button
|
||||
key={key}
|
||||
role="tab"
|
||||
aria-selected={direction === key}
|
||||
onClick={() => setDirection(key)}
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors min-h-touch ${
|
||||
direction === key
|
||||
? 'bg-white dark:bg-secondary-700 text-primary-700 shadow-sm'
|
||||
: 'text-secondary-600 dark:text-secondary-300 hover:text-secondary-900'
|
||||
}`}
|
||||
data-testid={`delegation-tab-${key}`}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center min-h-[30vh]" role="status" data-testid="delegations-loading">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Card className="p-6 flex items-center gap-3 text-danger-600" data-testid="delegations-error">
|
||||
<AlertTriangle className="w-5 h-5" aria-hidden="true" />
|
||||
<span>{t('delegations.loadError')}</span>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && items.length === 0 && (
|
||||
<Card className="p-8 flex flex-col items-center gap-3 text-secondary-500" data-testid="delegations-empty">
|
||||
<Inbox className="w-10 h-10" aria-hidden="true" />
|
||||
<p>{t('delegations.empty')}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{items.map((d) => (
|
||||
<DelegationCard
|
||||
key={d.id}
|
||||
d={d}
|
||||
userName={resolveName(d.from_user_id === user?.id ? d.to_user_id : d.from_user_id)}
|
||||
currentUserId={user?.id ?? ''}
|
||||
canWrite={canWrite}
|
||||
onToggleActive={handleToggleActive}
|
||||
onDelete={handleDelete}
|
||||
isMutating={isMutating}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CreateDelegationDialog
|
||||
open={showCreate}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={createMut.isPending}
|
||||
currentUserId={user?.id ?? ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DelegationsPage;
|
||||
Reference in New Issue
Block a user