diff --git a/.gitignore b/.gitignore index c7b5d6df..6f125eed 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ apps/api/uploads/ # Local git worktrees for parallel agent work — never commit .worktrees/ +.claude/worktrees/ diff --git a/apps/api/drizzle/0059_password_setup_tokens.sql b/apps/api/drizzle/0059_password_setup_tokens.sql new file mode 100644 index 00000000..e39c6249 --- /dev/null +++ b/apps/api/drizzle/0059_password_setup_tokens.sql @@ -0,0 +1,18 @@ +-- Tokens de «crea tu contraseña» para perfiles sin contraseña (#204, parte de +-- #168): un donante que pre-registra sin cuenta recibe un enlace de un solo uso +-- y con caducidad para establecer su contraseña. Se guarda SOLO el hash SHA-256 +-- del token (nunca el valor en claro), único para que la búsqueda sea una +-- igualdad indexada. Un token se consume marcando used_at; es válido mientras +-- used_at IS NULL AND expires_at > now(). FK en cascada: al borrar el usuario +-- desaparecen sus tokens. +CREATE TABLE IF NOT EXISTS password_setup_tokens ( + id uuid PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash text NOT NULL UNIQUE, + expires_at timestamptz NOT NULL, + used_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS password_setup_tokens_user_idx + ON password_setup_tokens (user_id); diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 960589b7..e255b3ae 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -83,6 +83,72 @@ ] } }, + "/auth/set-password": { + "post": { + "operationId": "AuthController_setPasswordRoute", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetPasswordDto" + } + } + } + }, + "responses": { + "200": { + "description": "Contraseña establecida", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetPasswordResponseDto" + } + } + } + }, + "400": { + "description": "Token inválido, caducado o ya usado" + }, + "429": { + "description": "Rate limit exceeded — try again later" + } + }, + "summary": "Establecer la contraseña de un perfil sin contraseña con un token de un solo uso (auto-login: devuelve un JWT)", + "tags": [ + "auth" + ] + } + }, + "/auth/set-password/request": { + "post": { + "operationId": "AuthController_requestPasswordSetupRoute", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestPasswordSetupDto" + } + } + } + }, + "responses": { + "202": { + "description": "Solicitud aceptada (se envía el email si procede)" + }, + "429": { + "description": "Rate limit exceeded — try again later" + } + }, + "summary": "Reenviar el email de «crea tu contraseña» a un perfil sin contraseña. Responde 202 siempre (no revela si el email existe).", + "tags": [ + "auth" + ] + } + }, "/auth/onboarding": { "post": { "operationId": "AuthController_onboardingRoute", @@ -9189,6 +9255,49 @@ "accessToken" ] }, + "SetPasswordDto": { + "type": "object", + "properties": { + "token": { + "type": "string", + "description": "Token de un solo uso recibido en el email de set-password" + }, + "password": { + "type": "string", + "example": "mi-nueva-contrasena", + "minLength": 8 + } + }, + "required": [ + "token", + "password" + ] + }, + "SetPasswordResponseDto": { + "type": "object", + "properties": { + "accessToken": { + "type": "string", + "description": "JWT access token (auto-login tras establecer la contraseña)" + } + }, + "required": [ + "accessToken" + ] + }, + "RequestPasswordSetupDto": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "donor@example.com", + "description": "Email del perfil sin contraseña al que reenviar el enlace de set-password. La respuesta es 202 siempre, exista o no la cuenta." + } + }, + "required": [ + "email" + ] + }, "OnboardingDto": { "type": "object", "properties": { diff --git a/apps/api/src/contexts/identity/application/request-password-setup.spec.ts b/apps/api/src/contexts/identity/application/request-password-setup.spec.ts new file mode 100644 index 00000000..98e6f184 --- /dev/null +++ b/apps/api/src/contexts/identity/application/request-password-setup.spec.ts @@ -0,0 +1,64 @@ +import { RequestPasswordSetup } from './request-password-setup'; +import { InMemoryUserRepository } from '../infrastructure/in-memory-user.repository'; +import { + SetPasswordInvite, + SetPasswordInviter, +} from '../domain/ports/set-password-inviter'; +import { User } from '../domain/user'; +import { UserId } from '../domain/user-id'; +import { Email } from '../domain/email'; + +class RecordingInviter implements SetPasswordInviter { + readonly invites: SetPasswordInvite[] = []; + invite(invite: SetPasswordInvite): Promise { + this.invites.push(invite); + return Promise.resolve(); + } +} + +describe('RequestPasswordSetup', () => { + let users: InMemoryUserRepository; + let inviter: RecordingInviter; + let request: RequestPasswordSetup; + + beforeEach(() => { + users = new InMemoryUserRepository(); + inviter = new RecordingInviter(); + request = new RequestPasswordSetup(users, inviter); + }); + + async function saveUser(email: string, passwordHash: string | null) { + await users.save( + User.create({ + id: UserId.create(), + email: Email.fromString(email), + passwordHash, + name: 'Donante', + isAdmin: false, + }), + ); + } + + it('invites a passwordless profile', async () => { + await saveUser('donor@example.com', null); + await request.execute({ email: 'donor@example.com' }); + expect(inviter.invites).toHaveLength(1); + expect(inviter.invites[0].email).toBe('donor@example.com'); + }); + + it('does not invite an account that already has a password', async () => { + await saveUser('member@example.com', 'existing-hash'); + await request.execute({ email: 'member@example.com' }); + expect(inviter.invites).toHaveLength(0); + }); + + it('is a silent no-op for an unknown email (no enumeration signal)', async () => { + await request.execute({ email: 'nobody@example.com' }); + expect(inviter.invites).toHaveLength(0); + }); + + it('is a silent no-op for a malformed email', async () => { + await request.execute({ email: 'not-an-email' }); + expect(inviter.invites).toHaveLength(0); + }); +}); diff --git a/apps/api/src/contexts/identity/application/request-password-setup.ts b/apps/api/src/contexts/identity/application/request-password-setup.ts new file mode 100644 index 00000000..63657df2 --- /dev/null +++ b/apps/api/src/contexts/identity/application/request-password-setup.ts @@ -0,0 +1,42 @@ +import { UserRepository } from '../domain/ports/user.repository'; +import { SetPasswordInviter } from '../domain/ports/set-password-inviter'; +import { Email } from '../domain/email'; + +export interface RequestPasswordSetupCommand { + email: string; +} + +/** + * Resend the "create your password" email for a passwordless profile (#204). + * + * SECURITY: account-enumeration safe — it returns nothing and the caller always + * responds 202 regardless of outcome. An invite is only actually sent when the + * email maps to an existing account that has NO password yet (a passwordless + * donor profile). A malformed email, an unknown email, or an account that + * already has a password are all silent no-ops, so the endpoint can't be used to + * discover which emails exist or to spam a real user with reset links. + */ +export class RequestPasswordSetup { + constructor( + private readonly users: UserRepository, + private readonly inviter: SetPasswordInviter, + ) {} + + async execute(cmd: RequestPasswordSetupCommand): Promise { + let email: Email; + try { + email = Email.fromString(cmd.email); + } catch { + return; + } + + const user = await this.users.findByEmail(email); + if (!user || user.passwordHash !== null) return; + + await this.inviter.invite({ + userId: user.id.value, + email: user.email.value, + name: user.name, + }); + } +} diff --git a/apps/api/src/contexts/identity/application/set-password.spec.ts b/apps/api/src/contexts/identity/application/set-password.spec.ts new file mode 100644 index 00000000..93152f6b --- /dev/null +++ b/apps/api/src/contexts/identity/application/set-password.spec.ts @@ -0,0 +1,126 @@ +import { SetPassword } from './set-password'; +import { InMemoryUserRepository } from '../infrastructure/in-memory-user.repository'; +import { InMemoryPasswordSetupTokenRepository } from '../infrastructure/in-memory-password-setup-token.repository'; +import { PasswordHasher } from '../domain/ports/password-hasher'; +import { TokenService, TokenPayload } from '../domain/ports/token.service'; +import { PasswordSetupToken } from '../domain/password-setup-token'; +import { + generateSetupToken, + hashSetupToken, +} from '../domain/password-setup-token-generator'; +import { User } from '../domain/user'; +import { UserId } from '../domain/user-id'; +import { Email } from '../domain/email'; +import { InvalidPasswordSetupTokenError } from '../domain/invalid-password-setup-token.error'; + +class FakeHasher implements PasswordHasher { + hash(plain: string): Promise { + return Promise.resolve(`hashed:${plain}`); + } + compare(plain: string, hash: string): Promise { + return Promise.resolve(hash === `hashed:${plain}`); + } +} + +class FakeTokenService implements TokenService { + sign(payload: TokenPayload): string { + return `jwt:${payload.sub}`; + } + verify(): TokenPayload { + throw new Error('not used'); + } +} + +const USER_ID = '22222222-2222-4222-8222-222222222222'; + +describe('SetPassword', () => { + let users: InMemoryUserRepository; + let tokens: InMemoryPasswordSetupTokenRepository; + let setPassword: SetPassword; + + beforeEach(async () => { + users = new InMemoryUserRepository(); + tokens = new InMemoryPasswordSetupTokenRepository(); + setPassword = new SetPassword( + tokens, + users, + new FakeHasher(), + new FakeTokenService(), + ); + await users.save( + User.create({ + id: UserId.fromString(USER_ID), + email: Email.fromString('donor@example.com'), + passwordHash: null, + name: 'Donante', + isAdmin: false, + }), + ); + }); + + async function issueToken(overrides?: { + plaintext?: string; + expiresAt?: Date; + used?: boolean; + }): Promise { + const { plaintext, hash } = overrides?.plaintext + ? { + plaintext: overrides.plaintext, + hash: hashSetupToken(overrides.plaintext), + } + : generateSetupToken(); + let token = PasswordSetupToken.issue({ + id: '11111111-1111-4111-8111-111111111111', + userId: USER_ID, + tokenHash: hash, + expiresAt: overrides?.expiresAt ?? new Date(Date.now() + 3_600_000), + }); + if (overrides?.used) token = token.markUsed(new Date()); + await tokens.save(token); + return plaintext; + } + + it('sets the password with a valid token and returns a JWT', async () => { + const plaintext = await issueToken(); + + const res = await setPassword.execute({ + token: plaintext, + newPassword: 'my-new-pass', + }); + + expect(res.accessToken).toBe(`jwt:${USER_ID}`); + const user = await users.findById(UserId.fromString(USER_ID)); + expect(user!.passwordHash).toBe('hashed:my-new-pass'); + }); + + it('invalidates the token after use (no replay)', async () => { + const plaintext = await issueToken(); + await setPassword.execute({ token: plaintext, newPassword: 'first-pass' }); + + await expect( + setPassword.execute({ token: plaintext, newPassword: 'second-pass' }), + ).rejects.toBeInstanceOf(InvalidPasswordSetupTokenError); + }); + + it('rejects an expired token', async () => { + const plaintext = await issueToken({ + expiresAt: new Date(Date.now() - 1_000), + }); + await expect( + setPassword.execute({ token: plaintext, newPassword: 'x' }), + ).rejects.toBeInstanceOf(InvalidPasswordSetupTokenError); + }); + + it('rejects an already-used token', async () => { + const plaintext = await issueToken({ used: true }); + await expect( + setPassword.execute({ token: plaintext, newPassword: 'x' }), + ).rejects.toBeInstanceOf(InvalidPasswordSetupTokenError); + }); + + it('rejects an unknown token', async () => { + await expect( + setPassword.execute({ token: 'not-a-real-token', newPassword: 'x' }), + ).rejects.toBeInstanceOf(InvalidPasswordSetupTokenError); + }); +}); diff --git a/apps/api/src/contexts/identity/application/set-password.ts b/apps/api/src/contexts/identity/application/set-password.ts new file mode 100644 index 00000000..657c8f3f --- /dev/null +++ b/apps/api/src/contexts/identity/application/set-password.ts @@ -0,0 +1,56 @@ +import { UserRepository } from '../domain/ports/user.repository'; +import { PasswordHasher } from '../domain/ports/password-hasher'; +import { TokenService } from '../domain/ports/token.service'; +import { PasswordSetupTokenRepository } from '../domain/ports/password-setup-token.repository'; +import { hashSetupToken } from '../domain/password-setup-token-generator'; +import { UserId } from '../domain/user-id'; +import { InvalidPasswordSetupTokenError } from '../domain/invalid-password-setup-token.error'; + +export interface SetPasswordCommand { + /** The raw token from the email link. */ + token: string; + newPassword: string; +} + +/** + * Redeem a set-password token (#204): validate it, set the account's password, + * and consume it (plus any siblings) so it cannot be replayed. Returns a JWT so + * the person is logged straight in and lands on their donations. + * + * SECURITY: a missing / expired / already-used token all raise the SAME error — + * the response never reveals which, so a token cannot be probed. The token is + * looked up by its hash; the raw value is never stored. + */ +export class SetPassword { + constructor( + private readonly tokens: PasswordSetupTokenRepository, + private readonly users: UserRepository, + private readonly hasher: PasswordHasher, + private readonly tokenService: TokenService, + ) {} + + async execute(cmd: SetPasswordCommand): Promise<{ accessToken: string }> { + const now = new Date(); + const token = await this.tokens.findByHash(hashSetupToken(cmd.token)); + if (!token || !token.isUsable(now)) { + throw new InvalidPasswordSetupTokenError(); + } + + const user = await this.users.findById(UserId.fromString(token.userId)); + if (!user) throw new InvalidPasswordSetupTokenError(); + + const passwordHash = await this.hasher.hash(cmd.newPassword); + await this.users.setPassword(user.id, passwordHash); + + // Consume the spent token AND invalidate every other outstanding invite for + // this account in one shot: no link (this one or an earlier resend) survives. + await this.tokens.markUsedForUser(user.id.value, now); + + const accessToken = this.tokenService.sign({ + sub: user.id.value, + email: user.email.value, + isAdmin: user.isAdmin, + }); + return { accessToken }; + } +} diff --git a/apps/api/src/contexts/identity/domain/invalid-password-setup-token.error.ts b/apps/api/src/contexts/identity/domain/invalid-password-setup-token.error.ts new file mode 100644 index 00000000..7a09a961 --- /dev/null +++ b/apps/api/src/contexts/identity/domain/invalid-password-setup-token.error.ts @@ -0,0 +1,11 @@ +/** + * Raised when a presented set-password token does not exist, has expired, or has + * already been used. Deliberately one error for all three cases so the response + * never distinguishes them (no oracle for probing tokens). + */ +export class InvalidPasswordSetupTokenError extends Error { + constructor() { + super('Invalid or expired password setup token'); + this.name = 'InvalidPasswordSetupTokenError'; + } +} diff --git a/apps/api/src/contexts/identity/domain/password-setup-token-generator.spec.ts b/apps/api/src/contexts/identity/domain/password-setup-token-generator.spec.ts new file mode 100644 index 00000000..2f1ce7d1 --- /dev/null +++ b/apps/api/src/contexts/identity/domain/password-setup-token-generator.spec.ts @@ -0,0 +1,25 @@ +import { + generateSetupToken, + hashSetupToken, +} from './password-setup-token-generator'; + +describe('password-setup-token-generator', () => { + it('generates a high-entropy, url-safe plaintext', () => { + const { plaintext } = generateSetupToken(); + // 32 random bytes → 43 base64url chars, only [A-Za-z0-9_-]. + expect(plaintext).toMatch(/^[A-Za-z0-9_-]{43}$/); + }); + + it('never repeats a token', () => { + const seen = new Set(); + for (let i = 0; i < 100; i++) seen.add(generateSetupToken().plaintext); + expect(seen.size).toBe(100); + }); + + it('stores a hash, not the plaintext, and the hash is deterministic', () => { + const { plaintext, hash } = generateSetupToken(); + expect(hash).not.toBe(plaintext); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + expect(hashSetupToken(plaintext)).toBe(hash); + }); +}); diff --git a/apps/api/src/contexts/identity/domain/password-setup-token-generator.ts b/apps/api/src/contexts/identity/domain/password-setup-token-generator.ts new file mode 100644 index 00000000..d8dd2985 --- /dev/null +++ b/apps/api/src/contexts/identity/domain/password-setup-token-generator.ts @@ -0,0 +1,26 @@ +import { randomBytes, createHash } from 'node:crypto'; + +export interface GeneratedSetupToken { + /** The opaque secret handed to the user (in the email link), shown once. */ + plaintext: string; + /** SHA-256 of the plaintext — this is what gets persisted, never the secret. */ + hash: string; +} + +/** + * Generate a password-setup token: 256 bits of cryptographic randomness, + * base64url-encoded so it travels cleanly in a URL query string. Only the SHA-256 + * hash is ever stored, so a database read never yields a usable token. SHA-256 + * (not bcrypt) is appropriate here — the secret is high-entropy, unlike a + * password, and lookups are a plain hash equality on an indexed column, so there + * is no per-character timing side-channel to defend against. + */ +export function generateSetupToken(): GeneratedSetupToken { + const plaintext = randomBytes(32).toString('base64url'); + return { plaintext, hash: hashSetupToken(plaintext) }; +} + +/** Derive the storage/lookup hash from a presented token. */ +export function hashSetupToken(plaintext: string): string { + return createHash('sha256').update(plaintext).digest('hex'); +} diff --git a/apps/api/src/contexts/identity/domain/password-setup-token.spec.ts b/apps/api/src/contexts/identity/domain/password-setup-token.spec.ts new file mode 100644 index 00000000..d66530ed --- /dev/null +++ b/apps/api/src/contexts/identity/domain/password-setup-token.spec.ts @@ -0,0 +1,49 @@ +import { PasswordSetupToken } from './password-setup-token'; + +const base = { + id: '11111111-1111-4111-8111-111111111111', + userId: '22222222-2222-4222-8222-222222222222', + tokenHash: 'a'.repeat(64), +}; + +describe('PasswordSetupToken', () => { + it('is usable while unused and unexpired', () => { + const now = new Date('2026-07-17T12:00:00Z'); + const token = PasswordSetupToken.issue({ + ...base, + expiresAt: new Date('2026-07-19T12:00:00Z'), + createdAt: now, + }); + expect(token.isUsable(now)).toBe(true); + }); + + it('is not usable once expired', () => { + const token = PasswordSetupToken.issue({ + ...base, + expiresAt: new Date('2026-07-17T12:00:00Z'), + }); + expect(token.isUsable(new Date('2026-07-17T12:00:01Z'))).toBe(false); + }); + + it('is not usable once marked used (single-use)', () => { + const now = new Date('2026-07-17T12:00:00Z'); + const token = PasswordSetupToken.issue({ + ...base, + expiresAt: new Date('2026-07-19T12:00:00Z'), + createdAt: now, + }); + const used = token.markUsed(now); + expect(used.isUsable(now)).toBe(false); + expect(used.usedAt).toEqual(now); + }); + + it('round-trips through a snapshot', () => { + const token = PasswordSetupToken.issue({ + ...base, + expiresAt: new Date('2026-07-19T12:00:00Z'), + createdAt: new Date('2026-07-17T12:00:00Z'), + }); + const restored = PasswordSetupToken.fromSnapshot(token.toSnapshot()); + expect(restored.toSnapshot()).toEqual(token.toSnapshot()); + }); +}); diff --git a/apps/api/src/contexts/identity/domain/password-setup-token.ts b/apps/api/src/contexts/identity/domain/password-setup-token.ts new file mode 100644 index 00000000..cfb08d98 --- /dev/null +++ b/apps/api/src/contexts/identity/domain/password-setup-token.ts @@ -0,0 +1,82 @@ +export interface PasswordSetupTokenSnapshot { + id: string; + userId: string; + /** SHA-256 of the secret; the plaintext is never stored. */ + tokenHash: string; + expiresAt: string; + /** When the token was consumed; null while still outstanding. */ + usedAt: string | null; + createdAt: string; +} + +/** + * A single-use, expiring invitation to set the password of a passwordless + * account (#204). Issued when an anonymous donor pre-registers (#168) and again + * on an explicit resend. Only the hash of the secret is held; validity is the + * conjunction of "not yet used" and "not past expiry". + */ +export class PasswordSetupToken { + private constructor( + public readonly id: string, + public readonly userId: string, + public readonly tokenHash: string, + public readonly expiresAt: Date, + public readonly usedAt: Date | null, + public readonly createdAt: Date, + ) {} + + static issue(props: { + id: string; + userId: string; + tokenHash: string; + expiresAt: Date; + createdAt?: Date; + }): PasswordSetupToken { + return new PasswordSetupToken( + props.id, + props.userId, + props.tokenHash, + props.expiresAt, + null, + props.createdAt ?? new Date(), + ); + } + + static fromSnapshot(s: PasswordSetupTokenSnapshot): PasswordSetupToken { + return new PasswordSetupToken( + s.id, + s.userId, + s.tokenHash, + new Date(s.expiresAt), + s.usedAt === null ? null : new Date(s.usedAt), + new Date(s.createdAt), + ); + } + + toSnapshot(): PasswordSetupTokenSnapshot { + return { + id: this.id, + userId: this.userId, + tokenHash: this.tokenHash, + expiresAt: this.expiresAt.toISOString(), + usedAt: this.usedAt === null ? null : this.usedAt.toISOString(), + createdAt: this.createdAt.toISOString(), + }; + } + + /** Usable exactly once, before it expires. */ + isUsable(now: Date): boolean { + return this.usedAt === null && this.expiresAt.getTime() > now.getTime(); + } + + markUsed(now: Date): PasswordSetupToken { + return new PasswordSetupToken( + this.id, + this.userId, + this.tokenHash, + this.expiresAt, + now, + this.createdAt, + ); + } +} diff --git a/apps/api/src/contexts/identity/domain/ports/email-sender.ts b/apps/api/src/contexts/identity/domain/ports/email-sender.ts new file mode 100644 index 00000000..97a90249 --- /dev/null +++ b/apps/api/src/contexts/identity/domain/ports/email-sender.ts @@ -0,0 +1,20 @@ +export const EMAIL_SENDER = Symbol('EmailSender'); + +export interface EmailMessage { + to: string; + subject: string; + /** Plain-text body (fallback for clients that do not render HTML). */ + text: string; + /** HTML body. */ + html: string; +} + +/** + * Output port for transactional email. Kept deliberately small and provider- + * agnostic so a concrete transport (SMTP, SES, a hosted API…) can be dropped in + * without touching callers. The default adapter logs the message — the transport + * is the one remaining swap and needs delivery credentials to wire in prod. + */ +export interface EmailSender { + send(message: EmailMessage): Promise; +} diff --git a/apps/api/src/contexts/identity/domain/ports/password-setup-token.repository.ts b/apps/api/src/contexts/identity/domain/ports/password-setup-token.repository.ts new file mode 100644 index 00000000..cc79d58b --- /dev/null +++ b/apps/api/src/contexts/identity/domain/ports/password-setup-token.repository.ts @@ -0,0 +1,18 @@ +import { PasswordSetupToken } from '../password-setup-token'; + +export const PASSWORD_SETUP_TOKEN_REPOSITORY = Symbol( + 'PasswordSetupTokenRepository', +); + +export interface PasswordSetupTokenRepository { + save(token: PasswordSetupToken): Promise; + /** Look a token up by its stored hash (the raw secret is never stored). */ + findByHash(tokenHash: string): Promise; + /** + * Mark every still-outstanding token of a user as used at `at`. Called on a + * successful set-password: it consumes the token that was just spent AND + * invalidates any other outstanding invites for that account, so no link can + * be replayed. + */ + markUsedForUser(userId: string, at: Date): Promise; +} diff --git a/apps/api/src/contexts/identity/domain/ports/user.repository.ts b/apps/api/src/contexts/identity/domain/ports/user.repository.ts index 69f778bf..80025c5e 100644 --- a/apps/api/src/contexts/identity/domain/ports/user.repository.ts +++ b/apps/api/src/contexts/identity/domain/ports/user.repository.ts @@ -17,6 +17,13 @@ export interface UserRepository { */ findByPhone(phone: string): Promise; save(user: User): Promise; + /** + * Set (or replace) the user's password hash (issue #204). Kept off + * {@link save} — whose upsert deliberately never touches `password_hash`, so a + * profile re-save can't clobber a credential — so a passwordless donor profile + * can gain a password when it redeems a set-password token. + */ + setPassword(id: UserId, passwordHash: string): Promise; /** * Stamp the user's last successful login (issue #176). Kept off {@link save} * (whose upsert only writes name/isAdmin) so login does not rewrite the rest diff --git a/apps/api/src/contexts/identity/infrastructure/drizzle/drizzle-password-setup-token.repository.ts b/apps/api/src/contexts/identity/infrastructure/drizzle/drizzle-password-setup-token.repository.ts new file mode 100644 index 00000000..31346575 --- /dev/null +++ b/apps/api/src/contexts/identity/infrastructure/drizzle/drizzle-password-setup-token.repository.ts @@ -0,0 +1,60 @@ +import { and, eq, isNull } from 'drizzle-orm'; +import { Db } from '../../../../shared/db'; +import { passwordSetupTokensTable } from './schema'; +import { PasswordSetupTokenRepository } from '../../domain/ports/password-setup-token.repository'; +import { + PasswordSetupToken, + PasswordSetupTokenSnapshot, +} from '../../domain/password-setup-token'; + +type Row = typeof passwordSetupTokensTable.$inferSelect; + +function rowToSnapshot(row: Row): PasswordSetupTokenSnapshot { + return { + id: row.id, + userId: row.userId, + tokenHash: row.tokenHash, + expiresAt: row.expiresAt.toISOString(), + usedAt: row.usedAt === null ? null : row.usedAt.toISOString(), + createdAt: row.createdAt.toISOString(), + }; +} + +export class DrizzlePasswordSetupTokenRepository implements PasswordSetupTokenRepository { + constructor(private readonly db: Db) {} + + async save(token: PasswordSetupToken): Promise { + const s = token.toSnapshot(); + await this.db.insert(passwordSetupTokensTable).values({ + id: s.id, + userId: s.userId, + tokenHash: s.tokenHash, + expiresAt: new Date(s.expiresAt), + usedAt: s.usedAt === null ? null : new Date(s.usedAt), + createdAt: new Date(s.createdAt), + }); + } + + async findByHash(tokenHash: string): Promise { + const rows = await this.db + .select() + .from(passwordSetupTokensTable) + .where(eq(passwordSetupTokensTable.tokenHash, tokenHash)) + .limit(1); + return rows[0] + ? PasswordSetupToken.fromSnapshot(rowToSnapshot(rows[0])) + : null; + } + + async markUsedForUser(userId: string, at: Date): Promise { + await this.db + .update(passwordSetupTokensTable) + .set({ usedAt: at }) + .where( + and( + eq(passwordSetupTokensTable.userId, userId), + isNull(passwordSetupTokensTable.usedAt), + ), + ); + } +} diff --git a/apps/api/src/contexts/identity/infrastructure/drizzle/drizzle-user.repository.ts b/apps/api/src/contexts/identity/infrastructure/drizzle/drizzle-user.repository.ts index 6eaad922..2f99e075 100644 --- a/apps/api/src/contexts/identity/infrastructure/drizzle/drizzle-user.repository.ts +++ b/apps/api/src/contexts/identity/infrastructure/drizzle/drizzle-user.repository.ts @@ -68,6 +68,13 @@ export class DrizzleUserRepository implements UserRepository { return rows.map((r) => User.fromSnapshot(rowToSnapshot(r))); } + async setPassword(id: UserId, passwordHash: string): Promise { + await this.db + .update(usersTable) + .set({ passwordHash }) + .where(eq(usersTable.id, id.value)); + } + async recordLogin(id: UserId, at: Date): Promise { await this.db .update(usersTable) diff --git a/apps/api/src/contexts/identity/infrastructure/drizzle/schema.ts b/apps/api/src/contexts/identity/infrastructure/drizzle/schema.ts index a3d10257..12b76fcc 100644 --- a/apps/api/src/contexts/identity/infrastructure/drizzle/schema.ts +++ b/apps/api/src/contexts/identity/infrastructure/drizzle/schema.ts @@ -59,6 +59,30 @@ export const userConsentsTable = pgTable( (t) => [index('user_consents_user_idx').on(t.userId)], ); +/** + * Single-use, expiring tokens that let a passwordless profile set its password + * (#204). Only the SHA-256 hash of the secret is stored — a DB read never yields + * a usable token. A token is spent by stamping `used_at`; validity is + * `used_at IS NULL AND expires_at > now()` (migration 0059). + */ +export const passwordSetupTokensTable = pgTable( + 'password_setup_tokens', + { + id: uuid('id').primaryKey(), + userId: uuid('user_id') + .notNull() + .references(() => usersTable.id, { onDelete: 'cascade' }), + /** SHA-256 hex of the raw token; unique so a lookup is an indexed equality. */ + tokenHash: text('token_hash').notNull().unique(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + usedAt: timestamp('used_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [index('password_setup_tokens_user_idx').on(t.userId)], +); + export const membershipsTable = pgTable( 'memberships', { diff --git a/apps/api/src/contexts/identity/infrastructure/email-set-password-inviter.spec.ts b/apps/api/src/contexts/identity/infrastructure/email-set-password-inviter.spec.ts new file mode 100644 index 00000000..868776de --- /dev/null +++ b/apps/api/src/contexts/identity/infrastructure/email-set-password-inviter.spec.ts @@ -0,0 +1,73 @@ +import { EmailSetPasswordInviter } from './email-set-password-inviter'; +import { InMemoryPasswordSetupTokenRepository } from './in-memory-password-setup-token.repository'; +import { EmailMessage, EmailSender } from '../domain/ports/email-sender'; +import { hashSetupToken } from '../domain/password-setup-token-generator'; + +class RecordingEmailSender implements EmailSender { + readonly sent: EmailMessage[] = []; + send(message: EmailMessage): Promise { + this.sent.push(message); + return Promise.resolve(); + } +} + +const USER_ID = '22222222-2222-4222-8222-222222222222'; + +describe('EmailSetPasswordInviter', () => { + let tokens: InMemoryPasswordSetupTokenRepository; + let email: RecordingEmailSender; + let inviter: EmailSetPasswordInviter; + const now = new Date('2026-07-17T12:00:00Z'); + + beforeEach(() => { + tokens = new InMemoryPasswordSetupTokenRepository(); + email = new RecordingEmailSender(); + inviter = new EmailSetPasswordInviter( + tokens, + email, + { + ttlHours: 48, + frontendUrl: 'https://responsegrid.app/', + setPasswordPath: '/crear-contrasena', + }, + () => now, + ); + }); + + it('persists only a token hash and emails a link with the raw token', async () => { + await inviter.invite({ + userId: USER_ID, + email: 'donor@example.com', + name: 'Ana', + }); + + expect(email.sent).toHaveLength(1); + const sent = email.sent[0]; + expect(sent.to).toBe('donor@example.com'); + + // Pull the raw token out of the link, then confirm the stored value is its + // hash — never the raw token itself. + const match = /token=([^&\s"]+)/.exec(sent.text); + expect(match).not.toBeNull(); + const rawToken = decodeURIComponent(match![1]); + + const stored = await tokens.findByHash(hashSetupToken(rawToken)); + expect(stored).not.toBeNull(); + expect(stored!.userId).toBe(USER_ID); + expect(stored!.tokenHash).not.toBe(rawToken); + expect(stored!.isUsable(now)).toBe(true); + // 48h expiry from the injected clock. + expect(stored!.expiresAt).toEqual(new Date('2026-07-19T12:00:00Z')); + }); + + it('builds the link from the frontend origin without a double slash', async () => { + await inviter.invite({ + userId: USER_ID, + email: 'donor@example.com', + name: 'Ana', + }); + expect(email.sent[0].text).toContain( + 'https://responsegrid.app/crear-contrasena?token=', + ); + }); +}); diff --git a/apps/api/src/contexts/identity/infrastructure/email-set-password-inviter.ts b/apps/api/src/contexts/identity/infrastructure/email-set-password-inviter.ts new file mode 100644 index 00000000..00a37f79 --- /dev/null +++ b/apps/api/src/contexts/identity/infrastructure/email-set-password-inviter.ts @@ -0,0 +1,65 @@ +import { randomUUID } from 'node:crypto'; +import { + SetPasswordInvite, + SetPasswordInviter, +} from '../domain/ports/set-password-inviter'; +import { PasswordSetupTokenRepository } from '../domain/ports/password-setup-token.repository'; +import { EmailSender } from '../domain/ports/email-sender'; +import { PasswordSetupToken } from '../domain/password-setup-token'; +import { generateSetupToken } from '../domain/password-setup-token-generator'; +import { renderSetPasswordEmail } from './set-password-email'; + +export interface EmailSetPasswordInviterConfig { + /** Hours a set-password link stays valid. */ + ttlHours: number; + /** Web origin used to build the set-password link (no trailing slash needed). */ + frontendUrl: string; + /** Path of the web set-password page. */ + setPasswordPath: string; +} + +/** + * Real {@link SetPasswordInviter} (#204, replaces the no-op seam): mints a + * single-use, expiring token, persists ONLY its hash, and emails the donor a + * link to set their password. The raw token lives only in the email. + */ +export class EmailSetPasswordInviter implements SetPasswordInviter { + constructor( + private readonly tokens: PasswordSetupTokenRepository, + private readonly email: EmailSender, + private readonly config: EmailSetPasswordInviterConfig, + private readonly clock: () => Date = () => new Date(), + ) {} + + async invite(invite: SetPasswordInvite): Promise { + const { plaintext, hash } = generateSetupToken(); + const now = this.clock(); + const expiresAt = new Date( + now.getTime() + this.config.ttlHours * 60 * 60 * 1000, + ); + + await this.tokens.save( + PasswordSetupToken.issue({ + id: randomUUID(), + userId: invite.userId, + tokenHash: hash, + expiresAt, + createdAt: now, + }), + ); + + const origin = this.config.frontendUrl.replace(/\/+$/, ''); + const path = this.config.setPasswordPath.startsWith('/') + ? this.config.setPasswordPath + : `/${this.config.setPasswordPath}`; + const link = `${origin}${path}?token=${encodeURIComponent(plaintext)}`; + + await this.email.send( + renderSetPasswordEmail({ + to: invite.email, + name: invite.name, + link, + }), + ); + } +} diff --git a/apps/api/src/contexts/identity/infrastructure/http/auth.controller.ts b/apps/api/src/contexts/identity/infrastructure/http/auth.controller.ts index a8f313c7..db282e94 100644 --- a/apps/api/src/contexts/identity/infrastructure/http/auth.controller.ts +++ b/apps/api/src/contexts/identity/infrastructure/http/auth.controller.ts @@ -17,6 +17,7 @@ import { ApiOperation, ApiOkResponse, ApiCreatedResponse, + ApiAcceptedResponse, ApiBadRequestResponse, ApiUnauthorizedResponse, ApiConflictResponse, @@ -27,6 +28,8 @@ import type { Request as ExpressRequest } from 'express'; import { Login } from '../../application/login'; import { RegisterUser } from '../../application/register-user'; import { CompleteRegistration } from '../../application/complete-registration'; +import { SetPassword } from '../../application/set-password'; +import { RequestPasswordSetup } from '../../application/request-password-setup'; import { UpdateProfile, UpdateProfileResult, @@ -40,6 +43,9 @@ import { UpdateProfileDto, OnboardingDto, OnboardingResponseDto, + SetPasswordDto, + SetPasswordResponseDto, + RequestPasswordSetupDto, } from './dto'; import { IdentityExceptionFilter } from './identity-exception.filter'; import { JwtAuthGuard, AuthenticatedUser } from './jwt-auth.guard'; @@ -70,6 +76,8 @@ export class AuthController { private readonly registerUser: RegisterUser, private readonly updateProfile: UpdateProfile, private readonly completeRegistration: CompleteRegistration, + private readonly setPassword: SetPassword, + private readonly requestPasswordSetup: RequestPasswordSetup, @Inject(CONSENT_REPOSITORY) private readonly consentRepo: ConsentRepository, ) {} @@ -118,6 +126,53 @@ export class AuthController { }); } + @Post('set-password') + @HttpCode(HttpStatus.OK) + @Throttle({ default: { ttl: 60_000, limit: 10 } }) + @ApiOperation({ + summary: + 'Establecer la contraseña de un perfil sin contraseña con un token de ' + + 'un solo uso (auto-login: devuelve un JWT)', + }) + @ApiOkResponse({ + description: 'Contraseña establecida', + type: SetPasswordResponseDto, + }) + @ApiBadRequestResponse({ + description: 'Token inválido, caducado o ya usado', + }) + @ApiTooManyRequestsResponse({ + description: 'Rate limit exceeded — try again later', + }) + async setPasswordRoute( + @Body() dto: SetPasswordDto, + ): Promise { + return this.setPassword.execute({ + token: dto.token, + newPassword: dto.password, + }); + } + + @Post('set-password/request') + @HttpCode(HttpStatus.ACCEPTED) + @Throttle({ default: { ttl: 60_000, limit: 3 } }) + @ApiOperation({ + summary: + 'Reenviar el email de «crea tu contraseña» a un perfil sin contraseña. ' + + 'Responde 202 siempre (no revela si el email existe).', + }) + @ApiAcceptedResponse({ + description: 'Solicitud aceptada (se envía el email si procede)', + }) + @ApiTooManyRequestsResponse({ + description: 'Rate limit exceeded — try again later', + }) + async requestPasswordSetupRoute( + @Body() dto: RequestPasswordSetupDto, + ): Promise { + await this.requestPasswordSetup.execute({ email: dto.email }); + } + @Post('onboarding') @UseGuards(JwtAuthGuard) @ApiBearerAuth() diff --git a/apps/api/src/contexts/identity/infrastructure/http/dto.ts b/apps/api/src/contexts/identity/infrastructure/http/dto.ts index b4a2fb0a..b60eda76 100644 --- a/apps/api/src/contexts/identity/infrastructure/http/dto.ts +++ b/apps/api/src/contexts/identity/infrastructure/http/dto.ts @@ -238,6 +238,38 @@ export class TrustedAuthResponseDto { user!: TrustedAuthUserDto; } +export class SetPasswordDto { + @ApiProperty({ + description: 'Token de un solo uso recibido en el email de set-password', + }) + @IsString() + @IsNotEmpty() + token!: string; + + @ApiProperty({ example: 'mi-nueva-contrasena', minLength: 8 }) + @IsString() + @MinLength(8) + password!: string; +} + +export class SetPasswordResponseDto { + @ApiProperty({ + description: 'JWT access token (auto-login tras establecer la contraseña)', + }) + accessToken!: string; +} + +export class RequestPasswordSetupDto { + @ApiProperty({ + example: 'donor@example.com', + description: + 'Email del perfil sin contraseña al que reenviar el enlace de ' + + 'set-password. La respuesta es 202 siempre, exista o no la cuenta.', + }) + @IsEmail() + email!: string; +} + export class UpdateProfileDto { @ApiProperty({ example: '+58 412 555 0101', diff --git a/apps/api/src/contexts/identity/infrastructure/http/identity-exception.filter.ts b/apps/api/src/contexts/identity/infrastructure/http/identity-exception.filter.ts index ff0b04c3..dbc63d58 100644 --- a/apps/api/src/contexts/identity/infrastructure/http/identity-exception.filter.ts +++ b/apps/api/src/contexts/identity/infrastructure/http/identity-exception.filter.ts @@ -11,6 +11,7 @@ import { AccountLinkRequiresAuthError } from '../../domain/account-link-requires import { ConsentRequiredError } from '../../domain/consent-required.error'; import { PhoneRequiredError } from '../../domain/phone-required.error'; import { UserNotFoundByPhoneError } from '../../domain/user-not-found-by-phone.error'; +import { InvalidPasswordSetupTokenError } from '../../domain/invalid-password-setup-token.error'; type IdentityError = | InvalidCredentialsError @@ -18,7 +19,8 @@ type IdentityError = | AccountLinkRequiresAuthError | ConsentRequiredError | PhoneRequiredError - | UserNotFoundByPhoneError; + | UserNotFoundByPhoneError + | InvalidPasswordSetupTokenError; @Catch( InvalidCredentialsError, @@ -27,6 +29,7 @@ type IdentityError = ConsentRequiredError, PhoneRequiredError, UserNotFoundByPhoneError, + InvalidPasswordSetupTokenError, ) export class IdentityExceptionFilter implements ExceptionFilter { catch(exception: IdentityError, host: ArgumentsHost): void { @@ -46,6 +49,10 @@ export class IdentityExceptionFilter implements ExceptionFilter { } else if (exception instanceof UserNotFoundByPhoneError) { // 404: no account for that phone → the bot falls back to register-by-phone. status = HttpStatus.NOT_FOUND; + } else if (exception instanceof InvalidPasswordSetupTokenError) { + // 400: the set-password token is missing/expired/used. One shape for all + // three so the endpoint is not an oracle for probing tokens. + status = HttpStatus.BAD_REQUEST; } else { status = HttpStatus.UNAUTHORIZED; } diff --git a/apps/api/src/contexts/identity/infrastructure/identity.module.ts b/apps/api/src/contexts/identity/infrastructure/identity.module.ts index 53454225..86b95f43 100644 --- a/apps/api/src/contexts/identity/infrastructure/identity.module.ts +++ b/apps/api/src/contexts/identity/infrastructure/identity.module.ts @@ -31,11 +31,20 @@ import { ListUsersAdmin } from '../application/list-users-admin'; import { GetUserAdminDetail } from '../application/get-user-admin-detail'; import { EnsureDonorAccount } from '../application/ensure-donor-account'; import { UpdateProfile } from '../application/update-profile'; +import { SetPassword } from '../application/set-password'; +import { RequestPasswordSetup } from '../application/request-password-setup'; import { SET_PASSWORD_INVITER, SetPasswordInviter, } from '../domain/ports/set-password-inviter'; -import { NoopSetPasswordInviter } from './noop-set-password-inviter'; +import { + PASSWORD_SETUP_TOKEN_REPOSITORY, + PasswordSetupTokenRepository, +} from '../domain/ports/password-setup-token.repository'; +import { EMAIL_SENDER, EmailSender } from '../domain/ports/email-sender'; +import { EmailSetPasswordInviter } from './email-set-password-inviter'; +import { LoggingEmailSender } from './logging-email-sender'; +import { DrizzlePasswordSetupTokenRepository } from './drizzle/drizzle-password-setup-token.repository'; import { USER_REPOSITORY, UserRepository, @@ -465,9 +474,53 @@ const authenticateWithProviderProvider = { ) => new AuthenticateWithProvider(userRepo, identityRepo, tokenService), }; +const passwordSetupTokenRepositoryProvider = { + provide: PASSWORD_SETUP_TOKEN_REPOSITORY, + inject: [DB], + useFactory: (db: Db): PasswordSetupTokenRepository => + new DrizzlePasswordSetupTokenRepository(db), +}; + +const emailSenderProvider = { + provide: EMAIL_SENDER, + useClass: LoggingEmailSender, +}; + const setPasswordInviterProvider = { provide: SET_PASSWORD_INVITER, - useClass: NoopSetPasswordInviter, + inject: [PASSWORD_SETUP_TOKEN_REPOSITORY, EMAIL_SENDER], + useFactory: ( + tokens: PasswordSetupTokenRepository, + email: EmailSender, + ): SetPasswordInviter => + new EmailSetPasswordInviter(tokens, email, { + ttlHours: Number(process.env.PASSWORD_SETUP_TTL_HOURS ?? '48'), + frontendUrl: process.env.FRONTEND_URL ?? 'http://localhost:3001', + setPasswordPath: '/crear-contrasena', + }), +}; + +const setPasswordProvider = { + provide: SetPassword, + inject: [ + PASSWORD_SETUP_TOKEN_REPOSITORY, + USER_REPOSITORY, + PASSWORD_HASHER, + TOKEN_SERVICE, + ], + useFactory: ( + tokens: PasswordSetupTokenRepository, + users: UserRepository, + hasher: BcryptPasswordHasher, + tokenService: JwtTokenService, + ) => new SetPassword(tokens, users, hasher, tokenService), +}; + +const requestPasswordSetupProvider = { + provide: RequestPasswordSetup, + inject: [USER_REPOSITORY, SET_PASSWORD_INVITER], + useFactory: (users: UserRepository, inviter: SetPasswordInviter) => + new RequestPasswordSetup(users, inviter), }; const ensureDonorAccountProvider = { @@ -526,7 +579,11 @@ const updateProfileProvider = { registerByPhoneProvider, completeRegistrationProvider, authenticateWithProviderProvider, + passwordSetupTokenRepositoryProvider, + emailSenderProvider, setPasswordInviterProvider, + setPasswordProvider, + requestPasswordSetupProvider, ensureDonorAccountProvider, updateProfileProvider, grantRoleProvider, diff --git a/apps/api/src/contexts/identity/infrastructure/in-memory-password-setup-token.repository.ts b/apps/api/src/contexts/identity/infrastructure/in-memory-password-setup-token.repository.ts new file mode 100644 index 00000000..a0fb5337 --- /dev/null +++ b/apps/api/src/contexts/identity/infrastructure/in-memory-password-setup-token.repository.ts @@ -0,0 +1,35 @@ +import { PasswordSetupTokenRepository } from '../domain/ports/password-setup-token.repository'; +import { + PasswordSetupToken, + PasswordSetupTokenSnapshot, +} from '../domain/password-setup-token'; + +export class InMemoryPasswordSetupTokenRepository implements PasswordSetupTokenRepository { + private store = new Map(); + + save(token: PasswordSetupToken): Promise { + this.store.set(token.id, token.toSnapshot()); + return Promise.resolve(); + } + + findByHash(tokenHash: string): Promise { + const snap = [...this.store.values()].find( + (s) => s.tokenHash === tokenHash, + ); + return Promise.resolve(snap ? PasswordSetupToken.fromSnapshot(snap) : null); + } + + markUsedForUser(userId: string, at: Date): Promise { + for (const [id, snap] of this.store) { + if (snap.userId === userId && snap.usedAt === null) { + this.store.set(id, { ...snap, usedAt: at.toISOString() }); + } + } + return Promise.resolve(); + } + + /** Test helper — total tokens stored. */ + countAll(): number { + return this.store.size; + } +} diff --git a/apps/api/src/contexts/identity/infrastructure/in-memory-user.repository.ts b/apps/api/src/contexts/identity/infrastructure/in-memory-user.repository.ts index 372277dc..7a52336a 100644 --- a/apps/api/src/contexts/identity/infrastructure/in-memory-user.repository.ts +++ b/apps/api/src/contexts/identity/infrastructure/in-memory-user.repository.ts @@ -34,6 +34,12 @@ export class InMemoryUserRepository implements UserRepository { return Promise.resolve(snap ? User.fromSnapshot(snap) : null); } + setPassword(id: UserId, passwordHash: string): Promise { + const snap = this.store.get(id.value); + if (snap) this.store.set(id.value, { ...snap, passwordHash }); + return Promise.resolve(); + } + /** Records when the user last logged in (issue #176). */ recordLogin(id: UserId): Promise { this.lastLoginById.set(id.value, new Date()); diff --git a/apps/api/src/contexts/identity/infrastructure/logging-email-sender.ts b/apps/api/src/contexts/identity/infrastructure/logging-email-sender.ts new file mode 100644 index 00000000..7e7134c6 --- /dev/null +++ b/apps/api/src/contexts/identity/infrastructure/logging-email-sender.ts @@ -0,0 +1,20 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { EmailMessage, EmailSender } from '../domain/ports/email-sender'; + +/** + * Default {@link EmailSender}: logs the message instead of delivering it. This is + * the delivery seam — the token/template logic around it is real and tested; the + * only piece still to wire for production is a concrete transport adapter (SMTP/ + * SES/hosted API), which needs delivery credentials. Swapping this provider does + * not touch any caller. + */ +@Injectable() +export class LoggingEmailSender implements EmailSender { + private readonly logger = new Logger(LoggingEmailSender.name); + + send(message: EmailMessage): Promise { + this.logger.log(`email → ${message.to} · ${message.subject}`); + this.logger.debug(message.text); + return Promise.resolve(); + } +} diff --git a/apps/api/src/contexts/identity/infrastructure/noop-set-password-inviter.ts b/apps/api/src/contexts/identity/infrastructure/noop-set-password-inviter.ts deleted file mode 100644 index 966a41f4..00000000 --- a/apps/api/src/contexts/identity/infrastructure/noop-set-password-inviter.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { - SetPasswordInvite, - SetPasswordInviter, -} from '../domain/ports/set-password-inviter'; - -/** - * No-op seam (#168): when a passwordless profile is created for an anonymous - * donor, this is where the "set your password" email would be sent. The email - * flow is not implemented yet, so this only logs the intent, keeping the - * integration point visible. Swap for a real notifier when email lands. - */ -@Injectable() -export class NoopSetPasswordInviter implements SetPasswordInviter { - private readonly logger = new Logger(NoopSetPasswordInviter.name); - - invite(invite: SetPasswordInvite): Promise { - this.logger.log( - `TODO(email): invite ${invite.email} (user ${invite.userId}) to set their password`, - ); - return Promise.resolve(); - } -} diff --git a/apps/api/src/contexts/identity/infrastructure/set-password-email.spec.ts b/apps/api/src/contexts/identity/infrastructure/set-password-email.spec.ts new file mode 100644 index 00000000..0504a780 --- /dev/null +++ b/apps/api/src/contexts/identity/infrastructure/set-password-email.spec.ts @@ -0,0 +1,38 @@ +import { renderSetPasswordEmail } from './set-password-email'; + +describe('renderSetPasswordEmail', () => { + const params = { + to: 'donor@example.com', + name: 'Ana', + link: 'https://responsegrid.app/crear-contrasena?token=abc123', + }; + + it('addresses the recipient and includes the link in both bodies', () => { + const msg = renderSetPasswordEmail(params); + expect(msg.to).toBe('donor@example.com'); + expect(msg.text).toContain('Ana'); + expect(msg.text).toContain(params.link); + expect(msg.html).toContain(params.link); + }); + + it('is bilingual (ES and EN)', () => { + const msg = renderSetPasswordEmail(params); + expect(msg.subject).toContain('Crea tu contraseña'); + expect(msg.subject).toContain('Create your password'); + // ES copy + expect(msg.text).toContain('Crea tu contraseña'); + // EN copy + expect(msg.text).toContain('Set your password'); + }); + + it('escapes HTML in the name and link to avoid injection', () => { + const msg = renderSetPasswordEmail({ + ...params, + name: '', + link: 'https://x/set?token=a&b=c', + }); + expect(msg.html).not.toContain('