fix(ws): фильтрация уведомлений и подписок по роли

Refs EventHub/EventHubFrontAdmin#8

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 17:14:51 +03:00
parent 9deaa34a6e
commit 9ff894988f
3 changed files with 50 additions and 10 deletions
+13
View File
@@ -67,3 +67,16 @@ export function getMenuSelectedKey(pathname: string): string {
} }
return pathname; return pathname;
} }
const WS_NOTIFICATION_ROUTES: Record<'report_created' | 'ticket_created', string> = {
report_created: '/reports',
ticket_created: '/tickets',
};
/** Доступ к WS-уведомлению по роли (согласовано с ROUTE_ACCESS). */
export function canReceiveWsNotification(
type: 'report_created' | 'ticket_created',
role: string | undefined
): boolean {
return canAccessRoute(WS_NOTIFICATION_ROUTES[type], role);
}
+29 -8
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useMetricsStore } from '../store/metricsStore'; import { useMetricsStore } from '../store/metricsStore';
import { canAccessRoute, canReceiveWsNotification } from '../config/adminAccess';
type WsMessage = { type WsMessage = {
type: 'report_created' | 'ticket_created' | 'node_metric'; type: 'report_created' | 'ticket_created' | 'node_metric';
@@ -11,10 +12,17 @@ type WsMessage = {
timestamp?: number; timestamp?: number;
}; };
const wsLog = (...args: unknown[]) => {
if (import.meta.env.DEV) {
console.log(...args);
}
};
export const useAdminWebSocket = () => { export const useAdminWebSocket = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const accessToken = useAuthStore((s) => s.accessToken); const accessToken = useAuthStore((s) => s.accessToken);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const role = useAuthStore((s) => s.user?.role);
const setCurrentMetric = useMetricsStore((s) => s.setCurrent); const setCurrentMetric = useMetricsStore((s) => s.setCurrent);
const addToAllHistory = useMetricsStore((s) => s.addToAllHistory); const addToAllHistory = useMetricsStore((s) => s.addToAllHistory);
@@ -74,9 +82,13 @@ export const useAdminWebSocket = () => {
ws.onopen = () => { ws.onopen = () => {
if (!isMounted || !shouldReconnectRef.current) return; if (!isMounted || !shouldReconnectRef.current) return;
console.log('[WS] Connected'); wsLog('[WS] Connected');
ws.send(JSON.stringify({ action: 'subscribe', channel: 'reports' })); if (canAccessRoute('/reports', role)) {
ws.send(JSON.stringify({ action: 'subscribe', channel: 'tickets' })); ws.send(JSON.stringify({ action: 'subscribe', channel: 'reports' }));
}
if (canAccessRoute('/tickets', role)) {
ws.send(JSON.stringify({ action: 'subscribe', channel: 'tickets' }));
}
pingIntervalRef.current = setInterval(() => { pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
@@ -89,7 +101,7 @@ export const useAdminWebSocket = () => {
if (!isMounted) return; if (!isMounted) return;
try { try {
const msg: WsMessage = JSON.parse(event.data); const msg: WsMessage = JSON.parse(event.data);
console.log('[WS] Message:', msg); wsLog('[WS] Message:', msg);
if (msg.timestamp && msg.timestamp === lastMessageTimestamp.current) { if (msg.timestamp && msg.timestamp === lastMessageTimestamp.current) {
return; return;
@@ -99,9 +111,14 @@ export const useAdminWebSocket = () => {
} }
if (msg.type === 'report_created' || msg.type === 'ticket_created') { if (msg.type === 'report_created' || msg.type === 'ticket_created') {
if (!canReceiveWsNotification(msg.type, role)) {
return;
}
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('admin-ws-message', { detail: msg }) new CustomEvent('admin-ws-message', { detail: msg })
); );
if (msg.type === 'report_created') { if (msg.type === 'report_created') {
queryClient.invalidateQueries({ queryKey: ['reports'] }); queryClient.invalidateQueries({ queryKey: ['reports'] });
} else if (msg.type === 'ticket_created') { } else if (msg.type === 'ticket_created') {
@@ -113,13 +130,15 @@ export const useAdminWebSocket = () => {
addToAllHistory(msg.data); addToAllHistory(msg.data);
} }
} catch (e) { } catch (e) {
console.warn('[WS] Failed to parse message:', e); if (import.meta.env.DEV) {
console.warn('[WS] Failed to parse message:', e);
}
} }
}; };
ws.onclose = (event) => { ws.onclose = (event) => {
if (!isMounted || !shouldReconnectRef.current) return; if (!isMounted || !shouldReconnectRef.current) return;
console.log('[WS] Disconnected:', event.reason); wsLog('[WS] Disconnected:', event.reason);
clearTimers(); clearTimers();
wsRef.current = null; wsRef.current = null;
reconnectTimeoutRef.current = setTimeout(connect, 5000); reconnectTimeoutRef.current = setTimeout(connect, 5000);
@@ -127,7 +146,9 @@ export const useAdminWebSocket = () => {
ws.onerror = (error) => { ws.onerror = (error) => {
if (!isMounted) return; if (!isMounted) return;
console.error('[WS] Error:', error); if (import.meta.env.DEV) {
console.error('[WS] Error:', error);
}
ws.close(); ws.close();
}; };
}; };
@@ -139,5 +160,5 @@ export const useAdminWebSocket = () => {
shouldReconnectRef.current = false; shouldReconnectRef.current = false;
closeSocket(); closeSocket();
}; };
}, [isAuthenticated, accessToken, queryClient, setCurrentMetric, addToAllHistory]); }, [isAuthenticated, accessToken, role, queryClient, setCurrentMetric, addToAllHistory]);
}; };
+8 -2
View File
@@ -23,7 +23,7 @@ import {
CloudServerOutlined, CloudServerOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { MENU_ITEMS, filterMenuByRole, getMenuSelectedKey } from '../config/adminAccess'; import { MENU_ITEMS, filterMenuByRole, getMenuSelectedKey, canReceiveWsNotification } from '../config/adminAccess';
const { Header, Sider, Content } = Layout; const { Header, Sider, Content } = Layout;
const { Text } = Typography; const { Text } = Typography;
@@ -45,6 +45,9 @@ const AdminLayout: React.FC = () => {
const handler = (event: CustomEvent) => { const handler = (event: CustomEvent) => {
const msg = event.detail; const msg = event.detail;
if (msg.type === 'report_created') { if (msg.type === 'report_created') {
if (!canReceiveWsNotification('report_created', user?.role)) {
return;
}
const { report_id, target_type, target_id, reason } = msg.data || {}; const { report_id, target_type, target_id, reason } = msg.data || {};
notification.info({ notification.info({
title: 'Новая жалоба', title: 'Новая жалоба',
@@ -68,6 +71,9 @@ const AdminLayout: React.FC = () => {
placement: 'topRight', placement: 'topRight',
}); });
} else if (msg.type === 'ticket_created') { } else if (msg.type === 'ticket_created') {
if (!canReceiveWsNotification('ticket_created', user?.role)) {
return;
}
const { ticket_id } = msg.data || {}; const { ticket_id } = msg.data || {};
notification.info({ notification.info({
title: 'Новый тикет', title: 'Новый тикет',
@@ -88,7 +94,7 @@ const AdminLayout: React.FC = () => {
}; };
window.addEventListener('admin-ws-message', handler as EventListener); window.addEventListener('admin-ws-message', handler as EventListener);
return () => window.removeEventListener('admin-ws-message', handler as EventListener); return () => window.removeEventListener('admin-ws-message', handler as EventListener);
}, [navigate]); }, [navigate, user?.role]);
const onlineNodes = React.useMemo(() => { const onlineNodes = React.useMemo(() => {
const cutoff = dayjs().subtract(NODE_STATS_THRESHOLD, 'minute'); const cutoff = dayjs().subtract(NODE_STATS_THRESHOLD, 'minute');