diff --git a/app/routes/workspaces.py b/app/routes/workspaces.py index 8f3d974..d921bfc 100644 --- a/app/routes/workspaces.py +++ b/app/routes/workspaces.py @@ -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} diff --git a/app/services/workspace_service.py b/app/services/workspace_service.py index 1478bdf..85b36c8 100644 --- a/app/services/workspace_service.py +++ b/app/services/workspace_service.py @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e8d3c33..be16d9e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 98670a0..f2b781b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 157ebd3..e8e59b8 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -30,6 +30,13 @@ export function getCsrfToken(): string | null { let onUnauthorized: (() => void) | null = null; let onValidationError: ((errors: Record) => 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) diff --git a/frontend/src/api/hooks/workspaces.ts b/frontend/src/api/hooks/workspaces.ts index 70339cc..3bedc17 100644 --- a/frontend/src/api/hooks/workspaces.ts +++ b/frontend/src/api/hooks/workspaces.ts @@ -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; +} + +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; + }) => 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; + }) => 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'] }); + }, + }); +} diff --git a/frontend/src/hooks/useWorkspace.ts b/frontend/src/hooks/useWorkspace.ts index 361d802..9907f3e 100644 --- a/frontend/src/hooks/useWorkspace.ts +++ b/frontend/src/hooks/useWorkspace.ts @@ -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(() => { - 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 = 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, }; } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 13fefc4..e82857a 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -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' }, ]; diff --git a/frontend/src/pages/SettingsWorkspaces.tsx b/frontend/src/pages/SettingsWorkspaces.tsx new file mode 100644 index 0000000..78a1b92 --- /dev/null +++ b/frontend/src/pages/SettingsWorkspaces.tsx @@ -0,0 +1,9 @@ +import { WorkspaceManager } from '@/components/settings/WorkspaceManager'; + +export function WorkspaceManagerPage() { + return ( +
+ +
+ ); +} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index fbd5fd6..83f6bcc 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -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() }, { path: 'custom-fields', element: withSuspense() }, { path: 'webhooks', element: withSuspense() }, + { path: 'workspaces', element: withSuspense() }, { path: 'backup', element: withSuspense() }, { path: 'rechte', element: {withSuspense()} }, { path: '*', element: }, diff --git a/frontend/src/store/__tests__/workspaceStore.test.ts b/frontend/src/store/__tests__/workspaceStore.test.ts new file mode 100644 index 0000000..02fc3d9 --- /dev/null +++ b/frontend/src/store/__tests__/workspaceStore.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useWorkspaceStore } from '@/store/workspaceStore'; + +// Mock sessionStorage +const mockSessionStorage = { + store: {} as Record, + 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([]); + }); +}); diff --git a/frontend/src/store/workspaceStore.ts b/frontend/src/store/workspaceStore.ts new file mode 100644 index 0000000..f52a169 --- /dev/null +++ b/frontend/src/store/workspaceStore.ts @@ -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; +} + +export interface WorkspaceWidgetConfig { + id: string; + widget_key: string; + position_x: number; + position_y: number; + width: number; + height: number; + config: Record; +} + +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 }[]; + 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; + hasWorkspaces: () => boolean; + // Reset + reset: () => void; +} + +const SESSION_STORAGE_KEY = 'leocrm-active-workspace'; + +export const useWorkspaceStore = create()( + 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 }), + } + ) +); diff --git a/tests/test_workspaces.py b/tests/test_workspaces.py new file mode 100644 index 0000000..67af132 --- /dev/null +++ b/tests/test_workspaces.py @@ -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