feat: add registration and authentication

This commit is contained in:
Vaka.pro
2026-05-21 00:01:35 +03:00
parent 13dd8fa426
commit 35c3554742
37 changed files with 2162 additions and 81 deletions

View File

@@ -0,0 +1,253 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, Navigate, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import {
ApiError,
forgotPassword,
register,
resendVerification,
resetPassword,
verifyEmail,
} from "../api";
import { useAuth } from "../app/auth/AuthContext";
function errorMessage(error: unknown, fallback: string): string {
if (error instanceof ApiError) {
return error.details.length > 0 ? error.details.join("; ") : error.message;
}
return fallback;
}
function TurnstileField(props: { onToken(token: string): void }): JSX.Element {
const ref = useRef<HTMLDivElement | null>(null);
const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY;
const { onToken } = props;
useEffect(() => {
if (!siteKey || !ref.current) {
onToken("mock-turnstile-token");
return;
}
const scriptId = "turnstile-script";
if (!document.getElementById(scriptId)) {
const script = document.createElement("script");
script.id = scriptId;
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
script.async = true;
script.defer = true;
document.head.appendChild(script);
}
let widgetId: string | null = null;
const timer = window.setInterval(() => {
if (window.turnstile && ref.current && !widgetId) {
widgetId = window.turnstile.render(ref.current, {
sitekey: siteKey,
callback: onToken,
"expired-callback": () => onToken(""),
});
window.clearInterval(timer);
}
}, 100);
return () => {
window.clearInterval(timer);
if (widgetId && window.turnstile) {
window.turnstile.remove(widgetId);
}
};
}, [onToken, siteKey]);
return <div className="auth-form__captcha" ref={ref} />;
}
export function LoginPage(): JSX.Element {
const { user, login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
if (user?.emailVerifiedAt) {
return <Navigate to="/" replace />;
}
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setIsSubmitting(true);
setError(null);
try {
await login(email, password);
const from = (location.state as { from?: Location } | null)?.from?.pathname ?? "/";
navigate(from, { replace: true });
} catch (err) {
setError(errorMessage(err, "Не удалось войти."));
} finally {
setIsSubmitting(false);
}
};
return (
<section className="page page--auth">
<h1 className="page__title">Вход</h1>
<form className="auth-form" onSubmit={handleSubmit}>
<label className="auth-form__field">
<span className="auth-form__label">Email</span>
<input className="auth-form__input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label className="auth-form__field">
<span className="auth-form__label">Пароль</span>
<input className="auth-form__input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</label>
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
<button className="btn btn--primary" type="submit" disabled={isSubmitting}>
Войти
</button>
</form>
<p className="auth-form__links">
<Link className="page-link" to="/register">Регистрация</Link>
<Link className="page-link" to="/forgot-password">Забыли пароль?</Link>
</p>
</section>
);
}
export function RegisterPage(): JSX.Element {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [turnstileToken, setTurnstileToken] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const handleToken = useCallback((token: string) => setTurnstileToken(token), []);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setMessage(null);
try {
await register({ email, password, turnstileToken });
setMessage("Проверьте почту: мы отправили ссылку для подтверждения email.");
} catch (err) {
setError(errorMessage(err, "Не удалось зарегистрироваться."));
}
};
return (
<section className="page page--auth">
<h1 className="page__title">Регистрация</h1>
<form className="auth-form" onSubmit={handleSubmit}>
<label className="auth-form__field">
<span className="auth-form__label">Email</span>
<input className="auth-form__input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label className="auth-form__field">
<span className="auth-form__label">Пароль</span>
<input className="auth-form__input" type="password" minLength={15} value={password} onChange={(e) => setPassword(e.target.value)} required />
</label>
<TurnstileField onToken={handleToken} />
{message ? <p className="page__subtitle">{message}</p> : null}
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
<button className="btn btn--primary" type="submit" disabled={!turnstileToken}>Создать аккаунт</button>
</form>
<p className="auth-form__links">
<Link className="page-link" to="/login">Уже есть аккаунт</Link>
</p>
</section>
);
}
export function VerifyEmailPage(): JSX.Element {
const { user, refresh } = useAuth();
const [params] = useSearchParams();
const [email, setEmail] = useState(user?.email ?? "");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const token = params.get("token");
if (!token) {
return;
}
void verifyEmail(token)
.then(async () => {
await refresh();
setMessage("Email подтверждён. Теперь можно пользоваться календарём.");
})
.catch((err) => setError(errorMessage(err, "Ссылка недействительна.")));
}, [params, refresh]);
const resend = async () => {
setError(null);
await resendVerification(email);
setMessage("Если email зарегистрирован и ещё не подтверждён, письмо отправлено.");
};
return (
<section className="page page--auth">
<h1 className="page__title">Подтверждение email</h1>
<p className="page__subtitle">Для доступа к календарю подтвердите email по ссылке из письма.</p>
<form className="auth-form" onSubmit={(e) => { e.preventDefault(); void resend(); }}>
<label className="auth-form__field">
<span className="auth-form__label">Email</span>
<input className="auth-form__input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
{message ? <p className="page__subtitle">{message}</p> : null}
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
<button className="btn btn--primary" type="submit">Отправить письмо ещё раз</button>
</form>
</section>
);
}
export function ForgotPasswordPage(): JSX.Element {
const [email, setEmail] = useState("");
const [message, setMessage] = useState<string | null>(null);
return (
<section className="page page--auth">
<h1 className="page__title">Сброс пароля</h1>
<form className="auth-form" onSubmit={async (e) => { e.preventDefault(); await forgotPassword(email); setMessage("Если email зарегистрирован, ссылка отправлена."); }}>
<label className="auth-form__field">
<span className="auth-form__label">Email</span>
<input className="auth-form__input" type="email" value={email} onChange={(event) => setEmail(event.target.value)} required />
</label>
{message ? <p className="page__subtitle">{message}</p> : null}
<button className="btn btn--primary" type="submit">Отправить ссылку</button>
</form>
</section>
);
}
export function ResetPasswordPage(): JSX.Element {
const [params] = useSearchParams();
const [password, setPassword] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
return (
<section className="page page--auth">
<h1 className="page__title">Новый пароль</h1>
<form className="auth-form" onSubmit={async (e) => {
e.preventDefault();
const token = params.get("token") ?? "";
try {
await resetPassword(token, password);
setMessage("Пароль обновлён. Теперь войдите заново.");
} catch (err) {
setError(errorMessage(err, "Не удалось обновить пароль."));
}
}}>
<label className="auth-form__field">
<span className="auth-form__label">Пароль</span>
<input className="auth-form__input" type="password" minLength={15} value={password} onChange={(event) => setPassword(event.target.value)} required />
</label>
{message ? <p className="page__subtitle">{message}</p> : null}
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
<button className="btn btn--primary" type="submit">Сохранить пароль</button>
</form>
</section>
);
}