improve: the structure of the error handling in sign up page

This commit is contained in:
alikia2x (寒寒) 2025-06-01 14:36:55 +08:00
parent 7f9563a2a6
commit bb7f846305
Signed by: alikia2x
GPG Key ID: 56209E0CCD8420C6
8 changed files with 228 additions and 139 deletions

View File

@ -1,4 +1,4 @@
type ErrorCode =
export type ErrorCode =
| "INVALID_QUERY_PARAMS"
| "UNKNOWN_ERROR"
| "INVALID_PAYLOAD"

View File

@ -0,0 +1,22 @@
import { DialogButton, DialogButtonGroup, DialogHeadline, DialogSupportingText } from "@/components/ui/Dialog";
import { ErrorCode as ResponseErrorCode } from "@backend/src/schema";
import { useTranslations } from "next-intl";
interface ErrorDialogProps {
children: React.ReactNode;
closeDialog: () => void;
errorCode?: ResponseErrorCode;
}
export const ErrorDialog: React.FC<ErrorDialogProps> = ({ children, closeDialog, errorCode }) => {
const t = useTranslations("backend.error_code");
return (
<>
<DialogHeadline>{errorCode ? t(errorCode) : "错误"}</DialogHeadline>
<DialogSupportingText>{children}</DialogSupportingText>
<DialogButtonGroup>
<DialogButton onClick={closeDialog}></DialogButton>
</DialogButtonGroup>
</>
);
};

View File

@ -3,16 +3,16 @@
import { useEffect, useState } from "react";
import TextField from "@/components/ui/TextField";
import LoadingSpinner from "@/components/icons/LoadingSpinner";
import { ApiRequestError } from "@/lib/net";
import { Portal } from "@/components/utils/Portal";
import { Dialog, DialogButton, DialogButtonGroup, DialogHeadline, DialogSupportingText } from "@/components/ui/Dialog";
import { FilledButton } from "@/components/ui/Buttons/FilledButton";
import { string, object, ValidationError } from "yup";
import { Dialog } from "@/components/ui/Dialog";
import { setLocale } from "yup";
import { useTranslations } from "next-intl";
import type { ErrorResponse } from "@backend/src/schema";
import Link from "next/link";
import { useCaptcha } from "@/components/hooks/useCaptcha";
import useSWRMutation from "swr/mutation";
import { requestSignUp } from "./request";
import { FilledButton } from "@/components/ui/Buttons/FilledButton";
import { ErrorDialog } from "./ErrorDialog";
import { ApiRequestError } from "@/lib/net";
setLocale({
mixed: {
@ -25,52 +25,30 @@ setLocale({
}
});
interface LocalizedMessage {
export interface LocalizedMessage {
key: string;
values: {
[key: string]: number | string;
};
}
const FormSchema = object().shape({
username: string().required().max(50),
password: string().required().min(4).max(120),
nickname: string().optional().max(30)
});
interface RegistrationFormProps {
backendURL: string;
}
interface ErrorDialogProps {
children: React.ReactNode;
closeDialog: () => void;
}
const ErrorDialog: React.FC<ErrorDialogProps> = ({ children, closeDialog }) => {
return (
<>
<DialogHeadline></DialogHeadline>
<DialogSupportingText>{children}</DialogSupportingText>
<DialogButtonGroup>
<DialogButton onClick={closeDialog}></DialogButton>
</DialogButtonGroup>
</>
);
};
const SignUpForm: React.FC<RegistrationFormProps> = ({ backendURL }) => {
const [usernameInput, setUsername] = useState("");
const [passwordInput, setPassword] = useState("");
const [nicknameInput, setNickname] = useState("");
const [loading, setLoading] = useState(false);
const [showDialog, setShowDialog] = useState(false);
const [dialogContent, setDialogContent] = useState(<></>);
const [isLoading, setLoading] = useState(false);
const t = useTranslations("");
const { startCaptcha, captchaResult } = useCaptcha({
const { startCaptcha, captchaResult, captchaUsed, setCaptchaUsedState, captchaError } = useCaptcha({
backendURL,
route: "POST-/user"
});
const { trigger } = useSWRMutation(`${backendURL}/user`, requestSignUp);
const translateErrorMessage = (item: LocalizedMessage | string, path?: string) => {
if (typeof item === "string") {
@ -80,115 +58,51 @@ const SignUpForm: React.FC<RegistrationFormProps> = ({ backendURL }) => {
};
const register = async () => {
let username: string | undefined;
let password: string | undefined;
let nickname: string | undefined;
try {
const formData = await FormSchema.validate(
{
if (captchaUsed || !captchaResult) {
await startCaptcha();
}
trigger({
data: {
username: usernameInput,
password: passwordInput,
nickname: nicknameInput
},
{ abortEarly: false }
);
username = formData.username;
password = formData.password;
nickname = formData.nickname;
} catch (e) {
if (!(e instanceof ValidationError)) {
return;
}
console.log(JSON.parse(JSON.stringify(e)));
setShowDialog(true);
setDialogContent(
<ErrorDialog closeDialog={() => setShowDialog(false)}>
<p></p>
<span>: </span>
<br />
<ol className="list-decimal list-inside">
{e.errors.map((item, i) => {
return <li key={i}>{translateErrorMessage(item, e.inner[i].path)}</li>;
})}
</ol>
</ErrorDialog>
);
return;
}
setLoading(true);
try {
if (!captchaResult || !captchaResult.token) {
await startCaptcha();
}
// Proceed with user registration using username, password, and nickname
const registrationUrl = new URL(`${backendURL}/user`);
const registrationResponse = await fetch(registrationUrl.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${captchaResult!.token}`
},
body: JSON.stringify({
username: username,
password: password,
nickname: nickname
})
setShowDialog,
captchaResult,
setCaptchaUsedState,
translateErrorMessage,
setDialogContent,
t
});
if (registrationResponse.ok) {
console.log("Registration successful!");
// Optionally redirect the user or show a success message
//router.push("/login"); // Example redirection
} else {
const res: ErrorResponse = await registrationResponse.json();
setShowDialog(true);
setDialogContent(
<ErrorDialog closeDialog={() => setShowDialog(false)}>
<p></p>
<p>: {res.code}</p>
<p>
: <br />
{res.i18n ? t(res.i18n.key, { ...res.i18n.values }) : res.message}
</p>
</ErrorDialog>
);
}
} catch (error) {
if (error instanceof ApiRequestError) {
const res = error.response as ErrorResponse;
setShowDialog(true);
setDialogContent(
<ErrorDialog closeDialog={() => setShowDialog(false)}>
<p></p>
<p>: {res.code}</p>
<p>
: <br />
{res.i18n
? t.rich(res.i18n.key, {
...res.i18n.values,
support: (chunks) => <Link href="/support">{chunks}</Link>
})
: res.message}
</p>
</ErrorDialog>
);
}
} finally {
setLoading(false);
}
};
useEffect(() => {
if (startCaptcha) {
startCaptcha();
if (!captchaError || captchaError === undefined) return;
const err = captchaError as ApiRequestError;
setShowDialog(true);
if (err.code && err.code == -1) {
setDialogContent(
<ErrorDialog closeDialog={() => setShowDialog(false)}>
<p></p>
</ErrorDialog>
);
}
}, [startCaptcha]);
}, [captchaError]);
useEffect(() => {
startCaptcha();
}, []);
return (
<form
className="w-full flex flex-col gap-6"
onSubmit={async (e) => {
setLoading(true);
e.preventDefault();
await register();
}}
@ -215,8 +129,8 @@ const SignUpForm: React.FC<RegistrationFormProps> = ({ backendURL }) => {
supportingText="昵称可以重复。"
maxChar={30}
/>
<FilledButton type="submit" disabled={loading}>
{!loading ? <span></span> : <LoadingSpinner />}
<FilledButton type="submit" disabled={isLoading}>
{isLoading ? <LoadingSpinner /> : <span></span>}
</FilledButton>
<Portal>
<Dialog show={showDialog}>{dialogContent}</Dialog>

View File

@ -0,0 +1,139 @@
import { Dispatch, JSX, SetStateAction } from "react";
import { ApiRequestError, fetcher } from "@/lib/net";
import type { CaptchaVerificationRawResponse, ErrorResponse } from "@backend/src/schema";
import Link from "next/link";
import { LocalizedMessage } from "./SignUpForm";
import { ErrorDialog } from "./ErrorDialog";
import { string, object, ValidationError } from "yup";
interface SignUpFormData {
username: string;
password: string;
nickname?: string;
}
const FormSchema = object().shape({
username: string().required().max(50),
password: string().required().min(4).max(120),
nickname: string().optional().max(30)
});
const validateForm = async (
data: SignUpFormData,
setShowDialog: Dispatch<SetStateAction<boolean>>,
setDialogContent: Dispatch<SetStateAction<JSX.Element>>,
translateErrorMessage: (item: LocalizedMessage | string, path?: string) => string
): Promise<SignUpFormData | null> => {
const { username: usernameInput, password: passwordInput, nickname: nicknameInput } = data;
try {
const formData = await FormSchema.validate(
{
username: usernameInput,
password: passwordInput,
nickname: nicknameInput
},
{ abortEarly: false }
);
return {
username: formData.username,
password: formData.password,
nickname: formData.nickname
};
} catch (e) {
if (!(e instanceof ValidationError)) {
return null;
}
setShowDialog(true);
setDialogContent(
<ErrorDialog closeDialog={() => setShowDialog(false)}>
<p></p>
<span>: </span>
<br />
<ol className="list-decimal list-inside">
{e.errors.map((item, i) => {
return <li key={i}>{translateErrorMessage(item, e.inner[i].path)}</li>;
})}
</ol>
</ErrorDialog>
);
return null;
}
};
interface RequestSignUpArgs {
data: SignUpFormData;
setShowDialog: Dispatch<SetStateAction<boolean>>;
setDialogContent: Dispatch<SetStateAction<JSX.Element>>;
translateErrorMessage: (item: LocalizedMessage | string, path?: string) => string;
setCaptchaUsedState: Dispatch<SetStateAction<boolean>>;
captchaResult: CaptchaVerificationRawResponse | undefined;
t: any;
}
export const requestSignUp = async (url: string, { arg }: { arg: RequestSignUpArgs }) => {
const { data, setShowDialog, setDialogContent, translateErrorMessage, setCaptchaUsedState, captchaResult, t } = arg;
const res = await validateForm(data, setShowDialog, setDialogContent, translateErrorMessage);
if (!res) {
return;
}
const { username, nickname, password } = res;
try {
if (!captchaResult) {
const err = new ApiRequestError("Cannot get captcha result");
err.response = {
code: "UNKNOWN_ERROR",
message: "Cannot get captch verifiction result",
i18n: {
key: "captcha_failed_to_get"
}
} as ErrorResponse;
throw err;
}
setCaptchaUsedState(true);
const registrationResponse = await fetcher(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${captchaResult!.token}`
},
data: {
username: username,
password: password,
nickname: nickname
}
});
return registrationResponse;
} catch (error) {
if (error instanceof ApiRequestError) {
const res = error.response as ErrorResponse;
setShowDialog(true);
setDialogContent(
<ErrorDialog closeDialog={() => setShowDialog(false)} errorCode={res.code}>
<p></p>
<p>
: <br />
{res.i18n
? t.rich(res.i18n.key, {
...res.i18n.values,
support: (chunks: string) => <Link href="/support">{chunks}</Link>
})
: res.message}
</p>
</ErrorDialog>
);
} else if (error instanceof Error) {
setShowDialog(true);
setDialogContent(
<ErrorDialog closeDialog={() => setShowDialog(false)}>
<p></p>
<p>
<br />
{error.message}
</p>
</ErrorDialog>
);
}
}
};

View File

@ -1,5 +1,6 @@
import useSWRMutation from "swr/mutation";
import type { ErrorResponse, CaptchaSessionResponse, CaptchaVerificationRawResponse } from "@backend/src/schema";
import { useState } from "react";
import type { CaptchaVerificationRawResponse, CaptchaSessionRawResponse } from "@backend/src/schema";
import { fetcher } from "@/lib/net";
import { computeVdfInWorker } from "@/lib/vdf";
@ -8,17 +9,16 @@ interface UseCaptchaOptions {
route: string;
}
function isErrResponse(res: ErrorResponse | object): res is ErrorResponse {
return (res as ErrorResponse).errors !== undefined;
}
export function useCaptcha({ backendURL, route }: UseCaptchaOptions) {
const fullUrl = `${backendURL}/captcha/session`;
const [isUsed, setIsUsed] = useState(false);
const { trigger, data, isMutating, error } = useSWRMutation<CaptchaVerificationRawResponse, Error>(
fullUrl,
async (url: string) => {
const sessionRes = await fetcher<CaptchaSessionResponse>(url, {
setIsUsed(false);
const sessionRes = await fetcher<CaptchaSessionRawResponse>(url, {
method: "POST",
headers: {
"Content-Type": "application/json"
@ -26,10 +26,6 @@ export function useCaptcha({ backendURL, route }: UseCaptchaOptions) {
data: { route }
});
if (isErrResponse(sessionRes)) {
throw new Error(sessionRes.message || "Failed to get captcha session");
}
const { g, n, t, id } = sessionRes;
if (!g || !n || !t || !id) {
throw new Error("Missing required CAPTCHA parameters");
@ -41,6 +37,7 @@ export function useCaptcha({ backendURL, route }: UseCaptchaOptions) {
resultUrl.searchParams.set("ans", ans.result.toString());
const result = await fetcher<CaptchaVerificationRawResponse>(resultUrl.toString());
return result;
}
);
@ -49,6 +46,8 @@ export function useCaptcha({ backendURL, route }: UseCaptchaOptions) {
startCaptcha: trigger,
captchaResult: data,
isLoadingCaptcha: isMutating,
captchaError: error
captchaError: error,
captchaUsed: isUsed,
setCaptchaUsedState: setIsUsed
};
}

View File

@ -9,9 +9,24 @@
"nickname": "昵称",
"backend": {
"error": {
"captcha_failed": "无法完成安全验证。",
"user_exists": "用户名 “{username}” 已被占用。",
"user_not_found_after_register": "我们的服务器出现错误:找不到名为'{username}'的用户。请联系我们的<support>支持团队</support>,反馈此问题。",
"captcha_not_found": "无法完成安全验证。你可以刷新页面并重试。如果此问题反复出现,你可以联系我们的<support>支持团队</support>,反馈此问题。"
},
"error_code": {
"INVALID_QUERY_PARAMS": "查询无效",
"UNKNOWN_ERROR": "未知错误",
"INVALID_PAYLOAD": "请求格式错误",
"INVALID_FORMAT": "数据格式错误",
"INVALID_HEADER": "请求头无效",
"BODY_TOO_LARGE": "请求体过大",
"UNAUTHORIZED": "未授权",
"INVALID_CREDENTIALS": "凭证无效",
"ENTITY_NOT_FOUND": "实体不存在",
"SERVER_ERROR": "服务器错误",
"RATE_LIMIT_EXCEEDED": "请求过于频繁",
"ENTITY_EXISTS": "实体已存在"
}
}
}

View File

@ -49,7 +49,7 @@ export async function fetcher<JSON = unknown>(
const { status, data } = axiosError.response;
throw new ApiRequestError(`HTTP error! status: ${status}`, data, status);
} else if (axiosError.request) {
throw new ApiRequestError("No response received.");
throw new ApiRequestError("No response received", undefined, -1);
} else {
throw new ApiRequestError(axiosError.message || "Unknown error");
}

View File

@ -5,7 +5,7 @@
"scripts": {
"dev": "next dev --turbopack -p 7400",
"build": "next build",
"start": "next start",
"start": "next start -p 7400",
"lint": "next lint",
"format": "prettier --write ."
},