diff --git a/app/components/FormErrors.tsx b/app/components/FormErrors.tsx
index 366e9ecbf..be262ce50 100644
--- a/app/components/FormErrors.tsx
+++ b/app/components/FormErrors.tsx
@@ -1,7 +1,7 @@
import { useActionData } from "@remix-run/react";
import { useTranslation } from "react-i18next";
-export function FormErrors({ namespace }: { namespace: "calendar" }) {
+export function FormErrors({ namespace }: { namespace: "user" | "calendar" }) {
const { t } = useTranslation(["common", namespace]);
const actionData = useActionData<{ errors?: string[] }>();
diff --git a/app/routes/u.$identifier/edit.tsx b/app/routes/u.$identifier/edit.tsx
index 4c60656c4..de44ddf58 100644
--- a/app/routes/u.$identifier/edit.tsx
+++ b/app/routes/u.$identifier/edit.tsx
@@ -16,6 +16,7 @@ import { useTranslation } from "react-i18next";
import invariant from "tiny-invariant";
import { z } from "zod";
import { Button } from "~/components/Button";
+import { FormErrors } from "~/components/FormErrors";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
@@ -26,7 +27,8 @@ import { requireUser } from "~/modules/auth";
import { i18next } from "~/modules/i18n";
import styles from "~/styles/u-edit.css";
import { translatedCountry } from "~/utils/i18n.server";
-import { parseRequestFormData } from "~/utils/remix";
+import { safeParseRequestFormData } from "~/utils/remix";
+import { errorIsSqliteUniqueConstraintFailure } from "~/utils/sql";
import { isCustomUrl, userPage } from "~/utils/urls";
import { falsyToNull, undefinedToNull } from "~/utils/zod";
import { type UserPageLoaderData } from "../u.$identifier";
@@ -49,22 +51,18 @@ const userEditActionSchema = z.object({
falsyToNull,
z.string().max(USER.BIO_MAX_LENGTH).nullable()
),
- // xxx: return error if using duplicate custom url
customUrl: z.preprocess(
falsyToNull,
z
.string()
.max(USER.CUSTOM_URL_MAX_LENGTH)
- .refine(
- (val) => val === null || isCustomUrl(val),
- // xxx: translate
- "Name in the custom URL can't only contain numbers"
- )
+ .refine((val) => val === null || isCustomUrl(val), {
+ message: "forms.errors.invalidCustomUrl.numbers",
+ })
.refine(
// validate val only contains numbers and letters
(val) => val === null || /^[a-zA-Z0-9-_]+$/.test(val),
- // xxx: translate
- "Custom URL can't contain special characters"
+ { message: "forms.errors.invalidCustomUrl.strangeCharacter" }
)
.transform((val) => val?.toLowerCase())
.nullable()
@@ -98,23 +96,41 @@ const userEditActionSchema = z.object({
});
export const action: ActionFunction = async ({ request }) => {
- const { inGameNameText, inGameNameDiscriminator, ...data } =
- await parseRequestFormData({
- request,
- schema: userEditActionSchema,
- });
- const user = await requireUser(request);
-
- const editedUser = db.users.updateProfile({
- ...data,
- inGameName:
- inGameNameText && inGameNameDiscriminator
- ? `${inGameNameText}#${inGameNameDiscriminator}`
- : null,
- id: user.id,
+ const parsedInput = await safeParseRequestFormData({
+ request,
+ schema: userEditActionSchema,
});
- return redirect(userPage(editedUser));
+ if (!parsedInput.success) {
+ return {
+ errors: parsedInput.errors,
+ };
+ }
+
+ const { inGameNameText, inGameNameDiscriminator, ...data } = parsedInput.data;
+
+ const user = await requireUser(request);
+
+ try {
+ const editedUser = db.users.updateProfile({
+ ...data,
+ inGameName:
+ inGameNameText && inGameNameDiscriminator
+ ? `${inGameNameText}#${inGameNameDiscriminator}`
+ : null,
+ id: user.id,
+ });
+
+ return redirect(userPage(editedUser));
+ } catch (e) {
+ if (!errorIsSqliteUniqueConstraintFailure(e)) {
+ throw e;
+ }
+
+ return {
+ errors: ["forms.errors.invalidCustomUrl.duplicate"],
+ };
+ }
};
export const loader = async ({ request }: LoaderArgs) => {
@@ -156,6 +172,7 @@ export default function UserEditPage() {
>
{t("common:actions.save")}
+