diff --git a/apps/api/prisma/migrations/20260722221759_add_email_verification/migration.sql b/apps/api/prisma/migrations/20260722221759_add_email_verification/migration.sql new file mode 100644 index 00000000..2a03e15e --- /dev/null +++ b/apps/api/prisma/migrations/20260722221759_add_email_verification/migration.sql @@ -0,0 +1,14 @@ +ALTER TABLE "User" ADD COLUMN "emailVerifiedAt" DATETIME; + +CREATE TABLE "EmailVerificationToken" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "usedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "EmailVerificationToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX "EmailVerificationToken_tokenHash_key" ON "EmailVerificationToken"("tokenHash"); +CREATE INDEX "EmailVerificationToken_userId_idx" ON "EmailVerificationToken"("userId"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 6b6e1a54..4005a669 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -8,16 +8,30 @@ datasource db { } model User { - id String @id - email String @unique - passwordHash String - name String? - timezone String? - targetRole String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - applications JobApplication[] - apiTokens ApiToken[] + id String @id + email String @unique + passwordHash String + name String? + timezone String? + targetRole String? + emailVerifiedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + applications JobApplication[] + apiTokens ApiToken[] + emailVerificationTokens EmailVerificationToken[] +} + +model EmailVerificationToken { + id String @id + userId String + tokenHash String @unique + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) } model ApiToken { diff --git a/apps/api/src/__tests__/application/auth/RegisterUseCase.test.ts b/apps/api/src/__tests__/application/auth/RegisterUseCase.test.ts index 10f5b5d9..07c6e19f 100644 --- a/apps/api/src/__tests__/application/auth/RegisterUseCase.test.ts +++ b/apps/api/src/__tests__/application/auth/RegisterUseCase.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { RegisterUseCase } from '@/use-cases/auth/RegisterUseCase.js'; import { makeUserRepository, makeUser } from '@/__tests__/helpers/mocks.js'; +import type { ISendEmailVerificationUseCase } from '@/use-cases/auth/ISendEmailVerificationUseCase.js'; vi.mock('bcryptjs', () => ({ default: { @@ -9,6 +10,13 @@ vi.mock('bcryptjs', () => ({ }, })); +const makeSendEmailVerificationUseCase = ( + overrides?: Partial, +): ISendEmailVerificationUseCase => ({ + execute: vi.fn().mockResolvedValue(undefined), + ...overrides, +}); + describe('RegisterUseCase', () => { beforeEach(() => { vi.clearAllMocks(); @@ -22,7 +30,11 @@ describe('RegisterUseCase', () => { }); const generateId = vi.fn().mockReturnValue('user-1'); - const useCase = new RegisterUseCase({ userRepository, generateId }); + const useCase = new RegisterUseCase({ + userRepository, + generateId, + sendEmailVerificationUseCase: makeSendEmailVerificationUseCase(), + }); const result = await useCase.execute({ email: 'test@example.com', password: 'password123' }); expect(result).toEqual({ userId: 'user-1', email: 'test@example.com' }); @@ -40,7 +52,11 @@ describe('RegisterUseCase', () => { }); const generateId = vi.fn().mockReturnValue('custom-id'); - const useCase = new RegisterUseCase({ userRepository, generateId }); + const useCase = new RegisterUseCase({ + userRepository, + generateId, + sendEmailVerificationUseCase: makeSendEmailVerificationUseCase(), + }); await useCase.execute({ email: 'test@example.com', password: 'pass' }); expect(generateId).toHaveBeenCalledOnce(); @@ -54,7 +70,11 @@ describe('RegisterUseCase', () => { findByEmail: vi.fn().mockResolvedValue(makeUser()), }); - const useCase = new RegisterUseCase({ userRepository, generateId: vi.fn() }); + const useCase = new RegisterUseCase({ + userRepository, + generateId: vi.fn(), + sendEmailVerificationUseCase: makeSendEmailVerificationUseCase(), + }); const err = await useCase .execute({ email: 'test@example.com', password: 'pass' }) .catch((e) => e); @@ -63,4 +83,45 @@ describe('RegisterUseCase', () => { expect((err as { code: string }).code).toBe('CONFLICT'); expect(userRepository.create).not.toHaveBeenCalled(); }); + + it('sends an email verification for the new user', async () => { + const user = makeUser({ id: 'user-1' }); + const userRepository = makeUserRepository({ + findByEmail: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue(user), + }); + const sendEmailVerificationUseCase = makeSendEmailVerificationUseCase(); + + const useCase = new RegisterUseCase({ + userRepository, + generateId: vi.fn().mockReturnValue('user-1'), + sendEmailVerificationUseCase, + }); + await useCase.execute({ email: 'test@example.com', password: 'password123' }); + + expect(sendEmailVerificationUseCase.execute).toHaveBeenCalledWith('user-1'); + }); + + it('still returns successfully when sending the verification email fails', async () => { + const user = makeUser({ id: 'user-1' }); + const userRepository = makeUserRepository({ + findByEmail: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue(user), + }); + const sendEmailVerificationUseCase = makeSendEmailVerificationUseCase({ + execute: vi.fn().mockRejectedValue(new Error('Brevo is down')), + }); + + const useCase = new RegisterUseCase({ + userRepository, + generateId: vi.fn().mockReturnValue('user-1'), + sendEmailVerificationUseCase, + }); + const result = await useCase.execute({ + email: 'test@example.com', + password: 'password123', + }); + + expect(result).toEqual({ userId: 'user-1', email: 'test@example.com' }); + }); }); diff --git a/apps/api/src/__tests__/application/auth/SendEmailVerificationUseCase.test.ts b/apps/api/src/__tests__/application/auth/SendEmailVerificationUseCase.test.ts new file mode 100644 index 00000000..0dd31358 --- /dev/null +++ b/apps/api/src/__tests__/application/auth/SendEmailVerificationUseCase.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SendEmailVerificationUseCase } from '@/use-cases/auth/SendEmailVerificationUseCase.js'; +import { + makeUserRepository, + makeEmailVerificationTokenRepository, + makeUser, +} from '@/__tests__/helpers/mocks.js'; +import type { IEmailService } from '@/use-cases/ports/IEmailService.js'; + +const makeEmailService = (overrides?: Partial): IEmailService => ({ + sendFollowUpReminder: vi.fn().mockResolvedValue(undefined), + sendWeeklyDigest: vi.fn().mockResolvedValue(undefined), + sendEmailVerification: vi.fn().mockResolvedValue(undefined), + ...overrides, +}); + +const makeDeps = (overrides?: object) => ({ + userRepository: makeUserRepository(), + emailVerificationTokenRepository: makeEmailVerificationTokenRepository(), + emailService: makeEmailService(), + generateId: vi.fn().mockReturnValue('generated-id'), + webAppOrigin: 'http://localhost:3000', + ...overrides, +}); + +describe('SendEmailVerificationUseCase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws NOT_FOUND when the user does not exist', async () => { + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(null) }); + + const err = await new SendEmailVerificationUseCase(makeDeps({ userRepository })) + .execute('missing') + .catch((e) => e); + + expect((err as { code: string }).code).toBe('NOT_FOUND'); + }); + + it('deletes existing tokens, creates a new one, and emails a verification link', async () => { + const user = makeUser({ id: 'user-1', email: 'test@example.com' }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + const emailVerificationTokenRepository = makeEmailVerificationTokenRepository(); + const emailService = makeEmailService(); + + await new SendEmailVerificationUseCase( + makeDeps({ + userRepository, + emailVerificationTokenRepository, + emailService, + webAppOrigin: 'https://app.jobfinder.com', + }), + ).execute('user-1'); + + expect(emailVerificationTokenRepository.deleteAllForUser).toHaveBeenCalledWith('user-1'); + expect(emailVerificationTokenRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ id: 'generated-id', userId: 'user-1' }), + ); + expect(emailService.sendEmailVerification).toHaveBeenCalledWith( + 'test@example.com', + expect.stringMatching(/^https:\/\/app\.jobfinder\.com\/verify-email\?token=[a-f0-9]+$/), + ); + }); + + it('sets an expiry roughly 24 hours in the future', async () => { + const user = makeUser({ id: 'user-1' }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + const emailVerificationTokenRepository = makeEmailVerificationTokenRepository(); + + const before = Date.now(); + await new SendEmailVerificationUseCase( + makeDeps({ userRepository, emailVerificationTokenRepository }), + ).execute('user-1'); + const after = Date.now(); + + const createCall = vi.mocked(emailVerificationTokenRepository.create).mock.calls[0][0]; + const expiresAtMs = createCall.expiresAt.getTime(); + const dayMs = 24 * 60 * 60 * 1000; + expect(expiresAtMs).toBeGreaterThanOrEqual(before + dayMs - 1000); + expect(expiresAtMs).toBeLessThanOrEqual(after + dayMs + 1000); + }); +}); diff --git a/apps/api/src/__tests__/application/auth/VerifyEmailUseCase.test.ts b/apps/api/src/__tests__/application/auth/VerifyEmailUseCase.test.ts new file mode 100644 index 00000000..ba893cb0 --- /dev/null +++ b/apps/api/src/__tests__/application/auth/VerifyEmailUseCase.test.ts @@ -0,0 +1,99 @@ +import { createHash } from 'crypto'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { VerifyEmailUseCase } from '@/use-cases/auth/VerifyEmailUseCase.js'; +import { + makeUserRepository, + makeEmailVerificationTokenRepository, + makeEmailVerificationToken, +} from '@/__tests__/helpers/mocks.js'; + +const RAW_TOKEN = 'raw-verify-token'; +const TOKEN_HASH = createHash('sha256').update(RAW_TOKEN).digest('hex'); + +describe('VerifyEmailUseCase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws UNAUTHORIZED when no token matches the hash', async () => { + const emailVerificationTokenRepository = makeEmailVerificationTokenRepository({ + findByTokenHash: vi.fn().mockResolvedValue(null), + }); + const userRepository = makeUserRepository(); + + const err = await new VerifyEmailUseCase({ + userRepository, + emailVerificationTokenRepository, + }) + .execute({ token: RAW_TOKEN }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + expect(userRepository.update).not.toHaveBeenCalled(); + }); + + it('throws UNAUTHORIZED when the token was already used', async () => { + const verificationToken = makeEmailVerificationToken({ + tokenHash: TOKEN_HASH, + usedAt: new Date('2024-01-01T00:30:00.000Z'), + }); + const emailVerificationTokenRepository = makeEmailVerificationTokenRepository({ + findByTokenHash: vi.fn().mockResolvedValue(verificationToken), + }); + const userRepository = makeUserRepository(); + + const err = await new VerifyEmailUseCase({ + userRepository, + emailVerificationTokenRepository, + }) + .execute({ token: RAW_TOKEN }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + expect(userRepository.update).not.toHaveBeenCalled(); + }); + + it('throws UNAUTHORIZED when the token has expired', async () => { + const verificationToken = makeEmailVerificationToken({ + tokenHash: TOKEN_HASH, + expiresAt: new Date(Date.now() - 1000), + }); + const emailVerificationTokenRepository = makeEmailVerificationTokenRepository({ + findByTokenHash: vi.fn().mockResolvedValue(verificationToken), + }); + const userRepository = makeUserRepository(); + + const err = await new VerifyEmailUseCase({ + userRepository, + emailVerificationTokenRepository, + }) + .execute({ token: RAW_TOKEN }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + expect(userRepository.update).not.toHaveBeenCalled(); + }); + + it('marks the user verified and the token used for a valid token', async () => { + const verificationToken = makeEmailVerificationToken({ + id: 'verify-1', + userId: 'user-1', + tokenHash: TOKEN_HASH, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }); + const emailVerificationTokenRepository = makeEmailVerificationTokenRepository({ + findByTokenHash: vi.fn().mockResolvedValue(verificationToken), + }); + const userRepository = makeUserRepository(); + + await new VerifyEmailUseCase({ userRepository, emailVerificationTokenRepository }).execute({ + token: RAW_TOKEN, + }); + + expect(userRepository.update).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ emailVerifiedAt: expect.any(Date) }), + ); + expect(emailVerificationTokenRepository.markUsed).toHaveBeenCalledWith('verify-1'); + }); +}); diff --git a/apps/api/src/__tests__/application/reminders/SendFollowUpRemindersUseCase.test.ts b/apps/api/src/__tests__/application/reminders/SendFollowUpRemindersUseCase.test.ts index c88b4da1..1de7017f 100644 --- a/apps/api/src/__tests__/application/reminders/SendFollowUpRemindersUseCase.test.ts +++ b/apps/api/src/__tests__/application/reminders/SendFollowUpRemindersUseCase.test.ts @@ -11,6 +11,7 @@ import type { IEmailService } from '@/use-cases/ports/IEmailService.js'; const makeEmailService = (overrides?: Partial): IEmailService => ({ sendFollowUpReminder: vi.fn().mockResolvedValue(undefined), sendWeeklyDigest: vi.fn().mockResolvedValue(undefined), + sendEmailVerification: vi.fn().mockResolvedValue(undefined), ...overrides, }); diff --git a/apps/api/src/__tests__/application/user/UpdateEmailUseCase.test.ts b/apps/api/src/__tests__/application/user/UpdateEmailUseCase.test.ts index f5a1679a..61ae042f 100644 --- a/apps/api/src/__tests__/application/user/UpdateEmailUseCase.test.ts +++ b/apps/api/src/__tests__/application/user/UpdateEmailUseCase.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import bcrypt from 'bcryptjs'; import { UpdateEmailUseCase } from '@/use-cases/user/UpdateEmailUseCase.js'; import { makeUserRepository, makeUser } from '@/__tests__/helpers/mocks.js'; +import type { ISendEmailVerificationUseCase } from '@/use-cases/auth/ISendEmailVerificationUseCase.js'; vi.mock('bcryptjs', () => ({ default: { @@ -10,6 +11,13 @@ vi.mock('bcryptjs', () => ({ }, })); +const makeSendEmailVerificationUseCase = ( + overrides?: Partial, +): ISendEmailVerificationUseCase => ({ + execute: vi.fn().mockResolvedValue(undefined), + ...overrides, +}); + describe('UpdateEmailUseCase', () => { beforeEach(() => { vi.clearAllMocks(); @@ -30,12 +38,18 @@ describe('UpdateEmailUseCase', () => { }); vi.mocked(bcrypt.compare).mockResolvedValue(true as never); - await new UpdateEmailUseCase({ userRepository }).execute(input); + await new UpdateEmailUseCase({ + userRepository, + sendEmailVerificationUseCase: makeSendEmailVerificationUseCase(), + }).execute(input); expect(userRepository.findById).toHaveBeenCalledWith('user-1'); expect(bcrypt.compare).toHaveBeenCalledWith(input.currentPassword, user.passwordHash); expect(userRepository.findByEmail).toHaveBeenCalledWith(input.newEmail); - expect(userRepository.update).toHaveBeenCalledWith('user-1', { email: input.newEmail }); + expect(userRepository.update).toHaveBeenCalledWith('user-1', { + email: input.newEmail, + emailVerifiedAt: null, + }); }); it('throws NOT_FOUND when user does not exist', async () => { @@ -43,7 +57,12 @@ describe('UpdateEmailUseCase', () => { findById: vi.fn().mockResolvedValue(null), }); - const err = await new UpdateEmailUseCase({ userRepository }).execute(input).catch((e) => e); + const err = await new UpdateEmailUseCase({ + userRepository, + sendEmailVerificationUseCase: makeSendEmailVerificationUseCase(), + }) + .execute(input) + .catch((e) => e); expect((err as { code: string }).code).toBe('NOT_FOUND'); expect(userRepository.update).not.toHaveBeenCalled(); @@ -56,7 +75,12 @@ describe('UpdateEmailUseCase', () => { }); vi.mocked(bcrypt.compare).mockResolvedValue(false as never); - const err = await new UpdateEmailUseCase({ userRepository }).execute(input).catch((e) => e); + const err = await new UpdateEmailUseCase({ + userRepository, + sendEmailVerificationUseCase: makeSendEmailVerificationUseCase(), + }) + .execute(input) + .catch((e) => e); expect((err as { code: string }).code).toBe('UNAUTHORIZED'); expect(userRepository.update).not.toHaveBeenCalled(); @@ -71,7 +95,12 @@ describe('UpdateEmailUseCase', () => { }); vi.mocked(bcrypt.compare).mockResolvedValue(true as never); - const err = await new UpdateEmailUseCase({ userRepository }).execute(input).catch((e) => e); + const err = await new UpdateEmailUseCase({ + userRepository, + sendEmailVerificationUseCase: makeSendEmailVerificationUseCase(), + }) + .execute(input) + .catch((e) => e); expect((err as { code: string }).code).toBe('CONFLICT'); expect(userRepository.update).not.toHaveBeenCalled(); @@ -87,8 +116,43 @@ describe('UpdateEmailUseCase', () => { vi.mocked(bcrypt.compare).mockResolvedValue(true as never); await expect( - new UpdateEmailUseCase({ userRepository }).execute(input), + new UpdateEmailUseCase({ + userRepository, + sendEmailVerificationUseCase: makeSendEmailVerificationUseCase(), + }).execute(input), ).resolves.toBeUndefined(); expect(userRepository.update).toHaveBeenCalled(); }); + + it('sends a new verification email for the updated address', async () => { + const user = makeUser({ id: 'user-1', email: 'old@example.com' }); + const userRepository = makeUserRepository({ + findById: vi.fn().mockResolvedValue(user), + findByEmail: vi.fn().mockResolvedValue(null), + update: vi.fn().mockResolvedValue({ ...user, email: input.newEmail }), + }); + vi.mocked(bcrypt.compare).mockResolvedValue(true as never); + const sendEmailVerificationUseCase = makeSendEmailVerificationUseCase(); + + await new UpdateEmailUseCase({ userRepository, sendEmailVerificationUseCase }).execute(input); + + expect(sendEmailVerificationUseCase.execute).toHaveBeenCalledWith('user-1'); + }); + + it('still succeeds when sending the verification email fails', async () => { + const user = makeUser({ id: 'user-1', email: 'old@example.com' }); + const userRepository = makeUserRepository({ + findById: vi.fn().mockResolvedValue(user), + findByEmail: vi.fn().mockResolvedValue(null), + update: vi.fn().mockResolvedValue({ ...user, email: input.newEmail }), + }); + vi.mocked(bcrypt.compare).mockResolvedValue(true as never); + const sendEmailVerificationUseCase = makeSendEmailVerificationUseCase({ + execute: vi.fn().mockRejectedValue(new Error('Brevo is down')), + }); + + await expect( + new UpdateEmailUseCase({ userRepository, sendEmailVerificationUseCase }).execute(input), + ).resolves.toBeUndefined(); + }); }); diff --git a/apps/api/src/__tests__/digest/SendWeeklyDigestUseCase.test.ts b/apps/api/src/__tests__/digest/SendWeeklyDigestUseCase.test.ts index f9e2d9e7..21cc1511 100644 --- a/apps/api/src/__tests__/digest/SendWeeklyDigestUseCase.test.ts +++ b/apps/api/src/__tests__/digest/SendWeeklyDigestUseCase.test.ts @@ -12,6 +12,7 @@ function makeEmailService(): IEmailService { return { sendFollowUpReminder: vi.fn().mockResolvedValue(undefined), sendWeeklyDigest: vi.fn().mockResolvedValue(undefined), + sendEmailVerification: vi.fn().mockResolvedValue(undefined), }; } diff --git a/apps/api/src/__tests__/helpers/createTestDb.ts b/apps/api/src/__tests__/helpers/createTestDb.ts index c98c48b0..f3844a22 100644 --- a/apps/api/src/__tests__/helpers/createTestDb.ts +++ b/apps/api/src/__tests__/helpers/createTestDb.ts @@ -12,6 +12,7 @@ const SCHEMA_STATEMENTS = [ "name" TEXT, "timezone" TEXT, "targetRole" TEXT, + "emailVerifiedAt" DATETIME, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`, @@ -115,6 +116,16 @@ const SCHEMA_STATEMENTS = [ FOREIGN KEY ("applicationId") REFERENCES "JobApplication"("id") ON DELETE CASCADE )`, `CREATE INDEX "Contact_applicationId_idx" ON "Contact"("applicationId")`, + `CREATE TABLE "EmailVerificationToken" ( + "id" TEXT PRIMARY KEY, + "userId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL UNIQUE, + "expiresAt" DATETIME NOT NULL, + "usedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE + )`, + `CREATE INDEX "EmailVerificationToken_userId_idx" ON "EmailVerificationToken"("userId")`, ]; export interface TestDb { diff --git a/apps/api/src/__tests__/helpers/mocks.ts b/apps/api/src/__tests__/helpers/mocks.ts index cdb5d6f0..40101bf2 100644 --- a/apps/api/src/__tests__/helpers/mocks.ts +++ b/apps/api/src/__tests__/helpers/mocks.ts @@ -10,6 +10,8 @@ import type { IInterviewRoundRepository } from '@/use-cases/ports/IInterviewRoun import type { IActivityLogRepository } from '@/use-cases/ports/IActivityLogRepository.js'; import type { IContactRepository } from '@/use-cases/ports/IContactRepository.js'; import type { IStorageProvider } from '@/use-cases/ports/IStorageProvider.js'; +import type { IEmailVerificationTokenRepository } from '@/use-cases/ports/IEmailVerificationTokenRepository.js'; +import type { EmailVerificationToken } from '@/domain/emailVerificationToken/EmailVerificationToken.js'; import type { User } from '@/domain/user/User.js'; import type { Application } from '@/domain/application/Application.js'; import type { Document } from '@/domain/document/Document.js'; @@ -108,6 +110,28 @@ export const makeApiToken = (overrides?: Partial): ApiToken => ({ ...overrides, }); +export const makeEmailVerificationTokenRepository = ( + overrides?: Partial, +): IEmailVerificationTokenRepository => ({ + create: vi.fn(), + findByTokenHash: vi.fn().mockResolvedValue(null), + markUsed: vi.fn().mockResolvedValue(undefined), + deleteAllForUser: vi.fn().mockResolvedValue(undefined), + ...overrides, +}); + +export const makeEmailVerificationToken = ( + overrides?: Partial, +): EmailVerificationToken => ({ + id: 'verify-token-1', + userId: 'user-1', + tokenHash: 'hashed-verify-token', + expiresAt: new Date('2024-01-02T00:00:00.000Z'), + usedAt: null, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + ...overrides, +}); + // Minimal FastifyInstance stub for AuthResolver (only jwt is used) export const makeFastifyJwt = (): { jwt: { sign: ReturnType; verify: ReturnType }; @@ -126,6 +150,7 @@ export const makeUser = (overrides?: Partial): User => ({ name: null, timezone: null, targetRole: null, + emailVerifiedAt: null, createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), ...overrides, diff --git a/apps/api/src/__tests__/infrastructure/db/repositories/PrismaEmailVerificationTokenRepository.test.ts b/apps/api/src/__tests__/infrastructure/db/repositories/PrismaEmailVerificationTokenRepository.test.ts new file mode 100644 index 00000000..17396b5b --- /dev/null +++ b/apps/api/src/__tests__/infrastructure/db/repositories/PrismaEmailVerificationTokenRepository.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaEmailVerificationTokenRepository } from '@/infrastructure/db/repositories/PrismaEmailVerificationTokenRepository.js'; +import { createTestDb, type TestDb } from '@/__tests__/helpers/createTestDb.js'; + +describe('PrismaEmailVerificationTokenRepository', () => { + let db: TestDb; + let repo: PrismaEmailVerificationTokenRepository; + + beforeAll(async () => { + db = await createTestDb(); + repo = new PrismaEmailVerificationTokenRepository({ prisma: db.prisma }); + }); + + afterAll(() => db.cleanup()); + + beforeEach(async () => { + await db.prisma.emailVerificationToken.deleteMany(); + await db.prisma.user.deleteMany(); + await db.prisma.user.create({ + data: { id: 'user-1', email: 'a@b.com', passwordHash: 'hashed' }, + }); + }); + + describe('create', () => { + it('persists a token and returns the entity', async () => { + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + const token = await repo.create({ + id: 'verify-1', + userId: 'user-1', + tokenHash: 'hash-1', + expiresAt, + }); + + expect(token.id).toBe('verify-1'); + expect(token.userId).toBe('user-1'); + expect(token.tokenHash).toBe('hash-1'); + expect(token.expiresAt).toEqual(expiresAt); + expect(token.usedAt).toBeNull(); + expect(token.createdAt).toBeInstanceOf(Date); + }); + }); + + describe('findByTokenHash', () => { + it('returns the token when it exists', async () => { + await repo.create({ + id: 'verify-1', + userId: 'user-1', + tokenHash: 'hash-1', + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }); + + const found = await repo.findByTokenHash('hash-1'); + expect(found?.id).toBe('verify-1'); + }); + + it('returns null when no token matches', async () => { + const found = await repo.findByTokenHash('missing-hash'); + expect(found).toBeNull(); + }); + }); + + describe('markUsed', () => { + it('sets usedAt on the token', async () => { + await repo.create({ + id: 'verify-1', + userId: 'user-1', + tokenHash: 'hash-1', + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }); + + await repo.markUsed('verify-1'); + + const found = await repo.findByTokenHash('hash-1'); + expect(found?.usedAt).toBeInstanceOf(Date); + }); + }); + + describe('deleteAllForUser', () => { + it('removes all tokens for the given user', async () => { + await repo.create({ + id: 'verify-1', + userId: 'user-1', + tokenHash: 'hash-1', + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }); + await repo.create({ + id: 'verify-2', + userId: 'user-1', + tokenHash: 'hash-2', + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }); + + await repo.deleteAllForUser('user-1'); + + expect(await repo.findByTokenHash('hash-1')).toBeNull(); + expect(await repo.findByTokenHash('hash-2')).toBeNull(); + }); + + it('does not affect tokens belonging to other users', async () => { + await db.prisma.user.create({ + data: { id: 'user-2', email: 'c@d.com', passwordHash: 'hashed' }, + }); + await repo.create({ + id: 'verify-1', + userId: 'user-1', + tokenHash: 'hash-1', + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }); + await repo.create({ + id: 'verify-2', + userId: 'user-2', + tokenHash: 'hash-2', + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }); + + await repo.deleteAllForUser('user-1'); + + expect(await repo.findByTokenHash('hash-1')).toBeNull(); + expect(await repo.findByTokenHash('hash-2')).not.toBeNull(); + }); + }); +}); diff --git a/apps/api/src/__tests__/infrastructure/db/repositories/PrismaUserRepository.test.ts b/apps/api/src/__tests__/infrastructure/db/repositories/PrismaUserRepository.test.ts index 5dc284b3..48837606 100644 --- a/apps/api/src/__tests__/infrastructure/db/repositories/PrismaUserRepository.test.ts +++ b/apps/api/src/__tests__/infrastructure/db/repositories/PrismaUserRepository.test.ts @@ -28,6 +28,11 @@ describe('PrismaUserRepository', () => { expect(user.updatedAt).toBeInstanceOf(Date); }); + it('defaults emailVerifiedAt to null', async () => { + const user = await repo.create({ id: 'u1', email: 'a@b.com', passwordHash: 'hashed' }); + expect(user.emailVerifiedAt).toBeNull(); + }); + it('defaults profile fields to null', async () => { const user = await repo.create({ id: 'u1', email: 'a@b.com', passwordHash: 'hashed' }); @@ -109,6 +114,22 @@ describe('PrismaUserRepository', () => { expect(updated.updatedAt).toBeInstanceOf(Date); }); + it('sets emailVerifiedAt', async () => { + await repo.create({ id: 'u1', email: 'a@b.com', passwordHash: 'hashed' }); + const verifiedAt = new Date(); + const updated = await repo.update('u1', { emailVerifiedAt: verifiedAt }); + + expect(updated.emailVerifiedAt).toEqual(verifiedAt); + }); + + it('clears emailVerifiedAt when given null', async () => { + await repo.create({ id: 'u1', email: 'a@b.com', passwordHash: 'hashed' }); + await repo.update('u1', { emailVerifiedAt: new Date() }); + const updated = await repo.update('u1', { emailVerifiedAt: null }); + + expect(updated.emailVerifiedAt).toBeNull(); + }); + it('updates name, timezone, and targetRole', async () => { await repo.create({ id: 'u1', email: 'a@b.com', passwordHash: 'hashed' }); const updated = await repo.update('u1', { diff --git a/apps/api/src/__tests__/infrastructure/email/BrevoEmailService.test.ts b/apps/api/src/__tests__/infrastructure/email/BrevoEmailService.test.ts index d2c08978..3981f9f8 100644 --- a/apps/api/src/__tests__/infrastructure/email/BrevoEmailService.test.ts +++ b/apps/api/src/__tests__/infrastructure/email/BrevoEmailService.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { BrevoEmailService } from '@/infrastructure/email/BrevoEmailService.js'; import { buildWeeklyDigestHtml } from '@/infrastructure/email/templates/weeklyDigestTemplate.js'; +import { buildEmailVerificationHtml } from '@/infrastructure/email/templates/emailVerificationTemplate.js'; import { ENV, EMAIL } from '@/constants.js'; import type { WeeklyDigestData } from '@/use-cases/ports/IEmailService.js'; @@ -159,4 +160,32 @@ describe('BrevoEmailService', () => { ); }); }); + + describe('sendEmailVerification', () => { + const verifyUrl = 'https://app.jobfinder.com/verify-email?token=abc123'; + + it('posts a subject and htmlContent built from the verify URL', async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse(true, 200) as never); + const service = new BrevoEmailService(); + + await service.sendEmailVerification('user@example.com', verifyUrl); + + const [url, options] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(options.body as string); + + expect(url).toBe(EMAIL.BREVO_API_URL); + expect(body.to).toEqual([{ email: 'user@example.com' }]); + expect(body.subject).toBe('Verify your Job Finder email'); + expect(body.htmlContent).toBe(buildEmailVerificationHtml(verifyUrl)); + }); + + it('throws with the status and body when the response fails', async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse(false, 503, 'unavailable') as never); + const service = new BrevoEmailService(); + + await expect(service.sendEmailVerification('user@example.com', verifyUrl)).rejects.toThrow( + /Brevo API error 503/, + ); + }); + }); }); diff --git a/apps/api/src/__tests__/infrastructure/email/templates/emailVerificationTemplate.test.ts b/apps/api/src/__tests__/infrastructure/email/templates/emailVerificationTemplate.test.ts new file mode 100644 index 00000000..f35e01e3 --- /dev/null +++ b/apps/api/src/__tests__/infrastructure/email/templates/emailVerificationTemplate.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { buildEmailVerificationHtml } from '@/infrastructure/email/templates/emailVerificationTemplate.js'; + +describe('buildEmailVerificationHtml', () => { + it('includes the verify URL as the button link', () => { + const html = buildEmailVerificationHtml('https://app.jobfinder.com/verify-email?token=abc123'); + + expect(html).toContain('href="https://app.jobfinder.com/verify-email?token=abc123"'); + }); + + it('mentions the 24 hour expiry and includes safe-to-ignore copy', () => { + const html = buildEmailVerificationHtml('https://app.jobfinder.com/verify-email?token=abc123'); + + expect(html).toContain('expires in 24 hours'); + expect(html).toContain('you can safely ignore this email'); + }); +}); diff --git a/apps/api/src/__tests__/interface-adapters/resolvers/AuthResolver.test.ts b/apps/api/src/__tests__/interface-adapters/resolvers/AuthResolver.test.ts index 111da1a5..10e70b32 100644 --- a/apps/api/src/__tests__/interface-adapters/resolvers/AuthResolver.test.ts +++ b/apps/api/src/__tests__/interface-adapters/resolvers/AuthResolver.test.ts @@ -3,6 +3,7 @@ import { AuthResolver } from '@/interface-adapters/resolvers/AuthResolver.js'; import { makeUser } from '@/__tests__/helpers/mocks.js'; import type { IRegisterUseCase } from '@/use-cases/auth/IRegisterUseCase.js'; import type { ILoginUseCase } from '@/use-cases/auth/ILoginUseCase.js'; +import type { IVerifyEmailUseCase } from '@/use-cases/auth/IVerifyEmailUseCase.js'; import type { ITokenService } from '@/use-cases/ports/ITokenService.js'; const makeRegisterUseCase = (overrides?: Partial): IRegisterUseCase => ({ @@ -15,12 +16,24 @@ const makeLoginUseCase = (overrides?: Partial): ILoginUseCase => ...overrides, }); +const makeVerifyEmailUseCase = (overrides?: Partial): IVerifyEmailUseCase => ({ + execute: vi.fn().mockResolvedValue(undefined), + ...overrides, +}); + const makeTokenService = (overrides?: Partial): ITokenService => ({ sign: vi.fn().mockReturnValue({ accessToken: 'access-token', refreshToken: 'refresh-token' }), verifyRefresh: vi.fn(), ...overrides, }); +const baseDeps = () => ({ + registerUseCase: makeRegisterUseCase(), + loginUseCase: makeLoginUseCase(), + tokenService: makeTokenService(), + verifyEmailUseCase: makeVerifyEmailUseCase(), +}); + describe('AuthResolver', () => { beforeEach(() => { vi.clearAllMocks(); @@ -33,11 +46,7 @@ describe('AuthResolver', () => { }); const tokenService = makeTokenService(); - const resolver = new AuthResolver({ - registerUseCase, - loginUseCase: makeLoginUseCase(), - tokenService, - }); + const resolver = new AuthResolver({ ...baseDeps(), registerUseCase, tokenService }); const result = await resolver.register('test@example.com', 'password123'); @@ -58,11 +67,7 @@ describe('AuthResolver', () => { }); const tokenService = makeTokenService(); - const resolver = new AuthResolver({ - registerUseCase: makeRegisterUseCase(), - loginUseCase, - tokenService, - }); + const resolver = new AuthResolver({ ...baseDeps(), loginUseCase, tokenService }); const result = await resolver.login('test@example.com', 'password123'); @@ -81,11 +86,7 @@ describe('AuthResolver', () => { verifyRefresh: vi.fn().mockReturnValue({ sub: 'user-1', email: 'test@example.com' }), }); - const resolver = new AuthResolver({ - registerUseCase: makeRegisterUseCase(), - loginUseCase: makeLoginUseCase(), - tokenService, - }); + const resolver = new AuthResolver({ ...baseDeps(), tokenService }); const result = resolver.refreshToken('valid-refresh-token'); @@ -101,11 +102,7 @@ describe('AuthResolver', () => { }), }); - const resolver = new AuthResolver({ - registerUseCase: makeRegisterUseCase(), - loginUseCase: makeLoginUseCase(), - tokenService, - }); + const resolver = new AuthResolver({ ...baseDeps(), tokenService }); const err = (() => { try { @@ -119,4 +116,29 @@ describe('AuthResolver', () => { expect((err as { code: string }).code).toBe('UNAUTHORIZED'); }); }); + + describe('verifyEmail', () => { + it('delegates to verifyEmailUseCase with the given token', async () => { + const verifyEmailUseCase = makeVerifyEmailUseCase(); + const resolver = new AuthResolver({ ...baseDeps(), verifyEmailUseCase }); + + await resolver.verifyEmail('raw-token'); + + expect(verifyEmailUseCase.execute).toHaveBeenCalledWith({ token: 'raw-token' }); + }); + + it('propagates errors from the use case', async () => { + const err = Object.assign(new Error('Invalid or expired verification link'), { + code: 'UNAUTHORIZED', + }); + const verifyEmailUseCase = makeVerifyEmailUseCase({ + execute: vi.fn().mockRejectedValue(err), + }); + const resolver = new AuthResolver({ ...baseDeps(), verifyEmailUseCase }); + + await expect(resolver.verifyEmail('bad-token')).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); }); diff --git a/apps/api/src/__tests__/security/authorizationGuards.test.ts b/apps/api/src/__tests__/security/authorizationGuards.test.ts index 4068989a..ea562900 100644 --- a/apps/api/src/__tests__/security/authorizationGuards.test.ts +++ b/apps/api/src/__tests__/security/authorizationGuards.test.ts @@ -11,6 +11,7 @@ import { UpdateEmailUseCase } from '@/use-cases/user/UpdateEmailUseCase.js'; import { UpdatePasswordUseCase } from '@/use-cases/user/UpdatePasswordUseCase.js'; import { DeleteAccountUseCase } from '@/use-cases/user/DeleteAccountUseCase.js'; import { makeUserRepository, makeUser } from '@/__tests__/helpers/mocks.js'; +import type { ISendEmailVerificationUseCase } from '@/use-cases/auth/ISendEmailVerificationUseCase.js'; vi.mock('bcryptjs', () => ({ default: { @@ -22,6 +23,10 @@ vi.mock('bcryptjs', () => ({ const WRONG_PW = false as never; const RIGHT_PW = true as never; +const sendEmailVerificationUseCase: ISendEmailVerificationUseCase = { + execute: vi.fn().mockResolvedValue(undefined), +}; + describe('Authorization guards', () => { beforeEach(() => { vi.clearAllMocks(); @@ -31,7 +36,10 @@ describe('Authorization guards', () => { const notFound = makeUserRepository({ findById: vi.fn().mockResolvedValue(null) }); it('UpdateEmailUseCase', async () => { - const err = await new UpdateEmailUseCase({ userRepository: notFound }) + const err = await new UpdateEmailUseCase({ + userRepository: notFound, + sendEmailVerificationUseCase, + }) .execute({ userId: 'x', currentPassword: 'p', newEmail: 'e@e.com' }) .catch((e) => e); expect((err as { code: string }).code).toBe('NOT_FOUND'); @@ -61,7 +69,10 @@ describe('Authorization guards', () => { }); it('UpdateEmailUseCase does not update on wrong password', async () => { - const err = await new UpdateEmailUseCase({ userRepository: repo }) + const err = await new UpdateEmailUseCase({ + userRepository: repo, + sendEmailVerificationUseCase, + }) .execute({ userId: 'user-1', currentPassword: 'wrong', newEmail: 'e@e.com' }) .catch((e) => e); @@ -100,7 +111,7 @@ describe('Authorization guards', () => { vi.mocked(bcrypt.compare).mockResolvedValue(RIGHT_PW); await expect( - new UpdateEmailUseCase({ userRepository: repo }).execute({ + new UpdateEmailUseCase({ userRepository: repo, sendEmailVerificationUseCase }).execute({ userId: 'user-1', currentPassword: 'correct', newEmail: 'new@example.com', diff --git a/apps/api/src/constants.ts b/apps/api/src/constants.ts index d56a2662..58630606 100644 --- a/apps/api/src/constants.ts +++ b/apps/api/src/constants.ts @@ -89,6 +89,14 @@ export const API_TOKEN_SCOPE = { READ: 'read', } as const; +/** Email-verification token settings. */ +export const EMAIL_VERIFICATION_TOKEN = { + /** Number of random bytes hex-encoded into the token body. */ + RANDOM_BYTES: 32, + /** How long a verification link stays valid, in milliseconds. */ + TTL_MS: 24 * 60 * 60 * 1000, // 24 hours +} as const; + /** HTTP Authorization header. */ export const AUTH_HEADER = { BEARER_PREFIX: 'Bearer ', diff --git a/apps/api/src/domain/emailVerificationToken/EmailVerificationToken.ts b/apps/api/src/domain/emailVerificationToken/EmailVerificationToken.ts new file mode 100644 index 00000000..ebf9feb5 --- /dev/null +++ b/apps/api/src/domain/emailVerificationToken/EmailVerificationToken.ts @@ -0,0 +1,8 @@ +export interface EmailVerificationToken { + id: string; + userId: string; + tokenHash: string; + expiresAt: Date; + usedAt: Date | null; + createdAt: Date; +} diff --git a/apps/api/src/domain/user/User.ts b/apps/api/src/domain/user/User.ts index 9f08e137..5f7e3811 100644 --- a/apps/api/src/domain/user/User.ts +++ b/apps/api/src/domain/user/User.ts @@ -5,6 +5,7 @@ export interface User { name: string | null; timezone: string | null; targetRole: string | null; + emailVerifiedAt: Date | null; createdAt: Date; updatedAt: Date; } diff --git a/apps/api/src/http/container.ts b/apps/api/src/http/container.ts index 2da514c2..a9616e02 100644 --- a/apps/api/src/http/container.ts +++ b/apps/api/src/http/container.ts @@ -16,6 +16,7 @@ import { PrismaInterviewRoundRepository } from '@/infrastructure/db/repositories import { CachedInterviewRoundRepository } from '@/infrastructure/db/repositories/CachedInterviewRoundRepository.js'; import { PrismaActivityLogRepository } from '@/infrastructure/db/repositories/PrismaActivityLogRepository.js'; import { PrismaContactRepository } from '@/infrastructure/db/repositories/PrismaContactRepository.js'; +import { PrismaEmailVerificationTokenRepository } from '@/infrastructure/db/repositories/PrismaEmailVerificationTokenRepository.js'; import { LocalStorageProvider } from '@/infrastructure/storage/LocalStorageProvider.js'; import { GCSStorageProvider } from '@/infrastructure/storage/GCSStorageProvider.js'; @@ -39,6 +40,8 @@ import { McpController } from '@/interface-adapters/mcp/McpController.js'; import { RegisterUseCase } from '@/use-cases/auth/RegisterUseCase.js'; import { LoginUseCase } from '@/use-cases/auth/LoginUseCase.js'; +import { SendEmailVerificationUseCase } from '@/use-cases/auth/SendEmailVerificationUseCase.js'; +import { VerifyEmailUseCase } from '@/use-cases/auth/VerifyEmailUseCase.js'; import { CreateApplicationUseCase } from '@/use-cases/jobs/CreateApplicationUseCase.js'; import { GetApplicationsUseCase } from '@/use-cases/jobs/GetApplicationsUseCase.js'; import { GetApplicationUseCase } from '@/use-cases/jobs/GetApplicationUseCase.js'; @@ -94,6 +97,7 @@ declare module '@fastify/awilix' { prisma: typeof prisma; storageProvider: LocalStorageProvider | GCSStorageProvider; generateId: () => string; + webAppOrigin: string; fastify: FastifyInstance; tokenService: FastifyJwtTokenService; cache: MemoryCache; @@ -113,6 +117,7 @@ declare module '@fastify/awilix' { activityLogRepository: PrismaActivityLogRepository; apiTokenRepository: PrismaApiTokenRepository; contactRepository: PrismaContactRepository; + emailVerificationTokenRepository: PrismaEmailVerificationTokenRepository; applicationMapper: ApplicationMapper; apiTokenMapper: ApiTokenMapper; @@ -134,6 +139,8 @@ declare module '@fastify/awilix' { registerUseCase: RegisterUseCase; loginUseCase: LoginUseCase; + sendEmailVerificationUseCase: SendEmailVerificationUseCase; + verifyEmailUseCase: VerifyEmailUseCase; createApplicationUseCase: CreateApplicationUseCase; getApplicationsUseCase: GetApplicationsUseCase; getApplicationUseCase: GetApplicationUseCase; @@ -195,6 +202,9 @@ export function buildContainer(fastify: FastifyInstance): void { prisma: asValue(prisma), storageProvider: asClass(StorageProvider, { lifetime: Lifetime.SINGLETON }), generateId: asValue(() => nanoid()), + webAppOrigin: asValue( + process.env[ENV.CORS_ORIGIN]?.split(',')[0]?.trim() ?? 'http://localhost:3000', + ), fastify: asValue(fastify), tokenService: asClass(FastifyJwtTokenService, { lifetime: Lifetime.SINGLETON }), cache: asValue(new MemoryCache()), @@ -223,6 +233,9 @@ export function buildContainer(fastify: FastifyInstance): void { activityLogRepository: asClass(PrismaActivityLogRepository, { lifetime: Lifetime.SINGLETON }), apiTokenRepository: asClass(PrismaApiTokenRepository, { lifetime: Lifetime.SINGLETON }), contactRepository: asClass(PrismaContactRepository, { lifetime: Lifetime.SINGLETON }), + emailVerificationTokenRepository: asClass(PrismaEmailVerificationTokenRepository, { + lifetime: Lifetime.SINGLETON, + }), // Mappers applicationMapper: asClass(ApplicationMapper, { lifetime: Lifetime.SINGLETON }), @@ -247,6 +260,10 @@ export function buildContainer(fastify: FastifyInstance): void { // Use Cases registerUseCase: asClass(RegisterUseCase, { lifetime: Lifetime.TRANSIENT }), loginUseCase: asClass(LoginUseCase, { lifetime: Lifetime.TRANSIENT }), + sendEmailVerificationUseCase: asClass(SendEmailVerificationUseCase, { + lifetime: Lifetime.TRANSIENT, + }), + verifyEmailUseCase: asClass(VerifyEmailUseCase, { lifetime: Lifetime.TRANSIENT }), createApplicationUseCase: asClass(CreateApplicationUseCase, { lifetime: Lifetime.TRANSIENT, }), diff --git a/apps/api/src/http/schema/mutations/authMutations.ts b/apps/api/src/http/schema/mutations/authMutations.ts index fca102a9..a3f78eb2 100644 --- a/apps/api/src/http/schema/mutations/authMutations.ts +++ b/apps/api/src/http/schema/mutations/authMutations.ts @@ -1,6 +1,7 @@ import { GraphQLError } from 'graphql'; import { builder } from '@/http/schema/builder.js'; import { setAuthCookies, clearAuthCookies } from '@/http/schema/types/AuthPayloadType.js'; +import { fromCodedError } from '@/http/errors/AppError.js'; import { ERROR_CODES } from '@/constants.js'; builder.mutationField('register', (t) => @@ -57,3 +58,20 @@ builder.mutationField('logout', (t) => }, }), ); + +builder.mutationField('verifyEmail', (t) => + t.boolean({ + args: { + token: t.arg.string({ required: true }), + }, + resolve: async (_root, args, ctx) => { + const { authResolver } = ctx.diScope.cradle; + try { + await authResolver.verifyEmail(args.token); + return true; + } catch (err) { + throw fromCodedError(err); + } + }, + }), +); diff --git a/apps/api/src/infrastructure/db/repositories/PrismaEmailVerificationTokenRepository.ts b/apps/api/src/infrastructure/db/repositories/PrismaEmailVerificationTokenRepository.ts new file mode 100644 index 00000000..d522707d --- /dev/null +++ b/apps/api/src/infrastructure/db/repositories/PrismaEmailVerificationTokenRepository.ts @@ -0,0 +1,62 @@ +import type { PrismaClient } from '@prisma/client'; +import type { EmailVerificationToken } from '@/domain/emailVerificationToken/EmailVerificationToken.js'; +import type { IEmailVerificationTokenRepository } from '@/use-cases/ports/IEmailVerificationTokenRepository.js'; +import { getClient } from '../transactionContext.js'; + +type PrismaEmailVerificationToken = { + id: string; + userId: string; + tokenHash: string; + expiresAt: Date; + usedAt: Date | null; + createdAt: Date; +}; + +export class PrismaEmailVerificationTokenRepository implements IEmailVerificationTokenRepository { + private readonly prisma: PrismaClient; + + constructor({ prisma }: { prisma: PrismaClient }) { + this.prisma = prisma; + } + + private get db(): PrismaClient { + return getClient(this.prisma); + } + + async create(data: { + id: string; + userId: string; + tokenHash: string; + expiresAt: Date; + }): Promise { + const row = await this.db.emailVerificationToken.create({ data }); + return this.toEntity(row); + } + + async findByTokenHash(tokenHash: string): Promise { + const row = await this.db.emailVerificationToken.findUnique({ where: { tokenHash } }); + return row ? this.toEntity(row) : null; + } + + async markUsed(id: string): Promise { + await this.db.emailVerificationToken.update({ + where: { id }, + data: { usedAt: new Date() }, + }); + } + + async deleteAllForUser(userId: string): Promise { + await this.db.emailVerificationToken.deleteMany({ where: { userId } }); + } + + private toEntity(row: PrismaEmailVerificationToken): EmailVerificationToken { + return { + id: row.id, + userId: row.userId, + tokenHash: row.tokenHash, + expiresAt: row.expiresAt, + usedAt: row.usedAt, + createdAt: row.createdAt, + }; + } +} diff --git a/apps/api/src/infrastructure/db/repositories/PrismaUserRepository.ts b/apps/api/src/infrastructure/db/repositories/PrismaUserRepository.ts index 85c3940b..db6ddd5b 100644 --- a/apps/api/src/infrastructure/db/repositories/PrismaUserRepository.ts +++ b/apps/api/src/infrastructure/db/repositories/PrismaUserRepository.ts @@ -42,6 +42,7 @@ export class PrismaUserRepository implements IUserRepository { name?: string | null; timezone?: string | null; targetRole?: string | null; + emailVerifiedAt?: Date | null; }, ): Promise { const row = await this.db.user.update({ where: { id }, data }); @@ -59,6 +60,7 @@ export class PrismaUserRepository implements IUserRepository { name: string | null; timezone: string | null; targetRole: string | null; + emailVerifiedAt: Date | null; createdAt: Date; updatedAt: Date; }): User { @@ -69,6 +71,7 @@ export class PrismaUserRepository implements IUserRepository { name: row.name, timezone: row.timezone, targetRole: row.targetRole, + emailVerifiedAt: row.emailVerifiedAt, createdAt: row.createdAt, updatedAt: row.updatedAt, }; diff --git a/apps/api/src/infrastructure/email/BrevoEmailService.ts b/apps/api/src/infrastructure/email/BrevoEmailService.ts index b55ab861..96d23c78 100644 --- a/apps/api/src/infrastructure/email/BrevoEmailService.ts +++ b/apps/api/src/infrastructure/email/BrevoEmailService.ts @@ -1,5 +1,6 @@ import type { IEmailService, WeeklyDigestData } from '@/use-cases/ports/IEmailService.js'; import { buildWeeklyDigestHtml } from './templates/weeklyDigestTemplate.js'; +import { buildEmailVerificationHtml } from './templates/emailVerificationTemplate.js'; import { EMAIL, ENV } from '@/constants.js'; export class BrevoEmailService implements IEmailService { @@ -63,4 +64,22 @@ export class BrevoEmailService implements IEmailService { throw new Error(`Brevo API error ${response.status}: ${body}`); } } + + async sendEmailVerification(to: string, verifyUrl: string): Promise { + const htmlContent = buildEmailVerificationHtml(verifyUrl); + const response = await fetch(EMAIL.BREVO_API_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'api-key': this.apiKey }, + body: JSON.stringify({ + sender: { name: this.fromName, email: this.fromEmail }, + to: [{ email: to }], + subject: 'Verify your Job Finder email', + htmlContent, + }), + }); + if (!response.ok && response.status !== 201) { + const body = await response.text(); + throw new Error(`Brevo API error ${response.status}: ${body}`); + } + } } diff --git a/apps/api/src/infrastructure/email/templates/emailVerificationTemplate.ts b/apps/api/src/infrastructure/email/templates/emailVerificationTemplate.ts new file mode 100644 index 00000000..a2892be6 --- /dev/null +++ b/apps/api/src/infrastructure/email/templates/emailVerificationTemplate.ts @@ -0,0 +1,30 @@ +export function buildEmailVerificationHtml(verifyUrl: string): string { + return ` + + + +
+ +
+

Verify your email

+
+ + +
+

+ Confirm this is your email address to finish setting up your Job Finder account. Click + the button below. This link expires in 24 hours. +

+

+ + Verify email + +

+

+ If you didn't create this account, you can safely ignore this email. +

+
+
+ +`; +} diff --git a/apps/api/src/interface-adapters/resolvers/AuthResolver.ts b/apps/api/src/interface-adapters/resolvers/AuthResolver.ts index 252fd856..b6763ed0 100644 --- a/apps/api/src/interface-adapters/resolvers/AuthResolver.ts +++ b/apps/api/src/interface-adapters/resolvers/AuthResolver.ts @@ -1,11 +1,13 @@ import type { IRegisterUseCase } from '@/use-cases/auth/IRegisterUseCase.js'; import type { ILoginUseCase } from '@/use-cases/auth/ILoginUseCase.js'; +import type { IVerifyEmailUseCase } from '@/use-cases/auth/IVerifyEmailUseCase.js'; import type { ITokenService, TokenPair } from '@/use-cases/ports/ITokenService.js'; interface Deps { registerUseCase: IRegisterUseCase; loginUseCase: ILoginUseCase; tokenService: ITokenService; + verifyEmailUseCase: IVerifyEmailUseCase; } export class AuthResolver { @@ -25,4 +27,8 @@ export class AuthResolver { const payload = this.deps.tokenService.verifyRefresh(refreshToken); return this.deps.tokenService.sign(payload.sub, payload.email); } + + async verifyEmail(token: string): Promise { + await this.deps.verifyEmailUseCase.execute({ token }); + } } diff --git a/apps/api/src/use-cases/auth/ISendEmailVerificationUseCase.ts b/apps/api/src/use-cases/auth/ISendEmailVerificationUseCase.ts new file mode 100644 index 00000000..e6c62bed --- /dev/null +++ b/apps/api/src/use-cases/auth/ISendEmailVerificationUseCase.ts @@ -0,0 +1,3 @@ +export interface ISendEmailVerificationUseCase { + execute(userId: string): Promise; +} diff --git a/apps/api/src/use-cases/auth/IVerifyEmailUseCase.ts b/apps/api/src/use-cases/auth/IVerifyEmailUseCase.ts new file mode 100644 index 00000000..2758f2d2 --- /dev/null +++ b/apps/api/src/use-cases/auth/IVerifyEmailUseCase.ts @@ -0,0 +1,7 @@ +export interface VerifyEmailInput { + token: string; +} + +export interface IVerifyEmailUseCase { + execute(input: VerifyEmailInput): Promise; +} diff --git a/apps/api/src/use-cases/auth/RegisterUseCase.ts b/apps/api/src/use-cases/auth/RegisterUseCase.ts index 3931a149..2247ec7f 100644 --- a/apps/api/src/use-cases/auth/RegisterUseCase.ts +++ b/apps/api/src/use-cases/auth/RegisterUseCase.ts @@ -1,5 +1,6 @@ import bcrypt from 'bcryptjs'; import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js'; +import type { ISendEmailVerificationUseCase } from '@/use-cases/auth/ISendEmailVerificationUseCase.js'; import { ERROR_CODES } from '@/constants.js'; import type { IRegisterUseCase, @@ -10,6 +11,7 @@ import type { interface Deps { userRepository: IUserRepository; generateId: () => string; + sendEmailVerificationUseCase: ISendEmailVerificationUseCase; } export class RegisterUseCase implements IRegisterUseCase { @@ -28,6 +30,13 @@ export class RegisterUseCase implements IRegisterUseCase { passwordHash, }); + try { + await this.deps.sendEmailVerificationUseCase.execute(user.id); + } catch { + // Verification email delivery is non-critical — don't block account + // creation if the email provider is down or unconfigured (e.g. local dev). + } + return { userId: user.id, email: user.email }; } } diff --git a/apps/api/src/use-cases/auth/SendEmailVerificationUseCase.ts b/apps/api/src/use-cases/auth/SendEmailVerificationUseCase.ts new file mode 100644 index 00000000..08830036 --- /dev/null +++ b/apps/api/src/use-cases/auth/SendEmailVerificationUseCase.ts @@ -0,0 +1,39 @@ +import { createHash, randomBytes } from 'crypto'; +import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js'; +import type { IEmailVerificationTokenRepository } from '@/use-cases/ports/IEmailVerificationTokenRepository.js'; +import type { IEmailService } from '@/use-cases/ports/IEmailService.js'; +import { ERROR_CODES, EMAIL_VERIFICATION_TOKEN } from '@/constants.js'; +import type { ISendEmailVerificationUseCase } from '@/use-cases/auth/ISendEmailVerificationUseCase.js'; + +interface Deps { + userRepository: IUserRepository; + emailVerificationTokenRepository: IEmailVerificationTokenRepository; + emailService: IEmailService; + generateId: () => string; + webAppOrigin: string; +} + +export class SendEmailVerificationUseCase implements ISendEmailVerificationUseCase { + constructor(private readonly deps: Deps) {} + + async execute(userId: string): Promise { + const user = await this.deps.userRepository.findById(userId); + if (!user) throw Object.assign(new Error('User not found'), { code: ERROR_CODES.NOT_FOUND }); + + await this.deps.emailVerificationTokenRepository.deleteAllForUser(user.id); + + const rawToken = randomBytes(EMAIL_VERIFICATION_TOKEN.RANDOM_BYTES).toString('hex'); + const tokenHash = createHash('sha256').update(rawToken).digest('hex'); + const expiresAt = new Date(Date.now() + EMAIL_VERIFICATION_TOKEN.TTL_MS); + + await this.deps.emailVerificationTokenRepository.create({ + id: this.deps.generateId(), + userId: user.id, + tokenHash, + expiresAt, + }); + + const verifyUrl = `${this.deps.webAppOrigin}/verify-email?token=${rawToken}`; + await this.deps.emailService.sendEmailVerification(user.email, verifyUrl); + } +} diff --git a/apps/api/src/use-cases/auth/VerifyEmailUseCase.ts b/apps/api/src/use-cases/auth/VerifyEmailUseCase.ts new file mode 100644 index 00000000..0e233161 --- /dev/null +++ b/apps/api/src/use-cases/auth/VerifyEmailUseCase.ts @@ -0,0 +1,38 @@ +import { createHash } from 'crypto'; +import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js'; +import type { IEmailVerificationTokenRepository } from '@/use-cases/ports/IEmailVerificationTokenRepository.js'; +import { ERROR_CODES } from '@/constants.js'; +import type { + IVerifyEmailUseCase, + VerifyEmailInput, +} from '@/use-cases/auth/IVerifyEmailUseCase.js'; + +interface Deps { + userRepository: IUserRepository; + emailVerificationTokenRepository: IEmailVerificationTokenRepository; +} + +export class VerifyEmailUseCase implements IVerifyEmailUseCase { + constructor(private readonly deps: Deps) {} + + async execute(input: VerifyEmailInput): Promise { + const tokenHash = createHash('sha256').update(input.token).digest('hex'); + const verificationToken = + await this.deps.emailVerificationTokenRepository.findByTokenHash(tokenHash); + + if ( + !verificationToken || + verificationToken.usedAt || + verificationToken.expiresAt < new Date() + ) { + throw Object.assign(new Error('Invalid or expired verification link'), { + code: ERROR_CODES.UNAUTHORIZED, + }); + } + + await this.deps.userRepository.update(verificationToken.userId, { + emailVerifiedAt: new Date(), + }); + await this.deps.emailVerificationTokenRepository.markUsed(verificationToken.id); + } +} diff --git a/apps/api/src/use-cases/ports/IEmailService.ts b/apps/api/src/use-cases/ports/IEmailService.ts index feef94b1..a8e228c7 100644 --- a/apps/api/src/use-cases/ports/IEmailService.ts +++ b/apps/api/src/use-cases/ports/IEmailService.ts @@ -9,4 +9,5 @@ export interface WeeklyDigestData { export interface IEmailService { sendFollowUpReminder(to: string, company: string, role: string, followUpAt: Date): Promise; sendWeeklyDigest(to: string, data: WeeklyDigestData): Promise; + sendEmailVerification(to: string, verifyUrl: string): Promise; } diff --git a/apps/api/src/use-cases/ports/IEmailVerificationTokenRepository.ts b/apps/api/src/use-cases/ports/IEmailVerificationTokenRepository.ts new file mode 100644 index 00000000..fb6f4bf0 --- /dev/null +++ b/apps/api/src/use-cases/ports/IEmailVerificationTokenRepository.ts @@ -0,0 +1,13 @@ +import type { EmailVerificationToken } from '@/domain/emailVerificationToken/EmailVerificationToken.js'; + +export interface IEmailVerificationTokenRepository { + create(data: { + id: string; + userId: string; + tokenHash: string; + expiresAt: Date; + }): Promise; + findByTokenHash(tokenHash: string): Promise; + markUsed(id: string): Promise; + deleteAllForUser(userId: string): Promise; +} diff --git a/apps/api/src/use-cases/ports/IUserRepository.ts b/apps/api/src/use-cases/ports/IUserRepository.ts index 4ac5076f..8e84f2a7 100644 --- a/apps/api/src/use-cases/ports/IUserRepository.ts +++ b/apps/api/src/use-cases/ports/IUserRepository.ts @@ -13,6 +13,7 @@ export interface IUserRepository { name?: string | null; timezone?: string | null; targetRole?: string | null; + emailVerifiedAt?: Date | null; }, ): Promise; delete(id: string): Promise; diff --git a/apps/api/src/use-cases/user/UpdateEmailUseCase.ts b/apps/api/src/use-cases/user/UpdateEmailUseCase.ts index e0177118..af38e011 100644 --- a/apps/api/src/use-cases/user/UpdateEmailUseCase.ts +++ b/apps/api/src/use-cases/user/UpdateEmailUseCase.ts @@ -1,5 +1,6 @@ import bcrypt from 'bcryptjs'; import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js'; +import type { ISendEmailVerificationUseCase } from '@/use-cases/auth/ISendEmailVerificationUseCase.js'; import { ERROR_CODES } from '@/constants.js'; import type { IUpdateEmailUseCase, @@ -8,6 +9,7 @@ import type { interface Deps { userRepository: IUserRepository; + sendEmailVerificationUseCase: ISendEmailVerificationUseCase; } export class UpdateEmailUseCase implements IUpdateEmailUseCase { @@ -26,6 +28,18 @@ export class UpdateEmailUseCase implements IUpdateEmailUseCase { throw Object.assign(new Error('Email already in use'), { code: ERROR_CODES.CONFLICT }); } - await this.deps.userRepository.update(input.userId, { email: input.newEmail }); + // Changing the email address invalidates verification of the old one — + // the new address must be re-confirmed before it counts as verified. + await this.deps.userRepository.update(input.userId, { + email: input.newEmail, + emailVerifiedAt: null, + }); + + try { + await this.deps.sendEmailVerificationUseCase.execute(input.userId); + } catch { + // Verification email delivery is non-critical — don't block the email + // change if the email provider is down or unconfigured (e.g. local dev). + } } } diff --git a/apps/web/src/__tests__/components/VerifyEmailPage.test.tsx b/apps/web/src/__tests__/components/VerifyEmailPage.test.tsx new file mode 100644 index 00000000..442a8ef7 --- /dev/null +++ b/apps/web/src/__tests__/components/VerifyEmailPage.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; + +const { mockGqlRequest, mockUseSearch } = vi.hoisted(() => ({ + mockGqlRequest: vi.fn(), + mockUseSearch: vi.fn().mockReturnValue({ token: 'valid-token' }), +})); + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: () => (opts: Record) => ({ + ...opts, + useSearch: mockUseSearch, + }), +})); + +vi.mock('#/graphql/client', () => ({ + gqlClient: { request: mockGqlRequest }, +})); + +import { VerifyEmailPage } from '#/routes/verify-email'; + +describe('VerifyEmailPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseSearch.mockReturnValue({ token: 'valid-token' }); + }); + + it('shows an invalid-link message when there is no token', () => { + mockUseSearch.mockReturnValue({}); + render(); + + expect(screen.getByText('This verification link is invalid.')).toBeInTheDocument(); + expect(mockGqlRequest).not.toHaveBeenCalled(); + }); + + it('shows a verifying state, then calls verifyEmail with the token', async () => { + mockGqlRequest.mockResolvedValue({ verifyEmail: true }); + render(); + + expect(screen.getByText('Verifying your email…')).toBeInTheDocument(); + + await waitFor(() => { + expect(mockGqlRequest).toHaveBeenCalledWith(expect.stringContaining('VerifyEmail'), { + token: 'valid-token', + }); + }); + }); + + it('shows a success message once verification succeeds', async () => { + mockGqlRequest.mockResolvedValue({ verifyEmail: true }); + render(); + + await waitFor(() => { + expect(screen.getByText('Your email has been verified.')).toBeInTheDocument(); + }); + }); + + it('shows an error message when verification fails', async () => { + mockGqlRequest.mockRejectedValue({ + response: { errors: [{ message: 'Invalid or expired verification link' }] }, + }); + render(); + + await waitFor(() => { + expect(screen.getByText('Invalid or expired verification link')).toBeInTheDocument(); + }); + }); + + it('shows a generic error message when the API error has no message', async () => { + mockGqlRequest.mockRejectedValue(new Error('network error')); + render(); + + await waitFor(() => { + expect(screen.getByText('Failed to verify email.')).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index a51aff42..774b6a8e 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as AuthenticatedRouteRouteImport } from './routes/_authenticated/route' import { Route as LoginRouteImport } from './routes/login' import { Route as RegisterRouteImport } from './routes/register' +import { Route as VerifyEmailRouteImport } from './routes/verify-email' import { Route as AuthenticatedAccountRouteImport } from './routes/_authenticated/account' import { Route as AuthenticatedAnalyticsRouteImport } from './routes/_authenticated/analytics' import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard' @@ -41,6 +42,11 @@ const RegisterRoute = RegisterRouteImport.update({ path: '/register', getParentRoute: () => rootRouteImport, } as any) +const VerifyEmailRoute = VerifyEmailRouteImport.update({ + id: '/verify-email', + path: '/verify-email', + getParentRoute: () => rootRouteImport, +} as any) const AuthenticatedAccountRoute = AuthenticatedAccountRouteImport.update({ id: '/account', path: '/account', @@ -91,6 +97,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/login': typeof LoginRoute '/register': typeof RegisterRoute + '/verify-email': typeof VerifyEmailRoute '/account': typeof AuthenticatedAccountRoute '/analytics': typeof AuthenticatedAnalyticsRoute '/dashboard': typeof AuthenticatedDashboardRoute @@ -104,6 +111,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/login': typeof LoginRoute '/register': typeof RegisterRoute + '/verify-email': typeof VerifyEmailRoute '/account': typeof AuthenticatedAccountRoute '/analytics': typeof AuthenticatedAnalyticsRoute '/dashboard': typeof AuthenticatedDashboardRoute @@ -119,6 +127,7 @@ export interface FileRoutesById { '/_authenticated': typeof AuthenticatedRouteRouteWithChildren '/login': typeof LoginRoute '/register': typeof RegisterRoute + '/verify-email': typeof VerifyEmailRoute '/_authenticated/account': typeof AuthenticatedAccountRoute '/_authenticated/analytics': typeof AuthenticatedAnalyticsRoute '/_authenticated/dashboard': typeof AuthenticatedDashboardRoute @@ -134,6 +143,7 @@ export interface FileRouteTypes { | '/' | '/login' | '/register' + | '/verify-email' | '/account' | '/analytics' | '/dashboard' @@ -147,6 +157,7 @@ export interface FileRouteTypes { | '/' | '/login' | '/register' + | '/verify-email' | '/account' | '/analytics' | '/dashboard' @@ -161,6 +172,7 @@ export interface FileRouteTypes { | '/_authenticated' | '/login' | '/register' + | '/verify-email' | '/_authenticated/account' | '/_authenticated/analytics' | '/_authenticated/dashboard' @@ -176,6 +188,7 @@ export interface RootRouteChildren { AuthenticatedRouteRoute: typeof AuthenticatedRouteRouteWithChildren LoginRoute: typeof LoginRoute RegisterRoute: typeof RegisterRoute + VerifyEmailRoute: typeof VerifyEmailRoute } declare module '@tanstack/react-router' { @@ -208,6 +221,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RegisterRouteImport parentRoute: typeof rootRouteImport } + '/verify-email': { + id: '/verify-email' + path: '/verify-email' + fullPath: '/verify-email' + preLoaderRoute: typeof VerifyEmailRouteImport + parentRoute: typeof rootRouteImport + } '/_authenticated/account': { id: '/_authenticated/account' path: '/account' @@ -299,6 +319,7 @@ const rootRouteChildren: RootRouteChildren = { AuthenticatedRouteRoute: AuthenticatedRouteRouteWithChildren, LoginRoute: LoginRoute, RegisterRoute: RegisterRoute, + VerifyEmailRoute: VerifyEmailRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/verify-email.tsx b/apps/web/src/routes/verify-email.tsx new file mode 100644 index 00000000..1226bbc8 --- /dev/null +++ b/apps/web/src/routes/verify-email.tsx @@ -0,0 +1,85 @@ +import { useEffect, useState } from 'react'; +import { createFileRoute } from '@tanstack/react-router'; +import { z } from 'zod'; +import { gqlClient } from '#/graphql/client'; + +const searchSchema = z.object({ token: z.string().optional() }); + +const VERIFY_EMAIL_MUTATION = ` + mutation VerifyEmail($token: String!) { + verifyEmail(token: $token) + } +`; + +export const Route = createFileRoute('/verify-email')({ + validateSearch: searchSchema, + component: VerifyEmailPage, +}); + +type Status = 'verifying' | 'success' | 'error'; + +export function VerifyEmailPage() { + const { token } = Route.useSearch(); + const [status, setStatus] = useState(token ? 'verifying' : 'error'); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + if (!token) return; + let cancelled = false; + + gqlClient + .request(VERIFY_EMAIL_MUTATION, { token }) + .then(() => { + if (!cancelled) setStatus('success'); + }) + .catch((err: unknown) => { + if (cancelled) return; + setErrorMessage(extractGqlError(err) ?? 'Failed to verify email.'); + setStatus('error'); + }); + + return () => { + cancelled = true; + }; + }, [token]); + + return ( +
+
+
+

Verify your email

+
+ + {status === 'verifying' && ( +

Verifying your email…

+ )} + + {status === 'success' && ( +

+ Your email has been verified. +

+ )} + + {status === 'error' && ( +

+ {token ? errorMessage : 'This verification link is invalid.'} +

+ )} + +

+ + Back to sign in + +

+
+
+ ); +} + +function extractGqlError(err: unknown): string | null { + if (typeof err === 'object' && err !== null && 'response' in err) { + const r = (err as { response?: { errors?: Array<{ message?: string }> } }).response; + return r?.errors?.[0]?.message ?? null; + } + return null; +}