From 33b4b52206632154f4a6736dd63a60c7ccbcd02c Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 13 Sep 2026 10:27:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(templates):=20UI=20fuer=20Berechtigungs-Vo?= =?UTF-8?q?rlagen=20=E2=80=94=20Modul=206/16=20des=20UI-Backlogs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../pages/PermissionTemplates.test.tsx | 240 +++++++++ frontend/src/api/permissionTemplates.ts | 111 +++++ frontend/src/i18n/locales/de.json | 35 ++ frontend/src/i18n/locales/en.json | 35 ++ frontend/src/pages/PermissionTemplates.tsx | 456 ++++++++++++++++++ frontend/src/pages/Settings.tsx | 1 + frontend/src/routes/index.tsx | 2 + 7 files changed, 880 insertions(+) create mode 100644 frontend/src/__tests__/pages/PermissionTemplates.test.tsx create mode 100644 frontend/src/api/permissionTemplates.ts create mode 100644 frontend/src/pages/PermissionTemplates.tsx diff --git a/frontend/src/__tests__/pages/PermissionTemplates.test.tsx b/frontend/src/__tests__/pages/PermissionTemplates.test.tsx new file mode 100644 index 0000000..0adccd8 --- /dev/null +++ b/frontend/src/__tests__/pages/PermissionTemplates.test.tsx @@ -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 => ({ + 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( + + + , + ); +} + +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'); + }); + }); +}); diff --git a/frontend/src/api/permissionTemplates.ts b/frontend/src/api/permissionTemplates.ts new file mode 100644 index 0000000..0f3fb66 --- /dev/null +++ b/frontend/src/api/permissionTemplates.ts @@ -0,0 +1,111 @@ +/** + * Permission Templates API client — reusable permission presets. + * + * Backend: /api/v1/permission-templates (list, create, update, delete, + * apply). Templates define default sharing rules; applying them creates + * entity_permissions entries automatically. + * Permissions: templates:read (list) / templates:write (everything else). + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiGet, apiPost, apiPut, apiDelete } from '@/api/client'; + +export type TemplateLevel = 'read' | 'write' | 'admin' | 'delete'; + +export interface ShareEntry { + principal_type?: string; + principal_id?: string; + level?: string; +} + +export interface PermissionTemplate { + id: string; + name: string; + entity_type: string; + trigger_condition: Record | null; + auto_share_with: ShareEntry[] | null; + level: TemplateLevel; + tenant_id: string; + created_at: string | null; + updated_at: string | null; +} + +export interface TemplateListResponse { + items: PermissionTemplate[]; + total: number; +} + +export interface TemplateCreatePayload { + name: string; + entity_type: string; + level?: TemplateLevel; + trigger_condition?: Record | null; + auto_share_with?: ShareEntry[] | null; +} + +export type TemplateUpdatePayload = Partial; + +export interface TemplateApplyPayload { + entity_type: string; + entity_id: string; + template_id?: string | null; +} + +export interface TemplateApplyResponse { + applied: Array>; + count: number; +} + +// ─── Query hooks ───────────────────────────────────────────── + +export function usePermissionTemplates(entityType?: string) { + const qs = entityType ? `?entity_type=${encodeURIComponent(entityType)}` : ''; + return useQuery({ + queryKey: ['permission-templates', entityType ?? null], + queryFn: () => apiGet(`/permission-templates${qs}`), + }); +} + +// ─── Mutation hooks ────────────────────────────────────────── + +function useInvalidateTemplates() { + const qc = useQueryClient(); + return () => { + qc.invalidateQueries({ queryKey: ['permission-templates'] }); + }; +} + +export function useCreatePermissionTemplate() { + const invalidate = useInvalidateTemplates(); + return useMutation({ + mutationFn: (data) => apiPost('/permission-templates', data), + onSuccess: invalidate, + }); +} + +export function useUpdatePermissionTemplate() { + const invalidate = useInvalidateTemplates(); + return useMutation< + PermissionTemplate, + Error, + { templateId: string; payload: TemplateUpdatePayload } + >({ + mutationFn: ({ templateId, payload }) => + apiPut(`/permission-templates/${templateId}`, payload), + onSuccess: invalidate, + }); +} + +export function useDeletePermissionTemplate() { + const invalidate = useInvalidateTemplates(); + return useMutation({ + mutationFn: (templateId) => apiDelete(`/permission-templates/${templateId}`), + onSuccess: invalidate, + }); +} + +export function useApplyPermissionTemplate() { + return useMutation({ + mutationFn: (data) => apiPost('/permission-templates/apply', data), + }); +} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 85b3096..41d273c 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1723,5 +1723,40 @@ "empty": "Keine Plugins gefunden.", "loadError": "Marketplace konnte nicht geladen werden.", "noAccess": "Kein Zugriff auf den Marketplace (marketplace:read noetig)." + }, + "permissionTemplates": { + "title": "Berechtigungs-Vorlagen", + "create": "Vorlage erstellen", + "createTitle": "Neue Berechtigungs-Vorlage", + "createSubmit": "Erstellen", + "editTitle": "Vorlage bearbeiten", + "save": "Speichern", + "name": "Name", + "namePlaceholder": "z.B. Standard-Freigabe Kontakte", + "entityType": "Entitaets-Typ", + "entityTypePlaceholder": "z.B. contact", + "entityId": "Entitaets-ID", + "level": "Standard-Level", + "levelRead": "Lesen", + "levelWrite": "Schreiben", + "levelAdmin": "Admin", + "levelDelete": "Loeschen", + "level_read": "Lesen", + "level_write": "Schreiben", + "level_admin": "Admin", + "level_delete": "Loeschen", + "autoShareWith": "Automatisch teilen mit", + "autoShareJson": "Freigaben (JSON)", + "autoShareHelper": "Liste von {principal_type, principal_id, level}. Leer = keine Auto-Freigaben.", + "jsonInvalid": "Ungueltiges JSON", + "jsonMustBeArray": "Das JSON muss eine Liste sein", + "apply": "Anwenden", + "applyTitle": "Vorlage '{{name}}' anwenden", + "applySubmit": "Anwenden", + "appliedCount": "{{count}} Berechtigung(en) erstellt", + "delete": "Loeschen", + "deleteConfirm": "Vorlage '{{name}}' wirklich loeschen?", + "empty": "Keine Berechtigungs-Vorlagen vorhanden.", + "loadError": "Vorlagen konnten nicht geladen werden." } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 7d8cdaf..ab18cc0 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1723,5 +1723,40 @@ "empty": "No plugins found.", "loadError": "Failed to load the marketplace.", "noAccess": "No marketplace access (marketplace:read required)." + }, + "permissionTemplates": { + "title": "Permission Templates", + "create": "Create template", + "createTitle": "New permission template", + "createSubmit": "Create", + "editTitle": "Edit template", + "save": "Save", + "name": "Name", + "namePlaceholder": "e.g. Standard contact sharing", + "entityType": "Entity type", + "entityTypePlaceholder": "e.g. contact", + "entityId": "Entity ID", + "level": "Default level", + "levelRead": "Read", + "levelWrite": "Write", + "levelAdmin": "Admin", + "levelDelete": "Delete", + "level_read": "Read", + "level_write": "Write", + "level_admin": "Admin", + "level_delete": "Delete", + "autoShareWith": "Auto-share with", + "autoShareJson": "Share entries (JSON)", + "autoShareHelper": "List of {principal_type, principal_id, level}. Empty = no auto-shares.", + "jsonInvalid": "Invalid JSON", + "jsonMustBeArray": "The JSON must be an array", + "apply": "Apply", + "applyTitle": "Apply template '{{name}}'", + "applySubmit": "Apply", + "appliedCount": "{{count}} permission(s) created", + "delete": "Delete", + "deleteConfirm": "Really delete template '{{name}}'?", + "empty": "No permission templates yet.", + "loadError": "Failed to load templates." } } diff --git a/frontend/src/pages/PermissionTemplates.tsx b/frontend/src/pages/PermissionTemplates.tsx new file mode 100644 index 0000000..4bcae8d --- /dev/null +++ b/frontend/src/pages/PermissionTemplates.tsx @@ -0,0 +1,456 @@ +/** + * Permission Templates settings page — reusable permission presets + * (UI-Backlog module 6/16). + * + * Backend: /api/v1/permission-templates (list, create, update, delete, + * apply). Applying a template creates entity_permissions entries from its + * auto_share_with list. + * Permissions: templates:read (list) / templates:write (CRUD + apply). + */ + +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + LayoutTemplate, + Plus, + Pencil, + Trash2, + Play, + KeyRound, + AlertTriangle, + Inbox, +} from 'lucide-react'; +import { + usePermissionTemplates, + useCreatePermissionTemplate, + useUpdatePermissionTemplate, + useDeletePermissionTemplate, + useApplyPermissionTemplate, + type PermissionTemplate, + type TemplateLevel, + type ShareEntry, +} from '@/api/permissionTemplates'; +import { usePermission } from '@/hooks/usePermission'; +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 LEVELS: { value: TemplateLevel; labelKey: string }[] = [ + { value: 'read', labelKey: 'permissionTemplates.levelRead' }, + { value: 'write', labelKey: 'permissionTemplates.levelWrite' }, + { value: 'admin', labelKey: 'permissionTemplates.levelAdmin' }, + { value: 'delete', labelKey: 'permissionTemplates.levelDelete' }, +]; + +const LEVEL_BADGE: Record = { + read: 'bg-secondary-200 text-secondary-800', + write: 'bg-primary-100 text-primary-800', + admin: 'bg-warning-100 text-warning-800', + delete: 'bg-danger-100 text-danger-800', +}; + +function summarizeShare(share: ShareEntry[] | null): string { + if (!share || share.length === 0) return '—'; + return share + .map((s) => `${s.principal_type ?? 'user'}:${(s.principal_id ?? '').slice(0, 8)} (${s.level ?? 'read'})`) + .join(', '); +} + +function TemplateCard({ + template, + canWrite, + onEdit, + onApply, + onDelete, + isMutating, +}: { + template: PermissionTemplate; + canWrite: boolean; + onEdit: (t: PermissionTemplate) => void; + onApply: (t: PermissionTemplate) => void; + onDelete: (t: PermissionTemplate) => void; + isMutating: boolean; +}) { + const { t } = useTranslation(); + + return ( + +
+
+
+
+
+ {t('permissionTemplates.autoShareWith')}: {summarizeShare(template.auto_share_with)} +
+
+ {canWrite && ( +
+ + + +
+ )} +
+
+ ); +} + +function TemplateFormDialog({ + open, + template, + onClose, + onSubmit, + isSubmitting, +}: { + open: boolean; + template: PermissionTemplate | null; + onClose: () => void; + onSubmit: (payload: { + name: string; + entity_type: string; + level: TemplateLevel; + auto_share_with?: ShareEntry[] | null; + }) => void; + isSubmitting: boolean; +}) { + const { t } = useTranslation(); + const [name, setName] = useState(template?.name ?? ''); + const [entityType, setEntityType] = useState(template?.entity_type ?? ''); + const [level, setLevel] = useState(template?.level ?? 'read'); + const [shareJson, setShareJson] = useState( + template?.auto_share_with ? JSON.stringify(template.auto_share_with, null, 2) : '', + ); + const [jsonError, setJsonError] = useState(null); + + // Reset on open (template may change between open/edit) + React.useEffect(() => { + if (open) { + setName(template?.name ?? ''); + setEntityType(template?.entity_type ?? ''); + setLevel(template?.level ?? 'read'); + setShareJson(template?.auto_share_with ? JSON.stringify(template.auto_share_with, null, 2) : ''); + setJsonError(null); + } + }, [open, template]); + + const parsedShare = (): ShareEntry[] | null | undefined => { + if (!shareJson.trim()) return null; + try { + const parsed = JSON.parse(shareJson); + if (!Array.isArray(parsed)) { + setJsonError(t('permissionTemplates.jsonMustBeArray')); + return undefined; + } + setJsonError(null); + return parsed as ShareEntry[]; + } catch { + setJsonError(t('permissionTemplates.jsonInvalid')); + return undefined; + } + }; + + const valid = name.trim().length > 0 && entityType.trim().length > 0; + + const submit = () => { + if (!valid) return; + const share = parsedShare(); + if (share === undefined) return; + onSubmit({ name: name.trim(), entity_type: entityType.trim(), level, auto_share_with: share }); + }; + + return ( + +
+ setName(e.target.value)} + required + placeholder={t('permissionTemplates.namePlaceholder')} + /> + setEntityType(e.target.value)} + required + placeholder={t('permissionTemplates.entityTypePlaceholder')} + /> +