feat(rbac): unified session store, deny-by-default guards, and tenant isolation
This commit is contained in:
parent
ffc5646ff5
commit
fcabd60523
8 changed files with 440 additions and 85 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import React from 'react';
|
||||
import { useSessionStore } from '../../core/session/sessionStore';
|
||||
import { useDemoStore } from '../../stores/demo';
|
||||
import { SYSTEM_ROLES, SystemRoleId } from '../../core/permissions/types';
|
||||
import { BUSINESS_PROFILES } from '../../core/profiles/profiles';
|
||||
|
|
@ -19,7 +20,8 @@ export const RouteGuard: React.FC<RouteGuardProps> = ({
|
|||
requiredCapability,
|
||||
children,
|
||||
}) => {
|
||||
const { currentRoleId, currentProfileId, stateOverride } = useDemoStore();
|
||||
const { currentRoleId, currentProfileId, capabilities, hasModulePermission } = useSessionStore();
|
||||
const { stateOverride } = useDemoStore();
|
||||
|
||||
// 1. Check Demo State Override
|
||||
if (stateOverride === 'permission_denied') {
|
||||
|
|
@ -34,29 +36,26 @@ export const RouteGuard: React.FC<RouteGuardProps> = ({
|
|||
throw new Error(`[Демо-ошибка]: Имитация сбоя при загрузке раздела «${moduleName || module}».`);
|
||||
}
|
||||
|
||||
// 2. Check RBAC Role permissions
|
||||
// 2. Strict deny-by-default role permission check
|
||||
const role = SYSTEM_ROLES[currentRoleId as SystemRoleId];
|
||||
if (role) {
|
||||
const isSuperRole = role.id === 'owner' || role.id === 'administrator' || role.allowedModules.includes('*');
|
||||
if (!isSuperRole && !role.allowedModules.includes(module)) {
|
||||
return (
|
||||
<ForbiddenPage
|
||||
moduleName={moduleName || module}
|
||||
requiredPermission={`Доступ к модулю «${module}» для роли «${role.label}»`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!role || !hasModulePermission(module)) {
|
||||
return (
|
||||
<ForbiddenPage
|
||||
moduleName={moduleName || module}
|
||||
requiredPermission={`Доступ к модулю «${module}» для роли «${role?.label || 'Не определена'}»`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Check Business Profile Capabilities
|
||||
const profile = BUSINESS_PROFILES[currentProfileId];
|
||||
if (profile && requiredCapability) {
|
||||
const isCapabilityEnabled = profile.capabilities[requiredCapability];
|
||||
if (requiredCapability) {
|
||||
const isCapabilityEnabled = capabilities ? capabilities[requiredCapability] : profile?.capabilities[requiredCapability];
|
||||
if (isCapabilityEnabled === false) {
|
||||
return (
|
||||
<ForbiddenPage
|
||||
moduleName={moduleName || module}
|
||||
requiredPermission={`Возможность «${requiredCapability}» в бизнес-профиле «${profile.name}»`}
|
||||
requiredPermission={`Возможность «${requiredCapability}» в бизнес-профиле «${profile?.name || currentProfileId}»`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
PRESET_TENANTS,
|
||||
TenantContextValue,
|
||||
} from './tenantContext';
|
||||
import { useSessionStore } from '../../core/session/sessionStore';
|
||||
|
||||
export interface TenantProviderProps {
|
||||
children: React.ReactNode;
|
||||
|
|
@ -20,15 +21,24 @@ export const TenantProvider: React.FC<TenantProviderProps> = ({
|
|||
children,
|
||||
initialTenantId,
|
||||
}) => {
|
||||
const sessionTenantId = useSessionStore((s) => s.activeTenantId);
|
||||
const [currentTenantId, setCurrentTenantId] = useState<string>(() => {
|
||||
return initialTenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID;
|
||||
return initialTenantId || sessionTenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID;
|
||||
});
|
||||
|
||||
const switchTenant = useCallback((tenantId: string) => {
|
||||
setCurrentTenantId(tenantId);
|
||||
setActiveGlobalTenantId(tenantId);
|
||||
useSessionStore.getState().setActiveTenantId(tenantId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionTenantId && sessionTenantId !== currentTenantId) {
|
||||
setCurrentTenantId(sessionTenantId);
|
||||
setActiveGlobalTenantId(sessionTenantId);
|
||||
}
|
||||
}, [sessionTenantId, currentTenantId]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveGlobalTenantId(currentTenantId);
|
||||
}, [currentTenantId]);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useDemoStore } from '../../mock/store';
|
||||
import { useSessionStore } from '../../core/session/sessionStore';
|
||||
import { useMetadata } from '../../services/hooks/useMetadata';
|
||||
import { SYSTEM_MODULES } from '../../core/modules/registry';
|
||||
import { SYSTEM_ROLES } from '../../core/permissions/types';
|
||||
|
|
@ -55,19 +55,13 @@ export interface SidebarProps {
|
|||
export const Sidebar: React.FC<SidebarProps> = ({ isTabletCompact = false }) => {
|
||||
const [userCollapsed, setUserCollapsed] = useState<boolean | null>(null);
|
||||
const collapsed = userCollapsed !== null ? userCollapsed : isTabletCompact;
|
||||
const { currentRoleId } = useDemoStore();
|
||||
const { currentRoleId, hasModulePermission } = useSessionStore();
|
||||
const { entities } = useMetadata();
|
||||
const customEntities = entities.filter((e) => !e.isSystem);
|
||||
const role = SYSTEM_ROLES[currentRoleId];
|
||||
|
||||
// Filter modules by role permissions (industry profile does not hide modules)
|
||||
const visibleModules = SYSTEM_MODULES.filter((m) => {
|
||||
// Role check
|
||||
if (!role.allowedModules.includes('*') && !role.allowedModules.includes(m.id)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// Filter modules by role permissions (strict deny-by-default via sessionStore)
|
||||
const visibleModules = SYSTEM_MODULES.filter((m) => hasModulePermission(m.id));
|
||||
|
||||
return (
|
||||
<aside
|
||||
|
|
|
|||
234
src/core/session/sessionStore.ts
Normal file
234
src/core/session/sessionStore.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { create } from 'zustand';
|
||||
import { SystemRoleId, SYSTEM_ROLES } from '../permissions/types';
|
||||
import { CapabilityId } from '../capabilities/types';
|
||||
import { BUSINESS_PROFILES } from '../profiles/profiles';
|
||||
import { BusinessProfile } from '../profiles/types';
|
||||
import { setActiveGlobalTenantId, getActiveGlobalTenantId } from '../../services/contracts/context';
|
||||
|
||||
export interface SessionState {
|
||||
activeTenantId: string;
|
||||
activeOrgId: string;
|
||||
currentUserId: string;
|
||||
currentRoleId: SystemRoleId;
|
||||
currentProfileId: string;
|
||||
activeLocation: string;
|
||||
capabilities: Record<CapabilityId, boolean>;
|
||||
|
||||
// Actions
|
||||
setActiveTenantId: (tenantId: string) => void;
|
||||
setActiveOrgId: (orgId: string) => void;
|
||||
setCurrentUserId: (userId: string) => void;
|
||||
setCurrentRoleId: (roleId: SystemRoleId) => void;
|
||||
setCurrentProfileId: (profileId: string) => void;
|
||||
setActiveLocation: (location: string) => void;
|
||||
setCapabilities: (capabilities: Record<CapabilityId, boolean>) => void;
|
||||
toggleCapability: (id: CapabilityId) => void;
|
||||
resetCapabilitiesToProfile: () => void;
|
||||
resetSession: () => void;
|
||||
|
||||
// RBAC & Permission helpers (Strict deny-by-default)
|
||||
hasModulePermission: (moduleId: string) => boolean;
|
||||
getActiveProfile: () => BusinessProfile;
|
||||
getActiveCapabilities: () => Record<CapabilityId, boolean>;
|
||||
}
|
||||
|
||||
const DEFAULT_PROFILE_ID = 'restaurant_hall_delivery';
|
||||
const DEFAULT_ROLE_ID: SystemRoleId = 'owner';
|
||||
const DEFAULT_TENANT_ID = 'tenant_main';
|
||||
const DEFAULT_ORG_ID = 'org_main';
|
||||
const DEFAULT_USER_ID = 'user_admin';
|
||||
|
||||
function getInitialProfileId(): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('krona_profile_id');
|
||||
if (saved && BUSINESS_PROFILES[saved]) return saved;
|
||||
}
|
||||
return DEFAULT_PROFILE_ID;
|
||||
}
|
||||
|
||||
function getInitialRoleId(): SystemRoleId {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('krona_role_id') as SystemRoleId;
|
||||
if (saved && SYSTEM_ROLES[saved]) return saved;
|
||||
}
|
||||
return DEFAULT_ROLE_ID;
|
||||
}
|
||||
|
||||
function getInitialTenantId(): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('krona_tenant_id') || localStorage.getItem('krona_active_tenant_id');
|
||||
if (saved) return saved;
|
||||
}
|
||||
return getActiveGlobalTenantId() || DEFAULT_TENANT_ID;
|
||||
}
|
||||
|
||||
function getInitialOrgId(): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('krona_org_id') || localStorage.getItem('krona_active_org_id');
|
||||
if (saved) return saved;
|
||||
}
|
||||
return DEFAULT_ORG_ID;
|
||||
}
|
||||
|
||||
function getInitialUserId(): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('krona_user_id') || localStorage.getItem('krona_current_user_id');
|
||||
if (saved) return saved;
|
||||
}
|
||||
return DEFAULT_USER_ID;
|
||||
}
|
||||
|
||||
function getInitialLocation(profileId: string): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('krona_location') || localStorage.getItem('krona_active_location');
|
||||
if (saved) return saved;
|
||||
}
|
||||
const prof = BUSINESS_PROFILES[profileId] || BUSINESS_PROFILES[DEFAULT_PROFILE_ID];
|
||||
return prof.locations[0] || 'Горский 1/1';
|
||||
}
|
||||
|
||||
function getInitialCapabilities(profileId: string): Record<CapabilityId, boolean> {
|
||||
const prof = BUSINESS_PROFILES[profileId] || BUSINESS_PROFILES[DEFAULT_PROFILE_ID];
|
||||
return { ...prof.capabilities };
|
||||
}
|
||||
|
||||
export const useSessionStore = create<SessionState>((set, get) => {
|
||||
const initialProfileId = getInitialProfileId();
|
||||
const initialTenantId = getInitialTenantId();
|
||||
|
||||
// Sync global tenant id on startup
|
||||
setActiveGlobalTenantId(initialTenantId);
|
||||
|
||||
return {
|
||||
activeTenantId: initialTenantId,
|
||||
activeOrgId: getInitialOrgId(),
|
||||
currentUserId: getInitialUserId(),
|
||||
currentRoleId: getInitialRoleId(),
|
||||
currentProfileId: initialProfileId,
|
||||
activeLocation: getInitialLocation(initialProfileId),
|
||||
capabilities: getInitialCapabilities(initialProfileId),
|
||||
|
||||
setActiveTenantId: (activeTenantId: string) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('krona_tenant_id', activeTenantId);
|
||||
localStorage.setItem('krona_active_tenant_id', activeTenantId);
|
||||
}
|
||||
setActiveGlobalTenantId(activeTenantId);
|
||||
set({ activeTenantId });
|
||||
},
|
||||
|
||||
setActiveOrgId: (activeOrgId: string) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('krona_org_id', activeOrgId);
|
||||
localStorage.setItem('krona_active_org_id', activeOrgId);
|
||||
}
|
||||
set({ activeOrgId });
|
||||
},
|
||||
|
||||
setCurrentUserId: (currentUserId: string) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('krona_user_id', currentUserId);
|
||||
localStorage.setItem('krona_current_user_id', currentUserId);
|
||||
}
|
||||
set({ currentUserId });
|
||||
},
|
||||
|
||||
setCurrentRoleId: (currentRoleId: SystemRoleId) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('krona_role_id', currentRoleId);
|
||||
}
|
||||
set({ currentRoleId });
|
||||
},
|
||||
|
||||
setCurrentProfileId: (currentProfileId: string) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('krona_profile_id', currentProfileId);
|
||||
}
|
||||
const profile = BUSINESS_PROFILES[currentProfileId] || BUSINESS_PROFILES[DEFAULT_PROFILE_ID];
|
||||
const newLoc = profile.locations[0] || 'Главная точка';
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('krona_location', newLoc);
|
||||
}
|
||||
set({
|
||||
currentProfileId,
|
||||
capabilities: { ...profile.capabilities },
|
||||
activeLocation: newLoc,
|
||||
});
|
||||
},
|
||||
|
||||
setActiveLocation: (activeLocation: string) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('krona_location', activeLocation);
|
||||
localStorage.setItem('krona_active_location', activeLocation);
|
||||
}
|
||||
set({ activeLocation });
|
||||
},
|
||||
|
||||
setCapabilities: (capabilities: Record<CapabilityId, boolean>) => {
|
||||
set({ capabilities });
|
||||
},
|
||||
|
||||
toggleCapability: (id: CapabilityId) => {
|
||||
set((state) => ({
|
||||
capabilities: {
|
||||
...state.capabilities,
|
||||
[id]: !state.capabilities[id],
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
resetCapabilitiesToProfile: () => {
|
||||
const state = get();
|
||||
const profile = BUSINESS_PROFILES[state.currentProfileId] || BUSINESS_PROFILES[DEFAULT_PROFILE_ID];
|
||||
set({ capabilities: { ...profile.capabilities } });
|
||||
},
|
||||
|
||||
resetSession: () => {
|
||||
const defaultProf = BUSINESS_PROFILES[DEFAULT_PROFILE_ID];
|
||||
setActiveGlobalTenantId(DEFAULT_TENANT_ID);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('krona_tenant_id');
|
||||
localStorage.removeItem('krona_active_tenant_id');
|
||||
localStorage.removeItem('krona_org_id');
|
||||
localStorage.removeItem('krona_active_org_id');
|
||||
localStorage.removeItem('krona_user_id');
|
||||
localStorage.removeItem('krona_current_user_id');
|
||||
localStorage.removeItem('krona_role_id');
|
||||
localStorage.removeItem('krona_profile_id');
|
||||
localStorage.removeItem('krona_location');
|
||||
}
|
||||
set({
|
||||
activeTenantId: DEFAULT_TENANT_ID,
|
||||
activeOrgId: DEFAULT_ORG_ID,
|
||||
currentUserId: DEFAULT_USER_ID,
|
||||
currentRoleId: DEFAULT_ROLE_ID,
|
||||
currentProfileId: DEFAULT_PROFILE_ID,
|
||||
activeLocation: defaultProf.locations[0] || 'Горский 1/1',
|
||||
capabilities: { ...defaultProf.capabilities },
|
||||
});
|
||||
},
|
||||
|
||||
// Strict deny-by-default RBAC check
|
||||
hasModulePermission: (moduleId: string) => {
|
||||
const state = get();
|
||||
const role = SYSTEM_ROLES[state.currentRoleId];
|
||||
if (!role) return false;
|
||||
|
||||
// Super roles or full wildcard
|
||||
if (role.id === 'owner' || role.id === 'administrator' || role.allowedModules.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return role.allowedModules.includes(moduleId);
|
||||
},
|
||||
|
||||
getActiveProfile: () => {
|
||||
const state = get();
|
||||
return BUSINESS_PROFILES[state.currentProfileId] || BUSINESS_PROFILES[DEFAULT_PROFILE_ID];
|
||||
},
|
||||
|
||||
getActiveCapabilities: () => {
|
||||
return get().capabilities;
|
||||
},
|
||||
};
|
||||
});
|
||||
127
src/features/__tests__/rbacSessionIntegrity.test.tsx
Normal file
127
src/features/__tests__/rbacSessionIntegrity.test.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { RouteGuard } from '../../app/guards/RouteGuard';
|
||||
import { Sidebar } from '../../app/shell/Sidebar';
|
||||
import { useSessionStore } from '../../core/session/sessionStore';
|
||||
import { useDemoStore } from '../../stores/demo';
|
||||
import { createRequestContext } from '../../services/contracts/context';
|
||||
|
||||
describe('RBAC & Session Integrity Suite (F01)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
act(() => {
|
||||
useSessionStore.getState().resetSession();
|
||||
useDemoStore.getState().resetDemoControls();
|
||||
});
|
||||
});
|
||||
|
||||
const renderWithRouter = (ui: React.ReactElement) => {
|
||||
return render(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
};
|
||||
|
||||
it('1. Hidden in navigation: restricted modules are omitted from Sidebar for restricted roles', () => {
|
||||
// Cook role is only permitted to access 'operations'
|
||||
act(() => {
|
||||
useSessionStore.getState().setCurrentRoleId('cook');
|
||||
});
|
||||
|
||||
renderWithRouter(<Sidebar />);
|
||||
|
||||
// 'Операции' should be visible
|
||||
expect(screen.getByText('Операции')).toBeInTheDocument();
|
||||
|
||||
// Restricted modules must NOT be rendered in Sidebar
|
||||
expect(screen.queryByText('Финансы')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Клиенты и CRM')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Складской учёт')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Персонал и HR')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Маркетинг')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('2. Direct navigation to forbidden URL renders 403 Forbidden with strict deny-by-default', () => {
|
||||
// Cashier role only has ['operations', 'sales', 'crm'], lacking 'finance'
|
||||
act(() => {
|
||||
useSessionStore.getState().setCurrentRoleId('cashier');
|
||||
});
|
||||
|
||||
renderWithRouter(
|
||||
<RouteGuard module="finance" moduleName="Финансы и казначейство">
|
||||
<div data-testid="finance-secret-data">Конфиденциальные финансовые данные</div>
|
||||
</RouteGuard>
|
||||
);
|
||||
|
||||
// Protected content must not be present
|
||||
expect(screen.queryByTestId('finance-secret-data')).not.toBeInTheDocument();
|
||||
|
||||
// 403 Forbidden page is rendered
|
||||
expect(screen.getByTestId('forbidden-page')).toBeInTheDocument();
|
||||
expect(screen.getByText(/403 Доступ ограничен/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Нет прав на просмотр «Финансы и казначейство»/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('3. Immediate reactivity: switching role immediately unblocks/blocks RouteGuard without delay', () => {
|
||||
act(() => {
|
||||
useSessionStore.getState().setCurrentRoleId('cook');
|
||||
});
|
||||
|
||||
const { rerender } = renderWithRouter(
|
||||
<RouteGuard module="crm" moduleName="Клиенты и CRM">
|
||||
<div data-testid="crm-content">CRM База клиентов</div>
|
||||
</RouteGuard>
|
||||
);
|
||||
|
||||
// Cook is blocked
|
||||
expect(screen.queryByTestId('crm-content')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('forbidden-page')).toBeInTheDocument();
|
||||
|
||||
// Immediately switch role to 'owner'
|
||||
act(() => {
|
||||
useSessionStore.getState().setCurrentRoleId('owner');
|
||||
});
|
||||
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<RouteGuard module="crm" moduleName="Клиенты и CRM">
|
||||
<div data-testid="crm-content">CRM База клиентов</div>
|
||||
</RouteGuard>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Now immediately accessible
|
||||
expect(screen.getByTestId('crm-content')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('forbidden-page')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('4. Multi-tenant and Organization isolation: switching tenant updates context and isolates session', () => {
|
||||
// Default tenant is 'tenant_main'
|
||||
expect(useSessionStore.getState().activeTenantId).toBe('tenant_main');
|
||||
|
||||
const defaultContext = createRequestContext();
|
||||
expect(defaultContext.tenantId).toBe('tenant_main');
|
||||
|
||||
// Switch tenant to 'tenant_alpha'
|
||||
act(() => {
|
||||
useSessionStore.getState().setActiveTenantId('tenant_alpha');
|
||||
useSessionStore.getState().setActiveOrgId('org_alpha_north');
|
||||
});
|
||||
|
||||
expect(useSessionStore.getState().activeTenantId).toBe('tenant_alpha');
|
||||
expect(useSessionStore.getState().activeOrgId).toBe('org_alpha_north');
|
||||
|
||||
const alphaContext = createRequestContext();
|
||||
expect(alphaContext.tenantId).toBe('tenant_alpha');
|
||||
|
||||
// Switch tenant to 'tenant_beta'
|
||||
act(() => {
|
||||
useSessionStore.getState().setActiveTenantId('tenant_beta');
|
||||
});
|
||||
|
||||
expect(useSessionStore.getState().activeTenantId).toBe('tenant_beta');
|
||||
const betaContext = createRequestContext();
|
||||
expect(betaContext.tenantId).toBe('tenant_beta');
|
||||
expect(betaContext.tenantId).not.toBe(alphaContext.tenantId);
|
||||
});
|
||||
});
|
||||
|
|
@ -338,6 +338,9 @@ const INITIAL_NOTIFICATIONS: AppNotification[] = [
|
|||
},
|
||||
];
|
||||
|
||||
import { useSessionStore } from '../core/session/sessionStore';
|
||||
export { useSessionStore };
|
||||
|
||||
export const useDemoStore = create<DemoStore>((set, get) => ({
|
||||
// Theme & Viewport defaults
|
||||
theme:
|
||||
|
|
@ -356,48 +359,27 @@ export const useDemoStore = create<DemoStore>((set, get) => ({
|
|||
viewportMode: 'desktop',
|
||||
setViewportMode: (viewportMode) => set({ viewportMode }),
|
||||
|
||||
// Profile & Role defaults
|
||||
currentProfileId:
|
||||
(typeof window !== 'undefined' && localStorage.getItem('krona_profile_id')) ||
|
||||
'restaurant_hall_delivery',
|
||||
// Profile & Role defaults (Delegated to canonical useSessionStore)
|
||||
currentProfileId: useSessionStore.getState().currentProfileId,
|
||||
setProfileId: (currentProfileId) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('krona_profile_id', currentProfileId);
|
||||
}
|
||||
const profile = BUSINESS_PROFILES[currentProfileId] || BUSINESS_PROFILES.restaurant_hall_delivery;
|
||||
set({
|
||||
currentProfileId,
|
||||
customCapabilities: { ...profile.capabilities },
|
||||
activeLocation: profile.locations[0] || 'Главная точка',
|
||||
});
|
||||
useDemoControlsStore.getState().setCurrentProfileId(currentProfileId);
|
||||
useSessionStore.getState().setCurrentProfileId(currentProfileId);
|
||||
},
|
||||
currentRoleId:
|
||||
(typeof window !== 'undefined' && (localStorage.getItem('krona_role_id') as SystemRoleId)) ||
|
||||
'owner',
|
||||
currentRoleId: useSessionStore.getState().currentRoleId,
|
||||
setRoleId: (currentRoleId) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('krona_role_id', currentRoleId);
|
||||
}
|
||||
set({ currentRoleId });
|
||||
useDemoControlsStore.getState().setCurrentRoleId(currentRoleId);
|
||||
useSessionStore.getState().setCurrentRoleId(currentRoleId);
|
||||
},
|
||||
activeLocation: useSessionStore.getState().activeLocation,
|
||||
setActiveLocation: (activeLocation) => {
|
||||
useSessionStore.getState().setActiveLocation(activeLocation);
|
||||
},
|
||||
activeLocation: 'Горский 1/1',
|
||||
setActiveLocation: (activeLocation) => set({ activeLocation }),
|
||||
|
||||
// Capabilities
|
||||
customCapabilities: { ...BUSINESS_PROFILES.restaurant_hall_delivery.capabilities },
|
||||
toggleCapability: (id) =>
|
||||
set((state) => ({
|
||||
customCapabilities: {
|
||||
...state.customCapabilities,
|
||||
[id]: !state.customCapabilities[id],
|
||||
},
|
||||
})),
|
||||
// Capabilities (Delegated to canonical useSessionStore)
|
||||
customCapabilities: useSessionStore.getState().capabilities,
|
||||
toggleCapability: (id) => {
|
||||
useSessionStore.getState().toggleCapability(id);
|
||||
},
|
||||
resetCapabilitiesToProfile: () => {
|
||||
const state = get();
|
||||
const profile = BUSINESS_PROFILES[state.currentProfileId] || BUSINESS_PROFILES.restaurant_hall_delivery;
|
||||
set({ customCapabilities: { ...profile.capabilities } });
|
||||
useSessionStore.getState().resetCapabilitiesToProfile();
|
||||
},
|
||||
|
||||
// Panels
|
||||
|
|
@ -574,10 +556,18 @@ export const useDemoStore = create<DemoStore>((set, get) => ({
|
|||
|
||||
// Getters
|
||||
getActiveProfile: () => {
|
||||
const s = get();
|
||||
return BUSINESS_PROFILES[s.currentProfileId] || BUSINESS_PROFILES.restaurant_hall_delivery;
|
||||
return useSessionStore.getState().getActiveProfile();
|
||||
},
|
||||
getActiveCapabilities: () => {
|
||||
return get().customCapabilities;
|
||||
return useSessionStore.getState().getActiveCapabilities();
|
||||
},
|
||||
}));
|
||||
|
||||
useSessionStore.subscribe((session) => {
|
||||
useDemoStore.setState({
|
||||
currentRoleId: session.currentRoleId,
|
||||
currentProfileId: session.currentProfileId,
|
||||
activeLocation: session.activeLocation,
|
||||
customCapabilities: session.capabilities,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ import React from 'react';
|
|||
import { ShieldAlert, Home, RefreshCw } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../../shared/ui/Button';
|
||||
import { useDemoStore } from '../../stores/demo';
|
||||
import { useDemoStore as useMockStore } from '../../mock/store';
|
||||
import { useSessionStore } from '../../core/session/sessionStore';
|
||||
|
||||
export interface ForbiddenPageProps {
|
||||
requiredPermission?: string;
|
||||
|
|
@ -15,11 +14,10 @@ export const ForbiddenPage: React.FC<ForbiddenPageProps> = ({
|
|||
moduleName,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { currentRoleId, setCurrentRoleId } = useDemoStore();
|
||||
const { currentRoleId, setCurrentRoleId } = useSessionStore();
|
||||
|
||||
const handleSwitchToOwner = () => {
|
||||
setCurrentRoleId('owner');
|
||||
useMockStore.getState().setRoleId('owner');
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { create } from 'zustand';
|
||||
import { useSessionStore } from '../core/session/sessionStore';
|
||||
import { SystemRoleId } from '../core/permissions/types';
|
||||
|
||||
export type DemoStateOverride = 'normal' | 'loading' | 'empty' | 'error' | 'permission_denied';
|
||||
export type DemoErrorType = 'none' | 'network_error' | 'forbidden_403' | 'conflict_409' | 'validation_422';
|
||||
|
|
@ -32,12 +34,8 @@ interface DemoState {
|
|||
const DEFAULT_DEMO_DATE = '2026-09-10T10:00:00.000Z';
|
||||
|
||||
export const useDemoStore = create<DemoState>((set) => ({
|
||||
currentProfileId:
|
||||
(typeof window !== 'undefined' && localStorage.getItem('krona_profile_id')) ||
|
||||
'multi-business',
|
||||
currentRoleId:
|
||||
(typeof window !== 'undefined' && localStorage.getItem('krona_role_id')) ||
|
||||
'owner',
|
||||
currentProfileId: useSessionStore.getState().currentProfileId,
|
||||
currentRoleId: useSessionStore.getState().currentRoleId,
|
||||
simulateLatency: false,
|
||||
latencyMs: 300,
|
||||
simulateErrors: false,
|
||||
|
|
@ -48,15 +46,11 @@ export const useDemoStore = create<DemoState>((set) => ({
|
|||
selectedScenario: 'retail-flow',
|
||||
|
||||
setCurrentProfileId: (profileId) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('krona_profile_id', profileId);
|
||||
}
|
||||
useSessionStore.getState().setCurrentProfileId(profileId);
|
||||
set({ currentProfileId: profileId });
|
||||
},
|
||||
setCurrentRoleId: (roleId) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('krona_role_id', roleId);
|
||||
}
|
||||
useSessionStore.getState().setCurrentRoleId(roleId as SystemRoleId);
|
||||
set({ currentRoleId: roleId });
|
||||
},
|
||||
setSimulateLatency: (enable) => set({ simulateLatency: enable }),
|
||||
|
|
@ -73,10 +67,11 @@ export const useDemoStore = create<DemoState>((set) => ({
|
|||
setStateOverride: (override) => set({ stateOverride: override }),
|
||||
setShowMaturityBadges: (show) => set({ showMaturityBadges: show }),
|
||||
setSelectedScenario: (scenario) => set({ selectedScenario: scenario }),
|
||||
resetDemoControls: () =>
|
||||
resetDemoControls: () => {
|
||||
useSessionStore.getState().resetSession();
|
||||
set({
|
||||
currentProfileId: 'multi-business',
|
||||
currentRoleId: 'owner',
|
||||
currentProfileId: useSessionStore.getState().currentProfileId,
|
||||
currentRoleId: useSessionStore.getState().currentRoleId,
|
||||
simulateLatency: false,
|
||||
latencyMs: 300,
|
||||
simulateErrors: false,
|
||||
|
|
@ -85,5 +80,13 @@ export const useDemoStore = create<DemoState>((set) => ({
|
|||
stateOverride: 'normal',
|
||||
showMaturityBadges: true,
|
||||
selectedScenario: 'retail-flow',
|
||||
}),
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
useSessionStore.subscribe((session) => {
|
||||
useDemoStore.setState({
|
||||
currentRoleId: session.currentRoleId,
|
||||
currentProfileId: session.currentProfileId,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue