sprint4+5: field-level permissions complete + universal ShareDialog frontend

This commit is contained in:
Agent Zero
2026-07-29 02:14:26 +02:00
parent b06aeeb720
commit 71ed592aa2
6 changed files with 748 additions and 3 deletions
+77 -2
View File
@@ -9,6 +9,11 @@ from __future__ import annotations
import logging import logging
from typing import Any 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__) logger = logging.getLogger(__name__)
# ── Core system permissions ── # ── Core system permissions ──
@@ -60,13 +65,50 @@ CORE_PERMISSIONS: list[dict[str, str]] = [
# ── Core field definitions for field-level permissions ── # ── Core field definitions for field-level permissions ──
CORE_FIELD_DEFINITIONS: list[dict[str, str]] = [ CORE_FIELD_DEFINITIONS: list[dict[str, str]] = [
# ── Contact fields ──
{"module": "contacts", "field": "firstname", "label": "First Name", "sensitivity": "normal"}, {"module": "contacts", "field": "firstname", "label": "First Name", "sensitivity": "normal"},
{"module": "contacts", "field": "surname", "label": "Last Name", "sensitivity": "normal"}, {"module": "contacts", "field": "surname", "label": "Last Name", "sensitivity": "normal"},
{"module": "contacts", "field": "email_1", "label": "Email", "sensitivity": "normal"}, {"module": "contacts", "field": "displayname", "label": "Display Name", "sensitivity": "normal"},
{"module": "contacts", "field": "phone_1", "label": "Phone", "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": "mobilephone", "label": "Mobile", "sensitivity": "sensitive"},
{"module": "contacts", "field": "function", "label": "Position", "sensitivity": "normal"}, {"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": "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": "email", "label": "Email", "sensitivity": "normal"},
{"module": "users", "field": "name", "label": "Name", "sensitivity": "normal"}, {"module": "users", "field": "name", "label": "Name", "sensitivity": "normal"},
{"module": "users", "field": "role", "label": "Role", "sensitivity": "normal"}, {"module": "users", "field": "role", "label": "Role", "sensitivity": "normal"},
@@ -166,6 +208,39 @@ class PermissionRegistry:
result.extend(defs) result.extend(defs)
return result 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 # Global instance
_registry = PermissionRegistry() _registry = PermissionRegistry()
+13
View File
@@ -461,6 +461,7 @@ def filter_fields_by_permission(
"""Filter response fields based on field-level permissions. """Filter response fields based on field-level permissions.
Removes fields marked as "hidden", keeps others. 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"): if resolved.get("is_system_admin"):
return data return data
@@ -476,5 +477,17 @@ def filter_fields_by_permission(
perm = module_perms.get(key) perm = module_perms.get(key)
if perm == "hidden": if perm == "hidden":
continue continue
# 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 result[key] = value
return result return result
+3
View File
@@ -44,6 +44,9 @@ class CustomFieldDefinition(Base, TenantMixin, OwnedMixin):
options: Mapped[list[str] | None] = mapped_column(JSON, nullable=True, default=list) options: Mapped[list[str] | None] = mapped_column(JSON, nullable=True, default=list)
default_value: Mapped[Any | None] = mapped_column(JSON, nullable=True, default=None) 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 ── # ── Behaviour ──
required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+131
View File
@@ -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(),
});
}
+138
View File
@@ -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<EntityPermissionListResponse> {
return apiGet<EntityPermissionListResponse>(
`/permissions/${entityType}/${entityId}`
);
}
/**
* Create a new permission on an entity.
*/
export function createEntityPermission(
entityType: string,
entityId: string,
data: CreateEntityPermissionPayload
): Promise<EntityPermission> {
return apiPost<EntityPermission>(
`/permissions/${entityType}/${entityId}`,
data
);
}
/**
* Update an existing permission on an entity.
*/
export function updateEntityPermission(
entityType: string,
entityId: string,
permId: string,
data: UpdateEntityPermissionPayload
): Promise<EntityPermission> {
return apiPut<EntityPermission>(
`/permissions/${entityType}/${entityId}/${permId}`,
data
);
}
/**
* Delete a permission from an entity.
*/
export function deleteEntityPermission(
entityType: string,
entityId: string,
permId: string
): Promise<void> {
return apiDelete<void>(
`/permissions/${entityType}/${entityId}/${permId}`
);
}
/**
* Fetch access info for the current user on an entity.
*/
export function fetchEntityAccess(
entityType: string,
entityId: string
): Promise<EntityAccessInfo> {
return apiGet<EntityAccessInfo>(
`/permissions/${entityType}/${entityId}/access`
);
}
/**
* Fetch the registry of all supported entity types.
*/
export function fetchEntityRegistry(): Promise<EntityRegistryEntry[]> {
return apiGet<EntityRegistryEntry[]>('/permissions/registry');
}
@@ -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<PermissionLevel>('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(
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50" onClick={onClose}>
<div
className="bg-white rounded-xl shadow-2xl w-full max-w-lg max-h-[80vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-secondary-200">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-primary-600" strokeWidth={2} />
<h2 className="text-lg font-semibold text-secondary-800">Freigabe: {entityName}</h2>
</div>
<button
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600 p-1 rounded-md hover:bg-secondary-100"
aria-label="Schließen"
>
<X className="w-5 h-5" strokeWidth={2} />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{/* Info banner */}
<div className="mb-4 p-3 bg-primary-50 border border-primary-200 rounded-lg text-sm text-primary-700">
<p className="font-medium mb-1">Element teilen</p>
<p className="text-primary-600">
Gewähre Benutzern oder Gruppen Zugriff auf dieses Element. Die Berechtigungsstufe bestimmt,
welche Aktionen durchgeführt werden können.
</p>
</div>
{/* Owner info */}
{ownerName && (
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm">
<div className="flex items-center gap-2 text-amber-700 font-medium mb-1">
<Crown className="w-4 h-4" strokeWidth={2} />
<span>Besitzer</span>
</div>
<p className="text-amber-600">
{ownerName}{ownerEmail ? ` (${ownerEmail})` : ''}
</p>
</div>
)}
{/* Existing permissions */}
{isLoading ? (
<div className="text-sm text-secondary-400 py-4 text-center">Laden</div>
) : permissions.length === 0 ? (
<div className="text-sm text-secondary-400 py-4 text-center">
Noch keine Berechtigungen vergeben. Dieses Element ist nur für den Besitzer sichtbar.
</div>
) : (
<div className="space-y-2">
{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 (
<div
key={perm.id}
className={clsx(
'flex items-center gap-3 p-3 border rounded-lg',
isExpired
? 'border-red-200 bg-red-50'
: 'border-secondary-200 hover:bg-secondary-50'
)}
>
<Icon className="w-4 h-4 text-secondary-400 flex-shrink-0" strokeWidth={2} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-secondary-700 truncate">
{name}
{isExpired && (
<span className="ml-2 text-xs text-red-500 font-normal">(abgelaufen)</span>
)}
</div>
{email && (
<div className="text-xs text-secondary-400 truncate">{email}</div>
)}
{/* Expiry date */}
<div className="flex items-center gap-1 mt-1">
<Calendar className="w-3 h-3 text-secondary-400" strokeWidth={2} />
<input
type="date"
value={perm.expires_at ? perm.expires_at.split('T')[0] : ''}
onChange={(e) => 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"
/>
</div>
</div>
{/* Permission level selector */}
<div className="relative">
<select
value={perm.permission_level}
onChange={(e) => handleUpdate(perm, e.target.value)}
className="appearance-none pl-8 pr-7 py-1.5 text-sm border border-secondary-200 rounded-md bg-white cursor-pointer hover:border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
{PERM_LEVELS.map((l) => (
<option key={l.value} value={l.value}>{l.label}</option>
))}
</select>
<PermIcon className="w-3.5 h-3.5 text-secondary-400 absolute left-2 top-1/2 -translate-y-1/2 pointer-events-none" strokeWidth={2} />
<ChevronDown className="w-3.5 h-3.5 text-secondary-400 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none" strokeWidth={2} />
</div>
{/* Delete */}
<button
onClick={() => handleDelete(perm)}
className="text-secondary-400 hover:text-red-600 p-1.5 rounded-md hover:bg-red-50 flex-shrink-0"
title="Entfernen"
aria-label="Berechtigung entfernen"
>
<Trash2 className="w-4 h-4" strokeWidth={2} />
</button>
</div>
);
})}
</div>
)}
{/* Add new permission */}
{showAdd ? (
<div className="mt-4 p-4 border-2 border-primary-200 rounded-lg bg-primary-50/50">
<div className="flex items-center gap-2 mb-3">
<Plus className="w-4 h-4 text-primary-600" strokeWidth={2} />
<span className="text-sm font-medium text-secondary-700">Neue Berechtigung</span>
</div>
{/* Type toggle */}
<div className="flex gap-2 mb-3">
<button
onClick={() => {
setAddType('user');
setAddPrincipalId('');
}}
className={clsx(
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border',
addType === 'user'
? 'bg-primary-600 text-white border-primary-600'
: 'bg-white text-secondary-600 border-secondary-200 hover:border-secondary-300'
)}
>
<User className="w-3.5 h-3.5" strokeWidth={2} />
Benutzer
</button>
<button
onClick={() => {
setAddType('group');
setAddPrincipalId('');
}}
className={clsx(
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border',
addType === 'group'
? 'bg-primary-600 text-white border-primary-600'
: 'bg-white text-secondary-600 border-secondary-200 hover:border-secondary-300'
)}
>
<Users className="w-3.5 h-3.5" strokeWidth={2} />
Gruppe
</button>
</div>
{/* Principal select */}
<select
value={addPrincipalId}
onChange={(e) => setAddPrincipalId(e.target.value)}
className="w-full px-3 py-2 text-sm border border-secondary-200 rounded-md mb-3 bg-white cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">
{addType === 'user' ? 'Benutzer auswählen…' : 'Gruppe auswählen…'}
</option>
{addType === 'user'
? users.map((u) => (
<option key={u.id} value={u.id}>
{u.name} ({u.email})
</option>
))
: groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
{/* Permission level */}
<div className="grid grid-cols-2 gap-2 mb-3">
{PERM_LEVELS.map((l) => (
<button
key={l.value}
onClick={() => setAddLevel(l.value)}
className={clsx(
'flex items-start gap-2 p-2.5 text-left rounded-md border text-sm',
addLevel === l.value
? 'bg-primary-50 border-primary-400 text-primary-700'
: 'bg-white border-secondary-200 text-secondary-600 hover:border-secondary-300'
)}
>
<l.icon className="w-4 h-4 flex-shrink-0 mt-0.5" strokeWidth={2} />
<div>
<div className="font-medium">{l.label}</div>
<div className="text-xs text-secondary-400">{l.desc}</div>
</div>
</button>
))}
</div>
{/* Expiry date */}
<div className="mb-3">
<label className="flex items-center gap-2 text-sm text-secondary-600 mb-1">
<Calendar className="w-4 h-4" strokeWidth={2} />
Ablaufdatum (optional)
</label>
<input
type="date"
value={addExpiresAt}
onChange={(e) => 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]}
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-2">
<button
onClick={() => setShowAdd(false)}
className="px-3 py-1.5 text-sm text-secondary-600 hover:bg-secondary-100 rounded-md"
>
Abbrechen
</button>
<button
onClick={handleAdd}
disabled={!addPrincipalId || createMut.isPending}
className="px-4 py-1.5 text-sm bg-primary-600 text-white rounded-md hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{createMut.isPending ? 'Speichern…' : 'Hinzufügen'}
</button>
</div>
</div>
) : (
<button
onClick={() => setShowAdd(true)}
className="mt-4 w-full flex items-center justify-center gap-2 py-2.5 text-sm text-primary-600 border-2 border-dashed border-primary-200 rounded-lg hover:bg-primary-50 hover:border-primary-300 transition-colors"
>
<Plus className="w-4 h-4" strokeWidth={2} />
Berechtigung hinzufügen
</button>
)}
</div>
{/* Footer */}
<div className="px-5 py-3 border-t border-secondary-200 flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-secondary-600 hover:bg-secondary-100 rounded-md"
>
Schließen
</button>
</div>
</div>
</div>,
document.body
);
}