Make registration errors informative

- Backend: explicitly return 409 EMAIL_TAKEN when the email already
  exists, instead of Better Auth's silent 200 (common after migration).
- Frontend: split error handling — network failure, non-JSON body
  (502/proxy), and meaningful server errors get distinct messages; the
  "email already registered" case shows login / reset-password links
  instead of a generic "connection error".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 13:20:56 +05:00
parent 05f46ec830
commit 8240db5d22
2 changed files with 71 additions and 19 deletions
+56 -17
View File
@@ -35,6 +35,7 @@ export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, of
const [termsAccepted, setTermsAccepted] = useState(false); const [termsAccepted, setTermsAccepted] = useState(false);
const [turnstileToken, setTurnstileToken] = useState<string | null>(null); const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [emailTaken, setEmailTaken] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
@@ -84,6 +85,13 @@ export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, of
{ url: offerUrl, label: "Договор-оферту" }, { url: offerUrl, label: "Договор-оферту" },
].filter((l) => l.url); ].filter((l) => l.url);
function resetTurnstile() {
if (turnstileWidgetId.current) {
window.turnstile?.reset(turnstileWidgetId.current);
setTurnstileToken(null);
}
}
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (showTermsCheckbox && !termsAccepted) { if (showTermsCheckbox && !termsAccepted) {
@@ -95,10 +103,13 @@ export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, of
return; return;
} }
setError(""); setError("");
setEmailTaken(false);
setLoading(true); setLoading(true);
// 1. Сетевой слой: настоящий обрыв связи — отдельно от ошибок сервера
let res: Response;
try { try {
const res = await fetch("/api/register", { res = await fetch("/api/register", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@@ -109,24 +120,40 @@ export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, of
cfTurnstileResponse: turnstileToken, cfTurnstileResponse: turnstileToken,
}), }),
}); });
const data = await res.json() as { message?: string };
if (!res.ok) {
setError(data.message ?? "Ошибка регистрации");
if (turnstileWidgetId.current) {
window.turnstile?.reset(turnstileWidgetId.current);
setTurnstileToken(null);
}
setLoading(false);
return;
}
setSuccess(true);
} catch { } catch {
setError("Ошибка соединения. Попробуйте ещё раз."); setError("Не удалось связаться с сервером. Проверьте интернет-соединение и попробуйте ещё раз.");
setLoading(false);
return;
} }
// 2. Тело может быть не-JSON (502 при передеплое, HTML от прокси)
let data: { message?: string; code?: string } = {};
try {
data = await res.json() as { message?: string; code?: string };
} catch {
setError(
res.ok
? "Сервер вернул неожиданный ответ. Попробуйте ещё раз."
: "Сервис временно недоступен. Попробуйте через минуту."
);
resetTurnstile();
setLoading(false);
return;
}
// 3. Осмысленные ошибки сервера
if (!res.ok) {
if (res.status === 409 || data.code === "EMAIL_TAKEN") {
setEmailTaken(true);
} else {
setError(data.message ?? "Не удалось зарегистрироваться. Попробуйте ещё раз.");
}
resetTurnstile();
setLoading(false);
return;
}
setSuccess(true);
setLoading(false); setLoading(false);
} }
@@ -239,7 +266,19 @@ export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, of
</label> </label>
)} )}
{error && ( {emailTaken && (
<div className="text-xs py-3 px-3 space-y-2" style={{ border: "2px solid oklch(0.577 0.245 27.325)" }}>
<p style={{ color: "oklch(0.577 0.245 27.325)" }}>
Аккаунт с таким email уже зарегистрирован.
</p>
<p style={{ color: "var(--muted-foreground)" }}>
<Link href="/login" className="underline" style={{ color: "var(--foreground)" }}>Войти</Link>
{" · "}
<Link href="/forgot-password" className="underline" style={{ color: "var(--foreground)" }}>Забыли пароль?</Link>
</p>
</div>
)}
{error && !emailTaken && (
<p className="text-xs py-2 px-3" style={{ border: "2px solid oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}> <p className="text-xs py-2 px-3" style={{ border: "2px solid oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}>
{error} {error}
</p> </p>
+15 -2
View File
@@ -1,5 +1,6 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
const disposableDomains = require("disposable-email-domains") as string[]; const disposableDomains = require("disposable-email-domains") as string[];
@@ -21,8 +22,8 @@ async function verifyTurnstile(token: string): Promise<boolean> {
} }
} }
function jsonError(message: string, status: number) { function jsonError(message: string, status: number, code?: string) {
return new Response(JSON.stringify({ message }), { return new Response(JSON.stringify({ message, code }), {
status, status,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
@@ -59,6 +60,18 @@ export async function POST(request: NextRequest) {
} }
} }
// Email уже зарегистрирован? Сообщаем явно, а не молчаливым «успехом»
// от Better Auth — частый случай после миграции (у людей уже есть аккаунт).
if (email) {
const existing = await prisma.user.findFirst({
where: { email: { equals: email, mode: "insensitive" } },
select: { id: true },
});
if (existing) {
return jsonError("Аккаунт с таким email уже зарегистрирован", 409, "EMAIL_TAKEN");
}
}
const baseUrl = const baseUrl =
process.env.BETTER_AUTH_URL ?? process.env.BETTER_AUTH_URL ??
`https://${request.headers.get("host")}`; `https://${request.headers.get("host")}`;