feat: verify actual email address with DNS lookup and 6-digit verification code in 0.0.9

This commit is contained in:
Nikita 2026-10-27 14:56:12 +05:00
parent e7b926fd48
commit fd1fcffef7
7 changed files with 620 additions and 165 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "has", "name": "has",
"private": true, "private": true,
"version": "0.0.8", "version": "0.0.9",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

2
src-tauri/Cargo.lock generated
View file

@ -1581,7 +1581,7 @@ dependencies = [
[[package]] [[package]]
name = "has" name = "has"
version = "0.0.8" version = "0.0.9"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bcrypt", "bcrypt",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "has" name = "has"
version = "0.0.8" version = "0.0.9"
description = "has" description = "has"
authors = ["autli"] authors = ["autli"]
edition = "2024" edition = "2024"

View file

@ -527,6 +527,91 @@ pub async fn authenticate_user(email: &str, password: &str) -> AuthResponse {
} }
} }
pub fn validate_email_syntax(email: &str) -> Result<(&str, &str), String> {
let clean = email.trim();
if clean.is_empty() {
return Err("Адрес электронной почты не может быть пустым".to_string());
}
if clean.len() > 254 {
return Err("Слишком длинный адрес электронной почты".to_string());
}
let parts: Vec<&str> = clean.split('@').collect();
if parts.len() != 2 {
return Err("Некорректный формат email: адрес должен содержать ровно один символ '@'".to_string());
}
let local = parts[0];
let domain = parts[1];
if local.is_empty() || local.len() > 64 {
return Err("Некорректная первая часть email адреса".to_string());
}
if domain.is_empty() || domain.len() > 253 {
return Err("Некорректный домен email адреса".to_string());
}
if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
return Err("Email не должен начинаться/заканчиваться точкой или содержать две точки подряд".to_string());
}
if !local.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' || c == '+' || c == '%') {
return Err("Email содержит недопустимые символы в имени пользователя".to_string());
}
if domain.starts_with('.') || domain.ends_with('.') || domain.contains("..") || !domain.contains('.') {
return Err("Некорректный домен почты (пример: name@mail.ru)".to_string());
}
let domain_parts: Vec<&str> = domain.split('.').collect();
let tld = match domain_parts.last() {
Some(t) => *t,
None => return Err("Отсутствует доменная зона почты".to_string()),
};
if tld.len() < 2 || !tld.chars().all(|c| c.is_ascii_alphabetic()) {
return Err("Некорректная доменная зона почты (например: .ru, .com)".to_string());
}
for part in &domain_parts {
if part.is_empty() || part.starts_with('-') || part.ends_with('-') {
return Err("Некорректное имя почтового сервера".to_string());
}
if !part.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return Err("Домен почты содержит недопустимые символы".to_string());
}
}
const DISPOSABLE_DOMAINS: &[&str] = &[
"tempmail.com", "10minutemail.com", "guerrillamail.com", "mailinator.com",
"trashmail.com", "yopmail.com", "temp-mail.org", "dispostable.com",
"getnada.com", "throwawaymail.com", "fakeinbox.com", "sharklasers.com",
"guerrillamailblock.com", "grr.la", "mohmal.com", "dropmail.me",
"tempail.com", "burnermail.io", "crazymailing.com", "generator.email"
];
let domain_lower = domain.to_lowercase();
if DISPOSABLE_DOMAINS.iter().any(|&d| domain_lower == d || domain_lower.ends_with(&format!(".{}", d))) {
return Err("Временные и одноразовые почтовые ящики не поддерживаются. Пожалуйста, используйте надежный адрес.".to_string());
}
Ok((local, domain))
}
pub async fn verify_domain_exists(domain: &str) -> Result<(), String> {
let domain = domain.trim();
let resolves = match tokio::net::lookup_host(format!("{}:25", domain)).await {
Ok(mut addrs) => addrs.next().is_some(),
Err(_) => match tokio::net::lookup_host(format!("{}:443", domain)).await {
Ok(mut addrs) => addrs.next().is_some(),
Err(_) => match tokio::net::lookup_host(format!("{}:80", domain)).await {
Ok(mut addrs) => addrs.next().is_some(),
Err(_) => false,
},
},
};
if !resolves {
return Err(format!("Почтовый домен '{}' не найден в сети Интернет. Проверьте правильность адреса.", domain));
}
Ok(())
}
pub async fn register_user(email: &str, password: &str, username: Option<&str>) -> AuthResponse { pub async fn register_user(email: &str, password: &str, username: Option<&str>) -> AuthResponse {
let clean_email = email.trim(); let clean_email = email.trim();
let clean_username = username.map(|u| u.trim()).filter(|u| !u.is_empty()); let clean_username = username.map(|u| u.trim()).filter(|u| !u.is_empty());
@ -538,6 +623,25 @@ pub async fn register_user(email: &str, password: &str, username: Option<&str>)
}; };
} }
let (_, domain) = match validate_email_syntax(clean_email) {
Ok(d) => d,
Err(err) => {
return AuthResponse {
success: false,
message: err,
user: None,
};
}
};
if let Err(err) = verify_domain_exists(domain).await {
return AuthResponse {
success: false,
message: err,
user: None,
};
}
let config = get_db_config(); let config = get_db_config();
let client = match connect_to_db(&config).await { let client = match connect_to_db(&config).await {
Ok(c) => c, Ok(c) => c,
@ -976,10 +1080,197 @@ pub async fn reset_password_with_code(email: &str, code: &str, new_password: &st
} }
} }
#[derive(Debug, Clone)]
struct RegistrationEntry {
code: String,
expires_at: std::time::Instant,
}
static REGISTRATION_CODES: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, RegistrationEntry>>> = std::sync::OnceLock::new();
fn get_registration_codes() -> &'static std::sync::Mutex<std::collections::HashMap<String, RegistrationEntry>> {
REGISTRATION_CODES.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
}
pub async fn request_registration_code(email: &str, _username: Option<&str>) -> ResetRequestResponse {
let clean_email = email.trim();
let (_, domain) = match validate_email_syntax(clean_email) {
Ok(res) => res,
Err(e) => {
return ResetRequestResponse {
success: false,
message: e,
dev_code: None,
};
}
};
if let Err(e) = verify_domain_exists(domain).await {
return ResetRequestResponse {
success: false,
message: e,
dev_code: None,
};
}
let config = get_db_config();
let client = match connect_to_db(&config).await {
Ok(c) => c,
Err(_) => {
return ResetRequestResponse {
success: false,
message: "Ошибка подключения к серверу. Попробуйте позже.".to_string(),
dev_code: None,
};
}
};
let check_query = format!(
"SELECT 1 FROM {} WHERE LOWER({}) = LOWER($1) LIMIT 1",
config.users_table, config.email_column
);
match client.query_opt(&check_query, &[&clean_email]).await {
Ok(Some(_)) => ResetRequestResponse {
success: false,
message: "Пользователь с таким адресом электронной почты уже зарегистрирован".to_string(),
dev_code: None,
},
Ok(None) => {
let code = generate_reset_code();
let expires_at = std::time::Instant::now() + std::time::Duration::from_secs(900); // 15 mins
{
let mut map = get_registration_codes().lock().unwrap();
map.insert(clean_email.to_lowercase(), RegistrationEntry {
code: code.clone(),
expires_at,
});
}
let smtp_host = extract_clean_env("SMTP_HOST", "smtp.mail.ru");
let smtp_port = extract_clean_env("SMTP_PORT", "465").parse::<u16>().unwrap_or(465);
let smtp_user = extract_clean_env("SMTP_USER", "scorporation@internet.ru");
let smtp_pass = extract_clean_env("SMTP_PASSWORD", "87AhqOfdlC5LPb5eIX3N");
let smtp_from = extract_clean_env("SMTP_FROM", &smtp_user);
let dev_code;
if !smtp_host.is_empty() {
let subject = "Home Autli Server: Код подтверждения регистрации";
let body = format!(
"Здравствуйте!\n\nВаш проверочный код для подтверждения почты и завершения регистрации в Home Autli Server: {}\n\nКод действителен в течение 15 минут.\nЕсли вы не регистрировались в Home Autli Server, просто проигнорируйте это письмо.",
code
);
match send_smtp_email(&smtp_host, smtp_port, &smtp_user, &smtp_pass, &smtp_from, clean_email, subject, &body).await {
Ok(_) => {
return ResetRequestResponse {
success: true,
message: format!("Код подтверждения успешно отправлен на почту {}", clean_email),
dev_code: None,
};
}
Err(e) => {
eprintln!("Не удалось отправить email через SMTP: {}. Тестовый код: {}", e, code);
dev_code = Some(code.clone());
}
}
} else {
dev_code = Some(code.clone());
}
ResetRequestResponse {
success: true,
message: if dev_code.is_some() {
format!("Проверочный код сгенерирован (тестовый режим: {})", code)
} else {
format!("Код подтверждения отправлен на {}", clean_email)
},
dev_code,
}
}
Err(e) => ResetRequestResponse {
success: false,
message: format!("Ошибка проверки почты: {}", e),
dev_code: None,
},
}
}
pub async fn register_with_code(
email: &str,
code: &str,
password: &str,
username: Option<&str>,
) -> AuthResponse {
let clean_email = email.trim();
let clean_code = code.trim();
if clean_code.is_empty() {
return AuthResponse {
success: false,
message: "Пожалуйста, введите проверочный код из письма".to_string(),
user: None,
};
}
{
let mut map = get_registration_codes().lock().unwrap();
let key = clean_email.to_lowercase();
if let Some(entry) = map.get(&key) {
if std::time::Instant::now() > entry.expires_at {
return AuthResponse {
success: false,
message: "Срок действия кода подтверждения истек. Запросите код заново.".to_string(),
user: None,
};
}
if entry.code != clean_code {
return AuthResponse {
success: false,
message: "Неверный проверочный код. Проверьте введенные цифры.".to_string(),
user: None,
};
}
map.remove(&key);
} else {
return AuthResponse {
success: false,
message: "Код подтверждения для данного адреса не найден или устарел. Запросите код снова.".to_string(),
user: None,
};
}
}
register_user(clean_email, password, username).await
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn test_validate_email_syntax() {
assert!(validate_email_syntax("user@gmail.com").is_ok());
assert!(validate_email_syntax("test.user+tag@mail.ru").is_ok());
assert!(validate_email_syntax("alex_99@yandex.ru").is_ok());
assert!(validate_email_syntax("").is_err());
assert!(validate_email_syntax("invalid").is_err());
assert!(validate_email_syntax("invalid@").is_err());
assert!(validate_email_syntax("@domain.com").is_err());
assert!(validate_email_syntax("user@.com").is_err());
assert!(validate_email_syntax("user@domain..com").is_err());
assert!(validate_email_syntax("user@tempmail.com").is_err());
assert!(validate_email_syntax("user@mailinator.com").is_err());
}
#[tokio::test]
async fn test_verify_domain_exists() {
assert!(verify_domain_exists("gmail.com").await.is_ok());
assert!(verify_domain_exists("mail.ru").await.is_ok());
assert!(verify_domain_exists("this-domain-surely-does-not-exist-123456789.ru").await.is_err());
}
#[tokio::test] #[tokio::test]
async fn test_database_connection() { async fn test_database_connection() {
let config = get_db_config(); let config = get_db_config();

View file

@ -35,6 +35,28 @@ async fn register(email: String, password: String, username: Option<String>) ->
Ok(auth::register_user(&email, &password, username.as_deref()).await) Ok(auth::register_user(&email, &password, username.as_deref()).await)
} }
#[tauri::command]
async fn request_registration_code(email: String, username: Option<String>) -> Result<ResetRequestResponse, String> {
Ok(auth::request_registration_code(&email, username.as_deref()).await)
}
#[tauri::command]
async fn register_with_code(
email: String,
code: String,
password: String,
username: Option<String>,
) -> Result<AuthResponse, String> {
Ok(auth::register_with_code(&email, &code, &password, username.as_deref()).await)
}
#[tauri::command]
async fn validate_email(email: String) -> Result<bool, String> {
let (_, domain) = auth::validate_email_syntax(&email)?;
auth::verify_domain_exists(domain).await?;
Ok(true)
}
#[tauri::command] #[tauri::command]
async fn request_reset_code(email: String) -> Result<ResetRequestResponse, String> { async fn request_reset_code(email: String) -> Result<ResetRequestResponse, String> {
Ok(auth::request_reset_code(&email).await) Ok(auth::request_reset_code(&email).await)
@ -194,6 +216,9 @@ pub fn run() {
check_db, check_db,
login, login,
register, register,
request_registration_code,
register_with_code,
validate_email,
request_reset_code, request_reset_code,
verify_reset_code, verify_reset_code,
reset_password, reset_password,

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "has", "productName": "has",
"version": "0.0.8", "version": "0.0.9",
"identifier": "com.has.tauri-app", "identifier": "com.has.tauri-app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View file

@ -32,6 +32,11 @@ export default function App() {
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [rememberMe, setRememberMe] = useState(true); const [rememberMe, setRememberMe] = useState(true);
// Registration verification code state
const [regStage, setRegStage] = useState<1 | 2>(1);
const [regCode, setRegCode] = useState("");
const [regCooldown, setRegCooldown] = useState(0);
// Password reset state // Password reset state
const [resetStage, setResetStage] = useState<1 | 2 | 3>(1); const [resetStage, setResetStage] = useState<1 | 2 | 3>(1);
const [resetEmail, setResetEmail] = useState(""); const [resetEmail, setResetEmail] = useState("");
@ -51,6 +56,13 @@ export default function App() {
const [showForgotModal, setShowForgotModal] = useState(false); const [showForgotModal, setShowForgotModal] = useState(false);
useEffect(() => {
if (regCooldown > 0) {
const timer = setTimeout(() => setRegCooldown(regCooldown - 1), 1000);
return () => clearTimeout(timer);
}
}, [regCooldown]);
// Check saved session on mount // Check saved session on mount
useEffect(() => { useEffect(() => {
const savedUser = localStorage.getItem("has_remembered_user"); const savedUser = localStorage.getItem("has_remembered_user");
@ -160,16 +172,26 @@ export default function App() {
} }
}; };
const handleRegister = async (e: React.FormEvent) => { const isValidEmailFormat = (val: string) => {
e.preventDefault(); return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(val.trim());
};
const handleRequestRegCode = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
setError(null); setError(null);
setSuccess(null); setSuccess(null);
if (!email.trim() || !password) { const cleanEmail = email.trim();
if (!cleanEmail || !password) {
setError("Пожалуйста, укажите email и пароль"); setError("Пожалуйста, укажите email и пароль");
return; return;
} }
if (!isValidEmailFormat(cleanEmail)) {
setError("Пожалуйста, введите корректный адрес электронной почты (например, name@mail.ru)");
return;
}
if (!username.trim()) { if (!username.trim()) {
setError("Пожалуйста, укажите имя пользователя"); setError("Пожалуйста, укажите имя пользователя");
return; return;
@ -180,24 +202,71 @@ export default function App() {
return; return;
} }
if (password.length < 6) {
setError("Пароль должен содержать минимум 6 символов");
return;
}
if (!isTauri()) { if (!isTauri()) {
setError("Для регистрации пользователя в PostgreSQL запустите через Tauri ('npm run tauri dev')"); setError("Для регистрации пользователя запустите приложение в десктопном режиме");
return; return;
} }
setLoading(true); setLoading(true);
try { try {
const res = await invoke<AuthResponse>("register", { const res = await invoke<ResetRequestResponse>("request_registration_code", {
email: cleanEmail,
username: username.trim(),
});
if (res.success) {
setRegStage(2);
setRegCooldown(60);
setSuccess(res.message);
if (res.dev_code) {
setRegCode(res.dev_code);
}
} else {
setError(res.message || "Ошибка отправки кода подтверждения на указанную почту.");
}
} catch (err: any) {
setError(`Ошибка: ${err?.message || err}`);
} finally {
setLoading(false);
}
};
const handleCompleteRegistration = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSuccess(null);
if (!regCode.trim()) {
setError("Пожалуйста, введите проверочный код из письма");
return;
}
if (!isTauri()) {
setError("Для регистрации пользователя запустите десктопное приложение");
return;
}
setLoading(true);
try {
const res = await invoke<AuthResponse>("register_with_code", {
email: email.trim(), email: email.trim(),
code: regCode.trim(),
password, password,
username: username.trim(), username: username.trim(),
}); });
if (res.success) { if (res.success) {
setSuccess("Аккаунт успешно создан! Теперь вы можете войти."); setSuccess("Аккаунт успешно создан и подтвержден! Теперь вы можете войти.");
setRegStage(1);
setRegCode("");
setMode("login"); setMode("login");
} else { } else {
setError(res.message || "Ошибка при создании аккаунта."); setError(res.message || "Ошибка подтверждения кода или создания аккаунта.");
} }
} catch (err: any) { } catch (err: any) {
setError(`Ошибка: ${err?.message || err}`); setError(`Ошибка: ${err?.message || err}`);
@ -631,194 +700,264 @@ export default function App() {
)} )}
{/* Form */} {/* Form */}
<form {mode === "register" && regStage === 2 ? (
className="auth-form" <form className="auth-form" onSubmit={handleCompleteRegistration}>
onSubmit={mode === "login" ? handleLogin : handleRegister} <div style={{ textAlign: "center", marginBottom: 12 }}>
> <div style={{ fontSize: 13, color: "var(--text-secondary, #94a3b8)", lineHeight: 1.5 }}>
{/* Username (Register mode only) */} На почту <strong style={{ color: "var(--text-primary, #fff)" }}>{email.trim()}</strong> отправлен 6-значный проверочный код.
{mode === "register" && ( Введите его для подтверждения адреса:
</div>
</div>
<OtpInput
value={regCode}
onChange={(code) => setRegCode(code)}
disabled={loading}
/>
<button
type="submit"
className="btn-primary"
disabled={loading || regCode.length < 6}
>
{loading ? (
<>
<SpinnerIcon className="w-5 h-5" />
<span>Создание аккаунта...</span>
</>
) : (
<>
<span>Завершить регистрацию</span>
<CheckCircleIcon className="w-5 h-5" />
</>
)}
</button>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 8 }}>
<button
type="button"
className="forgot-link"
style={{ background: "none", border: "none", cursor: "pointer", padding: 0 }}
onClick={() => {
setRegStage(1);
setError(null);
setSuccess(null);
}}
>
← Изменить данные
</button>
<button
type="button"
className="forgot-link"
style={{
background: "none",
border: "none",
cursor: regCooldown > 0 ? "not-allowed" : "pointer",
padding: 0,
opacity: regCooldown > 0 ? 0.6 : 1,
}}
disabled={regCooldown > 0 || loading}
onClick={() => handleRequestRegCode()}
>
{regCooldown > 0 ? `Повтор через ${regCooldown} с` : "Отправить код еще раз"}
</button>
</div>
</form>
) : (
<form
className="auth-form"
onSubmit={mode === "login" ? handleLogin : handleRequestRegCode}
>
{/* Username (Register mode only) */}
{mode === "register" && (
<div className="form-group">
<label className="form-label" htmlFor="username-input">
Имя пользователя
</label>
<div className="input-container">
<div className="input-icon-left">
<UserIcon />
</div>
<input
id="username-input"
type="text"
className="form-input"
placeholder="Например: Иван Иванов"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="name"
/>
</div>
</div>
)}
{/* Email Address */}
<div className="form-group"> <div className="form-group">
<label className="form-label" htmlFor="username-input"> <label className="form-label" htmlFor="email-input">
Имя пользователя Электронная почта
</label> </label>
<div className="input-container"> <div className="input-container">
<div className="input-icon-left"> <div className="input-icon-left">
<UserIcon /> <MailIcon />
</div> </div>
<input <input
id="username-input" id="email-input"
type="text" type="email"
className="form-input" className="form-input"
placeholder="Например: Иван Иванов" placeholder="user@example.com"
value={username} value={email}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
autoComplete="name" autoComplete="email"
/> />
</div> </div>
</div> </div>
)}
{/* Email Address */} {/* Password */}
<div className="form-group">
<label className="form-label" htmlFor="email-input">
Электронная почта
</label>
<div className="input-container">
<div className="input-icon-left">
<MailIcon />
</div>
<input
id="email-input"
type="email"
className="form-input"
placeholder="user@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
</div>
{/* Password */}
<div className="form-group">
<label className="form-label" htmlFor="password-input">
Пароль
</label>
<div className="input-container">
<div className="input-icon-left">
<LockIcon />
</div>
<input
id="password-input"
type={showPassword ? "text" : "password"}
className="form-input has-right-icon"
placeholder="••••••••••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete={mode === "login" ? "current-password" : "new-password"}
/>
<button
type="button"
className="input-icon-right"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
aria-label={showPassword ? "Скрыть пароль" : "Показать пароль"}
>
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
</button>
</div>
</div>
{/* Confirm Password (Register mode) */}
{mode === "register" && (
<div className="form-group"> <div className="form-group">
<label className="form-label" htmlFor="confirm-password-input"> <label className="form-label" htmlFor="password-input">
Подтверждение пароля Пароль
</label> </label>
<div className="input-container"> <div className="input-container">
<div className="input-icon-left"> <div className="input-icon-left">
<LockIcon /> <LockIcon />
</div> </div>
<input <input
id="confirm-password-input" id="password-input"
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
className="form-input" className="form-input has-right-icon"
placeholder="••••••••••••••••" placeholder="••••••••••••••••"
value={confirmPassword} value={password}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
autoComplete={mode === "login" ? "current-password" : "new-password"}
/> />
<button
type="button"
className="input-icon-right"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
aria-label={showPassword ? "Скрыть пароль" : "Показать пароль"}
>
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
</button>
</div> </div>
</div> </div>
)}
{/* Remember Me & Forgot Password (Login mode) */} {/* Confirm Password (Register mode) */}
{mode === "login" && ( {mode === "register" && (
<div className="form-options-row"> <div className="form-group">
<label className="remember-label"> <label className="form-label" htmlFor="confirm-password-input">
<input Подтверждение пароля
type="checkbox" </label>
className="custom-checkbox" <div className="input-container">
checked={rememberMe} <div className="input-icon-left">
onChange={(e) => setRememberMe(e.target.checked)} <LockIcon />
/> </div>
<span>Запомнить на 30 дней</span> <input
</label> id="confirm-password-input"
type={showPassword ? "text" : "password"}
<span className="form-input"
className="forgot-link" placeholder="••••••••••••••••"
onClick={() => { value={confirmPassword}
setMode("reset"); onChange={(e) => setConfirmPassword(e.target.value)}
setResetStage(1); required
setResetEmail(email.trim()); />
setError(null); </div>
setSuccess(null); </div>
}}
>
Забыли пароль?
</span>
</div>
)}
{/* Submit Button */}
<button
type="submit"
className="btn-primary"
disabled={loading}
>
{loading ? (
<>
<SpinnerIcon className="w-5 h-5" />
<span>Подождите...</span>
</>
) : (
<>
<span>{mode === "login" ? "Войти" : "Зарегистрироваться"}</span>
<ArrowRightLoginIcon className="w-5 h-5" />
</>
)} )}
</button>
{/* Switch Mode Link */} {/* Remember Me & Forgot Password (Login mode) */}
<div style={{ textAlign: "center", marginTop: 8 }}> {mode === "login" && (
<span className="signup-hint"> <div className="form-options-row">
{mode === "login" ? ( <label className="remember-label">
<input
type="checkbox"
className="custom-checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
/>
<span>Запомнить на 30 дней</span>
</label>
<span
className="forgot-link"
onClick={() => {
setMode("reset");
setResetStage(1);
setResetEmail(email.trim());
setError(null);
setSuccess(null);
}}
>
Забыли пароль?
</span>
</div>
)}
{/* Submit Button */}
<button
type="submit"
className="btn-primary"
disabled={loading}
>
{loading ? (
<> <>
Нет аккаунта?{" "} <SpinnerIcon className="w-5 h-5" />
<span <span>Подождите...</span>
className="signup-link"
onClick={() => {
setMode("register");
setUsername("");
setError(null);
setSuccess(null);
}}
>
Зарегистрироваться
</span>
</> </>
) : ( ) : (
<> <>
Уже есть аккаунт?{" "} <span>{mode === "login" ? "Войти" : "Получить проверочный код"}</span>
<span <ArrowRightLoginIcon className="w-5 h-5" />
className="signup-link"
onClick={() => {
setMode("login");
setUsername("");
setError(null);
setSuccess(null);
}}
>
Войти
</span>
</> </>
)} )}
</span> </button>
</div>
</form> {/* Switch Mode Link */}
<div style={{ textAlign: "center", marginTop: 8 }}>
<span className="signup-hint">
{mode === "login" ? (
<>
Нет аккаунта?{" "}
<span
className="signup-link"
onClick={() => {
setMode("register");
setRegStage(1);
setRegCode("");
setUsername("");
setError(null);
setSuccess(null);
}}
>
Зарегистрироваться
</span>
</>
) : (
<>
Уже есть аккаунт?{" "}
<span
className="signup-link"
onClick={() => {
setMode("login");
setRegStage(1);
setRegCode("");
setUsername("");
setError(null);
setSuccess(null);
}}
>
Войти
</span>
</>
)}
</span>
</div>
</form>
)}
</div> </div>
</main> </main>
)} )}