add qr code auth

This commit is contained in:
2025-12-20 17:11:23 +05:00
parent 3aa99e7262
commit 64b6129713
3 changed files with 442 additions and 72 deletions

View File

@ -511,6 +511,59 @@ export async function toggleBonusActivation(
return await response.json(); return await response.json();
} }
// ===== QR AUTH =====
export type QrInitResponse = {
token: string;
qr_url: string;
expires_at: string; // ISO
};
export type QrStatusPending = {
status: 'pending' | 'approved' | 'expired' | 'consumed';
};
export type QrStatusOk = {
status: 'ok';
accessToken: string;
clientToken: string;
selectedProfile: { id: string; name: string };
};
export type QrStatusResponse = QrStatusPending | QrStatusOk;
export async function qrInit(device_id?: string): Promise<QrInitResponse> {
const url = device_id
? `${API_BASE_URL}/auth/qr/init?device_id=${encodeURIComponent(device_id)}`
: `${API_BASE_URL}/auth/qr/init`;
const response = await fetch(url, { method: 'POST' });
if (!response.ok) {
throw new Error('Не удалось создать QR-логин');
}
return await response.json();
}
export async function qrStatus(
token: string,
device_id?: string,
): Promise<QrStatusResponse> {
const qs = new URLSearchParams({ token });
if (device_id) qs.set('device_id', device_id);
const response = await fetch(
`${API_BASE_URL}/auth/qr/status?${qs.toString()}`,
);
if (!response.ok) {
throw new Error('Не удалось проверить статус QR-логина');
}
return await response.json();
}
// ===== КЕЙСЫ ===== // ===== КЕЙСЫ =====
export interface CaseItemMeta { export interface CaseItemMeta {

View File

@ -1,10 +1,17 @@
import { useState } from "react"; import { useState } from 'react';
import { Box, Button, TextField, Typography, InputAdornment, IconButton } from '@mui/material'; import {
Box,
Button,
TextField,
Typography,
InputAdornment,
IconButton,
} from '@mui/material';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityIcon from '@mui/icons-material/Visibility';
import GradientTextField from '../GradientTextField'; import GradientTextField from '../GradientTextField';
import GradientVisibilityToggleIcon from '../../assets/Icons/GradientVisibilityToggleIcon' import GradientVisibilityToggleIcon from '../../assets/Icons/GradientVisibilityToggleIcon';
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom';
interface AuthFormProps { interface AuthFormProps {
config: { config: {
@ -35,13 +42,13 @@ const AuthForm = ({ config, handleInputChange, onLogin }: AuthFormProps) => {
onChange={handleInputChange} onChange={handleInputChange}
sx={{ sx={{
mt: '2.5vw', mt: '2.5vw',
mb: '0vw' mb: '0vw',
}} }}
/> />
<GradientTextField <GradientTextField
label="Пароль" label="Пароль"
required required
type={showPassword ? "text" : "password"} type={showPassword ? 'text' : 'password'}
name="password" name="password"
value={config.password} value={config.password}
onChange={handleInputChange} onChange={handleInputChange}
@ -62,36 +69,40 @@ const AuthForm = ({ config, handleInputChange, onLogin }: AuthFormProps) => {
onClick={() => setShowPassword((prev) => !prev)} onClick={() => setShowPassword((prev) => !prev)}
edge="end" edge="end"
sx={{ sx={{
color: "white", color: 'white',
margin: '0', margin: '0',
padding: '0', padding: '0',
'& MuiTouchRipple-root css-r3djoj-MuiTouchRipple-root': { '& MuiTouchRipple-root css-r3djoj-MuiTouchRipple-root': {
display: 'none', display: 'none',
}, },
}}> }}
>
<GradientVisibilityToggleIcon <GradientVisibilityToggleIcon
crossed={showPassword} // когда type="text" -> перечеркнуть crossed={showPassword} // когда type="text" -> перечеркнуть
sx={{ fontSize: "2.5vw", mr: '0.5vw' }} sx={{ fontSize: '2.5vw', mr: '0.5vw' }}
/> />
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
), ),
}} }}
/> />
<Button onClick={onLogin} variant="contained" <Button
onClick={onLogin}
variant="contained"
sx={{ sx={{
transition: 'transform 0.3s ease', transition: 'transform 0.3s ease',
width: '60%', width: '60%',
mt: 2, mt: 2,
background: 'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)', background:
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
fontFamily: 'Benzin-Bold', fontFamily: 'Benzin-Bold',
borderRadius: '2.5vw', borderRadius: '2.5vw',
fontSize: '2vw', fontSize: '2vw',
'&:hover': { '&:hover': {
transform: 'scale(1.02)', transform: 'scale(1.02)',
}, },
}}> }}
>
Войти Войти
</Button> </Button>
<Box <Box

View File

@ -1,10 +1,9 @@
import { Box } from '@mui/material'; import { Box, Button, Typography } from '@mui/material';
import useAuth from '../hooks/useAuth'; import useAuth from '../hooks/useAuth';
import AuthForm from '../components/Login/AuthForm';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import PopaPopa from '../components/popa-popa'; import PopaPopa from '../components/popa-popa';
import useConfig from '../hooks/useConfig'; import useConfig from '../hooks/useConfig';
import { useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { FullScreenLoader } from '../components/FullScreenLoader'; import { FullScreenLoader } from '../components/FullScreenLoader';
import React from 'react'; import React from 'react';
@ -15,6 +14,14 @@ import {
getNotifPositionFromSettings, getNotifPositionFromSettings,
} from '../utils/notifications'; } from '../utils/notifications';
// как в registration
import QRCodeStyling from 'qr-code-styling';
import popalogo from '../../../assets/icons/popa-popa.svg';
import GradientTextField from '../components/GradientTextField';
// твои API методы
import { qrInit, qrStatus } from '../api';
interface LoginProps { interface LoginProps {
onLoginSuccess?: (username: string) => void; onLoginSuccess?: (username: string) => void;
} }
@ -25,7 +32,30 @@ const Login = ({ onLoginSuccess }: LoginProps) => {
const auth = useAuth(); const auth = useAuth();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Snackbar / Notification states (как в LaunchPage) // ===== UI mode: по умолчанию QR, парольная форма показывается по кнопке =====
const [showPasswordLogin, setShowPasswordLogin] = useState(false);
// ===== QR =====
const [qrLoading, setQrLoading] = useState(false);
const [qrUrl, setQrUrl] = useState<string>('');
const qrRef = useRef<HTMLDivElement | null>(null);
const pollTimerRef = useRef<number | null>(null);
// хранит один инстанс QRCodeStyling
const qrInstanceRef = useRef<QRCodeStyling | null>(null);
const deviceId = useMemo(() => {
const key = 'qr_device_id';
const existing = localStorage.getItem(key);
if (existing) return existing;
const v = (
crypto?.randomUUID?.() ?? `${Date.now()}_${Math.random()}`
).toString();
localStorage.setItem(key, v);
return v;
}, []);
// ===== Notifications =====
const [notifOpen, setNotifOpen] = useState(false); const [notifOpen, setNotifOpen] = useState(false);
const [notifMsg, setNotifMsg] = useState<React.ReactNode>(''); const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
const [notifSeverity, setNotifSeverity] = useState< const [notifSeverity, setNotifSeverity] = useState<
@ -49,16 +79,140 @@ const Login = ({ onLoginSuccess }: LoginProps) => {
setNotifOpen(true); setNotifOpen(true);
}; };
const stopQrPolling = () => {
if (pollTimerRef.current) {
window.clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
};
useEffect(() => {
return () => stopQrPolling();
}, []);
// создаём QR инстанс с теми же настройками, что в registration
useEffect(() => {
if (!qrInstanceRef.current) {
qrInstanceRef.current = new QRCodeStyling({
width: 300,
height: 300,
image: popalogo,
data: 'https://t.me/popa_popa_popa_bot?start=test',
shape: 'square',
margin: 10,
dotsOptions: {
gradient: {
type: 'linear',
colorStops: [
{ offset: 0, color: 'rgb(242,113,33)' },
{ offset: 1, color: 'rgb(233,64,87)' },
],
},
type: 'extra-rounded',
},
imageOptions: {
crossOrigin: 'anonymous',
margin: 20,
imageSize: 0.5,
},
backgroundOptions: {
color: 'transparent',
},
});
}
}, []);
// аппендим QR в контейнер, когда мы на QR-экране
useEffect(() => {
if (showPasswordLogin) return;
if (!qrRef.current) return;
if (!qrInstanceRef.current) return;
while (qrRef.current.firstChild) {
qrRef.current.removeChild(qrRef.current.firstChild);
}
qrInstanceRef.current.append(qrRef.current);
}, [showPasswordLogin]);
// при изменении URL обновляем data в QR
useEffect(() => {
if (!qrInstanceRef.current) return;
if (!qrUrl) return;
qrInstanceRef.current.update({ data: qrUrl });
}, [qrUrl]);
const startQrLogin = async () => {
setQrLoading(true);
setQrUrl('');
stopQrPolling();
try {
const init = await qrInit(deviceId);
setQrUrl(init.qr_url);
pollTimerRef.current = window.setInterval(async () => {
try {
const res = await qrStatus(init.token, deviceId);
if (res.status === 'ok') {
stopQrPolling();
saveConfig({
accessToken: res.accessToken,
clientToken: res.clientToken,
username: res.selectedProfile?.name ?? config.username,
});
if (onLoginSuccess) {
onLoginSuccess(res.selectedProfile?.name ?? config.username);
}
showNotification('Успешный вход через QR', 'success');
navigate('/');
return;
}
if (res.status === 'expired') {
stopQrPolling();
showNotification('QR-код истёк. Нажми “Обновить QR”.', 'warning');
}
} catch {
// transient ошибки игнорим, следующий тик повторит
}
}, 2000);
} catch (e: any) {
showNotification(e?.message || 'Не удалось запустить QR-вход', 'error');
} finally {
setQrLoading(false);
}
};
// автозапуск QR при открытии страницы
useEffect(() => {
startQrLogin();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const goToPasswordLogin = () => {
stopQrPolling();
setShowPasswordLogin(true);
};
const backToQr = () => {
setShowPasswordLogin(false);
startQrLogin(); // перегенерим свежий QR при возврате
};
// ===== Password login =====
const mapAuthErrorToMessage = (error: any): string => { const mapAuthErrorToMessage = (error: any): string => {
const raw = error?.message ? String(error.message) : String(error); const raw = error?.message ? String(error.message) : String(error);
// сеть
if (raw.includes('Failed to fetch') || raw.includes('NetworkError')) { if (raw.includes('Failed to fetch') || raw.includes('NetworkError')) {
return 'Сервер недоступен'; return 'Сервер недоступен';
} }
// вытащим JSON после статуса
// пример: "Ошибка авторизации: 401 {"detail":"Invalid credentials"}"
const jsonStart = raw.indexOf('{'); const jsonStart = raw.indexOf('{');
if (jsonStart !== -1) { if (jsonStart !== -1) {
const jsonStr = raw.slice(jsonStart); const jsonStr = raw.slice(jsonStart);
@ -66,16 +220,14 @@ const Login = ({ onLoginSuccess }: LoginProps) => {
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
const detail = data?.detail; const detail = data?.detail;
if (detail === 'Invalid credentials') return 'Неверный логин или пароль'; if (detail === 'Invalid credentials')
return 'Неверный логин или пароль';
if (detail === 'User not verified') return 'Аккаунт не подтверждён'; if (detail === 'User not verified') return 'Аккаунт не подтверждён';
if (typeof detail === 'string' && detail.trim()) return detail; if (typeof detail === 'string' && detail.trim()) return detail;
} catch { } catch {}
// если это не JSON — идём дальше
}
} }
// если бэк вернул просто строку без JSON
if (raw.includes('Invalid credentials')) return 'Неверный логин или пароль'; if (raw.includes('Invalid credentials')) return 'Неверный логин или пароль';
if (raw.includes('User not verified')) return 'Аккаунт не подтверждён'; if (raw.includes('User not verified')) return 'Аккаунт не подтверждён';
@ -95,20 +247,17 @@ const Login = ({ onLoginSuccess }: LoginProps) => {
setLoading(true); setLoading(true);
try { try {
// Проверяем существующий токен
if (config.accessToken && config.clientToken) { if (config.accessToken && config.clientToken) {
const isValid = await auth.validateSession(config.accessToken); const isValid = await auth.validateSession(config.accessToken);
if (!isValid) { if (!isValid) {
// Пробуем обновить
const refreshed = await auth.refreshSession( const refreshed = await auth.refreshSession(
config.accessToken, config.accessToken,
config.clientToken, config.clientToken,
); );
if (!refreshed) { if (!refreshed) {
// Нужна новая авторизация await auth.authenticateUser(
const session = await auth.authenticateUser(
config.username, config.username,
config.password, config.password,
saveConfig, saveConfig,
@ -116,21 +265,15 @@ const Login = ({ onLoginSuccess }: LoginProps) => {
} }
} }
} else { } else {
// Новая авторизация await auth.authenticateUser(
const session = await auth.authenticateUser(
config.username, config.username,
config.password, config.password,
saveConfig, saveConfig,
); );
} }
if (onLoginSuccess) { if (onLoginSuccess) onLoginSuccess(config.username);
onLoginSuccess(config.username);
}
// (опционально) можно показать успех
showNotification('Успешный вход', 'success'); showNotification('Успешный вход', 'success');
navigate('/'); navigate('/');
} catch (error: any) { } catch (error: any) {
console.error('Ошибка авторизации:', error); console.error('Ошибка авторизации:', error);
@ -147,18 +290,181 @@ const Login = ({ onLoginSuccess }: LoginProps) => {
} }
}; };
const gradientTextSx = {
fontFamily: 'Benzin-Bold',
fontSize: '1.5vw',
textTransform: 'uppercase',
letterSpacing: '0.08em',
cursor: 'pointer',
backgroundImage:
'linear-gradient(71deg, #F27121 0%, #E940CD 60%, #8A2387 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
textShadow: '0 0 15px rgba(0,0,0,0.9)',
'&:hover': { opacity: 1 },
} as const;
const primaryButtonSx = {
transition: 'transform 0.3s ease',
width: '60%',
mt: 2,
background: 'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
fontFamily: 'Benzin-Bold',
borderRadius: '2.5vw',
fontSize: '2vw',
'&:hover': { transform: 'scale(1.1)' },
} as const;
return ( return (
<Box> <Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: '1vh',
width: '50vw',
mx: 'auto',
}}
>
{loading ? ( {loading ? (
<FullScreenLoader message="Входим..." /> <FullScreenLoader message="Входим..." />
) : ( ) : (
<> <>
<PopaPopa /> <PopaPopa />
<AuthForm
config={config} {/* ===== QR экран по умолчанию ===== */}
handleInputChange={handleInputChange} {!showPasswordLogin ? (
onLogin={handleLogin} <Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
alignItems: 'center',
}}
>
<Typography
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '2vw',
backgroundImage:
'linear-gradient( 136deg, rgb(242,113,33) 0%, rgb(233,64,87) 50%, rgb(138,35,135) 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
Вход через Telegram
</Typography>
<Button
variant="contained"
sx={primaryButtonSx}
onClick={() => qrUrl && window.open(qrUrl, '_blank')}
disabled={!qrUrl}
>
Открыть бота
</Button>
{/* QR контейнер */}
<div ref={qrRef} style={{ minHeight: 300 }} />
{qrLoading ? (
<FullScreenLoader fullScreen={false} />
) : (
<Typography variant="body1">
Ждем подтверждения в боте
</Typography>
)}
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
gap: '2vh',
}}
>
<Typography onClick={startQrLogin} sx={gradientTextSx}>
Обновить QR
</Typography>
<Typography onClick={goToPasswordLogin} sx={gradientTextSx}>
Войти по логину и паролю
</Typography>
<Typography
onClick={() => navigate('/registration')}
sx={gradientTextSx}
>
Зарегистрироваться
</Typography>
</Box>
</Box>
) : (
/* ===== экран логин/пароль (в стиле Registration.tsx) ===== */
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1,
}}
>
<Typography
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '2vw',
backgroundImage:
'linear-gradient( 136deg, rgb(242,113,33) 0%, rgb(233,64,87) 50%, rgb(138,35,135) 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
Вход по логину и паролю
</Typography>
<GradientTextField
label="Никнейм"
required
name="username"
value={config.username}
onChange={handleInputChange}
/> />
<GradientTextField
label="Пароль"
required
name="password"
type="password"
value={config.password}
onChange={handleInputChange}
/>
<Button
variant="contained"
sx={primaryButtonSx}
onClick={handleLogin}
>
Войти
</Button>
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
gap: '2vh',
mt: 2,
}}
>
<Typography
onClick={() => navigate('/registration')}
sx={gradientTextSx}
>
Зарегистрироваться
</Typography>
<Typography onClick={backToQr} sx={gradientTextSx}>
Назад к QR
</Typography>
</Box>
</Box>
)}
</> </>
)} )}