Files
web/frontend/src/pages/auth/RegisterForm.tsx
T

274 lines
11 KiB
TypeScript

import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { motion, type Transition, type Variants } from "motion/react";
import { useImmer } from "use-immer";
import type { RegisterRequest } from "@/core/types";
import { useUserStore } from "@/state/user";
import { MaterialButton, MaterialIconButton } from "@/utils/material";
import api from "@/core/api";
import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField";
import type { Alert, AlertType } from "./Auth";
import { AuthHeader, AlertsContainer } from "./Auth";
import styles from "./auth.module.scss";
const registerFieldVariants: Variants = {
initial: {
opacity: 0,
y: 10
},
animate: {
opacity: 1,
y: 0
}
};
const registerFieldTransition: Transition = {
duration: 0.3,
ease: "easeInOut"
};
const registerButtonVariants: Variants = {
initial: {
opacity: 0,
y: 10
},
animate: {
opacity: 1,
y: 0
}
};
const registerButtonTransition: Transition = {
duration: 0.3,
delay: 0.6,
ease: "easeInOut"
};
interface RegisterFormProps {
onSwitchMode: () => void;
}
export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
const [isLoading, setIsLoading] = useState(false);
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
const setUser = useUserStore(state => state.setUser);
const navigate = useNavigate();
function showAlert(type: AlertType, message: string) {
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
}
const displayNameElement = useRef<AuthTextFieldHandle>(null);
const usernameElement = useRef<AuthTextFieldHandle>(null);
const passwordElement = useRef<AuthTextFieldHandle>(null);
const confirmPasswordElement = useRef<AuthTextFieldHandle>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (isLoading) return;
const displayName = displayNameElement.current!.value.trim();
const username = usernameElement.current!.value.trim();
const password = passwordElement.current!.value.trim();
const confirmPassword = confirmPasswordElement.current!.value.trim();
if (!displayName || !username || !password || !confirmPassword) {
showAlert("danger", "Пожалуйста, заполните все поля");
return;
}
if (password !== confirmPassword) {
showAlert("danger", "Пароли не совпадают");
return;
}
if (displayName.length < 1 || displayName.length > 64) {
showAlert("danger", "Отображаемое имя должно быть от 1 до 64 символов");
return;
}
if (username.length < 3 || username.length > 20) {
showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов");
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
showAlert("danger", "Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания");
return;
}
if (password.length < 5 || password.length > 50) {
showAlert("danger", "Пароль должен быть от 5 до 50 символов");
return;
}
setIsLoading(true);
try {
const derived = await api.user.auth.deriveAuthSecret(username, password);
const request: RegisterRequest = {
display_name: displayName,
username: username,
password: derived,
confirm_password: derived
}
try {
const data = await api.user.auth.register(request);
setUser(data.token, data.user);
try {
await api.user.auth.ensureKeysOnLogin(password, data.token);
// Initialize Signal Protocol after keys are set up (non-blocking)
if (data.user?.id) {
// Run Signal Protocol initialization in background to avoid blocking navigation
(async () => {
try {
const { SignalProtocolService } = await import("@/utils/crypto/signalProtocol");
const { uploadPreKeyBundle, uploadAllPreKeys } = await import("@/core/api/crypto/prekeys");
const { uploadAllSessionsToServer, initializeSessionSync } = await import("@/utils/crypto/sessionSync");
const signalService = new SignalProtocolService(data.user!.id.toString());
await signalService.initialize();
// Initialize session sync (enables automatic upload of new sessions)
initializeSessionSync(data.user!.id.toString(), password, data.token);
// Upload base bundle with one prekey (for backward compatibility)
const bundle = await signalService.getPreKeyBundle();
await uploadPreKeyBundle(bundle, data.token);
// Upload all prekeys for server-side rotation
const baseBundle = await signalService.getBaseBundle();
const prekeys = await signalService.getAllPreKeys();
await uploadAllPreKeys(baseBundle, prekeys, data.token);
// Upload all current sessions to server (backup - will be empty on registration)
await uploadAllSessionsToServer(data.user!.id.toString(), password, data.token);
console.log(`Uploaded ${prekeys.length} prekeys to server`);
} catch (e) {
console.error("Key setup failed:", e);
}
})();
}
} catch (e) {
console.error("Key setup failed:", e);
}
navigate("/chat");
} catch (error: any) {
showAlert("danger", error.message || "Ошибка при регистрации");
}
} catch (error: any) {
showAlert("danger", error.message || "Ошибка соединения с сервером");
} finally {
setIsLoading(false);
}
}
return (
<>
<AuthHeader
icon="person_add"
title="Регистрация"
subtitle="Создайте новый аккаунт"
/>
<div className={styles.authBody}>
<AlertsContainer alerts={alerts} />
<motion.form onSubmit={handleSubmit}>
<motion.div
initial="initial"
animate="animate"
variants={registerFieldVariants}
transition={registerFieldTransition}
>
<AuthTextField
label="Отображаемое имя"
name="display_name"
icon="badge--filled"
autocomplete="name"
maxlength={64}
counter
required
ref={displayNameElement} />
</motion.div>
<motion.div
initial="initial"
animate="animate"
variants={registerFieldVariants}
transition={registerFieldTransition}
>
<AuthTextField
label="@Имя пользователя"
name="username"
icon="person--filled"
autocomplete="username"
maxlength={20}
counter
required
ref={usernameElement} />
</motion.div>
<motion.div
initial="initial"
animate="animate"
variants={registerFieldVariants}
transition={registerFieldTransition}
>
<AuthTextField
label="Пароль"
name="password"
type="password"
toggle-password
icon="password--filled"
autocomplete="new-password"
required
ref={passwordElement} />
</motion.div>
<motion.div
initial="initial"
animate="animate"
variants={registerFieldVariants}
transition={registerFieldTransition}
>
<AuthTextField
label="Подтвердите пароль"
name="confirm_password"
type="password"
toggle-password
icon="password--filled"
autocomplete="new-password"
required
ref={confirmPasswordElement} />
</motion.div>
<div className={styles.authButtons}>
<motion.div
initial="initial"
animate="animate"
variants={registerButtonVariants}
transition={registerButtonTransition}
>
<MaterialIconButton icon="arrow_back" onClick={onSwitchMode} />
</motion.div>
<motion.div
initial="initial"
animate="animate"
variants={registerButtonVariants}
transition={registerButtonTransition}
>
<MaterialButton type="submit" disabled={isLoading} loading={isLoading} icon="person_add">
{isLoading ? "Регистрация..." : "Зарегистрироваться"}
</MaterialButton>
</motion.div>
</div>
</motion.form>
</div>
</>
);
}