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
|
||||
|
||||
Generated
+73
@@ -46,6 +46,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
@@ -3172,6 +3173,25 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
||||
@@ -3724,6 +3744,12 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -4088,6 +4114,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/any-promise": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
@@ -5046,6 +5084,12 @@
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.12",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
|
||||
@@ -7112,6 +7156,15 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -8529,6 +8582,20 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
|
||||
@@ -8764,6 +8831,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/react-markdown": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
|
||||
@@ -30,6 +30,13 @@ export function getCsrfToken(): string | null {
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
let onValidationError: ((errors: Record<string, string[]>) => void) | null = null;
|
||||
|
||||
// Workspace context — set by workspaceStore, sent as X-Workspace-ID header
|
||||
let activeWorkspaceId: string | null = null;
|
||||
|
||||
export function setActiveWorkspaceId(id: string | null) {
|
||||
activeWorkspaceId = id;
|
||||
}
|
||||
|
||||
export function setUnauthorizedHandler(handler: () => void) {
|
||||
onUnauthorized = handler;
|
||||
}
|
||||
@@ -45,6 +52,10 @@ apiClient.interceptors.request.use(
|
||||
if (unsafe.includes(config.method?.toLowerCase() ?? '') && csrfToken) {
|
||||
config.headers['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
// Attach X-Workspace-ID header for workspace context (per-tab)
|
||||
if (activeWorkspaceId) {
|
||||
config.headers['X-Workspace-ID'] = activeWorkspaceId;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
|
||||
@@ -132,3 +132,87 @@ export function useRemoveWorkspaceUser() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Widget Hooks ─────────────────────────────────────────────
|
||||
|
||||
export interface WorkspaceWidget {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
widget_key: string;
|
||||
position_x: number;
|
||||
position_y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
config: Record<string, any>;
|
||||
}
|
||||
|
||||
export function useWorkspaceWidgets(workspaceId: string | null) {
|
||||
return useQuery<{ items: WorkspaceWidget[]; total: number }>({
|
||||
queryKey: ['workspace-widgets', workspaceId],
|
||||
queryFn: () => apiGet(`/api/v1/workspaces/${workspaceId}/widgets`),
|
||||
enabled: !!workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateWorkspaceWidget() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ workspaceId, ...data }: {
|
||||
workspaceId: string;
|
||||
widget_key: string;
|
||||
position_x?: number;
|
||||
position_y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
config?: Record<string, any>;
|
||||
}) => apiPost(`/api/v1/workspaces/${workspaceId}/widgets`, data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workspace-widgets'] });
|
||||
qc.invalidateQueries({ queryKey: ['workspace-context'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateWorkspaceWidget() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ workspaceId, widgetId, ...data }: {
|
||||
workspaceId: string;
|
||||
widgetId: string;
|
||||
position_x?: number;
|
||||
position_y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
config?: Record<string, any>;
|
||||
}) => apiPut(`/api/v1/workspaces/${workspaceId}/widgets/${widgetId}`, data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workspace-widgets'] });
|
||||
qc.invalidateQueries({ queryKey: ['workspace-context'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteWorkspaceWidget() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ workspaceId, widgetId }: { workspaceId: string; widgetId: string }) =>
|
||||
apiDelete(`/api/v1/workspaces/${workspaceId}/widgets/${widgetId}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workspace-widgets'] });
|
||||
qc.invalidateQueries({ queryKey: ['workspace-context'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Set Default Workspace ────────────────────────────────────
|
||||
|
||||
export function useSetDefaultWorkspace() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (workspaceId: string) =>
|
||||
apiPost(`/api/v1/workspaces/${workspaceId}/set-default`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['my-workspaces'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,70 +1,76 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useMyWorkspaces, useWorkspaceContext, type WorkspaceContext } from '@/api/hooks/workspaces';
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useMyWorkspaces, useWorkspaceContext } from '@/api/hooks/workspaces';
|
||||
import { useWorkspaceStore, type WorkspaceInfo, type WorkspaceContextState } from '@/store/workspaceStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
const STORAGE_KEY = 'leocrm-active-workspace';
|
||||
|
||||
/**
|
||||
* Manages the active workspace for the current browser tab.
|
||||
*
|
||||
* Uses sessionStorage (per-tab, not shared across tabs) to store the active workspace ID.
|
||||
* Sends X-Workspace-ID header on workspace-aware requests.
|
||||
* Uses the central workspaceStore (Zustand) for state management.
|
||||
* sessionStorage provides per-tab persistence.
|
||||
* X-Workspace-ID header is sent automatically by the API client interceptor.
|
||||
*
|
||||
* Workspaces are UI/navigation context only — they never affect permissions.
|
||||
*/
|
||||
export function useWorkspace() {
|
||||
const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(() => {
|
||||
return sessionStorage.getItem(STORAGE_KEY);
|
||||
});
|
||||
const activeWorkspaceId = useWorkspaceStore(s => s.activeWorkspaceId);
|
||||
const context = useWorkspaceStore(s => s.context);
|
||||
const myWorkspacesFromStore = useWorkspaceStore(s => s.myWorkspaces);
|
||||
const setActiveWorkspace = useWorkspaceStore(s => s.setActiveWorkspace);
|
||||
const setContext = useWorkspaceStore(s => s.setContext);
|
||||
const setMyWorkspaces = useWorkspaceStore(s => s.setMyWorkspaces);
|
||||
const isModuleVisibleFromStore = useWorkspaceStore(s => s.isModuleVisible);
|
||||
|
||||
const { data: myWorkspaces } = useMyWorkspaces();
|
||||
const { data: context } = useWorkspaceContext(activeWorkspaceId);
|
||||
const user = useAuthStore.getState().user;
|
||||
const isSystemAdmin = user?.is_system_admin;
|
||||
|
||||
// Fetch my workspaces and sync to store
|
||||
const { data: myWorkspacesData } = useMyWorkspaces();
|
||||
const { data: contextData } = useWorkspaceContext(activeWorkspaceId);
|
||||
|
||||
// Sync fetched workspaces to store
|
||||
useEffect(() => {
|
||||
if (myWorkspacesData?.items) {
|
||||
setMyWorkspaces(myWorkspacesData.items as WorkspaceInfo[]);
|
||||
}
|
||||
}, [myWorkspacesData, setMyWorkspaces]);
|
||||
|
||||
// Sync fetched context to store
|
||||
useEffect(() => {
|
||||
if (contextData !== undefined) {
|
||||
setContext(contextData as WorkspaceContextState | null);
|
||||
}
|
||||
}, [contextData, setContext]);
|
||||
|
||||
// Auto-select default workspace if none selected
|
||||
useEffect(() => {
|
||||
if (!activeWorkspaceId && myWorkspaces?.items?.length) {
|
||||
const defaultWs = myWorkspaces.items.find(w => w.is_user_default) || myWorkspaces.items[0];
|
||||
if (!activeWorkspaceId && myWorkspacesData?.items?.length) {
|
||||
const defaultWs = myWorkspacesData.items.find(w => w.is_user_default) || myWorkspacesData.items[0];
|
||||
if (defaultWs) {
|
||||
setActiveWorkspaceId(defaultWs.id);
|
||||
sessionStorage.setItem(STORAGE_KEY, defaultWs.id);
|
||||
setActiveWorkspace(defaultWs.id);
|
||||
}
|
||||
}
|
||||
}, [activeWorkspaceId, myWorkspaces]);
|
||||
}, [activeWorkspaceId, myWorkspacesData, setActiveWorkspace]);
|
||||
|
||||
// Switch workspace (per-tab)
|
||||
const switchWorkspace = useCallback((workspaceId: string | null) => {
|
||||
if (workspaceId) {
|
||||
sessionStorage.setItem(STORAGE_KEY, workspaceId);
|
||||
} else {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
setActiveWorkspaceId(workspaceId);
|
||||
}, []);
|
||||
|
||||
// Get visible module keys from workspace context
|
||||
const visibleModuleKeys: Set<string> = new Set(
|
||||
context?.modules?.map(m => m.module_key) || []
|
||||
);
|
||||
setActiveWorkspace(workspaceId);
|
||||
}, [setActiveWorkspace]);
|
||||
|
||||
// Check if a module is visible in the current workspace
|
||||
const isModuleVisible = useCallback((moduleKey: string): boolean => {
|
||||
// System admins see all modules regardless of workspace
|
||||
const user = useAuthStore.getState().user;
|
||||
if (user?.is_system_admin) return true;
|
||||
// While permissions are loading (is_system_admin undefined), show everything
|
||||
if (user && user.is_system_admin === undefined) return true;
|
||||
// If no workspace context, show all (backward compatible)
|
||||
if (!context?.workspace_id || !context?.modules?.length) return true;
|
||||
return visibleModuleKeys.has(moduleKey);
|
||||
}, [context, visibleModuleKeys]);
|
||||
return isModuleVisibleFromStore(moduleKey, isSystemAdmin);
|
||||
}, [isModuleVisibleFromStore, isSystemAdmin]);
|
||||
|
||||
const visibleModuleKeys = useWorkspaceStore(s => s.visibleModuleKeys());
|
||||
|
||||
return {
|
||||
activeWorkspaceId,
|
||||
activeWorkspace: context,
|
||||
myWorkspaces: myWorkspaces?.items || [],
|
||||
myWorkspaces: myWorkspacesFromStore,
|
||||
switchWorkspace,
|
||||
isModuleVisible,
|
||||
visibleModuleKeys,
|
||||
hasWorkspaces: (myWorkspaces?.items?.length || 0) > 0,
|
||||
hasWorkspaces: myWorkspacesFromStore.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export function SettingsPage() {
|
||||
{ to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' },
|
||||
{ to: '/settings/custom-fields', label: 'Custom Fields', icon: '\ud83d\udccb' },
|
||||
{ to: '/settings/webhooks', label: 'Webhooks', icon: '\ud83d\udd14' },
|
||||
{ to: '/settings/workspaces', label: 'Workspaces', icon: '\ud83d\udd58\ufe0f' },
|
||||
{ to: '/settings/backup', label: 'Backup & Restore', icon: '\ud83d\udcbe' },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { WorkspaceManager } from '@/components/settings/WorkspaceManager';
|
||||
|
||||
export function WorkspaceManagerPage() {
|
||||
return (
|
||||
<div data-testid="settings-workspaces">
|
||||
<WorkspaceManager />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,7 @@ const CustomFieldsPage = React.lazy(() => import('@/pages/CustomFields').then(m
|
||||
const ActivityTimelinePage = React.lazy(() => import('@/pages/ActivityTimeline').then(m => ({ default: m.ActivityTimelinePage })));
|
||||
const SettingsWebhooksPage = React.lazy(() => import('@/pages/SettingsWebhooks').then(m => ({ default: m.SettingsWebhooksPage })));
|
||||
const SettingsBackupPage = React.lazy(() => import('@/pages/SettingsBackup').then(m => ({ default: m.SettingsBackupPage })));
|
||||
const WorkspaceManagerPage = React.lazy(() => import('@/pages/SettingsWorkspaces').then(m => ({ default: m.WorkspaceManagerPage })));
|
||||
const SettingsRechtePage = React.lazy(() => import('@/pages/SettingsRechte').then(m => ({ default: m.SettingsRechtePage })));
|
||||
const NoAccessPage = React.lazy(() => import('@/pages/NoAccessPage').then(m => ({ default: m.NoAccessPage })));
|
||||
const StartPage = React.lazy(() => import('@/pages/StartPage').then(m => ({ default: m.StartPage })));
|
||||
@@ -193,6 +194,7 @@ const router = createBrowserRouter([
|
||||
{ path: 'menu', element: withSuspense(<SettingsMenuOrderPage />) },
|
||||
{ path: 'custom-fields', element: withSuspense(<CustomFieldsPage />) },
|
||||
{ path: 'webhooks', element: withSuspense(<SettingsWebhooksPage />) },
|
||||
{ path: 'workspaces', element: withSuspense(<WorkspaceManagerPage />) },
|
||||
{ path: 'backup', element: withSuspense(<SettingsBackupPage />) },
|
||||
{ path: 'rechte', element: <PermissionRoute permission="settings:read">{withSuspense(<SettingsRechtePage />)}</PermissionRoute> },
|
||||
{ path: '*', element: <PluginRouteRenderer /> },
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { useWorkspaceStore } from '@/store/workspaceStore';
|
||||
|
||||
// Mock sessionStorage
|
||||
const mockSessionStorage = {
|
||||
store: {} as Record<string, string>,
|
||||
getItem: vi.fn((key: string) => mockSessionStorage.store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => { mockSessionStorage.store[key] = value; }),
|
||||
removeItem: vi.fn((key: string) => { delete mockSessionStorage.store[key]; }),
|
||||
clear: vi.fn(() => { mockSessionStorage.store = {}; }),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
value: mockSessionStorage,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock the API client import
|
||||
vi.mock('@/api/client', () => ({
|
||||
setActiveWorkspaceId: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('workspaceStore', () => {
|
||||
beforeEach(() => {
|
||||
mockSessionStorage.clear();
|
||||
useWorkspaceStore.getState().reset();
|
||||
});
|
||||
|
||||
it('initializes with no active workspace', () => {
|
||||
expect(useWorkspaceStore.getState().activeWorkspaceId).toBeNull();
|
||||
expect(useWorkspaceStore.getState().context).toBeNull();
|
||||
expect(useWorkspaceStore.getState().myWorkspaces).toEqual([]);
|
||||
});
|
||||
|
||||
it('setActiveWorkspace stores ID in sessionStorage', () => {
|
||||
const wsId = 'test-workspace-id';
|
||||
useWorkspaceStore.getState().setActiveWorkspace(wsId);
|
||||
expect(useWorkspaceStore.getState().activeWorkspaceId).toBe(wsId);
|
||||
expect(mockSessionStorage.setItem).toHaveBeenCalledWith('leocrm-active-workspace', wsId);
|
||||
});
|
||||
|
||||
it('setActiveWorkspace(null) removes from sessionStorage', () => {
|
||||
useWorkspaceStore.getState().setActiveWorkspace('test-id');
|
||||
useWorkspaceStore.getState().setActiveWorkspace(null);
|
||||
expect(useWorkspaceStore.getState().activeWorkspaceId).toBeNull();
|
||||
expect(mockSessionStorage.removeItem).toHaveBeenCalledWith('leocrm-active-workspace');
|
||||
});
|
||||
|
||||
it('setActiveWorkspace clears context on switch', () => {
|
||||
useWorkspaceStore.getState().setContext({
|
||||
workspace_id: 'old-id',
|
||||
modules: [{ module_key: 'contacts', menu_order: 0, config: {} }],
|
||||
widgets: [],
|
||||
});
|
||||
useWorkspaceStore.getState().setActiveWorkspace('new-id');
|
||||
expect(useWorkspaceStore.getState().context).toBeNull();
|
||||
});
|
||||
|
||||
it('isModuleVisible returns true when no workspace context (backward compatible)', () => {
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('contacts')).toBe(true);
|
||||
});
|
||||
|
||||
it('isModuleVisible returns true for system admin', () => {
|
||||
useWorkspaceStore.getState().setContext({
|
||||
workspace_id: 'ws-1',
|
||||
modules: [{ module_key: 'contacts', menu_order: 0, config: {} }],
|
||||
widgets: [],
|
||||
});
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('mail', true)).toBe(true);
|
||||
});
|
||||
|
||||
it('isModuleVisible returns false for module not in workspace', () => {
|
||||
useWorkspaceStore.getState().setContext({
|
||||
workspace_id: 'ws-1',
|
||||
modules: [{ module_key: 'contacts', menu_order: 0, config: {} }],
|
||||
widgets: [],
|
||||
});
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('mail')).toBe(false);
|
||||
});
|
||||
|
||||
it('isModuleVisible returns true for module in workspace', () => {
|
||||
useWorkspaceStore.getState().setContext({
|
||||
workspace_id: 'ws-1',
|
||||
modules: [
|
||||
{ module_key: 'contacts', menu_order: 0, config: {} },
|
||||
{ module_key: 'calendar', menu_order: 1, config: {} },
|
||||
],
|
||||
widgets: [],
|
||||
});
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('contacts')).toBe(true);
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('calendar')).toBe(true);
|
||||
});
|
||||
|
||||
it('visibleModuleKeys returns set of visible module keys', () => {
|
||||
useWorkspaceStore.getState().setContext({
|
||||
workspace_id: 'ws-1',
|
||||
modules: [
|
||||
{ module_key: 'contacts', menu_order: 0, config: {} },
|
||||
{ module_key: 'calendar', menu_order: 1, config: {} },
|
||||
],
|
||||
widgets: [],
|
||||
});
|
||||
const keys = useWorkspaceStore.getState().visibleModuleKeys();
|
||||
expect(keys.has('contacts')).toBe(true);
|
||||
expect(keys.has('calendar')).toBe(true);
|
||||
expect(keys.has('mail')).toBe(false);
|
||||
});
|
||||
|
||||
it('hasWorkspaces returns false when empty', () => {
|
||||
expect(useWorkspaceStore.getState().hasWorkspaces()).toBe(false);
|
||||
});
|
||||
|
||||
it('hasWorkspaces returns true when workspaces exist', () => {
|
||||
useWorkspaceStore.getState().setMyWorkspaces([
|
||||
{ id: 'ws-1', name: 'WS1', icon: 'LayoutGrid', description: null, is_default: false, is_active: true },
|
||||
]);
|
||||
expect(useWorkspaceStore.getState().hasWorkspaces()).toBe(true);
|
||||
});
|
||||
|
||||
it('reset clears all state', () => {
|
||||
useWorkspaceStore.getState().setActiveWorkspace('test-id');
|
||||
useWorkspaceStore.getState().setMyWorkspaces([
|
||||
{ id: 'ws-1', name: 'WS1', icon: 'LayoutGrid', description: null, is_default: false, is_active: true },
|
||||
]);
|
||||
useWorkspaceStore.getState().setContext({
|
||||
workspace_id: 'ws-1',
|
||||
modules: [],
|
||||
widgets: [],
|
||||
});
|
||||
useWorkspaceStore.getState().reset();
|
||||
expect(useWorkspaceStore.getState().activeWorkspaceId).toBeNull();
|
||||
expect(useWorkspaceStore.getState().context).toBeNull();
|
||||
expect(useWorkspaceStore.getState().myWorkspaces).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { setActiveWorkspaceId as setApiClientWorkspaceId } from '@/api/client';
|
||||
|
||||
export interface WorkspaceModuleConfig {
|
||||
module_key: string;
|
||||
is_visible: boolean;
|
||||
menu_order: number;
|
||||
config: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface WorkspaceWidgetConfig {
|
||||
id: string;
|
||||
widget_key: string;
|
||||
position_x: number;
|
||||
position_y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
config: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface WorkspaceInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string | null;
|
||||
is_default: boolean;
|
||||
is_active: boolean;
|
||||
role?: 'member' | 'manager' | 'admin';
|
||||
is_user_default?: boolean;
|
||||
modules?: WorkspaceModuleConfig[];
|
||||
}
|
||||
|
||||
export interface WorkspaceContextState {
|
||||
workspace_id: string | null;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
role?: string;
|
||||
modules: { module_key: string; menu_order: number; config: Record<string, any> }[];
|
||||
widgets: WorkspaceWidgetConfig[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface WorkspaceStoreState {
|
||||
// Active workspace ID (per-tab via sessionStorage)
|
||||
activeWorkspaceId: string | null;
|
||||
// Cached context
|
||||
context: WorkspaceContextState | null;
|
||||
// Available workspaces for the user
|
||||
myWorkspaces: WorkspaceInfo[];
|
||||
// Loading states
|
||||
isLoading: boolean;
|
||||
// Actions
|
||||
setActiveWorkspace: (id: string | null) => void;
|
||||
setContext: (ctx: WorkspaceContextState | null) => void;
|
||||
setMyWorkspaces: (workspaces: WorkspaceInfo[]) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
// Helpers
|
||||
isModuleVisible: (moduleKey: string, isSystemAdmin?: boolean) => boolean;
|
||||
visibleModuleKeys: () => Set<string>;
|
||||
hasWorkspaces: () => boolean;
|
||||
// Reset
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const SESSION_STORAGE_KEY = 'leocrm-active-workspace';
|
||||
|
||||
export const useWorkspaceStore = create<WorkspaceStoreState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
activeWorkspaceId: sessionStorage.getItem(SESSION_STORAGE_KEY),
|
||||
context: null,
|
||||
myWorkspaces: [],
|
||||
isLoading: false,
|
||||
|
||||
setActiveWorkspace: (id) => {
|
||||
if (id) {
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, id);
|
||||
} else {
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
}
|
||||
setApiClientWorkspaceId(id);
|
||||
set({ activeWorkspaceId: id, context: null });
|
||||
},
|
||||
|
||||
setContext: (ctx) => set({ context: ctx }),
|
||||
|
||||
setMyWorkspaces: (workspaces) => set({ myWorkspaces: workspaces }),
|
||||
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
|
||||
isModuleVisible: (moduleKey: string, isSystemAdmin?: boolean) => {
|
||||
// System admins see all modules regardless of workspace
|
||||
if (isSystemAdmin) return true;
|
||||
// If no workspace context, show all (backward compatible)
|
||||
const ctx = get().context;
|
||||
if (!ctx?.workspace_id || !ctx?.modules?.length) return true;
|
||||
return get().visibleModuleKeys().has(moduleKey);
|
||||
},
|
||||
|
||||
visibleModuleKeys: () => {
|
||||
const ctx = get().context;
|
||||
return new Set(ctx?.modules?.map(m => m.module_key) || []);
|
||||
},
|
||||
|
||||
hasWorkspaces: () => get().myWorkspaces.length > 0,
|
||||
|
||||
reset: () => {
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
set({ activeWorkspaceId: null, context: null, myWorkspaces: [], isLoading: false });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'leocrm-workspace-store',
|
||||
storage: createJSONStorage(() => sessionStorage),
|
||||
partialize: (state) => ({ activeWorkspaceId: state.activeWorkspaceId }),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,632 @@
|
||||
"""Tests for Phase 6 — Workspaces.
|
||||
|
||||
Covers:
|
||||
- Workspace CRUD (create, list, get, update, delete)
|
||||
- Module configuration (set modules, visibility)
|
||||
- Widget CRUD (create, list, update, delete, multiple same key)
|
||||
- User assignment (assign, remove, cross-tenant block)
|
||||
- Manager role check
|
||||
- Default workspace seeding
|
||||
- Set user default workspace
|
||||
- Workspace context (modules + widgets)
|
||||
- Empty workspace shows no modules
|
||||
- Cross-tenant isolation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User, UserTenant
|
||||
from app.models.workspace import Workspace, WorkspaceModule, WorkspaceUser, WorkspaceWidget
|
||||
from app.services import workspace_service
|
||||
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_tenant_and_user(db: AsyncSession) -> dict:
|
||||
"""Seed a tenant and a user, return IDs."""
|
||||
tenant = Tenant(name="Test Tenant", slug="test-tenant")
|
||||
db.add(tenant)
|
||||
await db.flush()
|
||||
|
||||
user = User(
|
||||
email="test@example.com",
|
||||
name="Test User",
|
||||
password_hash="dummy",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
ut = UserTenant(
|
||||
user_id=user.id,
|
||||
tenant_id=tenant.id,
|
||||
is_default=True,
|
||||
role="admin",
|
||||
)
|
||||
db.add(ut)
|
||||
await db.flush()
|
||||
return {"tenant": tenant, "user": user}
|
||||
|
||||
|
||||
async def _seed_second_tenant_and_user(db: AsyncSession) -> dict:
|
||||
"""Seed a second tenant and user for cross-tenant tests."""
|
||||
tenant = Tenant(name="Other Tenant", slug="other-tenant")
|
||||
db.add(tenant)
|
||||
await db.flush()
|
||||
|
||||
user = User(
|
||||
email="other@example.com",
|
||||
name="Other User",
|
||||
password_hash="dummy",
|
||||
is_active=True,
|
||||
preferences={},
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
ut = UserTenant(
|
||||
user_id=user.id,
|
||||
tenant_id=tenant.id,
|
||||
is_default=True,
|
||||
role="admin",
|
||||
)
|
||||
db.add(ut)
|
||||
await db.flush()
|
||||
return {"tenant": tenant, "user": user}
|
||||
|
||||
|
||||
# ─── Workspace CRUD ───────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_workspace(db_session: AsyncSession):
|
||||
"""Creating a workspace returns correct data and auto-assigns creator as manager."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
result = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id,
|
||||
name="Einkauf", icon="ShoppingCart", description="Einkauf-Workspace",
|
||||
)
|
||||
assert result["name"] == "Einkauf"
|
||||
assert result["icon"] == "ShoppingCart"
|
||||
assert result["is_active"] is True
|
||||
assert result["user_count"] == 1 # Creator auto-assigned
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_workspaces(db_session: AsyncSession):
|
||||
"""list_workspaces returns all active workspaces for a tenant."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="WS1",
|
||||
)
|
||||
await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="WS2",
|
||||
)
|
||||
result = await workspace_service.list_workspaces(db_session, seed["tenant"].id)
|
||||
assert result["total"] == 2
|
||||
names = [w["name"] for w in result["items"]]
|
||||
assert "WS1" in names
|
||||
assert "WS2" in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_workspace(db_session: AsyncSession):
|
||||
"""get_workspace returns a single workspace with modules and user count."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
created = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="TestWS",
|
||||
)
|
||||
ws_id = uuid.UUID(created["id"])
|
||||
result = await workspace_service.get_workspace(db_session, seed["tenant"].id, ws_id)
|
||||
assert result is not None
|
||||
assert result["name"] == "TestWS"
|
||||
assert result["user_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_workspace(db_session: AsyncSession):
|
||||
"""update_workspace changes name and description."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
created = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="Original",
|
||||
)
|
||||
ws_id = uuid.UUID(created["id"])
|
||||
result = await workspace_service.update_workspace(
|
||||
db_session, seed["tenant"].id, ws_id,
|
||||
name="Updated", description="New desc",
|
||||
)
|
||||
assert result is not None
|
||||
assert result["name"] == "Updated"
|
||||
assert result["description"] == "New desc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_workspace_soft_delete(db_session: AsyncSession):
|
||||
"""delete_workspace sets is_active=False (soft delete)."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
created = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="ToDelete",
|
||||
)
|
||||
ws_id = uuid.UUID(created["id"])
|
||||
deleted = await workspace_service.delete_workspace(db_session, seed["tenant"].id, ws_id)
|
||||
assert deleted is True
|
||||
# Should not appear in list (only active workspaces)
|
||||
result = await workspace_service.list_workspaces(db_session, seed["tenant"].id)
|
||||
assert result["total"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_workspace_uniqueness(db_session: AsyncSession):
|
||||
"""Setting a new default workspace unsets the previous default."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
ws1 = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="WS1", is_default=True,
|
||||
)
|
||||
ws2 = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="WS2", is_default=True,
|
||||
)
|
||||
# ws1 should no longer be default
|
||||
result1 = await workspace_service.get_workspace(db_session, seed["tenant"].id, uuid.UUID(ws1["id"]))
|
||||
result2 = await workspace_service.get_workspace(db_session, seed["tenant"].id, uuid.UUID(ws2["id"]))
|
||||
assert result1["is_default"] is False
|
||||
assert result2["is_default"] is True
|
||||
|
||||
|
||||
# ─── Module Configuration ─────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_workspace_modules(db_session: AsyncSession):
|
||||
"""set_workspace_modules replaces all modules."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
created = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="TestWS",
|
||||
)
|
||||
ws_id = uuid.UUID(created["id"])
|
||||
modules = [
|
||||
{"module_key": "contacts", "is_visible": True, "menu_order": 10, "config": {}},
|
||||
{"module_key": "calendar", "is_visible": True, "menu_order": 20, "config": {}},
|
||||
{"module_key": "mail", "is_visible": False, "menu_order": 30, "config": {}},
|
||||
]
|
||||
result = await workspace_service.set_workspace_modules(db_session, seed["tenant"].id, ws_id, modules)
|
||||
assert len(result) == 3
|
||||
keys = [m["module_key"] for m in result]
|
||||
assert "contacts" in keys
|
||||
assert "calendar" in keys
|
||||
assert "mail" in keys
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_workspace_shows_no_modules(db_session: AsyncSession):
|
||||
"""A workspace with no modules configured shows no modules in context."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
created = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="EmptyWS",
|
||||
)
|
||||
ws_id = uuid.UUID(created["id"])
|
||||
ctx = await workspace_service.get_workspace_context(
|
||||
db_session, seed["tenant"].id, seed["user"].id, ws_id,
|
||||
)
|
||||
assert ctx is not None
|
||||
assert ctx["modules"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hidden_module_not_in_context(db_session: AsyncSession):
|
||||
"""A module with is_visible=False does not appear in workspace context."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
created = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="TestWS",
|
||||
)
|
||||
ws_id = uuid.UUID(created["id"])
|
||||
modules = [
|
||||
{"module_key": "contacts", "is_visible": True, "menu_order": 10, "config": {}},
|
||||
{"module_key": "mail", "is_visible": False, "menu_order": 20, "config": {}},
|
||||
]
|
||||
await workspace_service.set_workspace_modules(db_session, seed["tenant"].id, ws_id, modules)
|
||||
ctx = await workspace_service.get_workspace_context(
|
||||
db_session, seed["tenant"].id, seed["user"].id, ws_id,
|
||||
)
|
||||
assert ctx is not None
|
||||
module_keys = [m["module_key"] for m in ctx["modules"]]
|
||||
assert "contacts" in module_keys
|
||||
assert "mail" not in module_keys # Hidden module not in context
|
||||
|
||||
|
||||
# ─── Widget CRUD ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_widget(db_session: AsyncSession):
|
||||
"""Creating a widget returns correct data."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
created = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="TestWS",
|
||||
)
|
||||
ws_id = uuid.UUID(created["id"])
|
||||
result = await workspace_service.create_widget(
|
||||
db_session, seed["tenant"].id, ws_id,
|
||||
widget_key="recent_contacts", position_x=0, position_y=0, width=2, height=1,
|
||||
)
|
||||
assert result["widget_key"] == "recent_contacts"
|
||||
assert result["width"] == 2
|
||||
assert result["height"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_widgets(db_session: AsyncSession):
|
||||
"""list_widgets returns all widgets for a workspace."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
created = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="TestWS",
|
||||
)
|
||||
ws_id = uuid.UUID(created["id"])
|
||||
await workspace_service.create_widget(
|
||||
db_session, seed["tenant"].id, ws_id, widget_key="widget_a", position_y=0,
|
||||
)
|
||||
await workspace_service.create_widget(
|
||||
db_session, seed["tenant"].id, ws_id, widget_key="widget_b", position_y=1,
|
||||
)
|
||||
result = await workspace_service.get_widgets(db_session, seed["tenant"].id, ws_id)
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_same_widget_key(db_session: AsyncSession):
|
||||
"""Multiple instances of the same widget_key can exist (no unique constraint)."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
created = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="TestWS",
|
||||
)
|
||||
ws_id = uuid.UUID(created["id"])
|
||||
w1 = await workspace_service.create_widget(
|
||||
db_session, seed["tenant"].id, ws_id, widget_key="calendar_upcoming", position_x=0, position_y=0,
|
||||
)
|
||||
w2 = await workspace_service.create_widget(
|
||||
db_session, seed["tenant"].id, ws_id, widget_key="calendar_upcoming", position_x=1, position_y=0,
|
||||
)
|
||||
assert w1["id"] != w2["id"] # Different IDs
|
||||
assert w1["widget_key"] == w2["widget_key"] # Same key
|
||||
result = await workspace_service.get_widgets(db_session, seed["tenant"].id, ws_id)
|
||||
assert len(result) == 2 # Both exist
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_widget(db_session: AsyncSession):
|
||||
"""update_widget changes position and size."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
created = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="TestWS",
|
||||
)
|
||||
ws_id = uuid.UUID(created["id"])
|
||||
widget = await workspace_service.create_widget(
|
||||
db_session, seed["tenant"].id, ws_id, widget_key="test_widget",
|
||||
)
|
||||
widget_id = uuid.UUID(widget["id"])
|
||||
result = await workspace_service.update_widget(
|
||||
db_session, seed["tenant"].id, widget_id,
|
||||
position_x=2, position_y=3, width=4, height=2,
|
||||
)
|
||||
assert result is not None
|
||||
assert result["position_x"] == 2
|
||||
assert result["position_y"] == 3
|
||||
assert result["width"] == 4
|
||||
assert result["height"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_widget(db_session: AsyncSession):
|
||||
"""delete_widget removes the widget."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
created = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="TestWS",
|
||||
)
|
||||
ws_id = uuid.UUID(created["id"])
|
||||
widget = await workspace_service.create_widget(
|
||||
db_session, seed["tenant"].id, ws_id, widget_key="test_widget",
|
||||
)
|
||||
widget_id = uuid.UUID(widget["id"])
|
||||
deleted = await workspace_service.delete_widget(db_session, seed["tenant"].id, widget_id)
|
||||
assert deleted is True
|
||||
result = await workspace_service.get_widgets(db_session, seed["tenant"].id, ws_id)
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
# ─── User Assignment ──────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_user_to_workspace(db_session: AsyncSession):
|
||||
"""assign_user adds a user to a workspace."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
created = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="TestWS",
|
||||
)
|
||||
ws_id = uuid.UUID(created["id"])
|
||||
# Create a second user in the same tenant
|
||||
user2 = User(
|
||||
email="user2@example.com", name="User 2", password_hash="dummy",
|
||||
is_active=True, preferences={},
|
||||
)
|
||||
db_session.add(user2)
|
||||
await db_session.flush()
|
||||
ut2 = UserTenant(user_id=user2.id, tenant_id=seed["tenant"].id, is_default=True, role="viewer")
|
||||
db_session.add(ut2)
|
||||
await db_session.flush()
|
||||
|
||||
result = await workspace_service.assign_user(
|
||||
db_session, seed["tenant"].id, ws_id, user2.id, role="member",
|
||||
)
|
||||
assert result["role"] == "member"
|
||||
assert result["user_id"] == str(user2.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_user_from_workspace(db_session: AsyncSession):
|
||||
"""remove_user removes a user from a workspace."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
created = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="TestWS",
|
||||
)
|
||||
ws_id = uuid.UUID(created["id"])
|
||||
# Create a second user
|
||||
user2 = User(
|
||||
email="user2@example.com", name="User 2", password_hash="dummy",
|
||||
is_active=True, preferences={},
|
||||
)
|
||||
db_session.add(user2)
|
||||
await db_session.flush()
|
||||
ut2 = UserTenant(user_id=user2.id, tenant_id=seed["tenant"].id, is_default=True, role="viewer")
|
||||
db_session.add(ut2)
|
||||
await db_session.flush()
|
||||
|
||||
await workspace_service.assign_user(
|
||||
db_session, seed["tenant"].id, ws_id, user2.id, role="member",
|
||||
)
|
||||
removed = await workspace_service.remove_user(
|
||||
db_session, seed["tenant"].id, ws_id, user2.id,
|
||||
)
|
||||
assert removed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_tenant_user_assignment_blocked(db_session: AsyncSession):
|
||||
"""verify_user_same_tenant returns False for a user from a different tenant."""
|
||||
seed_a = await _seed_tenant_and_user(db_session)
|
||||
seed_b = await _seed_second_tenant_and_user(db_session)
|
||||
|
||||
# User B should not be assignable to tenant A's workspace
|
||||
result = await workspace_service.verify_user_same_tenant(
|
||||
db_session, seed_a["tenant"].id, seed_b["user"].id,
|
||||
)
|
||||
assert result is False
|
||||
|
||||
# User A should be verified for tenant A
|
||||
result = await workspace_service.verify_user_same_tenant(
|
||||
db_session, seed_a["tenant"].id, seed_a["user"].id,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
|
||||
# ─── Manager Role Check ───────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_workspace_manager(db_session: AsyncSession):
|
||||
"""Creator is auto-assigned as manager; a regular member is not a manager."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
created = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="TestWS",
|
||||
)
|
||||
ws_id = uuid.UUID(created["id"])
|
||||
|
||||
# Creator should be manager
|
||||
is_mgr = await workspace_service.is_workspace_manager(
|
||||
db_session, seed["tenant"].id, ws_id, seed["user"].id,
|
||||
)
|
||||
assert is_mgr is True
|
||||
|
||||
# Create a member (not manager)
|
||||
user2 = User(
|
||||
email="member@example.com", name="Member", password_hash="dummy",
|
||||
is_active=True, preferences={},
|
||||
)
|
||||
db_session.add(user2)
|
||||
await db_session.flush()
|
||||
ut2 = UserTenant(user_id=user2.id, tenant_id=seed["tenant"].id, is_default=True, role="viewer")
|
||||
db_session.add(ut2)
|
||||
await db_session.flush()
|
||||
|
||||
await workspace_service.assign_user(
|
||||
db_session, seed["tenant"].id, ws_id, user2.id, role="member",
|
||||
)
|
||||
|
||||
is_mgr2 = await workspace_service.is_workspace_manager(
|
||||
db_session, seed["tenant"].id, ws_id, user2.id,
|
||||
)
|
||||
assert is_mgr2 is False
|
||||
|
||||
|
||||
# ─── Default Workspace Seeding ────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_default_workspace(db_session: AsyncSession):
|
||||
"""seed_default_workspace creates a default workspace with all standard modules."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
result = await workspace_service.seed_default_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id,
|
||||
)
|
||||
assert result is not None
|
||||
assert result["name"] == "Standard"
|
||||
assert result["is_default"] is True
|
||||
assert result["user_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_default_workspace_idempotent(db_session: AsyncSession):
|
||||
"""seed_default_workspace returns None if workspaces already exist."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
# First call creates the default workspace
|
||||
await workspace_service.seed_default_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id,
|
||||
)
|
||||
# Second call should return None (already has workspaces)
|
||||
result = await workspace_service.seed_default_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ─── Set User Default Workspace ───────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_user_default_workspace(db_session: AsyncSession):
|
||||
"""set_user_default_workspace sets a workspace as default and unsets others."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
ws1 = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="WS1",
|
||||
)
|
||||
ws2 = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="WS2",
|
||||
)
|
||||
ws1_id = uuid.UUID(ws1["id"])
|
||||
ws2_id = uuid.UUID(ws2["id"])
|
||||
|
||||
# Assign user to both
|
||||
# (creator is already auto-assigned to both as manager)
|
||||
|
||||
# Set WS2 as default
|
||||
await workspace_service.set_user_default_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, ws2_id,
|
||||
)
|
||||
|
||||
# Verify via get_my_workspaces
|
||||
my = await workspace_service.get_my_workspaces(
|
||||
db_session, seed["tenant"].id, seed["user"].id,
|
||||
)
|
||||
for item in my["items"]:
|
||||
if item["id"] == ws2["id"]:
|
||||
assert item["is_user_default"] is True
|
||||
elif item["id"] == ws1["id"]:
|
||||
assert item["is_user_default"] is False
|
||||
|
||||
|
||||
# ─── Workspace Context ────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_context_includes_modules_and_widgets(db_session: AsyncSession):
|
||||
"""get_workspace_context returns both modules and widgets."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
created = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="TestWS",
|
||||
)
|
||||
ws_id = uuid.UUID(created["id"])
|
||||
|
||||
# Set modules
|
||||
await workspace_service.set_workspace_modules(
|
||||
db_session, seed["tenant"].id, ws_id,
|
||||
[{"module_key": "contacts", "is_visible": True, "menu_order": 10, "config": {}}],
|
||||
)
|
||||
|
||||
# Create widget
|
||||
await workspace_service.create_widget(
|
||||
db_session, seed["tenant"].id, ws_id, widget_key="recent_contacts",
|
||||
)
|
||||
|
||||
ctx = await workspace_service.get_workspace_context(
|
||||
db_session, seed["tenant"].id, seed["user"].id, ws_id,
|
||||
)
|
||||
assert ctx is not None
|
||||
assert len(ctx["modules"]) == 1
|
||||
assert ctx["modules"][0]["module_key"] == "contacts"
|
||||
assert len(ctx["widgets"]) == 1
|
||||
assert ctx["widgets"][0]["widget_key"] == "recent_contacts"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_context_unassigned_user(db_session: AsyncSession):
|
||||
"""get_workspace_context returns None for an unassigned user."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
created = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="TestWS",
|
||||
)
|
||||
ws_id = uuid.UUID(created["id"])
|
||||
|
||||
# Create a second user NOT assigned to the workspace
|
||||
user2 = User(
|
||||
email="unassigned@example.com", name="Unassigned", password_hash="dummy",
|
||||
is_active=True, preferences={},
|
||||
)
|
||||
db_session.add(user2)
|
||||
await db_session.flush()
|
||||
ut2 = UserTenant(user_id=user2.id, tenant_id=seed["tenant"].id, is_default=True, role="viewer")
|
||||
db_session.add(ut2)
|
||||
await db_session.flush()
|
||||
|
||||
ctx = await workspace_service.get_workspace_context(
|
||||
db_session, seed["tenant"].id, user2.id, ws_id,
|
||||
)
|
||||
assert ctx is None # User not assigned
|
||||
|
||||
|
||||
# ─── Cross-Tenant Isolation ───────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_tenant_workspace_isolation(db_session: AsyncSession):
|
||||
"""A workspace from tenant A cannot be accessed by tenant B."""
|
||||
seed_a = await _seed_tenant_and_user(db_session)
|
||||
seed_b = await _seed_second_tenant_and_user(db_session)
|
||||
|
||||
created = await workspace_service.create_workspace(
|
||||
db_session, seed_a["tenant"].id, seed_a["user"].id, name="TenantA_WS",
|
||||
)
|
||||
ws_id = uuid.UUID(created["id"])
|
||||
|
||||
# Tenant B should not see tenant A's workspace
|
||||
result = await workspace_service.get_workspace(
|
||||
db_session, seed_b["tenant"].id, ws_id,
|
||||
)
|
||||
assert result is None # Not found in tenant B
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_my_workspaces_only_assigned(db_session: AsyncSession):
|
||||
"""get_my_workspaces returns only workspaces the user is assigned to."""
|
||||
seed = await _seed_tenant_and_user(db_session)
|
||||
ws1 = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="AssignedWS",
|
||||
)
|
||||
ws2 = await workspace_service.create_workspace(
|
||||
db_session, seed["tenant"].id, seed["user"].id, name="AlsoAssignedWS",
|
||||
)
|
||||
|
||||
# Create a third workspace without assigning the user
|
||||
ws3 = Workspace(
|
||||
tenant_id=seed["tenant"].id,
|
||||
name="UnassignedWS",
|
||||
is_active=True,
|
||||
created_by=seed["user"].id,
|
||||
)
|
||||
db_session.add(ws3)
|
||||
await db_session.flush()
|
||||
|
||||
my = await workspace_service.get_my_workspaces(
|
||||
db_session, seed["tenant"].id, seed["user"].id,
|
||||
)
|
||||
names = [w["name"] for w in my["items"]]
|
||||
assert "AssignedWS" in names
|
||||
assert "AlsoAssignedWS" in names
|
||||
assert "UnassignedWS" not in names # User not assigned
|
||||
Reference in New Issue
Block a user