feat(auth): retry рефреша при 401 + вкладка «Сессии» в профиле с отзывом устройств

This commit is contained in:
2026-09-21 18:13:47 +03:00
parent c957a70260
commit f8d78afd2e
3 changed files with 159 additions and 5 deletions

View File

@@ -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 (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Активные устройства. Отзыв действует в течение ~30 секунд.
</p>
{sessions.filter(s => !s.isCurrent).length > 0 && (
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={revokeAll} disabled={revokeMutation.isPending}>
<LogOut className="w-3.5 h-3.5 mr-1" />
Отозвать все кроме текущей
</Button>
)}
</div>
{isLoading ? (
<p className="text-xs text-muted-foreground py-4 text-center">Загрузка…</p>
) : sessions.length === 0 ? (
<p className="text-xs text-muted-foreground py-4 text-center">Нет активных сессий</p>
) : (
sessions.map((s) => {
const isMobile = s.userAgent?.includes('Android') || s.userAgent?.includes('iPhone');
const Icon = isMobile ? Smartphone : Monitor;
return (
<div key={s.id} className="flex items-center gap-3 rounded-lg border px-3 py-2">
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs font-medium flex items-center gap-1.5">
<span className="truncate">{deviceLabel(s.userAgent)}</span>
{s.isCurrent && <Badge variant="secondary" className="text-[10px]">Текущая</Badge>}
</div>
<div className="text-[10px] text-muted-foreground truncate">
{s.ipAddress ? `${s.ipAddress} · ` : ''}вход {formatDate(s.createdAt)}
</div>
</div>
{!s.isCurrent && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive shrink-0"
title="Отозвать доступ устройства"
disabled={revokeMutation.isPending}
onClick={() => revokeMutation.mutate(s.id)}
data-testid={`button-revoke-session-${s.id}`}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</div>
);
})
)}
</div>
);
}

View File

@@ -194,9 +194,28 @@ export async function refreshSession(): Promise<RefreshResult> {
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}`);

View File

@@ -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
<TabsList className="flex flex-wrap justify-start gap-1 h-auto bg-transparent p-0 mb-2">
<TabsTrigger value="dossier" className={sectionChipClass}>Досье</TabsTrigger>
<TabsTrigger value="history" className={sectionChipClass}>История</TabsTrigger>
{/* Уведомления и Offline — для себя и для админа на чужой карточке */}
{/* Уведомления, Offline и Сессии — для себя и для админа на чужой карточке */}
{(isSelf || isAdminView) && (
<>
<TabsTrigger value="notifications" className={sectionChipClass}>Уведомления</TabsTrigger>
<TabsTrigger value="offline" className={sectionChipClass}>Offline</TabsTrigger>
<TabsTrigger value="sessions" className={sectionChipClass}>Сессии</TabsTrigger>
</>
)}
{/* Интерфейс, Безопасность и GPS — только на своём профиле:
@@ -857,7 +859,7 @@ export default function UserProfilePage({ userId: userIdProp }: { userId?: numbe
)}
</TabsContent>
{/* Уведомления и Offline — свой профиль или админский просмотр чужого */}
{/* Уведомления, Offline и Сессии — свой профиль или админский просмотр чужого */}
{(isSelf || isAdminView) && (
<>
<TabsContent value="notifications" className="space-y-4">
@@ -866,6 +868,9 @@ export default function UserProfilePage({ userId: userIdProp }: { userId?: numbe
<TabsContent value="offline" className="space-y-4">
<ProfileOfflineTab userId={userId} isSelf={isSelf} />
</TabsContent>
<TabsContent value="sessions" className="space-y-4">
<ProfileSessionsTab userId={userId} />
</TabsContent>
</>
)}
{/* Интерфейс, Безопасность и GPS — только на своём профиле */}