From 36771d471d79d4bf22af72e7abf68adfccf1b523 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 13 Sep 2026 09:17:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(delegations):=20UI=20fuer=20Berechtigungs-?= =?UTF-8?q?Delegationen=20=E2=80=94=20Modul=202/16=20des=20UI-Backlogs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/__tests__/pages/Delegations.test.tsx | 220 +++++++++++ frontend/src/api/delegations.ts | 106 +++++ frontend/src/components/layout/Sidebar.tsx | 3 +- frontend/src/i18n/locales/de.json | 30 +- frontend/src/i18n/locales/en.json | 30 +- frontend/src/pages/Delegations.tsx | 364 ++++++++++++++++++ frontend/src/routes/index.tsx | 2 + 7 files changed, 752 insertions(+), 3 deletions(-) create mode 100644 frontend/src/__tests__/pages/Delegations.test.tsx create mode 100644 frontend/src/api/delegations.ts create mode 100644 frontend/src/pages/Delegations.tsx diff --git a/frontend/src/__tests__/pages/Delegations.test.tsx b/frontend/src/__tests__/pages/Delegations.test.tsx new file mode 100644 index 0000000..9029a44 --- /dev/null +++ b/frontend/src/__tests__/pages/Delegations.test.tsx @@ -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 => ({ + 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( + + + , + ); +} + +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'); + }); + }); +}); diff --git a/frontend/src/api/delegations.ts b/frontend/src/api/delegations.ts new file mode 100644 index 0000000..60f3d13 --- /dev/null +++ b/frontend/src/api/delegations.ts @@ -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 | 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 | null; +} + +export interface DelegationUpdatePayload { + start_at?: string; + end_at?: string; + scope?: Record | null; + active?: boolean; +} + +export interface ActiveDelegationCheck { + is_active: boolean; + active_delegations: Delegation[]; + count: number; +} + +// ─── Query hooks ───────────────────────────────────────────── + +export function useDelegations(direction: DelegationDirection = 'all') { + return useQuery({ + queryKey: ['delegations', direction], + queryFn: () => + apiGet(`/delegations?direction=${direction}`), + }); +} + +export function useActiveDelegations() { + return useQuery({ + queryKey: ['delegations', 'active-check'], + queryFn: () => apiGet('/delegations/active'), + }); +} + +// ─── Mutation hooks ────────────────────────────────────────── + +function useInvalidateDelegations() { + const qc = useQueryClient(); + return () => { + qc.invalidateQueries({ queryKey: ['delegations'] }); + }; +} + +export function useCreateDelegation() { + const invalidate = useInvalidateDelegations(); + return useMutation({ + mutationFn: (data) => apiPost('/delegations', data), + onSuccess: invalidate, + }); +} + +export function useUpdateDelegation() { + const invalidate = useInvalidateDelegations(); + return useMutation< + Delegation, + Error, + { delegationId: string; payload: DelegationUpdatePayload } + >({ + mutationFn: ({ delegationId, payload }) => + apiPut(`/delegations/${delegationId}`, payload), + onSuccess: invalidate, + }); +} + +export function useDeleteDelegation() { + const invalidate = useInvalidateDelegations(); + return useMutation({ + mutationFn: (delegationId) => + apiDelete(`/delegations/${delegationId}`), + onSuccess: invalidate, + }); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 7f52cee..d08a6a6 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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: