From f8d78afd2e20ed7ab667660938c73667c852ea7e 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: Mon, 21 Sep 2026 18:13:47 +0300 Subject: [PATCH] =?UTF-8?q?feat(auth):=20retry=20=D1=80=D0=B5=D1=84=D1=80?= =?UTF-8?q?=D0=B5=D1=88=D0=B0=20=D0=BF=D1=80=D0=B8=20401=20+=20=D0=B2?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0=20=C2=AB=D0=A1=D0=B5=D1=81?= =?UTF-8?q?=D1=81=D0=B8=D0=B8=C2=BB=20=D0=B2=20=D0=BF=D1=80=D0=BE=D1=84?= =?UTF-8?q?=D0=B8=D0=BB=D0=B5=20=D1=81=20=D0=BE=D1=82=D0=B7=D1=8B=D0=B2?= =?UTF-8?q?=D0=BE=D0=BC=20=D1=83=D1=81=D1=82=D1=80=D0=BE=D0=B9=D1=81=D1=82?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/profile/ProfileSessionsTab.tsx | 130 ++++++++++++++++++ client/src/lib/queryClient.ts | 25 +++- client/src/pages/UserProfilePage.tsx | 9 +- 3 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 client/src/components/profile/ProfileSessionsTab.tsx diff --git a/client/src/components/profile/ProfileSessionsTab.tsx b/client/src/components/profile/ProfileSessionsTab.tsx new file mode 100644 index 0000000..0af2de3 --- /dev/null +++ b/client/src/components/profile/ProfileSessionsTab.tsx @@ -0,0 +1,130 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Monitor, Smartphone, LogOut, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { apiRequest } from '@/lib/queryClient'; +import { useToast } from '@/hooks/use-toast'; + +interface UserSession { + id: number; + createdAt: string; + expiresAt: string; + ipAddress: string | null; + userAgent: string | null; + isCurrent: boolean; +} + +/** Короткое описание устройства из UserAgent */ +function deviceLabel(ua: string | null): string { + if (!ua) return 'Неизвестное устройство'; + const browser = ua.includes('Firefox') ? 'Firefox' + : ua.includes('Edg/') ? 'Edge' + : ua.includes('Chrome') ? 'Chrome' + : ua.includes('Safari') ? 'Safari' + : 'Браузер'; + const os = ua.includes('Windows') ? 'Windows' + : ua.includes('Android') ? 'Android' + : ua.includes('iPhone') || ua.includes('iPad') ? 'iOS' + : ua.includes('Mac OS') ? 'macOS' + : ua.includes('Linux') ? 'Linux' + : ''; + const pwa = ua.includes('iistwin-pwa') || ua.includes('wv') ? ' · приложение' : ''; + return `${browser}${os ? ' · ' + os : ''}${pwa}`; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString('ru-RU', { + timeZone: 'Europe/Moscow', + day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', + }); +} + +/** + * Активные сессии пользователя (устройства) с возможностью отзыва — + * аналог «отозвать ключи устройства» в Pyrus. + * Отозванное устройство теряет доступ в течение ~30 секунд. + */ +export function ProfileSessionsTab({ userId }: { userId: number }) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + + const { data, isLoading } = useQuery<{ success: boolean; sessions: UserSession[] }>({ + queryKey: ['/api/users', userId, 'sessions'], + queryFn: async () => (await apiRequest('GET', `/api/users/${userId}/sessions`)).json(), + }); + + const revokeMutation = useMutation({ + mutationFn: async (sessionId: number) => { + await apiRequest('DELETE', `/api/auth/sessions/${sessionId}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/api/users', userId, 'sessions'] }); + toast({ title: 'Сессия отозвана', description: 'Устройство потеряет доступ в течение ~30 секунд' }); + }, + onError: (error: any) => { + toast({ title: 'Не удалось отозвать', description: error?.message || 'Ошибка', variant: 'destructive' }); + }, + }); + + const revokeAll = async () => { + const sessions = data?.sessions || []; + for (const s of sessions) { + if (!s.isCurrent) await revokeMutation.mutateAsync(s.id).catch(() => {}); + } + }; + + const sessions = data?.sessions || []; + + return ( +
+
+

+ Активные устройства. Отзыв действует в течение ~30 секунд. +

+ {sessions.filter(s => !s.isCurrent).length > 0 && ( + + )} +
+ {isLoading ? ( +

Загрузка…

+ ) : sessions.length === 0 ? ( +

Нет активных сессий

+ ) : ( + sessions.map((s) => { + const isMobile = s.userAgent?.includes('Android') || s.userAgent?.includes('iPhone'); + const Icon = isMobile ? Smartphone : Monitor; + return ( +
+ +
+
+ {deviceLabel(s.userAgent)} + {s.isCurrent && Текущая} +
+
+ {s.ipAddress ? `${s.ipAddress} · ` : ''}вход {formatDate(s.createdAt)} +
+
+ {!s.isCurrent && ( + + )} +
+ ); + }) + )} +
+ ); +} diff --git a/client/src/lib/queryClient.ts b/client/src/lib/queryClient.ts index b11f552..0681263 100644 --- a/client/src/lib/queryClient.ts +++ b/client/src/lib/queryClient.ts @@ -194,9 +194,28 @@ export async function refreshSession(): Promise { result = { ok: false, reason: 'transient' }; } } else if (res.status === 401 || res.status === 403 || res.status === 400) { - // 401/403 — сервер явно отклонил сессию; 400 — refresh-cookie - // отсутствует вовсе (истёк по сроку/вычищен). Все три — сессия мертва. - result = { ok: false, reason: 'unauthorized' }; + // Гонка ротации между вкладками: winner-ответ мог ещё не приземлиться + // в cookie- jar. Одна повторная попытка через ~1 сек — сервер в гонках + // специально отвечает «повтори ею» (сессия жива). Только после неё — logout. + await new Promise((r) => setTimeout(r, 1000)); + const retryRes = await fetchWithTimeout( + '/api/auth/refresh', + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), credentials: 'include' }, + 5000 + ); + if (retryRes.ok) { + const data = await retryRes.json(); + if (data.success && data.user) { + authService.setCachedUser(data.user); + result = { ok: true }; + } else { + result = { ok: false, reason: 'transient' }; + } + } else if (retryRes.status === 401 || retryRes.status === 403 || retryRes.status === 400) { + result = { ok: false, reason: 'unauthorized' }; + } else { + result = { ok: false, reason: 'transient' }; + } } else { // 429 (rate limit), 5xx — временный сбой, сессия может быть жива. console.warn(`Token refresh got transient HTTP ${res.status}`); diff --git a/client/src/pages/UserProfilePage.tsx b/client/src/pages/UserProfilePage.tsx index f8c510f..3557041 100644 --- a/client/src/pages/UserProfilePage.tsx +++ b/client/src/pages/UserProfilePage.tsx @@ -26,6 +26,7 @@ import { } from '@/components/profile/ProfileFieldRow'; import { ProfileNotificationsTab } from '@/components/profile/ProfileNotificationsTab'; import { ProfileOfflineTab } from '@/components/profile/ProfileOfflineTab'; +import { ProfileSessionsTab } from '@/components/profile/ProfileSessionsTab'; import { ProfileInterfaceTab } from '@/components/profile/ProfileInterfaceTab'; import { ProfileSecurityTab } from '@/components/profile/ProfileSecurityTab'; import { ProfileGpsTab } from '@/components/profile/ProfileGpsTab'; @@ -821,11 +822,12 @@ export default function UserProfilePage({ userId: userIdProp }: { userId?: numbe Досье История - {/* Уведомления и Offline — для себя и для админа на чужой карточке */} + {/* Уведомления, Offline и Сессии — для себя и для админа на чужой карточке */} {(isSelf || isAdminView) && ( <> Уведомления Offline + Сессии )} {/* Интерфейс, Безопасность и GPS — только на своём профиле: @@ -857,7 +859,7 @@ export default function UserProfilePage({ userId: userIdProp }: { userId?: numbe )} - {/* Уведомления и Offline — свой профиль или админский просмотр чужого */} + {/* Уведомления, Offline и Сессии — свой профиль или админский просмотр чужого */} {(isSelf || isAdminView) && ( <> @@ -866,6 +868,9 @@ export default function UserProfilePage({ userId: userIdProp }: { userId?: numbe + + + )} {/* Интерфейс, Безопасность и GPS — только на своём профиле */}