add qr code auth
This commit is contained in:
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user