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",
|
"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
2
src-tauri/Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
461
src/App.tsx
461
src/App.tsx
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue