From e6dcf853708c06929f840cde9ef18aac5af30b6e Mon Sep 17 00:00:00 2001 From: Henry Aviles <108551827+Haviles04@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:32:54 -0400 Subject: [PATCH 1/2] feat: initial implementation --- api/controllers/user-email/get.js | 12 + api/controllers/user-email/request-code.js | 50 ++++ api/controllers/user-email/verify.js | 54 ++++ api/helpers/email/hash-verification-code.js | 21 ++ api/helpers/email/send-verification-email.js | 52 ++++ api/helpers/wipe-database.js | 1 + api/models/UserEmail.js | 58 ++++ api/policies/hasValidEmail.js | 18 ++ config/policies.js | 4 + config/routes.js | 5 + eslint.config.js | 1 + package-lock.json | 10 + package.json | 1 + src/routes/home/HomeView.vue | 6 +- .../home/components/EmailPreferenceDialog.vue | 254 ++++++++++++++++++ .../announcementDialog/AnnouncementDialog.vue | 8 +- src/stores/userEmail.js | 47 ++++ src/translations/de.json | 23 ++ src/translations/en.json | 23 ++ src/translations/es.json | 23 ++ src/translations/fr.json | 23 ++ src/translations/ukr.json | 23 ++ tests/e2e/specs/out-of-game/home.spec.js | 69 +++++ tests/e2e/support/index.js | 9 +- tests/unit/specs/sails/user-email.spec.js | 133 +++++++++ tests/unit/vitest-sails.config.mjs | 3 + utils/local-storage-utils.js | 4 +- 27 files changed, 930 insertions(+), 5 deletions(-) create mode 100644 api/controllers/user-email/get.js create mode 100644 api/controllers/user-email/request-code.js create mode 100644 api/controllers/user-email/verify.js create mode 100644 api/helpers/email/hash-verification-code.js create mode 100644 api/helpers/email/send-verification-email.js create mode 100644 api/models/UserEmail.js create mode 100644 api/policies/hasValidEmail.js create mode 100644 src/routes/home/components/EmailPreferenceDialog.vue create mode 100644 src/stores/userEmail.js create mode 100644 tests/unit/specs/sails/user-email.spec.js diff --git a/api/controllers/user-email/get.js b/api/controllers/user-email/get.js new file mode 100644 index 000000000..28b7e014f --- /dev/null +++ b/api/controllers/user-email/get.js @@ -0,0 +1,12 @@ +module.exports = async function (req, res) { + try { + const userEmail = await UserEmail.findOne({ user: req.session.usr }); + + return res.ok({ + email: userEmail?.email ?? null, + promotional: userEmail?.promotional ?? false, + }); + } catch (err) { + return res.badRequest(err); + } +}; diff --git a/api/controllers/user-email/request-code.js b/api/controllers/user-email/request-code.js new file mode 100644 index 000000000..e12b566f7 --- /dev/null +++ b/api/controllers/user-email/request-code.js @@ -0,0 +1,50 @@ +const crypto = require('crypto'); + +const CODE_TTL_MS = 10 * 60 * 1000; +const RESEND_COOLDOWN_MS = 60 * 1000; + +module.exports = async function (req, res) { + try { + const { email } = req.body; + const promotional = req.body.promotional === true; + const userId = req.session.usr; + + // Email must not already belong to another user + const emailOwner = await UserEmail.findOne({ email }); + if (emailOwner && emailOwner.user !== userId) { + return res.status(409).json({ message: 'emailPreference.error.emailTaken' }); + } + + const userEmail = await UserEmail.findOne({ user: userId }); + + // Limit how often a user can request a code + const now = Date.now(); + if (userEmail?.codeSentAt && now - new Date(userEmail.codeSentAt).getTime() < RESEND_COOLDOWN_MS) { + return res.status(429).json({ message: 'emailPreference.error.tooManyRequests' }); + } + + const code = crypto.randomInt(0, 1_000_000).toString() + .padStart(6, '0'); + const updates = { + pendingEmail: email, + verificationCode: sails.helpers.email.hashVerificationCode(code), + codeExpiresAt: new Date(now + CODE_TTL_MS), + codeSentAt: new Date(now), + attempts: 0, + promotional, + }; + + if (userEmail) { + await UserEmail.updateOne({ id: userEmail.id }).set(updates); + } else { + await UserEmail.create({ user: userId, ...updates }); + } + + await sails.helpers.email.sendVerificationEmail(email, code); + + return res.ok(); + } catch (err) { + sails.log.error(err); + return res.badRequest({ message: 'emailPreference.error.sendFailed' }); + } +}; diff --git a/api/controllers/user-email/verify.js b/api/controllers/user-email/verify.js new file mode 100644 index 000000000..240504e5a --- /dev/null +++ b/api/controllers/user-email/verify.js @@ -0,0 +1,54 @@ +const crypto = require('crypto'); + +const MAX_ATTEMPTS = 5; + +module.exports = async function (req, res) { + try { + const code = typeof req.body.code === 'string' ? req.body.code.trim() : ''; + if (!/^\d{6}$/.test(code)) { + return res.badRequest({ message: 'emailPreference.error.invalidCode' }); + } + + const userEmail = await UserEmail.findOne({ user: req.session.usr }); + if (!userEmail?.pendingEmail || !userEmail.verificationCode) { + return res.badRequest({ message: 'emailPreference.error.noPendingEmail' }); + } + + if (new Date(userEmail.codeExpiresAt).getTime() < Date.now() || userEmail.attempts >= MAX_ATTEMPTS) { + return res.badRequest({ message: 'emailPreference.error.codeExpired' }); + } + + const expected = Buffer.from(userEmail.verificationCode); + const actual = Buffer.from(sails.helpers.email.hashVerificationCode(code)); + if (!crypto.timingSafeEqual(expected, actual)) { + await UserEmail.updateOne({ id: userEmail.id }).set({ attempts: userEmail.attempts + 1 }); + return res.badRequest({ message: 'emailPreference.error.invalidCode' }); + } + + // Another user verified this email after the code was sent + const emailOwner = await UserEmail.findOne({ email: userEmail.pendingEmail }); + if (emailOwner && emailOwner.user !== userEmail.user) { + return res.status(409).json({ message: 'emailPreference.error.emailTaken' }); + } + + try { + await UserEmail.updateOne({ id: userEmail.id }).set({ + email: userEmail.pendingEmail, + pendingEmail: null, + verificationCode: null, + codeExpiresAt: null, + attempts: 0, + }); + } catch (err) { + // Postgres unique index caught a concurrent verification of the same email + if (err.code === 'E_UNIQUE') { + return res.status(409).json({ message: 'emailPreference.error.emailTaken' }); + } + throw err; + } + + return res.ok({ email: userEmail.pendingEmail, promotional: userEmail.promotional }); + } catch (err) { + return res.badRequest(err); + } +}; diff --git a/api/helpers/email/hash-verification-code.js b/api/helpers/email/hash-verification-code.js new file mode 100644 index 000000000..190bf1044 --- /dev/null +++ b/api/helpers/email/hash-verification-code.js @@ -0,0 +1,21 @@ +const crypto = require('crypto'); + +module.exports = { + friendlyName: 'Hash Verification Code', + + description: 'Hashes an email verification code so it is never stored in plain text', + + inputs: { + code: { + type: 'string', + required: true, + }, + }, + + sync: true, + + fn: ({ code }, exits) => { + return exits.success(crypto.createHash('sha256').update(code) + .digest('hex')); + }, +}; diff --git a/api/helpers/email/send-verification-email.js b/api/helpers/email/send-verification-email.js new file mode 100644 index 000000000..d11670d05 --- /dev/null +++ b/api/helpers/email/send-verification-email.js @@ -0,0 +1,52 @@ +const nodemailer = require('nodemailer'); + +module.exports = { + friendlyName: 'Send Verification Email', + + description: 'Emails a 6 digit verification code using the configured gmail account', + + inputs: { + email: { + type: 'string', + required: true, + }, + code: { + type: 'string', + required: true, + }, + }, + + fn: async function ({ email, code }, exits) { + const { GMAIL_USER, GMAIL_APP_PASSWORD } = process.env; + + // Without credentials, log the code locally instead of sending it + if (!GMAIL_USER || !GMAIL_APP_PASSWORD) { + if (sails.config.environment === 'production') { + return exits.error(new Error('Email credentials are not configured')); + } + sails.log.info(`Email verification code for ${email}: ${code}`); + return exits.success(); + } + + try { + const transporter = nodemailer.createTransport({ + service: 'gmail', + auth: { + user: GMAIL_USER, + pass: GMAIL_APP_PASSWORD, + }, + }); + + await transporter.sendMail({ + from: `Cuttle <${GMAIL_USER}>`, + to: email, + subject: 'Your Cuttle verification code', + text: `Your Cuttle verification code is ${code}. It expires in 10 minutes.`, + }); + + return exits.success(); + } catch (err) { + return exits.error(err); + } + }, +}; diff --git a/api/helpers/wipe-database.js b/api/helpers/wipe-database.js index 2352cc98e..4f79743e3 100644 --- a/api/helpers/wipe-database.js +++ b/api/helpers/wipe-database.js @@ -18,6 +18,7 @@ module.exports = { UserSpectatingGame.destroy({}), GameStateRow.destroy({}), Identity.destroy({}), + UserEmail.destroy({}), ]); } catch (err) { diff --git a/api/models/UserEmail.js b/api/models/UserEmail.js new file mode 100644 index 000000000..9d0f44712 --- /dev/null +++ b/api/models/UserEmail.js @@ -0,0 +1,58 @@ +/** + * UserEmail.js + * + * @description :: A user's preferred email. One row per user. The email is only persisted to + * `email` after the user verifies it with the code sent to `pendingEmail` + * @docs :: http://sailsjs.org/#!documentation/models + */ + +module.exports = { + attributes: { + user: { + model: 'user', + required: true, + unique: true, + }, + /** + * Verified email, lowercased. Unique across users + * @value null until the user verifies an email + * Uniqueness is enforced in the controllers and by a unique index in postgres, because + * sails-disk does not support `unique` on nullable attributes + */ + email: { + type: 'string', + allowNull: true, + isEmail: true, + }, + /** + * Email awaiting verification + */ + pendingEmail: { + type: 'string', + allowNull: true, + }, + /** + * sha256 hash of the 6 digit code sent to pendingEmail + */ + verificationCode: { + type: 'string', + allowNull: true, + }, + codeExpiresAt: { + type: 'ref', + columnType: 'timestamptz', + }, + codeSentAt: { + type: 'ref', + columnType: 'timestamptz', + }, + attempts: { + type: 'number', + defaultsTo: 0, + }, + promotional: { + type: 'boolean', + defaultsTo: false, + }, + }, +}; diff --git a/api/policies/hasValidEmail.js b/api/policies/hasValidEmail.js new file mode 100644 index 000000000..e3210c6f4 --- /dev/null +++ b/api/policies/hasValidEmail.js @@ -0,0 +1,18 @@ +/** + * hasValidEmail + * + * @module :: Policy + * @description :: Only allows requests that contain an 'email' parameter in a valid email format. + * Normalizes the email to lowercase + * @docs :: http://sailsjs.org/#!/documentation/concepts/Policies + * + */ +module.exports = function (req, res, next) { + const email = typeof req.body.email === 'string' ? req.body.email.trim().toLowerCase() : ''; + + if (!email || email.length > 254 || !email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) { + return res.badRequest({ message: 'emailPreference.error.invalidEmail' }); + } + req.body.email = email; + return next(); +}; diff --git a/config/policies.js b/config/policies.js index e785089d6..9924d719d 100644 --- a/config/policies.js +++ b/config/policies.js @@ -39,6 +39,10 @@ module.exports.policies = { oAuthCompleteRegistration: [ 'hasValidUsername', 'hasNoProfanity', 'hasValidOAuthProvider' ], }, + 'user-email/get': [ 'isLoggedIn' ], + 'user-email/request-code': [ 'isLoggedIn', 'hasValidEmail' ], + 'user-email/verify': [ 'isLoggedIn' ], + 'game/create': [ 'isLoggedIn', 'hasGameName', 'hasNoProfanity' ], 'game/ai/create-vs-ai': [ 'isLoggedIn', 'isSocket' ], 'game/ai/ai-move': [ 'isLoggedIn', 'isSocket' ], diff --git a/config/routes.js b/config/routes.js index 7bf50babe..d925cd0d7 100644 --- a/config/routes.js +++ b/config/routes.js @@ -18,6 +18,11 @@ module.exports.routes = { '/api/user/logout': 'userController.logout', '/api/user/status': 'userController.status', + // User email + 'GET /api/user/email': 'user-email/get', + 'POST /api/user/email/request-code': 'user-email/request-code', + 'POST /api/user/email/verify': 'user-email/verify', + // OAuth 'GET /api/user/:provider/redirect': 'OAuthController.oAuthRedirect', 'GET /api/user/:provider/callback': 'OAuthController.oAuthCallback', diff --git a/eslint.config.js b/eslint.config.js index 870a4cdc0..ea086a105 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -35,6 +35,7 @@ const sailsGlobals = { UserSpectatingGame: true, GameStateRow: true, Identity: true, + UserEmail: true, }; const clientFiles = '**/client/**/*.{j,t}s?(x)'; diff --git a/package-lock.json b/package-lock.json index c5bb4046b..79480b009 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "lodash": "4.17.21", "machinepack-passwords": "2.3.0", "marked": "^16.4.1", + "nodemailer": "^10.0.15", "pinia": "2.2.4", "rollbar": "^2.26.5", "sails": "^1.5.15", @@ -15954,6 +15955,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "10.0.15", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.15.tgz", + "integrity": "sha512-EUqp5PhtcsYXs9Fq/lS7s/8zlTrnBqmzZWzFFROrNikMiz+om/YRKMwqN907t+0A1KKyT8jd39CBUbFZmaZmcA==", + "license": "MIT-0", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/normalize-package-data": { "version": "2.5.0", "dev": true, diff --git a/package.json b/package.json index 019aa9d75..76b6db4b1 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "lodash": "4.17.21", "machinepack-passwords": "2.3.0", "marked": "^16.4.1", + "nodemailer": "^10.0.15", "pinia": "2.2.4", "rollbar": "^2.26.5", "sails": "^1.5.15", diff --git a/src/routes/home/HomeView.vue b/src/routes/home/HomeView.vue index 9b7a34ede..0ee57504e 100644 --- a/src/routes/home/HomeView.vue +++ b/src/routes/home/HomeView.vue @@ -1,5 +1,6 @@ ' diff --git a/tests/unit/specs/sails/user-email.spec.js b/tests/unit/specs/sails/user-email.spec.js new file mode 100644 index 000000000..fbbb73d7e --- /dev/null +++ b/tests/unit/specs/sails/user-email.spec.js @@ -0,0 +1,133 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; + +const signup = async (username) => { + const agent = request.agent(globalThis.sailsApp); + const res = await agent.post('/api/user/signup').send({ username, password: 'password123' }); + expect(res.statusCode).toBe(200); + return { agent, userId: res.body }; +}; + +// Codes are hashed, so tests set a known code directly on the row +const setKnownCode = async (userId, code = '123456') => { + await UserEmail.updateOne({ user: userId }).set({ + verificationCode: sails.helpers.email.hashVerificationCode(code), + }); + return code; +}; + +describe('User preferred email', () => { + beforeEach(async () => { + await sails.helpers.wipeDatabase(); + }); + + it('Requires login', async () => { + const res = await request(globalThis.sailsApp).get('/api/user/email'); + expect(res.statusCode).toBe(401); + }); + + it('Returns no email for a new user', async () => { + const { agent } = await signup('myUser'); + const { body } = await agent.get('/api/user/email'); + expect(body).toEqual({ email: null, promotional: false }); + }); + + it('Rejects invalid email formats', async () => { + const { agent } = await signup('myUser'); + for (const email of [ '', 'not-an-email', 'a@b', 'a b@c.com', 42 ]) { + const res = await agent.post('/api/user/email/request-code').send({ email }); + expect(res.statusCode).toBe(400); + expect(res.body.message).toBe('emailPreference.error.invalidEmail'); + } + }); + + it('Does not persist the email until the code is verified', async () => { + const { agent, userId } = await signup('myUser'); + + const res = await agent.post('/api/user/email/request-code').send({ email: ' Me@Example.com ', promotional: true }); + expect(res.statusCode).toBe(200); + + const row = await UserEmail.findOne({ user: userId }); + expect(row.email).toBeNull(); + expect(row.pendingEmail).toBe('me@example.com'); + expect(row.verificationCode).toMatch(/^[a-f0-9]{64}$/); + + const code = await setKnownCode(userId); + const verifyRes = await agent.post('/api/user/email/verify').send({ code }); + expect(verifyRes.statusCode).toBe(200); + + const { body } = await agent.get('/api/user/email'); + expect(body).toEqual({ email: 'me@example.com', promotional: true }); + + const verified = await UserEmail.findOne({ user: userId }); + expect(verified.pendingEmail).toBeNull(); + expect(verified.verificationCode).toBeNull(); + }); + + it('Keeps one row per user', async () => { + const { agent, userId } = await signup('myUser'); + await agent.post('/api/user/email/request-code').send({ email: 'first@example.com' }); + await UserEmail.updateOne({ user: userId }).set({ codeSentAt: new Date(0) }); + await agent.post('/api/user/email/request-code').send({ email: 'second@example.com' }); + + const rows = await UserEmail.find({ user: userId }); + expect(rows).toHaveLength(1); + expect(rows[0].pendingEmail).toBe('second@example.com'); + }); + + it('Rate limits code requests', async () => { + const { agent } = await signup('myUser'); + await agent.post('/api/user/email/request-code').send({ email: 'me@example.com' }); + const res = await agent.post('/api/user/email/request-code').send({ email: 'me@example.com' }); + expect(res.statusCode).toBe(429); + }); + + it('Rejects wrong codes and locks after 5 attempts', async () => { + const { agent, userId } = await signup('myUser'); + await agent.post('/api/user/email/request-code').send({ email: 'me@example.com' }); + const code = await setKnownCode(userId); + + for (let i = 0; i < 5; i++) { + const res = await agent.post('/api/user/email/verify').send({ code: '000000' }); + expect(res.body.message).toBe('emailPreference.error.invalidCode'); + } + const res = await agent.post('/api/user/email/verify').send({ code }); + expect(res.statusCode).toBe(400); + expect(res.body.message).toBe('emailPreference.error.codeExpired'); + }); + + it('Rejects expired codes', async () => { + const { agent, userId } = await signup('myUser'); + await agent.post('/api/user/email/request-code').send({ email: 'me@example.com' }); + const code = await setKnownCode(userId); + await UserEmail.updateOne({ user: userId }).set({ codeExpiresAt: new Date(Date.now() - 1000) }); + + const res = await agent.post('/api/user/email/verify').send({ code }); + expect(res.body.message).toBe('emailPreference.error.codeExpired'); + }); + + it('Does not allow an email verified by another user', async () => { + const { agent: firstAgent, userId: firstId } = await signup('firstUser'); + await firstAgent.post('/api/user/email/request-code').send({ email: 'me@example.com' }); + await firstAgent.post('/api/user/email/verify').send({ code: await setKnownCode(firstId) }); + + const { agent: secondAgent } = await signup('secondUser'); + const res = await secondAgent.post('/api/user/email/request-code').send({ email: 'ME@example.com' }); + expect(res.statusCode).toBe(409); + expect(res.body.message).toBe('emailPreference.error.emailTaken'); + }); + + it('Returns 409 when another user verifies the same email first', async () => { + const { agent: firstAgent, userId: firstId } = await signup('firstUser'); + const { agent: secondAgent, userId: secondId } = await signup('secondUser'); + await firstAgent.post('/api/user/email/request-code').send({ email: 'me@example.com' }); + await secondAgent.post('/api/user/email/request-code').send({ email: 'me@example.com' }); + + await firstAgent.post('/api/user/email/verify').send({ code: await setKnownCode(firstId) }); + const res = await secondAgent.post('/api/user/email/verify').send({ code: await setKnownCode(secondId) }); + expect(res.statusCode).toBe(409); + + const secondRow = await UserEmail.findOne({ user: secondId }); + expect(secondRow.email).toBeNull(); + }); +}); diff --git a/tests/unit/vitest-sails.config.mjs b/tests/unit/vitest-sails.config.mjs index a12a114c3..ba88f5192 100644 --- a/tests/unit/vitest-sails.config.mjs +++ b/tests/unit/vitest-sails.config.mjs @@ -24,6 +24,9 @@ export default defineConfig({ isolate: false, env:{ VITE_JWT_SECRET: 'cuttle_TEST_JWT_SECRET', + // Never send real emails from tests + GMAIL_USER: '', + GMAIL_APP_PASSWORD: '', } }, }); diff --git a/utils/local-storage-utils.js b/utils/local-storage-utils.js index ac7e9bd84..1d839cc5d 100644 --- a/utils/local-storage-utils.js +++ b/utils/local-storage-utils.js @@ -2,6 +2,7 @@ const LS_PREFERS_RANKED_NAME = 'prefersRanked'; const LS_IS_RETURNING_USER_NAME = 'isReturningUser'; const LS_PLAY_TIME_DIALOG_DISMISSED = 'playTimeDialogDismissed'; const LS_ANNOUNCEMENT = 'announcement'; +const LS_EMAIL_PROMPT_DISMISSED = 'emailPromptDismissed'; const getLocalStorage = (key) => { try { @@ -25,5 +26,6 @@ export { LS_PREFERS_RANKED_NAME, LS_IS_RETURNING_USER_NAME, LS_PLAY_TIME_DIALOG_DISMISSED, - LS_ANNOUNCEMENT + LS_ANNOUNCEMENT, + LS_EMAIL_PROMPT_DISMISSED, }; From 6af78dbafd45e70c1679e733fbe06d147fce3adb Mon Sep 17 00:00:00 2001 From: Henry Aviles <108551827+Haviles04@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:38:34 -0400 Subject: [PATCH 2/2] fix: use dayjs --- api/controllers/user-email/request-code.js | 15 +++++++++------ api/controllers/user-email/verify.js | 5 ++++- tests/unit/specs/sails/user-email.spec.js | 9 +++++++-- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/api/controllers/user-email/request-code.js b/api/controllers/user-email/request-code.js index e12b566f7..88dc70ed0 100644 --- a/api/controllers/user-email/request-code.js +++ b/api/controllers/user-email/request-code.js @@ -1,7 +1,10 @@ const crypto = require('crypto'); +const dayjs = require('dayjs'); +const utc = require('dayjs/plugin/utc'); +dayjs.extend(utc); -const CODE_TTL_MS = 10 * 60 * 1000; -const RESEND_COOLDOWN_MS = 60 * 1000; +const CODE_TTL_MINUTES = 10; +const RESEND_COOLDOWN_SECONDS = 60; module.exports = async function (req, res) { try { @@ -18,8 +21,8 @@ module.exports = async function (req, res) { const userEmail = await UserEmail.findOne({ user: userId }); // Limit how often a user can request a code - const now = Date.now(); - if (userEmail?.codeSentAt && now - new Date(userEmail.codeSentAt).getTime() < RESEND_COOLDOWN_MS) { + const now = dayjs.utc(); + if (userEmail?.codeSentAt && now.diff(dayjs.utc(userEmail.codeSentAt), 'second') < RESEND_COOLDOWN_SECONDS) { return res.status(429).json({ message: 'emailPreference.error.tooManyRequests' }); } @@ -28,8 +31,8 @@ module.exports = async function (req, res) { const updates = { pendingEmail: email, verificationCode: sails.helpers.email.hashVerificationCode(code), - codeExpiresAt: new Date(now + CODE_TTL_MS), - codeSentAt: new Date(now), + codeExpiresAt: now.add(CODE_TTL_MINUTES, 'minute').toDate(), + codeSentAt: now.toDate(), attempts: 0, promotional, }; diff --git a/api/controllers/user-email/verify.js b/api/controllers/user-email/verify.js index 240504e5a..ddd1f71b8 100644 --- a/api/controllers/user-email/verify.js +++ b/api/controllers/user-email/verify.js @@ -1,4 +1,7 @@ const crypto = require('crypto'); +const dayjs = require('dayjs'); +const utc = require('dayjs/plugin/utc'); +dayjs.extend(utc); const MAX_ATTEMPTS = 5; @@ -14,7 +17,7 @@ module.exports = async function (req, res) { return res.badRequest({ message: 'emailPreference.error.noPendingEmail' }); } - if (new Date(userEmail.codeExpiresAt).getTime() < Date.now() || userEmail.attempts >= MAX_ATTEMPTS) { + if (dayjs.utc().isAfter(dayjs.utc(userEmail.codeExpiresAt)) || userEmail.attempts >= MAX_ATTEMPTS) { return res.badRequest({ message: 'emailPreference.error.codeExpired' }); } diff --git a/tests/unit/specs/sails/user-email.spec.js b/tests/unit/specs/sails/user-email.spec.js index fbbb73d7e..178f58c70 100644 --- a/tests/unit/specs/sails/user-email.spec.js +++ b/tests/unit/specs/sails/user-email.spec.js @@ -1,5 +1,8 @@ import { describe, it, expect, beforeEach } from 'vitest'; import request from 'supertest'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +dayjs.extend(utc); const signup = async (username) => { const agent = request.agent(globalThis.sailsApp); @@ -67,7 +70,8 @@ describe('User preferred email', () => { it('Keeps one row per user', async () => { const { agent, userId } = await signup('myUser'); await agent.post('/api/user/email/request-code').send({ email: 'first@example.com' }); - await UserEmail.updateOne({ user: userId }).set({ codeSentAt: new Date(0) }); + await UserEmail.updateOne({ user: userId }).set({ codeSentAt: dayjs.utc().subtract(1, 'hour') + .toDate() }); await agent.post('/api/user/email/request-code').send({ email: 'second@example.com' }); const rows = await UserEmail.find({ user: userId }); @@ -100,7 +104,8 @@ describe('User preferred email', () => { const { agent, userId } = await signup('myUser'); await agent.post('/api/user/email/request-code').send({ email: 'me@example.com' }); const code = await setKnownCode(userId); - await UserEmail.updateOne({ user: userId }).set({ codeExpiresAt: new Date(Date.now() - 1000) }); + await UserEmail.updateOne({ user: userId }).set({ codeExpiresAt: dayjs.utc().subtract(1, 'second') + .toDate() }); const res = await agent.post('/api/user/email/verify').send({ code }); expect(res.body.message).toBe('emailPreference.error.codeExpired');