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 React from 'react';
|
||||||
|
import { useSessionStore } from '../../core/session/sessionStore';
|
||||||
import { useDemoStore } from '../../stores/demo';
|
import { useDemoStore } from '../../stores/demo';
|
||||||
import { SYSTEM_ROLES, SystemRoleId } from '../../core/permissions/types';
|
import { SYSTEM_ROLES, SystemRoleId } from '../../core/permissions/types';
|
||||||
import { BUSINESS_PROFILES } from '../../core/profiles/profiles';
|
import { BUSINESS_PROFILES } from '../../core/profiles/profiles';
|
||||||
|
|
@ -19,7 +20,8 @@ export const RouteGuard: React.FC<RouteGuardProps> = ({
|
||||||
requiredCapability,
|
requiredCapability,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const { currentRoleId, currentProfileId, stateOverride } = useDemoStore();
|
const { currentRoleId, currentProfileId, capabilities, hasModulePermission } = useSessionStore();
|
||||||
|
const { stateOverride } = useDemoStore();
|
||||||
|
|
||||||
// 1. Check Demo State Override
|
// 1. Check Demo State Override
|
||||||
if (stateOverride === 'permission_denied') {
|
if (stateOverride === 'permission_denied') {
|
||||||
|
|
@ -34,29 +36,26 @@ export const RouteGuard: React.FC<RouteGuardProps> = ({
|
||||||
throw new Error(`[Демо-ошибка]: Имитация сбоя при загрузке раздела «${moduleName || module}».`);
|
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];
|
const role = SYSTEM_ROLES[currentRoleId as SystemRoleId];
|
||||||
if (role) {
|
if (!role || !hasModulePermission(module)) {
|
||||||
const isSuperRole = role.id === 'owner' || role.id === 'administrator' || role.allowedModules.includes('*');
|
|
||||||
if (!isSuperRole && !role.allowedModules.includes(module)) {
|
|
||||||
return (
|
return (
|
||||||
<ForbiddenPage
|
<ForbiddenPage
|
||||||
moduleName={moduleName || module}
|
moduleName={moduleName || module}
|
||||||
requiredPermission={`Доступ к модулю «${module}» для роли «${role.label}»`}
|
requiredPermission={`Доступ к модулю «${module}» для роли «${role?.label || 'Не определена'}»`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Check Business Profile Capabilities
|
// 3. Check Business Profile Capabilities
|
||||||
const profile = BUSINESS_PROFILES[currentProfileId];
|
const profile = BUSINESS_PROFILES[currentProfileId];
|
||||||
if (profile && requiredCapability) {
|
if (requiredCapability) {
|
||||||
const isCapabilityEnabled = profile.capabilities[requiredCapability];
|
const isCapabilityEnabled = capabilities ? capabilities[requiredCapability] : profile?.capabilities[requiredCapability];
|
||||||
if (isCapabilityEnabled === false) {
|
if (isCapabilityEnabled === false) {
|
||||||
return (
|
return (
|
||||||
<ForbiddenPage
|
<ForbiddenPage
|
||||||
moduleName={moduleName || module}
|
moduleName={moduleName || module}
|
||||||
requiredPermission={`Возможность «${requiredCapability}» в бизнес-профиле «${profile.name}»`}
|
requiredPermission={`Возможность «${requiredCapability}» в бизнес-профиле «${profile?.name || currentProfileId}»`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
PRESET_TENANTS,
|
PRESET_TENANTS,
|
||||||
TenantContextValue,
|
TenantContextValue,
|
||||||
} from './tenantContext';
|
} from './tenantContext';
|
||||||
|
import { useSessionStore } from '../../core/session/sessionStore';
|
||||||
|
|
||||||
export interface TenantProviderProps {
|
export interface TenantProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
@ -20,15 +21,24 @@ export const TenantProvider: React.FC<TenantProviderProps> = ({
|
||||||
children,
|
children,
|
||||||
initialTenantId,
|
initialTenantId,
|
||||||
}) => {
|
}) => {
|
||||||
|
const sessionTenantId = useSessionStore((s) => s.activeTenantId);
|
||||||
const [currentTenantId, setCurrentTenantId] = useState<string>(() => {
|
const [currentTenantId, setCurrentTenantId] = useState<string>(() => {
|
||||||
return initialTenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID;
|
return initialTenantId || sessionTenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID;
|
||||||
});
|
});
|
||||||
|
|
||||||
const switchTenant = useCallback((tenantId: string) => {
|
const switchTenant = useCallback((tenantId: string) => {
|
||||||
setCurrentTenantId(tenantId);
|
setCurrentTenantId(tenantId);
|
||||||
setActiveGlobalTenantId(tenantId);
|
setActiveGlobalTenantId(tenantId);
|
||||||
|
useSessionStore.getState().setActiveTenantId(tenantId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionTenantId && sessionTenantId !== currentTenantId) {
|
||||||
|
setCurrentTenantId(sessionTenantId);
|
||||||
|
setActiveGlobalTenantId(sessionTenantId);
|
||||||
|
}
|
||||||
|
}, [sessionTenantId, currentTenantId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveGlobalTenantId(currentTenantId);
|
setActiveGlobalTenantId(currentTenantId);
|
||||||
}, [currentTenantId]);
|
}, [currentTenantId]);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { useDemoStore } from '../../mock/store';
|
import { useSessionStore } from '../../core/session/sessionStore';
|
||||||
import { useMetadata } from '../../services/hooks/useMetadata';
|
import { useMetadata } from '../../services/hooks/useMetadata';
|
||||||
import { SYSTEM_MODULES } from '../../core/modules/registry';
|
import { SYSTEM_MODULES } from '../../core/modules/registry';
|
||||||
import { SYSTEM_ROLES } from '../../core/permissions/types';
|
import { SYSTEM_ROLES } from '../../core/permissions/types';
|
||||||
|
|
@ -55,19 +55,13 @@ export interface SidebarProps {
|
||||||
export const Sidebar: React.FC<SidebarProps> = ({ isTabletCompact = false }) => {
|
export const Sidebar: React.FC<SidebarProps> = ({ isTabletCompact = false }) => {
|
||||||
const [userCollapsed, setUserCollapsed] = useState<boolean | null>(null);
|
const [userCollapsed, setUserCollapsed] = useState<boolean | null>(null);
|
||||||
const collapsed = userCollapsed !== null ? userCollapsed : isTabletCompact;
|
const collapsed = userCollapsed !== null ? userCollapsed : isTabletCompact;
|
||||||
const { currentRoleId } = useDemoStore();
|
const { currentRoleId, hasModulePermission } = useSessionStore();
|
||||||
const { entities } = useMetadata();
|
const { entities } = useMetadata();
|
||||||
const customEntities = entities.filter((e) => !e.isSystem);
|
const customEntities = entities.filter((e) => !e.isSystem);
|
||||||
const role = SYSTEM_ROLES[currentRoleId];
|
const role = SYSTEM_ROLES[currentRoleId];
|
||||||
|
|
||||||
// Filter modules by role permissions (industry profile does not hide modules)
|
// Filter modules by role permissions (strict deny-by-default via sessionStore)
|
||||||
const visibleModules = SYSTEM_MODULES.filter((m) => {
|
const visibleModules = SYSTEM_MODULES.filter((m) => hasModulePermission(m.id));
|
||||||
// Role check
|
|
||||||
if (!role.allowedModules.includes('*') && !role.allowedModules.includes(m.id)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<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) => ({
|
export const useDemoStore = create<DemoStore>((set, get) => ({
|
||||||
// Theme & Viewport defaults
|
// Theme & Viewport defaults
|
||||||
theme:
|
theme:
|
||||||
|
|
@ -356,48 +359,27 @@ export const useDemoStore = create<DemoStore>((set, get) => ({
|
||||||
viewportMode: 'desktop',
|
viewportMode: 'desktop',
|
||||||
setViewportMode: (viewportMode) => set({ viewportMode }),
|
setViewportMode: (viewportMode) => set({ viewportMode }),
|
||||||
|
|
||||||
// Profile & Role defaults
|
// Profile & Role defaults (Delegated to canonical useSessionStore)
|
||||||
currentProfileId:
|
currentProfileId: useSessionStore.getState().currentProfileId,
|
||||||
(typeof window !== 'undefined' && localStorage.getItem('krona_profile_id')) ||
|
|
||||||
'restaurant_hall_delivery',
|
|
||||||
setProfileId: (currentProfileId) => {
|
setProfileId: (currentProfileId) => {
|
||||||
if (typeof window !== 'undefined') {
|
useSessionStore.getState().setCurrentProfileId(currentProfileId);
|
||||||
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);
|
|
||||||
},
|
},
|
||||||
currentRoleId:
|
currentRoleId: useSessionStore.getState().currentRoleId,
|
||||||
(typeof window !== 'undefined' && (localStorage.getItem('krona_role_id') as SystemRoleId)) ||
|
|
||||||
'owner',
|
|
||||||
setRoleId: (currentRoleId) => {
|
setRoleId: (currentRoleId) => {
|
||||||
if (typeof window !== 'undefined') {
|
useSessionStore.getState().setCurrentRoleId(currentRoleId);
|
||||||
localStorage.setItem('krona_role_id', currentRoleId);
|
},
|
||||||
}
|
activeLocation: useSessionStore.getState().activeLocation,
|
||||||
set({ currentRoleId });
|
setActiveLocation: (activeLocation) => {
|
||||||
useDemoControlsStore.getState().setCurrentRoleId(currentRoleId);
|
useSessionStore.getState().setActiveLocation(activeLocation);
|
||||||
},
|
},
|
||||||
activeLocation: 'Горский 1/1',
|
|
||||||
setActiveLocation: (activeLocation) => set({ activeLocation }),
|
|
||||||
|
|
||||||
// Capabilities
|
// Capabilities (Delegated to canonical useSessionStore)
|
||||||
customCapabilities: { ...BUSINESS_PROFILES.restaurant_hall_delivery.capabilities },
|
customCapabilities: useSessionStore.getState().capabilities,
|
||||||
toggleCapability: (id) =>
|
toggleCapability: (id) => {
|
||||||
set((state) => ({
|
useSessionStore.getState().toggleCapability(id);
|
||||||
customCapabilities: {
|
|
||||||
...state.customCapabilities,
|
|
||||||
[id]: !state.customCapabilities[id],
|
|
||||||
},
|
},
|
||||||
})),
|
|
||||||
resetCapabilitiesToProfile: () => {
|
resetCapabilitiesToProfile: () => {
|
||||||
const state = get();
|
useSessionStore.getState().resetCapabilitiesToProfile();
|
||||||
const profile = BUSINESS_PROFILES[state.currentProfileId] || BUSINESS_PROFILES.restaurant_hall_delivery;
|
|
||||||
set({ customCapabilities: { ...profile.capabilities } });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Panels
|
// Panels
|
||||||
|
|
@ -574,10 +556,18 @@ export const useDemoStore = create<DemoStore>((set, get) => ({
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
getActiveProfile: () => {
|
getActiveProfile: () => {
|
||||||
const s = get();
|
return useSessionStore.getState().getActiveProfile();
|
||||||
return BUSINESS_PROFILES[s.currentProfileId] || BUSINESS_PROFILES.restaurant_hall_delivery;
|
|
||||||
},
|
},
|
||||||
getActiveCapabilities: () => {
|
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 { ShieldAlert, Home, RefreshCw } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button } from '../../shared/ui/Button';
|
import { Button } from '../../shared/ui/Button';
|
||||||
import { useDemoStore } from '../../stores/demo';
|
import { useSessionStore } from '../../core/session/sessionStore';
|
||||||
import { useDemoStore as useMockStore } from '../../mock/store';
|
|
||||||
|
|
||||||
export interface ForbiddenPageProps {
|
export interface ForbiddenPageProps {
|
||||||
requiredPermission?: string;
|
requiredPermission?: string;
|
||||||
|
|
@ -15,11 +14,10 @@ export const ForbiddenPage: React.FC<ForbiddenPageProps> = ({
|
||||||
moduleName,
|
moduleName,
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { currentRoleId, setCurrentRoleId } = useDemoStore();
|
const { currentRoleId, setCurrentRoleId } = useSessionStore();
|
||||||
|
|
||||||
const handleSwitchToOwner = () => {
|
const handleSwitchToOwner = () => {
|
||||||
setCurrentRoleId('owner');
|
setCurrentRoleId('owner');
|
||||||
useMockStore.getState().setRoleId('owner');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { create } from 'zustand';
|
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 DemoStateOverride = 'normal' | 'loading' | 'empty' | 'error' | 'permission_denied';
|
||||||
export type DemoErrorType = 'none' | 'network_error' | 'forbidden_403' | 'conflict_409' | 'validation_422';
|
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';
|
const DEFAULT_DEMO_DATE = '2026-09-10T10:00:00.000Z';
|
||||||
|
|
||||||
export const useDemoStore = create<DemoState>((set) => ({
|
export const useDemoStore = create<DemoState>((set) => ({
|
||||||
currentProfileId:
|
currentProfileId: useSessionStore.getState().currentProfileId,
|
||||||
(typeof window !== 'undefined' && localStorage.getItem('krona_profile_id')) ||
|
currentRoleId: useSessionStore.getState().currentRoleId,
|
||||||
'multi-business',
|
|
||||||
currentRoleId:
|
|
||||||
(typeof window !== 'undefined' && localStorage.getItem('krona_role_id')) ||
|
|
||||||
'owner',
|
|
||||||
simulateLatency: false,
|
simulateLatency: false,
|
||||||
latencyMs: 300,
|
latencyMs: 300,
|
||||||
simulateErrors: false,
|
simulateErrors: false,
|
||||||
|
|
@ -48,15 +46,11 @@ export const useDemoStore = create<DemoState>((set) => ({
|
||||||
selectedScenario: 'retail-flow',
|
selectedScenario: 'retail-flow',
|
||||||
|
|
||||||
setCurrentProfileId: (profileId) => {
|
setCurrentProfileId: (profileId) => {
|
||||||
if (typeof window !== 'undefined') {
|
useSessionStore.getState().setCurrentProfileId(profileId);
|
||||||
localStorage.setItem('krona_profile_id', profileId);
|
|
||||||
}
|
|
||||||
set({ currentProfileId: profileId });
|
set({ currentProfileId: profileId });
|
||||||
},
|
},
|
||||||
setCurrentRoleId: (roleId) => {
|
setCurrentRoleId: (roleId) => {
|
||||||
if (typeof window !== 'undefined') {
|
useSessionStore.getState().setCurrentRoleId(roleId as SystemRoleId);
|
||||||
localStorage.setItem('krona_role_id', roleId);
|
|
||||||
}
|
|
||||||
set({ currentRoleId: roleId });
|
set({ currentRoleId: roleId });
|
||||||
},
|
},
|
||||||
setSimulateLatency: (enable) => set({ simulateLatency: enable }),
|
setSimulateLatency: (enable) => set({ simulateLatency: enable }),
|
||||||
|
|
@ -73,10 +67,11 @@ export const useDemoStore = create<DemoState>((set) => ({
|
||||||
setStateOverride: (override) => set({ stateOverride: override }),
|
setStateOverride: (override) => set({ stateOverride: override }),
|
||||||
setShowMaturityBadges: (show) => set({ showMaturityBadges: show }),
|
setShowMaturityBadges: (show) => set({ showMaturityBadges: show }),
|
||||||
setSelectedScenario: (scenario) => set({ selectedScenario: scenario }),
|
setSelectedScenario: (scenario) => set({ selectedScenario: scenario }),
|
||||||
resetDemoControls: () =>
|
resetDemoControls: () => {
|
||||||
|
useSessionStore.getState().resetSession();
|
||||||
set({
|
set({
|
||||||
currentProfileId: 'multi-business',
|
currentProfileId: useSessionStore.getState().currentProfileId,
|
||||||
currentRoleId: 'owner',
|
currentRoleId: useSessionStore.getState().currentRoleId,
|
||||||
simulateLatency: false,
|
simulateLatency: false,
|
||||||
latencyMs: 300,
|
latencyMs: 300,
|
||||||
simulateErrors: false,
|
simulateErrors: false,
|
||||||
|
|
@ -85,5 +80,13 @@ export const useDemoStore = create<DemoState>((set) => ({
|
||||||
stateOverride: 'normal',
|
stateOverride: 'normal',
|
||||||
showMaturityBadges: true,
|
showMaturityBadges: true,
|
||||||
selectedScenario: 'retail-flow',
|
selectedScenario: 'retail-flow',
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
useSessionStore.subscribe((session) => {
|
||||||
|
useDemoStore.setState({
|
||||||
|
currentRoleId: session.currentRoleId,
|
||||||
|
currentProfileId: session.currentProfileId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue