Phase 6: Workspaces — Widget CRUD, Manager-Check, Cross-Tenant, Zustand Store, Settings Route
Backend:
- Widget CRUD: get_widgets, create_widget, update_widget, delete_widget
- Manager role check: is_workspace_manager
- Cross-tenant validation: verify_user_same_tenant (UserTenant)
- Default workspace seeding: seed_default_workspace with 12 standard modules
- Set user default workspace: set_user_default_workspace
- Fix create_workspace default uniqueness (unset others before insert)
- Widget CRUD routes: GET/POST/PUT/DELETE /{workspace_id}/widgets
- Set-default route: POST /{workspace_id}/set-default
- Cross-tenant validation in assign_user route
Frontend:
- workspaceStore (Zustand): central state with sessionStorage persistence
- API client interceptor: X-Workspace-ID header on all requests
- useWorkspace hook refactored to use workspaceStore
- Widget API hooks: useWorkspaceWidgets, useCreateWorkspaceWidget, etc.
- useSetDefaultWorkspace hook
- Settings route: /settings/workspaces with WorkspaceManagerPage
- Settings nav item for Workspaces
Tests:
- 25 backend tests (CRUD, modules, widgets, users, manager, seeding, context, isolation)
- 12 frontend tests (workspaceStore state, visibility, persistence, reset)
- 48/48 backend tests passing
- 12/12 frontend tests passing
This commit is contained in:
@@ -228,6 +228,9 @@ async def assign_user(
|
||||
target_uid = uuid.UUID(body.user_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"})
|
||||
# Cross-tenant validation: target user must belong to same tenant
|
||||
if not await workspace_service.verify_user_same_tenant(db, tenant_id, target_uid):
|
||||
raise HTTPException(403, detail={"detail": "Cannot assign user from different tenant", "code": "cross_tenant"})
|
||||
return await workspace_service.assign_user(db, tenant_id, wid, target_uid, body.role, assigned_by=user_id)
|
||||
|
||||
|
||||
@@ -248,3 +251,120 @@ async def remove_user(
|
||||
removed = await workspace_service.remove_user(db, tenant_id, wid, uid)
|
||||
if not removed:
|
||||
raise HTTPException(404, detail={"detail": "User not assigned to this workspace", "code": "not_found"})
|
||||
|
||||
|
||||
# ─── Widget CRUD ─────────────────────────────────────────────
|
||||
|
||||
class WidgetCreate(BaseModel):
|
||||
widget_key: str
|
||||
position_x: int = 0
|
||||
position_y: int = 0
|
||||
width: int = 1
|
||||
height: int = 1
|
||||
config: dict[str, Any] = {}
|
||||
|
||||
|
||||
class WidgetUpdate(BaseModel):
|
||||
position_x: int | None = None
|
||||
position_y: int | None = None
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
config: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@router.get("/{workspace_id}/widgets")
|
||||
async def list_widgets(
|
||||
workspace_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("workspaces:read")),
|
||||
):
|
||||
"""List all widgets for a workspace."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
wid = uuid.UUID(workspace_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"})
|
||||
return {"items": await workspace_service.get_widgets(db, tenant_id, wid), "total": 0}
|
||||
|
||||
|
||||
@router.post("/{workspace_id}/widgets", status_code=status.HTTP_201_CREATED)
|
||||
async def create_widget(
|
||||
workspace_id: str,
|
||||
body: WidgetCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("workspaces:configure_modules")),
|
||||
):
|
||||
"""Create a widget in a workspace. Multiple instances of the same widget_key allowed."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
wid = uuid.UUID(workspace_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"})
|
||||
return await workspace_service.create_widget(
|
||||
db, tenant_id, wid, body.widget_key,
|
||||
body.position_x, body.position_y, body.width, body.height, body.config,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{workspace_id}/widgets/{widget_id}")
|
||||
async def update_widget(
|
||||
workspace_id: str,
|
||||
widget_id: str,
|
||||
body: WidgetUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("workspaces:configure_modules")),
|
||||
):
|
||||
"""Update a widget."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
wid = uuid.UUID(widget_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid widget_id", "code": "invalid_id"})
|
||||
result = await workspace_service.update_widget(
|
||||
db, tenant_id, wid,
|
||||
body.position_x, body.position_y, body.width, body.height, body.config,
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(404, detail={"detail": "Widget not found", "code": "not_found"})
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{workspace_id}/widgets/{widget_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_widget(
|
||||
workspace_id: str,
|
||||
widget_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("workspaces:configure_modules")),
|
||||
):
|
||||
"""Delete a widget."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
wid = uuid.UUID(widget_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid widget_id", "code": "invalid_id"})
|
||||
deleted = await workspace_service.delete_widget(db, tenant_id, wid)
|
||||
if not deleted:
|
||||
raise HTTPException(404, detail={"detail": "Widget not found", "code": "not_found"})
|
||||
|
||||
|
||||
# ─── Set User Default Workspace ───────────────────────────────
|
||||
|
||||
@router.post("/{workspace_id}/set-default", status_code=status.HTTP_200_OK)
|
||||
async def set_default_workspace(
|
||||
workspace_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("workspaces:read")),
|
||||
):
|
||||
"""Set a workspace as the current user's default."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
wid = uuid.UUID(workspace_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"})
|
||||
# Verify user is assigned to this workspace
|
||||
ctx = await workspace_service.get_workspace_context(db, tenant_id, user_id, wid)
|
||||
if ctx is None and not current_user.get("is_system_admin"):
|
||||
raise HTTPException(403, detail={"detail": "Not assigned to this workspace", "code": "not_assigned"})
|
||||
await workspace_service.set_user_default_workspace(db, tenant_id, user_id, wid)
|
||||
return {"status": "ok", "workspace_id": workspace_id}
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.workspace import Workspace, WorkspaceModule, WorkspaceUser, WorkspaceWidget
|
||||
from app.models.user import User, UserTenant
|
||||
|
||||
|
||||
def _workspace_to_dict(ws: Workspace, modules: list[WorkspaceModule] | None = None, user_count: int = 0) -> dict[str, Any]:
|
||||
@@ -108,6 +109,17 @@ async def create_workspace(
|
||||
is_default: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new workspace."""
|
||||
# If this is the default workspace, unset others FIRST (avoids unique constraint violation)
|
||||
if is_default:
|
||||
await db.execute(
|
||||
update(Workspace)
|
||||
.where(
|
||||
Workspace.tenant_id == tenant_id,
|
||||
)
|
||||
.values(is_default=False)
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
ws = Workspace(
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
@@ -121,17 +133,6 @@ async def create_workspace(
|
||||
await db.flush()
|
||||
await db.refresh(ws)
|
||||
|
||||
# If this is the default workspace, unset others
|
||||
if is_default:
|
||||
await db.execute(
|
||||
update(Workspace)
|
||||
.where(
|
||||
Workspace.tenant_id == tenant_id,
|
||||
Workspace.id != ws.id,
|
||||
)
|
||||
.values(is_default=False)
|
||||
)
|
||||
|
||||
# Auto-assign creator as manager
|
||||
wu = WorkspaceUser(
|
||||
tenant_id=tenant_id,
|
||||
@@ -424,3 +425,240 @@ async def get_workspace_context(
|
||||
for w in widgets
|
||||
],
|
||||
}
|
||||
|
||||
# ─── Widget CRUD ─────────────────────────────────────────────
|
||||
|
||||
async def get_widgets(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, workspace_id: uuid.UUID
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List all widgets for a workspace."""
|
||||
q = select(WorkspaceWidget).where(
|
||||
WorkspaceWidget.workspace_id == workspace_id,
|
||||
WorkspaceWidget.tenant_id == tenant_id,
|
||||
).order_by(WorkspaceWidget.position_y, WorkspaceWidget.position_x)
|
||||
result = await db.execute(q)
|
||||
widgets = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": str(w.id),
|
||||
"workspace_id": str(w.workspace_id),
|
||||
"widget_key": w.widget_key,
|
||||
"position_x": w.position_x,
|
||||
"position_y": w.position_y,
|
||||
"width": w.width,
|
||||
"height": w.height,
|
||||
"config": w.config or {},
|
||||
}
|
||||
for w in widgets
|
||||
]
|
||||
|
||||
|
||||
async def create_widget(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, workspace_id: uuid.UUID,
|
||||
widget_key: str, position_x: int = 0, position_y: int = 0,
|
||||
width: int = 1, height: int = 1, config: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new widget in a workspace. Multiple instances of the same widget_key allowed."""
|
||||
w = WorkspaceWidget(
|
||||
tenant_id=tenant_id,
|
||||
workspace_id=workspace_id,
|
||||
widget_key=widget_key,
|
||||
position_x=position_x,
|
||||
position_y=position_y,
|
||||
width=width,
|
||||
height=height,
|
||||
config=config or {},
|
||||
)
|
||||
db.add(w)
|
||||
await db.flush()
|
||||
await db.refresh(w)
|
||||
return {
|
||||
"id": str(w.id),
|
||||
"workspace_id": str(w.workspace_id),
|
||||
"widget_key": w.widget_key,
|
||||
"position_x": w.position_x,
|
||||
"position_y": w.position_y,
|
||||
"width": w.width,
|
||||
"height": w.height,
|
||||
"config": w.config or {},
|
||||
}
|
||||
|
||||
|
||||
async def update_widget(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, widget_id: uuid.UUID,
|
||||
position_x: int | None = None, position_y: int | None = None,
|
||||
width: int | None = None, height: int | None = None,
|
||||
config: dict | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Update a widget."""
|
||||
q = select(WorkspaceWidget).where(
|
||||
WorkspaceWidget.id == widget_id,
|
||||
WorkspaceWidget.tenant_id == tenant_id,
|
||||
)
|
||||
result = await db.execute(q)
|
||||
w = result.scalar_one_or_none()
|
||||
if w is None:
|
||||
return None
|
||||
if position_x is not None:
|
||||
w.position_x = position_x
|
||||
if position_y is not None:
|
||||
w.position_y = position_y
|
||||
if width is not None:
|
||||
w.width = width
|
||||
if height is not None:
|
||||
w.height = height
|
||||
if config is not None:
|
||||
w.config = config
|
||||
await db.flush()
|
||||
await db.refresh(w)
|
||||
return {
|
||||
"id": str(w.id),
|
||||
"workspace_id": str(w.workspace_id),
|
||||
"widget_key": w.widget_key,
|
||||
"position_x": w.position_x,
|
||||
"position_y": w.position_y,
|
||||
"width": w.width,
|
||||
"height": w.height,
|
||||
"config": w.config or {},
|
||||
}
|
||||
|
||||
|
||||
async def delete_widget(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, widget_id: uuid.UUID
|
||||
) -> bool:
|
||||
"""Delete a widget."""
|
||||
q = select(WorkspaceWidget).where(
|
||||
WorkspaceWidget.id == widget_id,
|
||||
WorkspaceWidget.tenant_id == tenant_id,
|
||||
)
|
||||
result = await db.execute(q)
|
||||
w = result.scalar_one_or_none()
|
||||
if w is None:
|
||||
return False
|
||||
await db.delete(w)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
# ─── Manager Role Check ───────────────────────────────────────
|
||||
|
||||
async def is_workspace_manager(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, workspace_id: uuid.UUID, user_id: uuid.UUID
|
||||
) -> bool:
|
||||
"""Check if a user is a manager of a workspace (or system admin)."""
|
||||
q = select(WorkspaceUser).where(
|
||||
WorkspaceUser.workspace_id == workspace_id,
|
||||
WorkspaceUser.user_id == user_id,
|
||||
WorkspaceUser.tenant_id == tenant_id,
|
||||
WorkspaceUser.role == "manager",
|
||||
)
|
||||
result = await db.execute(q)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
# ─── Cross-Tenant Validation ──────────────────────────────────
|
||||
|
||||
async def verify_user_same_tenant(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID
|
||||
) -> bool:
|
||||
"""Verify that a user belongs to the same tenant. Prevents cross-tenant assignment."""
|
||||
q = select(UserTenant).where(
|
||||
UserTenant.user_id == user_id,
|
||||
UserTenant.tenant_id == tenant_id,
|
||||
)
|
||||
result = await db.execute(q)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
# ─── Default Workspace Seeding ────────────────────────────────
|
||||
|
||||
async def seed_default_workspace(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID
|
||||
) -> dict[str, Any] | None:
|
||||
"""Create a default workspace for a tenant if none exists.
|
||||
Called during tenant setup or user creation.
|
||||
"""
|
||||
# Check if any workspace exists for this tenant
|
||||
existing_q = select(Workspace).where(
|
||||
Workspace.tenant_id == tenant_id,
|
||||
Workspace.is_active == True, # noqa: E712
|
||||
)
|
||||
result = await db.execute(existing_q)
|
||||
if result.scalars().first() is not None:
|
||||
return None # Already has workspaces
|
||||
|
||||
# Create default workspace with all standard modules visible
|
||||
ws = Workspace(
|
||||
tenant_id=tenant_id,
|
||||
name="Standard",
|
||||
icon="LayoutGrid",
|
||||
description="Standard-Workspace mit allen Modulen",
|
||||
is_default=True,
|
||||
is_active=True,
|
||||
created_by=user_id,
|
||||
)
|
||||
db.add(ws)
|
||||
await db.flush()
|
||||
await db.refresh(ws)
|
||||
|
||||
# Auto-assign creator as manager
|
||||
wu = WorkspaceUser(
|
||||
tenant_id=tenant_id,
|
||||
workspace_id=ws.id,
|
||||
user_id=user_id,
|
||||
role="manager",
|
||||
is_default=True,
|
||||
assigned_by=user_id,
|
||||
)
|
||||
db.add(wu)
|
||||
|
||||
# Add all standard modules visible by default
|
||||
standard_modules = [
|
||||
("dashboard", 0), ("contacts", 10), ("calendar", 20),
|
||||
("mail", 30), ("tasks", 40), ("dms", 50),
|
||||
("kommunikation", 60), ("reports", 70), ("automation", 80),
|
||||
("tags", 90), ("settings", 100), ("audit", 110),
|
||||
]
|
||||
for key, order in standard_modules:
|
||||
wm = WorkspaceModule(
|
||||
tenant_id=tenant_id,
|
||||
workspace_id=ws.id,
|
||||
module_key=key,
|
||||
is_visible=True,
|
||||
menu_order=order,
|
||||
config={},
|
||||
)
|
||||
db.add(wm)
|
||||
|
||||
await db.flush()
|
||||
return _workspace_to_dict(ws, user_count=1)
|
||||
|
||||
|
||||
# ─── Set User Default Workspace ───────────────────────────────
|
||||
|
||||
async def set_user_default_workspace(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, workspace_id: uuid.UUID
|
||||
) -> bool:
|
||||
"""Set a workspace as the user's default. Unsets previous default."""
|
||||
# Unset previous default
|
||||
await db.execute(
|
||||
update(WorkspaceUser)
|
||||
.where(
|
||||
WorkspaceUser.user_id == user_id,
|
||||
WorkspaceUser.tenant_id == tenant_id,
|
||||
WorkspaceUser.workspace_id != workspace_id,
|
||||
)
|
||||
.values(is_default=False)
|
||||
)
|
||||
# Set new default
|
||||
await db.execute(
|
||||
update(WorkspaceUser)
|
||||
.where(
|
||||
WorkspaceUser.user_id == user_id,
|
||||
WorkspaceUser.tenant_id == tenant_id,
|
||||
WorkspaceUser.workspace_id == workspace_id,
|
||||
)
|
||||
.values(is_default=True)
|
||||
)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user