sprint9: app visibility — sidebar permission filter + TopBar + ProtectedRoute + route guards
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
This commit is contained in:
@@ -18,6 +18,7 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
|||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.core.db import Base, TenantMixin
|
from app.core.db import Base, TenantMixin
|
||||||
|
from app.models.owned_mixin import OwnedMixin
|
||||||
|
|
||||||
|
|
||||||
class Calendar(Base, TenantMixin):
|
class Calendar(Base, TenantMixin):
|
||||||
@@ -37,7 +38,7 @@ class Calendar(Base, TenantMixin):
|
|||||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class CalendarEntry(Base, TenantMixin):
|
class CalendarEntry(Base, TenantMixin, OwnedMixin):
|
||||||
"""Calendar entry — appointment or task, tenant-scoped, soft-deletable."""
|
"""Calendar entry — appointment or task, tenant-scoped, soft-deletable."""
|
||||||
|
|
||||||
__tablename__ = "calendar_entries"
|
__tablename__ = "calendar_entries"
|
||||||
@@ -131,7 +132,7 @@ class UserCalendarVisibility(Base):
|
|||||||
visible: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
visible: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class Subtask(Base, TenantMixin):
|
class Subtask(Base, TenantMixin, OwnedMixin):
|
||||||
"""Subtask belonging to a calendar entry."""
|
"""Subtask belonging to a calendar entry."""
|
||||||
|
|
||||||
__tablename__ = "subtasks"
|
__tablename__ = "subtasks"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from sqlalchemy import select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
|
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
||||||
from app.deps import get_current_user, require_admin, require_permission
|
from app.deps import get_current_user, require_admin, require_permission
|
||||||
from app.plugins.builtins.calendar.ics_utils import (
|
from app.plugins.builtins.calendar.ics_utils import (
|
||||||
export_entries_to_ics,
|
export_entries_to_ics,
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
|||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.core.db import Base, TenantMixin
|
from app.core.db import Base, TenantMixin
|
||||||
|
from app.models.owned_mixin import OwnedMixin
|
||||||
|
|
||||||
|
|
||||||
class Folder(Base, TenantMixin):
|
class Folder(Base, TenantMixin, OwnedMixin):
|
||||||
"""Folder entity — hierarchical, tenant-scoped, soft-deletable."""
|
"""Folder entity — hierarchical, tenant-scoped, soft-deletable."""
|
||||||
|
|
||||||
__tablename__ = "folders"
|
__tablename__ = "folders"
|
||||||
@@ -41,7 +42,7 @@ class Folder(Base, TenantMixin):
|
|||||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class File(Base, TenantMixin):
|
class File(Base, TenantMixin, OwnedMixin):
|
||||||
"""File entity — stored on disk, tenant-scoped, soft-deletable."""
|
"""File entity — stored on disk, tenant-scoped, soft-deletable."""
|
||||||
|
|
||||||
__tablename__ = "files"
|
__tablename__ = "files"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.storage import get_storage_backend
|
from app.core.storage import get_storage_backend
|
||||||
|
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
||||||
from app.deps import get_current_user, require_permission
|
from app.deps import get_current_user, require_permission
|
||||||
from app.plugins.builtins.dms.models import File as DmsFile
|
from app.plugins.builtins.dms.models import File as DmsFile
|
||||||
from app.plugins.builtins.dms.models import Folder
|
from app.plugins.builtins.dms.models import Folder
|
||||||
@@ -106,13 +107,17 @@ async def list_folders(
|
|||||||
"""AC1: GET /api/v1/dms/folders → 200 + folder tree (recursive)."""
|
"""AC1: GET /api/v1/dms/folders → 200 + folder tree (recursive)."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
|
||||||
# Fetch all non-deleted folders for tenant
|
# Fetch all non-deleted folders for tenant with visibility filter
|
||||||
result = await db.execute(
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
select(Folder).where(
|
is_system_admin = current_user.get("role") == "admin"
|
||||||
|
query = select(Folder).where(
|
||||||
Folder.tenant_id == tenant_id,
|
Folder.tenant_id == tenant_id,
|
||||||
Folder.deleted_at.is_(None),
|
Folder.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
|
query = await apply_visibility_filter(
|
||||||
|
db, query, "dms_folder", Folder, user_id, tenant_id, is_system_admin
|
||||||
)
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
all_folders = result.scalars().all()
|
all_folders = result.scalars().all()
|
||||||
|
|
||||||
# Build lookup map
|
# Build lookup map
|
||||||
@@ -254,6 +259,8 @@ async def update_folder(
|
|||||||
):
|
):
|
||||||
"""AC3: PATCH /api/v1/dms/folders/{id} → 200, rename/move."""
|
"""AC3: PATCH /api/v1/dms/folders/{id} → 200, rename/move."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
is_system_admin = current_user.get("role") == "admin"
|
||||||
fid = _parse_uuid(folder_id, "folder_id")
|
fid = _parse_uuid(folder_id, "folder_id")
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -267,6 +274,9 @@ async def update_folder(
|
|||||||
if folder is None:
|
if folder is None:
|
||||||
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
|
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
|
||||||
|
|
||||||
|
if not await check_single_entity_access(db, "dms_folder", fid, user_id, tenant_id, "write", is_system_admin):
|
||||||
|
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||||
|
|
||||||
data = body.model_dump(exclude_unset=True)
|
data = body.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
if "name" in data and data["name"] is not None:
|
if "name" in data and data["name"] is not None:
|
||||||
@@ -366,6 +376,8 @@ async def delete_folder(
|
|||||||
):
|
):
|
||||||
"""AC4: DELETE /api/v1/dms/folders/{id} → 204, soft-delete with cascade."""
|
"""AC4: DELETE /api/v1/dms/folders/{id} → 204, soft-delete with cascade."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
is_system_admin = current_user.get("role") == "admin"
|
||||||
fid = _parse_uuid(folder_id, "folder_id")
|
fid = _parse_uuid(folder_id, "folder_id")
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -379,6 +391,9 @@ async def delete_folder(
|
|||||||
if folder is None:
|
if folder is None:
|
||||||
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
|
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
|
||||||
|
|
||||||
|
if not await check_single_entity_access(db, "dms_folder", fid, user_id, tenant_id, "delete", is_system_admin):
|
||||||
|
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
@@ -520,6 +535,8 @@ async def get_file(
|
|||||||
):
|
):
|
||||||
"""AC6: GET /api/v1/dms/files/{id} → 200 + file metadata."""
|
"""AC6: GET /api/v1/dms/files/{id} → 200 + file metadata."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
is_system_admin = current_user.get("role") == "admin"
|
||||||
fid = _parse_uuid(file_id, "file_id")
|
fid = _parse_uuid(file_id, "file_id")
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -533,6 +550,9 @@ async def get_file(
|
|||||||
if dms_file is None:
|
if dms_file is None:
|
||||||
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
|
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
|
||||||
|
|
||||||
|
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "read", is_system_admin):
|
||||||
|
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": str(dms_file.id),
|
"id": str(dms_file.id),
|
||||||
"name": dms_file.name,
|
"name": dms_file.name,
|
||||||
@@ -554,13 +574,17 @@ async def list_all_files(
|
|||||||
):
|
):
|
||||||
"""List all non-deleted files for the current tenant."""
|
"""List all non-deleted files for the current tenant."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
is_system_admin = current_user.get("role") == "admin"
|
||||||
|
|
||||||
result = await db.execute(
|
query = select(DmsFile).where(
|
||||||
select(DmsFile).where(
|
|
||||||
DmsFile.tenant_id == tenant_id,
|
DmsFile.tenant_id == tenant_id,
|
||||||
DmsFile.deleted_at.is_(None),
|
DmsFile.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
|
query = await apply_visibility_filter(
|
||||||
|
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
|
||||||
)
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
files = result.scalars().all()
|
files = result.scalars().all()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -587,6 +611,8 @@ async def list_files_in_folder(
|
|||||||
):
|
):
|
||||||
"""List all non-deleted files in a specific folder (non-recursive)."""
|
"""List all non-deleted files in a specific folder (non-recursive)."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
is_system_admin = current_user.get("role") == "admin"
|
||||||
fid = _parse_uuid(folder_id, "folder_id")
|
fid = _parse_uuid(folder_id, "folder_id")
|
||||||
|
|
||||||
# Validate folder exists
|
# Validate folder exists
|
||||||
@@ -600,13 +626,15 @@ async def list_files_in_folder(
|
|||||||
if folder_result.scalar_one_or_none() is None:
|
if folder_result.scalar_one_or_none() is None:
|
||||||
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
|
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
|
||||||
|
|
||||||
result = await db.execute(
|
query = select(DmsFile).where(
|
||||||
select(DmsFile).where(
|
|
||||||
DmsFile.tenant_id == tenant_id,
|
DmsFile.tenant_id == tenant_id,
|
||||||
DmsFile.folder_id == fid,
|
DmsFile.folder_id == fid,
|
||||||
DmsFile.deleted_at.is_(None),
|
DmsFile.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
|
query = await apply_visibility_filter(
|
||||||
|
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
|
||||||
)
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
files = result.scalars().all()
|
files = result.scalars().all()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -634,6 +662,8 @@ async def update_file(
|
|||||||
):
|
):
|
||||||
"""AC7: PATCH /api/v1/dms/files/{id} → 200, rename/move."""
|
"""AC7: PATCH /api/v1/dms/files/{id} → 200, rename/move."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
is_system_admin = current_user.get("role") == "admin"
|
||||||
fid = _parse_uuid(file_id, "file_id")
|
fid = _parse_uuid(file_id, "file_id")
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -647,6 +677,9 @@ async def update_file(
|
|||||||
if dms_file is None:
|
if dms_file is None:
|
||||||
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
|
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
|
||||||
|
|
||||||
|
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "write", is_system_admin):
|
||||||
|
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||||
|
|
||||||
data = body.model_dump(exclude_unset=True)
|
data = body.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
if "name" in data and data["name"] is not None:
|
if "name" in data and data["name"] is not None:
|
||||||
@@ -691,6 +724,8 @@ async def delete_file(
|
|||||||
):
|
):
|
||||||
"""AC8: DELETE /api/v1/dms/files/{id} → 204, soft-delete."""
|
"""AC8: DELETE /api/v1/dms/files/{id} → 204, soft-delete."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
is_system_admin = current_user.get("role") == "admin"
|
||||||
fid = _parse_uuid(file_id, "file_id")
|
fid = _parse_uuid(file_id, "file_id")
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -704,6 +739,9 @@ async def delete_file(
|
|||||||
if dms_file is None:
|
if dms_file is None:
|
||||||
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
|
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
|
||||||
|
|
||||||
|
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "delete", is_system_admin):
|
||||||
|
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
dms_file.deleted_at = datetime.now(UTC)
|
dms_file.deleted_at = datetime.now(UTC)
|
||||||
@@ -719,6 +757,8 @@ async def restore_file(
|
|||||||
):
|
):
|
||||||
"""AC9: POST /api/v1/dms/files/{id}/restore → 200, restored from trash."""
|
"""AC9: POST /api/v1/dms/files/{id}/restore → 200, restored from trash."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
is_system_admin = current_user.get("role") == "admin"
|
||||||
fid = _parse_uuid(file_id, "file_id")
|
fid = _parse_uuid(file_id, "file_id")
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -732,6 +772,9 @@ async def restore_file(
|
|||||||
if dms_file is None:
|
if dms_file is None:
|
||||||
raise HTTPException(404, detail={"detail": "Deleted file not found", "code": "not_found"})
|
raise HTTPException(404, detail={"detail": "Deleted file not found", "code": "not_found"})
|
||||||
|
|
||||||
|
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "write", is_system_admin):
|
||||||
|
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||||
|
|
||||||
dms_file.deleted_at = None
|
dms_file.deleted_at = None
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(dms_file)
|
await db.refresh(dms_file)
|
||||||
@@ -761,6 +804,8 @@ async def preview_file(
|
|||||||
):
|
):
|
||||||
"""AC10: GET /api/v1/dms/files/{id}/preview → 200 + PDF stream."""
|
"""AC10: GET /api/v1/dms/files/{id}/preview → 200 + PDF stream."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
is_system_admin = current_user.get("role") == "admin"
|
||||||
fid = _parse_uuid(file_id, "file_id")
|
fid = _parse_uuid(file_id, "file_id")
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -774,6 +819,9 @@ async def preview_file(
|
|||||||
if dms_file is None:
|
if dms_file is None:
|
||||||
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
|
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
|
||||||
|
|
||||||
|
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "read", is_system_admin):
|
||||||
|
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||||
|
|
||||||
if dms_file.mime_type != "application/pdf":
|
if dms_file.mime_type != "application/pdf":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
400, detail={"detail": "Only PDF files can be previewed", "code": "not_pdf"}
|
400, detail={"detail": "Only PDF files can be previewed", "code": "not_pdf"}
|
||||||
@@ -807,6 +855,7 @@ async def create_edit_session(
|
|||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = current_user["user_id"]
|
user_id = current_user["user_id"]
|
||||||
user_name = current_user.get("name", "Unknown")
|
user_name = current_user.get("name", "Unknown")
|
||||||
|
is_system_admin = current_user.get("role") == "admin"
|
||||||
fid = _parse_uuid(file_id, "file_id")
|
fid = _parse_uuid(file_id, "file_id")
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -820,6 +869,9 @@ async def create_edit_session(
|
|||||||
if dms_file is None:
|
if dms_file is None:
|
||||||
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
|
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
|
||||||
|
|
||||||
|
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "write", is_system_admin):
|
||||||
|
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||||
|
|
||||||
ext = _get_file_extension(dms_file.name)
|
ext = _get_file_extension(dms_file.name)
|
||||||
if ext not in OFFICE_EXTENSIONS:
|
if ext not in OFFICE_EXTENSIONS:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -866,6 +918,8 @@ async def share_file(
|
|||||||
):
|
):
|
||||||
"""AC12: POST /api/v1/dms/files/{id}/share → 200, internal share created."""
|
"""AC12: POST /api/v1/dms/files/{id}/share → 200, internal share created."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
is_system_admin = current_user.get("role") == "admin"
|
||||||
fid = _parse_uuid(file_id, "file_id")
|
fid = _parse_uuid(file_id, "file_id")
|
||||||
|
|
||||||
# Verify file exists
|
# Verify file exists
|
||||||
@@ -879,6 +933,9 @@ async def share_file(
|
|||||||
if file_result.scalar_one_or_none() is None:
|
if file_result.scalar_one_or_none() is None:
|
||||||
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
|
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
|
||||||
|
|
||||||
|
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "share", is_system_admin):
|
||||||
|
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||||
|
|
||||||
created_perms: list[dict] = []
|
created_perms: list[dict] = []
|
||||||
|
|
||||||
for uid_str in body.user_ids:
|
for uid_str in body.user_ids:
|
||||||
@@ -958,8 +1015,13 @@ async def remove_share(
|
|||||||
):
|
):
|
||||||
"""AC13: DELETE /api/v1/dms/files/{id}/share → 204, share removed."""
|
"""AC13: DELETE /api/v1/dms/files/{id}/share → 204, share removed."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
is_system_admin = current_user.get("role") == "admin"
|
||||||
fid = _parse_uuid(file_id, "file_id")
|
fid = _parse_uuid(file_id, "file_id")
|
||||||
|
|
||||||
|
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "share", is_system_admin):
|
||||||
|
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||||
|
|
||||||
if body.user_id:
|
if body.user_id:
|
||||||
uid = _parse_uuid(body.user_id, "user_id")
|
uid = _parse_uuid(body.user_id, "user_id")
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -1001,14 +1063,18 @@ async def search_files(
|
|||||||
):
|
):
|
||||||
"""AC16: GET /api/v1/dms/search?q=text → 200 + matching files (ILIKE)."""
|
"""AC16: GET /api/v1/dms/search?q=text → 200 + matching files (ILIKE)."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
is_system_admin = current_user.get("role") == "admin"
|
||||||
|
|
||||||
result = await db.execute(
|
query = select(DmsFile).where(
|
||||||
select(DmsFile).where(
|
|
||||||
DmsFile.tenant_id == tenant_id,
|
DmsFile.tenant_id == tenant_id,
|
||||||
DmsFile.deleted_at.is_(None),
|
DmsFile.deleted_at.is_(None),
|
||||||
DmsFile.name.ilike(f"%{q}%"),
|
DmsFile.name.ilike(f"%{q}%"),
|
||||||
)
|
)
|
||||||
|
query = await apply_visibility_filter(
|
||||||
|
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
|
||||||
)
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
files = result.scalars().all()
|
files = result.scalars().all()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -1034,6 +1100,7 @@ async def shared_with_me(
|
|||||||
"""AC17: GET /api/v1/dms/shared-with-me → 200 + shared files list."""
|
"""AC17: GET /api/v1/dms/shared-with-me → 200 + shared files list."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
is_system_admin = current_user.get("role") == "admin"
|
||||||
|
|
||||||
# Query permissions for this user and join with files
|
# Query permissions for this user and join with files
|
||||||
perm_result = await db.execute(
|
perm_result = await db.execute(
|
||||||
@@ -1048,14 +1115,16 @@ async def shared_with_me(
|
|||||||
if not file_ids:
|
if not file_ids:
|
||||||
return {"items": [], "total": 0}
|
return {"items": [], "total": 0}
|
||||||
|
|
||||||
file_result = await db.execute(
|
query = select(DmsFile).where(
|
||||||
select(DmsFile).where(
|
|
||||||
DmsFile.tenant_id == tenant_id,
|
DmsFile.tenant_id == tenant_id,
|
||||||
DmsFile.id.in_(file_ids),
|
DmsFile.id.in_(file_ids),
|
||||||
DmsFile.deleted_at.is_(None),
|
DmsFile.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
|
query = await apply_visibility_filter(
|
||||||
|
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
|
||||||
)
|
)
|
||||||
files = file_result.scalars().all()
|
result = await db.execute(query)
|
||||||
|
files = result.scalars().all()
|
||||||
|
|
||||||
# Map permissions for access_level
|
# Map permissions for access_level
|
||||||
perm_map: dict[uuid.UUID, str] = {}
|
perm_map: dict[uuid.UUID, str] = {}
|
||||||
@@ -1086,6 +1155,8 @@ async def bulk_move(
|
|||||||
):
|
):
|
||||||
"""AC18: POST /api/v1/dms/files/bulk-move → 200, files moved."""
|
"""AC18: POST /api/v1/dms/files/bulk-move → 200, files moved."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
is_system_admin = current_user.get("role") == "admin"
|
||||||
target_folder_id = (
|
target_folder_id = (
|
||||||
_parse_uuid(body.target_folder_id, "target_folder_id") if body.target_folder_id else None
|
_parse_uuid(body.target_folder_id, "target_folder_id") if body.target_folder_id else None
|
||||||
)
|
)
|
||||||
@@ -1106,13 +1177,15 @@ async def bulk_move(
|
|||||||
|
|
||||||
file_ids = [_parse_uuid(fid, "file_id") for fid in body.file_ids]
|
file_ids = [_parse_uuid(fid, "file_id") for fid in body.file_ids]
|
||||||
|
|
||||||
result = await db.execute(
|
query = select(DmsFile).where(
|
||||||
select(DmsFile).where(
|
|
||||||
DmsFile.tenant_id == tenant_id,
|
DmsFile.tenant_id == tenant_id,
|
||||||
DmsFile.id.in_(file_ids),
|
DmsFile.id.in_(file_ids),
|
||||||
DmsFile.deleted_at.is_(None),
|
DmsFile.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
|
query = await apply_visibility_filter(
|
||||||
|
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
|
||||||
)
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
files = result.scalars().all()
|
files = result.scalars().all()
|
||||||
|
|
||||||
moved_count = 0
|
moved_count = 0
|
||||||
@@ -1137,17 +1210,32 @@ async def bulk_delete(
|
|||||||
):
|
):
|
||||||
"""AC19: POST /api/v1/dms/files/bulk-delete → 200, files soft-deleted."""
|
"""AC19: POST /api/v1/dms/files/bulk-delete → 200, files soft-deleted."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
is_system_admin = current_user.get("role") == "admin"
|
||||||
file_ids = [_parse_uuid(fid, "file_id") for fid in body.file_ids]
|
file_ids = [_parse_uuid(fid, "file_id") for fid in body.file_ids]
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
# Apply visibility filter to only delete files user has access to
|
||||||
|
query = select(DmsFile).where(
|
||||||
|
DmsFile.tenant_id == tenant_id,
|
||||||
|
DmsFile.id.in_(file_ids),
|
||||||
|
DmsFile.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
query = await apply_visibility_filter(
|
||||||
|
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
accessible_files = result.scalars().all()
|
||||||
|
accessible_ids = [f.id for f in accessible_files]
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
update(DmsFile)
|
update(DmsFile)
|
||||||
.where(
|
.where(
|
||||||
DmsFile.tenant_id == tenant_id,
|
DmsFile.tenant_id == tenant_id,
|
||||||
DmsFile.id.in_(file_ids),
|
DmsFile.id.in_(accessible_ids),
|
||||||
DmsFile.deleted_at.is_(None),
|
DmsFile.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
.values(deleted_at=now)
|
.values(deleted_at=now)
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
|||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.core.db import Base, TenantMixin
|
from app.core.db import Base, TenantMixin
|
||||||
|
from app.models.owned_mixin import OwnedMixin
|
||||||
|
|
||||||
|
|
||||||
class Task(Base, TenantMixin):
|
class Task(Base, TenantMixin, OwnedMixin):
|
||||||
"""Task entity — free activities (calls, notes, visits) linked to contacts."""
|
"""Task entity — free activities (calls, notes, visits) linked to contacts."""
|
||||||
|
|
||||||
__tablename__ = "tasks"
|
__tablename__ = "tasks"
|
||||||
@@ -47,3 +48,4 @@ class Task(Base, TenantMixin):
|
|||||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { usePermission } from '@/hooks/usePermission';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
permission: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProtectedRoute({ permission, children }: ProtectedRouteProps) {
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
|
|
||||||
|
if (!hasPermission(permission)) {
|
||||||
|
return <Navigate to="/kein-zugriff" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { ChevronRight, FileText, Home, Settings, Users } from 'lucide-react';
|
|||||||
import { usePluginStore } from '@/store/pluginStore';
|
import { usePluginStore } from '@/store/pluginStore';
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
import { useMenuOrder } from '@/api/users';
|
import { useMenuOrder } from '@/api/users';
|
||||||
|
import { usePermission } from '@/hooks/usePermission';
|
||||||
|
|
||||||
interface NavSingleItem {
|
interface NavSingleItem {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -40,6 +41,7 @@ export function Sidebar() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const manifests = usePluginStore(s => s.manifests);
|
const manifests = usePluginStore(s => s.manifests);
|
||||||
const { data: menuOrderData } = useMenuOrder();
|
const { data: menuOrderData } = useMenuOrder();
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
|
|
||||||
const allMenuItems = useMemo(() => {
|
const allMenuItems = useMemo(() => {
|
||||||
const staticItems = singleItems.map(item => ({
|
const staticItems = singleItems.map(item => ({
|
||||||
@@ -50,10 +52,12 @@ export function Sidebar() {
|
|||||||
order: item.order,
|
order: item.order,
|
||||||
group: undefined as string | undefined,
|
group: undefined as string | undefined,
|
||||||
isStatic: true as const,
|
isStatic: true as const,
|
||||||
|
permission: item.to === '/dashboard' ? 'dashboard:read' : item.to === '/contacts' ? 'contacts:read' : undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const pluginItems = manifests
|
const pluginItems = manifests
|
||||||
.flatMap((m) => m.menu_items)
|
.flatMap((m) => m.menu_items)
|
||||||
|
.filter(item => !item.permission || hasPermission(item.permission))
|
||||||
.map(item => ({
|
.map(item => ({
|
||||||
path: item.path,
|
path: item.path,
|
||||||
labelKey: item.label_key,
|
labelKey: item.label_key,
|
||||||
@@ -62,6 +66,7 @@ export function Sidebar() {
|
|||||||
order: item.order,
|
order: item.order,
|
||||||
group: item.group,
|
group: item.group,
|
||||||
isStatic: false as const,
|
isStatic: false as const,
|
||||||
|
permission: item.permission,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const allItems = [...staticItems, ...pluginItems];
|
const allItems = [...staticItems, ...pluginItems];
|
||||||
@@ -152,6 +157,8 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
const elements: React.ReactNode[] = [];
|
const elements: React.ReactNode[] = [];
|
||||||
for (const item of singles) {
|
for (const item of singles) {
|
||||||
|
// Skip if user lacks permission
|
||||||
|
if (item.permission && !hasPermission(item.permission)) continue;
|
||||||
elements.push(
|
elements.push(
|
||||||
<li key={item.path}>
|
<li key={item.path}>
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -175,6 +182,9 @@ export function Sidebar() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const [group, items] of groups) {
|
for (const [group, items] of groups) {
|
||||||
|
// Filter group items by permission
|
||||||
|
const visibleItems = items.filter(item => !item.permission || hasPermission(item.permission));
|
||||||
|
if (visibleItems.length === 0) continue; // Hide empty groups
|
||||||
const groupKey = `plugin-group-${group}`;
|
const groupKey = `plugin-group-${group}`;
|
||||||
const expanded = expandedItems.has(groupKey);
|
const expanded = expandedItems.has(groupKey);
|
||||||
elements.push(
|
elements.push(
|
||||||
@@ -196,13 +206,13 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getIcon(items[0].icon as string)}
|
{getIcon(visibleItems[0].icon as string)}
|
||||||
<span className="flex-1 truncate">{group}</span>
|
<span className="flex-1 truncate">{group}</span>
|
||||||
{chevronIcon(expanded)}
|
{chevronIcon(expanded)}
|
||||||
</div>
|
</div>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<ul className="mt-1 ml-4 space-y-1 border-l border-secondary-700 pl-2" role="group">
|
<ul className="mt-1 ml-4 space-y-1 border-l border-secondary-700 pl-2" role="group">
|
||||||
{items.map((child) => (
|
{visibleItems.map((child) => (
|
||||||
<li key={child.path}>
|
<li key={child.path}>
|
||||||
<NavLink
|
<NavLink
|
||||||
to={child.path}
|
to={child.path}
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import { useLogout } from '@/api/hooks';
|
|||||||
import { Avatar } from '@/components/ui/Avatar';
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
import { SearchDropdown } from '@/components/shared/SearchDropdown';
|
import { SearchDropdown } from '@/components/shared/SearchDropdown';
|
||||||
import { SuggestionBadge } from '@/components/ai/SuggestionBadge';
|
import { SuggestionBadge } from '@/components/ai/SuggestionBadge';
|
||||||
import { Building, ChevronDown, Menu, Zap, Bot, Layers, Code } from 'lucide-react';
|
import { Building, ChevronDown, Menu, Zap, Bot, Layers, Code, Plus } from 'lucide-react';
|
||||||
import { NotificationBell } from '@/components/layout/NotificationBell';
|
import { NotificationBell } from '@/components/layout/NotificationBell';
|
||||||
import { useWindowStore } from '@/store/windowStore';
|
import { useWindowStore } from '@/store/windowStore';
|
||||||
|
import { usePermission } from '@/hooks/usePermission';
|
||||||
|
|
||||||
export function TopBar() {
|
export function TopBar() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -20,6 +21,7 @@ export function TopBar() {
|
|||||||
const { toggleSidebar, toggleMessageSidebar } = useUIStore();
|
const { toggleSidebar, toggleMessageSidebar } = useUIStore();
|
||||||
const logoutMutation = useLogout();
|
const logoutMutation = useLogout();
|
||||||
const minimizedWindows = useWindowStore((s) => s.windows.filter((w) => w.state === 'minimized'));
|
const minimizedWindows = useWindowStore((s) => s.windows.filter((w) => w.state === 'minimized'));
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
const restoreWindow = useWindowStore((s) => s.restoreWindow);
|
const restoreWindow = useWindowStore((s) => s.restoreWindow);
|
||||||
|
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
@@ -82,6 +84,18 @@ export function TopBar() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
|
{/* Quick Create */}
|
||||||
|
{hasPermission('contacts:write') && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/contacts/new')}
|
||||||
|
className="flex items-center gap-1.5 bg-primary-600 text-white px-3 py-1.5 rounded-md text-sm font-medium hover:bg-primary-700 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||||
|
aria-label={t('topbar.quickCreate')}
|
||||||
|
title={t('topbar.quickCreate', 'Neuer Kontakt')}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||||
|
<span className="hidden md:inline">{t('topbar.quickCreate', 'Neu')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{/* Minimized windows */}
|
{/* Minimized windows */}
|
||||||
{minimizedWindows.length > 0 && (
|
{minimizedWindows.length > 0 && (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -130,6 +144,7 @@ export function TopBar() {
|
|||||||
>
|
>
|
||||||
{t('topbar.profile')}
|
{t('topbar.profile')}
|
||||||
</button>
|
</button>
|
||||||
|
{hasPermission('settings:read') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setUserMenuOpen(false); navigate('/settings'); }}
|
onClick={() => { setUserMenuOpen(false); navigate('/settings'); }}
|
||||||
className="w-full text-left px-3 py-2 text-sm hover:bg-secondary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
className="w-full text-left px-3 py-2 text-sm hover:bg-secondary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||||
@@ -137,6 +152,7 @@ export function TopBar() {
|
|||||||
>
|
>
|
||||||
{t('topbar.settings')}
|
{t('topbar.settings')}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => { setUserMenuOpen(false); navigate('/automation'); }}
|
onClick={() => { setUserMenuOpen(false); navigate('/automation'); }}
|
||||||
className="w-full text-left px-3 py-2 text-sm hover:bg-secondary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 flex items-center gap-2"
|
className="w-full text-left px-3 py-2 text-sm hover:bg-secondary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 flex items-center gap-2"
|
||||||
@@ -153,6 +169,7 @@ export function TopBar() {
|
|||||||
<Bot className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
<Bot className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||||
{t('nav.agents', 'Agenten')}
|
{t('nav.agents', 'Agenten')}
|
||||||
</button>
|
</button>
|
||||||
|
{hasPermission('audit:read') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setUserMenuOpen(false); navigate('/audit-log'); }}
|
onClick={() => { setUserMenuOpen(false); navigate('/audit-log'); }}
|
||||||
className="w-full text-left px-3 py-2 text-sm hover:bg-secondary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
className="w-full text-left px-3 py-2 text-sm hover:bg-secondary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||||
@@ -160,6 +177,7 @@ export function TopBar() {
|
|||||||
>
|
>
|
||||||
{t('nav.auditLog')}
|
{t('nav.auditLog')}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<a
|
<a
|
||||||
href="/docs"
|
href="/docs"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { Suspense } from 'react';
|
|||||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||||
import { AppShell } from '@/components/layout/AppShell';
|
import { AppShell } from '@/components/layout/AppShell';
|
||||||
import { ProtectedRoute } from './ProtectedRoute';
|
import { ProtectedRoute } from './ProtectedRoute';
|
||||||
|
import { ProtectedRoute as PermissionRoute } from '@/components/common/ProtectedRoute';
|
||||||
import { LoginPage } from '@/pages/Login';
|
import { LoginPage } from '@/pages/Login';
|
||||||
import { PasswordResetRequestPage } from '@/pages/PasswordResetRequest';
|
import { PasswordResetRequestPage } from '@/pages/PasswordResetRequest';
|
||||||
import { PasswordResetConfirmPage } from '@/pages/PasswordResetConfirm';
|
import { PasswordResetConfirmPage } from '@/pages/PasswordResetConfirm';
|
||||||
@@ -117,31 +118,31 @@ const router = createBrowserRouter([
|
|||||||
children: [
|
children: [
|
||||||
{ path: '/', element: withSuspense(<DashboardPage />) },
|
{ path: '/', element: withSuspense(<DashboardPage />) },
|
||||||
{ path: '/dashboard', element: withSuspense(<DashboardPage />) },
|
{ path: '/dashboard', element: withSuspense(<DashboardPage />) },
|
||||||
{ path: '/contacts', element: withSuspense(<ContactsListPage />) },
|
{ path: '/contacts', element: <PermissionRoute permission="contacts:read">{withSuspense(<ContactsListPage />)}</PermissionRoute> },
|
||||||
{ path: '/contacts/:id', element: withSuspense(<ContactDetailPage />) },
|
{ path: '/contacts/:id', element: <PermissionRoute permission="contacts:read">{withSuspense(<ContactDetailPage />)}</PermissionRoute> },
|
||||||
{ path: '/audit-log', element: withSuspense(<AuditLogPage />) },
|
{ path: '/audit-log', element: <PermissionRoute permission="audit:read">{withSuspense(<AuditLogPage />)}</PermissionRoute> },
|
||||||
{ path: '/search', element: withSuspense(<GlobalSearchResultsPage />) },
|
{ path: '/search', element: withSuspense(<GlobalSearchResultsPage />) },
|
||||||
{ path: '/calendar', element: withSuspense(<CalendarPage />) },
|
{ path: '/calendar', element: <PermissionRoute permission="calendar:read">{withSuspense(<CalendarPage />)}</PermissionRoute> },
|
||||||
{ path: '/calendar/kanban', element: withSuspense(<CalendarKanbanPage />) },
|
{ path: '/calendar/kanban', element: <PermissionRoute permission="calendar:read">{withSuspense(<CalendarKanbanPage />)}</PermissionRoute> },
|
||||||
{ path: '/dms', element: withSuspense(<DmsPage />) },
|
{ path: '/dms', element: <PermissionRoute permission="dms:read">{withSuspense(<DmsPage />)}</PermissionRoute> },
|
||||||
{ path: '/dms/trash', element: withSuspense(<DmsTrashPage />) },
|
{ path: '/dms/trash', element: <PermissionRoute permission="dms:read">{withSuspense(<DmsTrashPage />)}</PermissionRoute> },
|
||||||
{ path: '/mail', element: withSuspense(<MailPage />) },
|
{ path: '/mail', element: <PermissionRoute permission="mail:read">{withSuspense(<MailPage />)}</PermissionRoute> },
|
||||||
{ path: '/mail/settings', element: withSuspense(<MailSettingsPage />) },
|
{ path: '/mail/settings', element: <PermissionRoute permission="mail:read">{withSuspense(<MailSettingsPage />)}</PermissionRoute> },
|
||||||
{ path: '/ai-assistant', element: withSuspense(<AIAssistantPage />) },
|
{ path: '/ai-assistant', element: <PermissionRoute permission="ai:read">{withSuspense(<AIAssistantPage />)}</PermissionRoute> },
|
||||||
{ path: '/automation', element: withSuspense(<AutomationDashboardPage />) },
|
{ path: '/automation', element: <PermissionRoute permission="automation:read">{withSuspense(<AutomationDashboardPage />)}</PermissionRoute> },
|
||||||
{ path: '/agents', element: withSuspense(<AgentDashboardPage />) },
|
{ path: '/agents', element: <PermissionRoute permission="automation:read">{withSuspense(<AgentDashboardPage />)}</PermissionRoute> },
|
||||||
{ path: '/reports', element: withSuspense(<ReportsPage />) },
|
{ path: '/reports', element: <PermissionRoute permission="reports:read">{withSuspense(<ReportsPage />)}</PermissionRoute> },
|
||||||
{ path: '/tasks', element: withSuspense(<TasksPage />) },
|
{ path: '/tasks', element: <PermissionRoute permission="tasks:read">{withSuspense(<TasksPage />)}</PermissionRoute> },
|
||||||
{ path: '/communication', element: withSuspense(<CommunicationPage />) },
|
{ path: '/communication', element: <PermissionRoute permission="communication:read">{withSuspense(<CommunicationPage />)}</PermissionRoute> },
|
||||||
{ path: '/workflows', element: withSuspense(<WorkflowsPage />) },
|
{ path: '/workflows', element: <PermissionRoute permission="workflows:read">{withSuspense(<WorkflowsPage />)}</PermissionRoute> },
|
||||||
{ path: '/contacts/dedup', element: withSuspense(<DedupMergePage />) },
|
{ path: '/contacts/dedup', element: <PermissionRoute permission="contacts:read">{withSuspense(<DedupMergePage />)}</PermissionRoute> },
|
||||||
{ path: '/import-export', element: withSuspense(<ImportExportPage />) },
|
{ path: '/import-export', element: <PermissionRoute permission="contacts:read">{withSuspense(<ImportExportPage />)}</PermissionRoute> },
|
||||||
{ path: '/tags', element: withSuspense(<TagsPage />) },
|
{ path: '/tags', element: <PermissionRoute permission="tags:read">{withSuspense(<TagsPage />)}</PermissionRoute> },
|
||||||
{ path: '/activity', element: withSuspense(<ActivityTimelinePage />) },
|
{ path: '/activity', element: <PermissionRoute permission="activity:read">{withSuspense(<ActivityTimelinePage />)}</PermissionRoute> },
|
||||||
{ path: '/profile', element: withSuspense(<SettingsProfilePage />) },
|
{ path: '/profile', element: withSuspense(<SettingsProfilePage />) },
|
||||||
{
|
{
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
element: withSuspense(<SettingsPage />),
|
element: <PermissionRoute permission="settings:read">{withSuspense(<SettingsPage />)}</PermissionRoute>,
|
||||||
children: [
|
children: [
|
||||||
{ path: 'stammdaten', element: withSuspense(<SettingsStammdatenPage />) },
|
{ path: 'stammdaten', element: withSuspense(<SettingsStammdatenPage />) },
|
||||||
{ path: 'user-management', element: withSuspense(<SettingsUserManagementPage />) },
|
{ path: 'user-management', element: withSuspense(<SettingsUserManagementPage />) },
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface PluginMenuItem {
|
|||||||
group: string;
|
group: string;
|
||||||
order: number;
|
order: number;
|
||||||
badge_key: string;
|
badge_key: string;
|
||||||
|
permission?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginPageRoute {
|
export interface PluginPageRoute {
|
||||||
|
|||||||
Reference in New Issue
Block a user