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()}> + + onModuleAccess(node, 'gpsAccess', !!v)} + className="h-3.5 w-3.5" + /> + GPS + + + onModuleAccess(node, 'financeAccess', !!v)} + className="h-3.5 w-3.5" + /> + Финансы + + + )} {/* Раскрытое содержимое */} @@ -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') ? ( { onAddChild={(parentId) => openCreateRole(parentId)} onDelete={handleDeleteRole} onManageMembers={openManageMembers} + onModuleAccess={(role, field, value) => roleModuleAccessMutation.mutate({ roleId: role.id, field, value })} isAdmin={hasPermission(user, 'roles.manage')} canReassign={canReassign} + canEditModuleAccess={isAdmin(user)} /> ))} diff --git a/client/src/pages/gps/SettingsTab.tsx b/client/src/pages/gps/SettingsTab.tsx index 953b772..2a44ee4 100644 --- a/client/src/pages/gps/SettingsTab.tsx +++ b/client/src/pages/gps/SettingsTab.tsx @@ -4,8 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Loader2, MapPin, Save, SlidersHorizontal, ShieldCheck, X } from 'lucide-react'; +import { Loader2, MapPin, Save, SlidersHorizontal, X } from 'lucide-react'; import { apiRequest } from '@/lib/queryClient'; import { useToast } from '@/hooks/use-toast'; import { useAuth } from '@/hooks/useAuth'; @@ -19,8 +18,6 @@ interface GpsSettings { defaultLat: number | null; defaultLng: number | null; defaultZoom: number | null; - allowedUserIds: number[] | null; - allowedRoleIds: number[] | null; } interface GeoSuggestion { @@ -173,9 +170,6 @@ export function GpsSettingsTab() { isAdminUser={isAdminUser} onSaved={invalidateSettings} /> - - {/* Доступ к модулю — только admin */} - {isAdminUser && } ); } @@ -330,132 +324,3 @@ function DefaultCityCard({ ); } - -/** Card «Доступ к GPS-модулю» (только admin): пользователи и роли */ -function AccessCard({ settings, onSaved }: { settings: GpsSettings | undefined; onSaved: () => void }) { - const { toast } = useToast(); - const [selectedUsers, setSelectedUsers] = useState | null>(null); - const [selectedRoles, setSelectedRoles] = useState | null>(null); - - // Инициализация из настроек (один раз при загрузке) - useEffect(() => { - if (settings && selectedUsers === null) { - setSelectedUsers(new Set(settings.allowedUserIds ?? [])); - setSelectedRoles(new Set(settings.allowedRoleIds ?? [])); - } - }, [settings, selectedUsers]); - - const { data: usersData } = useQuery<{ success: boolean; users: Array<{ id: number; firstName?: string; lastName?: string; email: string; fullName?: string }> }>({ - queryKey: ['/api/users'], - }); - const { data: rolesData } = useQuery<{ success: boolean; roles: Array<{ id: number; name: string }> }>({ - queryKey: ['/api/roles'], - }); - const users = usersData?.users || []; - const roles = rolesData?.roles || []; - - const toggle = (set: Set, id: number): Set => { - const next = new Set(set); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }; - - const saveMutation = useMutation({ - mutationFn: async () => { - const userIds = [...(selectedUsers ?? [])]; - const roleIds = [...(selectedRoles ?? [])]; - // Семантика бэкенда: оба списка пустые = доступ у всех → шлём null/null - const res = await apiRequest('PUT', '/api/gps/settings', { - allowedUserIds: userIds.length > 0 ? userIds : null, - allowedRoleIds: roleIds.length > 0 ? roleIds : null, - }); - return res.json(); - }, - onSuccess: () => { - toast({ title: 'Доступ сохранён' }); - onSaved(); - }, - onError: (err: any) => { - toast({ title: 'Ошибка', description: err?.message || 'Не удалось сохранить доступ', variant: 'destructive' }); - }, - }); - - const userLabel = (u: { firstName?: string; lastName?: string; email: string; fullName?: string }) => - u.fullName || `${u.firstName || ''} ${u.lastName || ''}`.trim() || u.email; - - if (selectedUsers === null || selectedRoles === null) { - return ( - - - - - - ); - } - - return ( - - - - - Доступ к GPS-модулю - - - - - Пусто = доступ у всех. Если выбраны пользователи или роли — модуль виден только им - (администраторы видят всегда). - - - - - Пользователи - - {users.length === 0 ? ( - Нет пользователей - ) : ( - users.map((u) => ( - - setSelectedUsers(toggle(selectedUsers, u.id))} - /> - {userLabel(u)} - - )) - )} - - - - Роли - - {roles.length === 0 ? ( - Нет ролей - ) : ( - roles.map((r) => ( - - setSelectedRoles(toggle(selectedRoles, r.id))} - /> - {r.name} - - )) - )} - - - - - saveMutation.mutate()}> - {saveMutation.isPending ? ( - - ) : ( - - )} - Сохранить доступ - - - - ); -} diff --git a/migrations/0074_module_access.sql b/migrations/0074_module_access.sql new file mode 100644 index 0000000..738403e --- /dev/null +++ b/migrations/0074_module_access.sql @@ -0,0 +1,9 @@ +-- Доступ к модулям GPS и Финансы: флаги на пользователях и организационных ролях. +-- DEFAULT true — при выкатке никого не блокируем. +-- Effective-доступ к модулю = users. OR любая роль пользователя с флагом OR app_role = 'admin'. +-- Флаг финансов работает как ДОПОЛНИТЕЛЬНЫЙ ограничитель поверх существующего +-- права finance.manage (app-role permissions) — только отбирает, не добавляет. +ALTER TABLE users ADD COLUMN IF NOT EXISTS gps_access BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE users ADD COLUMN IF NOT EXISTS finance_access BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE roles ADD COLUMN IF NOT EXISTS gps_access BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE roles ADD COLUMN IF NOT EXISTS finance_access BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/server/gps/access.ts b/server/gps/access.ts index 1d13ac3..2f7959c 100644 --- a/server/gps/access.ts +++ b/server/gps/access.ts @@ -1,33 +1,13 @@ -import { gpsStorage } from "./storage"; +import { hasModuleAccess, type ModuleAccessUser } from "../utils/module-access"; /** * Контроль доступа к GPS-вкладке. * - * Правило: - * - admin приложения (users.app_role = 'admin') — доступ всегда; - * - если allowed_user_ids и allowed_role_ids оба пустые/NULL — доступ у всех; - * - иначе — если user.id ∈ allowedUserIds ИЛИ пользователь состоит - * в организационной роли из allowedRoleIds (таблицы roles/role_members). + * Правило: users.gps_access OR любая организационная роль пользователя + * с gps_access OR appRole = 'admin' (см. utils/module-access). + * Раньше использовались gps_settings.allowed_user_ids/allowed_role_ids — + * колонки остались в БД, но больше не читаются. */ -export async function hasGpsAccess( - user: { id: number; organizationId: number; appRole?: string }, - settings?: Awaited> -): Promise { - if (user.appRole === "admin") return true; - - const s = settings ?? (await gpsStorage.getSettings(user.organizationId)); - const userIds = s.allowedUserIds ?? []; - const roleIds = s.allowedRoleIds ?? []; - - // Оба списка пустые — модуль открыт всем - if (userIds.length === 0 && roleIds.length === 0) return true; - - if (userIds.includes(user.id)) return true; - - if (roleIds.length > 0) { - const userRoleIds = await gpsStorage.getUserRoleIds(user.id, user.organizationId); - if (userRoleIds.some((id) => roleIds.includes(id))) return true; - } - - return false; +export async function hasGpsAccess(user: ModuleAccessUser): Promise { + return hasModuleAccess(user, "gps"); } diff --git a/server/gps/geozone.service.ts b/server/gps/geozone.service.ts index a6f93fb..c31ba04 100644 --- a/server/gps/geozone.service.ts +++ b/server/gps/geozone.service.ts @@ -53,7 +53,7 @@ export interface ProcessPositionInput { } // Вердикт фильтрации точки относительно предыдущей принятой -export type PointVerdict = "accept" | "stale" | "jump"; +export type PointVerdict = "accept" | "stale" | "jump" | "echo"; export interface GpsFilterSettings { jitterMeters: number; @@ -64,12 +64,17 @@ interface PrevPoint { lat: number; lng: number; recordedAt: Date; + /** Скорость, км/ч (null — неизвестна) */ + speed?: number | null; } // Сколько точек подряд около новой локации нужно, чтобы подтвердить смену реальности const CANDIDATE_CONFIRM_COUNT = 3; // Время жизни кандидата на смену локации (по времени точек) const CANDIDATE_TTL_MS = 30 * 60 * 1000; +// Окно heartbeat-эхо: статусная точка считается эхом стоянки, если последняя +// принятая точка «в движении» (speed > 10 км/ч) была меньше 5 минут назад +const ECHO_WINDOW_MS = 5 * 60 * 1000; interface JumpCandidate { lat: number; @@ -79,7 +84,7 @@ interface JumpCandidate { } /** - * Stateful-фильтр потока точек (stale/jump) с гистерезисом подтверждения. + * Stateful-фильтр потока точек (stale/jump/echo) с гистерезисом подтверждения. * * Проблема latch-up: если последняя принятая точка — стабильный LBS * (идёт каждую минуту с одной вышки), то при восстановлении GPS реальные @@ -90,18 +95,30 @@ interface JumpCandidate { * Прыжки между разными вышками (туда-сюда) никогда не подтверждаются. * Кандидат живёт CANDIDATE_TTL_MS с первой точки, потом сбрасывается. * + * Heartbeat-эхо: трекер шлёт поминутные «статусные» точки (speed≈0), + * повторяющие координаты стоянки, даже когда объект едет. Walker помнит + * последний кластер стоянки (lastStationary) и время последней принятой + * точки в движении (lastMovingAt). Точка с speed < 1 км/ч рядом со стоянкой + * в пределах ECHO_WINDOW_MS после движения — эхо, отбрасывается. Если движения + * нет дольше окна — правило отпускает и стоянка принимается (это корректно). + * * Используется и при приёме (processPosition, per-asset in-memory), * и при отдаче трека (filterTrackPoints). */ export class JumpFilterWalker { private prev: PrevPoint | null; private candidate: JumpCandidate | null = null; + // Последняя «стоянка»: кластер принятых точек с speed≈0 + private lastStationary: { lat: number; lng: number } | null = null; + // recordedAt последней принятой точки «в движении» (speed > 10 км/ч), ms + private lastMovingAt: number | null = null; constructor( private settings: GpsFilterSettings, initialPrev: PrevPoint | null = null ) { this.prev = initialPrev; + if (initialPrev) this.noteMotion(initialPrev); } /** Обновление настроек «на лету» (для долгоживущих per-asset walker'ов) */ @@ -109,6 +126,17 @@ export class JumpFilterWalker { this.settings = settings; } + /** Учёт принятой точки в состоянии стоянки/движения */ + private noteMotion(point: PrevPoint): void { + const speed = point.speed; + if (speed === null || speed === undefined) return; + if (speed < 1) { + this.lastStationary = { lat: point.lat, lng: point.lng }; + } else if (speed > 10) { + this.lastMovingAt = point.recordedAt.getTime(); + } + } + /** * Проверка очередной точки. При 'accept' walker обновляет prev — * вызывающий код должен обработать точку как валидную. @@ -119,10 +147,24 @@ export class JumpFilterWalker { return "stale"; } + // Heartbeat-эхо: статусная точка повторяет координаты стоянки на ходу + const speed = point.speed; + if ( + speed !== null && speed !== undefined && speed < 1 && + this.lastStationary && + this.lastMovingAt !== null && + point.recordedAt.getTime() - this.lastMovingAt < ECHO_WINDOW_MS && + haversineMeters(this.lastStationary.lat, this.lastStationary.lng, point.lat, point.lng) < + Math.max(this.settings.jitterMeters, 50) + ) { + return "echo"; + } + if (!this.isJump(point)) { // Обычный путь — кандидат не нужен this.candidate = null; this.prev = point; + this.noteMotion(point); return "accept"; } @@ -145,6 +187,7 @@ export class JumpFilterWalker { // Три подряд точки около новой локации — новая реальность, принимаем this.candidate = null; this.prev = point; + this.noteMotion(point); return "accept"; } return "jump"; @@ -172,9 +215,10 @@ export class JumpFilterWalker { /** * Фильтрация сохранённого трека при выдаче (без удаления из БД): - * те же правила stale/jump с подтверждением кандидата, что и при приёме. + * те же правила stale/jump/echo с подтверждением кандидата, что и при приёме, + * плюс spike-фильтр с lookahead (эхо стоянки между движущимися точками). */ -export function filterTrackPoints( +export function filterTrackPoints( points: T[], settings: GpsFilterSettings ): { points: T[]; filteredCount: number } { @@ -182,13 +226,38 @@ export function filterTrackPoints 250 && + dPrevNext < dPrevCur / 2 && + (cur.speed ?? 999) < 2 && + ((prev.speed ?? 0) > 10 || (next.speed ?? 0) > 10) + ) { + filteredCount++; + continue; + } + } + out.push(cur); + } + + return { points: out, filteredCount }; } /** @@ -239,17 +308,22 @@ export async function processPosition( // Предыдущая принятая точка объекта (если есть) — для инициализации walker'а const prev: PrevPoint | null = asset.lastLat !== null && asset.lastLng !== null && asset.lastRecordedAt !== null - ? { lat: asset.lastLat, lng: asset.lastLng, recordedAt: asset.lastRecordedAt } + ? { lat: asset.lastLat, lng: asset.lastLng, recordedAt: asset.lastRecordedAt, speed: asset.lastSpeed } : null; - // Фильтры stale/jump с гистерезисом подтверждения (устаревшие копии и LBS-скачки) + // Фильтры stale/jump/echo с гистерезисом подтверждения (устаревшие копии, + // LBS-скачки, heartbeat-эхо стоянки) const walker = getAssetWalker(assetId, prev, settings); - const verdict = walker.check({ lat: input.lat, lng: input.lng, recordedAt }); + const verdict = walker.check({ lat: input.lat, lng: input.lng, recordedAt, speed: input.speed }); if (verdict !== "accept") { if (verdict === "stale") { console.log( `[GPS] skipped stale: asset=${assetId} recordedAt=${recordedAt.toISOString()} <= lastRecordedAt=${prev?.recordedAt.toISOString()}` ); + } else if (verdict === "echo") { + console.log( + `[GPS] skipped echo: asset=${assetId} heartbeat-точка повторяет координаты стоянки (speed=${input.speed ?? "—"} км/ч)` + ); } else { const dist = prev ? haversineMeters(prev.lat, prev.lng, input.lat, input.lng) : 0; const dtSec = prev ? (recordedAt.getTime() - prev.recordedAt.getTime()) / 1000 : 0; diff --git a/server/gps/routes.ts b/server/gps/routes.ts index 2612bea..a25aba0 100644 --- a/server/gps/routes.ts +++ b/server/gps/routes.ts @@ -43,6 +43,8 @@ interface NormalizedPoint { course: number | null; accuracy: number | null; recordedAt: Date | null; + /** false только у нативного формата Traccar (position.valid === false) */ + valid: boolean; } /** @@ -69,6 +71,7 @@ function normalizePoint(raw: any): NormalizedPoint | null { course: parseNum(p.course), accuracy: parseNum(p.accuracy), recordedAt: parseDateParam(p.fixTime ?? p.deviceTime), + valid: p.valid !== false, }; } @@ -84,6 +87,7 @@ function normalizePoint(raw: any): NormalizedPoint | null { course: parseNum(raw.course), accuracy: parseNum(raw.accuracy), recordedAt: parseDateParam(raw.timestamp), + valid: true, }; } @@ -118,6 +122,14 @@ async function handleIngest(req: AuthenticatedRequest, res: Response) { continue; } + // Traccar пометил позицию как невалидную (valid=false) — не сохраняем, + // но считаем признаком жизни устройства + if (!point.valid) { + await gpsStorage.touchAssetSeen(asset.id); + processed++; + continue; + } + try { await processPosition(asset.id, asset.organizationId, { lat: point.lat, @@ -182,7 +194,7 @@ export function registerGpsRoutes(app: Express) { } const settings = await gpsStorage.getSettings(orgId); - const allowed = await hasGpsAccess(req.user!, settings); + const allowed = await hasGpsAccess(req.user!); const defaultCenter = settings.defaultLat !== null && settings.defaultLng !== null && settings.defaultZoom !== null ? { lat: settings.defaultLat, lng: settings.defaultLng, zoom: settings.defaultZoom } @@ -298,15 +310,13 @@ export function registerGpsRoutes(app: Express) { return badRequest(res, "tripStopMinutes: целое число от 0 до 120 (0 = не делить на поездки)"); } - // Поля центра карты и доступа — только для admin (у остальных молча игнорируются) + // Центр карты по умолчанию — только для admin (у остальных молча игнорируется) let defaultLat = current.defaultLat; let defaultLng = current.defaultLng; let defaultZoom = current.defaultZoom; - let allowedUserIds = current.allowedUserIds; - let allowedRoleIds = current.allowedRoleIds; if (isAdmin) { - // Центр по умолчанию: либо все три поля заданы, либо все null (очистка) + // Либо все три поля заданы, либо все null (очистка) const hasCenterField = req.body?.defaultLat !== undefined || req.body?.defaultLng !== undefined || req.body?.defaultZoom !== undefined; if (hasCenterField) { @@ -331,26 +341,6 @@ export function registerGpsRoutes(app: Express) { defaultZoom = zoom; } } - - // Списки доступа: массивы целых чисел или null (очистка) - if (req.body?.allowedUserIds !== undefined) { - if (req.body.allowedUserIds === null) { - allowedUserIds = null; - } else if (!isIntArray(req.body.allowedUserIds)) { - return badRequest(res, "allowedUserIds: массив целых чисел или null"); - } else { - allowedUserIds = req.body.allowedUserIds; - } - } - if (req.body?.allowedRoleIds !== undefined) { - if (req.body.allowedRoleIds === null) { - allowedRoleIds = null; - } else if (!isIntArray(req.body.allowedRoleIds)) { - return badRequest(res, "allowedRoleIds: массив целых чисел или null"); - } else { - allowedRoleIds = req.body.allowedRoleIds; - } - } } const row = await gpsStorage.upsertSettings(req.user!.organizationId, { @@ -360,8 +350,6 @@ export function registerGpsRoutes(app: Express) { defaultLat, defaultLng, defaultZoom, - allowedUserIds, - allowedRoleIds, }); res.json({ success: true, @@ -371,8 +359,6 @@ export function registerGpsRoutes(app: Express) { defaultLat: row.defaultLat, defaultLng: row.defaultLng, defaultZoom: row.defaultZoom, - allowedUserIds: row.allowedUserIds, - allowedRoleIds: row.allowedRoleIds, }); }); @@ -689,7 +675,3 @@ function isValidPolygon(polygon: unknown): polygon is Array<[number, number]> { ) ); } - -function isIntArray(value: unknown): value is number[] { - return Array.isArray(value) && value.every((v) => Number.isInteger(v)); -} diff --git a/server/gps/storage.ts b/server/gps/storage.ts index e725464..0872f4a 100644 --- a/server/gps/storage.ts +++ b/server/gps/storage.ts @@ -9,8 +9,6 @@ import { gpsGroupAssets, gpsAssetSubscribers, gpsSettings, - roles, - roleMembers, type GpsAsset, type GpsPosition, type GpsGeozone, @@ -471,8 +469,6 @@ export class GpsStorage { defaultLat: number | null; defaultLng: number | null; defaultZoom: number | null; - allowedUserIds: number[] | null; - allowedRoleIds: number[] | null; }> { const [row] = await db .select() @@ -485,8 +481,6 @@ export class GpsStorage { defaultLat: row?.defaultLat ?? null, defaultLng: row?.defaultLng ?? null, defaultZoom: row?.defaultZoom ?? null, - allowedUserIds: row?.allowedUserIds ?? null, - allowedRoleIds: row?.allowedRoleIds ?? null, }; } @@ -499,8 +493,6 @@ export class GpsStorage { defaultLat: number | null; defaultLng: number | null; defaultZoom: number | null; - allowedUserIds: number[] | null; - allowedRoleIds: number[] | null; } ): Promise { const [row] = await db @@ -513,16 +505,6 @@ export class GpsStorage { .returning(); return row; } - - /** Организационные роли пользователя (через role_members) в рамках организации */ - async getUserRoleIds(userId: number, organizationId: number): Promise { - const rows = await db - .select({ roleId: roleMembers.roleId }) - .from(roleMembers) - .innerJoin(roles, eq(roleMembers.roleId, roles.id)) - .where(and(eq(roleMembers.userId, userId), eq(roles.organizationId, organizationId))); - return rows.map((r) => r.roleId); - } } export const gpsStorage = new GpsStorage(); diff --git a/server/middleware/auth.middleware.ts b/server/middleware/auth.middleware.ts index bb2867b..5fd689d 100644 --- a/server/middleware/auth.middleware.ts +++ b/server/middleware/auth.middleware.ts @@ -403,6 +403,16 @@ export const requirePermission = (...codes: string[]) => { if (!has) { return res.status(403).json({ error: 'Недостаточно прав' }); } + // Модульный флаг доступа к Финансам (users/roles.finance_access) — + // дополнительный ограничитель поверх права finance.*: только отбирает, + // не добавляет. DEFAULT true — при выкатке поведение не меняется. + if (codes.some((c) => c.startsWith('finance.'))) { + const { hasModuleAccess } = await import('../utils/module-access'); + const allowed = await hasModuleAccess(req.user, 'finance'); + if (!allowed) { + return res.status(403).json({ error: 'Нет доступа к модулю «Финансы»' }); + } + } next(); }; }; diff --git a/server/routes/auth.core.routes.ts b/server/routes/auth.core.routes.ts index 4fb17db..e80abe0 100644 --- a/server/routes/auth.core.routes.ts +++ b/server/routes/auth.core.routes.ts @@ -190,6 +190,12 @@ export function registerAuthCoreRoutes(router: Router): void { return res.status(401).json({ success: false, error: 'Пользователь не найден' }); } const permissions = await storage.getAppRolePermissions(user.appRole); + // Эффективный доступ к модулям (GPS/Финансы) — флаг пользователя OR роли OR admin + const { hasModuleAccess } = await import("../utils/module-access"); + const moduleAccess = { + gps: await hasModuleAccess(user as any, "gps"), + finance: await hasModuleAccess(user as any, "finance"), + }; res.json({ success: true, user: { @@ -200,6 +206,7 @@ export function registerAuthCoreRoutes(router: Router): void { middleName: user.middleName, appRole: user.appRole, permissions, + moduleAccess, organization: user.organization ? { id: user.organization.id, name: user.organization.name, diff --git a/server/routes/auth.users.routes.ts b/server/routes/auth.users.routes.ts index a932687..15e5fb4 100644 --- a/server/routes/auth.users.routes.ts +++ b/server/routes/auth.users.routes.ts @@ -282,6 +282,8 @@ export function registerUserManagementRoutes(router: Router): void { lastLogin: user.lastLogin, lastActivityAt: user.lastActivityAt, createdAt: user.createdAt, + gpsAccess: user.gpsAccess, + financeAccess: user.financeAccess, organizationalRoles: roleMembersMap.get(user.id) || [], })); @@ -307,6 +309,56 @@ export function registerUserManagementRoutes(router: Router): void { } }); + // PATCH /api/users/:id/module-access — флаги доступа к модулям GPS/Финансы (только admin) + router.patch('/api/users/:id/module-access', + authenticateToken, + tenantIsolation, + async (req: AuthenticatedRequest, res) => { + try { + if (req.user!.appRole !== 'admin') { + return res.status(403).json({ success: false, error: 'Требуются права администратора' }); + } + const userId = parseInt(req.params.id); + if (isNaN(userId)) { + return res.status(400).json({ success: false, error: 'Некорректный ID пользователя' }); + } + + const { gpsAccess, financeAccess } = req.body ?? {}; + const updates: { gpsAccess?: boolean; financeAccess?: boolean } = {}; + if (gpsAccess !== undefined) { + if (typeof gpsAccess !== 'boolean') { + return res.status(400).json({ success: false, error: 'gpsAccess: boolean' }); + } + updates.gpsAccess = gpsAccess; + } + if (financeAccess !== undefined) { + if (typeof financeAccess !== 'boolean') { + return res.status(400).json({ success: false, error: 'financeAccess: boolean' }); + } + updates.financeAccess = financeAccess; + } + if (Object.keys(updates).length === 0) { + return res.status(400).json({ success: false, error: 'Передайте gpsAccess и/или financeAccess' }); + } + + // Tenant isolation: редактируем только пользователя своей организации + const target = await storage.getUser(userId); + if (!target || target.organizationId !== req.organizationId!) { + return res.status(404).json({ success: false, error: 'Пользователь не найден' }); + } + + const updated = await storage.updateUser(userId, updates); + res.json({ + success: true, + user: { id: updated.id, gpsAccess: updated.gpsAccess, financeAccess: updated.financeAccess }, + }); + } catch (error) { + console.error('Update user module access error:', error); + res.status(500).json({ success: false, error: 'Ошибка при обновлении доступа к модулям' }); + } + }); + + // Column values for users (autocomplete for filters) router.get('/api/users/column-values', async (req: AuthenticatedRequest, res) => { diff --git a/server/routes/roles.routes.ts b/server/routes/roles.routes.ts index 54da920..6340a1a 100644 --- a/server/routes/roles.routes.ts +++ b/server/routes/roles.routes.ts @@ -17,6 +17,45 @@ router.get('/api/roles', authenticateToken, tenantIsolation, async (req: Authent } }); +// PATCH /api/roles/:id/module-access — флаги доступа к модулям GPS/Финансы (только admin) +router.patch('/api/roles/:id/module-access', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { + try { + if (req.user!.appRole !== 'admin') { + return res.status(403).json({ success: false, error: 'Требуются права администратора' }); + } + const id = parseInt(req.params.id); + if (isNaN(id)) return res.status(400).json({ success: false, error: 'Некорректный ID роли' }); + + const { gpsAccess, financeAccess } = req.body ?? {}; + const updates: { gpsAccess?: boolean; financeAccess?: boolean } = {}; + if (gpsAccess !== undefined) { + if (typeof gpsAccess !== 'boolean') { + return res.status(400).json({ success: false, error: 'gpsAccess: boolean' }); + } + updates.gpsAccess = gpsAccess; + } + if (financeAccess !== undefined) { + if (typeof financeAccess !== 'boolean') { + return res.status(400).json({ success: false, error: 'financeAccess: boolean' }); + } + updates.financeAccess = financeAccess; + } + if (Object.keys(updates).length === 0) { + return res.status(400).json({ success: false, error: 'Передайте gpsAccess и/или financeAccess' }); + } + + const role = await storage.updateRole(id, req.organizationId!, updates); + if (!role) return res.status(404).json({ success: false, error: 'Роль не найдена' }); + return res.json({ + success: true, + role: { id: role.id, gpsAccess: role.gpsAccess, financeAccess: role.financeAccess }, + }); + } catch (error) { + console.error('Update role module access error:', error); + return res.status(500).json({ success: false, error: 'Ошибка при обновлении доступа к модулям' }); + } +}); + // GET /api/roles/:id — одна роль с участниками router.get('/api/roles/:id', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { diff --git a/server/storage/roles.storage.ts b/server/storage/roles.storage.ts index 6331411..0783299 100644 --- a/server/storage/roles.storage.ts +++ b/server/storage/roles.storage.ts @@ -48,6 +48,8 @@ export class RolesStorage extends BillingStorage { description: roles.description, parentId: roles.parentId, managerId: roles.managerId, + gpsAccess: roles.gpsAccess, + financeAccess: roles.financeAccess, createdAt: roles.createdAt, }).from(roles).where(eq(roles.organizationId, organizationId)).orderBy(asc(roles.name)); @@ -149,7 +151,7 @@ export class RolesStorage extends BillingStorage { return role; } - async updateRole(id: number, organizationId: number, updates: { name?: string; description?: string | null; parentId?: number | null; managerId?: number | null }) { + async updateRole(id: number, organizationId: number, updates: { name?: string; description?: string | null; parentId?: number | null; managerId?: number | null; gpsAccess?: boolean; financeAccess?: boolean }) { const { roles } = await import('@shared/schema'); if (updates.parentId) { if (updates.parentId === id) throw new Error('Роль не может быть родителем самой себя'); @@ -256,6 +258,8 @@ export class RolesStorage extends BillingStorage { description: roles.description, parentId: roles.parentId, managerId: roles.managerId, + gpsAccess: roles.gpsAccess, + financeAccess: roles.financeAccess, createdAt: roles.createdAt, }).from(roleMembers) .innerJoin(roles, eq(roleMembers.roleId, roles.id)) diff --git a/server/utils/module-access.ts b/server/utils/module-access.ts new file mode 100644 index 0000000..eaafedb --- /dev/null +++ b/server/utils/module-access.ts @@ -0,0 +1,41 @@ +import { db } from "../db"; +import { roles, roleMembers } from "@shared/schema"; +import { and, eq } from "drizzle-orm"; + +/** + * Доступ к модулям (GPS / Финансы) по флагам users. и roles.. + * + * Effective-доступ = users. OR любая организационная роль пользователя + * с флагом OR appRole = 'admin'. Оба флага NOT NULL DEFAULT true — при выкатке + * никто не блокируется; админ снимает галки в матрице на странице /users. + * + * Для Финансов флаг работает как ДОПОЛНИТЕЛЬНЫЙ ограничитель поверх + * существующего app-role права finance.manage (см. requirePermission) — + * только отбирает доступ, не добавляет. + */ + +export type ModuleAccessKey = "gps" | "finance"; + +export interface ModuleAccessUser { + id: number; + organizationId: number; + appRole?: string; + gpsAccess?: boolean; + financeAccess?: boolean; +} + +export async function hasModuleAccess(user: ModuleAccessUser, module: ModuleAccessKey): Promise { + if (user.appRole === "admin") return true; + + const userFlag = module === "gps" ? user.gpsAccess : user.financeAccess; + if (userFlag) return true; + + // Флаг любой организационной роли пользователя + const rows = await db + .select({ gpsAccess: roles.gpsAccess, financeAccess: roles.financeAccess }) + .from(roleMembers) + .innerJoin(roles, eq(roleMembers.roleId, roles.id)) + .where(and(eq(roleMembers.userId, user.id), eq(roles.organizationId, user.organizationId))); + + return rows.some((r) => (module === "gps" ? r.gpsAccess : r.financeAccess)); +} diff --git a/shared/schema.ts b/shared/schema.ts index 745b1ed..b2e5e3d 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -101,6 +101,9 @@ export const users = pgTable("users", { additionalPhones: jsonb("additional_phones").default(sql`'[]'`), additionalEmails: jsonb("additional_emails").default(sql`'[]'`), appRole: varchar("app_role", { length: 100 }).notNull().default("user"), // 'admin', 'user', 'accountant' + // Флаги доступа к модулям (матрица на /users): GPS и Финансы + gpsAccess: boolean("gps_access").notNull().default(true), + financeAccess: boolean("finance_access").notNull().default(true), isActive: boolean("is_active").default(true), emailVerified: boolean("email_verified").default(false), verificationToken: varchar("verification_token", { length: 255 }), @@ -2972,6 +2975,9 @@ export const roles = pgTable("roles", { description: text("description"), parentId: integer("parent_id"), managerId: integer("manager_id").references(() => users.id, { onDelete: "set null" }), + // Флаги доступа к модулям для членов роли (матрица на /users) + gpsAccess: boolean("gps_access").notNull().default(true), + financeAccess: boolean("finance_access").notNull().default(true), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ orgIdx: index("roles_org_idx").on(table.organizationId),
- Пусто = доступ у всех. Если выбраны пользователи или роли — модуль виден только им - (администраторы видят всегда). -
Нет пользователей
Нет ролей