Custom email ()

* added nodemailer and mjml

* added email template

* basic email util file

* added email queue

* email worker

* renamed folder

* showing which queues and workers were initialized

* initializing email on server boot
added some test code

* renamed to email worker

* type fix

* renamed queue

* added queue to list

* added worker to list

* logging when config was verified

* handling send mail result

* not minifying (not supported anymore)
using .html property
returning correct value

* dont send me emails

* added port to .env

* updated example

* updated test email

* using env email

* parseint base

* message

* refactor

* message

* rename

* moved email templates

* using async file read

* typo

* using mustache

* moved file
renamed to email client

* logging with prometheus

* added social links

* line

* using stronger types (object instead of array of any[])
moved prometheus logging into email client
added function to send mail using a template

* fixed templates not working

* removed console log

* name change

* rename

* moved templates

* rename

* string interpolation

* string interpolation

* moved to dev dependencies, exact versions

* moved types

* removed function, remove unnecessary if

* update template params

* updated name

* fixed button not clickable

* throwing

* moved template metadata to one place

* rename

* sending email on account creation

* removed test code
removed await

* fixed button

* not stopping the server if email client validation failed

* added metric for queue lengths

* exposing getjobcounts

* added job to log queue lengths

* added endpoint to request verification email

* using send verification email that calls api instead of firebase built in

* updated route

* renamed function

* recording time to complete

* returning 400 if email already verified

* setting transport initialized to true earlier, setting it to false if caught error

* moved forgot password flow to a simple popup, added new endpoint to ape

* added forgotpasswordemail route

* added email tempalte

* setting transport to false if caught error

* added email queue function

* moved try higher

* fixed log
This commit is contained in:
Jack 2023-02-13 13:24:43 +01:00 committed by GitHub
parent 8d965a7cf1
commit 1ca0fd1b23
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 2419 additions and 96 deletions

View file

@ -0,0 +1,137 @@
<mjml>
<mj-head>
<mj-style>
@import
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css";
.btn table{ width: 100%; } .btn a{ width: 100%; padding: 10px 0
!important;}
</mj-style>
</mj-head>
<mj-body background-color="#323437">
<mj-wrapper padding="20px 20px 200px 20px">
<mj-section padding="0px" padding-bottom="20px">
<mj-column width="600px">
<mj-image
width="200px"
src="https://github.com/monkeytypegame/monkeytype/blob/master/frontend/static/images/mtfulllogo.png?raw=true"
href="monkeytype.com"
align="left"
></mj-image>
</mj-column>
</mj-section>
<mj-section padding="0px">
<mj-column background-color="#2c2e31" border-radius="8px">
<mj-spacer></mj-spacer>
<mj-text color="#d1d0c5" font-size="20px" font-family="sans-serif">
Hey, {{name}}
</mj-text>
<mj-text
color="#d1d0c5"
font-size="16px"
line-height="24px"
font-family="sans-serif"
>
Nobody likes being locked out of their account. We're coming to your
rescue - just click the button below to get started. If you didn't
request a password reset, you can safely ignore this email.
</mj-text>
<mj-button
align="left"
background-color="#e2b714"
color="#323437"
font-size="16px"
line-height="32px"
css-class="btn"
href="{{passwordResetLink}}"
font-family="sans-serif"
>
Reset your password
</mj-button>
<mj-text
color="#d1d0c5"
font-size="16px"
line-height="24px"
padding-bottom="0px"
font-family="sans-serif"
>
Cheers,
</mj-text>
<mj-text
color="#d1d0c5"
font-size="16px"
line-height="24px"
padding-top="0px"
font-family="sans-serif"
>
Monkeytype Team
</mj-text>
<mj-divider border-color="#323437"></mj-divider>
<mj-text
color="#646669"
font-size="12px"
padding-bottom="0px"
font-family="sans-serif"
>
Alternatively, you can copy and paste the link below into your
browser:
</mj-text>
<mj-text color="#646669" font-size="12px" font-family="sans-serif">
{{passwordResetLink}}
</mj-text>
<mj-spacer></mj-spacer>
</mj-column>
</mj-section>
<mj-section padding-bottom="6px" padding-top="20px">
<mj-column width="50px">
<mj-button
font-size="20px"
padding="10px"
inner-padding="0"
color="#d1d0c5"
background-color="#323437"
href="https://github.com/monkeytypegame/monkeytype"
>
<i class="fab fa-fw fa-github"></i>
</mj-button>
</mj-column>
<mj-column width="50px">
<mj-button
font-size="20px"
padding="10px"
inner-padding="0"
color="#d1d0c5"
background-color="#323437"
href="https://twitter.com/monkeytypegame"
>
<i class="fab fa-fw fa-twitter"></i>
</mj-button>
</mj-column>
<mj-column width="50px">
<mj-button
font-size="20px"
padding="10px"
inner-padding="0"
color="#d1d0c5"
background-color="#323437"
href="https://discord.com/invite/monkeytype"
>
<i class="fab fa-fw fa-discord"></i>
</mj-button>
</mj-column>
</mj-section>
<mj-section padding-top="0">
<mj-column>
<mj-text align="center" color="#646669" background-color="#323437">
monkeytype.com
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
</mj-body>
</mjml>

View file

@ -0,0 +1,137 @@
<mjml>
<mj-head>
<mj-style>
@import
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css";
.btn table{ width: 100%; } .btn a{ width: 100%; padding: 10px 0
!important;}
</mj-style>
</mj-head>
<mj-body background-color="#323437">
<mj-wrapper padding="20px 20px 200px 20px">
<mj-section padding="0px" padding-bottom="20px">
<mj-column width="600px">
<mj-image
width="200px"
src="https://github.com/monkeytypegame/monkeytype/blob/master/frontend/static/images/mtfulllogo.png?raw=true"
href="monkeytype.com"
align="left"
></mj-image>
</mj-column>
</mj-section>
<mj-section padding="0px">
<mj-column background-color="#2c2e31" border-radius="8px">
<mj-spacer></mj-spacer>
<mj-text color="#d1d0c5" font-size="20px" font-family="sans-serif">
Hey, {{name}}
</mj-text>
<mj-text
color="#d1d0c5"
font-size="16px"
line-height="24px"
font-family="sans-serif"
>
Thanks for joining Monkeytype! We just need one more thing from you
- a quick confirmation of your email address and you'll be all set.
Click the button below to get started:
</mj-text>
<mj-button
align="left"
background-color="#e2b714"
color="#323437"
font-size="16px"
line-height="32px"
css-class="btn"
href="{{verificationLink}}"
font-family="sans-serif"
>
Verify
</mj-button>
<mj-text
color="#d1d0c5"
font-size="16px"
line-height="24px"
padding-bottom="0px"
font-family="sans-serif"
>
Cheers,
</mj-text>
<mj-text
color="#d1d0c5"
font-size="16px"
line-height="24px"
padding-top="0px"
font-family="sans-serif"
>
Monkeytype Team
</mj-text>
<mj-divider border-color="#323437"></mj-divider>
<mj-text
color="#646669"
font-size="12px"
padding-bottom="0px"
font-family="sans-serif"
>
Alternatively, you can copy and paste the link below into your
browser:
</mj-text>
<mj-text color="#646669" font-size="12px" font-family="sans-serif">
{{verificationLink}}
</mj-text>
<mj-spacer></mj-spacer>
</mj-column>
</mj-section>
<mj-section padding-bottom="6px" padding-top="20px">
<mj-column width="50px">
<mj-button
font-size="20px"
padding="10px"
inner-padding="0"
color="#d1d0c5"
background-color="#323437"
href="https://github.com/monkeytypegame/monkeytype"
>
<i class="fab fa-fw fa-github"></i>
</mj-button>
</mj-column>
<mj-column width="50px">
<mj-button
font-size="20px"
padding="10px"
inner-padding="0"
color="#d1d0c5"
background-color="#323437"
href="https://twitter.com/monkeytypegame"
>
<i class="fab fa-fw fa-twitter"></i>
</mj-button>
</mj-column>
<mj-column width="50px">
<mj-button
font-size="20px"
padding="10px"
inner-padding="0"
color="#d1d0c5"
background-color="#323437"
href="https://discord.com/invite/monkeytype"
>
<i class="fab fa-fw fa-discord"></i>
</mj-button>
</mj-column>
</mj-section>
<mj-section padding-top="0">
<mj-column>
<mj-text align="center" color="#646669" background-color="#323437">
monkeytype.com
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
</mj-body>
</mjml>

View file

@ -13,3 +13,11 @@ MODE=dev
# DB_PASSWORD=
# DB_AUTH_MECHANISM="SCRAM-SHA-256"
# DB_AUTH_SOURCE=admin
# You can get a testing email address over at
# https://ethereal.email/create
#
# EMAIL_PORT=587
# EMAIL_HOST=smtp.ethereal.email
# EMAIL_USER=
# EMAIL_PASS=

1731
backend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -30,8 +30,11 @@
"joi": "17.6.0",
"lodash": "4.17.21",
"lru-cache": "7.10.1",
"mjml": "4.13.0",
"mongodb": "4.4.0",
"mustache": "4.2.0",
"node-fetch": "2.6.7",
"nodemailer": "6.9.1",
"nodemon": "2.0.17",
"object-hash": "3.0.0",
"path": "0.12.7",
@ -53,8 +56,10 @@
"@types/ioredis": "4.28.10",
"@types/jest": "27.5.0",
"@types/lodash": "4.14.178",
"@types/mustache": "4.2.2",
"@types/node": "17.0.18",
"@types/node-fetch": "2.6.1",
"@types/nodemailer": "6.4.7",
"@types/object-hash": "2.2.1",
"@types/supertest": "2.0.12",
"@types/swagger-stats": "0.95.4",

View file

@ -19,6 +19,7 @@ import * as RedisClient from "../../init/redis";
import { v4 as uuidv4 } from "uuid";
import { ObjectId } from "mongodb";
import * as ReportDAL from "../../dal/report";
import emailQueue from "../../queues/email-queue";
async function verifyCaptcha(captcha: string): Promise<void> {
if (!(await verify(captcha))) {
@ -58,6 +59,59 @@ export async function createNewUser(
return new MonkeyResponse("User created");
}
export async function sendVerificationEmail(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const { email, uid } = req.ctx.decodedToken;
const isVerified = (await admin.auth().getUser(uid)).emailVerified;
if (isVerified === true) {
throw new MonkeyError(400, "Email already verified");
}
const userInfo = await UserDAL.getUser(uid, "request verification email");
const link = await admin.auth().generateEmailVerificationLink(email, {
url:
process.env.MODE === "dev"
? "http://localhost:3000"
: "https://monkeytype.com",
});
await emailQueue.sendVerificationEmail(email, userInfo.name, link);
return new MonkeyResponse("Email sent");
}
export async function sendForgotPasswordEmail(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const { email } = req.body;
let auth;
try {
auth = await admin.auth().getUserByEmail(email);
} catch (e) {
if (e.code === "auth/user-not-found") {
throw new MonkeyError(404, "User not found");
}
throw e;
}
const userInfo = await UserDAL.getUser(
auth.uid,
"request forgot password email"
);
const link = await admin.auth().generatePasswordResetLink(email, {
url:
process.env.MODE === "dev"
? "http://localhost:3000"
: "https://monkeytype.com",
});
await emailQueue.sendForgotPasswordEmail(email, userInfo.name, link);
return new MonkeyResponse("Email sent if user was found");
}
export async function deleteUser(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {

View file

@ -582,4 +582,22 @@ router.post(
asyncHandler(UserController.reportUser)
);
router.get(
"/verificationEmail",
authenticateRequest(),
RateLimit.userRequestVerificationEmail,
asyncHandler(UserController.sendVerificationEmail)
);
router.post(
"/forgotPasswordEmail",
RateLimit.userForgotPasswordEmail,
validateRequest({
body: {
email: joi.string().email().required(),
},
}),
asyncHandler(UserController.sendForgotPasswordEmail)
);
export default router;

View file

@ -0,0 +1,141 @@
import * as nodemailer from "nodemailer";
import Logger from "../utils/logger";
import fs from "fs";
import { join } from "path";
import mjml2html from "mjml";
import mustache from "mustache";
import { recordEmail } from "../utils/prometheus";
import { EmailTaskContexts, EmailType } from "../queues/email-queue";
interface EmailMetadata {
subject: string;
templateName: string;
}
const templates: Record<EmailType, EmailMetadata> = {
verify: {
subject: "Verify your Monkeytype account",
templateName: "verification.html",
},
resetPassword: {
subject: "Reset your Monkeytype password",
templateName: "reset-password.html",
},
};
let transportInitialized = false;
let transporter: nodemailer.Transporter;
export function isInitialized(): boolean {
return transportInitialized;
}
export async function init(): Promise<void> {
if (isInitialized()) {
return;
}
const { EMAIL_HOST, EMAIL_USER, EMAIL_PASS, EMAIL_PORT, MODE } = process.env;
if (!EMAIL_HOST || !EMAIL_USER || !EMAIL_PASS) {
if (MODE === "dev") {
Logger.warning(
"No email client configuration provided. Running without email."
);
return;
}
throw new Error("No email client configuration provided");
}
try {
transporter = nodemailer.createTransport({
host: EMAIL_HOST,
secure: EMAIL_PORT === "465" ? true : false,
port: parseInt(EMAIL_PORT ?? "578", 10),
auth: {
user: EMAIL_USER,
pass: EMAIL_PASS,
},
});
transportInitialized = true;
Logger.info("Verifying email client configuration...");
const result = await transporter.verify();
if (result !== true) {
throw new Error(
`Could not verify email client configuration: ` + JSON.stringify(result)
);
}
Logger.success("Email client configuration verified");
} catch (error) {
transportInitialized = false;
Logger.error(error.message);
Logger.error("Failed to verify email client configuration.");
}
}
interface MailResult {
success: boolean;
message: string;
}
export async function sendEmail<M extends EmailType>(
templateName: EmailType,
to: string,
data: EmailTaskContexts[M]
): Promise<MailResult> {
if (!isInitialized()) {
return {
success: false,
message: "Email client transport not initialized",
};
}
const template = await fillTemplate<typeof templateName>(templateName, data);
const mailOptions = {
from: "Monkeytype <noreply@monkeytype.com>",
to,
subject: templates[templateName].subject,
html: template,
};
const result = await transporter.sendMail(mailOptions);
recordEmail(templateName, result.accepted.length === 0 ? "fail" : "success");
return {
success: result.accepted.length !== 0,
message: result.response,
};
}
const EMAIL_TEMPLATES_DIRECTORY = join(__dirname, "../../email-templates");
const cachedTemplates: Record<string, string> = {};
async function getTemplate(name: string): Promise<string> {
if (cachedTemplates[name]) {
return cachedTemplates[name];
}
const template = await fs.promises.readFile(
`${EMAIL_TEMPLATES_DIRECTORY}/${name}`,
"utf-8"
);
const html = mjml2html(template).html;
cachedTemplates[name] = html;
return html;
}
async function fillTemplate<M extends EmailType>(
type: M,
data: EmailTaskContexts[M]
): Promise<string> {
const template = await getTemplate(templates[type].templateName);
return mustache.render(template, data);
}

View file

@ -441,6 +441,20 @@ export const userDiscordUnlink = rateLimit({
handler: customHandler,
});
export const userRequestVerificationEmail = rateLimit({
windowMs: ONE_HOUR_MS / 4,
max: 1 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userForgotPasswordEmail = rateLimit({
windowMs: ONE_HOUR_MS / 4,
max: 1 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userProfileGet = rateLimit({
windowMs: ONE_HOUR_MS,
max: 100 * REQUEST_MULTIPLIER,

View file

@ -0,0 +1,68 @@
import { MonkeyQueue } from "./monkey-queue";
const QUEUE_NAME = "email-tasks";
export type EmailType = "verify" | "resetPassword";
export interface EmailTask<M extends EmailType> {
type: M;
email: string;
ctx: EmailTaskContexts[M];
}
export type EmailTaskContexts = {
verify: {
name: string;
verificationLink: string;
};
resetPassword: {
name: string;
passwordResetLink: string;
};
};
function buildTask(
taskName: EmailType,
email: string,
taskContext: EmailTaskContexts[EmailType]
): EmailTask<EmailType> {
return {
type: taskName,
email: email,
ctx: taskContext,
};
}
class EmailQueue extends MonkeyQueue<EmailTask<EmailType>> {
async sendVerificationEmail(
email: string,
name: string,
verificationLink: string
): Promise<void> {
const taskName = "verify";
const task = buildTask(taskName, email, { name, verificationLink });
await this.add(taskName, task);
}
async sendForgotPasswordEmail(
email: string,
name: string,
passwordResetLink: string
): Promise<void> {
const taskName = "resetPassword";
const task = buildTask(taskName, email, { name, passwordResetLink });
await this.add(taskName, task);
}
}
export default new EmailQueue(QUEUE_NAME, {
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 3,
backoff: {
type: "exponential",
delay: 2000,
},
},
});

View file

@ -1,4 +1,5 @@
import LaterQueue from "./later-queue";
import GeorgeQueue from "./george-queue";
import EmailQueue from "./email-queue";
export default [GeorgeQueue, LaterQueue];
export default [GeorgeQueue, LaterQueue, EmailQueue];

View file

@ -14,6 +14,7 @@ import * as RedisClient from "./init/redis";
import queues from "./queues";
import workers from "./workers";
import Logger from "./utils/logger";
import * as EmailClient from "./init/email-client";
async function bootServer(port: number): Promise<Server> {
try {
@ -35,6 +36,9 @@ async function bootServer(port: number): Promise<Server> {
const liveConfiguration = await getLiveConfiguration();
Logger.success("Live configuration fetched");
Logger.info("Initializing email client...");
EmailClient.init();
Logger.info("Connecting to redis...");
await RedisClient.connect();
@ -46,13 +50,21 @@ async function bootServer(port: number): Promise<Server> {
queues.forEach((queue) => {
queue.init(connection);
});
Logger.success("Queues initialized");
Logger.success(
`Queues initialized: ${queues
.map((queue) => queue.queueName)
.join(", ")}`
);
Logger.info("Initializing workers...");
workers.forEach((worker) => {
worker(connection).run();
});
Logger.success("Workers initialized");
Logger.success(
`Workers initialized: ${workers
.map((worker) => worker(connection).name)
.join(", ")}`
);
}
initializeDailyLeaderboardsCache(liveConfiguration.dailyLeaderboards);

View file

@ -292,6 +292,16 @@ export function setQueueLength(
queueLength.set({ queueName, countType }, length);
}
const emailCount = new Counter({
name: "email_count",
help: "Emails sent by the server",
labelNames: ["type", "status"],
});
export function recordEmail(type: string, status: string): void {
emailCount.inc({ type, status });
}
const timeToCompleteJobTotal = new Counter({
name: "time_to_complete_job_total",
help: "Time to complete a job total",

View file

@ -0,0 +1,36 @@
import _ from "lodash";
import IORedis from "ioredis";
import { Worker, Job } from "bullmq";
import Logger from "../utils/logger";
import EmailQueue, {
EmailTaskContexts,
EmailType,
} from "../queues/email-queue";
import { sendEmail } from "../init/email-client";
import { recordTimeToCompleteJob } from "../utils/prometheus";
async function jobHandler(job: Job): Promise<void> {
const type: EmailType = job.data.type;
const email: string = job.data.email;
const ctx: EmailTaskContexts[typeof type] = job.data.ctx;
Logger.info(`Starting job: ${type}`);
const start = performance.now();
const result = await sendEmail(type, email, ctx);
if (!result.success) {
throw new Error(result.message);
}
const elapsed = performance.now() - start;
recordTimeToCompleteJob(EmailQueue.queueName, type, elapsed);
Logger.success(`Job: ${type} - completed in ${elapsed}ms`);
}
export default (redisConnection?: IORedis.Redis): Worker =>
new Worker(EmailQueue.queueName, jobHandler, {
autorun: false,
connection: redisConnection,
});

View file

@ -1,3 +1,4 @@
import LaterWorker from "./later-worker";
import EmailWorker from "./email-worker";
export default [LaterWorker];
export default [LaterWorker, EmailWorker];

View file

@ -241,4 +241,14 @@ export default class Users {
return await this.httpClient.post(`${BASE_PATH}/report`, { payload });
}
async verificationEmail(): Ape.EndpointData {
return await this.httpClient.get(`${BASE_PATH}/verificationEmail`);
}
async forgotPasswordEmail(email: string): Ape.EndpointData {
return await this.httpClient.post(`${BASE_PATH}/forgotPasswordEmail`, {
payload: { email },
});
}
}

View file

@ -26,7 +26,6 @@ import {
browserSessionPersistence,
browserLocalPersistence,
createUserWithEmailAndPassword,
sendEmailVerification,
signInWithEmailAndPassword,
signInWithPopup,
setPersistence,
@ -35,7 +34,6 @@ import {
linkWithCredential,
reauthenticateWithPopup,
getAdditionalUserInfo,
sendPasswordResetEmail,
User as UserType,
Unsubscribe,
} from "firebase/auth";
@ -50,26 +48,25 @@ import { update as updateTagsCommands } from "../commandline/lists/tags";
import * as ConnectionState from "../states/connection";
export const gmailProvider = new GoogleAuthProvider();
let canCall = true;
export function sendVerificationEmail(): void {
export async function sendVerificationEmail(): Promise<void> {
if (Auth === undefined) {
Notifications.add("Authentication uninitialized", -1, 3);
return;
}
Loader.show();
const user = Auth.currentUser;
if (user === null) return;
sendEmailVerification(user)
.then(() => {
Loader.hide();
Notifications.add("Email sent to " + user.email, 4000);
})
.catch((e) => {
Loader.hide();
Notifications.add("Error: " + e.message, 3000);
console.error(e.message);
});
const result = await Ape.users.verificationEmail();
if (result.status !== 200) {
Loader.hide();
Notifications.add(
"Failed to request verification email: " + result.message,
3000
);
} else {
Loader.hide();
Notifications.add("Verification email sent", 1, 3);
}
}
export async function getDataAndInit(): Promise<boolean> {
@ -241,12 +238,6 @@ export async function getDataAndInit(): Promise<boolean> {
export async function loadUser(user: UserType): Promise<void> {
// User is signed in.
$(".pageAccount .content p.accountVerificatinNotice").remove();
if (user.emailVerified === false) {
$(".pageAccount .content").prepend(
`<p class="accountVerificatinNotice" style="text-align:center">Your account is not verified. <a class="sendVerificationEmail">Send the verification email again</a>.`
);
}
PageTransition.set(false);
AccountButton.loading(true);
if ((await getDataAndInit()) === false) {
@ -413,35 +404,6 @@ export async function signIn(): Promise<void> {
});
}
export async function forgotPassword(email: any): Promise<void> {
if (Auth === undefined) {
Notifications.add("Authentication uninitialized", -1, 3);
return;
}
if (!canCall) {
return Notifications.add(
"Please wait before requesting another password reset link",
0,
5000
);
}
if (!email) return Notifications.add("Please enter an email!", -1);
try {
await sendPasswordResetEmail(Auth, email);
Notifications.add("Email sent", 1, 2);
} catch (error) {
Notifications.add(
Misc.createErrorMessage(error, "Failed to send email"),
-1
);
}
canCall = false;
setTimeout(function () {
canCall = true;
}, 10000);
}
export async function signInWithGoogle(): Promise<void> {
if (Auth === undefined) {
Notifications.add("Authentication uninitialized", -1, 3);
@ -676,7 +638,7 @@ async function signUp(): Promise<void> {
}
await updateProfile(createdAuthUser.user, { displayName: nname });
await sendEmailVerification(createdAuthUser.user);
await sendVerificationEmail();
AllTimeStats.clear();
$("#menu .textButton.account .text").text(nname);
$(".pageLogin .button").removeClass("disabled");
@ -725,13 +687,6 @@ async function signUp(): Promise<void> {
}
}
$(".pageLogin #forgotPasswordButton").on("click", () => {
const emailField =
($(".pageLogin .login input")[0] as HTMLInputElement).value || "";
const email = prompt("Email address", emailField);
forgotPassword(email);
});
$(".pageLogin .login input").keyup((e) => {
if (e.key === "Enter") {
UpdateConfig.setChangedBeforeDb(false);

View file

@ -17,6 +17,7 @@ import format from "date-fns/format";
import * as ConnectionState from "../states/connection";
import * as Skeleton from "../popups/skeleton";
import type { ScaleChartOptions } from "chart.js";
import { Auth } from "../firebase";
let filterDebug = false;
//toggle filterdebug
@ -1278,6 +1279,12 @@ export const page = new Page(
await update();
await Misc.sleep(0);
updateChartColors();
$(".pageAccount .content p.accountVerificatinNotice").remove();
if (Auth?.currentUser?.emailVerified === false) {
$(".pageAccount .content").prepend(
`<p class="accountVerificatinNotice" style="text-align:center">Your account is not verified. <a class="sendVerificationEmail">Send the verification email again</a>.`
);
}
},
async () => {
//

View file

@ -1241,6 +1241,50 @@ list["deleteCustomTheme"] = new SimplePopup(
}
);
list["forgotPassword"] = new SimplePopup(
"forgotPassword",
"text",
"Forgot Password",
[
{
type: "text",
placeholder: "Email",
initVal: "",
},
],
"",
"Send",
async (_thisPopup, email) => {
Loader.show();
const result = await Ape.users.forgotPasswordEmail(email);
if (result.status !== 200) {
Loader.hide();
Notifications.add(
"Failed to request password reset email: " + result.message,
5000
);
} else {
Loader.hide();
Notifications.add("Password reset email sent", 1, 3);
}
},
(thisPopup) => {
const inputValue = $(
`.pageLogin .login input[name="current-email"]`
).val() as string;
if (inputValue) {
thisPopup.inputs[0].initVal = inputValue;
}
},
() => {
//
}
);
$(".pageLogin #forgotPasswordButton").on("click", () => {
list["forgotPassword"].show();
});
$(".pageSettings .section.discordIntegration #unlinkDiscordButton").on(
"click",
() => {