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
@@ -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 }),
}
)
);