mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 18:33:39 +00:00
feat(auth): implement staff-triggered password-reset links
This commit is contained in:
@@ -1,10 +1,31 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
||||
import { EmailClientService } from "../notifications/email-client.service";
|
||||
import { SmsClientService } from "../notifications/sms-client.service";
|
||||
import { ResetChannel } from "./dto/forgot-password.dto";
|
||||
import { ForgotPasswordService } from "./forgot-password.service";
|
||||
import {
|
||||
ForgotPasswordService,
|
||||
RESET_LINK_TTL_MS,
|
||||
} from "./forgot-password.service";
|
||||
import { maskOtpTarget } from "./mask-target.util";
|
||||
|
||||
/** The account a staff-triggered reset would land on. */
|
||||
export interface CustomerResetTarget {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
}
|
||||
|
||||
export interface SentResetLink {
|
||||
channel: ResetChannel;
|
||||
maskedTarget: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CustomerResetService {
|
||||
@@ -14,19 +35,110 @@ export class CustomerResetService {
|
||||
@InjectRepository(ExternalProfile)
|
||||
private readonly externalProfileRepository: Repository<ExternalProfile>,
|
||||
private readonly forgotPasswordService: ForgotPasswordService,
|
||||
private readonly emailClient: EmailClientService,
|
||||
private readonly smsClient: SmsClientService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Send a reset code to the company's primary contact. Returns the masked
|
||||
* destination, or null when there is no eligible account for that channel.
|
||||
* The IAM account a reset would actually reach. The backoffice shows these
|
||||
* values rather than `company.email` / `company.phone`: the company row holds
|
||||
* business contact detail, while the link is delivered to the primary
|
||||
* contact's own login credentials — the two drift apart routinely, and showing
|
||||
* the wrong one has staff telling customers to check an inbox nothing was sent
|
||||
* to.
|
||||
*/
|
||||
async getResetTarget(companyId: string): Promise<CustomerResetTarget | null> {
|
||||
const resolved = await this.resolvePrimaryContactUser(companyId);
|
||||
if (!resolved) return null;
|
||||
|
||||
const { profile, user, userId } = resolved;
|
||||
return {
|
||||
userId,
|
||||
name: `${profile.firstName} ${profile.lastName}`.trim(),
|
||||
email: user.email ?? null,
|
||||
phone: user.phoneNumber ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a password-reset link and send it to the company's primary contact.
|
||||
* Returns the masked destination, or null when there is no eligible account
|
||||
* for that channel.
|
||||
*
|
||||
* Unlike the public flow this reports failure honestly — the caller is an
|
||||
* authenticated staff member, so there is nothing to enumerate.
|
||||
*/
|
||||
async sendResetToCustomer(
|
||||
async sendResetLinkToCustomer(
|
||||
companyId: string,
|
||||
channel: ResetChannel,
|
||||
): Promise<string | null> {
|
||||
): Promise<SentResetLink | null> {
|
||||
const resolved = await this.resolvePrimaryContactUser(companyId);
|
||||
if (!resolved) return null;
|
||||
|
||||
const { user, userId } = resolved;
|
||||
const target = this.forgotPasswordService.targetFor(user, channel);
|
||||
if (!target) return null;
|
||||
|
||||
// Mint first, send second: a failed send leaves an unused ticket that simply
|
||||
// expires, whereas sending a link before the ticket exists would hand the
|
||||
// customer a URL that is dead on arrival.
|
||||
const ticket = await this.forgotPasswordService.mintResetTicket(
|
||||
userId,
|
||||
RESET_LINK_TTL_MS,
|
||||
);
|
||||
const link = this.buildResetLink(ticket.userId, ticket.verificationCode);
|
||||
const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS);
|
||||
|
||||
const { queued } = target.email
|
||||
? await this.emailClient.sendEmail({
|
||||
to: target.email,
|
||||
subject: "Reset your EDR Freight password",
|
||||
text:
|
||||
"A password reset was started for your EDR Freight account.\n\n" +
|
||||
`Open this link to choose a new password:\n${link}\n\n` +
|
||||
"The link expires in 24 hours and can only be used once. If you did " +
|
||||
"not expect this, ignore this message — your password stays unchanged.",
|
||||
})
|
||||
: await this.smsClient.sendSms({
|
||||
to: target.phone as string,
|
||||
message: `Reset your EDR Freight password: ${link} (expires in 24 hours, single use)`,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Staff-triggered ${channel} reset link sent to user ${userId} (company ${companyId}) queued=${queued}`,
|
||||
);
|
||||
|
||||
if (!queued) {
|
||||
// The ticket is committed and the backoffice is about to say "link sent",
|
||||
// but nothing left this process — with RABBITMQ_ENABLED=false both clients
|
||||
// are no-ops. Without this line the only symptom is a customer who never
|
||||
// receives anything, indistinguishable from carrier loss.
|
||||
this.logger.error(
|
||||
`reset-link.dispatch.dropped channel=${channel} user=${userId} rabbitmqEnabled=${
|
||||
process.env.RABBITMQ_ENABLED ?? "unset"
|
||||
} — transport reported no hand-off; no link will arrive`,
|
||||
);
|
||||
// SECURITY: logs a live password-reset credential in cleartext. Same
|
||||
// deliberate tradeoff the OTP service makes — this is the only way to
|
||||
// complete a reset on an environment with no broker. Only reached when
|
||||
// delivery already failed.
|
||||
this.logger.warn(`Undelivered reset link for user ${userId}: ${link}`);
|
||||
}
|
||||
|
||||
return {
|
||||
channel,
|
||||
maskedTarget: maskOtpTarget(target),
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The company's primary contact, gated on the same active-account rule the
|
||||
* public flow uses — so a suspended customer cannot be reactivated by a
|
||||
* staff-triggered reset (IAM's `set-password` flips `isActive` back on).
|
||||
*/
|
||||
private async resolvePrimaryContactUser(companyId: string) {
|
||||
const profile = await this.externalProfileRepository.findOne({
|
||||
where: { companyId, isPrimaryContact: true },
|
||||
});
|
||||
@@ -36,24 +148,28 @@ export class CustomerResetService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve through the same active-account gate the public flow uses, so a
|
||||
// suspended customer cannot be reactivated by a staff-triggered reset.
|
||||
const user = await this.forgotPasswordService.resolveActiveUserById(
|
||||
profile.userId,
|
||||
);
|
||||
if (!user) {
|
||||
if (!user?.id) {
|
||||
this.logger.warn(
|
||||
`Primary contact ${profile.userId} of company ${companyId} is not an active account`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = await this.forgotPasswordService.requestReset(user, channel);
|
||||
if (!target) return null;
|
||||
return { profile, user, userId: user.id };
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Staff-triggered ${channel} reset sent to user ${user.id} (company ${companyId})`,
|
||||
);
|
||||
return this.forgotPasswordService.maskTarget(target);
|
||||
/**
|
||||
* The portal route that trades the token for a set-password form. Params are
|
||||
* URL-encoded because the token is base64url — safe as-is, but the encoding
|
||||
* keeps this correct if the token format ever changes.
|
||||
*/
|
||||
private buildResetLink(userId: string, token: string): string {
|
||||
const base = this.config.get<string>("app.portalBaseUrl");
|
||||
return `${base}/reset-password?uid=${encodeURIComponent(
|
||||
userId,
|
||||
)}&token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user