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:
@@ -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