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
+11
View File
@@ -30,6 +30,13 @@ export function getCsrfToken(): string | null {
let onUnauthorized: (() => void) | null = null;
let onValidationError: ((errors: Record<string, string[]>) => void) | null = null;
// Workspace context — set by workspaceStore, sent as X-Workspace-ID header
let activeWorkspaceId: string | null = null;
export function setActiveWorkspaceId(id: string | null) {
activeWorkspaceId = id;
}
export function setUnauthorizedHandler(handler: () => void) {
onUnauthorized = handler;
}
@@ -45,6 +52,10 @@ apiClient.interceptors.request.use(
if (unsafe.includes(config.method?.toLowerCase() ?? '') && csrfToken) {
config.headers['X-CSRF-Token'] = csrfToken;
}
// Attach X-Workspace-ID header for workspace context (per-tab)
if (activeWorkspaceId) {
config.headers['X-Workspace-ID'] = activeWorkspaceId;
}
return config;
},
(error) => Promise.reject(error)
+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 { useMyWorkspaces, useWorkspaceContext, type WorkspaceContext } from '@/api/hooks/workspaces';
import { useEffect, useCallback } from 'react';
import { useMyWorkspaces, useWorkspaceContext } from '@/api/hooks/workspaces';
import { useWorkspaceStore, type WorkspaceInfo, type WorkspaceContextState } from '@/store/workspaceStore';
import { useAuthStore } from '@/store/authStore';
const STORAGE_KEY = 'leocrm-active-workspace';
/**
* Manages the active workspace for the current browser tab.
*
* Uses sessionStorage (per-tab, not shared across tabs) to store the active workspace ID.
* Sends X-Workspace-ID header on workspace-aware requests.
* Uses the central workspaceStore (Zustand) for state management.
* sessionStorage provides per-tab persistence.
* X-Workspace-ID header is sent automatically by the API client interceptor.
*
* Workspaces are UI/navigation context only — they never affect permissions.
*/
export function useWorkspace() {
const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(() => {
return sessionStorage.getItem(STORAGE_KEY);
});
const activeWorkspaceId = useWorkspaceStore(s => s.activeWorkspaceId);
const context = useWorkspaceStore(s => s.context);
const myWorkspacesFromStore = useWorkspaceStore(s => s.myWorkspaces);
const setActiveWorkspace = useWorkspaceStore(s => s.setActiveWorkspace);
const setContext = useWorkspaceStore(s => s.setContext);
const setMyWorkspaces = useWorkspaceStore(s => s.setMyWorkspaces);
const isModuleVisibleFromStore = useWorkspaceStore(s => s.isModuleVisible);
const { data: myWorkspaces } = useMyWorkspaces();
const { data: context } = useWorkspaceContext(activeWorkspaceId);
const user = useAuthStore.getState().user;
const isSystemAdmin = user?.is_system_admin;
// Fetch my workspaces and sync to store
const { data: myWorkspacesData } = useMyWorkspaces();
const { data: contextData } = useWorkspaceContext(activeWorkspaceId);
// Sync fetched workspaces to store
useEffect(() => {
if (myWorkspacesData?.items) {
setMyWorkspaces(myWorkspacesData.items as WorkspaceInfo[]);
}
}, [myWorkspacesData, setMyWorkspaces]);
// Sync fetched context to store
useEffect(() => {
if (contextData !== undefined) {
setContext(contextData as WorkspaceContextState | null);
}
}, [contextData, setContext]);
// Auto-select default workspace if none selected
useEffect(() => {
if (!activeWorkspaceId && myWorkspaces?.items?.length) {
const defaultWs = myWorkspaces.items.find(w => w.is_user_default) || myWorkspaces.items[0];
if (!activeWorkspaceId && myWorkspacesData?.items?.length) {
const defaultWs = myWorkspacesData.items.find(w => w.is_user_default) || myWorkspacesData.items[0];
if (defaultWs) {
setActiveWorkspaceId(defaultWs.id);
sessionStorage.setItem(STORAGE_KEY, defaultWs.id);
setActiveWorkspace(defaultWs.id);
}
}
}, [activeWorkspaceId, myWorkspaces]);
}, [activeWorkspaceId, myWorkspacesData, setActiveWorkspace]);
// Switch workspace (per-tab)
const switchWorkspace = useCallback((workspaceId: string | null) => {
if (workspaceId) {
sessionStorage.setItem(STORAGE_KEY, workspaceId);
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
setActiveWorkspaceId(workspaceId);
}, []);
// Get visible module keys from workspace context
const visibleModuleKeys: Set<string> = new Set(
context?.modules?.map(m => m.module_key) || []
);
setActiveWorkspace(workspaceId);
}, [setActiveWorkspace]);
// Check if a module is visible in the current workspace
const isModuleVisible = useCallback((moduleKey: string): boolean => {
// System admins see all modules regardless of workspace
const user = useAuthStore.getState().user;
if (user?.is_system_admin) return true;
// While permissions are loading (is_system_admin undefined), show everything
if (user && user.is_system_admin === undefined) return true;
// If no workspace context, show all (backward compatible)
if (!context?.workspace_id || !context?.modules?.length) return true;
return visibleModuleKeys.has(moduleKey);
}, [context, visibleModuleKeys]);
return isModuleVisibleFromStore(moduleKey, isSystemAdmin);
}, [isModuleVisibleFromStore, isSystemAdmin]);
const visibleModuleKeys = useWorkspaceStore(s => s.visibleModuleKeys());
return {
activeWorkspaceId,
activeWorkspace: context,
myWorkspaces: myWorkspaces?.items || [],
myWorkspaces: myWorkspacesFromStore,
switchWorkspace,
isModuleVisible,
visibleModuleKeys,
hasWorkspaces: (myWorkspaces?.items?.length || 0) > 0,
hasWorkspaces: myWorkspacesFromStore.length > 0,
};
}
+1
View File
@@ -23,6 +23,7 @@ export function SettingsPage() {
{ to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' },
{ to: '/settings/custom-fields', label: 'Custom Fields', icon: '\ud83d\udccb' },
{ to: '/settings/webhooks', label: 'Webhooks', icon: '\ud83d\udd14' },
{ to: '/settings/workspaces', label: 'Workspaces', icon: '\ud83d\udd58\ufe0f' },
{ to: '/settings/backup', label: 'Backup & Restore', icon: '\ud83d\udcbe' },
];
@@ -0,0 +1,9 @@
import { WorkspaceManager } from '@/components/settings/WorkspaceManager';
export function WorkspaceManagerPage() {
return (
<div data-testid="settings-workspaces">
<WorkspaceManager />
</div>
);
}
+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 SettingsWebhooksPage = React.lazy(() => import('@/pages/SettingsWebhooks').then(m => ({ default: m.SettingsWebhooksPage })));
const SettingsBackupPage = React.lazy(() => import('@/pages/SettingsBackup').then(m => ({ default: m.SettingsBackupPage })));
const WorkspaceManagerPage = React.lazy(() => import('@/pages/SettingsWorkspaces').then(m => ({ default: m.WorkspaceManagerPage })));
const SettingsRechtePage = React.lazy(() => import('@/pages/SettingsRechte').then(m => ({ default: m.SettingsRechtePage })));
const NoAccessPage = React.lazy(() => import('@/pages/NoAccessPage').then(m => ({ default: m.NoAccessPage })));
const StartPage = React.lazy(() => import('@/pages/StartPage').then(m => ({ default: m.StartPage })));
@@ -193,6 +194,7 @@ const router = createBrowserRouter([
{ path: 'menu', element: withSuspense(<SettingsMenuOrderPage />) },
{ path: 'custom-fields', element: withSuspense(<CustomFieldsPage />) },
{ path: 'webhooks', element: withSuspense(<SettingsWebhooksPage />) },
{ path: 'workspaces', element: withSuspense(<WorkspaceManagerPage />) },
{ path: 'backup', element: withSuspense(<SettingsBackupPage />) },
{ path: 'rechte', element: <PermissionRoute permission="settings:read">{withSuspense(<SettingsRechtePage />)}</PermissionRoute> },
{ path: '*', element: <PluginRouteRenderer /> },
@@ -0,0 +1,135 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useWorkspaceStore } from '@/store/workspaceStore';
// Mock sessionStorage
const mockSessionStorage = {
store: {} as Record<string, string>,
getItem: vi.fn((key: string) => mockSessionStorage.store[key] ?? null),
setItem: vi.fn((key: string, value: string) => { mockSessionStorage.store[key] = value; }),
removeItem: vi.fn((key: string) => { delete mockSessionStorage.store[key]; }),
clear: vi.fn(() => { mockSessionStorage.store = {}; }),
};
Object.defineProperty(window, 'sessionStorage', {
value: mockSessionStorage,
writable: true,
});
// Mock the API client import
vi.mock('@/api/client', () => ({
setActiveWorkspaceId: vi.fn(),
}));
describe('workspaceStore', () => {
beforeEach(() => {
mockSessionStorage.clear();
useWorkspaceStore.getState().reset();
});
it('initializes with no active workspace', () => {
expect(useWorkspaceStore.getState().activeWorkspaceId).toBeNull();
expect(useWorkspaceStore.getState().context).toBeNull();
expect(useWorkspaceStore.getState().myWorkspaces).toEqual([]);
});
it('setActiveWorkspace stores ID in sessionStorage', () => {
const wsId = 'test-workspace-id';
useWorkspaceStore.getState().setActiveWorkspace(wsId);
expect(useWorkspaceStore.getState().activeWorkspaceId).toBe(wsId);
expect(mockSessionStorage.setItem).toHaveBeenCalledWith('leocrm-active-workspace', wsId);
});
it('setActiveWorkspace(null) removes from sessionStorage', () => {
useWorkspaceStore.getState().setActiveWorkspace('test-id');
useWorkspaceStore.getState().setActiveWorkspace(null);
expect(useWorkspaceStore.getState().activeWorkspaceId).toBeNull();
expect(mockSessionStorage.removeItem).toHaveBeenCalledWith('leocrm-active-workspace');
});
it('setActiveWorkspace clears context on switch', () => {
useWorkspaceStore.getState().setContext({
workspace_id: 'old-id',
modules: [{ module_key: 'contacts', menu_order: 0, config: {} }],
widgets: [],
});
useWorkspaceStore.getState().setActiveWorkspace('new-id');
expect(useWorkspaceStore.getState().context).toBeNull();
});
it('isModuleVisible returns true when no workspace context (backward compatible)', () => {
expect(useWorkspaceStore.getState().isModuleVisible('contacts')).toBe(true);
});
it('isModuleVisible returns true for system admin', () => {
useWorkspaceStore.getState().setContext({
workspace_id: 'ws-1',
modules: [{ module_key: 'contacts', menu_order: 0, config: {} }],
widgets: [],
});
expect(useWorkspaceStore.getState().isModuleVisible('mail', true)).toBe(true);
});
it('isModuleVisible returns false for module not in workspace', () => {
useWorkspaceStore.getState().setContext({
workspace_id: 'ws-1',
modules: [{ module_key: 'contacts', menu_order: 0, config: {} }],
widgets: [],
});
expect(useWorkspaceStore.getState().isModuleVisible('mail')).toBe(false);
});
it('isModuleVisible returns true for module in workspace', () => {
useWorkspaceStore.getState().setContext({
workspace_id: 'ws-1',
modules: [
{ module_key: 'contacts', menu_order: 0, config: {} },
{ module_key: 'calendar', menu_order: 1, config: {} },
],
widgets: [],
});
expect(useWorkspaceStore.getState().isModuleVisible('contacts')).toBe(true);
expect(useWorkspaceStore.getState().isModuleVisible('calendar')).toBe(true);
});
it('visibleModuleKeys returns set of visible module keys', () => {
useWorkspaceStore.getState().setContext({
workspace_id: 'ws-1',
modules: [
{ module_key: 'contacts', menu_order: 0, config: {} },
{ module_key: 'calendar', menu_order: 1, config: {} },
],
widgets: [],
});
const keys = useWorkspaceStore.getState().visibleModuleKeys();
expect(keys.has('contacts')).toBe(true);
expect(keys.has('calendar')).toBe(true);
expect(keys.has('mail')).toBe(false);
});
it('hasWorkspaces returns false when empty', () => {
expect(useWorkspaceStore.getState().hasWorkspaces()).toBe(false);
});
it('hasWorkspaces returns true when workspaces exist', () => {
useWorkspaceStore.getState().setMyWorkspaces([
{ id: 'ws-1', name: 'WS1', icon: 'LayoutGrid', description: null, is_default: false, is_active: true },
]);
expect(useWorkspaceStore.getState().hasWorkspaces()).toBe(true);
});
it('reset clears all state', () => {
useWorkspaceStore.getState().setActiveWorkspace('test-id');
useWorkspaceStore.getState().setMyWorkspaces([
{ id: 'ws-1', name: 'WS1', icon: 'LayoutGrid', description: null, is_default: false, is_active: true },
]);
useWorkspaceStore.getState().setContext({
workspace_id: 'ws-1',
modules: [],
widgets: [],
});
useWorkspaceStore.getState().reset();
expect(useWorkspaceStore.getState().activeWorkspaceId).toBeNull();
expect(useWorkspaceStore.getState().context).toBeNull();
expect(useWorkspaceStore.getState().myWorkspaces).toEqual([]);
});
});
+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 }),
}
)
);