Files
edr-platform/apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts
2026-07-20 12:10:49 +00:00

85 lines
3.2 KiB
TypeScript

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<ResetTicket> {
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<ResetLinkAccount> {
return this.forgotPasswordService.resolveResetLink(dto.userId, dto.token);
}
}