improve: the structure of the error handling in sign up page
This commit is contained in:
parent
7f9563a2a6
commit
bb7f846305
2
packages/backend/src/schema.d.ts
vendored
2
packages/backend/src/schema.d.ts
vendored
@ -1,4 +1,4 @@
|
||||
type ErrorCode =
|
||||
export type ErrorCode =
|
||||
| "INVALID_QUERY_PARAMS"
|
||||
| "UNKNOWN_ERROR"
|
||||
| "INVALID_PAYLOAD"
|
||||
|
22
packages/next/app/[locale]/signup/ErrorDialog.tsx
Normal file
22
packages/next/app/[locale]/signup/ErrorDialog.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
|
139
packages/next/app/[locale]/signup/request.tsx
Normal file
139
packages/next/app/[locale]/signup/request.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
@ -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
|
||||
};
|
||||
}
|
||||
|
@ -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": "实体已存在"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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");
|
||||
}
|
||||
|
@ -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 ."
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user