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,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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 />) },
|
||||
|
||||
Reference in New Issue
Block a user