From 64b612971319524ce061c635005513aff0eb9821 Mon Sep 17 00:00:00 2001 From: DIKER0K Date: Sat, 20 Dec 2025 17:11:23 +0500 Subject: [PATCH] add qr code auth --- src/renderer/api.ts | 53 +++ src/renderer/components/Login/AuthForm.tsx | 79 +++-- src/renderer/pages/Login.tsx | 382 +++++++++++++++++++-- 3 files changed, 442 insertions(+), 72 deletions(-) diff --git a/src/renderer/api.ts b/src/renderer/api.ts index d027946..6caa2d5 100644 --- a/src/renderer/api.ts +++ b/src/renderer/api.ts @@ -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 { + 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 { + 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 { diff --git a/src/renderer/components/Login/AuthForm.tsx b/src/renderer/components/Login/AuthForm.tsx index 9482baa..64976fe 100644 --- a/src/renderer/components/Login/AuthForm.tsx +++ b/src/renderer/components/Login/AuthForm.tsx @@ -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', }} /> { onClick={() => setShowPassword((prev) => !prev)} edge="end" sx={{ - color: "white", + color: 'white', margin: '0', padding: '0', '& MuiTouchRipple-root css-r3djoj-MuiTouchRipple-root': { display: 'none', }, - }}> + }} + > перечеркнуть - sx={{ fontSize: "2.5vw", mr: '0.5vw' }} + crossed={showPassword} // когда type="text" -> перечеркнуть + sx={{ fontSize: '2.5vw', mr: '0.5vw' }} /> ), }} /> - 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, }, - }} - > + }} + > Зарегистрироваться diff --git a/src/renderer/pages/Login.tsx b/src/renderer/pages/Login.tsx index 68e9c30..841551c 100644 --- a/src/renderer/pages/Login.tsx +++ b/src/renderer/pages/Login.tsx @@ -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(''); + const qrRef = useRef(null); + const pollTimerRef = useRef(null); + + // хранит один инстанс QRCodeStyling + const qrInstanceRef = useRef(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(''); 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 ( - + {loading ? ( ) : ( <> - + + {/* ===== QR экран по умолчанию ===== */} + {!showPasswordLogin ? ( + + + Вход через Telegram + + + + + {/* QR контейнер */} +
+ + {qrLoading ? ( + + ) : ( + + Ждем подтверждения в боте + + )} + + + + Обновить QR + + + Войти по логину и паролю + + navigate('/registration')} + sx={gradientTextSx} + > + Зарегистрироваться + + + + ) : ( + /* ===== экран логин/пароль (в стиле Registration.tsx) ===== */ + + + Вход по логину и паролю + + + + + + + + + + navigate('/registration')} + sx={gradientTextSx} + > + Зарегистрироваться + + + Назад к QR + + + + )} )}