diff --git a/public/locales/en/common.json b/public/locales/en/common.json index a3cc44a9..7b330cb0 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -248,6 +248,7 @@ "password": "Password", "passwordConfirm": "Confirm password", "registerButton": "Register", + "email": "Email (optional)", "username": "Username", "validation": { "password": { @@ -264,10 +265,44 @@ "min": "Username must be at least {{min, number}} characters long", "regex": "Username must only contain alphanumeric characters", "required": "Username is required" + }, + "email": { + "invalid": "Invalid email" } }, "usernameTaken": "Username is taken" }, + "passwordResetRequestPage": { + "rateLimited": "You cannot request a password reset again yet", + "email": "Email", + "title": "Request password reset", + "success": "Email sent", + "emailSent": "Password reset email sent", + "sendEmailButton": "Send password reset email", + "validation": { + "email": { + "invalid": "Invalid email" + } + } + }, + "passwordResetPage": { + "rateLimited": "You cannot request a password reset again yet", + "invalidToken": "Your password reset token is invalid or expired", + "changePassword": "Change password", + "title": "Reset password", + "success": "Password changed", + "validation": { + "password": { + "max": "Password can not be more than {{max, number}} characters long", + "min": "Password must be at least {{min, number}} characters long", + "required": "Password is required" + }, + "passwordConfirm": { + "noMatch": "Passwords must match", + "required": "Password confirmation is required" + } + } + }, "theme": { "dark": "Dark mode", "light": "Light mode", diff --git a/public/locales/fi/common.json b/public/locales/fi/common.json index d19f710a..bc613c64 100644 --- a/public/locales/fi/common.json +++ b/public/locales/fi/common.json @@ -249,6 +249,7 @@ "passwordConfirm": "Vahvista salasana", "registerButton": "Rekisteröidy", "username": "Käyttäjänimi", + "email": "Sähköposti (valinnainen)", "validation": { "password": { "max": "Salasana voi olla enintään {{max, number}} merkkiä pitkä", @@ -264,10 +265,44 @@ "min": "Käyttäjänimen tulee olla vähintään {{min, number}} merkkiä pitkä", "regex": "Käyttäjänimi voi sisältää vain alphanumeerisia merkkejä", "required": "Käyttäjänimi vaaditaan" + }, + "email": { + "invalid": "Virheellinen sähköpostiosoite" } }, "usernameTaken": "Käyttäjänimi on varattu" }, + "passwordResetRequestPage": { + "rateLimited": "Et voi pyytää uutta salasanan nollausta vielä", + "email": "Email", + "success": "Sähköposti lähetetty", + "title": "Salasanan nollaus", + "emailSent": "Salasanan nollaussähköposti lähetetty", + "sendEmailButton": "Lähetä salasanan nollaussähköposti", + "validation": { + "email": { + "invalid": "Virheellinen sähköpostiosoite" + } + } + }, + "passwordResetPage": { + "rateLimited": "Et voi pyytää uutta salasanan nollausta vielä", + "invalidToken": "Koodisi on virheellinen tai ei enää voimassa", + "changePassword": "Vaihda salasana", + "title": "Salasanan nollaus", + "success": "Salasana muutettu", + "validation": { + "password": { + "max": "Salasana voi olla enintään {{max, number}} merkkiä pitkä", + "min": "Salasanan tulee olla vähintään {{min, number}} merkkiä pitkä", + "required": "Salasana vaaditaan" + }, + "passwordConfirm": { + "noMatch": "Salasanat eivät täsmää", + "required": "Salasanavahvistus vaaditaan" + } + } + }, "theme": { "dark": "Tumma teema", "light": "Vaalea teema", diff --git a/src/app/[locale]/register/RegistrationForm.tsx b/src/app/[locale]/register/RegistrationForm.tsx index 76e0e74f..05aa60ce 100644 --- a/src/app/[locale]/register/RegistrationForm.tsx +++ b/src/app/[locale]/register/RegistrationForm.tsx @@ -19,6 +19,7 @@ export const RegistrationForm = () => { { /^[a-zA-Z0-9]*$/, t("registrationPage.validation.username.regex"), ), + email: Yup.string() + .email(t("registrationPage.validation.email.invalid")) + .optional(), password: Yup.string() .required(t("registrationPage.validation.password.required")) .min(8, t("registrationPage.validation.password.min", { min: 8 })) @@ -47,7 +51,18 @@ export const RegistrationForm = () => { })} onSubmit={async (values) => { setVisible(true); - const result = await register(values.username, values.password); + + let result; + if (values.email == "") { + result = await register(values.username, values.password); + } else { + result = await register( + values.username, + values.password, + values.email, + ); + } + switch (result) { case RegistrationResult.RateLimited: showNotification({ @@ -82,6 +97,11 @@ export const RegistrationForm = () => { name="username" label={t("registrationPage.username")} /> + { +export const register = async ( + username: string, + password: string, + email?: string, +) => { + const body = email + ? JSON.stringify({ + username: username, + email: email, + password: password, + }) + : JSON.stringify({ + username: username, + password: password, + }); + const response = await fetch( process.env.NEXT_PUBLIC_API_URL + "/auth/register", { @@ -25,10 +40,7 @@ export const register = async (username: string, password: string) => { "client-ip": headers().get("client-ip") ?? "Unknown IP", "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", }, - body: JSON.stringify({ - username: username, - password: password, - }), + body: body, }, ); diff --git a/src/app/[locale]/request-password-reset/PasswordResetRequestForm.tsx b/src/app/[locale]/request-password-reset/PasswordResetRequestForm.tsx new file mode 100644 index 00000000..7989685f --- /dev/null +++ b/src/app/[locale]/request-password-reset/PasswordResetRequestForm.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { Form, Formik } from "formik"; +import { useTranslation } from "react-i18next"; +import * as Yup from "yup"; +import { requestPasswordReset } from "./actions"; +import { Button, LoadingOverlay } from "@mantine/core"; +import { useState } from "react"; +import { showNotification } from "@mantine/notifications"; +import { PasswordResetRequestResult } from "../../../types"; +import { FormikTextInput } from "../../../components/forms/FormikTextInput"; + +export const PasswordResetRequestForm = () => { + const [visible, setVisible] = useState(false); + const { t } = useTranslation(); + + return ( + { + const result = await requestPasswordReset(values.email); + switch (result) { + case PasswordResetRequestResult.RateLimited: + showNotification({ + title: t("error"), + color: "red", + message: t("passwordResetRequestPage.rateLimited"), + }); + setVisible(false); + break; + case PasswordResetRequestResult.UnknownError: + showNotification({ + title: t("error"), + color: "red", + message: t("unknownErrorOccurred"), + }); + setVisible(false); + break; + case PasswordResetRequestResult.Success: + showNotification({ + title: t("passwordResetRequestPage.success"), + color: "green", + message: t("passwordResetRequestPage.emailSent"), + }); + setVisible(false); + break; + } + }} + > + {() => ( +
+ + + + )} +
+ ); +}; diff --git a/src/app/[locale]/request-password-reset/actions.ts b/src/app/[locale]/request-password-reset/actions.ts new file mode 100644 index 00000000..31f746f2 --- /dev/null +++ b/src/app/[locale]/request-password-reset/actions.ts @@ -0,0 +1,32 @@ +"use server"; + +import { headers } from "next/headers"; +import { PasswordResetRequestResult } from "../../../types"; + +export const requestPasswordReset = async (email: string) => { + const response = await fetch( + process.env.NEXT_PUBLIC_API_URL + "/auth/reset-password", + { + method: "POST", + cache: "no-cache", + headers: { + "Content-Type": "application/json", + "client-ip": headers().get("client-ip") ?? "Unknown IP", + "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", + }, + body: JSON.stringify({ + email: email, + }), + }, + ); + + if (!response.ok) { + if (response.status == 429) { + return PasswordResetRequestResult.RateLimited; + } + + return PasswordResetRequestResult.UnknownError; + } + + return PasswordResetRequestResult.Success; +}; diff --git a/src/app/[locale]/request-password-reset/page.tsx b/src/app/[locale]/request-password-reset/page.tsx new file mode 100644 index 00000000..6256c6bf --- /dev/null +++ b/src/app/[locale]/request-password-reset/page.tsx @@ -0,0 +1,20 @@ +import { Title, Group } from "@mantine/core"; +import { PasswordResetRequestForm } from "./PasswordResetRequestForm"; +import initTranslations from "../../i18n"; + +export default async function PasswordResetRequestPage({ + params: { locale }, +}: { + params: { locale: string }; +}) { + const { t } = await initTranslations(locale, ["common"]); + + return ( + + + {t("passwordResetRequestPage.title")} + + + + ); +} diff --git a/src/app/[locale]/reset-password/PasswordResetForm.tsx b/src/app/[locale]/reset-password/PasswordResetForm.tsx new file mode 100644 index 00000000..56e8c078 --- /dev/null +++ b/src/app/[locale]/reset-password/PasswordResetForm.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { Form, Formik } from "formik"; +import { useState } from "react"; +import * as Yup from "yup"; +import { showNotification } from "@mantine/notifications"; +import { FormikPasswordInput } from "../../../components/forms/FormikPasswordInput"; +import { Button, LoadingOverlay } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { PasswordResetResult } from "../../../types"; +import { resetPassword } from "./actions"; +import { redirect, useSearchParams } from "next/navigation"; + +export const PasswordResetForm = () => { + const [visible, setVisible] = useState(false); + const { t } = useTranslation(); + const token = useSearchParams().get("token"); + + if (token == null) { + // FIXME: Dumb + return redirect("/"); + } + + return ( + { + setVisible(true); + + const result = await resetPassword(token, values.password); + + switch (result) { + case PasswordResetResult.RateLimited: + showNotification({ + title: t("error"), + color: "red", + message: t("passwordResetPage.rateLimited"), + }); + setVisible(false); + break; + case PasswordResetResult.UnknownError: + showNotification({ + title: t("error"), + color: "red", + message: t("unknownErrorOccurred"), + }); + setVisible(false); + break; + case PasswordResetResult.InvalidToken: + showNotification({ + title: t("error"), + color: "red", + message: t("passwordResetPage.invalidToken"), + }); + setVisible(false); + break; + case PasswordResetResult.Success: + showNotification({ + title: t("passwordResetPage.title"), + color: "green", + message: t("passwordResetPage.success"), + }); + redirect("/login"); + } + }} + > + {() => ( +
+ + + + + )} +
+ ); +}; diff --git a/src/app/[locale]/reset-password/actions.ts b/src/app/[locale]/reset-password/actions.ts new file mode 100644 index 00000000..74df941d --- /dev/null +++ b/src/app/[locale]/reset-password/actions.ts @@ -0,0 +1,37 @@ +"use server"; + +import { headers } from "next/headers"; +import { PasswordResetResult } from "../../../types"; + +export const resetPassword = async (token: string, password: string) => { + const response = await fetch( + process.env.NEXT_PUBLIC_API_URL + "/auth/complete-password-reset", + { + method: "POST", + cache: "no-cache", + headers: { + "Content-Type": "application/json", + "client-ip": headers().get("client-ip") ?? "Unknown IP", + "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", + }, + body: JSON.stringify({ + token: token, + password: password, + }), + }, + ); + + if (!response.ok) { + if (response.status == 429) { + return PasswordResetResult.RateLimited; + } + + if (response.status == 400) { + return PasswordResetResult.InvalidToken; + } + + return PasswordResetResult.UnknownError; + } + + return PasswordResetResult.Success; +}; diff --git a/src/app/[locale]/reset-password/page.tsx b/src/app/[locale]/reset-password/page.tsx new file mode 100644 index 00000000..1dae265c --- /dev/null +++ b/src/app/[locale]/reset-password/page.tsx @@ -0,0 +1,20 @@ +import { Title, Group } from "@mantine/core"; +import { PasswordResetForm } from "./PasswordResetForm"; +import initTranslations from "../../i18n"; + +export default async function PasswordResetRequestPage({ + params: { locale }, +}: { + params: { locale: string }; +}) { + const { t } = await initTranslations(locale, ["common"]); + + return ( + + + {t("passwordResetPage.title")} + + + + ); +} diff --git a/src/types.ts b/src/types.ts index 50eb46e3..a74c50b1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -84,6 +84,18 @@ export enum PasswordChangeResult { UnknownError, } +export enum PasswordResetRequestResult { + Success, + RateLimited, + UnknownError, +} + +export enum PasswordResetResult { + Success, + InvalidToken, + RateLimited, + UnknownError, +} export enum AddFriendError { AlreadyFriends, NotFound,