feat(templates): UI fuer Berechtigungs-Vorlagen — Modul 6/16 des UI-Backlogs

Backend existierte vollstaendig (list mit entity_type-Filter, create, update,
delete, apply — templates:read/write, seit Audit-Fix im Katalog), Frontend
hatte 0% Abdeckung.

- api/permissionTemplates.ts: TanStack-Hooks (usePermissionTemplates,
  create/update/delete/apply mit Cache-Invalidierung, TemplateLevel-Typ)
- pages/PermissionTemplates.tsx: Template-Karten (Name, Level-Badge,
  Entity-Type, Auto-Share-Zusammenfassung), Create/Edit-Dialog mit
  Level-Select und JSON-Textarea inkl. Array-Validierung mit Fehlertext,
  Apply-Dialog (Entity-Type vorbelegt, Entity-ID), Ergebnis-Banner mit
  Anzahl erstellter Berechtigungen, Delete mit Confirm — alle Aktionen
  hinter templates:write gegated
- Platzierung: Settings-Subpage /settings/permission-templates (statisch,
  Core-Route) + Settings-Nav-Item
- i18n permissionTemplates.* de/en

Verifikation: Vitest 10/10 (Rendering, Level-Badges, Create mit validem +
invalidem JSON, Edit prefilled, Apply mit Ergebnis, Delete-Confirm,
Permission-Gating, Empty/Error) · tsc exit 0 · production build exit 0.
This commit is contained in:
Agent Zero
2026-09-13 10:27:10 +02:00
parent c99d2f19ef
commit 33b4b52206
7 changed files with 880 additions and 0 deletions
@@ -0,0 +1,240 @@
/**
* PermissionTemplates page tests — reusable permission presets (module 6/16).
*
* Covers: rendering, template cards with level badges, create flow with
* JSON validation, edit flow, apply flow, delete with confirmation,
* permission gating (templates:write), 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 { PermissionTemplatesPage } from '@/pages/PermissionTemplates';
import type { PermissionTemplate } from '@/api/permissionTemplates';
const createMut = vi.fn().mockImplementation((_payload, opts) => {
opts?.onSuccess?.({});
return Promise.resolve({});
});
const updateMut = vi.fn().mockImplementation((_payload, opts) => {
opts?.onSuccess?.({});
return Promise.resolve({});
});
const deleteMut = vi.fn().mockResolvedValue({});
const applyMut = vi.fn().mockImplementation((_payload, opts) => {
opts?.onSuccess?.({ applied: [{ id: 'ep-1' }], count: 1 });
return Promise.resolve({});
});
const makeTemplate = (overrides: Partial<PermissionTemplate> = {}): PermissionTemplate => ({
id: 'tp-1',
name: 'Standard-Freigabe Kontakte',
entity_type: 'contact',
trigger_condition: null,
auto_share_with: [
{ principal_type: 'user', principal_id: '22222222-2222-2222-2222-222222222222', level: 'read' },
],
level: 'read',
tenant_id: 't-1',
created_at: '2026-09-01T10:00:00Z',
updated_at: null,
...overrides,
});
let mockItems: PermissionTemplate[] = [];
let mockCanWrite = true;
let mockError = false;
vi.mock('@/api/permissionTemplates', () => ({
usePermissionTemplates: () => ({
data: { items: mockItems, total: mockItems.length },
isLoading: false,
isError: mockError,
isFetching: false,
refetch: vi.fn(),
}),
useCreatePermissionTemplate: () => ({
mutate: createMut,
isPending: false,
}),
useUpdatePermissionTemplate: () => ({
mutate: updateMut,
isPending: false,
}),
useDeletePermissionTemplate: () => ({
mutate: deleteMut,
isPending: false,
}),
useApplyPermissionTemplate: () => ({
mutate: applyMut,
isPending: false,
}),
}));
vi.mock('@/hooks/usePermission', () => ({
usePermission: () => ({
hasPermission: (perm: string) => mockCanWrite || perm !== 'templates:write',
}),
}));
function renderPage() {
return render(
<MemoryRouter>
<PermissionTemplatesPage />
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
mockItems = [];
mockCanWrite = true;
mockError = false;
});
describe('PermissionTemplatesPage', () => {
it('renders the page with title', () => {
renderPage();
expect(screen.getByTestId('permission-templates-page')).toBeInTheDocument();
});
it('shows empty state when no templates exist', () => {
renderPage();
expect(screen.getByTestId('templates-empty')).toBeInTheDocument();
});
it('shows error state on load failure', () => {
mockError = true;
renderPage();
expect(screen.getByTestId('templates-error')).toBeInTheDocument();
});
it('renders template cards with level badge and entity type', () => {
mockItems = [makeTemplate()];
renderPage();
expect(screen.getByTestId('template-card-tp-1')).toBeInTheDocument();
expect(screen.getByText('Standard-Freigabe Kontakte')).toBeInTheDocument();
expect(screen.getByTestId('template-level-tp-1')).toBeInTheDocument();
expect(screen.getByText('contact')).toBeInTheDocument();
});
it('hides action buttons without templates:write', () => {
mockItems = [makeTemplate()];
mockCanWrite = false;
renderPage();
expect(screen.queryByTestId('template-create-open')).not.toBeInTheDocument();
expect(screen.queryByTestId('template-edit-tp-1')).not.toBeInTheDocument();
expect(screen.queryByTestId('template-delete-tp-1')).not.toBeInTheDocument();
expect(screen.queryByTestId('template-apply-tp-1')).not.toBeInTheDocument();
});
it('creates a template via the dialog with valid JSON', async () => {
renderPage();
fireEvent.click(screen.getByTestId('template-create-open'));
expect(screen.getByTestId('template-form-submit')).toBeInTheDocument();
const nameInput = screen.getByLabelText(/name/i, { selector: 'input' }) as HTMLInputElement;
fireEvent.change(nameInput, { target: { value: 'Neue Vorlage' } });
const typeInputs = screen.getAllByLabelText(/entit/i, { selector: 'input' });
fireEvent.change(typeInputs[0], { target: { value: 'contact' } });
const jsonArea = screen.getByTestId('template-share-json') as HTMLTextAreaElement;
fireEvent.change(jsonArea, {
target: { value: '[{"principal_type": "user", "principal_id": "abc", "level": "read"}]' },
});
fireEvent.click(screen.getByTestId('template-form-submit'));
await waitFor(() => {
expect(createMut).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Neue Vorlage',
entity_type: 'contact',
auto_share_with: [{ principal_type: 'user', principal_id: 'abc', level: 'read' }],
}),
expect.anything(),
);
});
});
it('rejects invalid JSON in the create dialog', () => {
renderPage();
fireEvent.click(screen.getByTestId('template-create-open'));
const nameInput = screen.getByLabelText(/name/i, { selector: 'input' }) as HTMLInputElement;
fireEvent.change(nameInput, { target: { value: 'Neue Vorlage' } });
const typeInputs = screen.getAllByLabelText(/entit/i, { selector: 'input' });
fireEvent.change(typeInputs[0], { target: { value: 'contact' } });
const jsonArea = screen.getByTestId('template-share-json') as HTMLTextAreaElement;
fireEvent.change(jsonArea, { target: { value: '{invalid json' } });
fireEvent.click(screen.getByTestId('template-form-submit'));
expect(createMut).not.toHaveBeenCalled();
expect(screen.getByRole('alert')).toBeInTheDocument();
});
it('opens the edit dialog prefilled and submits the update', async () => {
mockItems = [makeTemplate()];
renderPage();
fireEvent.click(screen.getByTestId('template-edit-tp-1'));
const nameInput = screen.getByLabelText(/name/i, { selector: 'input' }) as HTMLInputElement;
expect(nameInput).toHaveValue('Standard-Freigabe Kontakte');
fireEvent.change(nameInput, { target: { value: 'Geänderte Vorlage' } });
fireEvent.click(screen.getByTestId('template-form-submit'));
await waitFor(() => {
expect(updateMut).toHaveBeenCalledWith(
{
templateId: 'tp-1',
payload: expect.objectContaining({
name: 'Geänderte Vorlage',
entity_type: 'contact',
}),
},
expect.anything(),
);
});
});
it('applies a template to an entity and shows the result count', async () => {
mockItems = [makeTemplate()];
renderPage();
fireEvent.click(screen.getByTestId('template-apply-tp-1'));
const entityInputs = screen.getAllByLabelText(/entit/i, { selector: 'input' });
const entityIdInput = entityInputs.find(
(el) => (el as HTMLInputElement).placeholder?.includes('00000000'),
) as HTMLInputElement;
fireEvent.change(entityIdInput, {
target: { value: '11111111-1111-1111-1111-111111111111' },
});
fireEvent.click(screen.getByTestId('template-apply-submit'));
await waitFor(() => {
expect(applyMut).toHaveBeenCalledWith(
{
entity_type: 'contact',
entity_id: '11111111-1111-1111-1111-111111111111',
template_id: 'tp-1',
},
expect.anything(),
);
});
expect(await screen.findByTestId('templates-result')).toBeInTheDocument();
});
it('deletes a template after confirmation', async () => {
mockItems = [makeTemplate()];
window.confirm = vi.fn(() => true);
renderPage();
fireEvent.click(screen.getByTestId('template-delete-tp-1'));
await waitFor(() => {
expect(deleteMut).toHaveBeenCalledWith('tp-1');
});
});
});