sprint4+5: field-level permissions complete + universal ShareDialog frontend
This commit is contained in:
@@ -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()
|
||||
|
||||
+14
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user