mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(auth): implement staff-triggered password-reset links
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,5 +1,5 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
import { IsEnum, IsNotEmpty, IsString } from "class-validator";
|
import { IsEnum, IsNotEmpty, IsString, IsUUID } from "class-validator";
|
||||||
|
|
||||||
/** The channel the reset code is delivered over. */
|
/** The channel the reset code is delivered over. */
|
||||||
export enum ResetChannel {
|
export enum ResetChannel {
|
||||||
@@ -33,3 +33,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
|
||||||
@@ -66,4 +71,16 @@ export class ForgotPasswordController {
|
|||||||
dto.otp,
|
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);
|
||||||
@@ -82,13 +103,23 @@ export class ForgotPasswordService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The address the code goes to, taken from the account — never from input. */
|
/** The address the code goes to, taken from the account — never from input. */
|
||||||
private targetFor(user: User, channel: ResetChannel): OtpTarget | null {
|
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;
|
||||||
}
|
}
|
||||||
return user.phoneNumber ? { phone: user.phoneNumber } : null;
|
return user.phoneNumber ? { phone: user.phoneNumber } : 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 a reset code to the account's own email/phone. Returns the target so
|
* Send a reset code to the account's own email/phone. Returns the target so
|
||||||
* authenticated (backoffice) callers can echo a masked version; unauthenticated
|
* authenticated (backoffice) callers can echo a masked version; unauthenticated
|
||||||
@@ -134,9 +165,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 +187,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 +197,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,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,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 />} />
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -121,6 +121,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user