feat(policies): UI fuer ABAC-Richtlinien mit Conditions-Builder — Modul 10/16 des UI-Backlogs
This commit is contained in:
@@ -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> = {}): 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<typeof import('@/api/policies')>();
|
||||||
|
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(
|
||||||
|
<MemoryRouter>
|
||||||
|
<SettingsPoliciesPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string[]> = {
|
||||||
|
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<PolicyListResponse>({
|
||||||
|
queryKey: ['policies', entityType],
|
||||||
|
queryFn: () => apiGet<PolicyListResponse>(`/policies/${entityType}`),
|
||||||
|
enabled: enabled && !!entityType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mutation hooks ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useCreatePolicy() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<Policy, Error, PolicyCreatePayload>({
|
||||||
|
mutationFn: (data) => apiPost<Policy>('/policies', data),
|
||||||
|
onSuccess: (policy) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['policies', policy.entity_type] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdatePolicy() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<Policy, Error, { id: string; data: PolicyUpdatePayload }>({
|
||||||
|
mutationFn: ({ id, data }) => apiPut<Policy>(`/policies/${id}`, data),
|
||||||
|
onSuccess: (policy) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['policies', policy.entity_type] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeletePolicy() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<void, Error, { id: string; entityType: string }>({
|
||||||
|
mutationFn: ({ id }) => apiDelete(`/policies/${id}`),
|
||||||
|
onSuccess: (_, vars) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['policies', vars.entityType] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1853,5 +1853,48 @@
|
|||||||
"deleteConfirm": "Dieses Memory wirklich loeschen?",
|
"deleteConfirm": "Dieses Memory wirklich loeschen?",
|
||||||
"empty": "Keine Memories fuer diesen Agenten.",
|
"empty": "Keine Memories fuer diesen Agenten.",
|
||||||
"loadError": "Memories konnten nicht geladen werden."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1853,5 +1853,48 @@
|
|||||||
"deleteConfirm": "Really delete this memory?",
|
"deleteConfirm": "Really delete this memory?",
|
||||||
"empty": "No memories for this agent.",
|
"empty": "No memories for this agent.",
|
||||||
"loadError": "Failed to load memories."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export function SettingsPage() {
|
|||||||
{ to: '/settings/api-tokens', label: 'API-Tokens', icon: '\ud83d\udd11' },
|
{ to: '/settings/api-tokens', label: 'API-Tokens', icon: '\ud83d\udd11' },
|
||||||
{ to: '/settings/tenants', label: 'Mandanten', icon: '\ud83c\udfe2' },
|
{ to: '/settings/tenants', label: 'Mandanten', icon: '\ud83c\udfe2' },
|
||||||
{ to: '/settings/permission-templates', label: 'Berechtigungs-Vorlagen', icon: '\ud83d\udd11' },
|
{ 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));
|
const existingPaths = new Set(hardcodedNavItems.map(item => item.to));
|
||||||
|
|||||||
@@ -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<PolicyEffect, string> = {
|
||||||
|
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 (
|
||||||
|
<Card className="p-4 space-y-2" data-testid={`policy-card-${policy.id}`}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span
|
||||||
|
className={clsx('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium', EFFECT_BADGE[policy.effect])}
|
||||||
|
data-testid={`policy-effect-${policy.id}`}
|
||||||
|
>
|
||||||
|
{policy.effect === 'allow' ? (
|
||||||
|
<ShieldCheck className="w-3 h-3" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<ShieldX className="w-3 h-3" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{t(`policies.effect_${policy.effect}`)}
|
||||||
|
</span>
|
||||||
|
{!policy.enabled && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs bg-secondary-200 text-secondary-700 dark:bg-secondary-800 dark:text-secondary-300">
|
||||||
|
{t('policies.disabledBadge')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-secondary-500">{t('policies.priority')}: {policy.priority}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm font-medium text-secondary-900 dark:text-secondary-100 break-words">
|
||||||
|
{policy.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-secondary-500 mt-1">
|
||||||
|
{t('policies.principal')}: <span className="font-medium">{policy.principal_type}</span>
|
||||||
|
{' · '}
|
||||||
|
<span title={policy.principal_id}>{principalLabel}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-secondary-500 mt-1 break-all" data-testid={`policy-conditions-${policy.id}`}>
|
||||||
|
{t('policies.conditions')}: {summarizeConditions(policy.conditions)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canWrite && (
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onToggle(policy)}
|
||||||
|
disabled={isMutating}
|
||||||
|
aria-label={policy.enabled ? t('policies.disable') : t('policies.enable')}
|
||||||
|
data-testid={`policy-toggle-${policy.id}`}
|
||||||
|
>
|
||||||
|
{policy.enabled ? t('policies.disable') : t('policies.enable')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onEdit(policy)}
|
||||||
|
disabled={isMutating}
|
||||||
|
aria-label={t('policies.edit')}
|
||||||
|
data-testid={`policy-edit-${policy.id}`}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete(policy)}
|
||||||
|
disabled={isMutating}
|
||||||
|
aria-label={t('policies.delete')}
|
||||||
|
data-testid={`policy-delete-${policy.id}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<FormState>(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<PolicyRule>) => {
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={policy ? t('policies.editTitle', { name: policy.name }) : t('policies.createTitle')}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label={t('policies.name')}
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
required
|
||||||
|
maxLength={200}
|
||||||
|
data-testid="policy-form-name"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<Select
|
||||||
|
label={t('policies.principalType')}
|
||||||
|
value={form.principalType}
|
||||||
|
onChange={(e) => {
|
||||||
|
const pt = e.target.value as PrincipalType;
|
||||||
|
setForm((f) => ({ ...f, principalType: pt, principalId: '' }));
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: 'user', label: t('policies.principalUser') },
|
||||||
|
{ value: 'group', label: t('policies.principalGroup') },
|
||||||
|
{ value: 'role', label: t('policies.principalRole') },
|
||||||
|
]}
|
||||||
|
required
|
||||||
|
data-testid="policy-form-principal-type"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label={t('policies.principal')}
|
||||||
|
value={form.principalId}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, principalId: e.target.value }))}
|
||||||
|
options={principalOptions}
|
||||||
|
required
|
||||||
|
placeholder={t('policies.principalPlaceholder')}
|
||||||
|
data-testid="policy-form-principal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<Select
|
||||||
|
label={t('policies.effect')}
|
||||||
|
value={form.effect}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, effect: e.target.value as PolicyEffect }))}
|
||||||
|
options={[
|
||||||
|
{ value: 'allow', label: t('policies.effect_allow') },
|
||||||
|
{ value: 'deny', label: t('policies.effect_deny') },
|
||||||
|
]}
|
||||||
|
required
|
||||||
|
data-testid="policy-form-effect"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('policies.priority')}
|
||||||
|
type="number"
|
||||||
|
value={form.priority}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, priority: e.target.value }))}
|
||||||
|
data-testid="policy-form-priority"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label={t('policies.enabled')}
|
||||||
|
value={form.enabled ? '1' : '0'}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.value === '1' }))}
|
||||||
|
options={[
|
||||||
|
{ value: '1', label: t('policies.enabledOption') },
|
||||||
|
{ value: '0', label: t('policies.disabledOption') },
|
||||||
|
]}
|
||||||
|
data-testid="policy-form-enabled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conditions builder */}
|
||||||
|
<div className="border-t border-secondary-200 dark:border-secondary-700 pt-3 space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h3 className="text-sm font-semibold text-secondary-900 dark:text-secondary-100">
|
||||||
|
{t('policies.conditions')}
|
||||||
|
</h3>
|
||||||
|
<Select
|
||||||
|
value={form.operator}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.rules.length === 0 && (
|
||||||
|
<p className="text-xs text-secondary-500">{t('policies.noRulesHint')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.rules.map((rule, idx) => (
|
||||||
|
<div key={idx} className="flex flex-wrap items-end gap-2" data-testid={`policy-rule-${idx}`}>
|
||||||
|
<div className="w-44">
|
||||||
|
<Select
|
||||||
|
label={idx === 0 ? t('policies.field') : undefined}
|
||||||
|
value={rule.field}
|
||||||
|
onChange={(e) => setRule(idx, { field: e.target.value })}
|
||||||
|
options={allowedFields.map((f) => ({ value: f, label: f }))}
|
||||||
|
placeholder={t('policies.fieldPlaceholder')}
|
||||||
|
aria-label={t('policies.field')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-40">
|
||||||
|
<Select
|
||||||
|
label={idx === 0 ? t('policies.op') : undefined}
|
||||||
|
value={rule.op}
|
||||||
|
onChange={(e) => setRule(idx, { op: e.target.value })}
|
||||||
|
options={POLICY_OPS.map((op) => ({ value: op, label: op }))}
|
||||||
|
aria-label={t('policies.op')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-32">
|
||||||
|
<Input
|
||||||
|
label={idx === 0 ? t('policies.value') : undefined}
|
||||||
|
value={NULL_OPS.includes(rule.op as never) ? '' : String(rule.value ?? '')}
|
||||||
|
onChange={(e) => setRule(idx, { value: e.target.value })}
|
||||||
|
disabled={NULL_OPS.includes(rule.op as never)}
|
||||||
|
aria-label={t('policies.value')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setForm((f) => ({ ...f, rules: f.rules.filter((_, i) => i !== idx) }))}
|
||||||
|
aria-label={t('policies.removeRule')}
|
||||||
|
data-testid={`policy-rule-remove-${idx}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setForm((f) => ({ ...f, rules: [...f.rules, { field: allowedFields[0] ?? '', op: 'eq', value: '' }] }))}
|
||||||
|
data-testid="policy-add-rule"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" aria-hidden="true" />
|
||||||
|
{t('policies.addRule')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="policy-form-submit">
|
||||||
|
{policy ? t('policies.save') : t('policies.createSubmit')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsPoliciesPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [entityType, setEntityType] = useState<string>('contact');
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [editPolicy, setEditPolicy] = useState<Policy | null>(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<PrincipalOption[]>(() => {
|
||||||
|
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 (
|
||||||
|
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-4" data-testid="policies-page">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="w-6 h-6 text-primary-600" aria-hidden="true" />
|
||||||
|
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||||
|
{t('policies.title')}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
{canWrite && (
|
||||||
|
<Button onClick={() => setShowCreate(true)} data-testid="policy-create-btn">
|
||||||
|
<Plus className="w-4 h-4" aria-hidden="true" />
|
||||||
|
{t('policies.create')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Entity type tabs */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 border-b border-secondary-200 dark:border-secondary-700 overflow-x-auto"
|
||||||
|
role="tablist"
|
||||||
|
aria-label={t('policies.entityType')}
|
||||||
|
>
|
||||||
|
{ENTITY_TYPES.map((et) => (
|
||||||
|
<button
|
||||||
|
key={et}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={entityType === et}
|
||||||
|
onClick={() => setEntityType(et)}
|
||||||
|
className={clsx(
|
||||||
|
'px-3 py-2 text-sm border-b-2 -mb-px transition-colors min-h-touch whitespace-nowrap font-mono',
|
||||||
|
entityType === et
|
||||||
|
? 'border-primary-500 text-primary-600 dark:text-primary-400 font-medium'
|
||||||
|
: 'border-transparent text-secondary-500 hover:text-secondary-700 dark:hover:text-secondary-300',
|
||||||
|
)}
|
||||||
|
data-testid={`policy-tab-${et}`}
|
||||||
|
>
|
||||||
|
{et}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!canRead ? (
|
||||||
|
<Card className="p-8 text-center" data-testid="policies-no-permission">
|
||||||
|
<AlertTriangle className="w-8 h-8 mx-auto text-warning-500" aria-hidden="true" />
|
||||||
|
<p className="mt-2 text-sm text-secondary-600">{t('policies.noPermission')}</p>
|
||||||
|
</Card>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12" role="status">
|
||||||
|
<span className="animate-spin h-6 w-6 border-2 border-primary-500 border-t-transparent rounded-full" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<AlertTriangle className="w-8 h-8 mx-auto text-danger-500" aria-hidden="true" />
|
||||||
|
<p className="mt-2 text-sm text-secondary-600">{t('policies.loadError')}</p>
|
||||||
|
</Card>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<Card className="p-12 text-center">
|
||||||
|
<Inbox className="w-10 h-10 mx-auto text-secondary-300" aria-hidden="true" />
|
||||||
|
<p className="mt-3 text-sm text-secondary-500" data-testid="policies-empty">
|
||||||
|
{t('policies.empty')}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map((policy) => (
|
||||||
|
<PolicyCard
|
||||||
|
key={policy.id}
|
||||||
|
policy={policy}
|
||||||
|
principalLabel={principalLabelFor(policy)}
|
||||||
|
canWrite={canWrite}
|
||||||
|
isMutating={isMutating}
|
||||||
|
onEdit={setEditPolicy}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PolicyForm
|
||||||
|
open={showCreate}
|
||||||
|
entityType={entityType}
|
||||||
|
policy={null}
|
||||||
|
principalOptions={principalOptions}
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
isSubmitting={createMut.isPending}
|
||||||
|
/>
|
||||||
|
<PolicyForm
|
||||||
|
open={!!editPolicy}
|
||||||
|
entityType={editPolicy?.entity_type ?? entityType}
|
||||||
|
policy={editPolicy}
|
||||||
|
principalOptions={principalOptions}
|
||||||
|
onClose={() => setEditPolicy(null)}
|
||||||
|
onSubmit={handleUpdate}
|
||||||
|
isSubmitting={updateMut.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ApiTokensPage = React.lazy(() => import('@/pages/ApiTokens').then(m => ({ default: m.ApiTokensPage })));
|
||||||
const TenantsPage = React.lazy(() => import('@/pages/Tenants').then(m => ({ default: m.TenantsPage })));
|
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 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 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 })));
|
||||||
@@ -209,6 +210,7 @@ const router = createBrowserRouter([
|
|||||||
{ path: 'api-tokens', element: withSuspense(<ApiTokensPage />) },
|
{ path: 'api-tokens', element: withSuspense(<ApiTokensPage />) },
|
||||||
{ path: 'tenants', element: withSuspense(<TenantsPage />) },
|
{ path: 'tenants', element: withSuspense(<TenantsPage />) },
|
||||||
{ path: 'permission-templates', element: withSuspense(<PermissionTemplatesPage />) },
|
{ path: 'permission-templates', element: withSuspense(<PermissionTemplatesPage />) },
|
||||||
|
{ path: 'policies', element: withSuspense(<SettingsPoliciesPage />) },
|
||||||
{ path: 'rechte', element: <PermissionRoute permission="settings:read">{withSuspense(<SettingsRechtePage />)}</PermissionRoute> },
|
{ path: 'rechte', element: <PermissionRoute permission="settings:read">{withSuspense(<SettingsRechtePage />)}</PermissionRoute> },
|
||||||
// Phase Q2: plugin settings sub-pages render with bare sub-segments
|
// Phase Q2: plugin settings sub-pages render with bare sub-segments
|
||||||
{ path: '*', element: <ErrorBoundary>{<PluginRouteRenderer variant="settings" />}</ErrorBoundary> },
|
{ path: '*', element: <ErrorBoundary>{<PluginRouteRenderer variant="settings" />}</ErrorBoundary> },
|
||||||
|
|||||||
Reference in New Issue
Block a user