sprint4+5: field-level permissions complete + universal ShareDialog frontend
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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