feat(frontend): create account from discord, better error alert
This commit is contained in:
		
							parent
							
								
									ff22530f0a
								
							
						
					
					
						commit
						103ba24555
					
				
					 4 changed files with 292 additions and 66 deletions
				
			
		|  | @ -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 }) { | |||
| 		<Alert variant="danger"> | ||||
| 			<Alert.Heading as="h4">{t("error.heading")}</Alert.Heading> | ||||
| 			{errorCodeDesc(t, error.code)} | ||||
| 			{error.errors && ( | ||||
| 				<ul> | ||||
| 					{error.errors.map((e, i) => ( | ||||
| 						<ValidationErrors key={i} errorKey={e.key} errors={e.errors} /> | ||||
| 					))} | ||||
| 				</ul> | ||||
| 			)} | ||||
| 		</Alert> | ||||
| 	); | ||||
| } | ||||
| 
 | ||||
| function ValidationErrors({ errorKey, errors }: { errorKey: string; errors: ValidationError[] }) { | ||||
| 	return ( | ||||
| 		<li> | ||||
| 			<strong> | ||||
| 				<code>{errorKey}</code> | ||||
| 			</strong> | ||||
| 			: | ||||
| 			<ul> | ||||
| 				{errors.map((e, i) => ( | ||||
| 					<li key={i}> | ||||
| 						<ValidationErrorEntry error={e} /> | ||||
| 					</li> | ||||
| 				))} | ||||
| 			</ul> | ||||
| 		</li> | ||||
| 	); | ||||
| } | ||||
| 
 | ||||
| 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 ( | ||||
| 					<Trans | ||||
| 						t={t} | ||||
| 						i18nKey={"error.validation.too-long"} | ||||
| 						values={{ maxLength: error.max_length!, actualLength: error.actual_length! }} | ||||
| 					> | ||||
| 						Value is too long, maximum length is {{ maxLength }}, current length is{" "} | ||||
| 						{{ actualLength }}. | ||||
| 					</Trans> | ||||
| 				); | ||||
| 			} | ||||
| 
 | ||||
| 			if (error.actual_length! < error.min_length!) { | ||||
| 				return ( | ||||
| 					<Trans | ||||
| 						t={t} | ||||
| 						i18nKey={"error.validation.too-short"} | ||||
| 						values={{ minLength: error.min_length!, actualLength: error.actual_length! }} | ||||
| 					> | ||||
| 						Value is too short, minimum length is {{ minLength }}, current length is{" "} | ||||
| 						{{ actualLength }}. | ||||
| 					</Trans> | ||||
| 				); | ||||
| 			} | ||||
| 
 | ||||
| 			break; | ||||
| 
 | ||||
| 		case ValidationErrorType.DisallowedValueError: | ||||
| 			return ( | ||||
| 				<Trans | ||||
| 					t={t} | ||||
| 					i18nKey={"error.validation.disallowed-value"} | ||||
| 					values={{ | ||||
| 						actualValue: error.actual_value!.toString(), | ||||
| 						allowedValues: error.allowed_values!.map((v) => v.toString()).join(", "), | ||||
| 					}} | ||||
| 				> | ||||
| 					{/* @ts-expect-error i18next handles interpolation */} | ||||
| 					The value <code>{{ actualValue }}</code> is not allowed here. Allowed values are:{" "} | ||||
| 					{/* @ts-expect-error i18next handles interpolation */} | ||||
| 					<code>{{ allowedValues }}</code> | ||||
| 				</Trans> | ||||
| 			); | ||||
| 
 | ||||
| 		default: | ||||
| 			if (error.actual_value) { | ||||
| 				return ( | ||||
| 					<Trans | ||||
| 						t={t} | ||||
| 						i18nKey={"error.validation.generic"} | ||||
| 						values={{ actualValue: error.actual_value!.toString(), reason: error.message }} | ||||
| 					> | ||||
| 						{/* @ts-expect-error i18next handles interpolation */} | ||||
| 						The value <code>{{ actualValue }}</code> is not allowed here. Reason: {{ reason }} | ||||
| 					</Trans> | ||||
| 				); | ||||
| 			} | ||||
| 
 | ||||
| 			return <>{t("error.validation.generic-no-value", { reason: error.message })}</>; | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| export const errorCodeDesc = (t: TFunction, code: ErrorCode) => { | ||||
| 	switch (code) { | ||||
| 		case ErrorCode.AuthenticationError: | ||||
|  |  | |||
|  | @ -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; | ||||
| }; | ||||
|  |  | |||
|  | @ -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<CallbackResponse>("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<AuthResponse>("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<typeof loader>(); | ||||
| 	const actionData = useActionData<typeof action>(); | ||||
| 
 | ||||
| 	if (data.hasAccount) { | ||||
| 		const username = data.user!.username; | ||||
| 		 | ||||
| 
 | ||||
| 		return ( | ||||
| 			<> | ||||
| 				<h1>{t("log-in.callback.success")}</h1> | ||||
| 				<p> | ||||
| 					<Trans t={t} i18nKey={"log-in.callback.success-link"} values={{ username: data.user!.username }}> | ||||
| 					<Trans | ||||
| 						t={t} | ||||
| 						i18nKey={"log-in.callback.success-link"} | ||||
| 						values={{ username: data.user!.username }} | ||||
| 					> | ||||
| 						{/* @ts-expect-error react-i18next handles interpolation here */} | ||||
| 						Welcome back, <Link to={`/@${data.user!.username}`}>@{{username}}</Link>! | ||||
| 						Welcome back, <Link to={`/@${data.user!.username}`}>@{{ username }}</Link>! | ||||
| 					</Trans> | ||||
| 					<br /> | ||||
| 					{t("log-in.callback.redirect-hint")} | ||||
|  | @ -66,6 +114,7 @@ export default function DiscordCallbackPage() { | |||
| 	return ( | ||||
| 		<RemixForm method="POST"> | ||||
| 			<Form as="div"> | ||||
| 				{actionData?.error && <RegisterError error={actionData.error} />} | ||||
| 				<Form.Group className="mb-3" controlId="remote-username"> | ||||
| 					<Form.Label>{t("log-in.callback.remote-username.discord")}</Form.Label> | ||||
| 					<Form.Control type="text" readOnly={true} value={data.remoteUser!} /> | ||||
|  | @ -82,3 +131,34 @@ export default function DiscordCallbackPage() { | |||
| 		</RemixForm> | ||||
| 	); | ||||
| } | ||||
| 
 | ||||
| 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 ( | ||||
| 			<Alert variant="danger"> | ||||
| 				<Alert.Heading as="h4">{t("error.heading")}</Alert.Heading> | ||||
| 				<Trans t={t} i18nKey={"log-in.callback.invalid-ticket"}> | ||||
| 					Invalid ticket (it might have been too long since you logged in with Discord), please{" "} | ||||
| 					<Link to="/auth/log-in">try again</Link>. | ||||
| 				</Trans> | ||||
| 			</Alert> | ||||
| 		); | ||||
| 	} | ||||
| 
 | ||||
| 	if (usernameMessage === "Username is already taken") { | ||||
| 		return ( | ||||
| 			<Alert variant="danger"> | ||||
| 				<Alert.Heading as="h4">{t("log-in.callback.invalid-username")}</Alert.Heading> | ||||
| 				{t("log-in.callback.username-taken")} | ||||
| 			</Alert> | ||||
| 		); | ||||
| 	} | ||||
| 
 | ||||
| 	return <ErrorAlert error={error} />; | ||||
| } | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue