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();
}
// ===== 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 {

View File

@ -1,10 +1,17 @@
import { useState } from "react";
import { Box, Button, TextField, Typography, InputAdornment, IconButton } from '@mui/material';
import { useState } from 'react';
import {
Box,
Button,
TextField,
Typography,
InputAdornment,
IconButton,
} from '@mui/material';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import VisibilityIcon from '@mui/icons-material/Visibility';
import GradientTextField from '../GradientTextField';
import GradientVisibilityToggleIcon from '../../assets/Icons/GradientVisibilityToggleIcon'
import { useNavigate } from 'react-router-dom'
import GradientVisibilityToggleIcon from '../../assets/Icons/GradientVisibilityToggleIcon';
import { useNavigate } from 'react-router-dom';
interface AuthFormProps {
config: {
@ -35,22 +42,22 @@ const AuthForm = ({ config, handleInputChange, onLogin }: AuthFormProps) => {
onChange={handleInputChange}
sx={{
mt: '2.5vw',
mb: '0vw'
mb: '0vw',
}}
/>
<GradientTextField
label="Пароль"
required
type={showPassword ? "text" : "password"}
type={showPassword ? 'text' : 'password'}
name="password"
value={config.password}
onChange={handleInputChange}
sx={{
'& .MuiInputBase-input': {
color: 'white',
padding: '1rem 0.7rem 1.1rem 1.5rem',
fontFamily: 'Benzin-Bold',
},
color: 'white',
padding: '1rem 0.7rem 1.1rem 1.5rem',
fontFamily: 'Benzin-Bold',
},
}}
InputProps={{
endAdornment: (
@ -62,43 +69,47 @@ const AuthForm = ({ config, handleInputChange, onLogin }: AuthFormProps) => {
onClick={() => setShowPassword((prev) => !prev)}
edge="end"
sx={{
color: "white",
color: 'white',
margin: '0',
padding: '0',
'& MuiTouchRipple-root css-r3djoj-MuiTouchRipple-root': {
display: 'none',
},
}}>
}}
>
<GradientVisibilityToggleIcon
crossed={showPassword} // когда type="text" -> перечеркнуть
sx={{ fontSize: "2.5vw", mr: '0.5vw' }}
crossed={showPassword} // когда type="text" -> перечеркнуть
sx={{ fontSize: '2.5vw', mr: '0.5vw' }}
/>
</IconButton>
</InputAdornment>
),
}}
/>
<Button onClick={onLogin} variant="contained"
sx={{
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.02)',
},
}}>
<Button
onClick={onLogin}
variant="contained"
sx={{
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.02)',
},
}}
>
Войти
</Button>
<Box
sx={{
display: 'flex',
justifyContent: 'flex-end',
}}
display: 'flex',
justifyContent: 'flex-end',
}}
>
<Typography
onClick={() => navigate('/registration')}
@ -109,15 +120,15 @@ const AuthForm = ({ config, handleInputChange, onLogin }: AuthFormProps) => {
letterSpacing: '0.08em',
cursor: 'pointer',
backgroundImage:
'linear-gradient(71deg, #F27121 0%, #E940CD 60%, #8A2387 100%)',
'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,
},
}}
>
}}
>
Зарегистрироваться
</Typography>
</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 AuthForm from '../components/Login/AuthForm';
import { useNavigate } from 'react-router-dom';
import PopaPopa from '../components/popa-popa';
import useConfig from '../hooks/useConfig';
import { useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { FullScreenLoader } from '../components/FullScreenLoader';
import React from 'react';
@ -15,6 +14,14 @@ import {
getNotifPositionFromSettings,
} 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 {
onLoginSuccess?: (username: string) => void;
}
@ -25,7 +32,30 @@ const Login = ({ onLoginSuccess }: LoginProps) => {
const auth = useAuth();
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 [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
const [notifSeverity, setNotifSeverity] = useState<
@ -49,36 +79,158 @@ const Login = ({ onLoginSuccess }: LoginProps) => {
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 raw = error?.message ? String(error.message) : String(error);
// сеть
if (raw.includes('Failed to fetch') || raw.includes('NetworkError')) {
return 'Сервер недоступен';
}
// вытащим JSON после статуса
// пример: "Ошибка авторизации: 401 {"detail":"Invalid credentials"}"
const jsonStart = raw.indexOf('{');
if (jsonStart !== -1) {
const jsonStr = raw.slice(jsonStart);
try {
const data = JSON.parse(jsonStr);
const detail = data?.detail;
if (detail === 'Invalid credentials') return 'Неверный логин или пароль';
if (detail === 'Invalid credentials')
return 'Неверный логин или пароль';
if (detail === 'User not verified') return 'Аккаунт не подтверждён';
if (typeof detail === 'string' && detail.trim()) return detail;
} catch {
// если это не JSON — идём дальше
}
} catch {}
}
// если бэк вернул просто строку без JSON
if (raw.includes('Invalid credentials')) return 'Неверный логин или пароль';
if (raw.includes('User not verified')) return 'Аккаунт не подтверждён';
return raw.startsWith('Ошибка') ? raw : `Ошибка: ${raw}`;
};
@ -95,20 +247,17 @@ const Login = ({ onLoginSuccess }: LoginProps) => {
setLoading(true);
try {
// Проверяем существующий токен
if (config.accessToken && config.clientToken) {
const isValid = await auth.validateSession(config.accessToken);
if (!isValid) {
// Пробуем обновить
const refreshed = await auth.refreshSession(
config.accessToken,
config.clientToken,
);
if (!refreshed) {
// Нужна новая авторизация
const session = await auth.authenticateUser(
await auth.authenticateUser(
config.username,
config.password,
saveConfig,
@ -116,28 +265,22 @@ const Login = ({ onLoginSuccess }: LoginProps) => {
}
}
} else {
// Новая авторизация
const session = await auth.authenticateUser(
await auth.authenticateUser(
config.username,
config.password,
saveConfig,
);
}
if (onLoginSuccess) {
onLoginSuccess(config.username);
}
// (опционально) можно показать успех
if (onLoginSuccess) onLoginSuccess(config.username);
showNotification('Успешный вход', 'success');
navigate('/');
} catch (error: any) {
console.error('Ошибка авторизации:', error);
const msg = mapAuthErrorToMessage(error);
showNotification(msg, 'error');
saveConfig({
accessToken: '',
clientToken: '',
@ -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 (
<Box>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: '1vh',
width: '50vw',
mx: 'auto',
}}
>
{loading ? (
<FullScreenLoader message="Входим..." />
) : (
<>
<PopaPopa />
<AuthForm
config={config}
handleInputChange={handleInputChange}
onLogin={handleLogin}
/>
{/* ===== QR экран по умолчанию ===== */}
{!showPasswordLogin ? (
<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>
)}
</>
)}