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 — только на своём профиле */}