feat(auth): retry рефреша при 401 + вкладка «Сессии» в профиле с отзывом устройств
This commit is contained in:
130
client/src/components/profile/ProfileSessionsTab.tsx
Normal file
130
client/src/components/profile/ProfileSessionsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 — только на своём профиле */}
|
||||
|
||||
Reference in New Issue
Block a user