feat: verify actual email address with DNS lookup and 6-digit verification code in 0.0.9
This commit is contained in:
parent
e7b926fd48
commit
fd1fcffef7
7 changed files with 620 additions and 165 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "has",
|
||||
"private": true,
|
||||
"version": "0.0.8",
|
||||
"version": "0.0.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
|
|||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -1581,7 +1581,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "has"
|
||||
version = "0.0.8"
|
||||
version = "0.0.9"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bcrypt",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "has"
|
||||
version = "0.0.8"
|
||||
version = "0.0.9"
|
||||
description = "has"
|
||||
authors = ["autli"]
|
||||
edition = "2024"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
let clean_email = email.trim();
|
||||
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 client = match connect_to_db(&config).await {
|
||||
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)]
|
||||
mod tests {
|
||||
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]
|
||||
async fn test_database_connection() {
|
||||
let config = get_db_config();
|
||||
|
|
|
|||
|
|
@ -35,6 +35,28 @@ async fn register(email: String, password: String, username: Option<String>) ->
|
|||
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]
|
||||
async fn request_reset_code(email: String) -> Result<ResetRequestResponse, String> {
|
||||
Ok(auth::request_reset_code(&email).await)
|
||||
|
|
@ -194,6 +216,9 @@ pub fn run() {
|
|||
check_db,
|
||||
login,
|
||||
register,
|
||||
request_registration_code,
|
||||
register_with_code,
|
||||
validate_email,
|
||||
request_reset_code,
|
||||
verify_reset_code,
|
||||
reset_password,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "has",
|
||||
"version": "0.0.8",
|
||||
"version": "0.0.9",
|
||||
"identifier": "com.has.tauri-app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
|
|
|||
461
src/App.tsx
461
src/App.tsx
|
|
@ -32,6 +32,11 @@ export default function App() {
|
|||
const [showPassword, setShowPassword] = useState(false);
|
||||
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
|
||||
const [resetStage, setResetStage] = useState<1 | 2 | 3>(1);
|
||||
const [resetEmail, setResetEmail] = useState("");
|
||||
|
|
@ -51,6 +56,13 @@ export default function App() {
|
|||
|
||||
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
|
||||
useEffect(() => {
|
||||
const savedUser = localStorage.getItem("has_remembered_user");
|
||||
|
|
@ -160,16 +172,26 @@ export default function App() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const isValidEmailFormat = (val: string) => {
|
||||
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);
|
||||
setSuccess(null);
|
||||
|
||||
if (!email.trim() || !password) {
|
||||
const cleanEmail = email.trim();
|
||||
if (!cleanEmail || !password) {
|
||||
setError("Пожалуйста, укажите email и пароль");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidEmailFormat(cleanEmail)) {
|
||||
setError("Пожалуйста, введите корректный адрес электронной почты (например, name@mail.ru)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!username.trim()) {
|
||||
setError("Пожалуйста, укажите имя пользователя");
|
||||
return;
|
||||
|
|
@ -180,24 +202,71 @@ export default function App() {
|
|||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError("Пароль должен содержать минимум 6 символов");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTauri()) {
|
||||
setError("Для регистрации пользователя в PostgreSQL запустите через Tauri ('npm run tauri dev')");
|
||||
setError("Для регистрации пользователя запустите приложение в десктопном режиме");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
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(),
|
||||
code: regCode.trim(),
|
||||
password,
|
||||
username: username.trim(),
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
setSuccess("Аккаунт успешно создан! Теперь вы можете войти.");
|
||||
setSuccess("Аккаунт успешно создан и подтвержден! Теперь вы можете войти.");
|
||||
setRegStage(1);
|
||||
setRegCode("");
|
||||
setMode("login");
|
||||
} else {
|
||||
setError(res.message || "Ошибка при создании аккаунта.");
|
||||
setError(res.message || "Ошибка подтверждения кода или создания аккаунта.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(`Ошибка: ${err?.message || err}`);
|
||||
|
|
@ -631,194 +700,264 @@ export default function App() {
|
|||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
className="auth-form"
|
||||
onSubmit={mode === "login" ? handleLogin : handleRegister}
|
||||
>
|
||||
{/* Username (Register mode only) */}
|
||||
{mode === "register" && (
|
||||
{mode === "register" && regStage === 2 ? (
|
||||
<form className="auth-form" onSubmit={handleCompleteRegistration}>
|
||||
<div style={{ textAlign: "center", marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 13, color: "var(--text-secondary, #94a3b8)", lineHeight: 1.5 }}>
|
||||
На почту <strong style={{ color: "var(--text-primary, #fff)" }}>{email.trim()}</strong> отправлен 6-значный проверочный код.
|
||||
Введите его для подтверждения адреса:
|
||||
</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">
|
||||
<label className="form-label" htmlFor="username-input">
|
||||
Имя пользователя
|
||||
<label className="form-label" htmlFor="email-input">
|
||||
Электронная почта
|
||||
</label>
|
||||
<div className="input-container">
|
||||
<div className="input-icon-left">
|
||||
<UserIcon />
|
||||
<MailIcon />
|
||||
</div>
|
||||
<input
|
||||
id="username-input"
|
||||
type="text"
|
||||
id="email-input"
|
||||
type="email"
|
||||
className="form-input"
|
||||
placeholder="Например: Иван Иванов"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="name"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Address */}
|
||||
<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" && (
|
||||
{/* Password */}
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="confirm-password-input">
|
||||
Подтверждение пароля
|
||||
<label className="form-label" htmlFor="password-input">
|
||||
Пароль
|
||||
</label>
|
||||
<div className="input-container">
|
||||
<div className="input-icon-left">
|
||||
<LockIcon />
|
||||
</div>
|
||||
<input
|
||||
id="confirm-password-input"
|
||||
id="password-input"
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="form-input"
|
||||
className="form-input has-right-icon"
|
||||
placeholder="••••••••••••••••"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Remember Me & Forgot Password (Login mode) */}
|
||||
{mode === "login" && (
|
||||
<div className="form-options-row">
|
||||
<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>{mode === "login" ? "Войти" : "Зарегистрироваться"}</span>
|
||||
<ArrowRightLoginIcon className="w-5 h-5" />
|
||||
</>
|
||||
{/* Confirm Password (Register mode) */}
|
||||
{mode === "register" && (
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="confirm-password-input">
|
||||
Подтверждение пароля
|
||||
</label>
|
||||
<div className="input-container">
|
||||
<div className="input-icon-left">
|
||||
<LockIcon />
|
||||
</div>
|
||||
<input
|
||||
id="confirm-password-input"
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="form-input"
|
||||
placeholder="••••••••••••••••"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Switch Mode Link */}
|
||||
<div style={{ textAlign: "center", marginTop: 8 }}>
|
||||
<span className="signup-hint">
|
||||
{mode === "login" ? (
|
||||
{/* Remember Me & Forgot Password (Login mode) */}
|
||||
{mode === "login" && (
|
||||
<div className="form-options-row">
|
||||
<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 ? (
|
||||
<>
|
||||
Нет аккаунта?{" "}
|
||||
<span
|
||||
className="signup-link"
|
||||
onClick={() => {
|
||||
setMode("register");
|
||||
setUsername("");
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
}}
|
||||
>
|
||||
Зарегистрироваться
|
||||
</span>
|
||||
<SpinnerIcon className="w-5 h-5" />
|
||||
<span>Подождите...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Уже есть аккаунт?{" "}
|
||||
<span
|
||||
className="signup-link"
|
||||
onClick={() => {
|
||||
setMode("login");
|
||||
setUsername("");
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
}}
|
||||
>
|
||||
Войти
|
||||
</span>
|
||||
<span>{mode === "login" ? "Войти" : "Получить проверочный код"}</span>
|
||||
<ArrowRightLoginIcon className="w-5 h-5" />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</button>
|
||||
|
||||
{/* 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>
|
||||
</main>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue