chore: add captcha to the forgot password modal

This commit is contained in:
Miodec 2025-02-12 12:12:37 +01:00
parent a2d91f2a73
commit a0c471a28e
9 changed files with 126 additions and 50 deletions

View file

@ -426,8 +426,11 @@ describe("user controller test", () => {
AuthUtils,
"sendForgotPasswordEmail"
);
const verifyCaptchaMock = vi.spyOn(Captcha, "verify");
beforeEach(() => {
sendForgotPasswordEmailMock.mockReset().mockResolvedValue();
verifyCaptchaMock.mockReset().mockResolvedValue(true);
});
it("should send forgot password email without authentication", async () => {
@ -436,7 +439,7 @@ describe("user controller test", () => {
//WHEN
const { body } = await mockApp
.post("/users/forgotPasswordEmail")
.send({ email: "bob@example.com" });
.send({ email: "bob@example.com", captcha: "" });
//THEN
expect(body).toEqual({
@ -458,7 +461,7 @@ describe("user controller test", () => {
//THEN
expect(body).toEqual({
message: "Invalid request data schema",
validationErrors: ['"email" Required'],
validationErrors: ['"captcha" Required', '"email" Required'],
});
});
it("should fail without unknown properties", async () => {
@ -471,7 +474,10 @@ describe("user controller test", () => {
//THEN
expect(body).toEqual({
message: "Invalid request data schema",
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
validationErrors: [
'"captcha" Required',
"Unrecognized key(s) in object: 'extra'",
],
});
});
});

View file

@ -238,7 +238,8 @@ export async function sendVerificationEmail(
export async function sendForgotPasswordEmail(
req: MonkeyRequest<undefined, ForgotPasswordEmailRequest>
): Promise<MonkeyResponse> {
const { email } = req.body;
const { email, captcha } = req.body;
await verifyCaptcha(captcha);
await authSendForgotPasswordEmail(email);
return new MonkeyResponse(
"Password reset request received. If the email is valid, you will receive an email shortly.",

View file

@ -4,6 +4,15 @@
</div>
</dialog>
<dialog id="forgotPasswordModal" class="modalWrapper hidden">
<div class="modal">
<div class="title">Forgot password</div>
<input type="text" placeholder="email" />
<div class="g-recaptcha"></div>
<!-- <button>send</button> -->
</div>
</dialog>
<dialog id="miniResultChartModal" class="modalWrapper hidden">
<div class="modal">
<canvas></canvas>

View file

@ -91,6 +91,12 @@ body.darkMode {
}
}
#forgotPasswordModal {
.modal {
max-width: 400px;
}
}
#customTextModal {
.modal {
max-width: 1200px;

View file

@ -0,0 +1,7 @@
import * as ForgotPasswordModal from "../modals/forgot-password";
const loginPage = document.querySelector("#pageLogin") as HTMLElement;
$(loginPage).on("click", "#forgotPasswordButton", () => {
ForgotPasswordModal.show();
});

View file

@ -9,6 +9,7 @@ import "./event-handlers/test";
import "./event-handlers/about";
import "./event-handlers/settings";
import "./event-handlers/account";
import "./event-handlers/login";
import "./modals/google-sign-up";

View file

@ -0,0 +1,91 @@
import * as CaptchaController from "../controllers/captcha-controller";
import AnimatedModal from "../utils/animated-modal";
import Ape from "../ape/index";
import * as Notifications from "../elements/notifications";
import * as Loader from "../elements/loader";
import { z } from "zod";
export function show(): void {
void modal.show({
mode: "dialog",
focusFirstInput: true,
beforeAnimation: async (modal) => {
CaptchaController.reset("forgotPasswordModal");
CaptchaController.render(
modal.querySelector(".g-recaptcha") as HTMLElement,
"forgotPasswordModal",
async () => {
await submit();
}
);
},
});
}
async function submit(): Promise<void> {
const captchaResponse = CaptchaController.getResponse("forgotPasswordModal");
if (!captchaResponse) {
Notifications.add("Please complete the captcha");
return;
}
const email = (
modal.getModal().querySelector("input") as HTMLInputElement
).value.trim();
if (!email) {
Notifications.add("Please enter your email address");
CaptchaController.reset("forgotPasswordModal");
return;
}
const emailSchema = z.string().email();
const validation = emailSchema.safeParse(email);
if (!validation.success) {
Notifications.add("Please enter a valid email address");
CaptchaController.reset("forgotPasswordModal");
return;
}
Loader.show();
void Ape.users
.forgotPasswordEmail({
body: { email, captcha: captchaResponse },
})
.then((result) => {
Loader.hide();
if (result.status !== 200) {
Notifications.add(
"Failed to send password reset email: " + result.body.message,
-1
);
return;
}
Notifications.add(result.body.message, 1, { duration: 5 });
});
hide();
}
function hide(): void {
void modal.hide();
}
async function setup(modalEl: HTMLElement): Promise<void> {
modalEl.querySelector("button")?.addEventListener("click", async () => {
await submit();
});
}
const modal = new AnimatedModal({
dialogId: "forgotPasswordModal",
setup,
customEscapeHandler: async (): Promise<void> => {
hide();
},
customWrapperClickHandler: async (): Promise<void> => {
hide();
},
});

View file

@ -60,7 +60,6 @@ type PopupKey =
| "resetProgressCustomTextLong"
| "updateCustomTheme"
| "deleteCustomTheme"
| "forgotPassword"
| "devGenerateData";
const list: Record<PopupKey, SimpleModal | undefined> = {
@ -86,7 +85,6 @@ const list: Record<PopupKey, SimpleModal | undefined> = {
resetProgressCustomTextLong: undefined,
updateCustomTheme: undefined,
deleteCustomTheme: undefined,
forgotPassword: undefined,
devGenerateData: undefined,
};
@ -1221,46 +1219,6 @@ list.deleteCustomTheme = new SimpleModal({
},
});
list.forgotPassword = new SimpleModal({
id: "forgotPassword",
title: "Forgot password",
inputs: [
{
type: "text",
placeholder: "email",
initVal: "",
},
],
buttonText: "send",
execFn: async (_thisPopup, email): Promise<ExecReturn> => {
const result = await Ape.users.forgotPasswordEmail({
body: { email: email.trim() },
});
if (result.status !== 200) {
return {
status: -1,
message: "Failed to send password reset email: " + result.body.message,
};
}
return {
status: 1,
message: result.body.message,
notificationOptions: {
duration: 8,
},
};
},
beforeInitFn: (thisPopup): void => {
const inputValue = $(
`.pageLogin .login input[name="current-email"]`
).val() as string;
if (inputValue) {
(thisPopup.inputs[0] as TextInput).initVal = inputValue;
}
},
});
list.devGenerateData = new SimpleModal({
id: "devGenerateData",
title: "Generate data",
@ -1365,10 +1323,6 @@ export function showPopup(
}
//todo: move these event handlers to their respective files (either global event files or popup files)
$(".pageLogin #forgotPasswordButton").on("click", () => {
showPopup("forgotPassword");
});
$(".pageAccountSettings").on("click", "#unlinkDiscordButton", () => {
showPopup("unlinkDiscord");
});

View file

@ -302,6 +302,7 @@ export const ReportUserRequestSchema = z.object({
export type ReportUserRequest = z.infer<typeof ReportUserRequestSchema>;
export const ForgotPasswordEmailRequestSchema = z.object({
captcha: z.string(),
email: z.string().email(),
});
export type ForgotPasswordEmailRequest = z.infer<