mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 04:15:43 +00:00
Merge pull request #836 from Tria-plc/freight/feat/fixes-v1
Freight/feat/fixes v1
This commit is contained in:
@@ -22,6 +22,10 @@ TELEBIRR_PRIVATE_KEY=
|
|||||||
TELEBIRR_PUBLIC_KEY=
|
TELEBIRR_PUBLIC_KEY=
|
||||||
TELEBIRR_INSECURE_TLS=false
|
TELEBIRR_INSECURE_TLS=false
|
||||||
|
|
||||||
|
# Public origin of the freight customer portal. Password-reset links sent to
|
||||||
|
# customers are built against this — it must be browser-reachable.
|
||||||
|
FREIGHT_PORTAL_URL=http://localhost:5173
|
||||||
|
|
||||||
# Portal pages the payment provider redirects the browser to after payment.
|
# Portal pages the payment provider redirects the browser to after payment.
|
||||||
# Point these at the freight portal's public payment result routes.
|
# Point these at the freight portal's public payment result routes.
|
||||||
PAYMENT_RETURN_URL=http://localhost:5173/payment/success
|
PAYMENT_RETURN_URL=http://localhost:5173/payment/success
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ export default registerAs("app", () => ({
|
|||||||
env: process.env.NODE_ENV ?? "development",
|
env: process.env.NODE_ENV ?? "development",
|
||||||
port: parseInt(process.env.PORT ?? "3001", 10),
|
port: parseInt(process.env.PORT ?? "3001", 10),
|
||||||
apiPrefix: "api",
|
apiPrefix: "api",
|
||||||
|
/**
|
||||||
|
* Public origin of the freight customer portal. Password-reset links mailed
|
||||||
|
* or SMS'd to customers are built against this, so it must be the address the
|
||||||
|
* customer's browser can actually reach — not an internal service name.
|
||||||
|
*/
|
||||||
|
portalBaseUrl: (
|
||||||
|
process.env.FREIGHT_PORTAL_URL ?? "http://localhost:5173"
|
||||||
|
).replace(/\/+$/, ""),
|
||||||
trainScheduling: {
|
trainScheduling: {
|
||||||
maxTrainWeightTons: numberFromEnv("TRAIN_SCHEDULING_MAX_WEIGHT_TONS", 3500),
|
maxTrainWeightTons: numberFromEnv("TRAIN_SCHEDULING_MAX_WEIGHT_TONS", 3500),
|
||||||
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),
|
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Get,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
@@ -11,11 +12,14 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
|||||||
import { BookingStaff } from "../../common/booking-guards";
|
import { BookingStaff } from "../../common/booking-guards";
|
||||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
|
import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
|
||||||
import { CustomerResetService } from "./customer-reset.service";
|
import {
|
||||||
|
CustomerResetService,
|
||||||
|
CustomerResetTarget,
|
||||||
|
} from "./customer-reset.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Staff-triggered password reset. The customer receives the code and sets their
|
* Staff-triggered password reset. The customer receives a single-use link and
|
||||||
* own password — staff never see or handle a credential.
|
* sets their own password — staff never see or handle a credential.
|
||||||
*/
|
*/
|
||||||
@ApiTags("backoffice")
|
@ApiTags("backoffice")
|
||||||
@Controller("backoffice/customers")
|
@Controller("backoffice/customers")
|
||||||
@@ -23,26 +27,45 @@ import { CustomerResetService } from "./customer-reset.service";
|
|||||||
export class CustomerResetController {
|
export class CustomerResetController {
|
||||||
constructor(private readonly customerResetService: CustomerResetService) {}
|
constructor(private readonly customerResetService: CustomerResetService) {}
|
||||||
|
|
||||||
|
@Get(":companyId/reset-target")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "The primary contact's IAM account a reset link would be sent to",
|
||||||
|
})
|
||||||
|
async resetTarget(
|
||||||
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
|
): Promise<CustomerResetTarget> {
|
||||||
|
const target = await this.customerResetService.getResetTarget(companyId);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
"This customer has no active primary-contact account to reset",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
@Post(":companyId/reset-password")
|
@Post(":companyId/reset-password")
|
||||||
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
|
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Send a password-reset code to a customer's primary contact",
|
summary: "Send a password-reset link to a customer's primary contact",
|
||||||
})
|
})
|
||||||
async resetPassword(
|
async resetPassword(
|
||||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
@Body() dto: BackofficeResetPasswordDto,
|
@Body() dto: BackofficeResetPasswordDto,
|
||||||
) {
|
) {
|
||||||
const maskedTarget = await this.customerResetService.sendResetToCustomer(
|
const sent = await this.customerResetService.sendResetLinkToCustomer(
|
||||||
companyId,
|
companyId,
|
||||||
dto.channel,
|
dto.channel,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!maskedTarget) {
|
if (!sent) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException(
|
||||||
`No active primary contact with ${dto.channel === "email" ? "an email address" : "a phone number"} for this customer`,
|
`No active primary contact with ${dto.channel === "email" ? "an email address" : "a phone number"} for this customer`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { channel: dto.channel, maskedTarget };
|
return sent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,31 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { Repository } from "typeorm";
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
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 { 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()
|
@Injectable()
|
||||||
export class CustomerResetService {
|
export class CustomerResetService {
|
||||||
@@ -14,19 +35,110 @@ export class CustomerResetService {
|
|||||||
@InjectRepository(ExternalProfile)
|
@InjectRepository(ExternalProfile)
|
||||||
private readonly externalProfileRepository: Repository<ExternalProfile>,
|
private readonly externalProfileRepository: Repository<ExternalProfile>,
|
||||||
private readonly forgotPasswordService: ForgotPasswordService,
|
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
|
* The IAM account a reset would actually reach. The backoffice shows these
|
||||||
* destination, or null when there is no eligible account for that channel.
|
* 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
|
* Unlike the public flow this reports failure honestly — the caller is an
|
||||||
* authenticated staff member, so there is nothing to enumerate.
|
* authenticated staff member, so there is nothing to enumerate.
|
||||||
*/
|
*/
|
||||||
async sendResetToCustomer(
|
async sendResetLinkToCustomer(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
channel: ResetChannel,
|
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({
|
const profile = await this.externalProfileRepository.findOne({
|
||||||
where: { companyId, isPrimaryContact: true },
|
where: { companyId, isPrimaryContact: true },
|
||||||
});
|
});
|
||||||
@@ -36,24 +148,28 @@ export class CustomerResetService {
|
|||||||
return null;
|
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(
|
const user = await this.forgotPasswordService.resolveActiveUserById(
|
||||||
profile.userId,
|
profile.userId,
|
||||||
);
|
);
|
||||||
if (!user) {
|
if (!user?.id) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Primary contact ${profile.userId} of company ${companyId} is not an active account`,
|
`Primary contact ${profile.userId} of company ${companyId} is not an active account`,
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = await this.forgotPasswordService.requestReset(user, channel);
|
return { profile, user, userId: user.id };
|
||||||
if (!target) return null;
|
}
|
||||||
|
|
||||||
this.logger.log(
|
/**
|
||||||
`Staff-triggered ${channel} reset sent to user ${user.id} (company ${companyId})`,
|
* 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
|
||||||
return this.forgotPasswordService.maskTarget(target);
|
* 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)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { IsEnum, IsNotEmpty, IsString } from "class-validator";
|
import { IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator";
|
||||||
|
|
||||||
/** The channel the reset code is delivered over. */
|
/**
|
||||||
|
* The channel a reset LINK is delivered over. The OTP flow no longer picks one —
|
||||||
|
* it sends to every contact on the account — but the staff-triggered link flow
|
||||||
|
* still delivers over exactly one transport.
|
||||||
|
*/
|
||||||
export enum ResetChannel {
|
export enum ResetChannel {
|
||||||
Email = "email",
|
Email = "email",
|
||||||
Phone = "phone",
|
Phone = "phone",
|
||||||
@@ -16,13 +20,27 @@ export class ForgotPasswordRequestDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
identifier!: string;
|
identifier!: string;
|
||||||
|
|
||||||
@ApiProperty({ enum: ResetChannel })
|
/**
|
||||||
|
* Accepted and ignored. The code now goes to the account's email AND phone,
|
||||||
|
* so there is nothing to choose — kept optional so clients still sending it
|
||||||
|
* (older portal/backoffice builds) are not rejected outright.
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: ResetChannel,
|
||||||
|
deprecated: true,
|
||||||
|
description: "Ignored — the code is sent to every contact on the account.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
@IsEnum(ResetChannel)
|
@IsEnum(ResetChannel)
|
||||||
channel!: ResetChannel;
|
channel?: ResetChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ForgotPasswordVerifyDto extends ForgotPasswordRequestDto {
|
export class ForgotPasswordVerifyDto extends ForgotPasswordRequestDto {
|
||||||
@ApiProperty({ description: "The 6-digit code sent to the chosen channel" })
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"The 6-digit code sent to the account's email and phone. Either delivery carries the same code.",
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
otp!: string;
|
otp!: string;
|
||||||
@@ -33,3 +51,19 @@ export class BackofficeResetPasswordDto {
|
|||||||
@IsEnum(ResetChannel)
|
@IsEnum(ResetChannel)
|
||||||
channel!: ResetChannel;
|
channel!: ResetChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two halves of a reset link's query string. Together they stand in for the
|
||||||
|
* identifier + OTP pair of the typed flow: the token proves possession of the
|
||||||
|
* inbox/handset the link was delivered to.
|
||||||
|
*/
|
||||||
|
export class ResolveResetLinkDto {
|
||||||
|
@ApiProperty({ description: "IAM user id from the reset link's `uid` param" })
|
||||||
|
@IsUUID()
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Opaque token from the reset link's `token` param" })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
token!: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ import { Public } from "@edr/api-common";
|
|||||||
import {
|
import {
|
||||||
ForgotPasswordRequestDto,
|
ForgotPasswordRequestDto,
|
||||||
ForgotPasswordVerifyDto,
|
ForgotPasswordVerifyDto,
|
||||||
|
ResolveResetLinkDto,
|
||||||
} from "./dto/forgot-password.dto";
|
} from "./dto/forgot-password.dto";
|
||||||
import { ForgotPasswordService, ResetTicket } from "./forgot-password.service";
|
import {
|
||||||
|
ForgotPasswordService,
|
||||||
|
ResetLinkAccount,
|
||||||
|
ResetTicket,
|
||||||
|
} from "./forgot-password.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Freight-owned reset flow. IAM ships a `forgot-password` route, but it only
|
* Freight-owned reset flow. IAM ships a `forgot-password` route, but it only
|
||||||
@@ -24,17 +29,19 @@ export class ForgotPasswordController {
|
|||||||
|
|
||||||
@Post("forgot-password/request")
|
@Post("forgot-password/request")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Send a password-reset code over email or SMS",
|
summary: "Send a password-reset code to the account's email AND phone",
|
||||||
description:
|
description:
|
||||||
"Always reports success. An unknown, inactive, or channel-less account is " +
|
"One code, delivered over every contact the account has; either delivery " +
|
||||||
"indistinguishable from a real one, so this cannot be used to enumerate accounts.",
|
"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 }> {
|
async request(@Body() dto: ForgotPasswordRequestDto): Promise<{ success: true }> {
|
||||||
const user = await this.forgotPasswordService.resolveActiveUser(dto.identifier);
|
const user = await this.forgotPasswordService.resolveActiveUser(dto.identifier);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
try {
|
try {
|
||||||
await this.forgotPasswordService.requestReset(user, dto.channel);
|
await this.forgotPasswordService.requestReset(user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// A delivery failure must not change the response shape either — log it
|
// A delivery failure must not change the response shape either — log it
|
||||||
// and let the caller sit on the OTP screen.
|
// and let the caller sit on the OTP screen.
|
||||||
@@ -60,10 +67,18 @@ export class ForgotPasswordController {
|
|||||||
"alongside the same identifier and the new password.",
|
"alongside the same identifier and the new password.",
|
||||||
})
|
})
|
||||||
verify(@Body() dto: ForgotPasswordVerifyDto): Promise<ResetTicket> {
|
verify(@Body() dto: ForgotPasswordVerifyDto): Promise<ResetTicket> {
|
||||||
return this.forgotPasswordService.verifyAndMintTicket(
|
return this.forgotPasswordService.verifyAndMintTicket(dto.identifier, dto.otp);
|
||||||
dto.identifier,
|
}
|
||||||
dto.channel,
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
|||||||
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
|
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
|
||||||
import { DataSource, Repository } from "typeorm";
|
import { DataSource, Repository } from "typeorm";
|
||||||
|
|
||||||
import { hashPassword } from "@tria-plc/api-common/utils/argon";
|
import { hashPassword, verifyPassword } from "@tria-plc/api-common/utils/argon";
|
||||||
import { EOtpType } from "@tria-plc/iamapi-common/enums/otp.enum";
|
import { EOtpType } from "@tria-plc/iamapi-common/enums/otp.enum";
|
||||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||||
import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user-verification.entity";
|
import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user-verification.entity";
|
||||||
@@ -22,11 +22,32 @@ const RESET_TICKET_TTL_MS = 10 * 60 * 1000;
|
|||||||
/** How long the emailed/SMS'd OTP stays valid before it must be re-requested. */
|
/** How long the emailed/SMS'd OTP stays valid before it must be re-requested. */
|
||||||
const RESET_OTP_TTL_MS = 10 * 60 * 1000;
|
const RESET_OTP_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A staff-triggered reset link lives longer than a typed OTP: the customer may
|
||||||
|
* only see the SMS/email hours after the call that prompted it.
|
||||||
|
*/
|
||||||
|
export const RESET_LINK_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** IAM refuses a ticket once its row hits this many failed attempts. */
|
||||||
|
const MAX_TICKET_ATTEMPTS = 5;
|
||||||
|
|
||||||
export interface ResetTicket {
|
export interface ResetTicket {
|
||||||
userId: string;
|
userId: string;
|
||||||
verificationCode: string;
|
verificationCode: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a valid reset link resolves to. `identifier` is the value IAM's
|
||||||
|
* `set-password` matches the user on (it accepts email / username / phone), so
|
||||||
|
* the portal can spend the ticket without the customer typing anything.
|
||||||
|
*/
|
||||||
|
export interface ResetLinkAccount {
|
||||||
|
userId: string;
|
||||||
|
identifier: string;
|
||||||
|
maskedIdentifier: string;
|
||||||
|
verificationCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ForgotPasswordService {
|
export class ForgotPasswordService {
|
||||||
private readonly logger = new Logger(ForgotPasswordService.name);
|
private readonly logger = new Logger(ForgotPasswordService.name);
|
||||||
@@ -81,8 +102,12 @@ export class ForgotPasswordService {
|
|||||||
.orderBy("u.createdAt", "DESC");
|
.orderBy("u.createdAt", "DESC");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The address the code goes to, taken from the account — never from input. */
|
/**
|
||||||
private targetFor(user: User, channel: ResetChannel): OtpTarget | null {
|
* A single channel of the account, for flows that genuinely deliver over one
|
||||||
|
* transport (the staff-triggered reset LINK picks email or SMS). Taken from
|
||||||
|
* the account — never from input.
|
||||||
|
*/
|
||||||
|
targetFor(user: User, channel: ResetChannel): OtpTarget | null {
|
||||||
if (channel === ResetChannel.Email) {
|
if (channel === ResetChannel.Email) {
|
||||||
return user.email ? { email: user.email } : null;
|
return user.email ? { email: user.email } : null;
|
||||||
}
|
}
|
||||||
@@ -90,20 +115,40 @@ export class ForgotPasswordService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a reset code to the account's own email/phone. Returns the target so
|
* Every contact the account has. The reset OTP goes to all of them and any one
|
||||||
* authenticated (backoffice) callers can echo a masked version; unauthenticated
|
* verifies it — a customer whose SMS never lands can finish from their inbox
|
||||||
* callers must discard it.
|
* without restarting the flow on a different channel. An account holding only
|
||||||
|
* one of the two degrades to that channel; only a contactless account is null.
|
||||||
|
*/
|
||||||
|
targetsFor(user: User): OtpTarget | null {
|
||||||
|
const target: OtpTarget = {};
|
||||||
|
if (user.email) target.email = user.email;
|
||||||
|
if (user.phoneNumber) target.phone = user.phoneNumber;
|
||||||
|
return target.email || target.phone ? target : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value IAM's `set-password` will match this account on. It looks the user
|
||||||
|
* up by email OR username OR phoneNumber (and lowercases whatever it is
|
||||||
|
* given), so prefer email, then phone, and fall back to username last —
|
||||||
|
* a mixed-case username would not survive that lowercasing.
|
||||||
|
*/
|
||||||
|
private identifierFor(user: User): string | null {
|
||||||
|
return user.email ?? user.phoneNumber ?? user.username ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send one reset code to every contact on the account — email AND phone —
|
||||||
|
* returning the target so authenticated (backoffice) callers can echo a masked
|
||||||
|
* version; unauthenticated callers must discard it.
|
||||||
*
|
*
|
||||||
* Note: `otp_verifications` keys rows by a unique phone/email, and `sendOtp`
|
* Note: `otp_verifications` keys rows by a unique phone/email, and `sendOtp`
|
||||||
* upserts. A reset request therefore overwrites any pending signup code for
|
* replaces every row the target overlaps. A reset request therefore overwrites
|
||||||
* the same address — last code sent wins. That is the pre-existing behaviour
|
* any pending signup code for the same addresses — last code sent wins. That
|
||||||
* between any two flows sharing this table.
|
* is the pre-existing behaviour between any two flows sharing this table.
|
||||||
*/
|
*/
|
||||||
async requestReset(
|
async requestReset(user: User): Promise<OtpTarget | null> {
|
||||||
user: User,
|
const target = this.targetsFor(user);
|
||||||
channel: ResetChannel,
|
|
||||||
): Promise<OtpTarget | null> {
|
|
||||||
const target = this.targetFor(user, channel);
|
|
||||||
if (!target) return null;
|
if (!target) return null;
|
||||||
|
|
||||||
await this.otpService.sendOtp(target);
|
await this.otpService.sendOtp(target);
|
||||||
@@ -120,11 +165,12 @@ export class ForgotPasswordService {
|
|||||||
*/
|
*/
|
||||||
async verifyAndMintTicket(
|
async verifyAndMintTicket(
|
||||||
identifier: string,
|
identifier: string,
|
||||||
channel: ResetChannel,
|
|
||||||
otp: string,
|
otp: string,
|
||||||
): Promise<ResetTicket> {
|
): Promise<ResetTicket> {
|
||||||
const user = await this.resolveActiveUser(identifier);
|
const user = await this.resolveActiveUser(identifier);
|
||||||
const target = user && this.targetFor(user, channel);
|
// Same set of contacts `requestReset` sent to, so the code resolves whichever
|
||||||
|
// of the two the customer actually received it on.
|
||||||
|
const target = user && this.targetsFor(user);
|
||||||
|
|
||||||
if (!user?.id || !target) {
|
if (!user?.id || !target) {
|
||||||
// Same shape as a wrong code: a caller probing for accounts learns nothing
|
// Same shape as a wrong code: a caller probing for accounts learns nothing
|
||||||
@@ -134,9 +180,18 @@ export class ForgotPasswordService {
|
|||||||
|
|
||||||
await this.otpService.verifyOtpForAction(target, otp, RESET_OTP_TTL_MS);
|
await this.otpService.verifyOtpForAction(target, otp, RESET_OTP_TTL_MS);
|
||||||
|
|
||||||
|
return await this.mintResetTicket(user.id, RESET_TICKET_TTL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a single-use IAM reset ticket. Shared by the OTP flow (where the code
|
||||||
|
* is the proof of possession) and the staff-triggered link flow (where the
|
||||||
|
* ticket travels in the link and delivery to the account's own inbox/handset
|
||||||
|
* is the proof).
|
||||||
|
*/
|
||||||
|
async mintResetTicket(userId: string, ttlMs: number): Promise<ResetTicket> {
|
||||||
const code = randomBytes(24).toString("base64url");
|
const code = randomBytes(24).toString("base64url");
|
||||||
const verificationCode = await hashPassword(code);
|
const verificationCode = await hashPassword(code);
|
||||||
const userId = user.id;
|
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const repo = manager.getRepository(UserVerification);
|
const repo = manager.getRepository(UserVerification);
|
||||||
@@ -147,7 +202,7 @@ export class ForgotPasswordService {
|
|||||||
userId,
|
userId,
|
||||||
otpType: EOtpType.RESET_PASSWORD,
|
otpType: EOtpType.RESET_PASSWORD,
|
||||||
verificationCode,
|
verificationCode,
|
||||||
expiresAt: new Date(Date.now() + RESET_TICKET_TTL_MS),
|
expiresAt: new Date(Date.now() + ttlMs),
|
||||||
isUsed: false,
|
isUsed: false,
|
||||||
attemptCount: 0,
|
attemptCount: 0,
|
||||||
});
|
});
|
||||||
@@ -157,6 +212,63 @@ export class ForgotPasswordService {
|
|||||||
return { userId, verificationCode: code };
|
return { userId, verificationCode: code };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a reset link and hand back everything the portal needs to spend it
|
||||||
|
* on IAM's `PATCH /api/auth/set-password`.
|
||||||
|
*
|
||||||
|
* The checks mirror IAM's own — newest row, unused, unexpired, attempts left,
|
||||||
|
* argon match — so a link that resolves here is one IAM will honour. Doing
|
||||||
|
* them up front is what lets the page say "this link has expired" before the
|
||||||
|
* customer types a password rather than after.
|
||||||
|
*
|
||||||
|
* Every rejection is the same message: a link is a bearer credential, and the
|
||||||
|
* holder of a bad one learns nothing about why it failed or whether the user
|
||||||
|
* id exists.
|
||||||
|
*/
|
||||||
|
async resolveResetLink(
|
||||||
|
userId: string,
|
||||||
|
token: string,
|
||||||
|
): Promise<ResetLinkAccount> {
|
||||||
|
const invalid = new BadRequestException(
|
||||||
|
"This password-reset link is invalid or has expired. Request a new one.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = await this.resolveActiveUserById(userId);
|
||||||
|
const identifier = user && this.identifierFor(user);
|
||||||
|
if (!user || !identifier) throw invalid;
|
||||||
|
|
||||||
|
const verification = await this.dataSource
|
||||||
|
.getRepository(UserVerification)
|
||||||
|
.findOne({
|
||||||
|
where: { userId, otpType: EOtpType.RESET_PASSWORD },
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// `expiresAt` / `attemptCount` are optional on IAM's entity but always
|
||||||
|
// written by `mintResetTicket`. A row missing either is malformed, so treat
|
||||||
|
// it as expired rather than letting it through unchecked.
|
||||||
|
if (
|
||||||
|
!verification ||
|
||||||
|
verification.isUsed ||
|
||||||
|
!verification.expiresAt ||
|
||||||
|
verification.expiresAt < new Date() ||
|
||||||
|
(verification.attemptCount ?? 0) >= MAX_TICKET_ATTEMPTS ||
|
||||||
|
!(await verifyPassword(token, verification.verificationCode))
|
||||||
|
) {
|
||||||
|
this.logger.warn(`Reset link rejected for user ${userId}`);
|
||||||
|
throw invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
identifier,
|
||||||
|
maskedIdentifier: maskOtpTarget(
|
||||||
|
identifier.includes("@") ? { email: identifier } : { phone: identifier },
|
||||||
|
),
|
||||||
|
verificationCode: token,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */
|
/** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */
|
||||||
maskTarget(target: OtpTarget): string {
|
maskTarget(target: OtpTarget): string {
|
||||||
return maskOtpTarget(target);
|
return maskOtpTarget(target);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
|||||||
import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity';
|
import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity';
|
||||||
|
|
||||||
import { ExternalProfile } from '../companies/entities/external-profile.entity';
|
import { ExternalProfile } from '../companies/entities/external-profile.entity';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { OtpModule } from '../otp/otp.module';
|
import { OtpModule } from '../otp/otp.module';
|
||||||
import { AccountController } from './account.controller';
|
import { AccountController } from './account.controller';
|
||||||
import { AccountService } from './account.service';
|
import { AccountService } from './account.service';
|
||||||
@@ -29,6 +30,8 @@ import { FreightMeService } from './freight-me.service';
|
|||||||
Employee,
|
Employee,
|
||||||
]),
|
]),
|
||||||
OtpModule,
|
OtpModule,
|
||||||
|
// Reset links go out over email/SMS directly, not through the OTP service.
|
||||||
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
FreightMeController,
|
FreightMeController,
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
import { OtpTarget } from "../otp/otp.service";
|
import { OtpTarget } from "../otp/otp.service";
|
||||||
|
|
||||||
|
function maskEmail(email: string): string {
|
||||||
|
const [local, domain] = email.split("@");
|
||||||
|
const head = local.slice(0, 1);
|
||||||
|
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskPhone(phone: string): string {
|
||||||
|
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mask an OTP target for echoing back to the caller: `+251911234567` ->
|
* Mask an OTP target for echoing back to the caller: `+251911234567` ->
|
||||||
* `+251•••••4567`; `ab@x.com` -> `a•@x.com`. Never return an unmasked target to
|
* `+251•••••4567`; `ab@x.com` -> `a•@x.com`. Never return an unmasked target to
|
||||||
* a caller who has not yet proven possession of the channel.
|
* a caller who has not yet proven possession of the channel.
|
||||||
|
*
|
||||||
|
* A dual-channel target masks both and joins them, so the UI can say exactly
|
||||||
|
* where the code went ("a•@x.com and +251•••••4567") — a user who only checks
|
||||||
|
* one of the two otherwise assumes the other never received anything.
|
||||||
*/
|
*/
|
||||||
export function maskOtpTarget(target: OtpTarget): string {
|
export function maskOtpTarget(target: OtpTarget): string {
|
||||||
if (target.email) {
|
const parts: string[] = [];
|
||||||
const [local, domain] = target.email.split("@");
|
if (target.email) parts.push(maskEmail(target.email));
|
||||||
const head = local.slice(0, 1);
|
if (target.phone) parts.push(maskPhone(target.phone));
|
||||||
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
|
return parts.join(" and ");
|
||||||
}
|
|
||||||
const phone = target.phone ?? "";
|
|
||||||
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,6 +253,19 @@ export class BookingLifecycleNotifierService {
|
|||||||
|
|
||||||
// ── Staff-facing (backoffice inbox) ────────────────────────────────────────
|
// ── Staff-facing (backoffice inbox) ────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A booking was created under a contract. Contract drawdowns never pass
|
||||||
|
* through submit, so this is the only point at which staff learn the booking
|
||||||
|
* exists — {@link submittedToStaff} covers the direct-booking flow instead.
|
||||||
|
*/
|
||||||
|
createdToStaff(b: Booking): void {
|
||||||
|
this.inAppStaff(
|
||||||
|
b,
|
||||||
|
'New booking created',
|
||||||
|
`Booking ${this.ref(b)} was created under a contract and has entered the pipeline.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Customer submitted a booking for review. */
|
/** Customer submitted a booking for review. */
|
||||||
submittedToStaff(b: Booking): void {
|
submittedToStaff(b: Booking): void {
|
||||||
this.inAppStaff(
|
this.inAppStaff(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
|||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{} as never, // clearanceFeeService
|
{} as never, // clearanceFeeService
|
||||||
|
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
||||||
{} as never, // dataSource
|
{} as never, // dataSource
|
||||||
{} as never, // trainSchedulingService
|
{} as never, // trainSchedulingService
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
|||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
invoiceService as never,
|
invoiceService as never,
|
||||||
{} as never, // clearanceFeeService
|
{} as never, // clearanceFeeService
|
||||||
|
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
||||||
{} as never, // dataSource
|
{} as never, // dataSource
|
||||||
{} as never, // trainSchedulingService
|
{} as never, // trainSchedulingService
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { BookingContainerUnit } from '../bookings/entities/booking-container-uni
|
|||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||||
import { BookingTransitionService } from '../bookings/booking-transition.service';
|
import { BookingTransitionService } from '../bookings/booking-transition.service';
|
||||||
|
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||||
import { ConsolidationService } from '../bookings/consolidation.service';
|
import { ConsolidationService } from '../bookings/consolidation.service';
|
||||||
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
||||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||||
@@ -97,6 +98,7 @@ export class ContractBookingService {
|
|||||||
private readonly workflowService: ClearanceWorkflowService,
|
private readonly workflowService: ClearanceWorkflowService,
|
||||||
private readonly invoiceService: BookingInvoiceService,
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
private readonly clearanceFeeService: ClearanceFeeService,
|
private readonly clearanceFeeService: ClearanceFeeService,
|
||||||
|
private readonly bookingNotifier: BookingLifecycleNotifierService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
@Inject(forwardRef(() => TrainSchedulingService))
|
@Inject(forwardRef(() => TrainSchedulingService))
|
||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
@@ -352,6 +354,12 @@ export class ContractBookingService {
|
|||||||
const withContainers = await this.bookingsRepository.findByIdWithFiles(
|
const withContainers = await this.bookingsRepository.findByIdWithFiles(
|
||||||
booking.id,
|
booking.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Tell staff the booking exists. Placed after the zero-price rollback (which
|
||||||
|
// hard-deletes the row) and before the consolidation gate, so it fires
|
||||||
|
// exactly once whether the booking parks for a partner or finalizes inline.
|
||||||
|
this.bookingNotifier.createdToStaff(withContainers ?? booking);
|
||||||
|
|
||||||
const intendedStatus =
|
const intendedStatus =
|
||||||
generalCustoms || generalSelfClear
|
generalCustoms || generalSelfClear
|
||||||
? 'AWAITING_DOCUMENTS'
|
? 'AWAITING_DOCUMENTS'
|
||||||
@@ -481,6 +489,7 @@ export class ContractBookingService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||||
|
this.bookingNotifier.createdToStaff(result ?? booking);
|
||||||
return { booking: result ?? booking, warnings: [] };
|
return { booking: result ?? booking, warnings: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,7 +577,10 @@ export class ContractBookingService {
|
|||||||
await this.clearanceFeeService.issueForBooking(booking, contract);
|
await this.clearanceFeeService.issueForBooking(booking, contract);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
|
const created =
|
||||||
|
(await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
|
||||||
|
this.bookingNotifier.createdToStaff(created);
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -73,6 +73,27 @@ function maskPhone(phone: string): string {
|
|||||||
return `${'•'.repeat(trimmed.length - 4)}${trimmed.slice(-4)}`;
|
return `${'•'.repeat(trimmed.length - 4)}${trimmed.slice(-4)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Email counterpart of {@link maskPhone} (`jane@x.com` → `j•••@x.com`). */
|
||||||
|
function maskEmail(email: string): string {
|
||||||
|
const [local, domain] = email.trim().split('@');
|
||||||
|
if (!domain) return email.trim();
|
||||||
|
return `${local.slice(0, 1)}${'•'.repeat(Math.max(local.length - 1, 1))}@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the signing code went, for the "we sent a code to …" line in the UI.
|
||||||
|
* Both contacts are listed when both were used — a signer who only watches their
|
||||||
|
* handset otherwise has no idea the email carries the same code.
|
||||||
|
*/
|
||||||
|
function maskSignerContacts(contacts: { phone?: string; email?: string }): string {
|
||||||
|
return [
|
||||||
|
contacts.email ? maskEmail(contacts.email) : null,
|
||||||
|
contacts.phone ? maskPhone(contacts.phone) : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' and ');
|
||||||
|
}
|
||||||
|
|
||||||
/** Status-machine guard mirroring booking-status.util. */
|
/** Status-machine guard mirroring booking-status.util. */
|
||||||
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
||||||
if (!allowed.includes(contract.status)) {
|
if (!allowed.includes(contract.status)) {
|
||||||
@@ -109,34 +130,39 @@ export class ContractTransitionService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The phone the signing OTP is sent to and verified against: the signer's own
|
* The contacts the signing OTP is sent to and verified against: the signer's
|
||||||
* IAM account number.
|
* own IAM account phone AND email. One code goes to both and either delivery
|
||||||
|
* verifies it, so a signer whose SMS is delayed can still complete from their
|
||||||
|
* inbox instead of abandoning a ready contract.
|
||||||
*
|
*
|
||||||
* H12(b): resolved server-side from the authenticated user id, never from the
|
* H12(b): resolved server-side from the authenticated user id, never from the
|
||||||
* request body — a caller-supplied number would let an attacker point the code
|
* request body — caller-supplied contacts would let an attacker point the code
|
||||||
* at their own phone. Ownership is already gated separately by
|
* at their own phone or mailbox. Ownership is already gated separately by
|
||||||
* {@link ContractsService.assertCustomerCanAccessContract}, so this binds the
|
* {@link ContractsService.assertCustomerCanAccessContract}, so this binds the
|
||||||
* signature to the *person* signing rather than to a company landline that may
|
* signature to the *person* signing rather than to a company landline that may
|
||||||
* be shared, stale, or imported from eTrade.
|
* be shared, stale, or imported from eTrade.
|
||||||
*/
|
*/
|
||||||
private async resolveSignerPhone(signerUserId?: string): Promise<string> {
|
private async resolveSignerContacts(
|
||||||
|
signerUserId?: string,
|
||||||
|
): Promise<{ phone?: string; email?: string }> {
|
||||||
if (!signerUserId) {
|
if (!signerUserId) {
|
||||||
// Unreachable in practice (the ownership gate rejects a missing user
|
// Unreachable in practice (the ownership gate rejects a missing user
|
||||||
// first), but never fall back to another number if it ever changes.
|
// first), but never fall back to another account if it ever changes.
|
||||||
throw new BadRequestException('Authentication required to sign');
|
throw new BadRequestException('Authentication required to sign');
|
||||||
}
|
}
|
||||||
const rows: Array<{ phone_number: string | null }> =
|
const rows: Array<{ phone_number: string | null; email: string | null }> =
|
||||||
await this.dataSource.query(
|
await this.dataSource.query(
|
||||||
`SELECT phone_number FROM iam.users WHERE id = $1 AND is_active = true`,
|
`SELECT phone_number, email FROM iam.users WHERE id = $1 AND is_active = true`,
|
||||||
[signerUserId],
|
[signerUserId],
|
||||||
);
|
);
|
||||||
const phone = rows[0]?.phone_number?.trim();
|
const phone = rows[0]?.phone_number?.trim();
|
||||||
if (!phone) {
|
const email = rows[0]?.email?.trim();
|
||||||
|
if (!phone && !email) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Your account has no registered phone number. Add one in Settings → Account before signing.',
|
'Your account has no registered phone number or email. Add one in Settings → Account before signing.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return phone;
|
return { ...(phone ? { phone } : {}), ...(email ? { email } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||||
@@ -839,11 +865,11 @@ export class ContractTransitionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send the sudo-mode signing OTP to the SIGNER's own registered phone — the
|
* Send the sudo-mode signing OTP to the SIGNER's own registered phone and
|
||||||
* same number {@link sign} verifies against. The client never picks the number
|
* email — the same contacts {@link sign} verifies against. The client never
|
||||||
* (that is the H12(b) trust property): it only asks us to send, and we resolve
|
* picks them (that is the H12(b) trust property): it only asks us to send, and
|
||||||
* the phone from the authenticated user id. Returns a masked hint so the UI can
|
* we resolve them from the authenticated user id. Returns a masked hint so the
|
||||||
* say where the code went without exposing the full number.
|
* UI can say where the code went without exposing the full values.
|
||||||
*/
|
*/
|
||||||
async sendSigningOtp(
|
async sendSigningOtp(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
@@ -858,9 +884,9 @@ export class ContractTransitionService {
|
|||||||
);
|
);
|
||||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||||
|
|
||||||
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
|
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
|
||||||
await this.otpService.sendOtp({ phone: signerPhone });
|
await this.otpService.sendOtp(signerContacts);
|
||||||
return { sentTo: maskPhone(signerPhone) };
|
return { sentTo: maskSignerContacts(signerContacts) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
|
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
|
||||||
@@ -887,17 +913,17 @@ export class ContractTransitionService {
|
|||||||
}
|
}
|
||||||
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
||||||
// signature is applied. H12(b): verify against the SIGNER's own registered
|
// signature is applied. H12(b): verify against the SIGNER's own registered
|
||||||
// phone, resolved server-side from the authenticated user id — never a
|
// contacts, resolved server-side from the authenticated user id — never
|
||||||
// caller-supplied number, which an attacker could point at their own
|
// caller-supplied ones, which an attacker could point at their own phone
|
||||||
// phone. Ownership is already asserted above, so this proves the specific
|
// or mailbox. Ownership is already asserted above, so this proves the
|
||||||
// person holding the account is present, not merely that someone reached a
|
// specific person holding the account is present, not merely that someone
|
||||||
// shared company line. Must resolve identically to sendSigningOtp, or send
|
// reached a shared company line. Must resolve identically to
|
||||||
// and verify would target different numbers.
|
// sendSigningOtp, or send and verify would target different contacts.
|
||||||
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
|
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
|
||||||
if (!dto.otp) {
|
if (!dto.otp) {
|
||||||
throw new BadRequestException('OTP verification is required to sign the contract');
|
throw new BadRequestException('OTP verification is required to sign the contract');
|
||||||
}
|
}
|
||||||
await this.otpService.verifyOtpForAction({ phone: signerPhone }, dto.otp);
|
await this.otpService.verifyOtpForAction(signerContacts, dto.otp);
|
||||||
await this.applySignature(contract, dto, options);
|
await this.applySignature(contract, dto, options);
|
||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'SIGNED_CUSTOMER',
|
status: 'SIGNED_CUSTOMER',
|
||||||
|
|||||||
@@ -11,12 +11,17 @@ import {
|
|||||||
import { OtpService, OtpTarget } from "./otp.service";
|
import { OtpService, OtpTarget } from "./otp.service";
|
||||||
import { Public } from "@edr/api-common";
|
import { Public } from "@edr/api-common";
|
||||||
|
|
||||||
// Exactly one of phone/email must be present per request — the channel the
|
// At least one of phone/email must be present. When BOTH are given the code is
|
||||||
// code is sent through / checked against.
|
// sent to both and either one verifies it — the caller no longer picks a single
|
||||||
|
// channel, it just states every address it knows for the account.
|
||||||
function toTarget(phone?: string, email?: string): OtpTarget {
|
function toTarget(phone?: string, email?: string): OtpTarget {
|
||||||
if (email) return { email };
|
const target: OtpTarget = {};
|
||||||
if (phone) return { phone };
|
if (email?.trim()) target.email = email;
|
||||||
throw new BadRequestException("phone or email is required");
|
if (phone?.trim()) target.phone = phone;
|
||||||
|
if (!target.email && !target.phone) {
|
||||||
|
throw new BadRequestException("phone or email is required");
|
||||||
|
}
|
||||||
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: these public routes need per-target + per-IP rate limiting (a NestJS
|
// TODO: these public routes need per-target + per-IP rate limiting (a NestJS
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { Injectable } from "@nestjs/common";
|
|||||||
|
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
|
||||||
import { Repository } from "typeorm";
|
import { FindOptionsWhere, Repository } from "typeorm";
|
||||||
|
|
||||||
import { OtpVerification } from "./otp.entity";
|
import { OtpVerification } from "./otp.entity";
|
||||||
|
|
||||||
|
type Target = { phone?: string; email?: string };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OtpRepository {
|
export class OtpRepository {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -46,54 +48,112 @@ export class OtpRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Find By Target (either channel)
|
// Find By Target (any named channel)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async findByTarget(
|
/**
|
||||||
target: { phone?: string; email?: string }
|
* OR across every channel the target names. A code sent to both phone and
|
||||||
) {
|
* email lives in ONE row carrying both values, so a verify that quotes either
|
||||||
return target.email
|
* one resolves the same row — that is what makes "sent to both, verify with
|
||||||
? this.findByEmail(target.email)
|
* either" work.
|
||||||
: this.findByPhone(target.phone!);
|
*/
|
||||||
}
|
private whereForTarget(
|
||||||
|
target: Target
|
||||||
|
): FindOptionsWhere<OtpVerification>[] {
|
||||||
|
const where: FindOptionsWhere<OtpVerification>[] =
|
||||||
|
[];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
if (target.email)
|
||||||
// Create OTP
|
where.push({
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async createOtp(
|
|
||||||
target: { phone?: string; email?: string },
|
|
||||||
otp: string
|
|
||||||
) {
|
|
||||||
const entity =
|
|
||||||
this.repository.create({
|
|
||||||
phone: target.phone,
|
|
||||||
email: target.email,
|
email: target.email,
|
||||||
otp,
|
|
||||||
verified: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.repository.save(
|
if (target.phone)
|
||||||
entity
|
where.push({
|
||||||
|
phone: target.phone,
|
||||||
|
});
|
||||||
|
|
||||||
|
return where;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllByTarget(
|
||||||
|
target: Target
|
||||||
|
) {
|
||||||
|
const where =
|
||||||
|
this.whereForTarget(target);
|
||||||
|
|
||||||
|
if (!where.length) return [];
|
||||||
|
|
||||||
|
// Newest first: a target that somehow overlaps two legacy single-channel
|
||||||
|
// rows should resolve to the most recently issued code, not an arbitrary one.
|
||||||
|
return this.repository.find({
|
||||||
|
where,
|
||||||
|
order: { updatedAt: "DESC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByTarget(
|
||||||
|
target: Target
|
||||||
|
) {
|
||||||
|
const [
|
||||||
|
newest,
|
||||||
|
] = await this.findAllByTarget(
|
||||||
|
target
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return newest ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Update OTP
|
// Replace OTP (upsert across every channel the target names)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async updateOtp(
|
/**
|
||||||
otpVerification: OtpVerification,
|
* Drop every row this target overlaps and write a single fresh one holding
|
||||||
|
* all its channels.
|
||||||
|
*
|
||||||
|
* `phone` and `email` are each UNIQUE, so a dual-channel send can collide with
|
||||||
|
* up to two pre-existing single-channel rows (say an old signup code on the
|
||||||
|
* phone and a reset code on the email). Merging into one row instead of
|
||||||
|
* updating in place is what keeps that from raising a unique violation, and it
|
||||||
|
* preserves the single-use guarantee: consuming the code deletes one row and
|
||||||
|
* kills every channel it was sent to at once.
|
||||||
|
*
|
||||||
|
* "Last code sent wins" was already the behaviour between any two flows
|
||||||
|
* sharing this table — this only widens it from one channel to all of them.
|
||||||
|
*/
|
||||||
|
async replaceOtp(
|
||||||
|
target: Target,
|
||||||
otp: string
|
otp: string
|
||||||
) {
|
): Promise<{
|
||||||
otpVerification.otp = otp;
|
record: OtpVerification;
|
||||||
|
rotated: boolean;
|
||||||
|
}> {
|
||||||
|
const existing =
|
||||||
|
await this.findAllByTarget(
|
||||||
|
target
|
||||||
|
);
|
||||||
|
|
||||||
otpVerification.verified =
|
if (existing.length) {
|
||||||
false;
|
await this.repository.remove(
|
||||||
|
existing
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.repository.save(
|
const record =
|
||||||
otpVerification
|
await this.repository.save(
|
||||||
);
|
this.repository.create({
|
||||||
|
phone: target.phone,
|
||||||
|
email: target.email,
|
||||||
|
otp,
|
||||||
|
verified: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
record,
|
||||||
|
rotated: existing.length > 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -115,8 +175,8 @@ export class OtpRepository {
|
|||||||
// Delete OTP (single-use consume)
|
// Delete OTP (single-use consume)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Hard delete so the unique `phone` row is freed and a fresh code can be
|
// Hard delete so the unique `phone`/`email` rows are freed and a fresh code can
|
||||||
// requested for the same number on the next action.
|
// be requested for the same target on the next action.
|
||||||
async deleteOtp(
|
async deleteOtp(
|
||||||
otpVerification: OtpVerification
|
otpVerification: OtpVerification
|
||||||
) {
|
) {
|
||||||
@@ -124,4 +184,4 @@ export class OtpRepository {
|
|||||||
otpVerification
|
otpVerification
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,55 +28,216 @@ describe('normalizeOtpTarget', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('OtpService — send/verify agree across phone formats', () => {
|
interface FakeRow {
|
||||||
// In-memory fake keyed by the exact phone string the service stores under, so
|
id: string;
|
||||||
// the test proves normalisation makes send and verify collide on one key.
|
phone?: string;
|
||||||
function makeService() {
|
email?: string;
|
||||||
const rows = new Map<string, { phone?: string; email?: string; otp: string; updatedAt: Date }>();
|
otp: string;
|
||||||
const repo = {
|
updatedAt: Date;
|
||||||
findByTarget: jest.fn(async (t: { phone?: string; email?: string }) =>
|
}
|
||||||
rows.get(t.email ?? t.phone!) ?? null,
|
|
||||||
),
|
|
||||||
updateOtp: jest.fn(async (existing: { otp: string }, otp: string) => {
|
|
||||||
existing.otp = otp;
|
|
||||||
}),
|
|
||||||
createOtp: jest.fn(async (t: { phone?: string; email?: string }, otp: string) => {
|
|
||||||
rows.set(t.phone ?? t.email!, { ...t, otp, updatedAt: new Date(0) });
|
|
||||||
}),
|
|
||||||
deleteOtp: jest.fn(async (row: { phone?: string; email?: string }) => {
|
|
||||||
rows.delete(row.phone ?? row.email!);
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
// Both clients return `{ queued }` — the service reads it to tell a published
|
|
||||||
// code apart from one the transport silently dropped.
|
|
||||||
const sms = { sendSms: jest.fn().mockResolvedValue({ queued: true }) };
|
|
||||||
const email = { sendEmail: jest.fn().mockResolvedValue({ queued: true }) };
|
|
||||||
const service = new OtpService(repo as never, sms as never, email as never);
|
|
||||||
return { service, rows };
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory stand-in for OtpRepository, mirroring the two properties the service
|
||||||
|
* depends on: rows are matched by OR across every channel named, and a send
|
||||||
|
* replaces all overlapping rows with one row carrying every channel.
|
||||||
|
*/
|
||||||
|
function makeService(
|
||||||
|
transports: {
|
||||||
|
sms?: () => Promise<{ queued: boolean }>;
|
||||||
|
email?: () => Promise<{ queued: boolean }>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
let rows: FakeRow[] = [];
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
const matches = (row: FakeRow, t: { phone?: string; email?: string }) =>
|
||||||
|
(!!t.email && row.email === t.email) || (!!t.phone && row.phone === t.phone);
|
||||||
|
|
||||||
|
const repo = {
|
||||||
|
findByTarget: jest.fn(
|
||||||
|
async (t: { phone?: string; email?: string }) =>
|
||||||
|
rows.filter((row) => matches(row, t))[0] ?? null,
|
||||||
|
),
|
||||||
|
replaceOtp: jest.fn(
|
||||||
|
async (t: { phone?: string; email?: string }, otp: string) => {
|
||||||
|
const overlapping = rows.filter((row) => matches(row, t));
|
||||||
|
rows = rows.filter((row) => !overlapping.includes(row));
|
||||||
|
const record: FakeRow = {
|
||||||
|
id: String(nextId++),
|
||||||
|
...t,
|
||||||
|
otp,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
rows.push(record);
|
||||||
|
return { record, rotated: overlapping.length > 0 };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
deleteOtp: jest.fn(async (row: FakeRow) => {
|
||||||
|
rows = rows.filter((r) => r !== row);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Both clients return `{ queued }` — the service reads it to tell a published
|
||||||
|
// code apart from one the transport silently dropped.
|
||||||
|
const sms = {
|
||||||
|
sendSms: jest.fn(transports.sms ?? (async () => ({ queued: true }))),
|
||||||
|
};
|
||||||
|
const email = {
|
||||||
|
sendEmail: jest.fn(transports.email ?? (async () => ({ queued: true }))),
|
||||||
|
};
|
||||||
|
const service = new OtpService(repo as never, sms as never, email as never);
|
||||||
|
return { service, sms, email, rows: () => rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OtpService — send/verify agree across phone formats', () => {
|
||||||
it('verifies a code sent to +251… when verify is called with 09…', async () => {
|
it('verifies a code sent to +251… when verify is called with 09…', async () => {
|
||||||
const { service, rows } = makeService();
|
const { service, rows } = makeService();
|
||||||
await service.sendOtp({ phone: '+251986680099' });
|
await service.sendOtp({ phone: '+251986680099' });
|
||||||
const stored = [...rows.values()][0]!.otp;
|
|
||||||
|
|
||||||
// Fresh TTL: stamp updatedAt to now so the action verifier does not expire it.
|
|
||||||
[...rows.values()][0]!.updatedAt = new Date();
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.verifyOtpForAction({ phone: '0986680099' }, stored),
|
service.verifyOtpForAction({ phone: '0986680099' }, rows()[0]!.otp),
|
||||||
).resolves.toEqual({ success: true });
|
).resolves.toEqual({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('verifies a code sent to User@X.com when verify is called with user@x.com', async () => {
|
it('verifies a code sent to User@X.com when verify is called with user@x.com', async () => {
|
||||||
const { service, rows } = makeService();
|
const { service, rows } = makeService();
|
||||||
await service.sendOtp({ email: ' User@Example.COM ' });
|
await service.sendOtp({ email: ' User@Example.COM ' });
|
||||||
const stored = [...rows.values()][0]!.otp;
|
|
||||||
|
|
||||||
[...rows.values()][0]!.updatedAt = new Date();
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.verifyOtpForAction({ email: 'user@example.com' }, stored),
|
service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp),
|
||||||
).resolves.toEqual({ success: true });
|
).resolves.toEqual({ success: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('OtpService — dual-channel send', () => {
|
||||||
|
const both = { phone: '0986680099', email: 'User@Example.COM' };
|
||||||
|
|
||||||
|
it('sends ONE code to both transports', async () => {
|
||||||
|
const { service, sms, email, rows } = makeService();
|
||||||
|
await service.sendOtp(both);
|
||||||
|
|
||||||
|
const otp = rows()[0]!.otp;
|
||||||
|
expect(sms.sendSms).toHaveBeenCalledTimes(1);
|
||||||
|
expect(email.sendEmail).toHaveBeenCalledTimes(1);
|
||||||
|
// Same secret on both messages — the user types whichever arrives first.
|
||||||
|
expect(sms.sendSms).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: '+251986680099',
|
||||||
|
message: expect.stringContaining(otp),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(email.sendEmail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: 'user@example.com',
|
||||||
|
text: expect.stringContaining(otp),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// One row, both channels canonicalised.
|
||||||
|
expect(rows()).toHaveLength(1);
|
||||||
|
expect(rows()[0]).toMatchObject({
|
||||||
|
phone: '+251986680099',
|
||||||
|
email: 'user@example.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['phone alone', { phone: '0986680099' }],
|
||||||
|
['email alone', { email: 'user@example.com' }],
|
||||||
|
['both', both],
|
||||||
|
])('verifies a dual-channel code when quoted back by %s', async (_label, target) => {
|
||||||
|
const { service, rows } = makeService();
|
||||||
|
await service.sendOtp(both);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.verifyOtpForAction(target, rows()[0]!.otp),
|
||||||
|
).resolves.toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('consuming the code via one channel kills the other', async () => {
|
||||||
|
const { service, rows } = makeService();
|
||||||
|
await service.sendOtp(both);
|
||||||
|
const otp = rows()[0]!.otp;
|
||||||
|
|
||||||
|
await service.verifyOtpForAction({ email: 'user@example.com' }, otp);
|
||||||
|
|
||||||
|
// Single-use is per-code, not per-channel: the phone half must be dead too.
|
||||||
|
await expect(
|
||||||
|
service.verifyOtpForAction({ phone: '0986680099' }, otp),
|
||||||
|
).rejects.toThrow(/No verification code was requested/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces an overlapping single-channel row instead of colliding with it', async () => {
|
||||||
|
const { service, rows } = makeService();
|
||||||
|
// A pending signup code on the phone only, then a dual-channel send.
|
||||||
|
await service.sendOtp({ phone: '0986680099' });
|
||||||
|
await service.sendOtp(both);
|
||||||
|
|
||||||
|
expect(rows()).toHaveLength(1);
|
||||||
|
expect(rows()[0]).toMatchObject({ email: 'user@example.com' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('degrades to one channel when the account has only one contact', async () => {
|
||||||
|
const { service, sms, email } = makeService();
|
||||||
|
await service.sendOtp({ phone: '0986680099' });
|
||||||
|
|
||||||
|
expect(sms.sendSms).toHaveBeenCalledTimes(1);
|
||||||
|
expect(email.sendEmail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still succeeds when one transport throws', async () => {
|
||||||
|
const { service, rows } = makeService({
|
||||||
|
sms: async () => {
|
||||||
|
throw new Error('broker down');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.sendOtp(both)).resolves.toMatchObject({
|
||||||
|
success: true,
|
||||||
|
delivered: true,
|
||||||
|
});
|
||||||
|
// The code is live and verifiable on the channel that worked.
|
||||||
|
await expect(
|
||||||
|
service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp),
|
||||||
|
).resolves.toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails the request when every transport throws', async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
sms: async () => {
|
||||||
|
throw new Error('broker down');
|
||||||
|
},
|
||||||
|
email: async () => {
|
||||||
|
throw new Error('broker down');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.sendOtp(both)).rejects.toThrow('Failed to send OTP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shares one brute-force budget across both channels', async () => {
|
||||||
|
const { service, rows } = makeService();
|
||||||
|
await service.sendOtp(both);
|
||||||
|
const otp = rows()[0]!.otp;
|
||||||
|
|
||||||
|
// Alternating channels must not hand the attacker two independent budgets:
|
||||||
|
// 5 wrong guesses in total burn the code regardless of how they are split.
|
||||||
|
for (const target of [
|
||||||
|
{ phone: '0986680099' },
|
||||||
|
{ email: 'user@example.com' },
|
||||||
|
{ phone: '0986680099' },
|
||||||
|
{ email: 'user@example.com' },
|
||||||
|
]) {
|
||||||
|
await expect(service.verifyOtpForAction(target, '000000')).rejects.toThrow(
|
||||||
|
'Invalid verification code',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await expect(
|
||||||
|
service.verifyOtpForAction({ email: 'user@example.com' }, '000000'),
|
||||||
|
).rejects.toThrow(/Too many incorrect attempts/);
|
||||||
|
|
||||||
|
// Burned: even the correct code no longer works.
|
||||||
|
await expect(service.verifyOtpForAction(both, otp)).rejects.toThrow(
|
||||||
|
/No verification code was requested/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,10 +8,24 @@ import { OtpRepository } from "./otp.repository";
|
|||||||
import { SmsClientService } from "../notifications/sms-client.service";
|
import { SmsClientService } from "../notifications/sms-client.service";
|
||||||
import { EmailClientService } from "../notifications/email-client.service";
|
import { EmailClientService } from "../notifications/email-client.service";
|
||||||
|
|
||||||
// Exactly one of phone/email is set — enforced by the controller before it
|
/**
|
||||||
// reaches here.
|
* Where a code goes. At least one of phone/email must be set — enforced by the
|
||||||
|
* controller and re-checked here. When BOTH are set the same code is sent to
|
||||||
|
* both and either one can be used to verify it: a user who never receives the
|
||||||
|
* SMS can still finish from their inbox, and vice versa. Callers that resolve
|
||||||
|
* contacts from IAM pass whatever the account actually has, so an account with
|
||||||
|
* only one of the two silently degrades to a single channel.
|
||||||
|
*/
|
||||||
export type OtpTarget = { phone?: string; email?: string };
|
export type OtpTarget = { phone?: string; email?: string };
|
||||||
|
|
||||||
|
/** Which transports a target resolves to, in a stable order for logging. */
|
||||||
|
function channelsOf(target: OtpTarget): Array<"email" | "sms"> {
|
||||||
|
const channels: Array<"email" | "sms"> = [];
|
||||||
|
if (target.email) channels.push("email");
|
||||||
|
if (target.phone) channels.push("sms");
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Canonicalise a phone to E.164 so the code stored on send and the one looked
|
* Canonicalise a phone to E.164 so the code stored on send and the one looked
|
||||||
* up on verify collide regardless of how the number was typed. Without this,
|
* up on verify collide regardless of how the number was typed. Without this,
|
||||||
@@ -19,30 +33,51 @@ export type OtpTarget = { phone?: string; email?: string };
|
|||||||
* a code sent to one is invisible to the others — the send/verify halves must
|
* a code sent to one is invisible to the others — the send/verify halves must
|
||||||
* agree on the exact string. Ethiopian local `09…`/`07…` (10 digits) maps to
|
* agree on the exact string. Ethiopian local `09…`/`07…` (10 digits) maps to
|
||||||
* `+2519…`/`+2517…`; a bare `251…` gains its `+`; anything already `+…` is kept.
|
* `+2519…`/`+2517…`; a bare `251…` gains its `+`; anything already `+…` is kept.
|
||||||
* Email targets pass through untouched.
|
*/
|
||||||
|
function normalizePhone(rawPhone: string): string {
|
||||||
|
const raw = rawPhone.trim();
|
||||||
|
const digits = raw.replace(/[^\d+]/g, '');
|
||||||
|
if (digits.startsWith('+')) return digits;
|
||||||
|
const bare = digits.replace(/^0+/, '');
|
||||||
|
if (/^251\d{9}$/.test(digits)) return `+${digits}`;
|
||||||
|
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`;
|
||||||
|
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
|
||||||
|
// it looks like a full international number, else leave as typed.
|
||||||
|
return digits.length >= 11 ? `+${digits}` : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonicalise every channel present on the target. Each field is normalised
|
||||||
|
* independently — a dual-channel target must end up with both halves in their
|
||||||
|
* canonical form, since verify may arrive naming either one.
|
||||||
*/
|
*/
|
||||||
export function normalizeOtpTarget(target: OtpTarget): OtpTarget {
|
export function normalizeOtpTarget(target: OtpTarget): OtpTarget {
|
||||||
if (target.email) {
|
const normalized: OtpTarget = {};
|
||||||
// Same contract as the phone branch below: the string stored on send and the
|
|
||||||
// one looked up on verify must be byte-identical, or the code is invisible to
|
if (target.email?.trim()) {
|
||||||
// the verifier. Addresses reach us from a raw `@Body("email")` with no DTO or
|
// Same contract as the phone branch: the string stored on send and the one
|
||||||
// ValidationPipe, so `User@X.com`, `user@x.com` and a copy-paste with a
|
// looked up on verify must be byte-identical, or the code is invisible to
|
||||||
|
// the verifier. Addresses reach us from a raw `@Body("email")` with no DTO
|
||||||
|
// or ValidationPipe, so `User@X.com`, `user@x.com` and a copy-paste with a
|
||||||
// trailing space are three different keys for one mailbox. Domains are
|
// trailing space are three different keys for one mailbox. Domains are
|
||||||
// case-insensitive (RFC 1035); local-parts are formally case-sensitive
|
// case-insensitive (RFC 1035); local-parts are formally case-sensitive
|
||||||
// (RFC 5321 §2.4) but no mail provider in practice treats them so, and
|
// (RFC 5321 §2.4) but no mail provider in practice treats them so, and
|
||||||
// matching what users expect beats matching the letter of the spec here.
|
// matching what users expect beats matching the letter of the spec here.
|
||||||
return { email: target.email.trim().toLowerCase() };
|
normalized.email = target.email.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
if (!target.phone) return target;
|
|
||||||
const raw = target.phone.trim();
|
if (target.phone?.trim()) {
|
||||||
const digits = raw.replace(/[^\d+]/g, '');
|
normalized.phone = normalizePhone(target.phone);
|
||||||
if (digits.startsWith('+')) return { phone: digits };
|
}
|
||||||
const bare = digits.replace(/^0+/, '');
|
|
||||||
if (/^251\d{9}$/.test(digits)) return { phone: `+${digits}` };
|
return normalized;
|
||||||
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return { phone: `+251${bare}` };
|
}
|
||||||
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
|
|
||||||
// it looks like a full international number, else leave as typed.
|
/** One transport's hand-off outcome. Never thrown — collected and reported. */
|
||||||
return { phone: digits.length >= 11 ? `+${digits}` : raw };
|
interface DispatchOutcome {
|
||||||
|
channel: "email" | "sms";
|
||||||
|
queued: boolean;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -69,35 +104,35 @@ export class OtpService {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async sendOtp(rawTarget: OtpTarget) {
|
async sendOtp(rawTarget: OtpTarget) {
|
||||||
// Store under the canonical E.164 key so verify (which normalises the same
|
// Store under the canonical keys so verify (which normalises the same way)
|
||||||
// way) always finds this row regardless of how either side typed the number.
|
// always finds this row regardless of how either side typed the number.
|
||||||
const target = normalizeOtpTarget(rawTarget);
|
const target = normalizeOtpTarget(rawTarget);
|
||||||
const channel = target.email ? "email" : "sms";
|
const channels = channelsOf(target);
|
||||||
const label = this.targetLabel(target);
|
const label = this.targetLabel(target);
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
if (channels.length === 0) {
|
||||||
|
throw new BadRequestException("phone or email is required");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// The verification code is generated server-side — never supplied by the
|
// The verification code is generated server-side — never supplied by the
|
||||||
// caller — so the OTP stays a secret known only to the server and the
|
// caller — so the OTP stays a secret known only to the server and the
|
||||||
// recipient of the SMS/email.
|
// recipient of the SMS/email. ONE code covers every channel: the user
|
||||||
|
// types whichever message reaches them first.
|
||||||
const otp = this.generateOtp();
|
const otp = this.generateOtp();
|
||||||
|
|
||||||
// find existing row for this channel
|
// Replaces every row this target overlaps with, so a dual-channel send
|
||||||
const existing = await this.otpRepository.findByTarget(target);
|
// leaves exactly one row holding both halves — verify then resolves the
|
||||||
|
// same row whichever channel it is given.
|
||||||
// update existing otp
|
const { rotated } = await this.otpRepository.replaceOtp(target, otp);
|
||||||
if (existing) {
|
|
||||||
await this.otpRepository.updateOtp(existing, otp);
|
|
||||||
} else {
|
|
||||||
// create new otp
|
|
||||||
await this.otpRepository.createOtp(target, otp);
|
|
||||||
}
|
|
||||||
|
|
||||||
// `rotate` means a code already existed for this target and was replaced —
|
// `rotate` means a code already existed for this target and was replaced —
|
||||||
// the previous one is now dead. A user holding a slow-to-arrive SMS and
|
// the previous one is now dead. A user holding a slow-to-arrive SMS and
|
||||||
// typing its code will fail against the row; this line is how that shows up
|
// typing its code will fail against the row; this line is how that shows up
|
||||||
// in the log rather than as an unexplained "invalid OTP" report.
|
// in the log rather than as an unexplained "invalid OTP" report.
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`otp.issue channel=${channel} target=${label} action=${existing ? "rotate" : "create"}`,
|
`otp.issue channels=${channels.join("+")} target=${label} action=${rotated ? "rotate" : "create"}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// NOTE: do NOT reset the brute-force attempt counter on send. Clearing it
|
// NOTE: do NOT reset the brute-force attempt counter on send. Clearing it
|
||||||
@@ -108,35 +143,49 @@ export class OtpService {
|
|||||||
// /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists
|
// /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists
|
||||||
// in the codebase yet.
|
// in the codebase yet.
|
||||||
|
|
||||||
|
// Fan out to every channel the target has, independently: one transport
|
||||||
|
// being down must not suppress the other, which is the whole point of
|
||||||
|
// sending to both. Each helper swallows its own failure so a rejected
|
||||||
|
// email publish still leaves the SMS delivered (and the code valid).
|
||||||
|
const outcomes = (
|
||||||
|
await Promise.all([
|
||||||
|
target.email ? this.dispatchEmail(target.email, otp) : null,
|
||||||
|
target.phone ? this.dispatchSms(target.phone, otp) : null,
|
||||||
|
])
|
||||||
|
).filter((outcome): outcome is DispatchOutcome => outcome !== null);
|
||||||
|
|
||||||
|
for (const outcome of outcomes) {
|
||||||
|
this.logger.log(
|
||||||
|
`otp.dispatch channel=${outcome.channel} target=${label} queued=${
|
||||||
|
outcome.queued
|
||||||
|
} latencyMs=${Date.now() - startedAt}${
|
||||||
|
outcome.error ? ` error=${outcome.error}` : ""
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every channel threw. Nothing can arrive and there is no partial success
|
||||||
|
// to preserve — fail the request the way a single-channel send always did.
|
||||||
|
if (outcomes.every((outcome) => outcome.error)) {
|
||||||
|
throw new Error(
|
||||||
|
outcomes.map((o) => `${o.channel}: ${o.error}`).join("; "),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Both clients report hand-off, not delivery — capture it rather than
|
// Both clients report hand-off, not delivery — capture it rather than
|
||||||
// discarding it, so "queued=false" is distinguishable from a code that was
|
// discarding it, so "delivered=false" is distinguishable from a code that
|
||||||
// published fine and lost downstream at the carrier.
|
// was published fine and lost downstream at the carrier.
|
||||||
const { queued } = target.email
|
const delivered = outcomes.some((outcome) => outcome.queued);
|
||||||
? await this.emailClient.sendEmail({
|
|
||||||
to: target.email,
|
|
||||||
subject: "Your EDR Freight verification code",
|
|
||||||
text: `Your verification code is ${otp}`,
|
|
||||||
})
|
|
||||||
: await this.smsClient.sendSms({
|
|
||||||
to: target.phone as string,
|
|
||||||
message: `Your verification code is ${otp}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(
|
if (!delivered) {
|
||||||
`otp.dispatch channel=${channel} target=${label} queued=${queued} latencyMs=${
|
|
||||||
Date.now() - startedAt
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!queued) {
|
|
||||||
// The row is committed and we are about to answer "OTP sent successfully",
|
// The row is committed and we are about to answer "OTP sent successfully",
|
||||||
// but nothing left this process. Without this line the only symptom is a
|
// but nothing left this process. Without this line the only symptom is a
|
||||||
// user who never receives a code — indistinguishable from carrier loss,
|
// user who never receives a code — indistinguishable from carrier loss,
|
||||||
// and the misleading success response makes it look like our side worked.
|
// and the misleading success response makes it look like our side worked.
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`otp.dispatch.dropped channel=${channel} target=${label} rabbitmqEnabled=${
|
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${
|
||||||
process.env.RABBITMQ_ENABLED ?? "unset"
|
process.env.RABBITMQ_ENABLED ?? "unset"
|
||||||
} — transport reported no hand-off; no code will arrive for this send`,
|
} — no transport reported hand-off; no code will arrive for this send`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,13 +195,13 @@ export class OtpService {
|
|||||||
// aggregation is the debugging path for flaky SMS here) — if that tradeoff
|
// aggregation is the debugging path for flaky SMS here) — if that tradeoff
|
||||||
// is ever revisited, gate on an env flag rather than deleting the line, so
|
// is ever revisited, gate on an env flag rather than deleting the line, so
|
||||||
// dev keeps its workflow.
|
// dev keeps its workflow.
|
||||||
this.logger.log(`OTP send for ${target.email ?? target.phone}: ${otp}`);
|
this.logger.log(`OTP send for ${label}: ${otp}`);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
||||||
// Distinguishes "we published it" from "the transport is a no-op". The
|
// Distinguishes "we published it" from "the transport is a no-op". The
|
||||||
// HTTP response shape is unchanged; the controller drops this field.
|
// HTTP response shape is unchanged; the controller drops this field.
|
||||||
delivered: queued,
|
delivered,
|
||||||
|
|
||||||
message: "OTP sent successfully",
|
message: "OTP sent successfully",
|
||||||
};
|
};
|
||||||
@@ -160,7 +209,7 @@ export class OtpService {
|
|||||||
// Log the real cause (DB/SMS/email failure) with its stack so a deployed
|
// Log the real cause (DB/SMS/email failure) with its stack so a deployed
|
||||||
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
|
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`otp.dispatch.failed channel=${channel} target=${label} latencyMs=${
|
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${
|
||||||
Date.now() - startedAt
|
Date.now() - startedAt
|
||||||
}: ${error instanceof Error ? error.message : String(error)}`,
|
}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
error instanceof Error ? error.stack : undefined,
|
error instanceof Error ? error.stack : undefined,
|
||||||
@@ -170,13 +219,60 @@ export class OtpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Correlation key shared by every `otp.*` line for one address, so a send and
|
* Publish to one transport, converting a throw into a reported outcome. A
|
||||||
* its later verify can be joined with a single grep. The raw target is used
|
* broker error on one channel must not abort the other — with dual-channel
|
||||||
|
* sends the user still has a working route to the code.
|
||||||
|
*/
|
||||||
|
private async dispatchEmail(
|
||||||
|
email: string,
|
||||||
|
otp: string,
|
||||||
|
): Promise<DispatchOutcome> {
|
||||||
|
try {
|
||||||
|
const { queued } = await this.emailClient.sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: "Your EDR Freight verification code",
|
||||||
|
text: `Your verification code is ${otp}`,
|
||||||
|
});
|
||||||
|
return { channel: "email", queued };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
channel: "email",
|
||||||
|
queued: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SMS half of {@link dispatchEmail}; same swallow-and-report contract. */
|
||||||
|
private async dispatchSms(
|
||||||
|
phone: string,
|
||||||
|
otp: string,
|
||||||
|
): Promise<DispatchOutcome> {
|
||||||
|
try {
|
||||||
|
const { queued } = await this.smsClient.sendSms({
|
||||||
|
to: phone,
|
||||||
|
message: `Your verification code is ${otp}`,
|
||||||
|
});
|
||||||
|
return { channel: "sms", queued };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
channel: "sms",
|
||||||
|
queued: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correlation key shared by every `otp.*` line for one target, so a send and
|
||||||
|
* its later verify can be joined with a single grep. The raw values are used
|
||||||
* because the code itself is already logged in cleartext above — hashing the
|
* because the code itself is already logged in cleartext above — hashing the
|
||||||
* address while printing the credential next to it would buy nothing.
|
* address while printing the credential next to it would buy nothing.
|
||||||
*/
|
*/
|
||||||
private targetLabel(target: OtpTarget): string {
|
private targetLabel(target: OtpTarget): string {
|
||||||
return target.email ?? target.phone ?? "unknown";
|
return (
|
||||||
|
[target.email, target.phone].filter(Boolean).join("+") || "unknown"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -190,15 +286,37 @@ export class OtpService {
|
|||||||
result: "ok" | "invalid" | "expired" | "exhausted" | "not_found",
|
result: "ok" | "invalid" | "expired" | "exhausted" | "not_found",
|
||||||
detail?: string,
|
detail?: string,
|
||||||
) {
|
) {
|
||||||
const line = `otp.verify channel=${
|
const line = `otp.verify channels=${channelsOf(target).join(
|
||||||
target.email ? "email" : "sms"
|
"+",
|
||||||
} target=${this.targetLabel(target)} mode=${mode} result=${result}${
|
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${
|
||||||
detail ? ` ${detail}` : ""
|
detail ? ` ${detail}` : ""
|
||||||
}`;
|
}`;
|
||||||
if (result === "ok") this.logger.log(line);
|
if (result === "ok") this.logger.log(line);
|
||||||
else this.logger.warn(line);
|
else this.logger.warn(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "No code for this target" phrased for whichever channels were named. A
|
||||||
|
* dual-channel caller gets a neutral message — naming one channel would be
|
||||||
|
* misleading when the code went to both.
|
||||||
|
*/
|
||||||
|
private notFoundMessage(target: OtpTarget, requested: boolean): string {
|
||||||
|
const channels = channelsOf(target);
|
||||||
|
if (channels.length !== 1) {
|
||||||
|
return requested
|
||||||
|
? "No verification code was requested for this account"
|
||||||
|
: "No verification code found for this account";
|
||||||
|
}
|
||||||
|
if (target.email) {
|
||||||
|
return requested
|
||||||
|
? "No verification code was requested for this email"
|
||||||
|
: "Email address not found";
|
||||||
|
}
|
||||||
|
return requested
|
||||||
|
? "No verification code was requested for this phone"
|
||||||
|
: "Phone number not found";
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Verify OTP
|
// Verify OTP
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -207,20 +325,24 @@ export class OtpService {
|
|||||||
// Same canonicalisation as sendOtp so a code stored under +2519… is found
|
// Same canonicalisation as sendOtp so a code stored under +2519… is found
|
||||||
// when verify is called with 09… (or any equivalent form).
|
// when verify is called with 09… (or any equivalent form).
|
||||||
const target = normalizeOtpTarget(rawTarget);
|
const target = normalizeOtpTarget(rawTarget);
|
||||||
// find the channel's row
|
// Matches on ANY channel the caller named, so a code sent to both phone and
|
||||||
|
// email verifies whichever one the user quotes back.
|
||||||
const otpData = await this.otpRepository.findByTarget(target);
|
const otpData = await this.otpRepository.findByTarget(target);
|
||||||
const key = this.targetKey(target);
|
|
||||||
|
|
||||||
// not found
|
// not found
|
||||||
if (!otpData) {
|
if (!otpData) {
|
||||||
// No row for this key. Most often a normalisation mismatch or a code that
|
// No row for this target. Most often a normalisation mismatch or a code
|
||||||
// was already consumed/burned — not necessarily a caller who never asked.
|
// that was already consumed/burned — not necessarily a caller who never
|
||||||
|
// asked.
|
||||||
this.logVerify(target, "simple", "not_found");
|
this.logVerify(target, "simple", "not_found");
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(this.notFoundMessage(target, false));
|
||||||
target.email ? "Email address not found" : "Phone number not found",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Key the attempt budget on the ROW, not on the channels the caller happened
|
||||||
|
// to name — otherwise guessing alternately by phone and by email would hand
|
||||||
|
// an attacker two independent budgets against the same code.
|
||||||
|
const key = otpData.id;
|
||||||
|
|
||||||
// TTL: reuse the same age window as the hardened action verifier — an old
|
// TTL: reuse the same age window as the hardened action verifier — an old
|
||||||
// code can't be verified.
|
// code can't be verified.
|
||||||
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
|
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
|
||||||
@@ -265,7 +387,8 @@ export class OtpService {
|
|||||||
throw new BadRequestException("Invalid OTP");
|
throw new BadRequestException("Invalid OTP");
|
||||||
}
|
}
|
||||||
|
|
||||||
// single-use: consume the code on success so it can't be replayed.
|
// single-use: consume the code on success so it can't be replayed. One row
|
||||||
|
// covers every channel it was sent to, so this kills all of them at once.
|
||||||
await this.otpRepository.deleteOtp(otpData);
|
await this.otpRepository.deleteOtp(otpData);
|
||||||
this.actionAttempts.delete(key);
|
this.actionAttempts.delete(key);
|
||||||
this.logVerify(target, "simple", "ok", `ageMs=${ageMs}`);
|
this.logVerify(target, "simple", "ok", `ageMs=${ageMs}`);
|
||||||
@@ -273,9 +396,7 @@ export class OtpService {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
||||||
message: target.email
|
message: "Verification successful",
|
||||||
? "Email verified successfully"
|
|
||||||
: "Phone verified successfully",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,10 +418,6 @@ export class OtpService {
|
|||||||
private readonly MAX_ACTION_ATTEMPTS = 5;
|
private readonly MAX_ACTION_ATTEMPTS = 5;
|
||||||
private readonly actionAttempts = new Map<string, number>();
|
private readonly actionAttempts = new Map<string, number>();
|
||||||
|
|
||||||
private targetKey(target: OtpTarget): string {
|
|
||||||
return target.email ? `email:${target.email}` : `phone:${target.phone}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifyOtpForAction(
|
async verifyOtpForAction(
|
||||||
rawTarget: OtpTarget,
|
rawTarget: OtpTarget,
|
||||||
otp: string,
|
otp: string,
|
||||||
@@ -308,17 +425,14 @@ export class OtpService {
|
|||||||
) {
|
) {
|
||||||
const target = normalizeOtpTarget(rawTarget);
|
const target = normalizeOtpTarget(rawTarget);
|
||||||
const otpData = await this.otpRepository.findByTarget(target);
|
const otpData = await this.otpRepository.findByTarget(target);
|
||||||
const key = this.targetKey(target);
|
|
||||||
|
|
||||||
if (!otpData) {
|
if (!otpData) {
|
||||||
this.logVerify(target, "action", "not_found");
|
this.logVerify(target, "action", "not_found");
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(this.notFoundMessage(target, true));
|
||||||
target.email
|
|
||||||
? "No verification code was requested for this email"
|
|
||||||
: "No verification code was requested for this phone",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Row-keyed for the same reason as verifyOtp: one code, one budget.
|
||||||
|
const key = otpData.id;
|
||||||
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
|
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
|
||||||
|
|
||||||
if (ageMs > ttlMs) {
|
if (ageMs > ttlMs) {
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||||
import {
|
|
||||||
emitApiError,
|
|
||||||
extractApiErrorPayload,
|
|
||||||
} from "@/components/errors/ApiErrorModal";
|
|
||||||
import { captureApiError } from "@/lib/posthog";
|
import { captureApiError } from "@/lib/posthog";
|
||||||
import {
|
import {
|
||||||
AUTH_TOKEN_COOKIE,
|
AUTH_TOKEN_COOKIE,
|
||||||
@@ -120,8 +116,8 @@ api.interceptors.response.use(
|
|||||||
error.response.status !== 401 &&
|
error.response.status !== 401 &&
|
||||||
!originalRequest?.suppressErrorModal
|
!originalRequest?.suppressErrorModal
|
||||||
) {
|
) {
|
||||||
const payload = extractApiErrorPayload(error);
|
// const payload = extractApiErrorPayload(error);
|
||||||
if (payload) emitApiError(payload);
|
// if (payload) emitApiError(payload);
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,13 +58,9 @@ export interface LoginResponse extends Partial<AuthTokens> {
|
|||||||
mfaRequired?: boolean;
|
mfaRequired?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The channel a password-reset code is delivered over. */
|
|
||||||
export type ResetChannel = "email" | "phone";
|
|
||||||
|
|
||||||
export interface ForgotPasswordRequestPayload {
|
export interface ForgotPasswordRequestPayload {
|
||||||
/** Email, username, or E.164 phone — whatever the user typed, normalised. */
|
/** Email, username, or E.164 phone — whatever the user typed, normalised. */
|
||||||
identifier: string;
|
identifier: string;
|
||||||
channel: ResetChannel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ForgotPasswordVerifyPayload extends ForgotPasswordRequestPayload {
|
export interface ForgotPasswordVerifyPayload extends ForgotPasswordRequestPayload {
|
||||||
|
|||||||
@@ -1,70 +1,20 @@
|
|||||||
import { Alert, Button, PinInput, SegmentedControl, Stack, Text } from "@mantine/core";
|
import { Alert, Button, PinInput, Stack, Text } from "@mantine/core";
|
||||||
import {
|
import { AlertCircle, ArrowLeft, RotateCw, ShieldCheck } from "lucide-react";
|
||||||
AlertCircle,
|
|
||||||
ArrowLeft,
|
|
||||||
Mail,
|
|
||||||
RotateCw,
|
|
||||||
ShieldCheck,
|
|
||||||
Smartphone,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { maskEmail, maskPhone } from "@/utils/identifier";
|
import { maskEmail, maskPhone } from "@/utils/identifier";
|
||||||
|
|
||||||
export type OtpChannel = "phone" | "email";
|
|
||||||
|
|
||||||
export const OTP_LENGTH = 6;
|
export const OTP_LENGTH = 6;
|
||||||
|
|
||||||
export interface OtpChannelSelectProps {
|
|
||||||
value: OtpChannel;
|
|
||||||
onChange: (channel: OtpChannel) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Phone/email toggle deciding where the verification code is sent. */
|
|
||||||
export function OtpChannelSelect({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
disabled,
|
|
||||||
label = "Send verification code via",
|
|
||||||
}: OtpChannelSelectProps) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Text size="sm" fw={500} c="edr-text">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<SegmentedControl
|
|
||||||
fullWidth
|
|
||||||
disabled={disabled}
|
|
||||||
value={value}
|
|
||||||
onChange={(v) => onChange(v as OtpChannel)}
|
|
||||||
data={[
|
|
||||||
{
|
|
||||||
value: "phone",
|
|
||||||
label: (
|
|
||||||
<span className="flex items-center justify-center gap-1.5">
|
|
||||||
<Smartphone size={14} /> Phone
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "email",
|
|
||||||
label: (
|
|
||||||
<span className="flex items-center justify-center gap-1.5">
|
|
||||||
<Mail size={14} /> Email
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OtpChannelStepProps {
|
export interface OtpChannelStepProps {
|
||||||
channel: OtpChannel;
|
/**
|
||||||
/** Raw email or phone the code went to; masked before display. */
|
* Raw contacts the code was sent to; masked before display. The API sends one
|
||||||
target: string;
|
* code to every contact on the account, so both are usually set — pass only
|
||||||
|
* what the client actually knows. Omit both when the client cannot know them
|
||||||
|
* (the forgot-password flow deliberately never reveals an account's contacts)
|
||||||
|
* and a generic line is shown instead.
|
||||||
|
*/
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (otp: string) => void;
|
onChange: (otp: string) => void;
|
||||||
onVerify: () => void;
|
onVerify: () => void;
|
||||||
@@ -82,11 +32,13 @@ export interface OtpChannelStepProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The "enter the code we sent you" stage. Shared by signup and the
|
* The "enter the code we sent you" stage. Shared by signup and the
|
||||||
* forgot-password flow — both send through the same `/api/otp/*` service.
|
* forgot-password flow — both send through the same `/api/otp/*` service, which
|
||||||
|
* delivers a single code to the account's email AND phone; whichever message
|
||||||
|
* arrives first can be typed here.
|
||||||
*/
|
*/
|
||||||
export default function OtpChannelStep({
|
export default function OtpChannelStep({
|
||||||
channel,
|
email,
|
||||||
target,
|
phone,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
onVerify,
|
onVerify,
|
||||||
@@ -100,7 +52,10 @@ export default function OtpChannelStep({
|
|||||||
description,
|
description,
|
||||||
submitLabel,
|
submitLabel,
|
||||||
}: OtpChannelStepProps) {
|
}: OtpChannelStepProps) {
|
||||||
const maskedTarget = channel === "email" ? maskEmail(target) : maskPhone(target);
|
const maskedTargets = [
|
||||||
|
email ? maskEmail(email) : null,
|
||||||
|
phone ? maskPhone(phone) : null,
|
||||||
|
].filter(Boolean) as string[];
|
||||||
const busy = sending || verifying;
|
const busy = sending || verifying;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -113,12 +68,23 @@ export default function OtpChannelStep({
|
|||||||
|
|
||||||
<div className="space-y-1.5 text-center">
|
<div className="space-y-1.5 text-center">
|
||||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||||
{title ?? `Verify your ${channel === "email" ? "email" : "phone"}`}
|
{title ?? "Verify it's you"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm leading-relaxed text-gray-500">
|
<p className="text-sm leading-relaxed text-gray-500">
|
||||||
We sent a {OTP_LENGTH}-digit code to{" "}
|
We sent a {OTP_LENGTH}-digit code to{" "}
|
||||||
<span className="font-medium text-gray-700">{maskedTarget}</span>.{" "}
|
{maskedTargets.length ? (
|
||||||
{description ?? "Enter it to continue."}
|
maskedTargets.map((target, index) => (
|
||||||
|
<span key={target}>
|
||||||
|
{index > 0 ? " and " : null}
|
||||||
|
<span className="font-medium text-gray-700">{target}</span>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="font-medium text-gray-700">
|
||||||
|
the email and phone on your account
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
. {description ?? "Enter it to continue."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Button, Modal, Radio, Stack, Text } from "@mantine/core";
|
import { Alert, Button, Loader, Modal, Radio, Stack, Text } from "@mantine/core";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { KeyRound } from "lucide-react";
|
import { KeyRound } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
@@ -10,32 +10,48 @@ import { api } from "@/services/api";
|
|||||||
import type { Company, ResetChannel } from "@/types/customer";
|
import type { Company, ResetChannel } from "@/types/customer";
|
||||||
|
|
||||||
export interface ResetPasswordActionProps {
|
export interface ResetPasswordActionProps {
|
||||||
company: Pick<Company, "id" | "email" | "phone">;
|
company: Pick<Company, "id">;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Staff-triggered password reset. Sends a one-time code to the customer's
|
* Staff-triggered password reset. Sends a single-use link to the customer's
|
||||||
* primary contact; the customer picks their own new password. No credential is
|
* primary contact; the customer opens it and picks their own new password. No
|
||||||
* ever shown to or handled by staff.
|
* credential is ever shown to or handled by staff.
|
||||||
*/
|
*/
|
||||||
export default function ResetPasswordAction({ company }: ResetPasswordActionProps) {
|
export default function ResetPasswordAction({
|
||||||
|
company,
|
||||||
|
}: ResetPasswordActionProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
const [channel, setChannel] = useState<ResetChannel>("phone");
|
const [channel, setChannel] = useState<ResetChannel>("phone");
|
||||||
|
|
||||||
|
const allowed = hasPermission(user, FREIGHT_PERMS.customers.resetPassword);
|
||||||
|
|
||||||
|
// The destination is the primary contact's IAM account, not the company
|
||||||
|
// record — those are different fields and routinely hold different values, so
|
||||||
|
// showing `company.phone` here would tell staff the wrong number. Only fetched
|
||||||
|
// once the modal is open.
|
||||||
|
const targetQuery = useQuery(
|
||||||
|
api.customers.resetTarget.queryOptions({
|
||||||
|
input: { companyId: company.id },
|
||||||
|
enabled: allowed && opened,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const target = targetQuery.data;
|
||||||
|
|
||||||
const { mutate, isPending } = useMutation(
|
const { mutate, isPending } = useMutation(
|
||||||
api.customers.resetPassword.mutationOptions({
|
api.customers.resetPassword.mutationOptions({
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setOpened(false);
|
setOpened(false);
|
||||||
toast({
|
toast({
|
||||||
title: "Reset code sent",
|
title: "Reset link sent",
|
||||||
description: `The customer can now reset their password using the code sent to ${result.maskedTarget}.`,
|
description: `The customer can set a new password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast({
|
toast({
|
||||||
title: "Could not send reset code",
|
title: "Could not send reset link",
|
||||||
description: error.message,
|
description: error.message,
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
@@ -43,7 +59,10 @@ export default function ResetPasswordAction({ company }: ResetPasswordActionProp
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasPermission(user, FREIGHT_PERMS.customers.resetPassword)) return null;
|
if (!allowed) return null;
|
||||||
|
|
||||||
|
const channelMissing =
|
||||||
|
!!target && (channel === "email" ? !target.email : !target.phone);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -58,46 +77,66 @@ export default function ResetPasswordAction({ company }: ResetPasswordActionProp
|
|||||||
<Modal
|
<Modal
|
||||||
opened={opened}
|
opened={opened}
|
||||||
onClose={() => setOpened(false)}
|
onClose={() => setOpened(false)}
|
||||||
title="Send a password-reset code"
|
title="Send a password-reset link"
|
||||||
centered
|
centered
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
We'll send a one-time code to this customer's primary contact.
|
We'll send a single-use link to this customer's primary
|
||||||
They choose their own new password — you will not see it.
|
contact. They choose their own new password — you will not see it.
|
||||||
|
The link expires in 24 hours.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Radio.Group
|
{targetQuery.isLoading ? (
|
||||||
value={channel}
|
<Stack align="center" py="md">
|
||||||
onChange={(v) => setChannel(v as ResetChannel)}
|
<Loader size="sm" />
|
||||||
label="Send the code via"
|
|
||||||
>
|
|
||||||
<Stack gap="xs" mt="xs">
|
|
||||||
<Radio
|
|
||||||
value="phone"
|
|
||||||
label="SMS"
|
|
||||||
description={company.phone ?? "No phone on the company record"}
|
|
||||||
/>
|
|
||||||
<Radio
|
|
||||||
value="email"
|
|
||||||
label="Email"
|
|
||||||
description={company.email ?? "No email on the company record"}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Radio.Group>
|
) : targetQuery.isError ? (
|
||||||
|
<Alert color="red" variant="light">
|
||||||
|
{targetQuery.error.message}
|
||||||
|
</Alert>
|
||||||
|
) : target ? (
|
||||||
|
<>
|
||||||
|
<Radio.Group
|
||||||
|
value={channel}
|
||||||
|
onChange={(v) => setChannel(v as ResetChannel)}
|
||||||
|
label={`Send the link to ${target.name || "the primary contact"} via`}
|
||||||
|
>
|
||||||
|
<Stack gap="xs" mt="xs">
|
||||||
|
<Radio
|
||||||
|
value="phone"
|
||||||
|
label="SMS"
|
||||||
|
disabled={!target.phone}
|
||||||
|
description={
|
||||||
|
target.phone ?? "No phone number on this account"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Radio
|
||||||
|
value="email"
|
||||||
|
label="Email"
|
||||||
|
disabled={!target.email}
|
||||||
|
description={
|
||||||
|
target.email ?? "No email address on this account"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Radio.Group>
|
||||||
|
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
The code goes to the primary contact's own email or phone, which
|
These are the primary contact's own login details, which may
|
||||||
may differ from the company contact details shown above.
|
differ from the company contact details on the profile.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
onClick={() => mutate({ companyId: company.id, channel })}
|
disabled={channelMissing}
|
||||||
>
|
onClick={() => mutate({ companyId: company.id, channel })}
|
||||||
Send reset code
|
>
|
||||||
</Button>
|
Send reset link
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export const QUERY_KEYS = {
|
|||||||
documents: (id: string) =>
|
documents: (id: string) =>
|
||||||
["customers", "detail", id, "documents"] as const,
|
["customers", "detail", id, "documents"] as const,
|
||||||
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
|
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
|
||||||
|
resetTarget: (id: string) =>
|
||||||
|
["customers", "detail", id, "reset-target"] as const,
|
||||||
changeRequests: (id: string) =>
|
changeRequests: (id: string) =>
|
||||||
["customers", "detail", id, "change-requests"] as const,
|
["customers", "detail", id, "change-requests"] as const,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ export const URL_CONSTANTS = {
|
|||||||
`/payments/by-company/${id}/customer-view`,
|
`/payments/by-company/${id}/customer-view`,
|
||||||
RESET_PASSWORD: (companyId: string) =>
|
RESET_PASSWORD: (companyId: string) =>
|
||||||
`/backoffice/customers/${companyId}/reset-password`,
|
`/backoffice/customers/${companyId}/reset-password`,
|
||||||
|
RESET_TARGET: (companyId: string) =>
|
||||||
|
`/backoffice/customers/${companyId}/reset-target`,
|
||||||
},
|
},
|
||||||
|
|
||||||
BILLING: {
|
BILLING: {
|
||||||
|
|||||||
@@ -10,11 +10,7 @@ import {
|
|||||||
} from "@/auth/api";
|
} from "@/auth/api";
|
||||||
import type { ResetTicket } from "@/auth/types";
|
import type { ResetTicket } from "@/auth/types";
|
||||||
import AuthShell from "@/components/auth/AuthShell";
|
import AuthShell from "@/components/auth/AuthShell";
|
||||||
import OtpChannelStep, {
|
import OtpChannelStep, { OTP_LENGTH } from "@/components/auth/OtpChannelStep";
|
||||||
OTP_LENGTH,
|
|
||||||
OtpChannelSelect,
|
|
||||||
type OtpChannel,
|
|
||||||
} from "@/components/auth/OtpChannelStep";
|
|
||||||
import PasswordChecklist from "@/components/auth/PasswordChecklist";
|
import PasswordChecklist from "@/components/auth/PasswordChecklist";
|
||||||
import { useResendCooldown } from "@/hooks/useResendCooldown";
|
import { useResendCooldown } from "@/hooks/useResendCooldown";
|
||||||
import { normaliseIdentifier } from "@/utils/identifier";
|
import { normaliseIdentifier } from "@/utils/identifier";
|
||||||
@@ -28,7 +24,6 @@ const ForgotPasswordPage = () => {
|
|||||||
|
|
||||||
const [stage, setStage] = useState<Stage>("identify");
|
const [stage, setStage] = useState<Stage>("identify");
|
||||||
const [identifier, setIdentifier] = useState("");
|
const [identifier, setIdentifier] = useState("");
|
||||||
const [channel, setChannel] = useState<OtpChannel>("phone");
|
|
||||||
const [otpCode, setOtpCode] = useState("");
|
const [otpCode, setOtpCode] = useState("");
|
||||||
// The reset ticket lives in memory only — persisting it would leave a
|
// The reset ticket lives in memory only — persisting it would leave a
|
||||||
// password-change credential sitting in localStorage.
|
// password-change credential sitting in localStorage.
|
||||||
@@ -45,7 +40,7 @@ const ForgotPasswordPage = () => {
|
|||||||
const normalised = normaliseIdentifier(identifier);
|
const normalised = normaliseIdentifier(identifier);
|
||||||
|
|
||||||
const sendCode = async () => {
|
const sendCode = async () => {
|
||||||
await requestPasswordResetRequest({ identifier: normalised, channel });
|
await requestPasswordResetRequest({ identifier: normalised });
|
||||||
setOtpCode("");
|
setOtpCode("");
|
||||||
resendCooldown.start();
|
resendCooldown.start();
|
||||||
};
|
};
|
||||||
@@ -89,7 +84,6 @@ const ForgotPasswordPage = () => {
|
|||||||
try {
|
try {
|
||||||
const result = await verifyPasswordResetOtpRequest({
|
const result = await verifyPasswordResetOtpRequest({
|
||||||
identifier: normalised,
|
identifier: normalised,
|
||||||
channel,
|
|
||||||
otp: otpCode.trim(),
|
otp: otpCode.trim(),
|
||||||
});
|
});
|
||||||
setTicket(result);
|
setTicket(result);
|
||||||
@@ -138,13 +132,10 @@ const ForgotPasswordPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const identifierLabel =
|
|
||||||
channel === "email" ? "the email on your account" : "the phone on your account";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell
|
<AuthShell
|
||||||
tagline="Recover your account"
|
tagline="Recover your account"
|
||||||
taglineBody="Reset your EDR Freight backoffice password with a one-time code sent to your email or phone."
|
taglineBody="Reset your EDR Freight backoffice password with a one-time code sent to your email and phone."
|
||||||
>
|
>
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col">
|
||||||
{stage === "identify" ? (
|
{stage === "identify" ? (
|
||||||
@@ -176,16 +167,9 @@ const ForgotPasswordPage = () => {
|
|||||||
onChange={(event) => setIdentifier(event.target.value)}
|
onChange={(event) => setIdentifier(event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<OtpChannelSelect
|
|
||||||
value={channel}
|
|
||||||
onChange={setChannel}
|
|
||||||
disabled={sending}
|
|
||||||
label="Send the code to"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
The code goes to {identifierLabel}, which may differ from what you
|
The code goes to the email and phone on your account, which may
|
||||||
typed above.
|
differ from what you typed above.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -217,8 +201,6 @@ const ForgotPasswordPage = () => {
|
|||||||
|
|
||||||
{stage === "otp" ? (
|
{stage === "otp" ? (
|
||||||
<OtpChannelStep
|
<OtpChannelStep
|
||||||
channel={channel}
|
|
||||||
target={normalised}
|
|
||||||
value={otpCode}
|
value={otpCode}
|
||||||
onChange={setOtpCode}
|
onChange={setOtpCode}
|
||||||
onVerify={handleVerify}
|
onVerify={handleVerify}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
CustomerBooking,
|
CustomerBooking,
|
||||||
CustomerDocument,
|
CustomerDocument,
|
||||||
CustomerPayment,
|
CustomerPayment,
|
||||||
|
CustomerResetTarget,
|
||||||
PaginatedCompanies,
|
PaginatedCompanies,
|
||||||
ProfileStatus,
|
ProfileStatus,
|
||||||
ResetChannel,
|
ResetChannel,
|
||||||
@@ -2581,6 +2582,13 @@ export const api = {
|
|||||||
({ id }) => QUERY_KEYS.CUSTOMERS.payments(id),
|
({ id }) => QUERY_KEYS.CUSTOMERS.payments(id),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
resetTarget: endpoint<{ companyId: string }, CustomerResetTarget>(
|
||||||
|
"customers",
|
||||||
|
"resetTarget",
|
||||||
|
({ companyId }) => customersService.resetTarget(companyId),
|
||||||
|
({ companyId }) => QUERY_KEYS.CUSTOMERS.resetTarget(companyId),
|
||||||
|
),
|
||||||
|
|
||||||
resetPassword: endpoint<
|
resetPassword: endpoint<
|
||||||
{ companyId: string; channel: ResetChannel },
|
{ companyId: string; channel: ResetChannel },
|
||||||
ResetPasswordResult
|
ResetPasswordResult
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
CustomerBooking,
|
CustomerBooking,
|
||||||
CustomerDocument,
|
CustomerDocument,
|
||||||
CustomerPayment,
|
CustomerPayment,
|
||||||
|
CustomerResetTarget,
|
||||||
PaginatedCompanies,
|
PaginatedCompanies,
|
||||||
ProfileStatus,
|
ProfileStatus,
|
||||||
ResetChannel,
|
ResetChannel,
|
||||||
@@ -89,8 +90,20 @@ export const customersService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a password-reset code to the company's primary contact. Staff never
|
* The IAM account a reset link would go to. Read before offering the action
|
||||||
* receive a credential — the customer sets their own password from the code.
|
* so staff see the credentials the link actually reaches, not the company's
|
||||||
|
* business contact details.
|
||||||
|
*/
|
||||||
|
resetTarget(companyId: string): Promise<CustomerResetTarget> {
|
||||||
|
return apiClient
|
||||||
|
.get<CustomerResetTarget>(URL_CONSTANTS.COMPANIES.RESET_TARGET(companyId))
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a password-reset link to the company's primary contact. Staff never
|
||||||
|
* receive a credential — the customer opens the link and sets their own
|
||||||
|
* password.
|
||||||
*/
|
*/
|
||||||
resetPassword(
|
resetPassword(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
|
|||||||
@@ -110,13 +110,27 @@ export interface CompanyChangeRequest {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The channel a customer's password-reset code is delivered over. */
|
/** The channel a customer's password-reset link is delivered over. */
|
||||||
export type ResetChannel = "email" | "phone";
|
export type ResetChannel = "email" | "phone";
|
||||||
|
|
||||||
export interface ResetPasswordResult {
|
export interface ResetPasswordResult {
|
||||||
channel: ResetChannel;
|
channel: ResetChannel;
|
||||||
/** Where the code went, e.g. `+251•••4821` — safe to show to staff. */
|
/** Where the link went, e.g. `+251•••4821` — safe to show to staff. */
|
||||||
maskedTarget: string;
|
maskedTarget: string;
|
||||||
|
/** ISO timestamp after which the link stops working. */
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The IAM account a reset link would reach — the company's primary contact.
|
||||||
|
* Distinct from `Company.email` / `Company.phone`, which are business contact
|
||||||
|
* details and routinely differ from the credentials the customer logs in with.
|
||||||
|
*/
|
||||||
|
export interface CustomerResetTarget {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mirrors backend `Company` (+ its `companyProfiles`). */
|
/** Mirrors backend `Company` (+ its `companyProfiles`). */
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import MyPortalPage from "./pages/MyPortalPage";
|
|||||||
import MySignaturePage from "./pages/MySignaturePage";
|
import MySignaturePage from "./pages/MySignaturePage";
|
||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
import ForgotPasswordPage from "./pages/accounts/ForgotPasswordPage";
|
import ForgotPasswordPage from "./pages/accounts/ForgotPasswordPage";
|
||||||
|
import ResetPasswordLinkPage from "./pages/accounts/ResetPasswordLinkPage";
|
||||||
import LoginPage from "./pages/accounts/LoginPage";
|
import LoginPage from "./pages/accounts/LoginPage";
|
||||||
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
|
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
|
||||||
import SignupPage from "./pages/accounts/SignupPage";
|
import SignupPage from "./pages/accounts/SignupPage";
|
||||||
@@ -265,6 +266,11 @@ const App = () => {
|
|||||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
{/* Staff-issued reset links land here. Deliberately outside
|
||||||
|
RedirectIfAuthed: a customer with a stale session still needs the link
|
||||||
|
to work, and the token — not the session — is what authorises it. */}
|
||||||
|
<Route path="/reset-password" element={<ResetPasswordLinkPage />} />
|
||||||
|
|
||||||
{/* Signup-flow pages; reached while a session already exists */}
|
{/* Signup-flow pages; reached while a session already exists */}
|
||||||
<Route path="/otp" element={<VerificationOtpPage />} />
|
<Route path="/otp" element={<VerificationOtpPage />} />
|
||||||
<Route path="/set-password" element={<SetPasswordPage />} />
|
<Route path="/set-password" element={<SetPasswordPage />} />
|
||||||
|
|||||||
@@ -1,70 +1,20 @@
|
|||||||
import { Alert, Button, PinInput, SegmentedControl, Stack, Text } from "@mantine/core";
|
import { Alert, Button, PinInput, Stack, Text } from "@mantine/core";
|
||||||
import {
|
import { AlertCircle, ArrowLeft, RotateCw, ShieldCheck } from "lucide-react";
|
||||||
AlertCircle,
|
|
||||||
ArrowLeft,
|
|
||||||
Mail,
|
|
||||||
RotateCw,
|
|
||||||
ShieldCheck,
|
|
||||||
Smartphone,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { maskEmail, maskPhone } from "@/utils/identifier";
|
import { maskEmail, maskPhone } from "@/utils/identifier";
|
||||||
|
|
||||||
export type OtpChannel = "phone" | "email";
|
|
||||||
|
|
||||||
export const OTP_LENGTH = 6;
|
export const OTP_LENGTH = 6;
|
||||||
|
|
||||||
export interface OtpChannelSelectProps {
|
|
||||||
value: OtpChannel;
|
|
||||||
onChange: (channel: OtpChannel) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Phone/email toggle deciding where the verification code is sent. */
|
|
||||||
export function OtpChannelSelect({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
disabled,
|
|
||||||
label = "Send verification code via",
|
|
||||||
}: OtpChannelSelectProps) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Text size="sm" fw={500} c="edr-text">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<SegmentedControl
|
|
||||||
fullWidth
|
|
||||||
disabled={disabled}
|
|
||||||
value={value}
|
|
||||||
onChange={(v) => onChange(v as OtpChannel)}
|
|
||||||
data={[
|
|
||||||
{
|
|
||||||
value: "phone",
|
|
||||||
label: (
|
|
||||||
<span className="flex items-center justify-center gap-1.5">
|
|
||||||
<Smartphone size={14} /> Phone
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "email",
|
|
||||||
label: (
|
|
||||||
<span className="flex items-center justify-center gap-1.5">
|
|
||||||
<Mail size={14} /> Email
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OtpChannelStepProps {
|
export interface OtpChannelStepProps {
|
||||||
channel: OtpChannel;
|
/**
|
||||||
/** Raw email or phone the code went to; masked before display. */
|
* Raw contacts the code was sent to; masked before display. The API sends one
|
||||||
target: string;
|
* code to every contact on the account, so both are usually set — pass only
|
||||||
|
* what the client actually knows. Omit both when the client cannot know them
|
||||||
|
* (the forgot-password flow deliberately never reveals an account's contacts)
|
||||||
|
* and a generic line is shown instead.
|
||||||
|
*/
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (otp: string) => void;
|
onChange: (otp: string) => void;
|
||||||
onVerify: () => void;
|
onVerify: () => void;
|
||||||
@@ -82,11 +32,13 @@ export interface OtpChannelStepProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The "enter the code we sent you" stage. Shared by signup and the
|
* The "enter the code we sent you" stage. Shared by signup and the
|
||||||
* forgot-password flow — both send through the same `/api/otp/*` service.
|
* forgot-password flow — both send through the same `/api/otp/*` service, which
|
||||||
|
* delivers a single code to the account's email AND phone; whichever message
|
||||||
|
* arrives first can be typed here.
|
||||||
*/
|
*/
|
||||||
export default function OtpChannelStep({
|
export default function OtpChannelStep({
|
||||||
channel,
|
email,
|
||||||
target,
|
phone,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
onVerify,
|
onVerify,
|
||||||
@@ -100,7 +52,10 @@ export default function OtpChannelStep({
|
|||||||
description,
|
description,
|
||||||
submitLabel,
|
submitLabel,
|
||||||
}: OtpChannelStepProps) {
|
}: OtpChannelStepProps) {
|
||||||
const maskedTarget = channel === "email" ? maskEmail(target) : maskPhone(target);
|
const maskedTargets = [
|
||||||
|
email ? maskEmail(email) : null,
|
||||||
|
phone ? maskPhone(phone) : null,
|
||||||
|
].filter(Boolean) as string[];
|
||||||
const busy = sending || verifying;
|
const busy = sending || verifying;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -113,12 +68,23 @@ export default function OtpChannelStep({
|
|||||||
|
|
||||||
<div className="space-y-1.5 text-center">
|
<div className="space-y-1.5 text-center">
|
||||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||||
{title ?? `Verify your ${channel === "email" ? "email" : "phone"}`}
|
{title ?? "Verify it's you"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm leading-relaxed text-gray-500">
|
<p className="text-sm leading-relaxed text-gray-500">
|
||||||
We sent a {OTP_LENGTH}-digit code to{" "}
|
We sent a {OTP_LENGTH}-digit code to{" "}
|
||||||
<span className="font-medium text-gray-700">{maskedTarget}</span>.{" "}
|
{maskedTargets.length ? (
|
||||||
{description ?? "Enter it to continue."}
|
maskedTargets.map((target, index) => (
|
||||||
|
<span key={target}>
|
||||||
|
{index > 0 ? " and " : null}
|
||||||
|
<span className="font-medium text-gray-700">{target}</span>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="font-medium text-gray-700">
|
||||||
|
the email and phone on your account
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
. {description ?? "Enter it to continue."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const EXCLUDED_PATH_PATTERNS = [
|
|||||||
/^\/forgot-password/,
|
/^\/forgot-password/,
|
||||||
/^\/otp/,
|
/^\/otp/,
|
||||||
/^\/set-password/,
|
/^\/set-password/,
|
||||||
|
/^\/reset-password/,
|
||||||
/warehouse/i,
|
/warehouse/i,
|
||||||
/first-mile/i,
|
/first-mile/i,
|
||||||
/last-mile/i,
|
/last-mile/i,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const URL_CONSTANTS = {
|
|||||||
CHANGE_PASSWORD: "/api/auth/change-password",
|
CHANGE_PASSWORD: "/api/auth/change-password",
|
||||||
FORGOT_PASSWORD_REQUEST: "/api/auth/forgot-password/request",
|
FORGOT_PASSWORD_REQUEST: "/api/auth/forgot-password/request",
|
||||||
FORGOT_PASSWORD_VERIFY: "/api/auth/forgot-password/verify",
|
FORGOT_PASSWORD_VERIFY: "/api/auth/forgot-password/verify",
|
||||||
|
FORGOT_PASSWORD_RESOLVE_LINK: "/api/auth/forgot-password/resolve-link",
|
||||||
},
|
},
|
||||||
|
|
||||||
USERS: {
|
USERS: {
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react";
|
import { AlertCircle, ArrowLeft, ArrowRight, Info } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
|
||||||
@@ -293,22 +294,28 @@ export default function CompanyProfileForm({
|
|||||||
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||||
const [poaSameAsContact, setPoaSameAsContact] = useState(false);
|
const [poaSameAsContact, setPoaSameAsContact] = useState(false);
|
||||||
|
|
||||||
// General Manager source: the eTrade-registered business owner when a TIN
|
// General Manager source. The company step's email/phone are seeded from
|
||||||
// lookup found one, otherwise the registering user's own account details.
|
// eTrade (and the account email) but stay editable, so the link reads the
|
||||||
|
// CURRENT form values rather than the frozen eTrade snapshot — an edit on the
|
||||||
|
// company step propagates here, the same way "Same as General Manager" tracks
|
||||||
|
// the general manager's live values. eTrade's owner name has no editable
|
||||||
|
// field of its own, so it falls back to the registering user's account name.
|
||||||
|
const companyEmail = watch("companyEmail");
|
||||||
|
const companyPhone = watch("companyPhone");
|
||||||
const gmSourceName = etradeOwner?.name ?? user.name?.en ?? "";
|
const gmSourceName = etradeOwner?.name ?? user.name?.en ?? "";
|
||||||
const gmSourcePhone = etradeOwner
|
const gmSourceEmail = companyEmail || user.email || "";
|
||||||
? etradeOwner.phone
|
const gmSourcePhone =
|
||||||
: toEthiopianE164(user.phoneNumber);
|
companyPhone || etradeOwner?.phone || toEthiopianE164(user.phoneNumber) || "";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!gmSameAsOwner) return;
|
if (!gmSameAsOwner) return;
|
||||||
setValue("generalManagerName", gmSourceName, { shouldValidate: true });
|
setValue("generalManagerName", gmSourceName, { shouldValidate: true });
|
||||||
setValue("generalManagerEmail", user.email ?? "", { shouldValidate: true });
|
setValue("generalManagerEmail", gmSourceEmail, { shouldValidate: true });
|
||||||
setValue("generalManagerPhone", gmSourcePhone ?? "", {
|
setValue("generalManagerPhone", gmSourcePhone, {
|
||||||
shouldValidate: true,
|
shouldValidate: true,
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [gmSameAsOwner, gmSourceName, gmSourcePhone, user.email]);
|
}, [gmSameAsOwner, gmSourceName, gmSourceEmail, gmSourcePhone]);
|
||||||
|
|
||||||
const toggleGmSameAsOwner = (checked: boolean) => {
|
const toggleGmSameAsOwner = (checked: boolean) => {
|
||||||
setGmSameAsOwner(checked);
|
setGmSameAsOwner(checked);
|
||||||
@@ -336,13 +343,28 @@ export default function CompanyProfileForm({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
|
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
|
||||||
|
|
||||||
|
// The contact-person step has no location/address of its own, so the linked
|
||||||
|
// PoA takes the company's — location as entered, address as composed from the
|
||||||
|
// company's address fields. Both stay mirrored while the link is checked.
|
||||||
|
const companyLocation = watch("companyLocation");
|
||||||
|
const companyAddress = watch("companyAddress");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!poaSameAsContact) return;
|
if (!poaSameAsContact) return;
|
||||||
setValue("poaName", contactName ?? "");
|
setValue("poaName", contactName ?? "");
|
||||||
setValue("poaEmail", contactEmail ?? "");
|
setValue("poaEmail", contactEmail ?? "");
|
||||||
setValue("poaPhone", contactPhone ?? "");
|
setValue("poaPhone", contactPhone ?? "");
|
||||||
|
setValue("poaLocation", companyLocation ?? "");
|
||||||
|
setValue("poaAddress", companyAddress ?? "");
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [poaSameAsContact, contactName, contactEmail, contactPhone]);
|
}, [
|
||||||
|
poaSameAsContact,
|
||||||
|
contactName,
|
||||||
|
contactEmail,
|
||||||
|
contactPhone,
|
||||||
|
companyLocation,
|
||||||
|
companyAddress,
|
||||||
|
]);
|
||||||
|
|
||||||
const toggleContactSameAsGm = (checked: boolean) => {
|
const toggleContactSameAsGm = (checked: boolean) => {
|
||||||
setContactSameAsGm(checked);
|
setContactSameAsGm(checked);
|
||||||
@@ -360,6 +382,8 @@ export default function CompanyProfileForm({
|
|||||||
setValue("poaName", "");
|
setValue("poaName", "");
|
||||||
setValue("poaEmail", "");
|
setValue("poaEmail", "");
|
||||||
setValue("poaPhone", "");
|
setValue("poaPhone", "");
|
||||||
|
setValue("poaLocation", "");
|
||||||
|
setValue("poaAddress", "");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -624,7 +648,24 @@ export default function CompanyProfileForm({
|
|||||||
{...register("vatNumber")}
|
{...register("vatNumber")}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="FAN Number (16 digits)"
|
label={
|
||||||
|
<Group gap={6} align="center" wrap="nowrap">
|
||||||
|
<span>FAN Number (16 digits)</span>
|
||||||
|
<Tooltip
|
||||||
|
label="The FAN must belong to the person with power of attorney. If the company has no power of attorney, use the general manager's FAN."
|
||||||
|
multiline
|
||||||
|
w={260}
|
||||||
|
withArrow
|
||||||
|
position="top-start"
|
||||||
|
>
|
||||||
|
<Info
|
||||||
|
size={14}
|
||||||
|
color="var(--mantine-color-gray-6)"
|
||||||
|
className="cursor-help"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
placeholder="1234567890123456"
|
placeholder="1234567890123456"
|
||||||
maxLength={16}
|
maxLength={16}
|
||||||
error={errors.fanNumber?.message}
|
error={errors.fanNumber?.message}
|
||||||
@@ -743,8 +784,8 @@ export default function CompanyProfileForm({
|
|||||||
title="Same as business owner"
|
title="Same as business owner"
|
||||||
description={
|
description={
|
||||||
etradeOwner
|
etradeOwner
|
||||||
? "Reuse the eTrade-registered owner's name and phone (email from your account). Uncheck to enter different details."
|
? "Reuse the eTrade-registered owner's name, plus the company email and phone as you entered them. Uncheck to enter different details."
|
||||||
: "Reuse your account's name, email and phone. Uncheck to enter different details."
|
: "Reuse your account's name and the company email and phone as you entered them. Uncheck to enter different details."
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -828,7 +869,7 @@ export default function CompanyProfileForm({
|
|||||||
checked={poaSameAsContact}
|
checked={poaSameAsContact}
|
||||||
onToggle={togglePoaSameAsContact}
|
onToggle={togglePoaSameAsContact}
|
||||||
title="Same as contact person"
|
title="Same as contact person"
|
||||||
description="Reuse the contact person's name, email and phone. Uncheck to enter different details."
|
description="Reuse the contact person's name, email and phone, plus the company's location and address. Uncheck to enter different details."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
@@ -5,11 +5,7 @@ import { Link, useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import { useResendCooldown } from "@/hooks/useResendCooldown";
|
import { useResendCooldown } from "@/hooks/useResendCooldown";
|
||||||
import AuthShell from "@/components/auth/AuthShell";
|
import AuthShell from "@/components/auth/AuthShell";
|
||||||
import OtpChannelStep, {
|
import OtpChannelStep, { OTP_LENGTH } from "@/components/auth/OtpChannelStep";
|
||||||
OTP_LENGTH,
|
|
||||||
OtpChannelSelect,
|
|
||||||
type OtpChannel,
|
|
||||||
} from "@/components/auth/OtpChannelStep";
|
|
||||||
import PasswordChecklist from "@/components/auth/PasswordChecklist";
|
import PasswordChecklist from "@/components/auth/PasswordChecklist";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { ResetTicket } from "@/types/auth";
|
import type { ResetTicket } from "@/types/auth";
|
||||||
@@ -24,7 +20,6 @@ export default function ForgotPasswordPage() {
|
|||||||
|
|
||||||
const [stage, setStage] = useState<Stage>("identify");
|
const [stage, setStage] = useState<Stage>("identify");
|
||||||
const [identifier, setIdentifier] = useState("");
|
const [identifier, setIdentifier] = useState("");
|
||||||
const [channel, setChannel] = useState<OtpChannel>("phone");
|
|
||||||
const [otpCode, setOtpCode] = useState("");
|
const [otpCode, setOtpCode] = useState("");
|
||||||
// The reset ticket lives in memory only — persisting it would leave a
|
// The reset ticket lives in memory only — persisting it would leave a
|
||||||
// password-change credential sitting in localStorage.
|
// password-change credential sitting in localStorage.
|
||||||
@@ -41,7 +36,7 @@ export default function ForgotPasswordPage() {
|
|||||||
const normalised = normaliseIdentifier(identifier);
|
const normalised = normaliseIdentifier(identifier);
|
||||||
|
|
||||||
const sendCode = async () => {
|
const sendCode = async () => {
|
||||||
await api.auth.requestPasswordReset.call({ identifier: normalised, channel });
|
await api.auth.requestPasswordReset.call({ identifier: normalised });
|
||||||
setOtpCode("");
|
setOtpCode("");
|
||||||
resendCooldown.start();
|
resendCooldown.start();
|
||||||
};
|
};
|
||||||
@@ -85,7 +80,6 @@ export default function ForgotPasswordPage() {
|
|||||||
try {
|
try {
|
||||||
const result = await api.auth.verifyPasswordResetOtp.call({
|
const result = await api.auth.verifyPasswordResetOtp.call({
|
||||||
identifier: normalised,
|
identifier: normalised,
|
||||||
channel,
|
|
||||||
otp: otpCode.trim(),
|
otp: otpCode.trim(),
|
||||||
});
|
});
|
||||||
setTicket(result);
|
setTicket(result);
|
||||||
@@ -134,13 +128,10 @@ export default function ForgotPasswordPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const identifierLabel =
|
|
||||||
channel === "email" ? "the email on your account" : "the phone on your account";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell
|
<AuthShell
|
||||||
tagline="Recover your account"
|
tagline="Recover your account"
|
||||||
taglineBody="Reset your EDR Freight password with a one-time code sent to your email or phone."
|
taglineBody="Reset your EDR Freight password with a one-time code sent to your email and phone."
|
||||||
>
|
>
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col">
|
||||||
{stage === "identify" ? (
|
{stage === "identify" ? (
|
||||||
@@ -172,16 +163,9 @@ export default function ForgotPasswordPage() {
|
|||||||
onChange={(event) => setIdentifier(event.target.value)}
|
onChange={(event) => setIdentifier(event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<OtpChannelSelect
|
|
||||||
value={channel}
|
|
||||||
onChange={setChannel}
|
|
||||||
disabled={sending}
|
|
||||||
label="Send the code to"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
The code goes to {identifierLabel}, which may differ from what you
|
The code goes to the email and phone on your account, which may
|
||||||
typed above.
|
differ from what you typed above.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -213,8 +197,6 @@ export default function ForgotPasswordPage() {
|
|||||||
|
|
||||||
{stage === "otp" ? (
|
{stage === "otp" ? (
|
||||||
<OtpChannelStep
|
<OtpChannelStep
|
||||||
channel={channel}
|
|
||||||
target={normalised}
|
|
||||||
value={otpCode}
|
value={otpCode}
|
||||||
onChange={setOtpCode}
|
onChange={setOtpCode}
|
||||||
onVerify={handleVerify}
|
onVerify={handleVerify}
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { type FormEvent, useEffect, useState } from "react";
|
||||||
|
import { Alert, Button, Loader, PasswordInput, Stack } from "@mantine/core";
|
||||||
|
import { AlertCircle, KeyRound } from "lucide-react";
|
||||||
|
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import AuthShell from "@/components/auth/AuthShell";
|
||||||
|
import PasswordChecklist from "@/components/auth/PasswordChecklist";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import type { ResetLinkAccount } from "@/types/auth";
|
||||||
|
import { meetsAllRequirements } from "@/utils/passwordSchema";
|
||||||
|
import { extractApiError } from "@/utils/result";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lands the password-reset link a staff member sends from the backoffice
|
||||||
|
* (`/reset-password?uid=…&token=…`).
|
||||||
|
*
|
||||||
|
* The link itself is the proof of possession — it was delivered to the address
|
||||||
|
* on the account — so there is no code to type. The token is validated before
|
||||||
|
* the form appears, which is what lets an expired link say so up front instead
|
||||||
|
* of after a password has been chosen.
|
||||||
|
*/
|
||||||
|
export default function ResetPasswordLinkPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const userId = params.get("uid") ?? "";
|
||||||
|
const token = params.get("token") ?? "";
|
||||||
|
|
||||||
|
const [account, setAccount] = useState<ResetLinkAccount | null>(null);
|
||||||
|
const [checking, setChecking] = useState(true);
|
||||||
|
const [linkError, setLinkError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId || !token) {
|
||||||
|
setLinkError(
|
||||||
|
"This password-reset link is incomplete. Open the full link from your email or SMS.",
|
||||||
|
);
|
||||||
|
setChecking(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
api.auth.resolveResetLink
|
||||||
|
.call({ userId, token })
|
||||||
|
.then((resolved) => {
|
||||||
|
if (!cancelled) setAccount(resolved);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setLinkError(extractApiError(err).message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setChecking(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [userId, token]);
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!account) return;
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError("Passwords do not match.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await api.auth.resetPassword.call({
|
||||||
|
userId: account.userId,
|
||||||
|
// Resolved server-side from the token — the account holder never types
|
||||||
|
// an identifier, so there is nothing here to get wrong.
|
||||||
|
email: account.identifier,
|
||||||
|
verificationCode: account.verificationCode,
|
||||||
|
newPassword: password,
|
||||||
|
confirmPassword,
|
||||||
|
});
|
||||||
|
navigate("/login", { replace: true, state: { passwordReset: true } });
|
||||||
|
} catch (err) {
|
||||||
|
setError(extractApiError(err).message);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthShell
|
||||||
|
tagline="Set a new password"
|
||||||
|
taglineBody="Choose a new password for your EDR Freight account."
|
||||||
|
>
|
||||||
|
<div className="flex w-full flex-col">
|
||||||
|
<div className="mb-1 flex justify-center">
|
||||||
|
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
|
<KeyRound size={22} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 mt-3 space-y-1.5 text-center sm:mb-5">
|
||||||
|
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||||
|
Choose a new password
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm leading-relaxed text-gray-500">
|
||||||
|
{checking
|
||||||
|
? "Checking your reset link…"
|
||||||
|
: account
|
||||||
|
? `Resetting the password for ${account.maskedIdentifier}.`
|
||||||
|
: "This link can no longer be used."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{checking ? (
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!checking && linkError ? (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||||
|
{linkError}
|
||||||
|
</Alert>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
fullWidth
|
||||||
|
onClick={() => navigate("/forgot-password")}
|
||||||
|
>
|
||||||
|
Request a new link
|
||||||
|
</Button>
|
||||||
|
<p className="text-center text-sm text-gray-500">
|
||||||
|
Remembered it?{" "}
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="font-semibold text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!checking && account ? (
|
||||||
|
<form onSubmit={handleSubmit} className="flex w-full flex-col">
|
||||||
|
<Stack gap="md">
|
||||||
|
<div>
|
||||||
|
<PasswordInput
|
||||||
|
label="New password"
|
||||||
|
placeholder="Create a strong password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
disabled={submitting}
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
<PasswordChecklist value={password} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
label="Confirm new password"
|
||||||
|
placeholder="Re-enter your password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
disabled={submitting}
|
||||||
|
error={
|
||||||
|
confirmPassword && confirmPassword !== password
|
||||||
|
? "Passwords do not match"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
icon={<AlertCircle size={18} />}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
color="edr-green"
|
||||||
|
fullWidth
|
||||||
|
loading={submitting}
|
||||||
|
disabled={
|
||||||
|
submitting ||
|
||||||
|
!meetsAllRequirements(password) ||
|
||||||
|
password !== confirmPassword
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Reset password
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</AuthShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,11 +18,7 @@ import useAuth from "@/hooks/useAuth";
|
|||||||
import { useResendCooldown } from "@/hooks/useResendCooldown";
|
import { useResendCooldown } from "@/hooks/useResendCooldown";
|
||||||
import type { SignupPayload } from "@/types/auth";
|
import type { SignupPayload } from "@/types/auth";
|
||||||
import AuthShell from "@/components/auth/AuthShell";
|
import AuthShell from "@/components/auth/AuthShell";
|
||||||
import OtpChannelStep, {
|
import OtpChannelStep, { OTP_LENGTH } from "@/components/auth/OtpChannelStep";
|
||||||
OTP_LENGTH,
|
|
||||||
OtpChannelSelect,
|
|
||||||
type OtpChannel,
|
|
||||||
} from "@/components/auth/OtpChannelStep";
|
|
||||||
import PasswordChecklist from "@/components/auth/PasswordChecklist";
|
import PasswordChecklist from "@/components/auth/PasswordChecklist";
|
||||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
@@ -64,15 +60,13 @@ export default function SignupPage() {
|
|||||||
const { signup } = useAuth();
|
const { signup } = useAuth();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Two-stage signup: fill the form, then a mandatory OTP challenge on the
|
// Two-stage signup: fill the form, then a mandatory OTP challenge before the
|
||||||
// chosen channel before the account is actually created. The account is only
|
// account is actually created. The code goes to BOTH the email and phone just
|
||||||
// created after the code is verified — the OTP is a hard requirement.
|
// entered — one code, either delivery verifies it — so there is nothing for
|
||||||
|
// the user to choose. The account is only created after the code is verified;
|
||||||
|
// the OTP is a hard requirement.
|
||||||
const [stage, setStage] = useState<"form" | "otp">("form");
|
const [stage, setStage] = useState<"form" | "otp">("form");
|
||||||
const [pendingData, setPendingData] = useState<FormData | null>(null);
|
const [pendingData, setPendingData] = useState<FormData | null>(null);
|
||||||
// Which contact method the code was sent to — chosen on the form, locked in
|
|
||||||
// once the challenge is sent.
|
|
||||||
const [channel, setChannel] = useState<OtpChannel>("phone");
|
|
||||||
const [otpChannel, setOtpChannel] = useState<OtpChannel>("phone");
|
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [verifying, setVerifying] = useState(false);
|
const [verifying, setVerifying] = useState(false);
|
||||||
const [otpCode, setOtpCode] = useState("");
|
const [otpCode, setOtpCode] = useState("");
|
||||||
@@ -101,8 +95,8 @@ export default function SignupPage() {
|
|||||||
const passwordValue = watch("password") ?? "";
|
const passwordValue = watch("password") ?? "";
|
||||||
|
|
||||||
// Step 1 — form is valid: make sure the email/phone aren't already
|
// Step 1 — form is valid: make sure the email/phone aren't already
|
||||||
// registered, then send a fresh code to the chosen channel and move to
|
// registered, then send a fresh code to both of them and move to the OTP
|
||||||
// the OTP challenge.
|
// challenge.
|
||||||
const requestOtp = async (data: FormData) => {
|
const requestOtp = async (data: FormData) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setSending(true);
|
setSending(true);
|
||||||
@@ -124,11 +118,8 @@ export default function SignupPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.auth.sendOTP.call(
|
await api.auth.sendOTP.call({ email: data.email, phone: data.phone });
|
||||||
channel === "email" ? { email: data.email } : { phone: data.phone },
|
|
||||||
);
|
|
||||||
setPendingData(data);
|
setPendingData(data);
|
||||||
setOtpChannel(channel);
|
|
||||||
setOtpCode("");
|
setOtpCode("");
|
||||||
setOtpError(null);
|
setOtpError(null);
|
||||||
resendCooldown.start();
|
resendCooldown.start();
|
||||||
@@ -145,11 +136,10 @@ export default function SignupPage() {
|
|||||||
setOtpError(null);
|
setOtpError(null);
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
await api.auth.sendOTP.call(
|
await api.auth.sendOTP.call({
|
||||||
otpChannel === "email"
|
email: pendingData.email,
|
||||||
? { email: pendingData.email }
|
phone: pendingData.phone,
|
||||||
: { phone: pendingData.phone },
|
});
|
||||||
);
|
|
||||||
setOtpCode("");
|
setOtpCode("");
|
||||||
resendCooldown.start();
|
resendCooldown.start();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -170,9 +160,8 @@ export default function SignupPage() {
|
|||||||
setVerifying(true);
|
setVerifying(true);
|
||||||
try {
|
try {
|
||||||
await api.auth.verifyOTP.call({
|
await api.auth.verifyOTP.call({
|
||||||
...(otpChannel === "email"
|
email: pendingData.email,
|
||||||
? { email: pendingData.email }
|
phone: pendingData.phone,
|
||||||
: { phone: pendingData.phone }),
|
|
||||||
otp: otpCode.trim(),
|
otp: otpCode.trim(),
|
||||||
});
|
});
|
||||||
const payload: SignupPayload = {
|
const payload: SignupPayload = {
|
||||||
@@ -259,12 +248,6 @@ export default function SignupPage() {
|
|||||||
disabled={sending}
|
disabled={sending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<OtpChannelSelect
|
|
||||||
value={channel}
|
|
||||||
onChange={setChannel}
|
|
||||||
disabled={sending}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="Password"
|
label="Password"
|
||||||
@@ -320,12 +303,8 @@ export default function SignupPage() {
|
|||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<OtpChannelStep
|
<OtpChannelStep
|
||||||
channel={otpChannel}
|
email={pendingData?.email}
|
||||||
target={
|
phone={pendingData?.phone}
|
||||||
otpChannel === "email"
|
|
||||||
? (pendingData?.email ?? "")
|
|
||||||
: (pendingData?.phone ?? "")
|
|
||||||
}
|
|
||||||
value={otpCode}
|
value={otpCode}
|
||||||
onChange={setOtpCode}
|
onChange={setOtpCode}
|
||||||
onVerify={confirmOtp}
|
onVerify={confirmOtp}
|
||||||
|
|||||||
@@ -56,9 +56,10 @@ export default function ContractViewPage() {
|
|||||||
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
|
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
|
||||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||||
|
|
||||||
// The signing OTP goes to the signed-in user's own registered phone, resolved
|
// The signing OTP goes to the signed-in user's own registered phone AND email,
|
||||||
// server-side from their account (the same number the server verifies
|
// resolved server-side from their account (the same contacts the server
|
||||||
// against). The client never picks the number, so send and verify can't
|
// verifies against). One code covers both, so a delayed SMS doesn't strand the
|
||||||
|
// signer. The client never picks the contacts, so send and verify can't
|
||||||
// disagree; we only get back a masked hint of where it landed.
|
// disagree; we only get back a masked hint of where it landed.
|
||||||
const [otpSentTo, setOtpSentTo] = useState<string | null>(null);
|
const [otpSentTo, setOtpSentTo] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -168,8 +169,9 @@ export default function ContractViewPage() {
|
|||||||
if (!signerName.trim()) return;
|
if (!signerName.trim()) return;
|
||||||
const image = usingSaved ? savedSignatureImage : signatureData;
|
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||||
if (!image) return;
|
if (!image) return;
|
||||||
// The server resolves and validates the company phone; if none is on file it
|
// The server resolves and validates the signer's own contacts; if the
|
||||||
// returns a clear 400 that surfaces via the mutation's onError.
|
// account has neither phone nor email it returns a clear 400 that surfaces
|
||||||
|
// via the mutation's onError.
|
||||||
setOtpCode("");
|
setOtpCode("");
|
||||||
sendOtpMutation.mutate();
|
sendOtpMutation.mutate();
|
||||||
};
|
};
|
||||||
@@ -405,8 +407,8 @@ export default function ContractViewPage() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
For security, enter the 6-digit code we sent by SMS to the
|
For security, enter the 6-digit code we sent to your registered
|
||||||
contract company's registered number
|
contacts
|
||||||
{otpSentTo ? (
|
{otpSentTo ? (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
|
|||||||
@@ -81,7 +81,9 @@ import type {
|
|||||||
SignupResponse,
|
SignupResponse,
|
||||||
ForgotPasswordRequestPayload,
|
ForgotPasswordRequestPayload,
|
||||||
ForgotPasswordVerifyPayload,
|
ForgotPasswordVerifyPayload,
|
||||||
|
ResetLinkAccount,
|
||||||
ResetTicket,
|
ResetTicket,
|
||||||
|
ResolveResetLinkPayload,
|
||||||
SendContactOtpPayload,
|
SendContactOtpPayload,
|
||||||
SendContactOtpResponse,
|
SendContactOtpResponse,
|
||||||
UpdateAccountNamePayload,
|
UpdateAccountNamePayload,
|
||||||
@@ -130,6 +132,11 @@ export const api = {
|
|||||||
"verifyPasswordResetOtp",
|
"verifyPasswordResetOtp",
|
||||||
authService.verifyPasswordResetOtp,
|
authService.verifyPasswordResetOtp,
|
||||||
),
|
),
|
||||||
|
resolveResetLink: endpoint<ResolveResetLinkPayload, ResetLinkAccount>(
|
||||||
|
"auth",
|
||||||
|
"resolveResetLink",
|
||||||
|
authService.resolveResetLink,
|
||||||
|
),
|
||||||
resetPassword: endpoint<SetPasswordPayload, void>(
|
resetPassword: endpoint<SetPasswordPayload, void>(
|
||||||
"auth",
|
"auth",
|
||||||
"resetPassword",
|
"resetPassword",
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import type {
|
|||||||
LoginResponse,
|
LoginResponse,
|
||||||
OtpPayload,
|
OtpPayload,
|
||||||
OtpResponse,
|
OtpResponse,
|
||||||
|
ResetLinkAccount,
|
||||||
ResetTicket,
|
ResetTicket,
|
||||||
|
ResolveResetLinkPayload,
|
||||||
SendContactOtpPayload,
|
SendContactOtpPayload,
|
||||||
SendContactOtpResponse,
|
SendContactOtpResponse,
|
||||||
SetPasswordPayload,
|
SetPasswordPayload,
|
||||||
@@ -79,6 +81,19 @@ export const authService = {
|
|||||||
return { userId: res.data.userId, verificationCode: res.data.verificationCode };
|
return { userId: res.data.userId, verificationCode: res.data.verificationCode };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a staff-issued reset link before showing the password form, and
|
||||||
|
* pick up the ticket it carries. Rejected links (expired, already spent) fail
|
||||||
|
* here rather than after the customer has typed a new password.
|
||||||
|
*/
|
||||||
|
resolveResetLink: async (body: ResolveResetLinkPayload) => {
|
||||||
|
const res = await client.post<ResetLinkAccount>(
|
||||||
|
URL_CONSTANTS.AUTH.FORGOT_PASSWORD_RESOLVE_LINK,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spend the reset ticket. Distinct from `setPassword` above, which the
|
* Spend the reset ticket. Distinct from `setPassword` above, which the
|
||||||
* authenticated post-signup flow drives through `useAuth` — this one carries
|
* authenticated post-signup flow drives through `useAuth` — this one carries
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ export interface SignupResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface OtpPayload {
|
export interface OtpPayload {
|
||||||
/** Exactly one of phone/email — the channel the code is sent through. */
|
/**
|
||||||
|
* At least one of phone/email. Send both and the API delivers one code to
|
||||||
|
* both, verifiable by quoting either back.
|
||||||
|
*/
|
||||||
phone?: string;
|
phone?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
/** Required on verify; omitted on send (the server generates the code). */
|
/** Required on verify; omitted on send (the server generates the code). */
|
||||||
@@ -102,13 +105,9 @@ export interface SetPasswordPayload {
|
|||||||
verificationCode: string;
|
verificationCode: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The channel a password-reset code is delivered over. */
|
|
||||||
export type ResetChannel = "email" | "phone";
|
|
||||||
|
|
||||||
export interface ForgotPasswordRequestPayload {
|
export interface ForgotPasswordRequestPayload {
|
||||||
/** Email, username, or E.164 phone — whatever the user typed, normalised. */
|
/** Email, username, or E.164 phone — whatever the user typed, normalised. */
|
||||||
identifier: string;
|
identifier: string;
|
||||||
channel: ResetChannel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ForgotPasswordVerifyPayload extends ForgotPasswordRequestPayload {
|
export interface ForgotPasswordVerifyPayload extends ForgotPasswordRequestPayload {
|
||||||
@@ -121,6 +120,21 @@ export interface ResetTicket {
|
|||||||
verificationCode: string;
|
verificationCode: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The `uid` / `token` pair carried by a staff-issued password-reset link. */
|
||||||
|
export interface ResolveResetLinkPayload {
|
||||||
|
userId: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A validated reset link. Carries the identifier IAM matches the account on, so
|
||||||
|
* the customer never has to type one — plus a masked copy safe to display.
|
||||||
|
*/
|
||||||
|
export interface ResetLinkAccount extends ResetTicket {
|
||||||
|
identifier: string;
|
||||||
|
maskedIdentifier: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GenerateVerificationCodePayload {
|
export interface GenerateVerificationCodePayload {
|
||||||
email: string;
|
email: string;
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
|
|||||||
@@ -2,10 +2,6 @@ import { UseQueryOptions, QueryObserverOptions } from "@tanstack/react-query";
|
|||||||
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||||
import {
|
|
||||||
emitApiError,
|
|
||||||
extractApiErrorPayload,
|
|
||||||
} from "@/components/errors/ApiErrorModal";
|
|
||||||
import { captureApiError } from "@/lib/posthog";
|
import { captureApiError } from "@/lib/posthog";
|
||||||
|
|
||||||
const client = axios.create({
|
const client = axios.create({
|
||||||
@@ -63,10 +59,9 @@ async function refreshSessionTokens(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TokenPair = { token: string; refreshToken: string };
|
type TokenPair = { token: string; refreshToken: string };
|
||||||
const { data } = await client.post<Partial<TokenPair> & { data?: TokenPair }>(
|
const { data } = await client.post<
|
||||||
URL_CONSTANTS.AUTH.REFRESH_TOKEN,
|
Partial<TokenPair> & { data?: TokenPair }
|
||||||
{ refreshToken },
|
>(URL_CONSTANTS.AUTH.REFRESH_TOKEN, { refreshToken });
|
||||||
);
|
|
||||||
// The API returns the pair flat ({ success, token, refreshToken }); accept
|
// The API returns the pair flat ({ success, token, refreshToken }); accept
|
||||||
// a { data: { ... } }-wrapped shape too so a transform change can't
|
// a { data: { ... } }-wrapped shape too so a transform change can't
|
||||||
// silently break refresh again.
|
// silently break refresh again.
|
||||||
@@ -118,8 +113,8 @@ client.interceptors.response.use(
|
|||||||
// Surface the server's actual error message in the global error modal
|
// Surface the server's actual error message in the global error modal
|
||||||
// (401s are handled by the session flow below/redirects, so skip them).
|
// (401s are handled by the session flow below/redirects, so skip them).
|
||||||
if (error.response && error.response.status !== 401) {
|
if (error.response && error.response.status !== 401) {
|
||||||
const payload = extractApiErrorPayload(error);
|
// const payload = extractApiErrorPayload(error);
|
||||||
if (payload) emitApiError(payload);
|
// if (payload) emitApiError(payload);
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user