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,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