254 lines
10 KiB
TypeScript
254 lines
10 KiB
TypeScript
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>
|
||
);
|
||
}
|