mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
The SMS gateway is domestic-only, but OTP sends fanned out to any phone on the account - a foreign number meant a code queued into the void while the response claimed success. isDomesticPhone (+2519/+2517 E.164) now gates SMS: dual-channel sends with a foreign phone go email-only (the phone stays on the row so verify still matches it), and a phone-only foreign target still tries SMS as the only route. The staff-triggered reset exposes phoneIsDomestic so the backoffice disables the SMS channel with an explanation, and the API refuses the channel directly for foreign numbers. EDRFREIGHT-186
197 lines
7.4 KiB
TypeScript
197 lines
7.4 KiB
TypeScript
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,
|
|
RESET_LINK_TTL_MS,
|
|
} from "./forgot-password.service";
|
|
import { maskOtpTarget } from "./mask-target.util";
|
|
import { isDomesticPhone } from "../otp/otp.service";
|
|
|
|
/** The account a staff-triggered reset would land on. */
|
|
export interface CustomerResetTarget {
|
|
userId: string;
|
|
name: string;
|
|
email: string | null;
|
|
phone: string | null;
|
|
/**
|
|
* Whether the SMS gateway (domestic-only) can reach `phone`. `null` when
|
|
* there is no phone. The backoffice uses this to disable the SMS channel for
|
|
* foreign numbers instead of sending a link that will never arrive.
|
|
*/
|
|
phoneIsDomestic: boolean | null;
|
|
}
|
|
|
|
export interface SentResetLink {
|
|
channel: ResetChannel;
|
|
maskedTarget: string;
|
|
expiresAt: string;
|
|
}
|
|
|
|
@Injectable()
|
|
export class CustomerResetService {
|
|
private readonly logger = new Logger(CustomerResetService.name);
|
|
|
|
constructor(
|
|
@InjectRepository(ExternalProfile)
|
|
private readonly externalProfileRepository: Repository<ExternalProfile>,
|
|
private readonly forgotPasswordService: ForgotPasswordService,
|
|
private readonly emailClient: EmailClientService,
|
|
private readonly smsClient: SmsClientService,
|
|
private readonly config: ConfigService,
|
|
) {}
|
|
|
|
/**
|
|
* 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,
|
|
phoneIsDomestic: user.phoneNumber
|
|
? isDomesticPhone(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 sendResetLinkToCustomer(
|
|
companyId: string,
|
|
channel: ResetChannel,
|
|
): 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;
|
|
|
|
// A foreign number is unreachable by the domestic-only SMS gateway — treat
|
|
// it like a missing phone rather than reporting "link sent" for a message
|
|
// that will never arrive. The backoffice disables the channel up front via
|
|
// `phoneIsDomestic`; this guards direct API calls.
|
|
if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) {
|
|
this.logger.warn(
|
|
`Staff reset via SMS refused for user ${userId} — non-domestic phone`,
|
|
);
|
|
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 },
|
|
});
|
|
|
|
if (!profile) {
|
|
this.logger.warn(`Company ${companyId} has no primary contact profile`);
|
|
return null;
|
|
}
|
|
|
|
const user = await this.forgotPasswordService.resolveActiveUserById(
|
|
profile.userId,
|
|
);
|
|
if (!user?.id) {
|
|
this.logger.warn(
|
|
`Primary contact ${profile.userId} of company ${companyId} is not an active account`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
return { profile, user, userId: user.id };
|
|
}
|
|
|
|
/**
|
|
* 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)}`;
|
|
}
|
|
}
|