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:
Agent Zero
2026-08-03 03:39:27 +02:00
parent 236f0d2a5d
commit 310a9f0542
13 changed files with 1481 additions and 50 deletions
+120
View File
@@ -228,6 +228,9 @@ async def assign_user(
target_uid = uuid.UUID(body.user_id) target_uid = uuid.UUID(body.user_id)
except ValueError: except ValueError:
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) 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) 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) removed = await workspace_service.remove_user(db, tenant_id, wid, uid)
if not removed: if not removed:
raise HTTPException(404, detail={"detail": "User not assigned to this workspace", "code": "not_found"}) 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}
+249 -11
View File
@@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.models.workspace import Workspace, WorkspaceModule, WorkspaceUser, WorkspaceWidget 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]: 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, is_default: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Create a new workspace.""" """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( ws = Workspace(
tenant_id=tenant_id, tenant_id=tenant_id,
name=name, name=name,
@@ -121,17 +133,6 @@ async def create_workspace(
await db.flush() await db.flush()
await db.refresh(ws) 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 # Auto-assign creator as manager
wu = WorkspaceUser( wu = WorkspaceUser(
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -424,3 +425,240 @@ async def get_workspace_context(
for w in widgets 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
+73
View File
@@ -46,6 +46,7 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.48.0", "@playwright/test": "^1.48.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.5.0", "@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.1", "@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
@@ -3172,6 +3173,25 @@
"url": "https://github.com/sponsors/tannerlinsley" "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": { "node_modules/@testing-library/jest-dom": {
"version": "6.9.1", "version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
@@ -3724,6 +3744,12 @@
"tslib": "^2.4.0" "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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -4088,6 +4114,18 @@
"node": ">=8" "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": { "node_modules/any-promise": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -5046,6 +5084,12 @@
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"dev": true "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": { "node_modules/dompurify": {
"version": "3.4.12", "version": "3.4.12",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", "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" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -8529,6 +8582,20 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/property-information": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", "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": { "node_modules/react-markdown": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
+1
View File
@@ -53,6 +53,7 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.48.0", "@playwright/test": "^1.48.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.5.0", "@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.1", "@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
+11
View File
@@ -30,6 +30,13 @@ export function getCsrfToken(): string | null {
let onUnauthorized: (() => void) | null = null; let onUnauthorized: (() => void) | null = null;
let onValidationError: ((errors: Record<string, string[]>) => 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) { export function setUnauthorizedHandler(handler: () => void) {
onUnauthorized = handler; onUnauthorized = handler;
} }
@@ -45,6 +52,10 @@ apiClient.interceptors.request.use(
if (unsafe.includes(config.method?.toLowerCase() ?? '') && csrfToken) { if (unsafe.includes(config.method?.toLowerCase() ?? '') && csrfToken) {
config.headers['X-CSRF-Token'] = 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; return config;
}, },
(error) => Promise.reject(error) (error) => Promise.reject(error)
+84
View File
@@ -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'] });
},
});
}
+45 -39
View File
@@ -1,70 +1,76 @@
import { useState, useEffect, useCallback } from 'react'; import { useEffect, useCallback } from 'react';
import { useMyWorkspaces, useWorkspaceContext, type WorkspaceContext } from '@/api/hooks/workspaces'; import { useMyWorkspaces, useWorkspaceContext } from '@/api/hooks/workspaces';
import { useWorkspaceStore, type WorkspaceInfo, type WorkspaceContextState } from '@/store/workspaceStore';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
const STORAGE_KEY = 'leocrm-active-workspace';
/** /**
* Manages the active workspace for the current browser tab. * Manages the active workspace for the current browser tab.
* *
* Uses sessionStorage (per-tab, not shared across tabs) to store the active workspace ID. * Uses the central workspaceStore (Zustand) for state management.
* Sends X-Workspace-ID header on workspace-aware requests. * 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. * Workspaces are UI/navigation context only — they never affect permissions.
*/ */
export function useWorkspace() { export function useWorkspace() {
const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(() => { const activeWorkspaceId = useWorkspaceStore(s => s.activeWorkspaceId);
return sessionStorage.getItem(STORAGE_KEY); 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 user = useAuthStore.getState().user;
const { data: context } = useWorkspaceContext(activeWorkspaceId); 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 // Auto-select default workspace if none selected
useEffect(() => { useEffect(() => {
if (!activeWorkspaceId && myWorkspaces?.items?.length) { if (!activeWorkspaceId && myWorkspacesData?.items?.length) {
const defaultWs = myWorkspaces.items.find(w => w.is_user_default) || myWorkspaces.items[0]; const defaultWs = myWorkspacesData.items.find(w => w.is_user_default) || myWorkspacesData.items[0];
if (defaultWs) { if (defaultWs) {
setActiveWorkspaceId(defaultWs.id); setActiveWorkspace(defaultWs.id);
sessionStorage.setItem(STORAGE_KEY, defaultWs.id);
} }
} }
}, [activeWorkspaceId, myWorkspaces]); }, [activeWorkspaceId, myWorkspacesData, setActiveWorkspace]);
// Switch workspace (per-tab) // Switch workspace (per-tab)
const switchWorkspace = useCallback((workspaceId: string | null) => { const switchWorkspace = useCallback((workspaceId: string | null) => {
if (workspaceId) { setActiveWorkspace(workspaceId);
sessionStorage.setItem(STORAGE_KEY, workspaceId); }, [setActiveWorkspace]);
} 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) || []
);
// Check if a module is visible in the current workspace // Check if a module is visible in the current workspace
const isModuleVisible = useCallback((moduleKey: string): boolean => { const isModuleVisible = useCallback((moduleKey: string): boolean => {
// System admins see all modules regardless of workspace return isModuleVisibleFromStore(moduleKey, isSystemAdmin);
const user = useAuthStore.getState().user; }, [isModuleVisibleFromStore, isSystemAdmin]);
if (user?.is_system_admin) return true;
// While permissions are loading (is_system_admin undefined), show everything const visibleModuleKeys = useWorkspaceStore(s => s.visibleModuleKeys());
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 { return {
activeWorkspaceId, activeWorkspaceId,
activeWorkspace: context, activeWorkspace: context,
myWorkspaces: myWorkspaces?.items || [], myWorkspaces: myWorkspacesFromStore,
switchWorkspace, switchWorkspace,
isModuleVisible, isModuleVisible,
visibleModuleKeys, visibleModuleKeys,
hasWorkspaces: (myWorkspaces?.items?.length || 0) > 0, hasWorkspaces: myWorkspacesFromStore.length > 0,
}; };
} }
+1
View File
@@ -23,6 +23,7 @@ export function SettingsPage() {
{ to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' }, { to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' },
{ to: '/settings/custom-fields', label: 'Custom Fields', icon: '\ud83d\udccb' }, { to: '/settings/custom-fields', label: 'Custom Fields', icon: '\ud83d\udccb' },
{ to: '/settings/webhooks', label: 'Webhooks', icon: '\ud83d\udd14' }, { 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' }, { 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>
);
}
+2
View File
@@ -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 ActivityTimelinePage = React.lazy(() => import('@/pages/ActivityTimeline').then(m => ({ default: m.ActivityTimelinePage })));
const SettingsWebhooksPage = React.lazy(() => import('@/pages/SettingsWebhooks').then(m => ({ default: m.SettingsWebhooksPage }))); 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 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 SettingsRechtePage = React.lazy(() => import('@/pages/SettingsRechte').then(m => ({ default: m.SettingsRechtePage })));
const NoAccessPage = React.lazy(() => import('@/pages/NoAccessPage').then(m => ({ default: m.NoAccessPage }))); const NoAccessPage = React.lazy(() => import('@/pages/NoAccessPage').then(m => ({ default: m.NoAccessPage })));
const StartPage = React.lazy(() => import('@/pages/StartPage').then(m => ({ default: m.StartPage }))); 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: 'menu', element: withSuspense(<SettingsMenuOrderPage />) },
{ path: 'custom-fields', element: withSuspense(<CustomFieldsPage />) }, { path: 'custom-fields', element: withSuspense(<CustomFieldsPage />) },
{ path: 'webhooks', element: withSuspense(<SettingsWebhooksPage />) }, { path: 'webhooks', element: withSuspense(<SettingsWebhooksPage />) },
{ path: 'workspaces', element: withSuspense(<WorkspaceManagerPage />) },
{ path: 'backup', element: withSuspense(<SettingsBackupPage />) }, { path: 'backup', element: withSuspense(<SettingsBackupPage />) },
{ path: 'rechte', element: <PermissionRoute permission="settings:read">{withSuspense(<SettingsRechtePage />)}</PermissionRoute> }, { path: 'rechte', element: <PermissionRoute permission="settings:read">{withSuspense(<SettingsRechtePage />)}</PermissionRoute> },
{ path: '*', element: <PluginRouteRenderer /> }, { 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([]);
});
});
+119
View File
@@ -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 }),
}
)
);
+632
View File
@@ -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