diff --git a/Foxnouns.Frontend/app/components/ErrorAlert.tsx b/Foxnouns.Frontend/app/components/ErrorAlert.tsx new file mode 100644 index 0000000..e30c516 --- /dev/null +++ b/Foxnouns.Frontend/app/components/ErrorAlert.tsx @@ -0,0 +1,38 @@ +import { TFunction } from "i18next"; +import Alert from "react-bootstrap/Alert"; +import { useTranslation } from "react-i18next"; +import { ApiError, ErrorCode } from "~/lib/api/error"; + +export default function ErrorAlert({ error }: { error: ApiError }) { + const { t } = useTranslation(); + + return ( + + {t("error.heading")} + {errorCodeDesc(t, error.code)} + + ); +} + +export const errorCodeDesc = (t: TFunction, code: ErrorCode) => { + switch (code) { + case ErrorCode.AuthenticationError: + return t("error.errors.authentication-error"); + case ErrorCode.AuthenticationRequired: + return t("error.errors.authentication-required"); + case ErrorCode.BadRequest: + return t("error.errors.bad-request"); + case ErrorCode.Forbidden: + return t("error.errors.forbidden"); + case ErrorCode.GenericApiError: + return t("error.errors.generic-error"); + case ErrorCode.InternalServerError: + return t("error.errors.internal-server-error"); + case ErrorCode.MemberNotFound: + return t("error.errors.member-not-found"); + case ErrorCode.UserNotFound: + return t("error.errors.user-not-found"); + } + + return t("error.errors.generic-error"); +}; diff --git a/Foxnouns.Frontend/app/lib/request.server.ts b/Foxnouns.Frontend/app/lib/request.server.ts index d7add9b..d6192ae 100644 --- a/Foxnouns.Frontend/app/lib/request.server.ts +++ b/Foxnouns.Frontend/app/lib/request.server.ts @@ -39,6 +39,8 @@ export default async function serverRequest( return (await resp.json()) as T; } +export const getToken = (req: Request) => getCookie(req, "pronounscc-token"); + export function getCookie(req: Request, cookieName: string): string | undefined { const header = req.headers.get("Cookie"); if (!header) return undefined; diff --git a/Foxnouns.Frontend/app/root.tsx b/Foxnouns.Frontend/app/root.tsx index d70be26..a3b5877 100644 --- a/Foxnouns.Frontend/app/root.tsx +++ b/Foxnouns.Frontend/app/root.tsx @@ -6,10 +6,11 @@ import { Scripts, ScrollRestoration, useLoaderData, + useRouteError, } from "@remix-run/react"; import { LoaderFunctionArgs } from "@remix-run/node"; import { useChangeLanguage } from "remix-i18next/react"; -import { useTranslation } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import serverRequest, { getCookie, writeCookie } from "./lib/request.server"; import Meta from "./lib/api/meta"; @@ -20,6 +21,7 @@ import { ApiError, ErrorCode } from "./lib/api/error"; import "./app.scss"; import getLocalSettings from "./lib/settings.server"; import { LANGUAGE } from "~/env.server"; +import { errorCodeDesc } from "./components/ErrorAlert"; export const loader = async ({ request }: LoaderFunctionArgs) => { const meta = await serverRequest("GET", "/meta"); @@ -73,6 +75,47 @@ export function Layout({ children }: { children: React.ReactNode }) { ); } +export function ErrorBoundary() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const error: any = useRouteError(); + const { t } = useTranslation(); + + console.log(error); + + const errorElem = + "code" in error && "message" in error ? ( + + ) : ( + <>{t("error.errors.generic-error")} + ); + + return ( + + + {t("error.title")} - pronouns.cc + + + + + {errorElem} + + + + ); +} + +function ApiErrorElem({ error }: { error: ApiError }) { + const { t } = useTranslation(); + const errorDesc = errorCodeDesc(t, error.code); + + return ( + <> +

{t("error.heading")}

+

{errorDesc}

+ + ); +} + export default function App() { const { meta, meUser, settings } = useLoaderData(); diff --git a/Foxnouns.Frontend/app/routes/auth.log-in/route.tsx b/Foxnouns.Frontend/app/routes/auth.log-in/route.tsx index 4fd891f..7eccac5 100644 --- a/Foxnouns.Frontend/app/routes/auth.log-in/route.tsx +++ b/Foxnouns.Frontend/app/routes/auth.log-in/route.tsx @@ -1,11 +1,22 @@ -import { MetaFunction, json, LoaderFunctionArgs, ActionFunction, redirect } from "@remix-run/node"; -import { Form as RemixForm } from "@remix-run/react"; +import { + MetaFunction, + json, + LoaderFunctionArgs, + redirect, + ActionFunctionArgs, +} from "@remix-run/node"; +import { Form as RemixForm, Link, useActionData } from "@remix-run/react"; import Form from "react-bootstrap/Form"; import Button from "react-bootstrap/Button"; +import Row from "react-bootstrap/Row"; +import Col from "react-bootstrap/Col"; import { useTranslation } from "react-i18next"; import i18n from "~/i18next.server"; -import serverRequest, { writeCookie } from "~/lib/request.server"; +import serverRequest, { getToken, writeCookie } from "~/lib/request.server"; import { AuthResponse } from "~/lib/api/auth"; +import { ApiError, ErrorCode } from "~/lib/api/error"; +import ErrorAlert from "~/components/ErrorAlert"; +import { User } from "~/lib/api/user"; export const meta: MetaFunction = ({ data }) => { return [{ title: `${data?.meta.title || "Log in"} - pronouns.cc` }]; @@ -13,50 +24,85 @@ export const meta: MetaFunction = ({ data }) => { export const loader = async ({ request }: LoaderFunctionArgs) => { const t = await i18n.getFixedT(request); + const token = getToken(request); + if (token) { + try { + await serverRequest("GET", "/users/@me", { token }); + + throw redirect("/?err=already-logged-in", 303); + } catch (e) { + // ignore + } + } return json({ meta: { title: t("log-in.title") }, }); }; -export const action: ActionFunction = async ({ request }) => { +export const action = async ({ request }: ActionFunctionArgs) => { const body = await request.formData(); const email = body.get("email") as string | null; const password = body.get("password") as string | null; console.log(email, password); - const resp = await serverRequest("POST", "/auth/email/login", { - body: { email, password }, - }); + try { + const resp = await serverRequest("POST", "/auth/email/login", { + body: { email, password }, + }); - return redirect("/", { - status: 303, - headers: { - "Set-Cookie": writeCookie("pronounscc-token", resp.token), - }, - }); + return redirect("/", { + status: 303, + headers: { + "Set-Cookie": writeCookie("pronounscc-token", resp.token), + }, + }); + } catch (e) { + return json({ error: e as ApiError }); + } }; export default function LoginPage() { const { t } = useTranslation(); + const actionData = useActionData(); return ( - -
- - {t("log-in.email")} - - - - {t("log-in.password")} - - + + +

{t("log-in.form-title")}

+ {actionData?.error && } + + + + {t("log-in.email")} + + + + {t("log-in.password")} + + - - - + + + +
+ + +

{t("log-in.3rd-party-title")}

+ + ); } + +function LoginError({ error }: { error: ApiError }) { + const { t } = useTranslation(); + + if (error.code !== ErrorCode.UserNotFound) return ; + + return <>{t("log-in.invalid-credentials")}; +} diff --git a/Foxnouns.Frontend/public/locales/en.json b/Foxnouns.Frontend/public/locales/en.json index 17b3948..5bcf161 100644 --- a/Foxnouns.Frontend/public/locales/en.json +++ b/Foxnouns.Frontend/public/locales/en.json @@ -1,18 +1,35 @@ { - "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": { - "title": "Log in", - "email": "Email address", - "password": "Password", - "log-in-button": "Log in" - } + "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." + } + }, + "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": { + "title": "Log in", + "form-title": "", + "email": "Email address", + "password": "Password", + "log-in-button": "Log in", + "register-with-email": "Register with email", + "3rd-party-title": "Log in with another account", + "invalid-credentials": "Invalid email address or password, please check your spelling and try again." + } }