diff --git a/src/controllers/UsersControllers.test.ts b/src/controllers/UsersControllers.test.ts index 2d973e490..05f09f808 100644 --- a/src/controllers/UsersControllers.test.ts +++ b/src/controllers/UsersControllers.test.ts @@ -112,7 +112,7 @@ describe('UsersController.register', () => { expect(res.cookie).toHaveBeenCalledWith('token', 'jwt-reg-tok'); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ token: 'jwt-reg-tok', verificationPending: true }) + expect.objectContaining({ token: 'jwt-reg-tok' }) ); expect(next).not.toHaveBeenCalled(); }); @@ -241,7 +241,7 @@ describe('UsersController.verifyEmail', () => { return { redirect } as unknown as express.Response & { redirect: jest.Mock }; }; - it('redirects to /uploads?verified=1 when the verify_email token is valid', async () => { + it('redirects to /login?verified=1 when the verify_email token is valid and user is unauthenticated', async () => { const verifyMagicToken = jest .fn() .mockResolvedValue({ userId: 7, purpose: 'verify_email' }); @@ -254,7 +254,31 @@ describe('UsersController.verifyEmail', () => { await controller.verifyEmail(req, res, next); expect(markEmailVerified).toHaveBeenCalledWith('7'); - expect(res.redirect).toHaveBeenCalledWith('/downloads?verified=1'); + expect(res.redirect).toHaveBeenCalledWith('/login?verified=1'); + }); + + it('redirects to /account?verified=1 when the token is valid and user is authenticated', async () => { + const verifyMagicToken = jest + .fn() + .mockResolvedValue({ userId: 7, purpose: 'verify_email' }); + const markEmailVerified = jest.fn().mockResolvedValue(1); + const authGetUserFrom = jest.fn().mockResolvedValue({ id: 7 }); + const { controller } = buildVerifyEmailController({ + verifyMagicToken, + markEmailVerified, + authGetUserFrom, + }); + const req = { + params: { token: 'valid-verify-tok' }, + cookies: { token: 'session' }, + } as unknown as express.Request; + const res = buildVerifyEmailRes(); + const next = jest.fn(); + + await controller.verifyEmail(req, res, next); + + expect(markEmailVerified).toHaveBeenCalledWith('7'); + expect(res.redirect).toHaveBeenCalledWith('/account?verified=1'); }); it('redirects to /login?verify_error=expired when token is invalid and user is unauthenticated', async () => { @@ -405,6 +429,7 @@ describe('UsersController.verifyMagicLink', () => { newJWTToken?: jest.Mock; persistToken?: jest.Mock; updateLastLoginAt?: jest.Mock; + markEmailVerified?: jest.Mock; }) => { const userService = { verifyMagicToken: @@ -414,6 +439,8 @@ describe('UsersController.verifyMagicLink', () => { jest.fn().mockResolvedValue({ id: 1, email: 'al@example.com' }), updateLastLoginAt: overrides?.updateLastLoginAt ?? jest.fn().mockResolvedValue(undefined), + markEmailVerified: + overrides?.markEmailVerified ?? jest.fn().mockResolvedValue(1), } as unknown as UsersService; const authService = { newJWTToken: @@ -498,6 +525,7 @@ describe('UsersController.verifyMagicLink', () => { getUserById, updateResetToken, updateLastLoginAt: jest.fn().mockResolvedValue(undefined), + markEmailVerified: jest.fn().mockResolvedValue(1), } as unknown as UsersService; const authService = { newJWTToken: jest.fn().mockResolvedValue('jwt-tok'), @@ -521,6 +549,62 @@ describe('UsersController.verifyMagicLink', () => { expect(jsonCall.reset_token.length).toBeGreaterThan(0); expect(updateResetToken).toHaveBeenCalledWith('8', jsonCall.reset_token); }); + + it('marks email verified after a successful login magic link', async () => { + const verifyMagicToken = jest + .fn() + .mockResolvedValue({ userId: 5, purpose: 'login' }); + const getUserById = jest + .fn() + .mockResolvedValue({ id: 5, email: 'al@example.com' }); + const markEmailVerified = jest.fn().mockResolvedValue(1); + const { controller } = buildVerifyController({ + verifyMagicToken, + getUserById, + markEmailVerified, + }); + const req = { params: { token: 'valid-tok' } } as unknown as express.Request; + const res = buildVerifyRes(); + const next = jest.fn(); + + await controller.verifyMagicLink(req, res, next); + + expect(markEmailVerified).toHaveBeenCalledWith('5'); + }); + + it('marks email verified after a successful password_reset magic link', async () => { + const verifyMagicToken = jest + .fn() + .mockResolvedValue({ userId: 8, purpose: 'password_reset' }); + const getUserById = jest + .fn() + .mockResolvedValue({ id: 8, email: 'reset@example.com' }); + const markEmailVerified = jest.fn().mockResolvedValue(1); + const updateResetToken = jest.fn().mockResolvedValue(undefined); + const userService = { + verifyMagicToken, + getUserById, + updateResetToken, + updateLastLoginAt: jest.fn().mockResolvedValue(undefined), + markEmailVerified, + } as unknown as UsersService; + const authService = { + newJWTToken: jest.fn().mockResolvedValue('jwt-tok'), + persistToken: jest.fn().mockResolvedValue(undefined), + } as unknown as AuthenticationService; + const controller = new UsersController( + userService, + authService, + {} as ReturnType + ); + const req = { params: { token: 'reset-tok' } } as unknown as express.Request; + const res = buildVerifyRes(); + const next = jest.fn(); + + await controller.verifyMagicLink(req, res, next); + + expect(markEmailVerified).toHaveBeenCalledWith('8'); + }); }); describe('UsersController.getLocals', () => { @@ -593,97 +677,3 @@ describe('UsersController.getLocals', () => { }); }); -describe('UsersController.resendVerificationEmail', () => { - it('returns 200 with ok:true on success', async () => { - const resendVerificationEmail = jest.fn().mockResolvedValue({ ok: true }); - const userService = { resendVerificationEmail } as unknown as UsersService; - const authService = {} as unknown as AuthenticationService; - const controller = new UsersController( - userService, - authService, - {} as ReturnType - ); - const req = {} as express.Request; - const res = { - locals: { owner: 5 }, - json: jest.fn(), - status: jest.fn().mockReturnThis(), - } as unknown as express.Response & { json: jest.Mock; status: jest.Mock }; - const next = jest.fn(); - - await controller.resendVerificationEmail(req, res, next); - - expect(res.json).toHaveBeenCalledWith({ ok: true }); - }); - - it('returns 200 with alreadyVerified:true when already verified', async () => { - const resendVerificationEmail = jest - .fn() - .mockResolvedValue({ ok: true, alreadyVerified: true }); - const userService = { resendVerificationEmail } as unknown as UsersService; - const authService = {} as unknown as AuthenticationService; - const controller = new UsersController( - userService, - authService, - {} as ReturnType - ); - const req = {} as express.Request; - const res = { - locals: { owner: 5 }, - json: jest.fn(), - status: jest.fn().mockReturnThis(), - } as unknown as express.Response & { json: jest.Mock; status: jest.Mock }; - const next = jest.fn(); - - await controller.resendVerificationEmail(req, res, next); - - expect(res.json).toHaveBeenCalledWith({ ok: true, alreadyVerified: true }); - }); - - it('returns 429 when rate limited', async () => { - const resendVerificationEmail = jest - .fn() - .mockRejectedValue(new MagicLinkRateLimitError()); - const userService = { resendVerificationEmail } as unknown as UsersService; - const authService = {} as unknown as AuthenticationService; - const controller = new UsersController( - userService, - authService, - {} as ReturnType - ); - const req = {} as express.Request; - const json = jest.fn(); - const res = { - locals: { owner: 5 }, - json, - status: jest.fn().mockReturnValue({ json }), - } as unknown as express.Response & { json: jest.Mock; status: jest.Mock }; - const next = jest.fn(); - - await controller.resendVerificationEmail(req, res, next); - - expect(res.status).toHaveBeenCalledWith(429); - }); - - it('returns 401 when not authenticated', async () => { - const userService = {} as unknown as UsersService; - const authService = {} as unknown as AuthenticationService; - const controller = new UsersController( - userService, - authService, - {} as ReturnType - ); - const req = {} as express.Request; - const json = jest.fn(); - const res = { - locals: {}, - json, - status: jest.fn().mockReturnValue({ json }), - } as unknown as express.Response & { json: jest.Mock; status: jest.Mock }; - const next = jest.fn(); - - await controller.resendVerificationEmail(req, res, next); - - expect(res.status).toHaveBeenCalledWith(401); - }); -}); diff --git a/src/controllers/UsersControllers.ts b/src/controllers/UsersControllers.ts index 34c05be99..94b7e4ffc 100644 --- a/src/controllers/UsersControllers.ts +++ b/src/controllers/UsersControllers.ts @@ -192,7 +192,7 @@ class UsersController { await this.authService.persistToken(token, newUser.id.toString()); await this.userService.updateLastLoginAt(newUser.id.toString()); res.cookie('token', token); - return res.status(200).json({ token, verificationPending: true }); + return res.status(200).json({ token }); } } res.status(200).json({ message: 'ok' }); @@ -547,7 +547,7 @@ class UsersController { const isNewUser = !user; if (!user) { const hashedPassword = this.authService.getHashPassword(getRandomUUID()); - await this.userService.register(name ?? email, hashedPassword, email, null, true); + await this.userService.register(name ?? email, hashedPassword, email, null); user = await this.userService.getUserFrom(email); } if (isNewUser && user) { @@ -665,54 +665,27 @@ class UsersController { return res.status(200).json({ message: 'ok' }); } - async resendVerificationEmail( - _req: express.Request, - res: express.Response, - next: express.NextFunction - ) { - const { owner } = res.locals; - if (owner == null) { - return res.status(401).json({ message: 'Authentication required' }); - } - - try { - const result = await this.userService.resendVerificationEmail(owner.toString()); - return res.json(result); - } catch (error) { - if (error instanceof MagicLinkRateLimitError) { - return res.status(429).json({ message: 'Too many requests. Try again in a minute.' }); - } - next(error); - } - } - async verifyEmail( req: express.Request, res: express.Response, next: express.NextFunction ) { const { token } = req.params; - - const expiredRedirect = async () => { - const sessionUser = await this.authService.getUserFrom(req.cookies?.token); - return sessionUser != null - ? res.redirect('/account?verify_error=expired') - : res.redirect('/login?verify_error=expired'); - }; + const sessionUser = await this.authService.getUserFrom(req.cookies?.token); + const base = sessionUser ? '/account' : '/login'; if (token == null || token.length === 0) { - return expiredRedirect(); + return res.redirect(`${base}?verify_error=expired`); } try { const result = await this.userService.verifyMagicToken(token); if (result?.purpose !== 'verify_email') { - return expiredRedirect(); + return res.redirect(`${base}?verify_error=expired`); } await this.userService.markEmailVerified(result.userId.toString()); - return res.redirect('/downloads?verified=1'); + return res.redirect(`${base}?verified=1`); } catch (error) { - console.error('Email verification failed:', error); next(error); } } @@ -747,6 +720,7 @@ class UsersController { const jwtToken = await this.authService.newJWTToken(user.id); await this.authService.persistToken(jwtToken, user.id.toString()); await this.userService.updateLastLoginAt(user.id.toString()); + await this.userService.markEmailVerified(user.id.toString()); res.cookie('token', jwtToken); return res.status(200).json({ token: jwtToken }); } @@ -760,6 +734,7 @@ class UsersController { } const resetToken = crypto.randomUUID(); await this.userService.updateResetToken(user.id.toString(), resetToken); + await this.userService.markEmailVerified(user.id.toString()); return res.status(200).json({ purpose: 'password_reset', reset_token: resetToken }); } diff --git a/src/lib/storage/jobs/helpers/sendReEngagementEmails.test.ts b/src/lib/storage/jobs/helpers/sendReEngagementEmails.test.ts index 82ff6165a..797acf8f4 100644 --- a/src/lib/storage/jobs/helpers/sendReEngagementEmails.test.ts +++ b/src/lib/storage/jobs/helpers/sendReEngagementEmails.test.ts @@ -14,7 +14,6 @@ function buildEmailService(): jest.Mocked { sendMagicLinkEmail: jest.fn(), sendReEngagementEmail: jest.fn().mockResolvedValue(undefined), sendInactivityWarningEmail: jest.fn().mockResolvedValue(undefined), - sendVerificationEmail: jest.fn().mockResolvedValue(undefined), sendAbandonedCheckoutRecoveryEmail: jest.fn().mockResolvedValue(undefined), }; } diff --git a/src/routes/UserRouter.ts b/src/routes/UserRouter.ts index d26199778..3e58f94a9 100644 --- a/src/routes/UserRouter.ts +++ b/src/routes/UserRouter.ts @@ -192,8 +192,8 @@ const UserRouter = () => { * @swagger * /api/users/verify/{token}: * get: - * summary: Verify email address - * description: Validates an email verification token, marks the user's email as verified, and redirects to the app. + * summary: Honor in-flight email verification links + * description: Kept alive for verify_email tokens issued before email verification was removed. New signups no longer receive these. * tags: [Authentication] * parameters: * - in: path @@ -203,7 +203,7 @@ const UserRouter = () => { * type: string * responses: * 302: - * description: Redirects to /uploads on success, or /login?error=verification-expired on failure + * description: Redirects to /login?verified=1 (or /account?verified=1 if signed in) on success; same paths with verify_error=expired on failure */ router.get('/api/users/verify/:token', (req, res, next) => controller.verifyEmail(req, res, next) @@ -545,12 +545,6 @@ const UserRouter = () => { (req, res) => controller.startTrial(req, res) ); - router.post( - '/api/users/resend-verification', - RequireAuthentication, - (req, res, next) => controller.resendVerificationEmail(req, res, next) - ); - /** * @swagger * /login: diff --git a/src/services/EmailService/EmailService.ts b/src/services/EmailService/EmailService.ts index 84664236c..f5aab728f 100644 --- a/src/services/EmailService/EmailService.ts +++ b/src/services/EmailService/EmailService.ts @@ -15,7 +15,6 @@ import { SUBSCRIPTION_CANCELLED_TEMPLATE, SUBSCRIPTION_CANCELLATIONS_LOG_PATH, SUBSCRIPTION_SCHEDULED_CANCELLATION_TEMPLATE, - VERIFY_EMAIL_TEMPLATE, } from './constants'; import { isValidDeckName, addDeckNameSuffix } from '../../lib/anki/format'; import { ClientResponse } from '@sendgrid/mail'; @@ -58,7 +57,6 @@ export interface IEmailService { token: string ): Promise; sendInactivityWarningEmail(to: string): Promise; - sendVerificationEmail(to: string, token: string): Promise; sendAbandonedCheckoutRecoveryEmail(to: string): Promise; } @@ -283,26 +281,6 @@ class EmailService implements IEmailService { } } - async sendVerificationEmail(to: string, token: string): Promise { - const link = `${process.env.DOMAIN ?? 'https://2anki.net'}/api/users/verify/${token}`; - const markup = VERIFY_EMAIL_TEMPLATE.replace('{{link}}', link); - const msg = { - to, - from: this.defaultSender, - subject: 'Verify your 2anki email address', - text: `Thanks for signing up. Verify your email address here: ${link} (expires in 24 hours)`, - html: markup, - replyTo: 'support@2anki.net', - }; - - try { - await sgMail.send(msg); - } catch (error) { - console.error('Failed to send verification email:', error); - throw error; - } - } - async sendAbandonedCheckoutRecoveryEmail(to: string): Promise { const domain = process.env.DOMAIN ?? 'https://2anki.net'; const link = `${domain}/pricing?from=recovery`; @@ -534,10 +512,6 @@ export class UnimplementedEmailService implements IEmailService { console.info('sendInactivityWarningEmail not handled', to); } - async sendVerificationEmail(to: string, token: string): Promise { - console.info('sendVerificationEmail not handled'); - } - async sendAbandonedCheckoutRecoveryEmail(to: string): Promise { console.info('sendAbandonedCheckoutRecoveryEmail not handled', to); } diff --git a/src/services/EmailService/constants.ts b/src/services/EmailService/constants.ts index b82f8e543..00de5fdea 100644 --- a/src/services/EmailService/constants.ts +++ b/src/services/EmailService/constants.ts @@ -55,11 +55,6 @@ export const INACTIVITY_WARNING_TEMPLATE = fs.readFileSync( 'utf8' ); -export const VERIFY_EMAIL_TEMPLATE = fs.readFileSync( - path.join(EMAIL_TEMPLATES_DIRECTORY, 'verify-email.html'), - 'utf8' -); - export const ABANDONED_CHECKOUT_RECOVERY_TEMPLATE = fs.readFileSync( path.join(EMAIL_TEMPLATES_DIRECTORY, 'abandoned-checkout-recovery.html'), 'utf8' diff --git a/src/services/EmailService/templates/verify-email.html b/src/services/EmailService/templates/verify-email.html deleted file mode 100644 index bdb227824..000000000 --- a/src/services/EmailService/templates/verify-email.html +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - 2anki.net - Verify your email address - - - -
- -
- - diff --git a/src/services/UsersService.test.ts b/src/services/UsersService.test.ts index e675eaec7..29ef072cb 100644 --- a/src/services/UsersService.test.ts +++ b/src/services/UsersService.test.ts @@ -4,8 +4,6 @@ import type { IEmailService } from './EmailService/EmailService'; import type AuthenticationService from './AuthenticationService'; import { InMemoryMagicTokenRepository } from '../data_layer/MagicTokenRepository'; -const noopEmailService = {} as IEmailService; - function buildEmailService( overrides: Partial = {} ): jest.Mocked { @@ -22,7 +20,6 @@ function buildEmailService( sendMagicLinkEmail: jest.fn().mockResolvedValue(undefined), sendReEngagementEmail: jest.fn().mockResolvedValue(undefined), sendInactivityWarningEmail: jest.fn().mockResolvedValue(undefined), - sendVerificationEmail: jest.fn().mockResolvedValue(undefined), ...overrides, } as jest.Mocked; } @@ -60,40 +57,6 @@ describe('UsersService.register', () => { ); }); - it('sends a verification email after creating the user', async () => { - const repository = buildRegisterRepository(); - const emailService = buildEmailService(); - const magicTokenRepo = new InMemoryMagicTokenRepository(); - const service = new UsersService(repository, emailService, magicTokenRepo); - - await service.register('Alex', 'hashed', 'alex@example.com'); - - expect(emailService.sendVerificationEmail).toHaveBeenCalledTimes(1); - expect(emailService.sendVerificationEmail).toHaveBeenCalledWith( - 'alex@example.com', - expect.any(String) - ); - }); - - it('stores a verify_email magic token with 24h expiry', async () => { - const repository = buildRegisterRepository(); - const emailService = buildEmailService(); - const magicTokenRepo = new InMemoryMagicTokenRepository(); - const before = new Date(); - const service = new UsersService(repository, emailService, magicTokenRepo); - - await service.register('Alex', 'hashed', 'alex@example.com'); - - const sentToken = (emailService.sendVerificationEmail as jest.Mock).mock.calls[0][1]; - const record = await magicTokenRepo.findValidToken(sentToken); - expect(record).not.toBeNull(); - expect(record!.purpose).toBe('verify_email'); - const expectedExpiry = new Date(before.getTime() + 24 * 60 * 60 * 1000); - expect(record!.expires_at.getTime()).toBeGreaterThanOrEqual( - expectedExpiry.getTime() - 1000 - ); - }); - it('defaults the name to the local part of the email when no name is supplied', async () => { const repository = buildRegisterRepository(); const service = new UsersService(repository, buildEmailService()); @@ -122,17 +85,6 @@ describe('UsersService.register', () => { ); }); - it('skips the verification email and magic token when skipEmailVerification is true', async () => { - const repository = buildRegisterRepository(); - const emailService = buildEmailService(); - const magicTokenRepo = new InMemoryMagicTokenRepository(); - const service = new UsersService(repository, emailService, magicTokenRepo); - - await service.register('Alex', 'hashed', 'alex@example.com', null, true); - - expect(emailService.sendVerificationEmail).not.toHaveBeenCalled(); - }); - it('forwards a validated signup_origin to the repository', async () => { const repository = buildRegisterRepository(); const service = new UsersService(repository, buildEmailService()); @@ -341,65 +293,6 @@ describe('UsersService.requestMagicLink', () => { }); }); -describe('UsersService.resendVerificationEmail', () => { - it('returns ok:true on happy path and sends a verification email', async () => { - const emailService = buildEmailService(); - const magicTokenRepo = new InMemoryMagicTokenRepository(); - const getById = jest.fn().mockResolvedValue({ - id: 5, - email: 'al@example.com', - email_verified: false, - }); - const repository = { getById } as unknown as UsersRepository; - const service = new UsersService(repository, emailService, magicTokenRepo); - - const result = await service.resendVerificationEmail('5'); - - expect(result).toEqual({ ok: true }); - expect(emailService.sendVerificationEmail).toHaveBeenCalledWith( - 'al@example.com', - expect.any(String) - ); - }); - - it('returns alreadyVerified:true without sending email when user is already verified', async () => { - const emailService = buildEmailService(); - const magicTokenRepo = new InMemoryMagicTokenRepository(); - const getById = jest.fn().mockResolvedValue({ - id: 5, - email: 'al@example.com', - email_verified: true, - }); - const repository = { getById } as unknown as UsersRepository; - const service = new UsersService(repository, emailService, magicTokenRepo); - - const result = await service.resendVerificationEmail('5'); - - expect(result).toEqual({ ok: true, alreadyVerified: true }); - expect(emailService.sendVerificationEmail).not.toHaveBeenCalled(); - }); - - it('throws MagicLinkRateLimitError when the rate limit is hit', async () => { - const emailService = buildEmailService(); - const magicTokenRepo = new InMemoryMagicTokenRepository(); - const getById = jest.fn().mockResolvedValue({ - id: 3, - email: 'rate@example.com', - email_verified: false, - }); - const repository = { getById } as unknown as UsersRepository; - const service = new UsersService(repository, emailService, magicTokenRepo); - - for (let i = 0; i < 5; i++) { - await service.resendVerificationEmail('3'); - } - - await expect(service.resendVerificationEmail('3')).rejects.toThrow( - MagicLinkRateLimitError - ); - }); -}); - describe('UsersService.verifyMagicToken', () => { it('returns userId and purpose for a valid token', async () => { const getByEmail = jest diff --git a/src/services/UsersService.ts b/src/services/UsersService.ts index 594467208..97fe3888c 100644 --- a/src/services/UsersService.ts +++ b/src/services/UsersService.ts @@ -9,7 +9,6 @@ import type { IMagicTokenRepository } from '../data_layer/MagicTokenRepository'; const MAGIC_LINK_RATE_LIMIT = 5; const MAGIC_LINK_RATE_WINDOW_MS = 60 * 60 * 1000; const MAGIC_LINK_EXPIRY_MS = 15 * 60 * 1000; -const VERIFY_EMAIL_EXPIRY_MS = 24 * 60 * 60 * 1000; export class MagicLinkRateLimitError extends Error { constructor() { @@ -65,32 +64,18 @@ class UsersService { name: string, password: string, email: string, - signupOrigin?: string | null, - skipEmailVerification?: boolean + signupOrigin?: string | null ) { const normalizedEmail = email.toLowerCase(); const trimmedName = name?.trim() ?? ''; const resolvedName = trimmedName.length > 0 ? trimmedName : normalizedEmail.split('@')[0]; - const rows = await this.repository.createUser( + return this.repository.createUser( resolvedName, password, normalizedEmail, signupOrigin ?? null ); - const userId = Array.isArray(rows) ? rows[0]?.id : null; - if (userId != null && this.magicTokenRepository != null && !skipEmailVerification) { - const token = crypto.randomBytes(64).toString('hex'); - const expiresAt = new Date(Date.now() + VERIFY_EMAIL_EXPIRY_MS); - await this.magicTokenRepository.create( - token, - Number(userId), - 'verify_email', - expiresAt - ); - await this.emailService.sendVerificationEmail(normalizedEmail, token); - } - return rows; } deleteUser(owner: any) { @@ -156,31 +141,6 @@ class UsersService { return this.repository.markEmailVerified(userId); } - async resendVerificationEmail( - userId: string - ): Promise<{ ok: true } | { ok: true; alreadyVerified: true }> { - const user = await this.repository.getById(userId); - if (user?.email_verified) { - return { ok: true, alreadyVerified: true }; - } - if (this.magicTokenRepository == null || user?.email == null) { - return { ok: true }; - } - const oneHourAgo = new Date(Date.now() - MAGIC_LINK_RATE_WINDOW_MS); - const recentCount = await this.magicTokenRepository.countRecentByOwner( - user.id, - oneHourAgo - ); - if (recentCount >= MAGIC_LINK_RATE_LIMIT) { - throw new MagicLinkRateLimitError(); - } - const token = crypto.randomBytes(64).toString('hex'); - const expiresAt = new Date(Date.now() + VERIFY_EMAIL_EXPIRY_MS); - await this.magicTokenRepository.create(token, user.id, 'verify_email', expiresAt); - await this.emailService.sendVerificationEmail(user.email, token); - return { ok: true }; - } - markTrialStarted(userId: string) { return this.repository.markTrialStarted(userId); } diff --git a/src/usecases/ops/SendAbandonedCheckoutRecoveryUseCase.test.ts b/src/usecases/ops/SendAbandonedCheckoutRecoveryUseCase.test.ts index b15868a8b..93ef61ecb 100644 --- a/src/usecases/ops/SendAbandonedCheckoutRecoveryUseCase.test.ts +++ b/src/usecases/ops/SendAbandonedCheckoutRecoveryUseCase.test.ts @@ -13,7 +13,6 @@ function makeEmailService(): jest.Mocked { sendMagicLinkEmail: jest.fn(), sendReEngagementEmail: jest.fn(), sendInactivityWarningEmail: jest.fn(), - sendVerificationEmail: jest.fn(), sendAbandonedCheckoutRecoveryEmail: jest.fn().mockResolvedValue(undefined), }; } diff --git a/src/usecases/ops/SendInactivityWarningsUseCase.test.ts b/src/usecases/ops/SendInactivityWarningsUseCase.test.ts index 8edcab264..6b1b0932c 100644 --- a/src/usecases/ops/SendInactivityWarningsUseCase.test.ts +++ b/src/usecases/ops/SendInactivityWarningsUseCase.test.ts @@ -16,7 +16,6 @@ function makeEmailService( sendMagicLinkEmail: jest.fn(), sendReEngagementEmail: jest.fn(), sendInactivityWarningEmail: jest.fn().mockResolvedValue(undefined), - sendVerificationEmail: jest.fn().mockResolvedValue(undefined), sendAbandonedCheckoutRecoveryEmail: jest.fn().mockResolvedValue(undefined), ...overrides, }; diff --git a/web/src/App.tsx b/web/src/App.tsx index a45e87ea5..852098922 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -17,7 +17,6 @@ import DeleteAccountPage from './pages/DeleteAccountPage'; import { getErrorMessage } from './components/errors/helpers/getErrorMessage'; import { sendError } from './lib/SendError'; import { useUserLocals } from './lib/hooks/useUserLocals'; -import { get2ankiApi } from './lib/backend/get2ankiApi'; import { SkeletonPage } from './components/Skeleton/Skeleton'; import NotFoundPage from './pages/NotFoundPage'; @@ -113,20 +112,14 @@ function AppContent({ ); - const handleResendVerification = async () => { - await get2ankiApi().resendVerificationEmail(); - }; - return ( {}} >
page
diff --git a/web/src/components/AppShell/AppShell.tsx b/web/src/components/AppShell/AppShell.tsx index 5ba85b5a2..bdf89d4bf 100644 --- a/web/src/components/AppShell/AppShell.tsx +++ b/web/src/components/AppShell/AppShell.tsx @@ -16,10 +16,8 @@ function shouldForceTopBar(pathname: string): boolean { interface AppShellProps { isLoggedIn: boolean | undefined; email: string | null | undefined; - emailVerified: boolean; locals: SidebarLocals | undefined | null; features: SidebarFeatures | undefined | null; - onResendVerification: () => Promise; error?: Error | null; children: ReactNode; } @@ -27,10 +25,8 @@ interface AppShellProps { export function AppShell({ isLoggedIn, email, - emailVerified, locals, features, - onResendVerification, error, children, }: Readonly) { @@ -51,11 +47,9 @@ export function AppShell({ return ( {children} diff --git a/web/src/components/AppShell/SidebarLayout.test.tsx b/web/src/components/AppShell/SidebarLayout.test.tsx index ce4fbe562..78ecbe16f 100644 --- a/web/src/components/AppShell/SidebarLayout.test.tsx +++ b/web/src/components/AppShell/SidebarLayout.test.tsx @@ -10,11 +10,9 @@ function renderLayout() {
hello
diff --git a/web/src/components/AppShell/SidebarLayout.tsx b/web/src/components/AppShell/SidebarLayout.tsx index 996c60e89..014a0da17 100644 --- a/web/src/components/AppShell/SidebarLayout.tsx +++ b/web/src/components/AppShell/SidebarLayout.tsx @@ -3,7 +3,6 @@ import { Sidebar, SidebarFeatures, SidebarLocals } from './Sidebar'; import { MobileTopBar } from './MobileTopBar'; import { SkeletonPage } from '../Skeleton/Skeleton'; import { ErrorPresenter } from '../errors/ErrorPresenter'; -import { EmailVerificationBanner } from '../EmailVerificationBanner/EmailVerificationBanner'; import { MonthlyLimitBanner } from '../MonthlyLimitBanner/MonthlyLimitBanner'; import { isPayingUser } from '../NavigationBar/helpers/getPlanLabel'; import sharedStyles from '../../styles/shared.module.css'; @@ -11,22 +10,18 @@ import styles from './AppShell.module.css'; interface SidebarLayoutProps { email: string | null | undefined; - emailVerified: boolean; locals: SidebarLocals | undefined | null; features: SidebarFeatures | undefined | null; onLogOut: (event: React.MouseEvent) => void; - onResendVerification: () => Promise; error?: Error | null; children: ReactNode; } export function SidebarLayout({ email, - emailVerified, locals, features, onLogOut, - onResendVerification, error, children, }: Readonly) { @@ -66,11 +61,6 @@ export function SidebarLayout({ onOpen={() => setIsDrawerOpen(true)} onClose={() => setIsDrawerOpen(false)} /> - {error && }
diff --git a/web/src/components/EmailVerificationBanner/EmailVerificationBanner.module.css b/web/src/components/EmailVerificationBanner/EmailVerificationBanner.module.css deleted file mode 100644 index 9c84d7e66..000000000 --- a/web/src/components/EmailVerificationBanner/EmailVerificationBanner.module.css +++ /dev/null @@ -1,59 +0,0 @@ -.banner { - composes: notificationInfo from '../../styles/shared.module.css'; - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - padding: 10px 16px; - font-size: 14px; -} - -.text { - display: flex; - flex-direction: column; - gap: 2px; -} - -.sub { - opacity: 0.8; -} - -.actions { - display: flex; - align-items: center; - gap: 8px; - flex-shrink: 0; -} - -.resend { - background: none; - border: none; - cursor: pointer; - font-size: 14px; - font-weight: 500; - color: inherit; - padding: 0; - text-decoration: underline; - white-space: nowrap; -} - -.resend:disabled { - cursor: default; - opacity: 0.7; - text-decoration: none; -} - -.dismiss { - background: none; - border: none; - cursor: pointer; - font-size: 16px; - color: inherit; - padding: 0 4px; - line-height: 1; - opacity: 0.7; -} - -.dismiss:hover { - opacity: 1; -} diff --git a/web/src/components/EmailVerificationBanner/EmailVerificationBanner.test.tsx b/web/src/components/EmailVerificationBanner/EmailVerificationBanner.test.tsx deleted file mode 100644 index 82d4653fd..000000000 --- a/web/src/components/EmailVerificationBanner/EmailVerificationBanner.test.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen, fireEvent, act } from '@testing-library/react'; -import { EmailVerificationBanner } from './EmailVerificationBanner'; - -describe('EmailVerificationBanner', () => { - it('renders when emailVerified is false', () => { - render( - - ); - - expect(screen.getByText(/verify your email/i)).toBeTruthy(); - expect(screen.getByText(/al@example.com/)).toBeTruthy(); - }); - - it('does not render when emailVerified is true', () => { - render( - - ); - - expect(screen.queryByText(/verify your email/i)).toBeNull(); - }); - - it('calls onResend and shows sent state after click', async () => { - const onResend = vi.fn().mockResolvedValue(undefined); - - render( - - ); - - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /resend email/i })); - }); - - expect(onResend).toHaveBeenCalledTimes(1); - expect(screen.getByText(/sent — check your inbox/i)).toBeTruthy(); - }); - - it('shows rate-limited state when onResend rejects', async () => { - const onResend = vi.fn().mockRejectedValue(new Error('rate limited')); - - render( - - ); - - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /resend email/i })); - }); - - expect(screen.getByText(/try again in a minute/i)).toBeTruthy(); - }); - - it('shows dismiss button only after resend is clicked', async () => { - const onResend = vi.fn().mockResolvedValue(undefined); - - render( - - ); - - expect(screen.queryByRole('button', { name: /dismiss/i })).toBeNull(); - - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /resend email/i })); - }); - - expect(screen.getByRole('button', { name: /dismiss/i })).toBeTruthy(); - }); - - it('hides banner when dismiss is clicked after resend', async () => { - const onResend = vi.fn().mockResolvedValue(undefined); - - render( - - ); - - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /resend email/i })); - }); - fireEvent.click(screen.getByRole('button', { name: /dismiss/i })); - - expect(screen.queryByText(/verify your email/i)).toBeNull(); - }); -}); diff --git a/web/src/components/EmailVerificationBanner/EmailVerificationBanner.tsx b/web/src/components/EmailVerificationBanner/EmailVerificationBanner.tsx deleted file mode 100644 index 7a527753f..000000000 --- a/web/src/components/EmailVerificationBanner/EmailVerificationBanner.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useState } from 'react'; -import styles from './EmailVerificationBanner.module.css'; - -type ResendState = 'idle' | 'sending' | 'sent' | 'rate-limited'; - -interface Props { - emailVerified: boolean; - email: string; - onResend: () => Promise; -} - -export function EmailVerificationBanner({ emailVerified, email, onResend }: Readonly) { - const [resendState, setResendState] = useState('idle'); - const [dismissed, setDismissed] = useState(false); - - if (emailVerified || dismissed) { - return null; - } - - const handleResend = async () => { - setResendState('sending'); - try { - await onResend(); - setResendState('sent'); - setTimeout(() => setResendState('idle'), 60_000); - } catch { - setResendState('rate-limited'); - setTimeout(() => setResendState('idle'), 60_000); - } - }; - - const resendLabel: Record = { - idle: 'Resend email', - sending: 'Sending…', - sent: 'Sent — check your inbox', - 'rate-limited': 'Try again in a minute', - }; - - return ( -
-
- Verify your email so you can recover your account. - We sent a link to {email}. -
-
- - {resendState !== 'idle' && ( - - )} -
-
- ); -} diff --git a/web/src/components/TopMessage/TopMessage.tsx b/web/src/components/TopMessage/TopMessage.tsx index 6662d14ce..e0819f0d3 100644 --- a/web/src/components/TopMessage/TopMessage.tsx +++ b/web/src/components/TopMessage/TopMessage.tsx @@ -5,6 +5,15 @@ import styles from '../../styles/shared.module.css'; function TopMessage() { const query = useQuery(); const errorMessage = query.get('error'); + const verified = query.get('verified'); + + if (verified === '1') { + return ( + +

Email verified. Sign in to continue.

+
+ ); + } if (errorMessage === 'upload_limit_exceeded') { return ( diff --git a/web/src/lib/backend/Backend.ts b/web/src/lib/backend/Backend.ts index 06903bbd3..dbcee5b1e 100644 --- a/web/src/lib/backend/Backend.ts +++ b/web/src/lib/backend/Backend.ts @@ -44,16 +44,6 @@ export class Backend { globalThis.location.href = '/'; } - async resendVerificationEmail(): Promise< - { ok: true } | { ok: true; alreadyVerified: true } - > { - const response = await post(`${this.baseURL}users/resend-verification`, {}); - if (!response.ok) { - throw new Error(`${response.status}`); - } - return response.json(); - } - async getNotionConnectionInfo(): Promise { return get(`${this.baseURL}notion/get-notion-link`); } diff --git a/web/src/pages/AccountPage/AccountPage.module.css b/web/src/pages/AccountPage/AccountPage.module.css index 11b085604..b0a34e0d2 100644 --- a/web/src/pages/AccountPage/AccountPage.module.css +++ b/web/src/pages/AccountPage/AccountPage.module.css @@ -44,6 +44,13 @@ margin: 0 0 0.5rem; } +.profileVerified { + font-size: var(--text-xs); + font-weight: var(--font-medium); + color: var(--color-text-secondary); + margin: 0 0 0.5rem; +} + .sectionTitle { font-size: var(--text-base); font-weight: var(--font-semibold); diff --git a/web/src/pages/AccountPage/AccountPage.tsx b/web/src/pages/AccountPage/AccountPage.tsx index 98e094edc..5fffc9d49 100644 --- a/web/src/pages/AccountPage/AccountPage.tsx +++ b/web/src/pages/AccountPage/AccountPage.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; import { useUserLocals } from '../../lib/hooks/useUserLocals'; import { SkeletonPage } from '../../components/Skeleton/Skeleton'; @@ -22,8 +21,7 @@ export default function AccountPage() { const notionData = useNotionData(get2ankiApi()); const [searchParams, setSearchParams] = useSearchParams(); const justSubscribed = searchParams.get('subscribed') === '1'; - const verifyError = searchParams.get('verify_error'); - const [resendState, setResendState] = useState<'idle' | 'sending' | 'sent' | 'rate-limited'>('idle'); + const justVerified = searchParams.get('verified') === '1'; const dismissSubscribedBanner = () => { const next = new URLSearchParams(searchParams); @@ -31,14 +29,10 @@ export default function AccountPage() { setSearchParams(next, { replace: true }); }; - const handleResend = async () => { - setResendState('sending'); - try { - await get2ankiApi().resendVerificationEmail(); - setResendState('sent'); - } catch { - setResendState('rate-limited'); - } + const dismissVerifiedBanner = () => { + const next = new URLSearchParams(searchParams); + next.delete('verified'); + setSearchParams(next, { replace: true }); }; if (isLoading) return ; @@ -79,48 +73,26 @@ export default function AccountPage() { )} + {justVerified && ( +
+

Email verified.

+ +
+ )} +
- {verifyError === 'expired' && ( -
- That verification link has expired. Links expire after 24 hours.{' '} - -
- )} - - {!data.user.email_verified && verifyError !== 'expired' && ( - <> -

Email verification

-
-
- Not verified yet - -
-
- - )} -

Plan details

diff --git a/web/src/pages/AccountPage/components/UserProfile.tsx b/web/src/pages/AccountPage/components/UserProfile.tsx index dfe5bd338..fae4c7796 100644 --- a/web/src/pages/AccountPage/components/UserProfile.tsx +++ b/web/src/pages/AccountPage/components/UserProfile.tsx @@ -3,6 +3,7 @@ import styles from '../AccountPage.module.css'; interface User { name: string; email: string; + email_verified?: boolean; } interface UserProfileProps { @@ -19,6 +20,9 @@ export function UserProfile({ user }: UserProfileProps) {

{user.email}

+ {user.email_verified && ( +

Email verified

+ )}
); diff --git a/web/src/pages/WhatsNewPage/changelog.ts b/web/src/pages/WhatsNewPage/changelog.ts index d03f94db0..77a73caab 100644 --- a/web/src/pages/WhatsNewPage/changelog.ts +++ b/web/src/pages/WhatsNewPage/changelog.ts @@ -5,6 +5,7 @@ export interface ChangelogEntry { } export const changelog: ChangelogEntry[] = [ + { type: 'fix', title: 'Signup goes straight to your decks — no verification email to chase down, your address is confirmed the first time you use a sign-in link or password reset', date: '2026-05-15' }, { type: 'fix', title: 'Notion mentions (people, dates, linked pages) appear as text in your cards instead of a JSON dump', date: '2026-05-15' }, { type: 'fix', title: 'Downloaded Notion decks use the page title (or your custom deck name) as the filename', date: '2026-05-15' }, { type: 'feature', title: 'Pricing page speaks to MCAT, USMLE, and bar-exam learners when you sign up from the US', date: '2026-05-15' },