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 { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useUIStore } from '@/store/uiStore';
|
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';
|
import { usePluginStore } from '@/store/pluginStore';
|
||||||
// Curated icon map — avoids `import * as LucideIcons` which loads ALL icons and causes OOM in tests
|
// Curated icon map — avoids `import * as LucideIcons` which loads ALL icons and causes OOM in tests
|
||||||
import {
|
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: '/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: '/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: '/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[] = [];
|
const bottomItems: NavSingleItem[] = [];
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
"tasks": "Aufgaben",
|
"tasks": "Aufgaben",
|
||||||
"wiki": "Wiki",
|
"wiki": "Wiki",
|
||||||
"systemDashboard": "System Dashboard",
|
"systemDashboard": "System Dashboard",
|
||||||
"approvals": "Freigaben"
|
"approvals": "Freigaben",
|
||||||
|
"delegations": "Delegationen"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Anmelden",
|
"login": "Anmelden",
|
||||||
@@ -1631,5 +1632,32 @@
|
|||||||
"requesterUser": "Benutzer",
|
"requesterUser": "Benutzer",
|
||||||
"requesterAgent": "Agent",
|
"requesterAgent": "Agent",
|
||||||
"requesterSystem": "System"
|
"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",
|
"tasks": "Tasks",
|
||||||
"wiki": "Wiki",
|
"wiki": "Wiki",
|
||||||
"systemDashboard": "System Dashboard",
|
"systemDashboard": "System Dashboard",
|
||||||
"approvals": "Approvals"
|
"approvals": "Approvals",
|
||||||
|
"delegations": "Delegations"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Sign In",
|
"login": "Sign In",
|
||||||
@@ -1631,5 +1632,32 @@
|
|||||||
"requesterUser": "User",
|
"requesterUser": "User",
|
||||||
"requesterAgent": "Agent",
|
"requesterAgent": "Agent",
|
||||||
"requesterSystem": "System"
|
"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 CustomFieldsPage = React.lazy(() => import('@/pages/CustomFields').then(m => ({ default: m.CustomFieldsPage })));
|
||||||
const ActivityTimelinePage = React.lazy(() => import('@/pages/ActivityTimeline').then(m => ({ default: m.ActivityTimelinePage })));
|
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 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 SettingsWebhooksPage = React.lazy(() => import('@/pages/SettingsWebhooks').then(m => ({ default: m.SettingsWebhooksPage })));
|
||||||
const SettingsBackupPage = React.lazy(() => import('@/pages/SettingsBackup').then(m => ({ default: m.SettingsBackupPage })));
|
const SettingsBackupPage = React.lazy(() => import('@/pages/SettingsBackup').then(m => ({ default: m.SettingsBackupPage })));
|
||||||
const WorkspaceManagerPage = React.lazy(() => import('@/pages/SettingsWorkspaces').then(m => ({ default: m.WorkspaceManagerPage })));
|
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.
|
// via PluginRouteRenderer below - single source of truth.
|
||||||
{ path: '/trash', element: <PermissionRoute permission="contacts:read">{withSuspense(<TrashPage />)}</PermissionRoute> },
|
{ path: '/trash', element: <PermissionRoute permission="contacts:read">{withSuspense(<TrashPage />)}</PermissionRoute> },
|
||||||
{ path: '/approvals', element: <PermissionRoute permission="approvals:read">{withSuspense(<ApprovalsPage />)}</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: '/api-docs', element: <PermissionRoute permission="settings:read">{withSuspense(<ApiDocsPage />)}</PermissionRoute> },
|
||||||
{ path: '/activity', element: <PermissionRoute permission="audit:read">{withSuspense(<ActivityTimelinePage />)}</PermissionRoute> },
|
{ path: '/activity', element: <PermissionRoute permission="audit:read">{withSuspense(<ActivityTimelinePage />)}</PermissionRoute> },
|
||||||
{ path: '/system-dashboard', element: withSuspense(<SystemDashboardPage />) },
|
{ path: '/system-dashboard', element: withSuspense(<SystemDashboardPage />) },
|
||||||
|
|||||||
Reference in New Issue
Block a user