From d73492363615a97fc68e988040eef9aee2c4d236 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Mon, 14 Sep 2026 23:41:55 +0200 Subject: [PATCH] =?UTF-8?q?feat(policies):=20UI=20fuer=20ABAC-Richtlinien?= =?UTF-8?q?=20mit=20Conditions-Builder=20=E2=80=94=20Modul=2010/16=20des?= =?UTF-8?q?=20UI-Backlogs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/pages/SettingsPolicies.test.tsx | 254 +++++++ frontend/src/api/policies.ts | 154 +++++ frontend/src/i18n/locales/de.json | 43 ++ frontend/src/i18n/locales/en.json | 43 ++ frontend/src/pages/Settings.tsx | 1 + frontend/src/pages/SettingsPolicies.tsx | 630 ++++++++++++++++++ frontend/src/routes/index.tsx | 2 + 7 files changed, 1127 insertions(+) create mode 100644 frontend/src/__tests__/pages/SettingsPolicies.test.tsx create mode 100644 frontend/src/api/policies.ts create mode 100644 frontend/src/pages/SettingsPolicies.tsx diff --git a/frontend/src/__tests__/pages/SettingsPolicies.test.tsx b/frontend/src/__tests__/pages/SettingsPolicies.test.tsx new file mode 100644 index 0000000..21804e9 --- /dev/null +++ b/frontend/src/__tests__/pages/SettingsPolicies.test.tsx @@ -0,0 +1,254 @@ +/** + * Policies settings page tests — ABAC entity policy management UI + * (UI-Backlog module 10/16). + * + * Covers: rendering, entity type tabs, permission gating, policy cards, + * create form with conditions builder, toggle enable/disable, edit flow, + * delete flow. + */ +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 { SettingsPoliciesPage } from '@/pages/SettingsPolicies'; +import type { Policy } from '@/api/policies'; + +const createMut = vi.fn().mockResolvedValue({}); +const updateMut = vi.fn().mockResolvedValue({}); +const deleteMut = vi.fn().mockResolvedValue({}); + +const makePolicy = (overrides: Partial = {}): Policy => ({ + id: '11111111-1111-1111-1111-111111111111', + name: 'VIP-Kunden erlauben', + entity_type: 'contact', + principal_type: 'user', + principal_id: '22222222-2222-2222-2222-222222222222', + effect: 'allow', + conditions: { + operator: 'AND', + rules: [{ field: 'status', op: 'eq', value: 'active' }], + }, + priority: 10, + tenant_id: 't-1', + enabled: true, + created_at: '2026-09-01T10:00:00Z', + updated_at: null, + ...overrides, +}); + +let mockItems: Policy[] = []; +let mockCanRead = true; +let mockCanWrite = true; + +vi.mock('@/api/policies', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + usePolicies: () => ({ + data: { items: mockItems, total: mockItems.length }, + isLoading: false, + isError: false, + }), + useCreatePolicy: () => ({ mutate: createMut, isPending: false }), + useUpdatePolicy: () => ({ mutate: updateMut, isPending: false }), + useDeletePolicy: () => ({ mutate: deleteMut, isPending: false }), + }; +}); + +vi.mock('@/api/users', () => ({ + useUsers: () => ({ + data: { + items: [ + { id: '22222222-2222-2222-2222-222222222222', name: 'Admin User', email: 'admin@test.de' }, + ], + }, + }), +})); + +vi.mock('@/api/groups', () => ({ + useGroups: () => ({ + data: { + items: [{ id: '33333333-3333-3333-3333-333333333333', name: 'Sales' }], + }, + }), +})); + +vi.mock('@/api/roles', () => ({ + useRoles: () => ({ + data: { + items: [{ id: '44444444-4444-4444-4444-444444444444', name: 'manager' }], + }, + }), +})); + +vi.mock('@/hooks/usePermission', () => ({ + usePermission: () => ({ + hasPermission: (perm: string) => + (mockCanRead || perm !== 'policies:read') && + (mockCanWrite || perm !== 'policies:write'), + }), +})); + +function renderPage() { + return render( + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockItems = []; + mockCanRead = true; + mockCanWrite = true; + vi.spyOn(window, 'confirm').mockReturnValue(true); +}); + +describe('SettingsPoliciesPage', () => { + it('renders page title and entity type tabs', () => { + renderPage(); + expect(screen.getByTestId('policies-page')).toBeInTheDocument(); + expect(screen.getByTestId('policy-tab-contact')).toBeInTheDocument(); + expect(screen.getByTestId('policy-tab-file')).toBeInTheDocument(); + expect(screen.getByTestId('policy-tab-task')).toBeInTheDocument(); + expect(screen.getByTestId('policy-tab-workflow')).toBeInTheDocument(); + }); + + it('shows empty state when no policies exist', () => { + renderPage(); + expect(screen.getByTestId('policies-empty')).toBeInTheDocument(); + }); + + it('shows no-permission card when policies:read is missing', () => { + mockCanRead = false; + renderPage(); + expect(screen.getByTestId('policies-no-permission')).toBeInTheDocument(); + expect(screen.queryByTestId('policies-empty')).not.toBeInTheDocument(); + }); + + it('renders policy cards with effect badge and conditions summary', () => { + mockItems = [makePolicy()]; + renderPage(); + const card = screen.getByTestId('policy-card-11111111-1111-1111-1111-111111111111'); + expect(card).toBeInTheDocument(); + expect( + screen.getByTestId('policy-effect-11111111-1111-1111-1111-111111111111'), + ).toHaveTextContent('Erlauben'); + expect( + screen.getByTestId('policy-conditions-11111111-1111-1111-1111-111111111111'), + ).toHaveTextContent('status eq active'); + }); + + it('shows deny badge and disabled badge for deny policies', () => { + mockItems = [makePolicy({ effect: 'deny', enabled: false })]; + renderPage(); + expect( + screen.getByTestId('policy-effect-11111111-1111-1111-1111-111111111111'), + ).toHaveTextContent('Verweigern'); + expect(screen.getByText('Deaktiviert')).toBeInTheDocument(); + }); + + it('hides action buttons without policies:write', () => { + mockItems = [makePolicy()]; + mockCanWrite = false; + renderPage(); + expect( + screen.queryByTestId('policy-edit-11111111-1111-1111-1111-111111111111'), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('policy-create-btn')).not.toBeInTheDocument(); + }); + + it('opens the create form, adds a rule, and submits the payload', async () => { + renderPage(); + fireEvent.click(screen.getByTestId('policy-create-btn')); + + const nameInput = screen.getByTestId('policy-form-name'); + fireEvent.change(nameInput, { target: { value: 'Neue Richtlinie' } }); + fireEvent.change(screen.getByTestId('policy-form-principal'), { + target: { value: '22222222-2222-2222-2222-222222222222' }, + }); + + // Add one condition rule (whitelisted field from ABAC_ALLOWED_FIELDS.contact) + fireEvent.click(screen.getByTestId('policy-add-rule')); + const ruleRow = screen.getByTestId('policy-rule-0'); + const fieldSelect = ruleRow.querySelector('select'); + if (fieldSelect) { + fireEvent.change(fieldSelect, { target: { value: 'status' } }); + } + const valueInput = ruleRow.querySelector('input'); + if (valueInput) { + fireEvent.change(valueInput, { target: { value: 'active' } }); + } + + fireEvent.click(screen.getByTestId('policy-form-submit')); + + await waitFor(() => expect(createMut).toHaveBeenCalled()); + const call = createMut.mock.calls[0][0]; + expect(call.name).toBe('Neue Richtlinie'); + expect(call.entity_type).toBe('contact'); + expect(call.principal_id).toBe('22222222-2222-2222-2222-222222222222'); + expect(call.conditions).toEqual({ + operator: 'AND', + rules: [{ field: 'status', op: 'eq', value: 'active' }], + }); + }); + + it('submits null conditions when no rules are added', async () => { + renderPage(); + fireEvent.click(screen.getByTestId('policy-create-btn')); + fireEvent.change(screen.getByTestId('policy-form-name'), { + target: { value: 'Alles erlauben' }, + }); + fireEvent.change(screen.getByTestId('policy-form-principal'), { + target: { value: '33333333-3333-3333-3333-333333333333' }, + }); + fireEvent.click(screen.getByTestId('policy-form-submit')); + + await waitFor(() => expect(createMut).toHaveBeenCalled()); + expect(createMut.mock.calls[0][0].conditions).toBeNull(); + }); + + it('toggles a policy enabled state via update mutation', async () => { + mockItems = [makePolicy()]; + renderPage(); + fireEvent.click(screen.getByTestId('policy-toggle-11111111-1111-1111-1111-111111111111')); + await waitFor(() => + expect(updateMut).toHaveBeenCalledWith({ + id: '11111111-1111-1111-1111-111111111111', + data: { enabled: false }, + }), + ); + }); + + it('opens the edit form pre-filled with policy values', async () => { + mockItems = [makePolicy()]; + renderPage(); + fireEvent.click(screen.getByTestId('policy-edit-11111111-1111-1111-1111-111111111111')); + + await waitFor(() => { + expect(screen.getByTestId('policy-form-name')).toHaveValue('VIP-Kunden erlauben'); + }); + // number inputs report numeric values + expect(screen.getByTestId('policy-form-priority')).toHaveValue(10); + }); + + it('deletes a policy after confirm', async () => { + mockItems = [makePolicy()]; + renderPage(); + fireEvent.click(screen.getByTestId('policy-delete-11111111-1111-1111-1111-111111111111')); + await waitFor(() => + expect(deleteMut).toHaveBeenCalledWith({ + id: '11111111-1111-1111-1111-111111111111', + entityType: 'contact', + }), + ); + }); + + it('switches entity type tab and shows the tab as selected', () => { + renderPage(); + const tab = screen.getByTestId('policy-tab-file'); + fireEvent.click(tab); + expect(tab).toHaveAttribute('aria-selected', 'true'); + }); +}); diff --git a/frontend/src/api/policies.ts b/frontend/src/api/policies.ts new file mode 100644 index 0000000..6710959 --- /dev/null +++ b/frontend/src/api/policies.ts @@ -0,0 +1,154 @@ +/** + * Policies API client — ABAC entity policies (attribute-based access + * control) management (UI-Backlog module 10/16). + * + * Backend: /api/v1/policies + * - GET /{entity_type} → list policies for one entity type + * - POST / → create policy (201) + * - PUT /{policy_id} → update policy + * - DELETE /{policy_id} → delete policy (204) + * Permissions: policies:read (list) / policies:write (create, update, delete). + * + * Conditions format (JSONB): + * { operator: 'AND' | 'OR', rules: [{ field, op, value }] } + * Supported ops: eq, neq, in, not_in, gt, gte, lt, lte, contains, + * starts_with, is_null, is_not_null. + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiGet, apiPost, apiPut, apiDelete } from '@/api/client'; + +export type PrincipalType = 'user' | 'group' | 'role'; +export type PolicyEffect = 'allow' | 'deny'; +export type ConditionOperator = 'AND' | 'OR'; + +export interface PolicyRule { + field: string; + op: string; + value: unknown; +} + +export interface PolicyConditions { + operator: ConditionOperator; + rules: PolicyRule[]; +} + +export interface Policy { + id: string; + name: string; + entity_type: string; + principal_type: PrincipalType; + principal_id: string; + effect: PolicyEffect; + conditions: PolicyConditions | null; + priority: number; + tenant_id: string; + enabled: boolean; + created_at: string | null; + updated_at: string | null; +} + +export interface PolicyListResponse { + items: Policy[]; + total: number; +} + +export interface PolicyCreatePayload { + name: string; + entity_type: string; + principal_type: PrincipalType; + principal_id: string; + effect?: PolicyEffect; + conditions?: PolicyConditions | null; + priority?: number; +} + +export interface PolicyUpdatePayload { + name?: string; + entity_type?: string; + principal_type?: PrincipalType; + principal_id?: string; + effect?: PolicyEffect; + conditions?: PolicyConditions | null; + priority?: number; + enabled?: boolean; +} + +/** Entity types with ABAC field whitelists (backend ABAC_ALLOWED_FIELDS). */ +export const POLICY_ENTITY_TYPES = [ + 'contact', + 'file', + 'task', + 'calendar_event', + 'mailbox', + 'address', + 'contact_folder', + 'workflow', +] as const; + +export const POLICY_OPS = [ + 'eq', 'neq', 'in', 'not_in', 'gt', 'gte', 'lt', 'lte', + 'contains', 'starts_with', 'is_null', 'is_not_null', +] as const; + +/** + * Mirrored from backend policy_service.ABAC_ALLOWED_FIELDS — the fields + * each entity type may reference in policy conditions. The backend skips + * non-whitelisted fields; showing them here prevents invalid input. + * Keep in sync with app/services/policy_service.py. + */ +export const ABAC_ALLOWED_FIELDS: Record = { + contact: ['status', 'type', 'country', 'tags', 'created_at', 'updated_at', 'owner_id', 'name', 'displayname', 'firstname', 'surname', 'email_1', 'email_2', 'code'], + file: ['status', 'size', 'mime_type', 'created_at'], + task: ['status', 'priority', 'due_date', 'created_at'], + calendar_event: ['status', 'start_time', 'end_time', 'created_at'], + mailbox: ['status', 'created_at'], + address: ['country', 'city', 'created_at', 'updated_at'], + contact_folder: ['name', 'created_at'], + workflow: ['status', 'created_at'], +}; + +/** Ops where the rule value is ignored (null checks). */ +export const NULL_OPS = ['is_null', 'is_not_null'] as const; + +// ─── Query hooks ───────────────────────────────────────────── + +export function usePolicies(entityType: string, enabled = true) { + return useQuery({ + queryKey: ['policies', entityType], + queryFn: () => apiGet(`/policies/${entityType}`), + enabled: enabled && !!entityType, + }); +} + +// ─── Mutation hooks ────────────────────────────────────────── + +export function useCreatePolicy() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data) => apiPost('/policies', data), + onSuccess: (policy) => { + qc.invalidateQueries({ queryKey: ['policies', policy.entity_type] }); + }, + }); +} + +export function useUpdatePolicy() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }) => apiPut(`/policies/${id}`, data), + onSuccess: (policy) => { + qc.invalidateQueries({ queryKey: ['policies', policy.entity_type] }); + }, + }); +} + +export function useDeletePolicy() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id }) => apiDelete(`/policies/${id}`), + onSuccess: (_, vars) => { + qc.invalidateQueries({ queryKey: ['policies', vars.entityType] }); + }, + }); +} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 6af309d..808d89e 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1853,5 +1853,48 @@ "deleteConfirm": "Dieses Memory wirklich loeschen?", "empty": "Keine Memories fuer diesen Agenten.", "loadError": "Memories konnten nicht geladen werden." + }, + "policies": { + "title": "ABAC-Richtlinien", + "create": "Richtlinie erstellen", + "createTitle": "Neue ABAC-Richtlinie", + "editTitle": "Richtlinie bearbeiten: {{name}}", + "entityType": "Entitätstyp", + "name": "Name", + "principal": "Prinzipal", + "principalType": "Prinzipal-Typ", + "principalUser": "Benutzer", + "principalGroup": "Gruppe", + "principalRole": "Rolle", + "principalPlaceholder": "Bitte wählen…", + "effect": "Effekt", + "effect_allow": "Erlauben", + "effect_deny": "Verweigern", + "priority": "Priorität", + "enabled": "Aktiv", + "enabledOption": "Aktiv", + "disabledOption": "Inaktiv", + "disabledBadge": "Deaktiviert", + "conditions": "Bedingungen", + "operatorLabel": "Verknüpfung", + "operatorAND": "UND", + "operatorOR": "ODER", + "field": "Feld", + "fieldPlaceholder": "Feld wählen…", + "op": "Operator", + "value": "Wert", + "addRule": "Regel hinzufügen", + "removeRule": "Regel entfernen", + "noRulesHint": "Keine Bedingungen — Richtlinie gilt für alle Entitäten dieses Typs.", + "save": "Speichern", + "createSubmit": "Erstellen", + "edit": "Bearbeiten", + "delete": "Löschen", + "enable": "Aktivieren", + "disable": "Deaktivieren", + "deleteConfirm": "Richtlinie \"{{name}}\" wirklich löschen?", + "empty": "Keine Richtlinien für diesen Entitätstyp.", + "noPermission": "Keine Berechtigung zum Anzeigen der Richtlinien (policies:read erforderlich).", + "loadError": "Richtlinien konnten nicht geladen werden." } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index cd09d04..0fe1990 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1853,5 +1853,48 @@ "deleteConfirm": "Really delete this memory?", "empty": "No memories for this agent.", "loadError": "Failed to load memories." + }, + "policies": { + "title": "ABAC Policies", + "create": "Create policy", + "createTitle": "New ABAC policy", + "editTitle": "Edit policy: {{name}}", + "entityType": "Entity type", + "name": "Name", + "principal": "Principal", + "principalType": "Principal type", + "principalUser": "User", + "principalGroup": "Group", + "principalRole": "Role", + "principalPlaceholder": "Select…", + "effect": "Effect", + "effect_allow": "Allow", + "effect_deny": "Deny", + "priority": "Priority", + "enabled": "Enabled", + "enabledOption": "Enabled", + "disabledOption": "Disabled", + "disabledBadge": "Disabled", + "conditions": "Conditions", + "operatorLabel": "Combinator", + "operatorAND": "AND", + "operatorOR": "OR", + "field": "Field", + "fieldPlaceholder": "Select field…", + "op": "Operator", + "value": "Value", + "addRule": "Add rule", + "removeRule": "Remove rule", + "noRulesHint": "No conditions — the policy applies to all entities of this type.", + "save": "Save", + "createSubmit": "Create", + "edit": "Edit", + "delete": "Delete", + "enable": "Enable", + "disable": "Disable", + "deleteConfirm": "Delete policy \"{{name}}\"?", + "empty": "No policies for this entity type.", + "noPermission": "You lack permission to view policies (policies:read required).", + "loadError": "Failed to load policies." } } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index e0adc02..d848e07 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -51,6 +51,7 @@ export function SettingsPage() { { to: '/settings/api-tokens', label: 'API-Tokens', icon: '\ud83d\udd11' }, { to: '/settings/tenants', label: 'Mandanten', icon: '\ud83c\udfe2' }, { to: '/settings/permission-templates', label: 'Berechtigungs-Vorlagen', icon: '\ud83d\udd11' }, + { to: '/settings/policies', label: 'ABAC-Richtlinien', icon: '\ud83d\udee1\ufe0f' }, ]; const existingPaths = new Set(hardcodedNavItems.map(item => item.to)); diff --git a/frontend/src/pages/SettingsPolicies.tsx b/frontend/src/pages/SettingsPolicies.tsx new file mode 100644 index 0000000..5ca291b --- /dev/null +++ b/frontend/src/pages/SettingsPolicies.tsx @@ -0,0 +1,630 @@ +/** + * Policies settings page — ABAC entity policies (attribute-based access + * control) management (UI-Backlog module 10/16). + * + * Backend: /api/v1/policies (list per entity type, create, update, delete). + * Permissions: policies:read (list) / policies:write (create, update, delete). + * + * Policy evaluation: allow policies OR-joined (one must match), deny takes + * precedence. Conditions: { operator: AND|OR, rules: [{ field, op, value }] } + * with a per-entity field whitelist (ABAC_ALLOWED_FIELDS, mirrored from + * the backend service). + */ + +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import clsx from 'clsx'; +import { + ShieldCheck, + ShieldX, + Plus, + Pencil, + Trash2, + Inbox, + AlertTriangle, +} from 'lucide-react'; +import { + usePolicies, + useCreatePolicy, + useUpdatePolicy, + useDeletePolicy, + POLICY_OPS, + ABAC_ALLOWED_FIELDS, + NULL_OPS, + type Policy, + type PrincipalType, + type PolicyEffect, + type PolicyRule, + type ConditionOperator, +} from '@/api/policies'; +import { useUsers, type UserResponse } from '@/api/users'; +import { useGroups, type Group } from '@/api/groups'; +import { useRoles, type Role } from '@/api/roles'; +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'; + +const ENTITY_TYPES = Object.keys(ABAC_ALLOWED_FIELDS); + +const EFFECT_BADGE: Record = { + allow: 'bg-success-100 text-success-800 dark:bg-success-900/30 dark:text-success-300', + deny: 'bg-danger-100 text-danger-800 dark:bg-danger-900/30 dark:text-danger-300', +}; + +interface PrincipalOption { + value: string; + label: string; +} + +function summarizeConditions(conditions: Policy['conditions']): string { + if (!conditions || !conditions.rules || conditions.rules.length === 0) return '—'; + return conditions.rules + .map((r) => `${r.field} ${r.op}${NULL_OPS.includes(r.op as never) ? '' : ` ${String(r.value)}`}`) + .join(` ${conditions.operator} `); +} + +function conditionsFromRules(operator: ConditionOperator, rules: PolicyRule[]): Policy['conditions'] { + if (rules.length === 0) return null; + return { operator, rules }; +} + +function rulesFromConditions(conditions: Policy['conditions']): { operator: ConditionOperator; rules: PolicyRule[] } { + if (!conditions || !Array.isArray(conditions.rules)) return { operator: 'AND', rules: [] }; + return { operator: conditions.operator ?? 'AND', rules: conditions.rules }; +} + +function PolicyCard({ + policy, + principalLabel, + canWrite, + isMutating, + onEdit, + onDelete, + onToggle, +}: { + policy: Policy; + principalLabel: string; + canWrite: boolean; + isMutating: boolean; + onEdit: (p: Policy) => void; + onDelete: (p: Policy) => void; + onToggle: (p: Policy) => void; +}) { + const { t } = useTranslation(); + + return ( + +
+
+
+ + {policy.effect === 'allow' ? ( + + {!policy.enabled && ( + + {t('policies.disabledBadge')} + + )} + {t('policies.priority')}: {policy.priority} +
+

+ {policy.name} +

+

+ {t('policies.principal')}: {policy.principal_type} + {' · '} + {principalLabel} +

+

+ {t('policies.conditions')}: {summarizeConditions(policy.conditions)} +

+
+ {canWrite && ( +
+ + + +
+ )} +
+
+ ); +} + +interface FormState { + name: string; + principalType: PrincipalType; + principalId: string; + effect: PolicyEffect; + priority: string; + enabled: boolean; + operator: ConditionOperator; + rules: PolicyRule[]; +} + +const EMPTY_FORM: FormState = { + name: '', + principalType: 'user', + principalId: '', + effect: 'allow', + priority: '0', + enabled: true, + operator: 'AND', + rules: [], +}; + +function PolicyForm({ + open, + entityType, + policy, + principalOptions, + onClose, + onSubmit, + isSubmitting, +}: { + open: boolean; + entityType: string; + policy: Policy | null; + principalOptions: PrincipalOption[]; + onClose: () => void; + onSubmit: (payload: { + name: string; + principal_type: PrincipalType; + principal_id: string; + effect: PolicyEffect; + priority: number; + enabled: boolean; + conditions: Policy['conditions']; + }) => void; + isSubmitting: boolean; +}) { + const { t } = useTranslation(); + const [form, setForm] = useState(EMPTY_FORM); + + useEffect(() => { + if (open) { + if (policy) { + const parsed = rulesFromConditions(policy.conditions); + setForm({ + name: policy.name, + principalType: policy.principal_type, + principalId: policy.principal_id, + effect: policy.effect, + priority: String(policy.priority), + enabled: policy.enabled, + operator: parsed.operator, + rules: parsed.rules, + }); + } else { + setForm(EMPTY_FORM); + } + } + }, [open, policy]); + + const allowedFields = ABAC_ALLOWED_FIELDS[entityType] ?? []; + + const valid = + form.name.trim().length > 0 && + form.principalId.trim().length > 0 && + form.rules.every((r) => r.field && r.op); + + const setRule = (idx: number, patch: Partial) => { + setForm((f) => ({ + ...f, + rules: f.rules.map((r, i) => (i === idx ? { ...r, ...patch } : r)), + })); + }; + + const submit = () => { + if (!valid) return; + const rules = form.rules.map((r) => ({ + ...r, + value: NULL_OPS.includes(r.op as never) ? null : r.value, + })); + onSubmit({ + name: form.name.trim(), + principal_type: form.principalType, + principal_id: form.principalId.trim(), + effect: form.effect, + priority: parseInt(form.priority, 10) || 0, + enabled: form.enabled, + conditions: conditionsFromRules(form.operator, rules), + }); + }; + + return ( + +
+ setForm((f) => ({ ...f, name: e.target.value }))} + required + maxLength={200} + data-testid="policy-form-name" + /> + +
+ setForm((f) => ({ ...f, principalId: e.target.value }))} + options={principalOptions} + required + placeholder={t('policies.principalPlaceholder')} + data-testid="policy-form-principal" + /> +
+ +
+ setForm((f) => ({ ...f, priority: e.target.value }))} + data-testid="policy-form-priority" + /> + setForm((f) => ({ ...f, operator: e.target.value as ConditionOperator }))} + options={[ + { value: 'AND', label: t('policies.operatorAND') }, + { value: 'OR', label: t('policies.operatorOR') }, + ]} + className="w-32" + aria-label={t('policies.operatorLabel')} + data-testid="policy-form-operator" + /> +
+ + {form.rules.length === 0 && ( +

{t('policies.noRulesHint')}

+ )} + + {form.rules.map((rule, idx) => ( +
+
+ setRule(idx, { op: e.target.value })} + options={POLICY_OPS.map((op) => ({ value: op, label: op }))} + aria-label={t('policies.op')} + /> +
+
+ setRule(idx, { value: e.target.value })} + disabled={NULL_OPS.includes(rule.op as never)} + aria-label={t('policies.value')} + /> +
+ +
+ ))} + + +
+ +
+ + +
+ +
+ ); +} + +export function SettingsPoliciesPage() { + const { t } = useTranslation(); + const [entityType, setEntityType] = useState('contact'); + const [showCreate, setShowCreate] = useState(false); + const [editPolicy, setEditPolicy] = useState(null); + + const { hasPermission } = usePermission(); + const canRead = hasPermission('policies:read'); + const canWrite = hasPermission('policies:write'); + + const { data, isLoading, isError } = usePolicies(entityType, canRead); + const createMut = useCreatePolicy(); + const updateMut = useUpdatePolicy(); + const deleteMut = useDeletePolicy(); + + const { data: usersData } = useUsers(1, 100); + const { data: groupsData } = useGroups(); + const { data: rolesData } = useRoles(); + + const isMutating = createMut.isPending || updateMut.isPending || deleteMut.isPending; + + // Principal picker options depend on the selected principal type — built + // once here and passed down so the form can switch without re-fetching. + const principalOptions = useMemo(() => { + const users: UserResponse[] = (usersData as any)?.items ?? []; + const groups: Group[] = groupsData?.items ?? []; + const roles: Role[] = rolesData?.items ?? []; + return [ + ...users.map((u) => ({ value: u.id, label: `${u.name || u.email} (user)` })), + ...groups.map((g) => ({ value: g.id, label: `${g.name} (group)` })), + ...roles.map((r) => ({ value: r.id, label: `${r.name} (role)` })), + ]; + }, [usersData, groupsData, rolesData]); + + const principalLabelFor = (p: Policy): string => { + const opt = principalOptions.find((o) => o.value === p.principal_id); + return opt ? opt.label : `${p.principal_id.slice(0, 8)}…`; + }; + + const handleCreate = (payload: { + name: string; + principal_type: PrincipalType; + principal_id: string; + effect: PolicyEffect; + priority: number; + enabled: boolean; + conditions: Policy['conditions']; + }) => { + createMut.mutate( + { entity_type: entityType, conditions: payload.conditions, effect: payload.effect, name: payload.name, principal_id: payload.principal_id, principal_type: payload.principal_type, priority: payload.priority }, + { onSuccess: () => setShowCreate(false) }, + ); + }; + + const handleUpdate = (payload: { + name: string; + principal_type: PrincipalType; + principal_id: string; + effect: PolicyEffect; + priority: number; + enabled: boolean; + conditions: Policy['conditions']; + }) => { + if (!editPolicy) return; + updateMut.mutate( + { + id: editPolicy.id, + data: { + name: payload.name, + principal_type: payload.principal_type, + principal_id: payload.principal_id, + effect: payload.effect, + priority: payload.priority, + enabled: payload.enabled, + conditions: payload.conditions, + }, + }, + { onSuccess: () => setEditPolicy(null) }, + ); + }; + + const handleToggle = (policy: Policy) => { + updateMut.mutate({ id: policy.id, data: { enabled: !policy.enabled } }); + }; + + const handleDelete = (policy: Policy) => { + if (window.confirm(t('policies.deleteConfirm', { name: policy.name }))) { + deleteMut.mutate({ id: policy.id, entityType }); + } + }; + + const items = data?.items ?? []; + + return ( +
+
+
+
+ {canWrite && ( + + )} +
+ + {/* Entity type tabs */} +
+ {ENTITY_TYPES.map((et) => ( + + ))} +
+ + {!canRead ? ( + + + ) : isLoading ? ( +
+
+ ) : isError ? ( + + + ) : items.length === 0 ? ( + + + ) : ( +
+ {items.map((policy) => ( + + ))} +
+ )} + + setShowCreate(false)} + onSubmit={handleCreate} + isSubmitting={createMut.isPending} + /> + setEditPolicy(null)} + onSubmit={handleUpdate} + isSubmitting={updateMut.isPending} + /> +
+ ); +} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index a2c510a..bf778c3 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -49,6 +49,7 @@ const OutboxPage = React.lazy(() => import('@/pages/Outbox').then(m => ({ defaul const ApiTokensPage = React.lazy(() => import('@/pages/ApiTokens').then(m => ({ default: m.ApiTokensPage }))); const TenantsPage = React.lazy(() => import('@/pages/Tenants').then(m => ({ default: m.TenantsPage }))); const PermissionTemplatesPage = React.lazy(() => import('@/pages/PermissionTemplates').then(m => ({ default: m.PermissionTemplatesPage }))); +const SettingsPoliciesPage = React.lazy(() => import('@/pages/SettingsPolicies').then(m => ({ default: m.SettingsPoliciesPage }))); 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 }))); @@ -209,6 +210,7 @@ const router = createBrowserRouter([ { path: 'api-tokens', element: withSuspense() }, { path: 'tenants', element: withSuspense() }, { path: 'permission-templates', element: withSuspense() }, + { path: 'policies', element: withSuspense() }, { path: 'rechte', element: {withSuspense()} }, // Phase Q2: plugin settings sub-pages render with bare sub-segments { path: '*', element: {} },