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:
Agent Zero
2026-09-13 09:17:17 +02:00
parent b58c96ff71
commit 36771d471d
7 changed files with 752 additions and 3 deletions
@@ -0,0 +1,220 @@
/**
* Delegations page tests — temporary permission handover UI (module 2/16).
*
* Covers: rendering, direction tabs, phase badges, activate/deactivate +
* delete with permission gating (delegations:write), create dialog with
* recipient picker, empty and error states.
*/
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 { DelegationsPage } from '@/pages/Delegations';
import type { Delegation } from '@/api/delegations';
import type { UserResponse } from '@/api/users';
const createMut = vi.fn().mockImplementation((_payload, opts) => {
opts?.onSuccess?.({});
return Promise.resolve({});
});
const updateMut = vi.fn().mockResolvedValue({});
const deleteMut = vi.fn().mockResolvedValue({});
const CURRENT_USER_ID = '11111111-1111-1111-1111-111111111111';
const OTHER_USER = {
id: '22222222-2222-2222-2222-222222222222',
email: 'other@example.com',
name: 'Other User',
first_name: null,
last_name: null,
avatar_url: null,
role: 'viewer',
role_id: null,
is_active: true,
tenant_id: 't-1',
} as UserResponse;
const makeDelegation = (overrides: Partial<Delegation> = {}): Delegation => ({
id: 'dg-1',
from_user_id: CURRENT_USER_ID,
to_user_id: OTHER_USER.id,
start_at: '2026-09-01T00:00:00Z',
end_at: '2026-10-01T00:00:00Z',
scope: { all: true },
active: true,
tenant_id: 't-1',
created_at: '2026-08-30T10:00:00Z',
updated_at: null,
...overrides,
});
let mockItems: Delegation[] = [];
let mockCanWrite = true;
let mockError = false;
vi.mock('@/api/delegations', () => ({
useDelegations: () => ({
data: { items: mockItems, total: mockItems.length },
isLoading: false,
isError: mockError,
isFetching: false,
refetch: vi.fn(),
}),
useCreateDelegation: () => ({
mutate: createMut,
isPending: false,
}),
useUpdateDelegation: () => ({
mutate: updateMut,
isPending: false,
}),
useDeleteDelegation: () => ({
mutate: deleteMut,
isPending: false,
}),
}));
vi.mock('@/api/users', () => ({
useUsers: () => ({
data: { items: [OTHER_USER], total: 1, page: 1, page_size: 100 },
isLoading: false,
}),
}));
vi.mock('@/hooks/usePermission', () => ({
usePermission: () => ({
hasPermission: (perm: string) => mockCanWrite || perm !== 'delegations:write',
}),
}));
vi.mock('@/store/authStore', () => ({
useAuthStore: (selector: (s: { user: { id: string } }) => unknown) =>
selector({ user: { id: CURRENT_USER_ID } }),
}));
function renderPage() {
return render(
<MemoryRouter>
<DelegationsPage />
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
mockItems = [];
mockCanWrite = true;
mockError = false;
});
describe('DelegationsPage', () => {
it('renders the page with title and direction tabs', () => {
renderPage();
expect(screen.getByTestId('delegations-page')).toBeInTheDocument();
expect(screen.getByTestId('delegation-tab-all')).toBeInTheDocument();
expect(screen.getByTestId('delegation-tab-from')).toBeInTheDocument();
expect(screen.getByTestId('delegation-tab-to')).toBeInTheDocument();
});
it('shows empty state when no delegations exist', () => {
renderPage();
expect(screen.getByTestId('delegations-empty')).toBeInTheDocument();
});
it('shows error state on load failure', () => {
mockError = true;
renderPage();
expect(screen.getByTestId('delegations-error')).toBeInTheDocument();
});
it('renders delegation cards with phase badges', () => {
mockItems = [
makeDelegation(),
makeDelegation({ id: 'dg-2', active: false }),
];
renderPage();
expect(screen.getByTestId('delegation-card-dg-1')).toBeInTheDocument();
expect(screen.getByTestId('delegation-card-dg-2')).toBeInTheDocument();
// dg-1: 2026 range is in the past relative to real now — phase derived
// from dates; both badges must exist with the right testids
expect(screen.getByTestId('delegation-phase-dg-1')).toBeInTheDocument();
expect(screen.getByTestId('delegation-phase-dg-2')).toBeInTheDocument();
// inactive card shows the inactive badge text
expect(screen.getByTestId('delegation-phase-dg-2')).toHaveTextContent(/inactive|inaktiv/i);
});
it('shows create button only with delegations:write', () => {
const { unmount } = renderPage();
expect(screen.getByTestId('delegation-create-open')).toBeInTheDocument();
unmount();
mockCanWrite = false;
const { getByTestId } = renderPage();
expect(getByTestId('delegations-page')).toBeInTheDocument();
// create button is NOT rendered without the permission
expect(screen.queryByTestId('delegation-create-open')).not.toBeInTheDocument();
});
it('hides action buttons without delegations:write', () => {
mockItems = [makeDelegation()];
mockCanWrite = false;
renderPage();
expect(screen.queryByTestId('delegation-toggle-dg-1')).not.toBeInTheDocument();
expect(screen.queryByTestId('delegation-delete-dg-1')).not.toBeInTheDocument();
});
it('opens the create dialog, picks a recipient and submits', async () => {
renderPage();
fireEvent.click(screen.getByTestId('delegation-create-open'));
// Dialog is open
expect(screen.getByTestId('delegation-create-submit')).toBeInTheDocument();
const select = screen.getByRole('combobox');
fireEvent.change(select, { target: { value: OTHER_USER.id } });
// The Input component renders label htmlFor + matching input id —
// resolve the datetime inputs via their label text (i18n-resolved:
// de "Beginn" / "Ende", en "Start" / "End").
const startInput = (screen.getByLabelText(/beginn|start/i) ||
document.querySelector('input[type="datetime-local"]')) as HTMLInputElement;
const dateInputs = Array.from(document.querySelectorAll('input[type="datetime-local"]')) as HTMLInputElement[];
const startEl = startInput || dateInputs[0];
const endEl = dateInputs[1] ?? dateInputs[0];
fireEvent.change(startEl, { target: { value: '2026-09-14T09:00' } });
fireEvent.change(endEl, { target: { value: '2026-09-20T09:00' } });
fireEvent.click(screen.getByTestId('delegation-create-submit'));
await waitFor(() => {
// createMut IS the mutate function: mutate(payload, { onSuccess }) — two args
expect(createMut).toHaveBeenCalledWith(
expect.objectContaining({
to_user_id: OTHER_USER.id,
scope: { all: true },
}),
expect.anything(),
);
});
});
it('deactivates a delegation via the toggle button', async () => {
mockItems = [makeDelegation()];
renderPage();
fireEvent.click(screen.getByTestId('delegation-toggle-dg-1'));
await waitFor(() => {
expect(updateMut).toHaveBeenCalledWith(
{ delegationId: 'dg-1', payload: { active: false } },
);
});
});
it('deletes a delegation after confirmation', async () => {
mockItems = [makeDelegation()];
window.confirm = vi.fn(() => true);
renderPage();
fireEvent.click(screen.getByTestId('delegation-delete-dg-1'));
await waitFor(() => {
expect(deleteMut).toHaveBeenCalledWith('dg-1');
});
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* Delegations API client — temporary permission handovers between users.
*
* Backend: /api/v1/delegations (list, create, update, delete, active-check).
* Lifecycle: active while now is within [start_at, end_at] and active=true.
* Permissions: delegations:read / delegations:write.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPut, apiDelete } from '@/api/client';
export type DelegationDirection = 'from' | 'to' | 'all';
export interface Delegation {
id: string;
from_user_id: string;
to_user_id: string;
start_at: string | null;
end_at: string | null;
scope: Record<string, unknown> | null;
active: boolean;
tenant_id: string;
created_at: string | null;
updated_at: string | null;
}
export interface DelegationListResponse {
items: Delegation[];
total: number;
}
export interface DelegationCreatePayload {
to_user_id: string;
start_at: string;
end_at: string;
scope?: Record<string, unknown> | null;
}
export interface DelegationUpdatePayload {
start_at?: string;
end_at?: string;
scope?: Record<string, unknown> | null;
active?: boolean;
}
export interface ActiveDelegationCheck {
is_active: boolean;
active_delegations: Delegation[];
count: number;
}
// ─── Query hooks ─────────────────────────────────────────────
export function useDelegations(direction: DelegationDirection = 'all') {
return useQuery<DelegationListResponse>({
queryKey: ['delegations', direction],
queryFn: () =>
apiGet<DelegationListResponse>(`/delegations?direction=${direction}`),
});
}
export function useActiveDelegations() {
return useQuery<ActiveDelegationCheck>({
queryKey: ['delegations', 'active-check'],
queryFn: () => apiGet<ActiveDelegationCheck>('/delegations/active'),
});
}
// ─── Mutation hooks ──────────────────────────────────────────
function useInvalidateDelegations() {
const qc = useQueryClient();
return () => {
qc.invalidateQueries({ queryKey: ['delegations'] });
};
}
export function useCreateDelegation() {
const invalidate = useInvalidateDelegations();
return useMutation<Delegation, Error, DelegationCreatePayload>({
mutationFn: (data) => apiPost<Delegation>('/delegations', data),
onSuccess: invalidate,
});
}
export function useUpdateDelegation() {
const invalidate = useInvalidateDelegations();
return useMutation<
Delegation,
Error,
{ delegationId: string; payload: DelegationUpdatePayload }
>({
mutationFn: ({ delegationId, payload }) =>
apiPut<Delegation>(`/delegations/${delegationId}`, payload),
onSuccess: invalidate,
});
}
export function useDeleteDelegation() {
const invalidate = useInvalidateDelegations();
return useMutation<void, Error, string>({
mutationFn: (delegationId) =>
apiDelete(`/delegations/${delegationId}`),
onSuccess: invalidate,
});
}
+2 -1
View File
@@ -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 , CheckCircle2 } from 'lucide-react';
import { ArrowLeft, ArrowRightLeft, CheckCircle2, ChevronRight, FileText, Home, Settings, Users } 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 {
@@ -56,6 +56,7 @@ const singleItems: NavSingleItem[] = [
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: <Home className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 0 },
{ to: '/system-dashboard', labelKey: 'nav.systemDashboard', icon: <Activity className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 90 },
{ to: '/approvals', labelKey: 'nav.approvals', icon: <CheckCircle2 className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 91 },
{ to: '/delegations', labelKey: 'nav.delegations', icon: <ArrowRightLeft className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 92 },
];
const bottomItems: NavSingleItem[] = [];
+29 -1
View File
@@ -22,7 +22,8 @@
"tasks": "Aufgaben",
"wiki": "Wiki",
"systemDashboard": "System Dashboard",
"approvals": "Freigaben"
"approvals": "Freigaben",
"delegations": "Delegationen"
},
"auth": {
"login": "Anmelden",
@@ -1631,5 +1632,32 @@
"requesterUser": "Benutzer",
"requesterAgent": "Agent",
"requesterSystem": "System"
},
"delegations": {
"title": "Delegationen",
"tabAll": "Alle",
"tabFrom": "Von mir vergeben",
"tabTo": "An mich vergeben",
"create": "Delegation erstellen",
"createTitle": "Berechtigungen delegieren",
"createSubmit": "Delegieren",
"recipient": "Empfaenger",
"recipientPlaceholder": "Benutzer auswaehlen",
"startAt": "Beginn",
"endAt": "Ende",
"scopeAll": "Alle Berechtigungen delegieren",
"cardTo": "An {{user}}",
"cardFrom": "Von {{user}}",
"period": "Zeitraum:",
"phase_active": "Aktiv",
"phase_upcoming": "Geplant",
"phase_expired": "Abgelaufen",
"phase_inactive": "Inaktiv",
"activate": "Aktivieren",
"deactivate": "Deaktivieren",
"delete": "Loeschen",
"deleteConfirm": "Diese Delegation wirklich loeschen?",
"empty": "Keine Delegationen in dieser Ansicht.",
"loadError": "Delegationen konnten nicht geladen werden."
}
}
+29 -1
View File
@@ -22,7 +22,8 @@
"tasks": "Tasks",
"wiki": "Wiki",
"systemDashboard": "System Dashboard",
"approvals": "Approvals"
"approvals": "Approvals",
"delegations": "Delegations"
},
"auth": {
"login": "Sign In",
@@ -1631,5 +1632,32 @@
"requesterUser": "User",
"requesterAgent": "Agent",
"requesterSystem": "System"
},
"delegations": {
"title": "Delegations",
"tabAll": "All",
"tabFrom": "Given by me",
"tabTo": "Given to me",
"create": "Create delegation",
"createTitle": "Delegate permissions",
"createSubmit": "Delegate",
"recipient": "Recipient",
"recipientPlaceholder": "Select a user",
"startAt": "Start",
"endAt": "End",
"scopeAll": "Delegate all permissions",
"cardTo": "To {{user}}",
"cardFrom": "From {{user}}",
"period": "Period:",
"phase_active": "Active",
"phase_upcoming": "Upcoming",
"phase_expired": "Expired",
"phase_inactive": "Inactive",
"activate": "Activate",
"deactivate": "Deactivate",
"delete": "Delete",
"deleteConfirm": "Really delete this delegation?",
"empty": "No delegations in this view.",
"loadError": "Failed to load delegations."
}
}
+364
View File
@@ -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;
+2
View File
@@ -44,6 +44,7 @@ const AutomationSettingsPage = React.lazy(() => import('@/pages/AutomationSettin
const CustomFieldsPage = React.lazy(() => import('@/pages/CustomFields').then(m => ({ default: m.CustomFieldsPage })));
const ActivityTimelinePage = React.lazy(() => import('@/pages/ActivityTimeline').then(m => ({ default: m.ActivityTimelinePage })));
const ApprovalsPage = React.lazy(() => import('@/pages/Approvals').then(m => ({ default: m.ApprovalsPage })));
const DelegationsPage = React.lazy(() => import('@/pages/Delegations').then(m => ({ default: m.DelegationsPage })));
const SettingsWebhooksPage = React.lazy(() => import('@/pages/SettingsWebhooks').then(m => ({ default: m.SettingsWebhooksPage })));
const SettingsBackupPage = React.lazy(() => import('@/pages/SettingsBackup').then(m => ({ default: m.SettingsBackupPage })));
const WorkspaceManagerPage = React.lazy(() => import('@/pages/SettingsWorkspaces').then(m => ({ default: m.WorkspaceManagerPage })));
@@ -226,6 +227,7 @@ const router = createBrowserRouter([
// via PluginRouteRenderer below - single source of truth.
{ path: '/trash', element: <PermissionRoute permission="contacts:read">{withSuspense(<TrashPage />)}</PermissionRoute> },
{ path: '/approvals', element: <PermissionRoute permission="approvals:read">{withSuspense(<ApprovalsPage />)}</PermissionRoute> },
{ path: '/delegations', element: <PermissionRoute permission="delegations:read">{withSuspense(<DelegationsPage />)}</PermissionRoute> },
{ path: '/api-docs', element: <PermissionRoute permission="settings:read">{withSuspense(<ApiDocsPage />)}</PermissionRoute> },
{ path: '/activity', element: <PermissionRoute permission="audit:read">{withSuspense(<ActivityTimelinePage />)}</PermissionRoute> },
{ path: '/system-dashboard', element: withSuspense(<SystemDashboardPage />) },