diff --git a/Foxnouns.Frontend/app/components/ErrorAlert.tsx b/Foxnouns.Frontend/app/components/ErrorAlert.tsx index e30c516..cedb69a 100644 --- a/Foxnouns.Frontend/app/components/ErrorAlert.tsx +++ b/Foxnouns.Frontend/app/components/ErrorAlert.tsx @@ -1,7 +1,13 @@ import { TFunction } from "i18next"; import Alert from "react-bootstrap/Alert"; -import { useTranslation } from "react-i18next"; -import { ApiError, ErrorCode } from "~/lib/api/error"; +import { Trans, useTranslation } from "react-i18next"; +import { + ApiError, + ErrorCode, + ValidationError, + validationErrorType, + ValidationErrorType, +} from "~/lib/api/error"; export default function ErrorAlert({ error }: { error: ApiError }) { const { t } = useTranslation(); @@ -10,10 +16,112 @@ export default function ErrorAlert({ error }: { error: ApiError }) { {t("error.heading")} {errorCodeDesc(t, error.code)} + {error.errors && ( + + )} ); } +function ValidationErrors({ errorKey, errors }: { errorKey: string; errors: ValidationError[] }) { + return ( +
  • + + {errorKey} + + : + +
  • + ); +} + +function ValidationErrorEntry({ error }: { error: ValidationError }) { + const { t } = useTranslation(); + + const { + min_length: minLength, + max_length: maxLength, + actual_length: actualLength, + message: reason, + actual_value: actualValue, + allowed_values: allowedValues, + } = error; + + switch (validationErrorType(error)) { + case ValidationErrorType.LengthError: + if (error.actual_length! > error.max_length!) { + return ( + + Value is too long, maximum length is {{ maxLength }}, current length is{" "} + {{ actualLength }}. + + ); + } + + if (error.actual_length! < error.min_length!) { + return ( + + Value is too short, minimum length is {{ minLength }}, current length is{" "} + {{ actualLength }}. + + ); + } + + break; + + case ValidationErrorType.DisallowedValueError: + return ( + v.toString()).join(", "), + }} + > + {/* @ts-expect-error i18next handles interpolation */} + The value {{ actualValue }} is not allowed here. Allowed values are:{" "} + {/* @ts-expect-error i18next handles interpolation */} + {{ allowedValues }} + + ); + + default: + if (error.actual_value) { + return ( + + {/* @ts-expect-error i18next handles interpolation */} + The value {{ actualValue }} is not allowed here. Reason: {{ reason }} + + ); + } + + return <>{t("error.validation.generic-no-value", { reason: error.message })}; + } +} + export const errorCodeDesc = (t: TFunction, code: ErrorCode) => { switch (code) { case ErrorCode.AuthenticationError: diff --git a/Foxnouns.Frontend/app/lib/api/error.ts b/Foxnouns.Frontend/app/lib/api/error.ts index 02e871c..0a3d9b9 100644 --- a/Foxnouns.Frontend/app/lib/api/error.ts +++ b/Foxnouns.Frontend/app/lib/api/error.ts @@ -3,7 +3,7 @@ export type ApiError = { status: number; message: string; code: ErrorCode; - errors?: ValidationError[]; + errors?: Array<{ key: string; errors: ValidationError[] }>; }; export enum ErrorCode { @@ -26,3 +26,31 @@ export type ValidationError = { allowed_values?: any[]; actual_value?: any; }; + +/** + * Returns the first error for the value `key` in `error`. + * @param error The error object to traverse. + * @param key The JSON key to find. + */ +export const firstErrorFor = (error: ApiError, key: string): ValidationError | undefined => { + if (!error.errors) return undefined; + const field = error.errors.find((e) => e.key == key); + if (!field?.errors) return undefined; + return field.errors.length != 0 ? field.errors[0] : undefined; +}; + +export enum ValidationErrorType { + LengthError = 0, + DisallowedValueError = 1, + GenericValidationError = 2, +} + +export const validationErrorType = (error: ValidationError) => { + if (error.min_length && error.max_length && error.actual_length) { + return ValidationErrorType.LengthError; + } + if (error.allowed_values && error.actual_value) { + return ValidationErrorType.DisallowedValueError; + } + return ValidationErrorType.GenericValidationError; +}; diff --git a/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx b/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx index f946ee0..31d4d64 100644 --- a/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx +++ b/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx @@ -1,11 +1,23 @@ -import { json, LoaderFunctionArgs } from "@remix-run/node"; -import { type ApiError, ErrorCode } from "~/lib/api/error"; +import { ActionFunctionArgs, json, redirect, LoaderFunctionArgs } from "@remix-run/node"; +import { type ApiError, ErrorCode, firstErrorFor } from "~/lib/api/error"; import serverRequest, { writeCookie } from "~/lib/request.server"; -import { CallbackResponse } from "~/lib/api/auth"; -import { Form as RemixForm, Link, useLoaderData } from "@remix-run/react"; +import { AuthResponse, CallbackResponse } from "~/lib/api/auth"; +import { + Form as RemixForm, + Link, + useActionData, + useLoaderData, + ShouldRevalidateFunction, +} from "@remix-run/react"; import { Trans, useTranslation } from "react-i18next"; import Form from "react-bootstrap/Form"; import Button from "react-bootstrap/Button"; +import ErrorAlert from "~/components/ErrorAlert"; +import Alert from "react-bootstrap/Alert"; + +export const shouldRevalidate: ShouldRevalidateFunction = ({ actionResult }) => { + return !actionResult; +}; export const loader = async ({ request }: LoaderFunctionArgs) => { const url = new URL(request.url); @@ -17,7 +29,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { throw { status: 400, code: ErrorCode.BadRequest, message: "Missing code or state" } as ApiError; const resp = await serverRequest("POST", "/auth/discord/callback", { - body: { code, state } + body: { code, state }, }); if (resp.has_account) { @@ -25,9 +37,9 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { { hasAccount: true, user: resp.user!, ticket: null, remoteUser: null }, { headers: { - "Set-Cookie": writeCookie("pronounscc-token", resp.token!) - } - } + "Set-Cookie": writeCookie("pronounscc-token", resp.token!), + }, + }, ); } @@ -35,26 +47,62 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { hasAccount: false, user: null, ticket: resp.ticket!, - remoteUser: resp.remote_username! + remoteUser: resp.remote_username!, }); }; -// TODO: action function +export const action = async ({ request }: ActionFunctionArgs) => { + const data = await request.formData(); + const username = data.get("username") as string | null; + const ticket = data.get("ticket") as string | null; + + if (!username || !ticket) + return json({ + error: { + status: 403, + code: ErrorCode.BadRequest, + message: "Invalid username or ticket", + } as ApiError, + user: null, + }); + + try { + const resp = await serverRequest("POST", "/auth/discord/register", { + body: { username, ticket }, + }); + + return redirect("/auth/welcome", { + headers: { + "Set-Cookie": writeCookie("pronounscc-token", resp.token), + }, + status: 303, + }); + } catch (e) { + JSON.stringify(e); + + return json({ error: e as ApiError }); + } +}; export default function DiscordCallbackPage() { const { t } = useTranslation(); const data = useLoaderData(); + const actionData = useActionData(); if (data.hasAccount) { const username = data.user!.username; - + return ( <>

    {t("log-in.callback.success")}

    - + {/* @ts-expect-error react-i18next handles interpolation here */} - Welcome back, @{{username}}! + Welcome back, @{{ username }}!
    {t("log-in.callback.redirect-hint")} @@ -66,6 +114,7 @@ export default function DiscordCallbackPage() { return (

    + {actionData?.error && } {t("log-in.callback.remote-username.discord")} @@ -82,3 +131,34 @@ export default function DiscordCallbackPage() { ); } + +function RegisterError({ error }: { error: ApiError }) { + const { t } = useTranslation(); + + // TODO: maybe turn these messages into their own error codes? + const ticketMessage = firstErrorFor(error, "ticket")?.message; + const usernameMessage = firstErrorFor(error, "username")?.message; + + if (ticketMessage === "Invalid ticket") { + return ( + + {t("error.heading")} + + Invalid ticket (it might have been too long since you logged in with Discord), please{" "} + try again. + + + ); + } + + if (usernameMessage === "Username is already taken") { + return ( + + {t("log-in.callback.invalid-username")} + {t("log-in.callback.username-taken")} + + ); + } + + return ; +} diff --git a/Foxnouns.Frontend/public/locales/en.json b/Foxnouns.Frontend/public/locales/en.json index 83455f7..f2dfbf2 100644 --- a/Foxnouns.Frontend/public/locales/en.json +++ b/Foxnouns.Frontend/public/locales/en.json @@ -1,52 +1,62 @@ { - "error": { - "heading": "An error occurred", - "errors": { - "authentication-error": "There was an error validating your credentials.", - "authentication-required": "You need to log in.", - "bad-request": "Server rejected your input, please check anything for errors.", - "forbidden": "You are not allowed to perform that action.", - "generic-error": "An unknown error occurred.", - "internal-server-error": "Server experienced an internal error, please try again later.", - "member-not-found": "Member not found, please check your spelling and try again.", - "user-not-found": "User not found, please check your spelling and try again." - }, - "title": "Error" - }, - "navbar": { - "view-profile": "View profile", - "settings": "Settings", - "log-out": "Log out", - "log-in": "Log in or sign up", - "theme": "Theme", - "theme-auto": "Automatic", - "theme-dark": "Dark", - "theme-light": "Light" - }, - "log-in": { - "callback": { - "success": "Successfully logged in!", - "success-link": "Welcome back, <1>@{{username}}!", - "redirect-hint": "If you're not redirected to your profile in a few seconds, press the link above.", - "remote-username": { - "discord": "Your discord username" - }, - "username": "Username", - "sign-up-button": "Sign up" - }, - "title": "Log in", - "form-title": "Log in with email", - "email": "Email address", - "password": "Password", - "log-in-button": "Log in", - "register-with-email": "Register with email", - "3rd-party": { - "title": "Log in with another service", - "desc": "If you prefer, you can also log in with one of these services:", - "discord": "Log in with Discord", - "google": "Log in with Google", - "tumblr": "Log in with Tumblr" - }, - "invalid-credentials": "Invalid email address or password, please check your spelling and try again." - } + "error": { + "heading": "An error occurred", + "validation": { + "too-long": "Value is too long, maximum length is {{maxLength}}, current length is {{actualLength}}.", + "too-short": "Value is too short, minimum length is {{minLength}}, current length is {{actualLength}}.", + "disallowed-value": "The value <1>{{actualValue}} is not allowed here. Allowed values are: <4>{{allowedValues}}", + "generic": "The value <1>{{actualValue}} is not allowed here. Reason: {{reason}}", + "generic-no-value": "The value you entered is not allowed here. Reason: {{reason}}" + }, + "errors": { + "authentication-error": "There was an error validating your credentials.", + "authentication-required": "You need to log in.", + "bad-request": "Server rejected your input, please check anything for errors.", + "forbidden": "You are not allowed to perform that action.", + "generic-error": "An unknown error occurred.", + "internal-server-error": "Server experienced an internal error, please try again later.", + "member-not-found": "Member not found, please check your spelling and try again.", + "user-not-found": "User not found, please check your spelling and try again." + }, + "title": "Error" + }, + "navbar": { + "view-profile": "View profile", + "settings": "Settings", + "log-out": "Log out", + "log-in": "Log in or sign up", + "theme": "Theme", + "theme-auto": "Automatic", + "theme-dark": "Dark", + "theme-light": "Light" + }, + "log-in": { + "callback": { + "success": "Successfully logged in!", + "success-link": "Welcome back, <1>@{{username}}!", + "redirect-hint": "If you're not redirected to your profile in a few seconds, press the link above.", + "remote-username": { + "discord": "Your discord username" + }, + "username": "Username", + "sign-up-button": "Sign up", + "invalid-ticket": "Invalid ticket (it might have been too long since you logged in with Discord), please <2>try again.", + "invalid-username": "Invalid username", + "username-taken": "That username is already taken, please try something else." + }, + "title": "Log in", + "form-title": "Log in with email", + "email": "Email address", + "password": "Password", + "log-in-button": "Log in", + "register-with-email": "Register with email", + "3rd-party": { + "title": "Log in with another service", + "desc": "If you prefer, you can also log in with one of these services:", + "discord": "Log in with Discord", + "google": "Log in with Google", + "tumblr": "Log in with Tumblr" + }, + "invalid-credentials": "Invalid email address or password, please check your spelling and try again." + } }