diff --git a/apps/api/prisma/migrations/20260722213047_add_password_reset_tokens/migration.sql b/apps/api/prisma/migrations/20260722213047_add_password_reset_tokens/migration.sql new file mode 100644 index 00000000..f1384917 --- /dev/null +++ b/apps/api/prisma/migrations/20260722213047_add_password_reset_tokens/migration.sql @@ -0,0 +1,12 @@ +CREATE TABLE "PasswordResetToken" ( + "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 "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX "PasswordResetToken_tokenHash_key" ON "PasswordResetToken"("tokenHash"); +CREATE INDEX "PasswordResetToken_userId_idx" ON "PasswordResetToken"("userId"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 5bdbdcd1..b4db299c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -24,6 +24,7 @@ model User { emailVerificationTokens EmailVerificationToken[] sessions Session[] loginEvents LoginEvent[] + passwordResetTokens PasswordResetToken[] } model LoginEvent { @@ -63,6 +64,18 @@ model EmailVerificationToken { @@index([userId]) } +model PasswordResetToken { + 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 { id String @id userId String diff --git a/apps/api/src/__tests__/application/auth/RequestPasswordResetUseCase.test.ts b/apps/api/src/__tests__/application/auth/RequestPasswordResetUseCase.test.ts new file mode 100644 index 00000000..838adb48 --- /dev/null +++ b/apps/api/src/__tests__/application/auth/RequestPasswordResetUseCase.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { RequestPasswordResetUseCase } from '@/use-cases/auth/RequestPasswordResetUseCase.js'; +import { + makeUserRepository, + makePasswordResetTokenRepository, + makeUser, + makeRateLimiter, +} 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), + sendPasswordReset: vi.fn().mockResolvedValue(undefined), + sendEmailVerification: vi.fn().mockResolvedValue(undefined), + ...overrides, +}); + +const makeDeps = (overrides?: object) => ({ + userRepository: makeUserRepository(), + passwordResetTokenRepository: makePasswordResetTokenRepository(), + emailService: makeEmailService(), + passwordResetRateLimiter: makeRateLimiter(), + generateId: vi.fn().mockReturnValue('generated-id'), + webAppOrigin: 'http://localhost:3000', + ...overrides, +}); + +describe('RequestPasswordResetUseCase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('silently no-ops when the email does not match a user', async () => { + const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(null) }); + const passwordResetTokenRepository = makePasswordResetTokenRepository(); + const emailService = makeEmailService(); + + await new RequestPasswordResetUseCase( + makeDeps({ userRepository, passwordResetTokenRepository, emailService }), + ).execute({ email: 'nobody@example.com', ipAddress: '127.0.0.1' }); + + expect(passwordResetTokenRepository.create).not.toHaveBeenCalled(); + expect(emailService.sendPasswordReset).not.toHaveBeenCalled(); + }); + + it('deletes existing tokens, creates a new one, and emails a reset link', async () => { + const user = makeUser({ id: 'user-1', email: 'test@example.com' }); + const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(user) }); + const passwordResetTokenRepository = makePasswordResetTokenRepository(); + const emailService = makeEmailService(); + + await new RequestPasswordResetUseCase( + makeDeps({ + userRepository, + passwordResetTokenRepository, + emailService, + webAppOrigin: 'https://app.jobfinder.com', + }), + ).execute({ email: 'test@example.com', ipAddress: '127.0.0.1' }); + + expect(passwordResetTokenRepository.deleteAllForUser).toHaveBeenCalledWith('user-1'); + expect(passwordResetTokenRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ id: 'generated-id', userId: 'user-1' }), + ); + expect(emailService.sendPasswordReset).toHaveBeenCalledWith( + 'test@example.com', + expect.stringMatching(/^https:\/\/app\.jobfinder\.com\/reset-password\?token=[a-f0-9]+$/), + ); + }); + + it('sets an expiry roughly one hour in the future', async () => { + const user = makeUser({ id: 'user-1' }); + const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(user) }); + const passwordResetTokenRepository = makePasswordResetTokenRepository(); + + const before = Date.now(); + await new RequestPasswordResetUseCase( + makeDeps({ userRepository, passwordResetTokenRepository }), + ).execute({ email: 'test@example.com', ipAddress: '127.0.0.1' }); + const after = Date.now(); + + const createCall = vi.mocked(passwordResetTokenRepository.create).mock.calls[0][0]; + const expiresAtMs = createCall.expiresAt.getTime(); + expect(expiresAtMs).toBeGreaterThanOrEqual(before + 60 * 60 * 1000 - 1000); + expect(expiresAtMs).toBeLessThanOrEqual(after + 60 * 60 * 1000 + 1000); + }); + + it('does not let an email-provider failure surface differently than the unknown-email no-op', async () => { + const user = makeUser({ id: 'user-1', email: 'test@example.com' }); + const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(user) }); + const emailService = makeEmailService({ + sendPasswordReset: vi.fn().mockRejectedValue(new Error('Brevo API error 500')), + }); + + await expect( + new RequestPasswordResetUseCase(makeDeps({ userRepository, emailService })).execute({ + email: 'test@example.com', + ipAddress: '127.0.0.1', + }), + ).resolves.toBeUndefined(); + }); + + it('rate-limits by email address regardless of whether the account exists', async () => { + const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(null) }); + const rateLimiter = makeRateLimiter({ consume: vi.fn().mockReturnValue(false) }); + + const err = await new RequestPasswordResetUseCase( + makeDeps({ userRepository, passwordResetRateLimiter: rateLimiter }), + ) + .execute({ email: 'nobody@example.com', ipAddress: '127.0.0.1' }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('RATE_LIMITED'); + expect(rateLimiter.consume).toHaveBeenCalledWith('password-reset:email:nobody@example.com'); + expect(userRepository.findByEmail).not.toHaveBeenCalled(); + }); + + it('rate-limits by IP address in addition to email', async () => { + const rateLimiter = makeRateLimiter({ + consume: vi.fn().mockImplementation((key: string) => !key.startsWith('password-reset:ip:')), + }); + + const err = await new RequestPasswordResetUseCase( + makeDeps({ passwordResetRateLimiter: rateLimiter }), + ) + .execute({ email: 'test@example.com', ipAddress: '203.0.113.5' }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('RATE_LIMITED'); + expect(rateLimiter.consume).toHaveBeenCalledWith('password-reset:ip:203.0.113.5'); + }); + + it('lowercases the email when building the rate-limit key', async () => { + const rateLimiter = makeRateLimiter(); + const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(null) }); + + await new RequestPasswordResetUseCase( + makeDeps({ userRepository, passwordResetRateLimiter: rateLimiter }), + ).execute({ + email: 'Test@Example.com', + ipAddress: null, + }); + + expect(rateLimiter.consume).toHaveBeenCalledWith('password-reset:email:test@example.com'); + }); +}); diff --git a/apps/api/src/__tests__/application/auth/ResetPasswordUseCase.test.ts b/apps/api/src/__tests__/application/auth/ResetPasswordUseCase.test.ts new file mode 100644 index 00000000..87ddfc37 --- /dev/null +++ b/apps/api/src/__tests__/application/auth/ResetPasswordUseCase.test.ts @@ -0,0 +1,129 @@ +import { createHash } from 'crypto'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import bcrypt from 'bcryptjs'; +import { ResetPasswordUseCase } from '@/use-cases/auth/ResetPasswordUseCase.js'; +import { + makeUserRepository, + makePasswordResetTokenRepository, + makePasswordResetToken, + makeSessionRepository, +} from '@/__tests__/helpers/mocks.js'; + +vi.mock('bcryptjs', () => ({ + default: { + hash: vi.fn(), + compare: vi.fn(), + }, +})); + +const RAW_TOKEN = 'raw-reset-token'; +const TOKEN_HASH = createHash('sha256').update(RAW_TOKEN).digest('hex'); + +const makeDeps = (overrides?: object) => ({ + userRepository: makeUserRepository(), + passwordResetTokenRepository: makePasswordResetTokenRepository(), + sessionRepository: makeSessionRepository(), + ...overrides, +}); + +describe('ResetPasswordUseCase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws VALIDATION when the new password is too short', async () => { + const userRepository = makeUserRepository(); + + const err = await new ResetPasswordUseCase(makeDeps({ userRepository })) + .execute({ token: RAW_TOKEN, newPassword: 'short1' }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('VALIDATION'); + expect(userRepository.update).not.toHaveBeenCalled(); + }); + + it('throws UNAUTHORIZED when no token matches the hash', async () => { + const passwordResetTokenRepository = makePasswordResetTokenRepository({ + findByTokenHash: vi.fn().mockResolvedValue(null), + }); + const userRepository = makeUserRepository(); + + const err = await new ResetPasswordUseCase( + makeDeps({ userRepository, passwordResetTokenRepository }), + ) + .execute({ token: RAW_TOKEN, newPassword: 'newPassword123' }) + .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 resetToken = makePasswordResetToken({ + tokenHash: TOKEN_HASH, + usedAt: new Date('2024-01-01T00:30:00.000Z'), + }); + const passwordResetTokenRepository = makePasswordResetTokenRepository({ + findByTokenHash: vi.fn().mockResolvedValue(resetToken), + }); + const userRepository = makeUserRepository(); + + const err = await new ResetPasswordUseCase( + makeDeps({ userRepository, passwordResetTokenRepository }), + ) + .execute({ token: RAW_TOKEN, newPassword: 'newPassword123' }) + .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 resetToken = makePasswordResetToken({ + tokenHash: TOKEN_HASH, + expiresAt: new Date(Date.now() - 1000), + }); + const passwordResetTokenRepository = makePasswordResetTokenRepository({ + findByTokenHash: vi.fn().mockResolvedValue(resetToken), + }); + const userRepository = makeUserRepository(); + + const err = await new ResetPasswordUseCase( + makeDeps({ userRepository, passwordResetTokenRepository }), + ) + .execute({ token: RAW_TOKEN, newPassword: 'newPassword123' }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + expect(userRepository.update).not.toHaveBeenCalled(); + }); + + it('updates the password, marks the token used, and revokes all sessions for a valid token', async () => { + const resetToken = makePasswordResetToken({ + id: 'reset-1', + userId: 'user-1', + tokenHash: TOKEN_HASH, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }); + const passwordResetTokenRepository = makePasswordResetTokenRepository({ + findByTokenHash: vi.fn().mockResolvedValue(resetToken), + }); + const userRepository = makeUserRepository(); + const sessionRepository = makeSessionRepository(); + vi.mocked(bcrypt.hash).mockResolvedValue('new-hashed-password' as never); + + await new ResetPasswordUseCase( + makeDeps({ userRepository, passwordResetTokenRepository, sessionRepository }), + ).execute({ + token: RAW_TOKEN, + newPassword: 'newPassword123', + }); + + expect(bcrypt.hash).toHaveBeenCalledWith('newPassword123', 12); + expect(userRepository.update).toHaveBeenCalledWith('user-1', { + passwordHash: 'new-hashed-password', + }); + expect(passwordResetTokenRepository.markUsed).toHaveBeenCalledWith('reset-1'); + expect(sessionRepository.revokeAllForUser).toHaveBeenCalledWith('user-1'); + }); +}); diff --git a/apps/api/src/__tests__/application/auth/SendEmailVerificationUseCase.test.ts b/apps/api/src/__tests__/application/auth/SendEmailVerificationUseCase.test.ts index 0dd31358..65cf4173 100644 --- a/apps/api/src/__tests__/application/auth/SendEmailVerificationUseCase.test.ts +++ b/apps/api/src/__tests__/application/auth/SendEmailVerificationUseCase.test.ts @@ -11,6 +11,7 @@ const makeEmailService = (overrides?: Partial): IEmailService => sendFollowUpReminder: vi.fn().mockResolvedValue(undefined), sendWeeklyDigest: vi.fn().mockResolvedValue(undefined), sendEmailVerification: vi.fn().mockResolvedValue(undefined), + sendPasswordReset: vi.fn().mockResolvedValue(undefined), ...overrides, }); diff --git a/apps/api/src/__tests__/application/reminders/SendFollowUpRemindersUseCase.test.ts b/apps/api/src/__tests__/application/reminders/SendFollowUpRemindersUseCase.test.ts index 68dd8f14..ebce5f7c 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), + sendPasswordReset: vi.fn().mockResolvedValue(undefined), sendEmailVerification: vi.fn().mockResolvedValue(undefined), ...overrides, }); diff --git a/apps/api/src/__tests__/digest/SendWeeklyDigestUseCase.test.ts b/apps/api/src/__tests__/digest/SendWeeklyDigestUseCase.test.ts index 08732fad..21e6402f 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), + sendPasswordReset: 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 1f0d28fd..977b7c95 100644 --- a/apps/api/src/__tests__/helpers/createTestDb.ts +++ b/apps/api/src/__tests__/helpers/createTestDb.ts @@ -149,6 +149,16 @@ const SCHEMA_STATEMENTS = [ FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE )`, `CREATE INDEX "EmailVerificationToken_userId_idx" ON "EmailVerificationToken"("userId")`, + `CREATE TABLE "PasswordResetToken" ( + "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 "PasswordResetToken_userId_idx" ON "PasswordResetToken"("userId")`, ]; export interface TestDb { diff --git a/apps/api/src/__tests__/helpers/mocks.ts b/apps/api/src/__tests__/helpers/mocks.ts index 38077822..1c556be6 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 { IPasswordResetTokenRepository } from '@/use-cases/ports/IPasswordResetTokenRepository.js'; +import type { PasswordResetToken } from '@/domain/passwordResetToken/PasswordResetToken.js'; import type { ILoginEventRepository } from '@/use-cases/ports/ILoginEventRepository.js'; import type { ISessionRepository } from '@/use-cases/ports/ISessionRepository.js'; import type { Session } from '@/domain/session/Session.js'; @@ -22,6 +24,7 @@ import type { Note } from '@/domain/note/Note.js'; import type { InterviewRound } from '@/domain/interviewRound/InterviewRound.js'; import type { Contact } from '@/domain/contact/Contact.js'; import type { LoginEvent } from '@/domain/loginEvent/LoginEvent.js'; +import type { IRateLimiter } from '@/use-cases/ports/IRateLimiter.js'; export const makeUserRepository = (overrides?: Partial): IUserRepository => ({ findById: vi.fn(), @@ -122,6 +125,16 @@ export const makeApiToken = (overrides?: Partial): ApiToken => ({ ...overrides, }); +export const makePasswordResetTokenRepository = ( + overrides?: Partial, +): IPasswordResetTokenRepository => ({ + create: vi.fn(), + findByTokenHash: vi.fn().mockResolvedValue(null), + markUsed: vi.fn().mockResolvedValue(undefined), + deleteAllForUser: vi.fn().mockResolvedValue(undefined), + ...overrides, +}); + export const makeSessionRepository = ( overrides?: Partial, ): ISessionRepository => ({ @@ -132,6 +145,7 @@ export const makeSessionRepository = ( touch: vi.fn().mockResolvedValue(undefined), revoke: vi.fn().mockResolvedValue(undefined), revokeAllForUserExcept: vi.fn().mockResolvedValue(undefined), + revokeAllForUser: vi.fn().mockResolvedValue(undefined), ...overrides, }); @@ -157,6 +171,18 @@ export const makeEmailVerificationTokenRepository = ( ...overrides, }); +export const makePasswordResetToken = ( + overrides?: Partial, +): PasswordResetToken => ({ + id: 'reset-token-1', + userId: 'user-1', + tokenHash: 'hashed-reset-token', + expiresAt: new Date('2024-01-01T01:00:00.000Z'), + usedAt: null, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + ...overrides, +}); + export const makeEmailVerificationToken = ( overrides?: Partial, ): EmailVerificationToken => ({ @@ -188,6 +214,11 @@ export const makeLoginEvent = (overrides?: Partial): LoginEvent => ( ...overrides, }); +export const makeRateLimiter = (overrides?: Partial): IRateLimiter => ({ + consume: vi.fn().mockReturnValue(true), + ...overrides, +}); + // Domain object fixtures export const makeUser = (overrides?: Partial): User => ({ id: 'user-1', diff --git a/apps/api/src/__tests__/http/errors/AppError.test.ts b/apps/api/src/__tests__/http/errors/AppError.test.ts index 7a214295..38007e45 100644 --- a/apps/api/src/__tests__/http/errors/AppError.test.ts +++ b/apps/api/src/__tests__/http/errors/AppError.test.ts @@ -6,6 +6,7 @@ import { UnauthorizedError, ForbiddenError, ValidationError, + RateLimitedError, fromCodedError, } from '@/http/errors/AppError.js'; import { ERROR_CODES } from '@/constants.js'; @@ -86,6 +87,22 @@ describe('ValidationError', () => { }); }); +describe('RateLimitedError', () => { + it('defaults to "Too many requests" with a 429 status and RATE_LIMITED code', () => { + const err = new RateLimitedError(); + + expect(err.message).toBe('Too many requests'); + expect(err.statusCode).toBe(429); + expect(err.code).toBe(ERROR_CODES.RATE_LIMITED); + }); + + it('uses the given message when provided', () => { + expect(new RateLimitedError('Too many password reset requests').message).toBe( + 'Too many password reset requests', + ); + }); +}); + describe('fromCodedError', () => { it('returns the same instance when already an AppError', () => { const original = new ConflictError('Already exists'); @@ -97,6 +114,7 @@ describe('fromCodedError', () => { [ERROR_CODES.UNAUTHORIZED, UnauthorizedError], [ERROR_CODES.FORBIDDEN, ForbiddenError], [ERROR_CODES.VALIDATION, ValidationError], + [ERROR_CODES.RATE_LIMITED, RateLimitedError], ])('maps a %s-coded Error to a %s, preserving the message', (code, ExpectedClass) => { const raw = Object.assign(new Error('Domain-specific message'), { code }); diff --git a/apps/api/src/__tests__/http/errors/formatError.test.ts b/apps/api/src/__tests__/http/errors/formatError.test.ts index 0a879dbc..6d4066b1 100644 --- a/apps/api/src/__tests__/http/errors/formatError.test.ts +++ b/apps/api/src/__tests__/http/errors/formatError.test.ts @@ -67,6 +67,19 @@ describe('formatError', () => { expect(consoleErrorSpy).not.toHaveBeenCalled(); }); + it('maps a RATE_LIMITED-coded error to a client-safe GraphQLError without logging', () => { + const original = Object.assign(new Error('Too many password reset requests'), { + code: ERROR_CODES.RATE_LIMITED, + }); + const wrapper = new GraphQLError('wrapped', { originalError: original }); + + const result = formatError(wrapper); + + expect(result.extensions.code).toBe(ERROR_CODES.RATE_LIMITED); + expect(result.extensions.statusCode).toBe(429); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + it('logs and returns a generic 500 error for an uncoded error, without leaking its message', () => { const original = new Error('TypeError: cannot read property x of undefined at db.ts:42'); const wrapper = new GraphQLError('wrapped', { originalError: original }); diff --git a/apps/api/src/__tests__/infrastructure/db/repositories/PrismaPasswordResetTokenRepository.test.ts b/apps/api/src/__tests__/infrastructure/db/repositories/PrismaPasswordResetTokenRepository.test.ts new file mode 100644 index 00000000..0996efea --- /dev/null +++ b/apps/api/src/__tests__/infrastructure/db/repositories/PrismaPasswordResetTokenRepository.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaPasswordResetTokenRepository } from '@/infrastructure/db/repositories/PrismaPasswordResetTokenRepository.js'; +import { createTestDb, type TestDb } from '@/__tests__/helpers/createTestDb.js'; + +describe('PrismaPasswordResetTokenRepository', () => { + let db: TestDb; + let repo: PrismaPasswordResetTokenRepository; + + beforeAll(async () => { + db = await createTestDb(); + repo = new PrismaPasswordResetTokenRepository({ prisma: db.prisma }); + }); + + afterAll(() => db.cleanup()); + + beforeEach(async () => { + await db.prisma.passwordResetToken.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() + 60 * 60 * 1000); + const token = await repo.create({ + id: 'reset-1', + userId: 'user-1', + tokenHash: 'hash-1', + expiresAt, + }); + + expect(token.id).toBe('reset-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: 'reset-1', + userId: 'user-1', + tokenHash: 'hash-1', + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }); + + const found = await repo.findByTokenHash('hash-1'); + expect(found?.id).toBe('reset-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: 'reset-1', + userId: 'user-1', + tokenHash: 'hash-1', + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }); + + await repo.markUsed('reset-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: 'reset-1', + userId: 'user-1', + tokenHash: 'hash-1', + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }); + await repo.create({ + id: 'reset-2', + userId: 'user-1', + tokenHash: 'hash-2', + expiresAt: new Date(Date.now() + 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: 'reset-1', + userId: 'user-1', + tokenHash: 'hash-1', + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }); + await repo.create({ + id: 'reset-2', + userId: 'user-2', + tokenHash: 'hash-2', + expiresAt: new Date(Date.now() + 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/PrismaSessionRepository.test.ts b/apps/api/src/__tests__/infrastructure/db/repositories/PrismaSessionRepository.test.ts index 48cce4a3..9a8c8de3 100644 --- a/apps/api/src/__tests__/infrastructure/db/repositories/PrismaSessionRepository.test.ts +++ b/apps/api/src/__tests__/infrastructure/db/repositories/PrismaSessionRepository.test.ts @@ -211,4 +211,51 @@ describe('PrismaSessionRepository', () => { expect(await repo.findActiveByUserId('user-2')).toHaveLength(1); }); }); + + describe('revokeAllForUser', () => { + it('revokes every session for the user, including the most recently used one', async () => { + await repo.create({ + id: 'session-a', + userId: 'user-1', + userAgent: null, + ipAddress: null, + expiresAt: futureExpiry(), + }); + await repo.create({ + id: 'session-b', + userId: 'user-1', + userAgent: null, + ipAddress: null, + expiresAt: futureExpiry(), + }); + + await repo.revokeAllForUser('user-1'); + + expect(await repo.findActiveByUserId('user-1')).toHaveLength(0); + }); + + it('does not affect sessions belonging to other users', async () => { + await db.prisma.user.create({ + data: { id: 'user-2', email: 'c@d.com', passwordHash: 'hashed' }, + }); + await repo.create({ + id: 'mine', + userId: 'user-1', + userAgent: null, + ipAddress: null, + expiresAt: futureExpiry(), + }); + await repo.create({ + id: 'theirs', + userId: 'user-2', + userAgent: null, + ipAddress: null, + expiresAt: futureExpiry(), + }); + + await repo.revokeAllForUser('user-1'); + + expect(await repo.findActiveByUserId('user-2')).toHaveLength(1); + }); + }); }); diff --git a/apps/api/src/__tests__/infrastructure/email/BrevoEmailService.test.ts b/apps/api/src/__tests__/infrastructure/email/BrevoEmailService.test.ts index 3981f9f8..c6298803 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 { buildPasswordResetHtml } from '@/infrastructure/email/templates/passwordResetTemplate.js'; import { buildEmailVerificationHtml } from '@/infrastructure/email/templates/emailVerificationTemplate.js'; import { ENV, EMAIL } from '@/constants.js'; import type { WeeklyDigestData } from '@/use-cases/ports/IEmailService.js'; @@ -161,6 +162,34 @@ describe('BrevoEmailService', () => { }); }); + describe('sendPasswordReset', () => { + const resetUrl = 'https://app.jobfinder.com/reset-password?token=abc123'; + + it('posts a subject and htmlContent built from the reset URL', async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse(true, 200) as never); + const service = new BrevoEmailService(); + + await service.sendPasswordReset('user@example.com', resetUrl); + + 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('Reset your Job Finder password'); + expect(body.htmlContent).toBe(buildPasswordResetHtml(resetUrl)); + }); + + 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.sendPasswordReset('user@example.com', resetUrl)).rejects.toThrow( + /Brevo API error 503/, + ); + }); + }); + describe('sendEmailVerification', () => { const verifyUrl = 'https://app.jobfinder.com/verify-email?token=abc123'; diff --git a/apps/api/src/__tests__/infrastructure/email/templates/passwordResetTemplate.test.ts b/apps/api/src/__tests__/infrastructure/email/templates/passwordResetTemplate.test.ts new file mode 100644 index 00000000..9b1cdc1f --- /dev/null +++ b/apps/api/src/__tests__/infrastructure/email/templates/passwordResetTemplate.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { buildPasswordResetHtml } from '@/infrastructure/email/templates/passwordResetTemplate.js'; + +describe('buildPasswordResetHtml', () => { + it('includes the reset URL as the button link', () => { + const html = buildPasswordResetHtml('https://app.jobfinder.com/reset-password?token=abc123'); + + expect(html).toContain('href="https://app.jobfinder.com/reset-password?token=abc123"'); + }); + + it('mentions the 1 hour expiry and includes safe-to-ignore copy', () => { + const html = buildPasswordResetHtml('https://app.jobfinder.com/reset-password?token=abc123'); + + expect(html).toContain('expires in 1 hour'); + expect(html).toContain('you can safely ignore this email'); + }); +}); diff --git a/apps/api/src/__tests__/infrastructure/rateLimit/RateLimiter.test.ts b/apps/api/src/__tests__/infrastructure/rateLimit/RateLimiter.test.ts new file mode 100644 index 00000000..bf1eff9d --- /dev/null +++ b/apps/api/src/__tests__/infrastructure/rateLimit/RateLimiter.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { RateLimiter } from '@/infrastructure/rateLimit/RateLimiter.js'; + +describe('RateLimiter', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T00:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('allows requests up to the configured maximum within the window', () => { + const limiter = new RateLimiter(3, 60_000); + + expect(limiter.consume('key-1')).toBe(true); + expect(limiter.consume('key-1')).toBe(true); + expect(limiter.consume('key-1')).toBe(true); + }); + + it('rejects requests once the maximum is exceeded within the window', () => { + const limiter = new RateLimiter(2, 60_000); + + expect(limiter.consume('key-1')).toBe(true); + expect(limiter.consume('key-1')).toBe(true); + expect(limiter.consume('key-1')).toBe(false); + }); + + it('tracks separate keys independently', () => { + const limiter = new RateLimiter(1, 60_000); + + expect(limiter.consume('key-1')).toBe(true); + expect(limiter.consume('key-2')).toBe(true); + expect(limiter.consume('key-1')).toBe(false); + expect(limiter.consume('key-2')).toBe(false); + }); + + it('resets the count once the window has elapsed', () => { + const limiter = new RateLimiter(1, 60_000); + + expect(limiter.consume('key-1')).toBe(true); + expect(limiter.consume('key-1')).toBe(false); + + vi.advanceTimersByTime(60_001); + + expect(limiter.consume('key-1')).toBe(true); + }); +}); 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 7ce5c419..c77ae409 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,8 @@ import { AuthResolver } from '@/interface-adapters/resolvers/AuthResolver.js'; import { makeUser, makeSession } 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 { IRequestPasswordResetUseCase } from '@/use-cases/auth/IRequestPasswordResetUseCase.js'; +import type { IResetPasswordUseCase } from '@/use-cases/auth/IResetPasswordUseCase.js'; import type { IVerifyEmailUseCase } from '@/use-cases/auth/IVerifyEmailUseCase.js'; import type { ITokenService } from '@/use-cases/ports/ITokenService.js'; import type { CreateSessionUseCase } from '@/use-cases/sessions/CreateSessionUseCase.js'; @@ -20,6 +22,20 @@ const makeLoginUseCase = (overrides?: Partial): ILoginUseCase => ...overrides, }); +const makeRequestPasswordResetUseCase = ( + overrides?: Partial, +): IRequestPasswordResetUseCase => ({ + execute: vi.fn().mockResolvedValue(undefined), + ...overrides, +}); + +const makeResetPasswordUseCase = ( + overrides?: Partial, +): IResetPasswordUseCase => ({ + execute: vi.fn().mockResolvedValue(undefined), + ...overrides, +}); + const makeVerifyEmailUseCase = (overrides?: Partial): IVerifyEmailUseCase => ({ execute: vi.fn().mockResolvedValue(undefined), ...overrides, @@ -37,6 +53,8 @@ const baseDeps = () => ({ registerUseCase: makeRegisterUseCase(), loginUseCase: makeLoginUseCase(), tokenService: makeTokenService(), + requestPasswordResetUseCase: makeRequestPasswordResetUseCase(), + resetPasswordUseCase: makeResetPasswordUseCase(), createSessionUseCase: stub({ execute: vi.fn().mockResolvedValue(makeSession()), }), @@ -193,4 +211,46 @@ describe('AuthResolver', () => { }); }); }); + + describe('requestPasswordReset', () => { + it('delegates to requestPasswordResetUseCase with the given email and IP', async () => { + const requestPasswordResetUseCase = makeRequestPasswordResetUseCase(); + const resolver = new AuthResolver({ ...baseDeps(), requestPasswordResetUseCase }); + + await resolver.requestPasswordReset('test@example.com', '127.0.0.1'); + + expect(requestPasswordResetUseCase.execute).toHaveBeenCalledWith({ + email: 'test@example.com', + ipAddress: '127.0.0.1', + }); + }); + }); + + describe('resetPassword', () => { + it('delegates to resetPasswordUseCase with the token and new password', async () => { + const resetPasswordUseCase = makeResetPasswordUseCase(); + const resolver = new AuthResolver({ ...baseDeps(), resetPasswordUseCase }); + + await resolver.resetPassword('raw-token', 'newPassword123'); + + expect(resetPasswordUseCase.execute).toHaveBeenCalledWith({ + token: 'raw-token', + newPassword: 'newPassword123', + }); + }); + + it('propagates errors from the use case', async () => { + const err = Object.assign(new Error('Invalid or expired reset link'), { + code: 'UNAUTHORIZED', + }); + const resetPasswordUseCase = makeResetPasswordUseCase({ + execute: vi.fn().mockRejectedValue(err), + }); + const resolver = new AuthResolver({ ...baseDeps(), resetPasswordUseCase }); + + await expect(resolver.resetPassword('bad-token', 'newPassword123')).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); }); diff --git a/apps/api/src/constants.ts b/apps/api/src/constants.ts index f34ff9c2..1dce1366 100644 --- a/apps/api/src/constants.ts +++ b/apps/api/src/constants.ts @@ -16,6 +16,7 @@ export const ERROR_CODES = { NOT_FOUND: 'NOT_FOUND', CONFLICT: 'CONFLICT', VALIDATION: 'VALIDATION', + RATE_LIMITED: 'RATE_LIMITED', INTERNAL_ERROR: 'INTERNAL_ERROR', } as const; @@ -89,6 +90,25 @@ export const API_TOKEN = { RANDOM_BYTES: 24, } as const; +/** Password-reset token settings. */ +export const PASSWORD_RESET_TOKEN = { + /** Number of random bytes hex-encoded into the token body. */ + RANDOM_BYTES: 32, + /** How long a reset link stays valid, in milliseconds. */ + TTL_MS: 60 * 60 * 1000, // 1 hour +} as const; + +/** Minimum length enforced server-side for any newly-set password. */ +export const PASSWORD_MIN_LENGTH = 8; + +/** Rate limits for auth endpoints prone to abuse (in-process, fixed-window). */ +export const RATE_LIMIT = { + PASSWORD_RESET_REQUEST: { + MAX_ATTEMPTS: 5, + WINDOW_MS: 15 * 60 * 1000, // 15 minutes + }, +} as const; + /** API-token scopes (mirrors the `ApiTokenScope` domain union). */ export const API_TOKEN_SCOPE = { FULL: 'full', diff --git a/apps/api/src/domain/passwordResetToken/PasswordResetToken.ts b/apps/api/src/domain/passwordResetToken/PasswordResetToken.ts new file mode 100644 index 00000000..e195289f --- /dev/null +++ b/apps/api/src/domain/passwordResetToken/PasswordResetToken.ts @@ -0,0 +1,8 @@ +export interface PasswordResetToken { + id: string; + userId: string; + tokenHash: string; + expiresAt: Date; + usedAt: Date | null; + createdAt: Date; +} diff --git a/apps/api/src/http/container.ts b/apps/api/src/http/container.ts index f9d47e0e..915f726b 100644 --- a/apps/api/src/http/container.ts +++ b/apps/api/src/http/container.ts @@ -5,6 +5,7 @@ import { diContainer } from '@fastify/awilix'; import { prisma } from '@/infrastructure/db/client.js'; import { MemoryCache } from '@/infrastructure/cache/MemoryCache.js'; +import { RateLimiter } from '@/infrastructure/rateLimit/RateLimiter.js'; import { PrismaUserRepository } from '@/infrastructure/db/repositories/PrismaUserRepository.js'; import { PrismaApplicationRepository } from '@/infrastructure/db/repositories/PrismaApplicationRepository.js'; import { PrismaNoteRepository } from '@/infrastructure/db/repositories/PrismaNoteRepository.js'; @@ -16,6 +17,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 { PrismaPasswordResetTokenRepository } from '@/infrastructure/db/repositories/PrismaPasswordResetTokenRepository.js'; import { PrismaLoginEventRepository } from '@/infrastructure/db/repositories/PrismaLoginEventRepository.js'; import { PrismaSessionRepository } from '@/infrastructure/db/repositories/PrismaSessionRepository.js'; import { PrismaEmailVerificationTokenRepository } from '@/infrastructure/db/repositories/PrismaEmailVerificationTokenRepository.js'; @@ -45,6 +47,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 { RequestPasswordResetUseCase } from '@/use-cases/auth/RequestPasswordResetUseCase.js'; +import { ResetPasswordUseCase } from '@/use-cases/auth/ResetPasswordUseCase.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'; @@ -102,7 +106,7 @@ import { RevokeSessionUseCase } from '@/use-cases/sessions/RevokeSessionUseCase. import { RevokeOtherSessionsUseCase } from '@/use-cases/sessions/RevokeOtherSessionsUseCase.js'; import type { FastifyInstance } from 'fastify'; -import { ENV, LLM_PROVIDER, STORAGE_PROVIDER } from '@/constants.js'; +import { ENV, LLM_PROVIDER, RATE_LIMIT, STORAGE_PROVIDER } from '@/constants.js'; import type { ILLMProvider } from '@/use-cases/ports/ILLMProvider.js'; // Augment the @fastify/awilix Cradle interface so diContainer and diScope are fully typed @@ -115,6 +119,7 @@ declare module '@fastify/awilix' { fastify: FastifyInstance; tokenService: FastifyJwtTokenService; cache: MemoryCache; + passwordResetRateLimiter: RateLimiter; // Raw Prisma repositories (used internally by the cached decorators) userRepository: PrismaUserRepository; @@ -131,6 +136,7 @@ declare module '@fastify/awilix' { activityLogRepository: PrismaActivityLogRepository; apiTokenRepository: PrismaApiTokenRepository; contactRepository: PrismaContactRepository; + passwordResetTokenRepository: PrismaPasswordResetTokenRepository; loginEventRepository: PrismaLoginEventRepository; sessionRepository: PrismaSessionRepository; emailVerificationTokenRepository: PrismaEmailVerificationTokenRepository; @@ -158,6 +164,8 @@ declare module '@fastify/awilix' { registerUseCase: RegisterUseCase; loginUseCase: LoginUseCase; + requestPasswordResetUseCase: RequestPasswordResetUseCase; + resetPasswordUseCase: ResetPasswordUseCase; sendEmailVerificationUseCase: SendEmailVerificationUseCase; verifyEmailUseCase: VerifyEmailUseCase; createApplicationUseCase: CreateApplicationUseCase; @@ -236,6 +244,12 @@ export function buildContainer(fastify: FastifyInstance): void { fastify: asValue(fastify), tokenService: asClass(FastifyJwtTokenService, { lifetime: Lifetime.SINGLETON }), cache: asValue(new MemoryCache()), + passwordResetRateLimiter: asValue( + new RateLimiter( + RATE_LIMIT.PASSWORD_RESET_REQUEST.MAX_ATTEMPTS, + RATE_LIMIT.PASSWORD_RESET_REQUEST.WINDOW_MS, + ), + ), // Transaction manager transactionManager: asClass(PrismaTransactionManager, { lifetime: Lifetime.SINGLETON }), @@ -261,6 +275,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 }), + passwordResetTokenRepository: asClass(PrismaPasswordResetTokenRepository, { + lifetime: Lifetime.SINGLETON, + }), loginEventRepository: asClass(PrismaLoginEventRepository, { lifetime: Lifetime.SINGLETON }), sessionRepository: asClass(PrismaSessionRepository, { lifetime: Lifetime.SINGLETON }), emailVerificationTokenRepository: asClass(PrismaEmailVerificationTokenRepository, { @@ -293,6 +310,10 @@ export function buildContainer(fastify: FastifyInstance): void { // Use Cases registerUseCase: asClass(RegisterUseCase, { lifetime: Lifetime.TRANSIENT }), loginUseCase: asClass(LoginUseCase, { lifetime: Lifetime.TRANSIENT }), + requestPasswordResetUseCase: asClass(RequestPasswordResetUseCase, { + lifetime: Lifetime.TRANSIENT, + }), + resetPasswordUseCase: asClass(ResetPasswordUseCase, { lifetime: Lifetime.TRANSIENT }), sendEmailVerificationUseCase: asClass(SendEmailVerificationUseCase, { lifetime: Lifetime.TRANSIENT, }), diff --git a/apps/api/src/http/errors/AppError.ts b/apps/api/src/http/errors/AppError.ts index b035587b..623fd3db 100644 --- a/apps/api/src/http/errors/AppError.ts +++ b/apps/api/src/http/errors/AppError.ts @@ -41,6 +41,12 @@ export class ValidationError extends AppError { } } +export class RateLimitedError extends AppError { + constructor(message = 'Too many requests') { + super(message, 429, ERROR_CODES.RATE_LIMITED); + } +} + export function fromCodedError(err: unknown): AppError { if (err instanceof AppError) return err; if (err instanceof Error) { @@ -56,6 +62,8 @@ export function fromCodedError(err: unknown): AppError { return new ForbiddenError(err.message); case ERROR_CODES.VALIDATION: return new ValidationError(err.message); + case ERROR_CODES.RATE_LIMITED: + return new RateLimitedError(err.message); } } return new AppError('Internal server error', 500, ERROR_CODES.INTERNAL_ERROR); diff --git a/apps/api/src/http/errors/formatError.ts b/apps/api/src/http/errors/formatError.ts index 738b50ed..bde782d3 100644 --- a/apps/api/src/http/errors/formatError.ts +++ b/apps/api/src/http/errors/formatError.ts @@ -8,6 +8,7 @@ const EXPECTED_ERROR_CODES: string[] = [ ERROR_CODES.CONFLICT, ERROR_CODES.UNAUTHORIZED, ERROR_CODES.FORBIDDEN, + ERROR_CODES.RATE_LIMITED, ]; export function formatError(err: GraphQLError): GraphQLError { diff --git a/apps/api/src/http/schema/mutations/authMutations.ts b/apps/api/src/http/schema/mutations/authMutations.ts index 84f1ecc8..a46fc33d 100644 --- a/apps/api/src/http/schema/mutations/authMutations.ts +++ b/apps/api/src/http/schema/mutations/authMutations.ts @@ -81,6 +81,41 @@ builder.mutationField('logout', (t) => }), ); +builder.mutationField('requestPasswordReset', (t) => + t.boolean({ + args: { + email: t.arg.string({ required: true }), + }, + resolve: async (_root, args, ctx) => { + const { authResolver } = ctx.diScope.cradle; + try { + await authResolver.requestPasswordReset(args.email, ctx.request.ip ?? null); + return true; + } catch (err) { + throw fromCodedError(err); + } + }, + }), +); + +builder.mutationField('resetPassword', (t) => + t.boolean({ + args: { + token: t.arg.string({ required: true }), + newPassword: t.arg.string({ required: true }), + }, + resolve: async (_root, args, ctx) => { + const { authResolver } = ctx.diScope.cradle; + try { + await authResolver.resetPassword(args.token, args.newPassword); + return true; + } catch (err) { + throw fromCodedError(err); + } + }, + }), +); + builder.mutationField('verifyEmail', (t) => t.boolean({ args: { diff --git a/apps/api/src/infrastructure/db/repositories/PrismaPasswordResetTokenRepository.ts b/apps/api/src/infrastructure/db/repositories/PrismaPasswordResetTokenRepository.ts new file mode 100644 index 00000000..b5d821bf --- /dev/null +++ b/apps/api/src/infrastructure/db/repositories/PrismaPasswordResetTokenRepository.ts @@ -0,0 +1,59 @@ +import type { PrismaClient } from '@prisma/client'; +import type { PasswordResetToken } from '@/domain/passwordResetToken/PasswordResetToken.js'; +import type { IPasswordResetTokenRepository } from '@/use-cases/ports/IPasswordResetTokenRepository.js'; +import { getClient } from '../transactionContext.js'; + +type PrismaPasswordResetToken = { + id: string; + userId: string; + tokenHash: string; + expiresAt: Date; + usedAt: Date | null; + createdAt: Date; +}; + +export class PrismaPasswordResetTokenRepository implements IPasswordResetTokenRepository { + 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.passwordResetToken.create({ data }); + return this.toEntity(row); + } + + async findByTokenHash(tokenHash: string): Promise { + const row = await this.db.passwordResetToken.findUnique({ where: { tokenHash } }); + return row ? this.toEntity(row) : null; + } + + async markUsed(id: string): Promise { + await this.db.passwordResetToken.update({ where: { id }, data: { usedAt: new Date() } }); + } + + async deleteAllForUser(userId: string): Promise { + await this.db.passwordResetToken.deleteMany({ where: { userId } }); + } + + private toEntity(row: PrismaPasswordResetToken): PasswordResetToken { + 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/PrismaSessionRepository.ts b/apps/api/src/infrastructure/db/repositories/PrismaSessionRepository.ts index 84cafa27..3e1e3937 100644 --- a/apps/api/src/infrastructure/db/repositories/PrismaSessionRepository.ts +++ b/apps/api/src/infrastructure/db/repositories/PrismaSessionRepository.ts @@ -66,6 +66,13 @@ export class PrismaSessionRepository implements ISessionRepository { }); } + async revokeAllForUser(userId: string): Promise { + await this.db.session.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } + private toEntity(row: PrismaSession): Session { return { id: row.id, diff --git a/apps/api/src/infrastructure/email/BrevoEmailService.ts b/apps/api/src/infrastructure/email/BrevoEmailService.ts index 96d23c78..9e9c58f9 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 { buildPasswordResetHtml } from './templates/passwordResetTemplate.js'; import { buildEmailVerificationHtml } from './templates/emailVerificationTemplate.js'; import { EMAIL, ENV } from '@/constants.js'; @@ -65,6 +66,24 @@ export class BrevoEmailService implements IEmailService { } } + async sendPasswordReset(to: string, resetUrl: string): Promise { + const htmlContent = buildPasswordResetHtml(resetUrl); + 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: 'Reset your Job Finder password', + htmlContent, + }), + }); + if (!response.ok && response.status !== 201) { + const body = await response.text(); + 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, { diff --git a/apps/api/src/infrastructure/email/templates/passwordResetTemplate.ts b/apps/api/src/infrastructure/email/templates/passwordResetTemplate.ts new file mode 100644 index 00000000..4cf6b558 --- /dev/null +++ b/apps/api/src/infrastructure/email/templates/passwordResetTemplate.ts @@ -0,0 +1,31 @@ +export function buildPasswordResetHtml(resetUrl: string): string { + return ` + + + +
+ +
+

Reset your password

+
+ + +
+

+ We received a request to reset the password for your Job Finder account. Click the + button below to choose a new password. This link expires in 1 hour. +

+

+ + Reset password + +

+

+ If you didn't request this, you can safely ignore this email — your password won't be + changed. +

+
+
+ +`; +} diff --git a/apps/api/src/infrastructure/rateLimit/RateLimiter.ts b/apps/api/src/infrastructure/rateLimit/RateLimiter.ts new file mode 100644 index 00000000..12b5a662 --- /dev/null +++ b/apps/api/src/infrastructure/rateLimit/RateLimiter.ts @@ -0,0 +1,36 @@ +import type { IRateLimiter } from '@/use-cases/ports/IRateLimiter.js'; + +interface Bucket { + count: number; + resetAt: number; +} + +/** + * In-process, fixed-window rate limiter. Suitable for a single Fastify + * instance (no shared store) — buckets live in memory and are never + * persisted, so limits reset on process restart and aren't shared across + * horizontally-scaled instances. + */ +export class RateLimiter implements IRateLimiter { + private readonly buckets = new Map(); + + constructor( + private readonly maxAttempts: number, + private readonly windowMs: number, + ) {} + + consume(key: string): boolean { + const now = Date.now(); + const bucket = this.buckets.get(key); + + if (!bucket || now > bucket.resetAt) { + this.buckets.set(key, { count: 1, resetAt: now + this.windowMs }); + return true; + } + + if (bucket.count >= this.maxAttempts) return false; + + bucket.count += 1; + return true; + } +} diff --git a/apps/api/src/interface-adapters/resolvers/AuthResolver.ts b/apps/api/src/interface-adapters/resolvers/AuthResolver.ts index 1aca855e..081fa0b0 100644 --- a/apps/api/src/interface-adapters/resolvers/AuthResolver.ts +++ b/apps/api/src/interface-adapters/resolvers/AuthResolver.ts @@ -1,5 +1,7 @@ import type { IRegisterUseCase } from '@/use-cases/auth/IRegisterUseCase.js'; import type { ILoginUseCase } from '@/use-cases/auth/ILoginUseCase.js'; +import type { IRequestPasswordResetUseCase } from '@/use-cases/auth/IRequestPasswordResetUseCase.js'; +import type { IResetPasswordUseCase } from '@/use-cases/auth/IResetPasswordUseCase.js'; import type { IVerifyEmailUseCase } from '@/use-cases/auth/IVerifyEmailUseCase.js'; import type { ITokenService, TokenPair } from '@/use-cases/ports/ITokenService.js'; import type { CreateSessionUseCase } from '@/use-cases/sessions/CreateSessionUseCase.js'; @@ -9,6 +11,8 @@ interface Deps { registerUseCase: IRegisterUseCase; loginUseCase: ILoginUseCase; tokenService: ITokenService; + requestPasswordResetUseCase: IRequestPasswordResetUseCase; + resetPasswordUseCase: IResetPasswordUseCase; createSessionUseCase: CreateSessionUseCase; touchSessionUseCase: TouchSessionUseCase; verifyEmailUseCase: IVerifyEmailUseCase; @@ -48,4 +52,12 @@ export class AuthResolver { async verifyEmail(token: string): Promise { await this.deps.verifyEmailUseCase.execute({ token }); } + + async requestPasswordReset(email: string, ipAddress: string | null): Promise { + await this.deps.requestPasswordResetUseCase.execute({ email, ipAddress }); + } + + async resetPassword(token: string, newPassword: string): Promise { + await this.deps.resetPasswordUseCase.execute({ token, newPassword }); + } } diff --git a/apps/api/src/use-cases/auth/IRequestPasswordResetUseCase.ts b/apps/api/src/use-cases/auth/IRequestPasswordResetUseCase.ts new file mode 100644 index 00000000..1aa303ba --- /dev/null +++ b/apps/api/src/use-cases/auth/IRequestPasswordResetUseCase.ts @@ -0,0 +1,8 @@ +export interface RequestPasswordResetInput { + email: string; + ipAddress: string | null; +} + +export interface IRequestPasswordResetUseCase { + execute(input: RequestPasswordResetInput): Promise; +} diff --git a/apps/api/src/use-cases/auth/IResetPasswordUseCase.ts b/apps/api/src/use-cases/auth/IResetPasswordUseCase.ts new file mode 100644 index 00000000..db980054 --- /dev/null +++ b/apps/api/src/use-cases/auth/IResetPasswordUseCase.ts @@ -0,0 +1,8 @@ +export interface ResetPasswordInput { + token: string; + newPassword: string; +} + +export interface IResetPasswordUseCase { + execute(input: ResetPasswordInput): Promise; +} diff --git a/apps/api/src/use-cases/auth/RequestPasswordResetUseCase.ts b/apps/api/src/use-cases/auth/RequestPasswordResetUseCase.ts new file mode 100644 index 00000000..c7a1bb36 --- /dev/null +++ b/apps/api/src/use-cases/auth/RequestPasswordResetUseCase.ts @@ -0,0 +1,66 @@ +import { createHash, randomBytes } from 'crypto'; +import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js'; +import type { IPasswordResetTokenRepository } from '@/use-cases/ports/IPasswordResetTokenRepository.js'; +import type { IEmailService } from '@/use-cases/ports/IEmailService.js'; +import type { IRateLimiter } from '@/use-cases/ports/IRateLimiter.js'; +import { ERROR_CODES, PASSWORD_RESET_TOKEN } from '@/constants.js'; +import type { + IRequestPasswordResetUseCase, + RequestPasswordResetInput, +} from '@/use-cases/auth/IRequestPasswordResetUseCase.js'; + +interface Deps { + userRepository: IUserRepository; + passwordResetTokenRepository: IPasswordResetTokenRepository; + emailService: IEmailService; + passwordResetRateLimiter: IRateLimiter; + generateId: () => string; + webAppOrigin: string; +} + +export class RequestPasswordResetUseCase implements IRequestPasswordResetUseCase { + constructor(private readonly deps: Deps) {} + + async execute(input: RequestPasswordResetInput): Promise { + // Rate-limit by both email and IP *before* looking the account up, and by + // the exact same amount of work regardless of outcome, so a rate-limit + // response never reveals whether the account exists. + const emailAllowed = this.deps.passwordResetRateLimiter.consume( + `password-reset:email:${input.email.toLowerCase()}`, + ); + const ipAllowed = input.ipAddress + ? this.deps.passwordResetRateLimiter.consume(`password-reset:ip:${input.ipAddress}`) + : true; + if (!emailAllowed || !ipAllowed) { + throw Object.assign(new Error('Too many password reset requests. Try again later.'), { + code: ERROR_CODES.RATE_LIMITED, + }); + } + + const user = await this.deps.userRepository.findByEmail(input.email); + // Silently no-op for unknown emails so this endpoint can't be used to enumerate accounts. + if (!user) return; + + await this.deps.passwordResetTokenRepository.deleteAllForUser(user.id); + + const rawToken = randomBytes(PASSWORD_RESET_TOKEN.RANDOM_BYTES).toString('hex'); + const tokenHash = createHash('sha256').update(rawToken).digest('hex'); + const expiresAt = new Date(Date.now() + PASSWORD_RESET_TOKEN.TTL_MS); + + await this.deps.passwordResetTokenRepository.create({ + id: this.deps.generateId(), + userId: user.id, + tokenHash, + expiresAt, + }); + + const resetUrl = `${this.deps.webAppOrigin}/reset-password?token=${rawToken}`; + try { + await this.deps.emailService.sendPasswordReset(user.email, resetUrl); + } catch { + // An email-provider failure must not surface differently than the + // silent no-op above for unknown emails — otherwise it becomes an + // enumeration oracle (errors only ever occur for real accounts). + } + } +} diff --git a/apps/api/src/use-cases/auth/ResetPasswordUseCase.ts b/apps/api/src/use-cases/auth/ResetPasswordUseCase.ts new file mode 100644 index 00000000..934309f8 --- /dev/null +++ b/apps/api/src/use-cases/auth/ResetPasswordUseCase.ts @@ -0,0 +1,45 @@ +import { createHash } from 'crypto'; +import bcrypt from 'bcryptjs'; +import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js'; +import type { IPasswordResetTokenRepository } from '@/use-cases/ports/IPasswordResetTokenRepository.js'; +import type { ISessionRepository } from '@/use-cases/ports/ISessionRepository.js'; +import { ERROR_CODES, PASSWORD_MIN_LENGTH } from '@/constants.js'; +import type { + IResetPasswordUseCase, + ResetPasswordInput, +} from '@/use-cases/auth/IResetPasswordUseCase.js'; + +interface Deps { + userRepository: IUserRepository; + passwordResetTokenRepository: IPasswordResetTokenRepository; + sessionRepository: ISessionRepository; +} + +export class ResetPasswordUseCase implements IResetPasswordUseCase { + constructor(private readonly deps: Deps) {} + + async execute(input: ResetPasswordInput): Promise { + if (input.newPassword.length < PASSWORD_MIN_LENGTH) { + throw Object.assign( + new Error(`Password must be at least ${PASSWORD_MIN_LENGTH} characters`), + { code: ERROR_CODES.VALIDATION }, + ); + } + + const tokenHash = createHash('sha256').update(input.token).digest('hex'); + const resetToken = await this.deps.passwordResetTokenRepository.findByTokenHash(tokenHash); + + if (!resetToken || resetToken.usedAt || resetToken.expiresAt < new Date()) { + throw Object.assign(new Error('Invalid or expired reset link'), { + code: ERROR_CODES.UNAUTHORIZED, + }); + } + + const passwordHash = await bcrypt.hash(input.newPassword, 12); + await this.deps.userRepository.update(resetToken.userId, { passwordHash }); + await this.deps.passwordResetTokenRepository.markUsed(resetToken.id); + // Invalidate every existing session so a refresh token stolen before the + // reset can't survive it — otherwise the whole point of the reset is defeated. + await this.deps.sessionRepository.revokeAllForUser(resetToken.userId); + } +} diff --git a/apps/api/src/use-cases/ports/IEmailService.ts b/apps/api/src/use-cases/ports/IEmailService.ts index a8e228c7..779bb9e7 100644 --- a/apps/api/src/use-cases/ports/IEmailService.ts +++ b/apps/api/src/use-cases/ports/IEmailService.ts @@ -9,5 +9,6 @@ export interface WeeklyDigestData { export interface IEmailService { sendFollowUpReminder(to: string, company: string, role: string, followUpAt: Date): Promise; sendWeeklyDigest(to: string, data: WeeklyDigestData): Promise; + sendPasswordReset(to: string, resetUrl: string): Promise; sendEmailVerification(to: string, verifyUrl: string): Promise; } diff --git a/apps/api/src/use-cases/ports/IPasswordResetTokenRepository.ts b/apps/api/src/use-cases/ports/IPasswordResetTokenRepository.ts new file mode 100644 index 00000000..01ea5dda --- /dev/null +++ b/apps/api/src/use-cases/ports/IPasswordResetTokenRepository.ts @@ -0,0 +1,13 @@ +import type { PasswordResetToken } from '@/domain/passwordResetToken/PasswordResetToken.js'; + +export interface IPasswordResetTokenRepository { + 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/IRateLimiter.ts b/apps/api/src/use-cases/ports/IRateLimiter.ts new file mode 100644 index 00000000..f9bfc2b3 --- /dev/null +++ b/apps/api/src/use-cases/ports/IRateLimiter.ts @@ -0,0 +1,4 @@ +export interface IRateLimiter { + /** Returns true if the request identified by `key` is allowed, false if it should be rejected. */ + consume(key: string): boolean; +} diff --git a/apps/api/src/use-cases/ports/ISessionRepository.ts b/apps/api/src/use-cases/ports/ISessionRepository.ts index bf3980c1..a20ca40a 100644 --- a/apps/api/src/use-cases/ports/ISessionRepository.ts +++ b/apps/api/src/use-cases/ports/ISessionRepository.ts @@ -16,4 +16,5 @@ export interface ISessionRepository { touch(id: string, expiresAt: Date): Promise; revoke(id: string): Promise; revokeAllForUserExcept(userId: string, exceptId: string): Promise; + revokeAllForUser(userId: string): Promise; } diff --git a/apps/web/src/__tests__/components/ForgotPasswordPage.test.tsx b/apps/web/src/__tests__/components/ForgotPasswordPage.test.tsx new file mode 100644 index 00000000..1cf3ef33 --- /dev/null +++ b/apps/web/src/__tests__/components/ForgotPasswordPage.test.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +const { mockGqlRequest } = vi.hoisted(() => ({ + mockGqlRequest: vi.fn(), +})); + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: () => (opts: unknown) => opts, +})); + +vi.mock('#/graphql/client', () => ({ + gqlClient: { request: mockGqlRequest }, +})); + +import { ForgotPasswordPage } from '#/routes/forgot-password'; + +describe('ForgotPasswordPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the email input', () => { + render(); + expect(screen.getByPlaceholderText('you@example.com')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /send reset link/i })).toBeInTheDocument(); + }); + + it('shows a validation error for an invalid email', async () => { + render(); + // 'user@domain' passes HTML5 email validation (so jsdom won't block submit) + // but fails Zod's stricter email regex (requires a proper TLD like .com) + fireEvent.change(screen.getByPlaceholderText('you@example.com'), { + target: { value: 'user@domain' }, + }); + fireEvent.click(screen.getByRole('button', { name: /send reset link/i })); + + await waitFor(() => { + expect(screen.getByText('Invalid email')).toBeInTheDocument(); + }); + expect(mockGqlRequest).not.toHaveBeenCalled(); + }); + + it('calls requestPasswordReset with the given email and shows the confirmation message', async () => { + mockGqlRequest.mockResolvedValue({ requestPasswordReset: true }); + render(); + + fireEvent.change(screen.getByPlaceholderText('you@example.com'), { + target: { value: 'test@example.com' }, + }); + fireEvent.click(screen.getByRole('button', { name: /send reset link/i })); + + await waitFor(() => { + expect(mockGqlRequest).toHaveBeenCalledWith(expect.stringContaining('RequestPasswordReset'), { + email: 'test@example.com', + }); + }); + await waitFor(() => { + expect(screen.getByText(/we've sent a password reset link/i)).toBeInTheDocument(); + }); + }); + + it('shows the same confirmation message even when the backend errors', async () => { + // The mutation always resolves true regardless of whether the email is + // known, so there is no error path to distinguish in the UI. + mockGqlRequest.mockResolvedValue({ requestPasswordReset: true }); + render(); + + fireEvent.change(screen.getByPlaceholderText('you@example.com'), { + target: { value: 'unknown@example.com' }, + }); + fireEvent.click(screen.getByRole('button', { name: /send reset link/i })); + + await waitFor(() => { + expect(screen.getByText(/we've sent a password reset link/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/__tests__/components/LoginPage.test.tsx b/apps/web/src/__tests__/components/LoginPage.test.tsx index 91a50a36..a36f09e6 100644 --- a/apps/web/src/__tests__/components/LoginPage.test.tsx +++ b/apps/web/src/__tests__/components/LoginPage.test.tsx @@ -43,6 +43,14 @@ describe('LoginPage', () => { expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument(); }); + it('links to the forgot-password page', () => { + render(); + expect(screen.getByRole('link', { name: /forgot password/i })).toHaveAttribute( + 'href', + '/forgot-password', + ); + }); + it('shows validation error for invalid email', async () => { const user = userEvent.setup(); render(); diff --git a/apps/web/src/__tests__/components/ResetPasswordPage.test.tsx b/apps/web/src/__tests__/components/ResetPasswordPage.test.tsx new file mode 100644 index 00000000..f36962bd --- /dev/null +++ b/apps/web/src/__tests__/components/ResetPasswordPage.test.tsx @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, 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 { ResetPasswordPage } from '#/routes/reset-password'; + +describe('ResetPasswordPage', () => { + 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 reset link is invalid/i)).toBeInTheDocument(); + expect(screen.queryByPlaceholderText('••••••••')).not.toBeInTheDocument(); + }); + + it('renders the password fields when a token is present', () => { + render(); + expect(screen.getAllByPlaceholderText('••••••••')).toHaveLength(2); + expect(screen.getByRole('button', { name: /reset password/i })).toBeInTheDocument(); + }); + + it('shows a validation error when passwords do not match', async () => { + render(); + const inputs = screen.getAllByPlaceholderText('••••••••'); + + fireEvent.change(inputs[0], { target: { value: 'newPassword123' } }); + fireEvent.change(inputs[1], { target: { value: 'differentPassword' } }); + fireEvent.click(screen.getByRole('button', { name: /reset password/i })); + + await waitFor(() => { + expect(screen.getByText('Passwords do not match')).toBeInTheDocument(); + }); + expect(mockGqlRequest).not.toHaveBeenCalled(); + }); + + it('shows a validation error when the password is too short', async () => { + render(); + const inputs = screen.getAllByPlaceholderText('••••••••'); + + fireEvent.change(inputs[0], { target: { value: 'short' } }); + fireEvent.change(inputs[1], { target: { value: 'short' } }); + fireEvent.click(screen.getByRole('button', { name: /reset password/i })); + + await waitFor(() => { + expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument(); + }); + expect(mockGqlRequest).not.toHaveBeenCalled(); + }); + + it('calls resetPassword with the token and new password, then shows success', async () => { + mockGqlRequest.mockResolvedValue({ resetPassword: true }); + render(); + const inputs = screen.getAllByPlaceholderText('••••••••'); + + fireEvent.change(inputs[0], { target: { value: 'newPassword123' } }); + fireEvent.change(inputs[1], { target: { value: 'newPassword123' } }); + fireEvent.click(screen.getByRole('button', { name: /reset password/i })); + + await waitFor(() => { + expect(mockGqlRequest).toHaveBeenCalledWith(expect.stringContaining('ResetPassword'), { + token: 'valid-token', + newPassword: 'newPassword123', + }); + }); + await waitFor(() => { + expect(screen.getByText('Your password has been reset.')).toBeInTheDocument(); + }); + }); + + it('shows an error message when the reset link is invalid or expired', async () => { + mockGqlRequest.mockRejectedValue({ + response: { errors: [{ message: 'Invalid or expired reset link' }] }, + }); + render(); + const inputs = screen.getAllByPlaceholderText('••••••••'); + + fireEvent.change(inputs[0], { target: { value: 'newPassword123' } }); + fireEvent.change(inputs[1], { target: { value: 'newPassword123' } }); + fireEvent.click(screen.getByRole('button', { name: /reset password/i })); + + await waitFor(() => { + expect(screen.getByText('Invalid or expired reset link')).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 774b6a8e..83232298 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,8 +11,10 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as AuthenticatedRouteRouteImport } from './routes/_authenticated/route' +import { Route as ForgotPasswordRouteImport } from './routes/forgot-password' import { Route as LoginRouteImport } from './routes/login' import { Route as RegisterRouteImport } from './routes/register' +import { Route as ResetPasswordRouteImport } from './routes/reset-password' import { Route as VerifyEmailRouteImport } from './routes/verify-email' import { Route as AuthenticatedAccountRouteImport } from './routes/_authenticated/account' import { Route as AuthenticatedAnalyticsRouteImport } from './routes/_authenticated/analytics' @@ -32,6 +34,11 @@ const AuthenticatedRouteRoute = AuthenticatedRouteRouteImport.update({ id: '/_authenticated', getParentRoute: () => rootRouteImport, } as any) +const ForgotPasswordRoute = ForgotPasswordRouteImport.update({ + id: '/forgot-password', + path: '/forgot-password', + getParentRoute: () => rootRouteImport, +} as any) const LoginRoute = LoginRouteImport.update({ id: '/login', path: '/login', @@ -42,6 +49,11 @@ const RegisterRoute = RegisterRouteImport.update({ path: '/register', getParentRoute: () => rootRouteImport, } as any) +const ResetPasswordRoute = ResetPasswordRouteImport.update({ + id: '/reset-password', + path: '/reset-password', + getParentRoute: () => rootRouteImport, +} as any) const VerifyEmailRoute = VerifyEmailRouteImport.update({ id: '/verify-email', path: '/verify-email', @@ -95,8 +107,10 @@ const AuthenticatedApplicationsApplicationIdEditRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/forgot-password': typeof ForgotPasswordRoute '/login': typeof LoginRoute '/register': typeof RegisterRoute + '/reset-password': typeof ResetPasswordRoute '/verify-email': typeof VerifyEmailRoute '/account': typeof AuthenticatedAccountRoute '/analytics': typeof AuthenticatedAnalyticsRoute @@ -109,8 +123,10 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/forgot-password': typeof ForgotPasswordRoute '/login': typeof LoginRoute '/register': typeof RegisterRoute + '/reset-password': typeof ResetPasswordRoute '/verify-email': typeof VerifyEmailRoute '/account': typeof AuthenticatedAccountRoute '/analytics': typeof AuthenticatedAnalyticsRoute @@ -125,8 +141,10 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/_authenticated': typeof AuthenticatedRouteRouteWithChildren + '/forgot-password': typeof ForgotPasswordRoute '/login': typeof LoginRoute '/register': typeof RegisterRoute + '/reset-password': typeof ResetPasswordRoute '/verify-email': typeof VerifyEmailRoute '/_authenticated/account': typeof AuthenticatedAccountRoute '/_authenticated/analytics': typeof AuthenticatedAnalyticsRoute @@ -141,8 +159,10 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/forgot-password' | '/login' | '/register' + | '/reset-password' | '/verify-email' | '/account' | '/analytics' @@ -155,8 +175,10 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/forgot-password' | '/login' | '/register' + | '/reset-password' | '/verify-email' | '/account' | '/analytics' @@ -170,8 +192,10 @@ export interface FileRouteTypes { | '__root__' | '/' | '/_authenticated' + | '/forgot-password' | '/login' | '/register' + | '/reset-password' | '/verify-email' | '/_authenticated/account' | '/_authenticated/analytics' @@ -186,8 +210,10 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute AuthenticatedRouteRoute: typeof AuthenticatedRouteRouteWithChildren + ForgotPasswordRoute: typeof ForgotPasswordRoute LoginRoute: typeof LoginRoute RegisterRoute: typeof RegisterRoute + ResetPasswordRoute: typeof ResetPasswordRoute VerifyEmailRoute: typeof VerifyEmailRoute } @@ -207,6 +233,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedRouteRouteImport parentRoute: typeof rootRouteImport } + '/forgot-password': { + id: '/forgot-password' + path: '/forgot-password' + fullPath: '/forgot-password' + preLoaderRoute: typeof ForgotPasswordRouteImport + parentRoute: typeof rootRouteImport + } '/login': { id: '/login' path: '/login' @@ -221,6 +254,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RegisterRouteImport parentRoute: typeof rootRouteImport } + '/reset-password': { + id: '/reset-password' + path: '/reset-password' + fullPath: '/reset-password' + preLoaderRoute: typeof ResetPasswordRouteImport + parentRoute: typeof rootRouteImport + } '/verify-email': { id: '/verify-email' path: '/verify-email' @@ -317,8 +357,10 @@ const AuthenticatedRouteRouteWithChildren = const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AuthenticatedRouteRoute: AuthenticatedRouteRouteWithChildren, + ForgotPasswordRoute: ForgotPasswordRoute, LoginRoute: LoginRoute, RegisterRoute: RegisterRoute, + ResetPasswordRoute: ResetPasswordRoute, VerifyEmailRoute: VerifyEmailRoute, } export const routeTree = rootRouteImport diff --git a/apps/web/src/routes/forgot-password.tsx b/apps/web/src/routes/forgot-password.tsx new file mode 100644 index 00000000..5bbf1ffe --- /dev/null +++ b/apps/web/src/routes/forgot-password.tsx @@ -0,0 +1,87 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { gqlClient } from '#/graphql/client'; + +const schema = z.object({ + email: z.string().email('Invalid email'), +}); +type FormValues = z.infer; + +const REQUEST_PASSWORD_RESET_MUTATION = ` + mutation RequestPasswordReset($email: String!) { + requestPasswordReset(email: $email) + } +`; + +export const Route = createFileRoute('/forgot-password')({ + component: ForgotPasswordPage, +}); + +export function ForgotPasswordPage() { + const { + register, + handleSubmit, + formState: { errors, isSubmitting, isSubmitSuccessful }, + } = useForm({ + resolver: zodResolver(schema), + }); + + const onSubmit = async (data: FormValues) => { + // Always resolves — the backend responds identically for known and unknown + // emails so this form can't be used to enumerate accounts. + await gqlClient.request(REQUEST_PASSWORD_RESET_MUTATION, data); + }; + + return ( +
+
+
+

+ Forgot your password? +

+

+ Enter your email and we'll send you a link to reset it. +

+
+ + {isSubmitSuccessful ? ( +

+ If an account exists for that email, we've sent a password reset link. Check your + inbox. +

+ ) : ( +
+
+ + + {errors.email &&

{errors.email.message}

} +
+ + +
+ )} + +

+ + Back to sign in + +

+
+
+ ); +} diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index 5c4771dc..9bf73b27 100644 --- a/apps/web/src/routes/login.tsx +++ b/apps/web/src/routes/login.tsx @@ -76,9 +76,14 @@ export function LoginPage() {
- +
+ + + Forgot password? + +
d.newPassword === d.confirmPassword, { + message: 'Passwords do not match', + path: ['confirmPassword'], + }); +type FormValues = z.infer; + +const RESET_PASSWORD_MUTATION = ` + mutation ResetPassword($token: String!, $newPassword: String!) { + resetPassword(token: $token, newPassword: $newPassword) + } +`; + +export const Route = createFileRoute('/reset-password')({ + validateSearch: searchSchema, + component: ResetPasswordPage, +}); + +export function ResetPasswordPage() { + const { token } = Route.useSearch(); + const { + register, + handleSubmit, + formState: { errors, isSubmitting, isSubmitSuccessful }, + setError, + } = useForm({ + resolver: zodResolver(schema), + }); + + const onSubmit = async (data: FormValues) => { + if (!token) return; + try { + await gqlClient.request(RESET_PASSWORD_MUTATION, { token, newPassword: data.newPassword }); + } catch (err: unknown) { + const msg = extractGqlError(err) ?? 'Failed to reset password. Please try again.'; + setError('root', { message: msg }); + } + }; + + return ( +
+
+
+

+ Reset your password +

+
+ + {!token ? ( +

+ This reset link is invalid. Please request a new one. +

+ ) : isSubmitSuccessful ? ( +

+ Your password has been reset. +

+ ) : ( +
+
+ + + {errors.newPassword && ( +

{errors.newPassword.message}

+ )} +
+ +
+ + + {errors.confirmPassword && ( +

{errors.confirmPassword.message}

+ )} +
+ + {errors.root && ( +

+ {errors.root.message} +

+ )} + + +
+ )} + +

+ + 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; +}