Files
ospab.host/ospabhost/frontend/src/pages/qr-login.tsx
2025-12-31 19:59:43 +03:00

293 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { isAxiosError } from 'axios';
import apiClient from '../utils/apiClient';
interface UserData {
id: number;
username: string;
email: string;
}
const QRLoginPage = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [status, setStatus] = useState<'loading' | 'confirm' | 'success' | 'error' | 'expired'>('loading');
const [message, setMessage] = useState('Проверка QR-кода...');
const [userData, setUserData] = useState<UserData | null>(null);
const [remaining, setRemaining] = useState<number>(0);
const [requestInfo, setRequestInfo] = useState<{ ip?: string; ua?: string } | null>(null);
const code = searchParams.get('code');
useEffect(() => {
if (!code) {
setStatus('error');
setMessage('Неверный QR-код');
return;
}
let countdownTimer: ReturnType<typeof setInterval> | null = null;
const updateRemaining = async () => {
try {
const resp = await apiClient.get(`/api/qr-auth/status/${code}`);
if (typeof resp.data.expiresIn === 'number') {
setRemaining(Math.max(0, Math.ceil(resp.data.expiresIn)));
}
// Set request info if provided
if (resp.data.ipAddress || resp.data.userAgent) {
setRequestInfo({ ip: resp.data.ipAddress, ua: resp.data.userAgent });
}
if (resp.data.status === 'expired') {
setStatus('expired');
setMessage('QR-код истёк');
return false;
}
return true;
} catch (err) {
console.error('Ошибка при обновлении remaining:', err);
return false;
}
};
const checkAuth = async () => {
try {
setStatus('loading');
setMessage('Проверка авторизации...');
// Получаем токен из localStorage
const token = localStorage.getItem('access_token');
if (!token) {
setStatus('error');
setMessage('Вы не авторизованы. Войдите в аккаунт на телефоне');
return;
}
try {
await apiClient.post('/api/qr-auth/scanning', { code });
} catch (err) {
console.log('Не удалось обновить статус на scanning:', err);
}
// Получаем данные текущего пользователя
const userResponse = await apiClient.get('/api/auth/me');
setUserData(userResponse.data.user);
setStatus('confirm');
setMessage('Подтвердите вход на новом устройстве');
// Start countdown for confirmation page
await updateRemaining();
countdownTimer = setInterval(async () => {
await updateRemaining();
// decrease visible counter only if updateRemaining didn't set a new value
setRemaining((prev: number) => {
if (prev <= 1) {
if (countdownTimer) clearInterval(countdownTimer);
setStatus('expired');
setMessage('QR-код истёк');
return 0;
}
return prev - 1;
});
}, 1000);
} catch (error) {
console.error('Ошибка проверки авторизации:', error);
if (isAxiosError(error) && error.response?.status === 401) {
setStatus('error');
setMessage('Вы не авторизованы. Войдите в аккаунт на телефоне');
} else if (isAxiosError(error) && error.response?.status === 500) {
setStatus('error');
setMessage('Серверная ошибка при проверке QR-кода');
} else {
setStatus('error');
setMessage('Ошибка проверки авторизации');
}
}
};
checkAuth();
return () => {
if (countdownTimer) clearInterval(countdownTimer);
};
}, [code]);
const handleConfirm = async () => {
try {
setStatus('loading');
setMessage('Подтверждение входа...');
const response = await apiClient.post('/api/qr-auth/confirm', { code });
if (response.data.success) {
setStatus('success');
setMessage('Вход успешно подтверждён!');
// Перенаправление на главную через 2 секунды
setTimeout(() => {
window.close(); // Попытка закрыть вкладку если открыта из QR-сканера
}, 2000);
}
} catch (error) {
console.error('Ошибка подтверждения:', error);
if (isAxiosError(error) && error.response?.status === 401) {
setStatus('error');
setMessage('Вы не авторизованы. Войдите в аккаунт на телефоне');
} else if (isAxiosError(error) && (error.response?.status === 404 || error.response?.status === 410)) {
setStatus('expired');
setMessage('QR-код истёк или уже использован');
} else if (isAxiosError(error) && error.response?.data && typeof error.response.data === 'object' && 'error' in error.response.data) {
const data = error.response.data as { error?: string };
setStatus('error');
setMessage(data.error ?? 'Ошибка подтверждения входа');
} else {
setStatus('error');
setMessage('Ошибка подтверждения входа');
}
}
};
const getIcon = () => {
switch (status) {
case 'loading':
return (
<div className="animate-spin rounded-full h-16 w-16 border-b-4 border-blue-500 mx-auto"></div>
);
case 'confirm':
return (
<div className="w-20 h-20 bg-blue-100 rounded-full flex items-center justify-center mx-auto">
<svg className="w-12 h-12 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
);
case 'success':
return <div className="text-6xl text-green-500"></div>;
case 'expired':
return <div className="text-6xl text-orange-500"></div>;
case 'error':
return <div className="text-6xl text-red-500"></div>;
}
};
const getColor = () => {
switch (status) {
case 'loading':
return 'text-blue-600';
case 'confirm':
return 'text-gray-800';
case 'success':
return 'text-green-600';
case 'expired':
return 'text-orange-600';
case 'error':
return 'text-red-600';
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-md text-center">
<div className="mb-6">
{getIcon()}
</div>
<h1 className={`text-2xl font-bold mb-4 ${getColor()}`}>
{status === 'loading' && 'Проверка'}
{status === 'confirm' && 'Подтвердите вход'}
{status === 'success' && 'Успешно!'}
{status === 'expired' && 'QR-код истёк'}
{status === 'error' && 'Ошибка'}
</h1>
{status === 'confirm' && userData && (
<div className="mb-6">
<div className="bg-gray-50 rounded-xl p-6 mb-4">
<p className="text-gray-600 mb-2">Войти на новом устройстве как:</p>
<p className="text-xl font-bold text-gray-900">{userData.username}</p>
<p className="text-sm text-gray-500">{userData.email}</p>
</div>
{/* Device info */}
<div className="mb-4 text-sm text-gray-600">
<div className="mb-2">Детали запроса:</div>
<div className="bg-white p-3 rounded-lg border border-gray-100 text-xs text-gray-700">
<div>IP: <span className="font-medium">{requestInfo?.ip ?? '—'}</span></div>
<div className="mt-1">Device: <span className="font-medium">{requestInfo?.ua ?? '—'}</span></div>
</div>
</div>
{/* Mobile-friendly confirmation with timer */}
<div className="mb-4">
<p className="text-gray-600 text-sm mb-3">{remaining > 0 ? `Осталось времени: ${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, '0')}` : 'QR-код истёк'}</p>
<div className="flex gap-3">
<button
onClick={async () => {
try {
await apiClient.post('/api/qr-auth/reject', { code });
setStatus('error');
setMessage('Вход отклонён');
} catch (err) {
console.error('Ошибка отклонения:', err);
setMessage('Не удалось отклонить вход');
}
}}
className="flex-1 bg-gray-200 hover:bg-gray-300 text-gray-700 px-6 py-4 rounded-lg font-medium transition-colors duration-200 text-sm"
>
Отклонить
</button>
<button
onClick={handleConfirm}
disabled={remaining <= 0}
className={`flex-1 ${remaining > 0 ? 'bg-blue-500 hover:bg-blue-600 text-white' : 'bg-gray-200 text-gray-500 cursor-not-allowed'} px-6 py-4 rounded-lg font-medium transition-colors duration-200 text-sm`}
>
Подтвердить
</button>
</div>
</div>
{remaining <= 0 && (
<div className="mt-3 text-center">
<button
onClick={() => window.open('/login?qr=1', '_blank')}
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200"
>
Сгенерировать QR заново (открыть страницу входа)
</button>
</div>
)}
</div>
)}
{status !== 'confirm' && (
<p className="text-gray-600 mb-6">
{message}
</p>
)}
{status === 'success' && (
<p className="text-sm text-gray-500">
Вы можете закрыть эту страницу
</p>
)}
{(status === 'error' || status === 'expired') && (
<button
onClick={() => navigate('/login')}
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200"
>
Вернуться к входу
</button>
)}
</div>
</div>
);
};
export default QRLoginPage;