From ac4f89d42e3350ef0cfacaf40e3a7d5910bf6d2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Wed, 26 Aug 2026 17:13:49 +0300 Subject: [PATCH] =?UTF-8?q?feat(gps):=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D1=80=D1=8B=20heartbeat-=D1=8D=D1=85=D0=BE/valid=3Dfalse/spike?= =?UTF-8?q?;=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=20=D0=BA=20GPS=20?= =?UTF-8?q?=D0=B8=20=D0=A4=D0=B8=D0=BD=D0=B0=D0=BD=D1=81=D0=B0=D0=BC=20?= =?UTF-8?q?=E2=80=94=20=D0=BC=D0=B0=D1=82=D1=80=D0=B8=D1=86=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B0=20/users=20(=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D0=B8+=D1=80=D0=BE=D0=BB=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/MobileBottomNav.tsx | 2 +- client/src/components/Sidebar.tsx | 4 +- client/src/pages/Users.tsx | 153 +++++++++++++++++++++- client/src/pages/gps/SettingsTab.tsx | 137 +------------------ migrations/0074_module_access.sql | 9 ++ server/gps/access.ts | 34 +---- server/gps/geozone.service.ts | 92 +++++++++++-- server/gps/routes.ts | 48 +++---- server/gps/storage.ts | 18 --- server/middleware/auth.middleware.ts | 10 ++ server/routes/auth.core.routes.ts | 7 + server/routes/auth.users.routes.ts | 52 ++++++++ server/routes/roles.routes.ts | 39 ++++++ server/storage/roles.storage.ts | 6 +- server/utils/module-access.ts | 41 ++++++ shared/schema.ts | 6 + 16 files changed, 431 insertions(+), 227 deletions(-) create mode 100644 migrations/0074_module_access.sql create mode 100644 server/utils/module-access.ts diff --git a/client/src/components/MobileBottomNav.tsx b/client/src/components/MobileBottomNav.tsx index 697ab06..334d8d2 100644 --- a/client/src/components/MobileBottomNav.tsx +++ b/client/src/components/MobileBottomNav.tsx @@ -380,7 +380,7 @@ export function MobileBottomNav() { Шаблоны полей - {isAdmin(user) && ( + {isAdmin(user) && (user as any)?.moduleAccess?.finance !== false && ( diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 32c90ac..3985c27 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -1086,7 +1086,9 @@ function FinanceNavItem({ collapsed }: { collapsed: boolean }) { const [location] = useLocation(); const isActive = location === '/finance' || location.startsWith('/finance/'); - if (!user || (!isAdmin(user) && !hasPermission(user, 'finance.view'))) return null; + // Доступ к финансам: app-role право + флаг модуля (user/роль) из /api/auth/me + const moduleAllowed = (user as any)?.moduleAccess?.finance !== false; + if (!user || !moduleAllowed || (!isAdmin(user) && !hasPermission(user, 'finance.view'))) return null; if (collapsed) { return ( diff --git a/client/src/pages/Users.tsx b/client/src/pages/Users.tsx index f06e827..2fc7d45 100644 --- a/client/src/pages/Users.tsx +++ b/client/src/pages/Users.tsx @@ -82,9 +82,10 @@ import { import { useColumnFilters } from '@/hooks/useColumnFilters'; import { ColumnFilter } from '@/components/ui/ColumnFilter'; import { ActiveFilterBadges } from '@/components/ui/ActiveFilterBadges'; -import { apiRequest } from '@/lib/queryClient'; +import { apiRequest, queryClient } from '@/lib/queryClient'; import { cn } from '@/lib/utils'; import { Checkbox } from '@/components/ui/checkbox'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Dialog, DialogContent, @@ -115,6 +116,9 @@ interface Role { directMemberCount: number; totalMemberCount: number; members: RoleMember[]; + /** Флаги доступа к модулям (матрица доступа) */ + gpsAccess?: boolean; + financeAccess?: boolean; } interface RoleTreeNode extends Role { @@ -132,6 +136,46 @@ interface UserStatus { const PARENT_NONE = 'none'; +/** + * Ячейка матрицы доступа: чекбокс флага пользователя + пометка, + * если доступ унаследован от роли (флаг снят, но вкладка доступна). + */ +function ModuleAccessCell({ + checked, + inheritedFrom, + disabled, + onChange, +}: { + checked: boolean; + /** Названия ролей, через которые доступ уже есть */ + inheritedFrom: string[]; + disabled?: boolean; + onChange: (value: boolean) => void; +}) { + return ( +
+ onChange(!!v)} + className="h-3.5 w-3.5" + /> + {!checked && inheritedFrom.length > 0 && ( + + + + + + + + Доступ через {inheritedFrom.length === 1 ? 'роль' : 'роли'}: {inheritedFrom.join(', ')} + + + )} +
+ ); +} + function buildRoleTree(roles: Role[]): RoleTreeNode[] { const map = new Map(); roles.forEach((r) => map.set(r.id, { ...r, children: [] })); @@ -153,8 +197,10 @@ function RoleNode({ onAddChild, onDelete, onManageMembers, + onModuleAccess, isAdmin, canReassign, + canEditModuleAccess, }: { node: RoleTreeNode; depth: number; @@ -162,8 +208,11 @@ function RoleNode({ onAddChild: (parentId: number) => void; onDelete: (id: number) => void; onManageMembers: (role: Role) => void; + onModuleAccess: (role: Role, field: 'gpsAccess' | 'financeAccess', value: boolean) => void; isAdmin: boolean; canReassign: boolean; + /** Матрица доступа — только для администратора приложения (эндпоинт admin-only) */ + canEditModuleAccess: boolean; }) { const [open, setOpen] = useState(depth < 2); const hasChildren = node.children.length > 0; @@ -252,6 +301,28 @@ function RoleNode({ )} + + {/* Доступ роли к модулям (только администратор приложения) */} + {canEditModuleAccess && ( +
e.stopPropagation()}> + + +
+ )} {/* Раскрытое содержимое */} @@ -288,8 +359,10 @@ function RoleNode({ onAddChild={onAddChild} onDelete={onDelete} onManageMembers={onManageMembers} + onModuleAccess={onModuleAccess} isAdmin={isAdmin} canReassign={canReassign} + canEditModuleAccess={canEditModuleAccess} /> ))} @@ -763,6 +836,50 @@ const UsersPage = () => { }, }); + // === Матрица доступа к модулям (GPS / Финансы), admin only === + const invalidateModuleAccess = () => { + refetchUsers(); + refetchRoles(); + queryClient.invalidateQueries({ queryKey: ['/api/gps/config'] }); + }; + + const userModuleAccessMutation = useMutation({ + mutationFn: async ({ userId, field, value }: { userId: number; field: 'gpsAccess' | 'financeAccess'; value: boolean }) => { + const res = await apiRequest('PATCH', `/api/users/${userId}/module-access`, { [field]: value }); + return res.json(); + }, + onSuccess: () => { + invalidateModuleAccess(); + toast({ title: 'Доступ обновлён' }); + }, + onError: () => { + toast({ title: 'Ошибка', description: 'Не удалось обновить доступ', variant: 'destructive' }); + }, + }); + + const roleModuleAccessMutation = useMutation({ + mutationFn: async ({ roleId, field, value }: { roleId: number; field: 'gpsAccess' | 'financeAccess'; value: boolean }) => { + const res = await apiRequest('PATCH', `/api/roles/${roleId}/module-access`, { [field]: value }); + return res.json(); + }, + onSuccess: () => { + invalidateModuleAccess(); + toast({ title: 'Доступ обновлён' }); + }, + onError: () => { + toast({ title: 'Ошибка', description: 'Не удалось обновить доступ', variant: 'destructive' }); + }, + }); + + // Флаги доступа ролей по id — для пометки «доступ через роль» у пользователя + const roleAccessById = useMemo(() => { + const map = new Map(); + for (const r of roles) { + map.set(r.id, { name: r.name, gpsAccess: !!r.gpsAccess, financeAccess: !!r.financeAccess }); + } + return map; + }, [roles]); + const openCreateStatus = () => { setEditingStatus(null); setStatusName(''); @@ -934,6 +1051,12 @@ const UsersPage = () => { Орг. роли + {isAdmin(user) && ( + <> + GPS + Финансы + + )} Статус @@ -983,6 +1106,32 @@ const UsersPage = () => { {(tableUser.organizationalRoles || []).map((r: any) => r.name).join(', ') || '—'} + {isAdmin(user) && ( + <> + e.stopPropagation()}> + roleAccessById.get(r.id)) + .filter((r: { name: string; gpsAccess: boolean; financeAccess: boolean } | undefined) => r?.gpsAccess) + .map((r: { name: string }) => r.name)} + disabled={userModuleAccessMutation.isPending} + onChange={(v) => userModuleAccessMutation.mutate({ userId: tableUser.id, field: 'gpsAccess', value: v })} + /> + + e.stopPropagation()}> + roleAccessById.get(r.id)) + .filter((r: { name: string; gpsAccess: boolean; financeAccess: boolean } | undefined) => r?.financeAccess) + .map((r: { name: string }) => r.name)} + disabled={userModuleAccessMutation.isPending} + onChange={(v) => userModuleAccessMutation.mutate({ userId: tableUser.id, field: 'financeAccess', value: v })} + /> + + + )} e.stopPropagation()}> {hasPermission(user, 'users.manage') ? (