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:
Generated
+73
@@ -46,6 +46,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
@@ -3172,6 +3173,25 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
||||
@@ -3724,6 +3744,12 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -4088,6 +4114,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/any-promise": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
@@ -5046,6 +5084,12 @@
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.12",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
|
||||
@@ -7112,6 +7156,15 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -8529,6 +8582,20 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
|
||||
@@ -8764,6 +8831,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/react-markdown": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
|
||||
@@ -30,6 +30,13 @@ export function getCsrfToken(): string | null {
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
let onValidationError: ((errors: Record<string, string[]>) => void) | null = null;
|
||||
|
||||
// Workspace context — set by workspaceStore, sent as X-Workspace-ID header
|
||||
let activeWorkspaceId: string | null = null;
|
||||
|
||||
export function setActiveWorkspaceId(id: string | null) {
|
||||
activeWorkspaceId = id;
|
||||
}
|
||||
|
||||
export function setUnauthorizedHandler(handler: () => void) {
|
||||
onUnauthorized = handler;
|
||||
}
|
||||
@@ -45,6 +52,10 @@ apiClient.interceptors.request.use(
|
||||
if (unsafe.includes(config.method?.toLowerCase() ?? '') && csrfToken) {
|
||||
config.headers['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
// Attach X-Workspace-ID header for workspace context (per-tab)
|
||||
if (activeWorkspaceId) {
|
||||
config.headers['X-Workspace-ID'] = activeWorkspaceId;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
|
||||
@@ -132,3 +132,87 @@ export function useRemoveWorkspaceUser() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Widget Hooks ─────────────────────────────────────────────
|
||||
|
||||
export interface WorkspaceWidget {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
widget_key: string;
|
||||
position_x: number;
|
||||
position_y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
config: Record<string, any>;
|
||||
}
|
||||
|
||||
export function useWorkspaceWidgets(workspaceId: string | null) {
|
||||
return useQuery<{ items: WorkspaceWidget[]; total: number }>({
|
||||
queryKey: ['workspace-widgets', workspaceId],
|
||||
queryFn: () => apiGet(`/api/v1/workspaces/${workspaceId}/widgets`),
|
||||
enabled: !!workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateWorkspaceWidget() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ workspaceId, ...data }: {
|
||||
workspaceId: string;
|
||||
widget_key: string;
|
||||
position_x?: number;
|
||||
position_y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
config?: Record<string, any>;
|
||||
}) => apiPost(`/api/v1/workspaces/${workspaceId}/widgets`, data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workspace-widgets'] });
|
||||
qc.invalidateQueries({ queryKey: ['workspace-context'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateWorkspaceWidget() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ workspaceId, widgetId, ...data }: {
|
||||
workspaceId: string;
|
||||
widgetId: string;
|
||||
position_x?: number;
|
||||
position_y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
config?: Record<string, any>;
|
||||
}) => apiPut(`/api/v1/workspaces/${workspaceId}/widgets/${widgetId}`, data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workspace-widgets'] });
|
||||
qc.invalidateQueries({ queryKey: ['workspace-context'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteWorkspaceWidget() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ workspaceId, widgetId }: { workspaceId: string; widgetId: string }) =>
|
||||
apiDelete(`/api/v1/workspaces/${workspaceId}/widgets/${widgetId}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workspace-widgets'] });
|
||||
qc.invalidateQueries({ queryKey: ['workspace-context'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Set Default Workspace ────────────────────────────────────
|
||||
|
||||
export function useSetDefaultWorkspace() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (workspaceId: string) =>
|
||||
apiPost(`/api/v1/workspaces/${workspaceId}/set-default`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['my-workspaces'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,70 +1,76 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useMyWorkspaces, useWorkspaceContext, type WorkspaceContext } from '@/api/hooks/workspaces';
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useMyWorkspaces, useWorkspaceContext } from '@/api/hooks/workspaces';
|
||||
import { useWorkspaceStore, type WorkspaceInfo, type WorkspaceContextState } from '@/store/workspaceStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
const STORAGE_KEY = 'leocrm-active-workspace';
|
||||
|
||||
/**
|
||||
* Manages the active workspace for the current browser tab.
|
||||
*
|
||||
* Uses sessionStorage (per-tab, not shared across tabs) to store the active workspace ID.
|
||||
* Sends X-Workspace-ID header on workspace-aware requests.
|
||||
* Uses the central workspaceStore (Zustand) for state management.
|
||||
* sessionStorage provides per-tab persistence.
|
||||
* X-Workspace-ID header is sent automatically by the API client interceptor.
|
||||
*
|
||||
* Workspaces are UI/navigation context only — they never affect permissions.
|
||||
*/
|
||||
export function useWorkspace() {
|
||||
const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(() => {
|
||||
return sessionStorage.getItem(STORAGE_KEY);
|
||||
});
|
||||
const activeWorkspaceId = useWorkspaceStore(s => s.activeWorkspaceId);
|
||||
const context = useWorkspaceStore(s => s.context);
|
||||
const myWorkspacesFromStore = useWorkspaceStore(s => s.myWorkspaces);
|
||||
const setActiveWorkspace = useWorkspaceStore(s => s.setActiveWorkspace);
|
||||
const setContext = useWorkspaceStore(s => s.setContext);
|
||||
const setMyWorkspaces = useWorkspaceStore(s => s.setMyWorkspaces);
|
||||
const isModuleVisibleFromStore = useWorkspaceStore(s => s.isModuleVisible);
|
||||
|
||||
const { data: myWorkspaces } = useMyWorkspaces();
|
||||
const { data: context } = useWorkspaceContext(activeWorkspaceId);
|
||||
const user = useAuthStore.getState().user;
|
||||
const isSystemAdmin = user?.is_system_admin;
|
||||
|
||||
// Fetch my workspaces and sync to store
|
||||
const { data: myWorkspacesData } = useMyWorkspaces();
|
||||
const { data: contextData } = useWorkspaceContext(activeWorkspaceId);
|
||||
|
||||
// Sync fetched workspaces to store
|
||||
useEffect(() => {
|
||||
if (myWorkspacesData?.items) {
|
||||
setMyWorkspaces(myWorkspacesData.items as WorkspaceInfo[]);
|
||||
}
|
||||
}, [myWorkspacesData, setMyWorkspaces]);
|
||||
|
||||
// Sync fetched context to store
|
||||
useEffect(() => {
|
||||
if (contextData !== undefined) {
|
||||
setContext(contextData as WorkspaceContextState | null);
|
||||
}
|
||||
}, [contextData, setContext]);
|
||||
|
||||
// Auto-select default workspace if none selected
|
||||
useEffect(() => {
|
||||
if (!activeWorkspaceId && myWorkspaces?.items?.length) {
|
||||
const defaultWs = myWorkspaces.items.find(w => w.is_user_default) || myWorkspaces.items[0];
|
||||
if (!activeWorkspaceId && myWorkspacesData?.items?.length) {
|
||||
const defaultWs = myWorkspacesData.items.find(w => w.is_user_default) || myWorkspacesData.items[0];
|
||||
if (defaultWs) {
|
||||
setActiveWorkspaceId(defaultWs.id);
|
||||
sessionStorage.setItem(STORAGE_KEY, defaultWs.id);
|
||||
setActiveWorkspace(defaultWs.id);
|
||||
}
|
||||
}
|
||||
}, [activeWorkspaceId, myWorkspaces]);
|
||||
}, [activeWorkspaceId, myWorkspacesData, setActiveWorkspace]);
|
||||
|
||||
// Switch workspace (per-tab)
|
||||
const switchWorkspace = useCallback((workspaceId: string | null) => {
|
||||
if (workspaceId) {
|
||||
sessionStorage.setItem(STORAGE_KEY, workspaceId);
|
||||
} else {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
setActiveWorkspaceId(workspaceId);
|
||||
}, []);
|
||||
|
||||
// Get visible module keys from workspace context
|
||||
const visibleModuleKeys: Set<string> = new Set(
|
||||
context?.modules?.map(m => m.module_key) || []
|
||||
);
|
||||
setActiveWorkspace(workspaceId);
|
||||
}, [setActiveWorkspace]);
|
||||
|
||||
// Check if a module is visible in the current workspace
|
||||
const isModuleVisible = useCallback((moduleKey: string): boolean => {
|
||||
// System admins see all modules regardless of workspace
|
||||
const user = useAuthStore.getState().user;
|
||||
if (user?.is_system_admin) return true;
|
||||
// While permissions are loading (is_system_admin undefined), show everything
|
||||
if (user && user.is_system_admin === undefined) return true;
|
||||
// If no workspace context, show all (backward compatible)
|
||||
if (!context?.workspace_id || !context?.modules?.length) return true;
|
||||
return visibleModuleKeys.has(moduleKey);
|
||||
}, [context, visibleModuleKeys]);
|
||||
return isModuleVisibleFromStore(moduleKey, isSystemAdmin);
|
||||
}, [isModuleVisibleFromStore, isSystemAdmin]);
|
||||
|
||||
const visibleModuleKeys = useWorkspaceStore(s => s.visibleModuleKeys());
|
||||
|
||||
return {
|
||||
activeWorkspaceId,
|
||||
activeWorkspace: context,
|
||||
myWorkspaces: myWorkspaces?.items || [],
|
||||
myWorkspaces: myWorkspacesFromStore,
|
||||
switchWorkspace,
|
||||
isModuleVisible,
|
||||
visibleModuleKeys,
|
||||
hasWorkspaces: (myWorkspaces?.items?.length || 0) > 0,
|
||||
hasWorkspaces: myWorkspacesFromStore.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export function SettingsPage() {
|
||||
{ to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' },
|
||||
{ to: '/settings/custom-fields', label: 'Custom Fields', icon: '\ud83d\udccb' },
|
||||
{ to: '/settings/webhooks', label: 'Webhooks', icon: '\ud83d\udd14' },
|
||||
{ to: '/settings/workspaces', label: 'Workspaces', icon: '\ud83d\udd58\ufe0f' },
|
||||
{ to: '/settings/backup', label: 'Backup & Restore', icon: '\ud83d\udcbe' },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { WorkspaceManager } from '@/components/settings/WorkspaceManager';
|
||||
|
||||
export function WorkspaceManagerPage() {
|
||||
return (
|
||||
<div data-testid="settings-workspaces">
|
||||
<WorkspaceManager />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,7 @@ const CustomFieldsPage = React.lazy(() => import('@/pages/CustomFields').then(m
|
||||
const ActivityTimelinePage = React.lazy(() => import('@/pages/ActivityTimeline').then(m => ({ default: m.ActivityTimelinePage })));
|
||||
const SettingsWebhooksPage = React.lazy(() => import('@/pages/SettingsWebhooks').then(m => ({ default: m.SettingsWebhooksPage })));
|
||||
const SettingsBackupPage = React.lazy(() => import('@/pages/SettingsBackup').then(m => ({ default: m.SettingsBackupPage })));
|
||||
const WorkspaceManagerPage = React.lazy(() => import('@/pages/SettingsWorkspaces').then(m => ({ default: m.WorkspaceManagerPage })));
|
||||
const SettingsRechtePage = React.lazy(() => import('@/pages/SettingsRechte').then(m => ({ default: m.SettingsRechtePage })));
|
||||
const NoAccessPage = React.lazy(() => import('@/pages/NoAccessPage').then(m => ({ default: m.NoAccessPage })));
|
||||
const StartPage = React.lazy(() => import('@/pages/StartPage').then(m => ({ default: m.StartPage })));
|
||||
@@ -193,6 +194,7 @@ const router = createBrowserRouter([
|
||||
{ path: 'menu', element: withSuspense(<SettingsMenuOrderPage />) },
|
||||
{ path: 'custom-fields', element: withSuspense(<CustomFieldsPage />) },
|
||||
{ path: 'webhooks', element: withSuspense(<SettingsWebhooksPage />) },
|
||||
{ path: 'workspaces', element: withSuspense(<WorkspaceManagerPage />) },
|
||||
{ path: 'backup', element: withSuspense(<SettingsBackupPage />) },
|
||||
{ path: 'rechte', element: <PermissionRoute permission="settings:read">{withSuspense(<SettingsRechtePage />)}</PermissionRoute> },
|
||||
{ path: '*', element: <PluginRouteRenderer /> },
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { useWorkspaceStore } from '@/store/workspaceStore';
|
||||
|
||||
// Mock sessionStorage
|
||||
const mockSessionStorage = {
|
||||
store: {} as Record<string, string>,
|
||||
getItem: vi.fn((key: string) => mockSessionStorage.store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => { mockSessionStorage.store[key] = value; }),
|
||||
removeItem: vi.fn((key: string) => { delete mockSessionStorage.store[key]; }),
|
||||
clear: vi.fn(() => { mockSessionStorage.store = {}; }),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
value: mockSessionStorage,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock the API client import
|
||||
vi.mock('@/api/client', () => ({
|
||||
setActiveWorkspaceId: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('workspaceStore', () => {
|
||||
beforeEach(() => {
|
||||
mockSessionStorage.clear();
|
||||
useWorkspaceStore.getState().reset();
|
||||
});
|
||||
|
||||
it('initializes with no active workspace', () => {
|
||||
expect(useWorkspaceStore.getState().activeWorkspaceId).toBeNull();
|
||||
expect(useWorkspaceStore.getState().context).toBeNull();
|
||||
expect(useWorkspaceStore.getState().myWorkspaces).toEqual([]);
|
||||
});
|
||||
|
||||
it('setActiveWorkspace stores ID in sessionStorage', () => {
|
||||
const wsId = 'test-workspace-id';
|
||||
useWorkspaceStore.getState().setActiveWorkspace(wsId);
|
||||
expect(useWorkspaceStore.getState().activeWorkspaceId).toBe(wsId);
|
||||
expect(mockSessionStorage.setItem).toHaveBeenCalledWith('leocrm-active-workspace', wsId);
|
||||
});
|
||||
|
||||
it('setActiveWorkspace(null) removes from sessionStorage', () => {
|
||||
useWorkspaceStore.getState().setActiveWorkspace('test-id');
|
||||
useWorkspaceStore.getState().setActiveWorkspace(null);
|
||||
expect(useWorkspaceStore.getState().activeWorkspaceId).toBeNull();
|
||||
expect(mockSessionStorage.removeItem).toHaveBeenCalledWith('leocrm-active-workspace');
|
||||
});
|
||||
|
||||
it('setActiveWorkspace clears context on switch', () => {
|
||||
useWorkspaceStore.getState().setContext({
|
||||
workspace_id: 'old-id',
|
||||
modules: [{ module_key: 'contacts', menu_order: 0, config: {} }],
|
||||
widgets: [],
|
||||
});
|
||||
useWorkspaceStore.getState().setActiveWorkspace('new-id');
|
||||
expect(useWorkspaceStore.getState().context).toBeNull();
|
||||
});
|
||||
|
||||
it('isModuleVisible returns true when no workspace context (backward compatible)', () => {
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('contacts')).toBe(true);
|
||||
});
|
||||
|
||||
it('isModuleVisible returns true for system admin', () => {
|
||||
useWorkspaceStore.getState().setContext({
|
||||
workspace_id: 'ws-1',
|
||||
modules: [{ module_key: 'contacts', menu_order: 0, config: {} }],
|
||||
widgets: [],
|
||||
});
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('mail', true)).toBe(true);
|
||||
});
|
||||
|
||||
it('isModuleVisible returns false for module not in workspace', () => {
|
||||
useWorkspaceStore.getState().setContext({
|
||||
workspace_id: 'ws-1',
|
||||
modules: [{ module_key: 'contacts', menu_order: 0, config: {} }],
|
||||
widgets: [],
|
||||
});
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('mail')).toBe(false);
|
||||
});
|
||||
|
||||
it('isModuleVisible returns true for module in workspace', () => {
|
||||
useWorkspaceStore.getState().setContext({
|
||||
workspace_id: 'ws-1',
|
||||
modules: [
|
||||
{ module_key: 'contacts', menu_order: 0, config: {} },
|
||||
{ module_key: 'calendar', menu_order: 1, config: {} },
|
||||
],
|
||||
widgets: [],
|
||||
});
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('contacts')).toBe(true);
|
||||
expect(useWorkspaceStore.getState().isModuleVisible('calendar')).toBe(true);
|
||||
});
|
||||
|
||||
it('visibleModuleKeys returns set of visible module keys', () => {
|
||||
useWorkspaceStore.getState().setContext({
|
||||
workspace_id: 'ws-1',
|
||||
modules: [
|
||||
{ module_key: 'contacts', menu_order: 0, config: {} },
|
||||
{ module_key: 'calendar', menu_order: 1, config: {} },
|
||||
],
|
||||
widgets: [],
|
||||
});
|
||||
const keys = useWorkspaceStore.getState().visibleModuleKeys();
|
||||
expect(keys.has('contacts')).toBe(true);
|
||||
expect(keys.has('calendar')).toBe(true);
|
||||
expect(keys.has('mail')).toBe(false);
|
||||
});
|
||||
|
||||
it('hasWorkspaces returns false when empty', () => {
|
||||
expect(useWorkspaceStore.getState().hasWorkspaces()).toBe(false);
|
||||
});
|
||||
|
||||
it('hasWorkspaces returns true when workspaces exist', () => {
|
||||
useWorkspaceStore.getState().setMyWorkspaces([
|
||||
{ id: 'ws-1', name: 'WS1', icon: 'LayoutGrid', description: null, is_default: false, is_active: true },
|
||||
]);
|
||||
expect(useWorkspaceStore.getState().hasWorkspaces()).toBe(true);
|
||||
});
|
||||
|
||||
it('reset clears all state', () => {
|
||||
useWorkspaceStore.getState().setActiveWorkspace('test-id');
|
||||
useWorkspaceStore.getState().setMyWorkspaces([
|
||||
{ id: 'ws-1', name: 'WS1', icon: 'LayoutGrid', description: null, is_default: false, is_active: true },
|
||||
]);
|
||||
useWorkspaceStore.getState().setContext({
|
||||
workspace_id: 'ws-1',
|
||||
modules: [],
|
||||
widgets: [],
|
||||
});
|
||||
useWorkspaceStore.getState().reset();
|
||||
expect(useWorkspaceStore.getState().activeWorkspaceId).toBeNull();
|
||||
expect(useWorkspaceStore.getState().context).toBeNull();
|
||||
expect(useWorkspaceStore.getState().myWorkspaces).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { setActiveWorkspaceId as setApiClientWorkspaceId } from '@/api/client';
|
||||
|
||||
export interface WorkspaceModuleConfig {
|
||||
module_key: string;
|
||||
is_visible: boolean;
|
||||
menu_order: number;
|
||||
config: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface WorkspaceWidgetConfig {
|
||||
id: string;
|
||||
widget_key: string;
|
||||
position_x: number;
|
||||
position_y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
config: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface WorkspaceInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string | null;
|
||||
is_default: boolean;
|
||||
is_active: boolean;
|
||||
role?: 'member' | 'manager' | 'admin';
|
||||
is_user_default?: boolean;
|
||||
modules?: WorkspaceModuleConfig[];
|
||||
}
|
||||
|
||||
export interface WorkspaceContextState {
|
||||
workspace_id: string | null;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
role?: string;
|
||||
modules: { module_key: string; menu_order: number; config: Record<string, any> }[];
|
||||
widgets: WorkspaceWidgetConfig[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface WorkspaceStoreState {
|
||||
// Active workspace ID (per-tab via sessionStorage)
|
||||
activeWorkspaceId: string | null;
|
||||
// Cached context
|
||||
context: WorkspaceContextState | null;
|
||||
// Available workspaces for the user
|
||||
myWorkspaces: WorkspaceInfo[];
|
||||
// Loading states
|
||||
isLoading: boolean;
|
||||
// Actions
|
||||
setActiveWorkspace: (id: string | null) => void;
|
||||
setContext: (ctx: WorkspaceContextState | null) => void;
|
||||
setMyWorkspaces: (workspaces: WorkspaceInfo[]) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
// Helpers
|
||||
isModuleVisible: (moduleKey: string, isSystemAdmin?: boolean) => boolean;
|
||||
visibleModuleKeys: () => Set<string>;
|
||||
hasWorkspaces: () => boolean;
|
||||
// Reset
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const SESSION_STORAGE_KEY = 'leocrm-active-workspace';
|
||||
|
||||
export const useWorkspaceStore = create<WorkspaceStoreState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
activeWorkspaceId: sessionStorage.getItem(SESSION_STORAGE_KEY),
|
||||
context: null,
|
||||
myWorkspaces: [],
|
||||
isLoading: false,
|
||||
|
||||
setActiveWorkspace: (id) => {
|
||||
if (id) {
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, id);
|
||||
} else {
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
}
|
||||
setApiClientWorkspaceId(id);
|
||||
set({ activeWorkspaceId: id, context: null });
|
||||
},
|
||||
|
||||
setContext: (ctx) => set({ context: ctx }),
|
||||
|
||||
setMyWorkspaces: (workspaces) => set({ myWorkspaces: workspaces }),
|
||||
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
|
||||
isModuleVisible: (moduleKey: string, isSystemAdmin?: boolean) => {
|
||||
// System admins see all modules regardless of workspace
|
||||
if (isSystemAdmin) return true;
|
||||
// If no workspace context, show all (backward compatible)
|
||||
const ctx = get().context;
|
||||
if (!ctx?.workspace_id || !ctx?.modules?.length) return true;
|
||||
return get().visibleModuleKeys().has(moduleKey);
|
||||
},
|
||||
|
||||
visibleModuleKeys: () => {
|
||||
const ctx = get().context;
|
||||
return new Set(ctx?.modules?.map(m => m.module_key) || []);
|
||||
},
|
||||
|
||||
hasWorkspaces: () => get().myWorkspaces.length > 0,
|
||||
|
||||
reset: () => {
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
set({ activeWorkspaceId: null, context: null, myWorkspaces: [], isLoading: false });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'leocrm-workspace-store',
|
||||
storage: createJSONStorage(() => sessionStorage),
|
||||
partialize: (state) => ({ activeWorkspaceId: state.activeWorkspaceId }),
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user