diff --git a/app/core/permission_registry.py b/app/core/permission_registry.py index cf05a7c..faf322b 100644 --- a/app/core/permission_registry.py +++ b/app/core/permission_registry.py @@ -9,6 +9,11 @@ from __future__ import annotations import logging from typing import Any +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.custom_field_definition import CustomFieldDefinition + logger = logging.getLogger(__name__) # ── Core system permissions ── @@ -60,13 +65,50 @@ CORE_PERMISSIONS: list[dict[str, str]] = [ # ── Core field definitions for field-level permissions ── CORE_FIELD_DEFINITIONS: list[dict[str, str]] = [ + # ── Contact fields ── {"module": "contacts", "field": "firstname", "label": "First Name", "sensitivity": "normal"}, {"module": "contacts", "field": "surname", "label": "Last Name", "sensitivity": "normal"}, - {"module": "contacts", "field": "email_1", "label": "Email", "sensitivity": "normal"}, - {"module": "contacts", "field": "phone_1", "label": "Phone", "sensitivity": "normal"}, + {"module": "contacts", "field": "displayname", "label": "Display Name", "sensitivity": "normal"}, + {"module": "contacts", "field": "name", "label": "Name", "sensitivity": "normal"}, + {"module": "contacts", "field": "email_1", "label": "Email 1", "sensitivity": "normal"}, + {"module": "contacts", "field": "email_2", "label": "Email 2", "sensitivity": "normal"}, + {"module": "contacts", "field": "phone_1", "label": "Phone 1", "sensitivity": "normal"}, + {"module": "contacts", "field": "phone_2", "label": "Phone 2", "sensitivity": "normal"}, {"module": "contacts", "field": "mobilephone", "label": "Mobile", "sensitivity": "sensitive"}, {"module": "contacts", "field": "function", "label": "Position", "sensitivity": "normal"}, + {"module": "contacts", "field": "website", "label": "Website", "sensitivity": "normal"}, + {"module": "contacts", "field": "status", "label": "Status", "sensitivity": "normal"}, + {"module": "contacts", "field": "type", "label": "Type", "sensitivity": "normal"}, + {"module": "contacts", "field": "gender", "label": "Gender", "sensitivity": "normal"}, + {"module": "contacts", "field": "suffix", "label": "Suffix", "sensitivity": "normal"}, + {"module": "contacts", "field": "ext_name_line", "label": "Extra Name Line", "sensitivity": "normal"}, + {"module": "contacts", "field": "country", "label": "Country", "sensitivity": "normal"}, + # ── Financial / sensitive fields ── + {"module": "contacts", "field": "code", "label": "Code", "sensitivity": "sensitive"}, + {"module": "contacts", "field": "accounting_code", "label": "Accounting Code", "sensitivity": "sensitive"}, + {"module": "contacts", "field": "vendor_accounting_code", "label": "Vendor Accounting Code", "sensitivity": "sensitive"}, + {"module": "contacts", "field": "vat_code", "label": "VAT Code", "sensitivity": "sensitive"}, + {"module": "contacts", "field": "fiscal_code", "label": "Fiscal Code", "sensitivity": "sensitive"}, + {"module": "contacts", "field": "commerce_code", "label": "Commerce Code", "sensitivity": "sensitive"}, + {"module": "contacts", "field": "purchase_number", "label": "Purchase Number", "sensitivity": "sensitive"}, + {"module": "contacts", "field": "bic", "label": "BIC", "sensitivity": "sensitive"}, + # ── Addresses ── + {"module": "contacts", "field": "mailing_street", "label": "Mailing Street", "sensitivity": "normal"}, + {"module": "contacts", "field": "mailing_city", "label": "Mailing City", "sensitivity": "normal"}, + {"module": "contacts", "field": "mailing_postalcode", "label": "Mailing Postal Code", "sensitivity": "normal"}, + {"module": "contacts", "field": "mailing_country", "label": "Mailing Country", "sensitivity": "normal"}, + {"module": "contacts", "field": "visit_street", "label": "Visit Street", "sensitivity": "normal"}, + {"module": "contacts", "field": "visit_city", "label": "Visit City", "sensitivity": "normal"}, + {"module": "contacts", "field": "visit_postalcode", "label": "Visit Postal Code", "sensitivity": "normal"}, + {"module": "contacts", "field": "visit_country", "label": "Visit Country", "sensitivity": "normal"}, + {"module": "contacts", "field": "invoice_street", "label": "Invoice Street", "sensitivity": "normal"}, + {"module": "contacts", "field": "invoice_city", "label": "Invoice City", "sensitivity": "normal"}, + {"module": "contacts", "field": "invoice_postalcode", "label": "Invoice Postal Code", "sensitivity": "normal"}, + {"module": "contacts", "field": "invoice_country", "label": "Invoice Country", "sensitivity": "normal"}, + # ── Notes & Tags ── {"module": "contacts", "field": "notes", "label": "Notes", "sensitivity": "sensitive"}, + {"module": "contacts", "field": "tags", "label": "Tags", "sensitivity": "sensitive"}, + # ── User fields ── {"module": "users", "field": "email", "label": "Email", "sensitivity": "normal"}, {"module": "users", "field": "name", "label": "Name", "sensitivity": "normal"}, {"module": "users", "field": "role", "label": "Role", "sensitivity": "normal"}, @@ -166,6 +208,39 @@ class PermissionRegistry: result.extend(defs) return result + async def get_all_field_definitions_with_custom( + self, + db: AsyncSession, + tenant_id: Any, + ) -> list[dict[str, str]]: + """Return all field definitions including custom fields from DB. + + Merges core field definitions with plugin-provided definitions + and active custom field definitions from the database. + """ + result = list(self._core_field_definitions) + + # Add plugin field definitions + for defs in self._field_definitions.values(): + result.extend(defs) + + # Load custom field definitions from DB + q = select(CustomFieldDefinition).where( + CustomFieldDefinition.tenant_id == tenant_id, + CustomFieldDefinition.is_active.is_(True), + ) + custom_defs = await db.execute(q) + for cfd in custom_defs.scalars().all(): + result.append({ + "module": cfd.entity, + "field": cfd.name, + "label": cfd.label, + "sensitivity": cfd.sensitivity, + "custom": "true", + }) + + return result + # Global instance _registry = PermissionRegistry() diff --git a/app/core/permissions.py b/app/core/permissions.py index feb03c7..d8ddc1e 100644 --- a/app/core/permissions.py +++ b/app/core/permissions.py @@ -461,6 +461,7 @@ def filter_fields_by_permission( """Filter response fields based on field-level permissions. Removes fields marked as "hidden", keeps others. + Also filters custom_fields (JSONB dict) entries that are marked as hidden. """ if resolved.get("is_system_admin"): return data @@ -476,5 +477,17 @@ def filter_fields_by_permission( perm = module_perms.get(key) if perm == "hidden": continue - result[key] = value + + # Special handling for custom_fields JSONB dict + if key == "custom_fields" and isinstance(value, dict): + filtered_custom = {} + for cf_key, cf_value in value.items(): + cf_perm = module_perms.get(cf_key) + if cf_perm == "hidden": + continue + filtered_custom[cf_key] = cf_value + result[key] = filtered_custom + else: + result[key] = value + return result diff --git a/app/models/custom_field_definition.py b/app/models/custom_field_definition.py index 6bfc025..c0bb17c 100644 --- a/app/models/custom_field_definition.py +++ b/app/models/custom_field_definition.py @@ -44,6 +44,9 @@ class CustomFieldDefinition(Base, TenantMixin, OwnedMixin): options: Mapped[list[str] | None] = mapped_column(JSON, nullable=True, default=list) default_value: Mapped[Any | None] = mapped_column(JSON, nullable=True, default=None) + # ── Sensitivity (field-level permissions) ── + sensitivity: Mapped[str] = mapped_column(String(20), nullable=False, default='normal') + # ── Behaviour ── required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) diff --git a/frontend/src/api/entityPermissionHooks.ts b/frontend/src/api/entityPermissionHooks.ts new file mode 100644 index 0000000..fc49a11 --- /dev/null +++ b/frontend/src/api/entityPermissionHooks.ts @@ -0,0 +1,131 @@ +/** + * React Query hooks for the universal entity permission API. + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + fetchEntityPermissions, + createEntityPermission, + updateEntityPermission, + deleteEntityPermission, + fetchEntityAccess, + fetchEntityRegistry, + type CreateEntityPermissionPayload, + type UpdateEntityPermissionPayload, +} from './entityPermissions'; + +// ─── Query Key Factory ───────────────────────────────────────────────────── + +export const entityPermissionKeys = { + all: ['entityPermissions'] as const, + list: (entityType: string, entityId: string) => + [...entityPermissionKeys.all, entityType, entityId] as const, + access: (entityType: string, entityId: string) => + ['entityAccess', entityType, entityId] as const, + registry: () => ['entityRegistry'] as const, +}; + +// ─── Hooks ───────────────────────────────────────────────────────────────── + +/** + * Fetch all permissions for a given entity. + */ +export function useEntityPermissions(entityType: string, entityId: string) { + return useQuery({ + queryKey: entityPermissionKeys.list(entityType, entityId), + queryFn: () => fetchEntityPermissions(entityType, entityId), + enabled: !!entityType && !!entityId, + }); +} + +/** + * Create a new permission on an entity. + */ +export function useCreateEntityPermission() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + entityType, + entityId, + data, + }: { + entityType: string; + entityId: string; + data: CreateEntityPermissionPayload; + }) => createEntityPermission(entityType, entityId, data), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: entityPermissionKeys.list(variables.entityType, variables.entityId), + }); + }, + }); +} + +/** + * Update an existing permission on an entity. + */ +export function useUpdateEntityPermission() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + entityType, + entityId, + permId, + data, + }: { + entityType: string; + entityId: string; + permId: string; + data: UpdateEntityPermissionPayload; + }) => updateEntityPermission(entityType, entityId, permId, data), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: entityPermissionKeys.list(variables.entityType, variables.entityId), + }); + }, + }); +} + +/** + * Delete a permission from an entity. + */ +export function useDeleteEntityPermission() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + entityType, + entityId, + permId, + }: { + entityType: string; + entityId: string; + permId: string; + }) => deleteEntityPermission(entityType, entityId, permId), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: entityPermissionKeys.list(variables.entityType, variables.entityId), + }); + }, + }); +} + +/** + * Fetch access info for the current user on an entity. + */ +export function useEntityAccess(entityType: string, entityId: string) { + return useQuery({ + queryKey: entityPermissionKeys.access(entityType, entityId), + queryFn: () => fetchEntityAccess(entityType, entityId), + enabled: !!entityType && !!entityId, + }); +} + +/** + * Fetch the registry of all supported entity types. + */ +export function useEntityRegistry() { + return useQuery({ + queryKey: entityPermissionKeys.registry(), + queryFn: () => fetchEntityRegistry(), + }); +} diff --git a/frontend/src/api/entityPermissions.ts b/frontend/src/api/entityPermissions.ts new file mode 100644 index 0000000..8501984 --- /dev/null +++ b/frontend/src/api/entityPermissions.ts @@ -0,0 +1,138 @@ +/** + * Universal Entity Permission API client. + * + * Works with any entity type via the generic permissions endpoint: + * /api/v1/permissions/{entity_type}/{entity_id} + */ + +import { apiDelete, apiGet, apiPost, apiPut } from './client'; + +// ─── Types ───────────────────────────────────────────────────────────────── + +export type PrincipalType = 'user' | 'group'; + +export type PermissionLevel = 'read' | 'write' | 'admin' | 'delete'; + +export interface EntityPermission { + id: string; + entity_type: string; + entity_id: string; + user_id: string | null; + group_id: string | null; + principal_type: PrincipalType; + user_name: string | null; + group_name: string | null; + user_email: string | null; + permission_level: PermissionLevel; + expires_at: string | null; + created_at: string | null; + updated_at: string | null; +} + +export interface EntityAccessInfo { + entity_type: string; + entity_id: string; + access_level: PermissionLevel | 'owner' | 'none'; + is_owner: boolean; + is_shared: boolean; + owner_name: string | null; + owner_email: string | null; +} + +export interface EntityRegistryEntry { + entity_type: string; + display_name: string; + description: string | null; +} + +export interface EntityPermissionListResponse { + items: EntityPermission[]; + total: number; +} + +export interface CreateEntityPermissionPayload { + user_id?: string; + group_id?: string; + permission_level: PermissionLevel; + expires_at?: string | null; +} + +export interface UpdateEntityPermissionPayload { + permission_level?: PermissionLevel; + expires_at?: string | null; +} + +// ─── API Functions ───────────────────────────────────────────────────────── + +/** + * Fetch all permissions for a given entity. + */ +export function fetchEntityPermissions( + entityType: string, + entityId: string +): Promise { + return apiGet( + `/permissions/${entityType}/${entityId}` + ); +} + +/** + * Create a new permission on an entity. + */ +export function createEntityPermission( + entityType: string, + entityId: string, + data: CreateEntityPermissionPayload +): Promise { + return apiPost( + `/permissions/${entityType}/${entityId}`, + data + ); +} + +/** + * Update an existing permission on an entity. + */ +export function updateEntityPermission( + entityType: string, + entityId: string, + permId: string, + data: UpdateEntityPermissionPayload +): Promise { + return apiPut( + `/permissions/${entityType}/${entityId}/${permId}`, + data + ); +} + +/** + * Delete a permission from an entity. + */ +export function deleteEntityPermission( + entityType: string, + entityId: string, + permId: string +): Promise { + return apiDelete( + `/permissions/${entityType}/${entityId}/${permId}` + ); +} + +/** + * Fetch access info for the current user on an entity. + */ +export function fetchEntityAccess( + entityType: string, + entityId: string +): Promise { + return apiGet( + `/permissions/${entityType}/${entityId}/access` + ); +} + +/** + * Fetch the registry of all supported entity types. + */ +export function fetchEntityRegistry(): Promise { + return apiGet('/permissions/registry'); +} diff --git a/frontend/src/components/common/ShareDialog.tsx b/frontend/src/components/common/ShareDialog.tsx new file mode 100644 index 0000000..c398a24 --- /dev/null +++ b/frontend/src/components/common/ShareDialog.tsx @@ -0,0 +1,385 @@ +import React, { useState } from 'react'; +import { createPortal } from 'react-dom'; +import clsx from 'clsx'; +import { + X, + Shield, + User, + Users, + Plus, + Trash2, + Lock, + Eye, + Pencil, + ChevronDown, + Calendar, + Crown, +} from 'lucide-react'; +import { + useEntityPermissions, + useCreateEntityPermission, + useUpdateEntityPermission, + useDeleteEntityPermission, + useEntityAccess, +} from '@/api/entityPermissionHooks'; +import { useUsers } from '@/api/users'; +import { useGroups } from '@/api/groups'; +import type { EntityPermission, PermissionLevel } from '@/api/entityPermissions'; + +interface ShareDialogProps { + entityType: string; + entityId: string; + entityName: string; + onClose: () => void; +} + +const PERM_LEVELS: { value: PermissionLevel; label: string; icon: React.ComponentType<{ className?: string; strokeWidth?: number }>; desc: string }[] = [ + { value: 'read', label: 'Lesen', icon: Eye, desc: 'Inhalt ansehen' }, + { value: 'write', label: 'Schreiben', icon: Pencil, desc: 'Inhalt bearbeiten, neue hinzufügen' }, + { value: 'admin', label: 'Admin', icon: Shield, desc: 'Bearbeiten + Löschen + Rechte verwalten' }, + { value: 'delete', label: 'Löschen', icon: Trash2, desc: 'Vollzugriff inkl. Löschen' }, +]; + +function permIcon(level: string) { + const p = PERM_LEVELS.find((l) => l.value === level); + return p ? p.icon : Eye; +} + +function permLabel(level: string) { + const p = PERM_LEVELS.find((l) => l.value === level); + return p ? p.label : level; +} + +export function ShareDialog({ entityType, entityId, entityName, onClose }: ShareDialogProps) { + const { data: permData, isLoading } = useEntityPermissions(entityType, entityId); + const { data: accessData } = useEntityAccess(entityType, entityId); + const { data: usersData } = useUsers(1, 100); + const { data: groupsData } = useGroups(); + const createMut = useCreateEntityPermission(); + const updateMut = useUpdateEntityPermission(); + const deleteMut = useDeleteEntityPermission(); + + const [showAdd, setShowAdd] = useState(false); + const [addType, setAddType] = useState<'user' | 'group'>('user'); + const [addPrincipalId, setAddPrincipalId] = useState(''); + const [addLevel, setAddLevel] = useState('read'); + const [addExpiresAt, setAddExpiresAt] = useState(''); + + const permissions = permData?.items ?? []; + const users = usersData?.items ?? []; + const groups = groupsData?.items ?? []; + const ownerName = accessData?.owner_name ?? null; + const ownerEmail = accessData?.owner_email ?? null; + + const handleAdd = () => { + if (!addPrincipalId) return; + createMut.mutate( + { + entityType, + entityId, + data: { + user_id: addType === 'user' ? addPrincipalId : undefined, + group_id: addType === 'group' ? addPrincipalId : undefined, + permission_level: addLevel, + expires_at: addExpiresAt || null, + }, + }, + { + onSuccess: () => { + setShowAdd(false); + setAddPrincipalId(''); + setAddLevel('read'); + setAddExpiresAt(''); + }, + } + ); + }; + + const handleUpdate = (perm: EntityPermission, newLevel: string) => { + updateMut.mutate({ + entityType, + entityId, + permId: perm.id, + data: { permission_level: newLevel as PermissionLevel }, + }); + }; + + const handleUpdateExpiry = (perm: EntityPermission, expiresAt: string) => { + updateMut.mutate({ + entityType, + entityId, + permId: perm.id, + data: { expires_at: expiresAt || null }, + }); + }; + + const handleDelete = (perm: EntityPermission) => { + if (!confirm(`Berechtigung für ${perm.user_name || perm.group_name || 'diesen Eintrag'} entfernen?`)) return; + deleteMut.mutate({ entityType, entityId, permId: perm.id }); + }; + + return createPortal( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ +

Freigabe: {entityName}

+
+ +
+ + {/* Body */} +
+ {/* Info banner */} +
+

Element teilen

+

+ Gewähre Benutzern oder Gruppen Zugriff auf dieses Element. Die Berechtigungsstufe bestimmt, + welche Aktionen durchgeführt werden können. +

+
+ + {/* Owner info */} + {ownerName && ( +
+
+ + Besitzer +
+

+ {ownerName}{ownerEmail ? ` (${ownerEmail})` : ''} +

+
+ )} + + {/* Existing permissions */} + {isLoading ? ( +
Laden…
+ ) : permissions.length === 0 ? ( +
+ Noch keine Berechtigungen vergeben. Dieses Element ist nur für den Besitzer sichtbar. +
+ ) : ( +
+ {permissions.map((perm) => { + const Icon = perm.user_id ? User : Users; + const name = perm.user_name || perm.group_name || 'Unbekannt'; + const email = perm.user_email; + const PermIcon = permIcon(perm.permission_level); + const isExpired = perm.expires_at ? new Date(perm.expires_at) < new Date() : false; + return ( +
+ +
+
+ {name} + {isExpired && ( + (abgelaufen) + )} +
+ {email && ( +
{email}
+ )} + {/* Expiry date */} +
+ + handleUpdateExpiry(perm, e.target.value || '')} + className="text-xs border border-secondary-200 rounded px-1 py-0.5 bg-transparent focus:outline-none focus:ring-1 focus:ring-primary-500 text-secondary-500" + title="Ablaufdatum setzen" + /> +
+
+ {/* Permission level selector */} +
+ + + +
+ {/* Delete */} + +
+ ); + })} +
+ )} + + {/* Add new permission */} + {showAdd ? ( +
+
+ + Neue Berechtigung +
+ + {/* Type toggle */} +
+ + +
+ + {/* Principal select */} + + + {/* Permission level */} +
+ {PERM_LEVELS.map((l) => ( + + ))} +
+ + {/* Expiry date */} +
+ + setAddExpiresAt(e.target.value)} + className="w-full px-3 py-2 text-sm border border-secondary-200 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-primary-500" + min={new Date().toISOString().split('T')[0]} + /> +
+ + {/* Actions */} +
+ + +
+
+ ) : ( + + )} +
+ + {/* Footer */} +
+ +
+
+
, + document.body + ); +}