From 7fe682e8f0641a9df38d396ffac1c6ad33497a8d Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Fri, 11 Aug 2023 13:21:26 +0100 Subject: [PATCH] feat: email changing --- client/src/app/pages/RegisterPage.tsx | 14 +- .../dashboard/users/UserSettingsPage.tsx | 196 +++++++++++++----- .../components/layout/misc/EmailVerify.tsx | 2 +- server/src/server/router/api/v1/auth/auth.ts | 4 + .../src/server/router/api/v1/auth/router.ts | 5 +- .../router/api/v1/users/_userID/router.ts | 123 ++++++++++- 6 files changed, 281 insertions(+), 63 deletions(-) diff --git a/client/src/app/pages/RegisterPage.tsx b/client/src/app/pages/RegisterPage.tsx index ba861581d..bb89ab5bc 100644 --- a/client/src/app/pages/RegisterPage.tsx +++ b/client/src/app/pages/RegisterPage.tsx @@ -52,6 +52,16 @@ export default function RegisterPage() { return; } + // user trying to gmail but can't use their keyboard + // like TEN people have made this mistake and then complained to me on discord + // what the hell? + // + // how do real websites deal with this? + if (values.email.match(/@gma/u) && !values.email.match(/@gmail\.com *$/u)) { + setErr("This email address is probably typo'd. Did you mean 'gmail'?"); + return; + } + const rj = await APIFetchV1( "/auth/register", { @@ -71,7 +81,9 @@ export default function RegisterPage() { true ); - recaptchaRef.current.reset(); + if (recaptchaRef.current) { + recaptchaRef.current.reset(); + } if (!rj.success) { setErr(HumaniseError(rj.description)); diff --git a/client/src/app/pages/dashboard/users/UserSettingsPage.tsx b/client/src/app/pages/dashboard/users/UserSettingsPage.tsx index 3e8bfe1f1..a5b156b51 100644 --- a/client/src/app/pages/dashboard/users/UserSettingsPage.tsx +++ b/client/src/app/pages/dashboard/users/UserSettingsPage.tsx @@ -62,7 +62,7 @@ export default function UserSettingsDocumentPage({ reqUser }: Props) { setValue={setPage} id="account" > - Change Password + Change Email/Password @@ -91,7 +91,7 @@ export default function UserSettingsDocumentPage({ reqUser }: Props) { } function AccountSettings({ reqUser }: { reqUser: UserDocument }) { - const formik = useFormik({ + const formikPassword = useFormik({ initialValues: { "!oldPassword": "", "!password": "", @@ -112,7 +112,7 @@ function AccountSettings({ reqUser }: { reqUser: UserDocument }) { ); if (r.success) { - formik.setValues({ + formikPassword.setValues({ "!oldPassword": "", "!password": "", confPass: "", @@ -121,60 +121,146 @@ function AccountSettings({ reqUser }: { reqUser: UserDocument }) { }, }); + const formikEmail = useFormik({ + initialValues: { + "!password": "", + email: "", + confEmail: "", + }, + onSubmit: async (values) => { + const r = await APIFetchV1( + `/users/${reqUser.id}/change-email`, + { + method: "POST", + ...FetchJSONBody({ + "!password": values["!password"], + email: values.email, + }), + }, + true, + true + ); + + if (r.success) { + formikEmail.setValues({ + "!password": "", + email: "", + confEmail: "", + }); + } + }, + }); + return ( -
- - Old Password - - - - New Password - - {formik.values["!password"].length < 8 && ( - - Passwords have to be at least 8 characters long. - + <> + + + Password + + + + New Email + + {formikEmail.values["!password"].length < 8 && ( + + Passwords have to be at least 8 characters long. + + )} + + + Confirm New Email + + + {!(formikEmail.values.email === formikEmail.values.confEmail) && ( + Emails don't match! )} - - - Confirm New Password - - - {!(formik.values["!password"] === formik.values.confPass) && ( - Passwords don't match! - )} - -
+ + + +
+ + Old Password + + + + New Password + + {formikPassword.values["!password"].length < 8 && ( + + Passwords have to be at least 8 characters long. + + )} + + + Confirm New Password + + + {!(formikPassword.values["!password"] === formikPassword.values.confPass) && ( + Passwords don't match! + )} + +
+ ); } diff --git a/client/src/components/layout/misc/EmailVerify.tsx b/client/src/components/layout/misc/EmailVerify.tsx index 8ceda37eb..891f7e500 100644 --- a/client/src/components/layout/misc/EmailVerify.tsx +++ b/client/src/components/layout/misc/EmailVerify.tsx @@ -38,7 +38,7 @@ export default function EmailVerify({
One last thing! You need to verify your email before you can use the site.
- An email has been sent to the address you signed up with. + An email has been sent to the address you signed up with. ({email})
It might've ended up in your spam, so check there too!
diff --git a/server/src/server/router/api/v1/auth/auth.ts b/server/src/server/router/api/v1/auth/auth.ts index 8cb9157d2..bba84518b 100644 --- a/server/src/server/router/api/v1/auth/auth.ts +++ b/server/src/server/router/api/v1/auth/auth.ts @@ -18,6 +18,10 @@ const BCRYPT_SALT_ROUNDS = 12; export const ValidatePassword = (self: unknown) => (typeof self === "string" && self.length >= 8) || "Passwords must be 8 characters or more."; +const LAZY_EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/u; + +export const ValidateEmail = p.regex(LAZY_EMAIL_REGEX); + /** * Compares a plaintext string of a users password to a hash. * @param plaintext The provided user input. diff --git a/server/src/server/router/api/v1/auth/router.ts b/server/src/server/router/api/v1/auth/router.ts index 72cb788af..68cda4309 100644 --- a/server/src/server/router/api/v1/auth/router.ts +++ b/server/src/server/router/api/v1/auth/router.ts @@ -6,6 +6,7 @@ import { PasswordCompare, ReinstateInvite, ValidateCaptcha, + ValidateEmail, ValidatePassword, } from "./auth"; import { Router } from "express"; @@ -37,8 +38,6 @@ const logger = CreateLogCtx(__filename); const router: Router = Router({ mergeParams: true }); -const LAZY_EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/u; - /** * Logs in a user. * @name POST /api/v1/auth/login @@ -170,7 +169,7 @@ router.post( { username: p.regex(/^[a-zA-Z_-][a-zA-Z0-9_-]{2,20}$/u), "!password": ValidatePassword, - email: p.regex(LAZY_EMAIL_REGEX), + email: ValidateEmail, inviteCode: "*string", captcha: "string", }, diff --git a/server/src/server/router/api/v1/users/_userID/router.ts b/server/src/server/router/api/v1/users/_userID/router.ts index 22973ec1c..5a2858489 100644 --- a/server/src/server/router/api/v1/users/_userID/router.ts +++ b/server/src/server/router/api/v1/users/_userID/router.ts @@ -11,16 +11,19 @@ import notifsRouter from "./notifications/router"; import pfpRouter from "./pfp/router"; import sessionsRouter from "./sessions/router"; import settingsRouter from "./settings/router"; -import { HashPassword, PasswordCompare, ValidatePassword } from "../../auth/auth"; +import { HashPassword, PasswordCompare, ValidateEmail, ValidatePassword } from "../../auth/auth"; import { Router } from "express"; import db from "external/mongo/db"; import { GetRecentActivity } from "lib/activity/activity"; import { ONE_MONTH } from "lib/constants/time"; +import { SendEmail } from "lib/email/client"; +import { EmailFormatVerifyEmail } from "lib/email/formats"; import CreateLogCtx from "lib/logger/logger"; import { GetRivalIDs } from "lib/rivals/rivals"; +import { ServerConfig } from "lib/setup/config"; import { p } from "prudence"; import prValidate from "server/middleware/prudence-validate"; -import { DeleteUndefinedProps, IsNonEmptyString, StripUrl } from "utils/misc"; +import { DeleteUndefinedProps, IsNonEmptyString, Random20Hex, StripUrl } from "utils/misc"; import { optNullFluffStrField } from "utils/prudence"; import { GetGoalSummary, @@ -29,7 +32,7 @@ import { GetRecentlyViewedFoldersAnyGPT, } from "utils/queries/summary"; import { GetUser } from "utils/req-tachi-data"; -import { FormatUserDoc, GetAllRankings, GetUserWithID } from "utils/user"; +import { CheckIfEmailInUse, FormatUserDoc, GetAllRankings, GetUserWithID } from "utils/user"; import type { AnyProfileRatingAlg, GPTString, @@ -309,6 +312,120 @@ router.get("/is-email-verified", RequireSelfRequestFromUser, async (req, res) => }); }); +/** + * Get what email this user signed up with. + * + * @name GET /api/v1/users/:userID/email + */ +router.get("/email", RequireSelfRequestFromUser, async (req, res) => { + const user = GetUser(req); + + const email = await db["user-private-information"].findOne({ + userID: user.id, + }); + + if (email) { + return res.status(200).json({ + success: true, + description: `User signed up with this email.`, + body: email.email, + }); + } + + logger.error(`User ${user.id} doesn't have private info?`); + + return res.status(500).json({ + success: false, + description: `Internal Server Error`, + }); +}); + +/** + * Change what email is associated with this account. + * + * @name GET /api/v1/users/:userID/email + */ +router.post( + "/change-email", + RequireSelfRequestFromUser, + prValidate({ + email: ValidateEmail, + "!password": ValidatePassword, + }), + async (req, res) => { + const user = GetUser(req); + + const body = req.safeBody as { + "!password": string; + email: string; + }; + + const privateInfo = await db["user-private-information"].findOne({ + userID: user.id, + }); + + if (!privateInfo) { + logger.error(`User ${user.id} has no associated private info?`); + return res.status(500).json({ + success: false, + description: `Internal server error.`, + }); + } + + const isPasswordValid = await PasswordCompare(body["!password"], privateInfo.password); + + if (!isPasswordValid) { + return res.status(403).json({ + success: false, + description: `Invalid password.`, + }); + } + + const existingEmail = await CheckIfEmailInUse(body.email); + + if (existingEmail) { + logger.info(`User attempted to change to email that was already in use.`); + return res.status(409).json({ + success: false, + description: `This email is already in use.`, + }); + } + + logger.info(`User ${user.id} changed email from ${privateInfo.email} to ${body.email}`); + + await db["user-private-information"].update( + { + userID: user.id, + }, + { + $set: { + email: body.email, + }, + } + ); + + if (ServerConfig.EMAIL_CONFIG) { + const resetEmailCode = Random20Hex(); + + await db["verify-email-codes"].insert({ + code: resetEmailCode, + userID: user.id, + email: body.email, + }); + + const { text, html } = EmailFormatVerifyEmail(user.username, resetEmailCode); + + void SendEmail(body.email, "Email Verification", html, text); + } + + return res.status(200).json({ + success: true, + description: `Re-sent email verification to new email`, + body: null, + }); + } +); + /** * Changes the users password. * Requires self-key level permissions.