import { Body, Controller, Logger, Post } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { Public } from "@edr/api-common"; import { ForgotPasswordRequestDto, ForgotPasswordVerifyDto, ResolveResetLinkDto, } from "./dto/forgot-password.dto"; import { ForgotPasswordService, ResetLinkAccount, ResetTicket, } from "./forgot-password.service"; /** * Freight-owned reset flow. IAM ships a `forgot-password` route, but it only * ever SMSes a magic link (no email channel, and it needs `FE_BASE_URL`, which * this API does not set). These routes drive freight's own email-or-phone OTP * service instead, then hand back a ticket for IAM's public `set-password`. */ @ApiTags("auth") @Controller("auth") @Public() export class ForgotPasswordController { private readonly logger = new Logger(ForgotPasswordController.name); constructor(private readonly forgotPasswordService: ForgotPasswordService) {} @Post("forgot-password/request") @ApiOperation({ summary: "Send a password-reset code to the account's email AND phone", description: "One code, delivered over every contact the account has; either delivery " + "verifies it. Always reports success — an unknown, inactive, or contactless " + "account is indistinguishable from a real one, so this cannot be used to " + "enumerate accounts.", }) async request(@Body() dto: ForgotPasswordRequestDto): Promise<{ success: true }> { const user = await this.forgotPasswordService.resolveActiveUser(dto.identifier); if (user) { try { await this.forgotPasswordService.requestReset(user); } catch (error) { // A delivery failure must not change the response shape either — log it // and let the caller sit on the OTP screen. this.logger.error( `Reset code delivery failed for user ${user.id}: ${ error instanceof Error ? error.message : String(error) }`, error instanceof Error ? error.stack : undefined, ); } } else { this.logger.log("Reset requested for an unknown or inactive account"); } return { success: true }; } @Post("forgot-password/verify") @ApiOperation({ summary: "Exchange a valid reset code for a single-use set-password ticket", description: "The returned { userId, verificationCode } is the body for PATCH /api/auth/set-password, " + "alongside the same identifier and the new password.", }) verify(@Body() dto: ForgotPasswordVerifyDto): Promise { return this.forgotPasswordService.verifyAndMintTicket(dto.identifier, dto.otp); } @Post("forgot-password/resolve-link") @ApiOperation({ summary: "Validate a staff-issued reset link and return its set-password ticket", description: "Takes the link's uid/token pair. The returned { userId, identifier, verificationCode } " + "is the body for PATCH /api/auth/set-password, so the customer never types an identifier. " + "A bad or expired link is rejected here rather than after the password is typed.", }) resolveLink(@Body() dto: ResolveResetLinkDto): Promise { return this.forgotPasswordService.resolveResetLink(dto.userId, dto.token); } }