feat: add registration and authentication
This commit is contained in:
253
frontend/src/pages/AuthPages.tsx
Normal file
253
frontend/src/pages/AuthPages.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user