mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: password reset flow
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
|
||||
import { CustomerResetService } from "./customer-reset.service";
|
||||
|
||||
/**
|
||||
* Staff-triggered password reset. The customer receives the code and sets their
|
||||
* own password — staff never see or handle a credential.
|
||||
*/
|
||||
@ApiTags("backoffice")
|
||||
@Controller("backoffice/customers")
|
||||
@ApiBearerAuth()
|
||||
export class CustomerResetController {
|
||||
constructor(private readonly customerResetService: CustomerResetService) {}
|
||||
|
||||
@Post(":companyId/reset-password")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
|
||||
@ApiOperation({
|
||||
summary: "Send a password-reset code to a customer's primary contact",
|
||||
})
|
||||
async resetPassword(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
@Body() dto: BackofficeResetPasswordDto,
|
||||
) {
|
||||
const maskedTarget = await this.customerResetService.sendResetToCustomer(
|
||||
companyId,
|
||||
dto.channel,
|
||||
);
|
||||
|
||||
if (!maskedTarget) {
|
||||
throw new NotFoundException(
|
||||
`No active primary contact with ${dto.channel === "email" ? "an email address" : "a phone number"} for this customer`,
|
||||
);
|
||||
}
|
||||
|
||||
return { channel: dto.channel, maskedTarget };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
||||
import { ResetChannel } from "./dto/forgot-password.dto";
|
||||
import { ForgotPasswordService } from "./forgot-password.service";
|
||||
|
||||
@Injectable()
|
||||
export class CustomerResetService {
|
||||
private readonly logger = new Logger(CustomerResetService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ExternalProfile)
|
||||
private readonly externalProfileRepository: Repository<ExternalProfile>,
|
||||
private readonly forgotPasswordService: ForgotPasswordService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Send a reset code to the company's primary contact. Returns the masked
|
||||
* destination, or null when there is no eligible account for that channel.
|
||||
*
|
||||
* Unlike the public flow this reports failure honestly — the caller is an
|
||||
* authenticated staff member, so there is nothing to enumerate.
|
||||
*/
|
||||
async sendResetToCustomer(
|
||||
companyId: string,
|
||||
channel: ResetChannel,
|
||||
): Promise<string | null> {
|
||||
const profile = await this.externalProfileRepository.findOne({
|
||||
where: { companyId, isPrimaryContact: true },
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
this.logger.warn(`Company ${companyId} has no primary contact profile`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve through the same active-account gate the public flow uses, so a
|
||||
// suspended customer cannot be reactivated by a staff-triggered reset.
|
||||
const user = await this.forgotPasswordService.resolveActiveUserById(
|
||||
profile.userId,
|
||||
);
|
||||
if (!user) {
|
||||
this.logger.warn(
|
||||
`Primary contact ${profile.userId} of company ${companyId} is not an active account`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = await this.forgotPasswordService.requestReset(user, channel);
|
||||
if (!target) return null;
|
||||
|
||||
this.logger.log(
|
||||
`Staff-triggered ${channel} reset sent to user ${user.id} (company ${companyId})`,
|
||||
);
|
||||
return this.forgotPasswordService.maskTarget(target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEnum, IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
/** The channel the reset code is delivered over. */
|
||||
export enum ResetChannel {
|
||||
Email = "email",
|
||||
Phone = "phone",
|
||||
}
|
||||
|
||||
export class ForgotPasswordRequestDto {
|
||||
@ApiProperty({
|
||||
description: "Email, username, or phone number of the account to reset",
|
||||
example: "name@company.com",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
identifier!: string;
|
||||
|
||||
@ApiProperty({ enum: ResetChannel })
|
||||
@IsEnum(ResetChannel)
|
||||
channel!: ResetChannel;
|
||||
}
|
||||
|
||||
export class ForgotPasswordVerifyDto extends ForgotPasswordRequestDto {
|
||||
@ApiProperty({ description: "The 6-digit code sent to the chosen channel" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
otp!: string;
|
||||
}
|
||||
|
||||
export class BackofficeResetPasswordDto {
|
||||
@ApiProperty({ enum: ResetChannel })
|
||||
@IsEnum(ResetChannel)
|
||||
channel!: ResetChannel;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Body, Controller, Logger, Post } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { Public } from "@edr/api-common";
|
||||
|
||||
import {
|
||||
ForgotPasswordRequestDto,
|
||||
ForgotPasswordVerifyDto,
|
||||
} from "./dto/forgot-password.dto";
|
||||
import { ForgotPasswordService, ResetTicket } from "./forgot-password.service";
|
||||
|
||||
/**
|
||||
* Freight-owned reset flow. IAM ships a `forgot-password` route, but it only
|
||||
* ever SMSes a magic link (no email channel, and it needs `FE_BASE_URL`, which
|
||||
* this API does not set). These routes drive freight's own email-or-phone OTP
|
||||
* service instead, then hand back a ticket for IAM's public `set-password`.
|
||||
*/
|
||||
@ApiTags("auth")
|
||||
@Controller("auth")
|
||||
@Public()
|
||||
export class ForgotPasswordController {
|
||||
private readonly logger = new Logger(ForgotPasswordController.name);
|
||||
|
||||
constructor(private readonly forgotPasswordService: ForgotPasswordService) {}
|
||||
|
||||
@Post("forgot-password/request")
|
||||
@ApiOperation({
|
||||
summary: "Send a password-reset code over email or SMS",
|
||||
description:
|
||||
"Always reports success. An unknown, inactive, or channel-less account is " +
|
||||
"indistinguishable from a real one, so this cannot be used to enumerate accounts.",
|
||||
})
|
||||
async request(@Body() dto: ForgotPasswordRequestDto): Promise<{ success: true }> {
|
||||
const user = await this.forgotPasswordService.resolveActiveUser(dto.identifier);
|
||||
|
||||
if (user) {
|
||||
try {
|
||||
await this.forgotPasswordService.requestReset(user, dto.channel);
|
||||
} catch (error) {
|
||||
// A delivery failure must not change the response shape either — log it
|
||||
// and let the caller sit on the OTP screen.
|
||||
this.logger.error(
|
||||
`Reset code delivery failed for user ${user.id}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.log("Reset requested for an unknown or inactive account");
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post("forgot-password/verify")
|
||||
@ApiOperation({
|
||||
summary: "Exchange a valid reset code for a single-use set-password ticket",
|
||||
description:
|
||||
"The returned { userId, verificationCode } is the body for PATCH /api/auth/set-password, " +
|
||||
"alongside the same identifier and the new password.",
|
||||
})
|
||||
verify(@Body() dto: ForgotPasswordVerifyDto): Promise<ResetTicket> {
|
||||
return this.forgotPasswordService.verifyAndMintTicket(
|
||||
dto.identifier,
|
||||
dto.channel,
|
||||
dto.otp,
|
||||
);
|
||||
}
|
||||
}
|
||||
169
apps/edr-freight-api/src/modules/auth/forgot-password.service.ts
Normal file
169
apps/edr-freight-api/src/modules/auth/forgot-password.service.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
|
||||
import { DataSource, Repository } from "typeorm";
|
||||
|
||||
import { hashPassword } from "@tria-plc/api-common/utils/argon";
|
||||
import { EOtpType } from "@tria-plc/iamapi-common/enums/otp.enum";
|
||||
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 { OtpService, OtpTarget } from "../otp/otp.service";
|
||||
import { ResetChannel } from "./dto/forgot-password.dto";
|
||||
|
||||
/**
|
||||
* How long the reset ticket minted for `PATCH /api/auth/set-password` stays
|
||||
* valid. The IAM `setPassword` handler enforces this via `expiresAt`.
|
||||
*/
|
||||
const RESET_TICKET_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
/** How long the emailed/SMS'd OTP stays valid before it must be re-requested. */
|
||||
const RESET_OTP_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export interface ResetTicket {
|
||||
userId: string;
|
||||
verificationCode: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ForgotPasswordService {
|
||||
private readonly logger = new Logger(ForgotPasswordService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly otpService: OtpService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolve an account that is actually eligible for a password reset.
|
||||
*
|
||||
* IAM's `set-password` handler flips `isActive: true` on the user as a side
|
||||
* effect, so a reset on a deactivated account would silently resurrect it.
|
||||
* Gating here — rather than at the set-password call — is what keeps that
|
||||
* from being reachable. Mirrors IAM's own login lookup: match on any of
|
||||
* email / username / phone, and require an active credential row.
|
||||
*/
|
||||
async resolveActiveUser(identifier: string): Promise<User | null> {
|
||||
const id = identifier.trim();
|
||||
if (!id) return null;
|
||||
|
||||
return await this.activeUserQuery()
|
||||
.andWhere(
|
||||
"(LOWER(u.email) = LOWER(:id) OR u.username = :id OR u.phoneNumber = :id)",
|
||||
{ id },
|
||||
)
|
||||
.getOne();
|
||||
}
|
||||
|
||||
/** Same eligibility gate as {@link resolveActiveUser}, keyed by IAM user id. */
|
||||
async resolveActiveUserById(userId: string): Promise<User | null> {
|
||||
if (!userId) return null;
|
||||
return await this.activeUserQuery()
|
||||
.andWhere("u.id = :userId", { userId })
|
||||
.getOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* Base query for accounts eligible to reset. `.where()` is claimed here so
|
||||
* callers must use `.andWhere()` — TypeORM's `.where()` resets the clause,
|
||||
* which would silently drop the `isActive` gate.
|
||||
*/
|
||||
private activeUserQuery() {
|
||||
return this.userRepository
|
||||
.createQueryBuilder("u")
|
||||
.innerJoin("u.userCredentials", "uc", "uc.isActive = true")
|
||||
.where("u.isActive = true")
|
||||
.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 {
|
||||
if (channel === ResetChannel.Email) {
|
||||
return user.email ? { email: user.email } : null;
|
||||
}
|
||||
return user.phoneNumber ? { phone: user.phoneNumber } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reset code to the account's own email/phone. Returns 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`
|
||||
* upserts. A reset request therefore overwrites any pending signup code for
|
||||
* the same address — last code sent wins. That is the pre-existing behaviour
|
||||
* between any two flows sharing this table.
|
||||
*/
|
||||
async requestReset(
|
||||
user: User,
|
||||
channel: ResetChannel,
|
||||
): Promise<OtpTarget | null> {
|
||||
const target = this.targetFor(user, channel);
|
||||
if (!target) return null;
|
||||
|
||||
await this.otpService.sendOtp(target);
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prove possession of the OTP, then mint an IAM reset ticket the caller can
|
||||
* spend on the public `PATCH /api/auth/set-password`.
|
||||
*
|
||||
* Minting a `UserVerification` row rather than writing `UserCredential`
|
||||
* ourselves keeps IAM as the single owner of the password write path (old
|
||||
* credential deactivation, argon hashing, changed-at bookkeeping).
|
||||
*/
|
||||
async verifyAndMintTicket(
|
||||
identifier: string,
|
||||
channel: ResetChannel,
|
||||
otp: string,
|
||||
): Promise<ResetTicket> {
|
||||
const user = await this.resolveActiveUser(identifier);
|
||||
const target = user && this.targetFor(user, channel);
|
||||
|
||||
if (!user?.id || !target) {
|
||||
// Same shape as a wrong code: a caller probing for accounts learns nothing
|
||||
// beyond what the request step already (deliberately) refuses to tell them.
|
||||
throw new BadRequestException("Invalid verification code");
|
||||
}
|
||||
|
||||
await this.otpService.verifyOtpForAction(target, otp, RESET_OTP_TTL_MS);
|
||||
|
||||
const code = randomBytes(24).toString("base64url");
|
||||
const verificationCode = await hashPassword(code);
|
||||
const userId = user.id;
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(UserVerification);
|
||||
// Retire any outstanding codes so only the ticket we just minted can be
|
||||
// spent — `findVerificationForPrimaryReset` reads the newest row.
|
||||
await repo.update({ userId }, { isUsed: true });
|
||||
await repo.insert({
|
||||
userId,
|
||||
otpType: EOtpType.RESET_PASSWORD,
|
||||
verificationCode,
|
||||
expiresAt: new Date(Date.now() + RESET_TICKET_TTL_MS),
|
||||
isUsed: false,
|
||||
attemptCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
this.logger.log(`Reset ticket minted for user ${userId}`);
|
||||
return { userId, verificationCode: code };
|
||||
}
|
||||
|
||||
/** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */
|
||||
maskTarget(target: OtpTarget): string {
|
||||
if (target.email) {
|
||||
const [local, domain] = target.email.split("@");
|
||||
const head = local.slice(0, 1);
|
||||
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
|
||||
}
|
||||
const phone = target.phone ?? "";
|
||||
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,35 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
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 { ExternalProfile } from '../companies/entities/external-profile.entity';
|
||||
import { OtpModule } from '../otp/otp.module';
|
||||
import { CheckAvailabilityController } from './check-availability.controller';
|
||||
import { CheckAvailabilityService } from './check-availability.service';
|
||||
import { CustomerResetController } from './customer-reset.controller';
|
||||
import { CustomerResetService } from './customer-reset.service';
|
||||
import { ForgotPasswordController } from './forgot-password.controller';
|
||||
import { ForgotPasswordService } from './forgot-password.service';
|
||||
import { FreightMeController } from './freight-me.controller';
|
||||
import { FreightMeService } from './freight-me.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
controllers: [FreightMeController, CheckAvailabilityController],
|
||||
providers: [FreightMeService, CheckAvailabilityService],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]),
|
||||
OtpModule,
|
||||
],
|
||||
controllers: [
|
||||
FreightMeController,
|
||||
CheckAvailabilityController,
|
||||
ForgotPasswordController,
|
||||
CustomerResetController,
|
||||
],
|
||||
providers: [
|
||||
FreightMeService,
|
||||
CheckAvailabilityService,
|
||||
ForgotPasswordService,
|
||||
CustomerResetService,
|
||||
],
|
||||
})
|
||||
export class FreightAuthModule {}
|
||||
|
||||
@@ -582,7 +582,7 @@ export class ContractTransitionService {
|
||||
if (!dto.otpPhone || !dto.otp) {
|
||||
throw new BadRequestException('OTP verification is required to sign the contract');
|
||||
}
|
||||
await this.otpService.verifyOtpForAction(dto.otpPhone, dto.otp);
|
||||
await this.otpService.verifyOtpForAction({ phone: dto.otpPhone }, dto.otp);
|
||||
await this.applySignature(contract, dto, options);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SIGNED_CUSTOMER',
|
||||
|
||||
@@ -50,6 +50,9 @@ export class OtpService {
|
||||
await this.otpRepository.createOtp(target, otp);
|
||||
}
|
||||
|
||||
// A freshly issued code gets a fresh guess budget.
|
||||
this.actionAttempts.delete(this.targetKey(target));
|
||||
|
||||
if (target.email) {
|
||||
// send email (queued to RabbitMQ via the shared Email service)
|
||||
await this.emailClient.sendEmail({
|
||||
@@ -121,24 +124,44 @@ export class OtpService {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Fresh, single-use challenge gating a sensitive action (e.g. applying a
|
||||
// contract signature). Unlike verifyOtp above — which marks a phone verified
|
||||
// and leaves the code in place — this enforces a short TTL and consumes the
|
||||
// code on success so it can never be replayed.
|
||||
// contract signature, resetting a forgotten password). Unlike verifyOtp above
|
||||
// — which marks a target verified and leaves the code in place — this enforces
|
||||
// a TTL and consumes the code on success so it can never be replayed.
|
||||
private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
async verifyOtpForAction(phone: string, otp: string) {
|
||||
const otpData = await this.otpRepository.findByPhone(phone);
|
||||
// Without a cap, a 6-digit code guarding a password reset is brute-forceable
|
||||
// within its own TTL. `otp_verifications` has no attempt column, so the
|
||||
// counter lives here and the code is burned once the budget is spent.
|
||||
// Per-process: it resets on restart and is not shared across replicas — a
|
||||
// persisted counter needs a migration on OtpVerification.
|
||||
private readonly MAX_ACTION_ATTEMPTS = 5;
|
||||
private readonly actionAttempts = new Map<string, number>();
|
||||
|
||||
private targetKey(target: OtpTarget): string {
|
||||
return target.email ? `email:${target.email}` : `phone:${target.phone}`;
|
||||
}
|
||||
|
||||
async verifyOtpForAction(
|
||||
target: OtpTarget,
|
||||
otp: string,
|
||||
ttlMs: number = this.ACTION_OTP_TTL_MS,
|
||||
) {
|
||||
const otpData = await this.otpRepository.findByTarget(target);
|
||||
const key = this.targetKey(target);
|
||||
|
||||
if (!otpData) {
|
||||
throw new BadRequestException(
|
||||
"No verification code was requested for this phone",
|
||||
target.email
|
||||
? "No verification code was requested for this email"
|
||||
: "No verification code was requested for this phone",
|
||||
);
|
||||
}
|
||||
|
||||
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
|
||||
|
||||
if (ageMs > this.ACTION_OTP_TTL_MS) {
|
||||
if (ageMs > ttlMs) {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
|
||||
throw new BadRequestException(
|
||||
"Verification code has expired. Request a new one.",
|
||||
@@ -146,11 +169,24 @@ export class OtpService {
|
||||
}
|
||||
|
||||
if (otpData.otp !== otp) {
|
||||
const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
|
||||
|
||||
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
|
||||
throw new BadRequestException(
|
||||
"Too many incorrect attempts. Request a new code.",
|
||||
);
|
||||
}
|
||||
|
||||
this.actionAttempts.set(key, attempts);
|
||||
throw new BadRequestException("Invalid verification code");
|
||||
}
|
||||
|
||||
// single-use: consume on success
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('d1a00001-0001-4000-8000-000000000003', 'edr_freight_app:customers:update', 'Update customer'),
|
||||
perm('d1a00001-0001-4000-8000-000000000004', 'edr_freight_app:customers:deactivate', 'Deactivate customer'),
|
||||
perm('d1a00001-0001-4000-8000-000000000005', 'edr_freight_app:customers:verify', 'Verify customer (KYC/Fayda)'),
|
||||
perm('d1a00001-0001-4000-8000-000000000006', 'edr_freight_app:customers:reset-password', 'Trigger customer password reset'),
|
||||
];
|
||||
|
||||
// D. Finance — payments + invoices
|
||||
@@ -394,6 +395,7 @@ export const FREIGHT_PERMS = {
|
||||
update: 'edr_freight_app:customers:update',
|
||||
deactivate: 'edr_freight_app:customers:deactivate',
|
||||
verify: 'edr_freight_app:customers:verify',
|
||||
resetPassword: 'edr_freight_app:customers:reset-password',
|
||||
},
|
||||
payments: {
|
||||
view: 'edr_freight_app:payments:view',
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Button, Modal, Radio, Stack, Text } from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type { Company, ResetChannel } from "@/types/customer";
|
||||
|
||||
export interface ResetPasswordActionProps {
|
||||
company: Pick<Company, "id" | "email" | "phone">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff-triggered password reset. Sends a one-time code to the customer's
|
||||
* primary contact; the customer picks their own new password. No credential is
|
||||
* ever shown to or handled by staff.
|
||||
*/
|
||||
export default function ResetPasswordAction({ company }: ResetPasswordActionProps) {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [channel, setChannel] = useState<ResetChannel>("phone");
|
||||
|
||||
const { mutate, isPending } = useMutation(
|
||||
api.customers.resetPassword.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
setOpened(false);
|
||||
toast({
|
||||
title: "Reset code sent",
|
||||
description: `The customer can now reset their password using the code sent to ${result.maskedTarget}.`,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Could not send reset code",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!hasPermission(user, FREIGHT_PERMS.customers.resetPassword)) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<KeyRound size={16} />}
|
||||
onClick={() => setOpened(true)}
|
||||
>
|
||||
Reset password
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title="Send a password-reset code"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
We'll send a one-time code to this customer's primary contact.
|
||||
They choose their own new password — you will not see it.
|
||||
</Text>
|
||||
|
||||
<Radio.Group
|
||||
value={channel}
|
||||
onChange={(v) => setChannel(v as ResetChannel)}
|
||||
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>
|
||||
</Radio.Group>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
The code goes to the primary contact's own email or phone, which
|
||||
may differ from the company contact details shown above.
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={isPending}
|
||||
onClick={() => mutate({ companyId: company.id, channel })}
|
||||
>
|
||||
Send reset code
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,5 +13,9 @@ export {
|
||||
ChangeRequestReview,
|
||||
ChangeRequestPendingBadge,
|
||||
} from "./ChangeRequestReview";
|
||||
export {
|
||||
default as ResetPasswordAction,
|
||||
type ResetPasswordActionProps,
|
||||
} from "./ResetPasswordAction";
|
||||
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
||||
export { TableCard, type TableCardProps } from "./TableCard";
|
||||
|
||||
@@ -86,6 +86,8 @@ export const URL_CONSTANTS = {
|
||||
`/bookings/by-company/${id}/customer-view`,
|
||||
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
|
||||
`/payments/by-company/${id}/customer-view`,
|
||||
RESET_PASSWORD: (companyId: string) =>
|
||||
`/backoffice/customers/${companyId}/reset-password`,
|
||||
},
|
||||
|
||||
BILLING: {
|
||||
|
||||
@@ -62,6 +62,7 @@ export const FREIGHT_PERMS = {
|
||||
update: "edr_freight_app:customers:update",
|
||||
deactivate: "edr_freight_app:customers:deactivate",
|
||||
verify: "edr_freight_app:customers:verify",
|
||||
resetPassword: "edr_freight_app:customers:reset-password",
|
||||
},
|
||||
payments: {
|
||||
view: "edr_freight_app:payments:view",
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
ProfileChips,
|
||||
ProfileStatusBadge,
|
||||
ProfileTypeBadge,
|
||||
ResetPasswordAction,
|
||||
TableCard,
|
||||
formatBytes,
|
||||
formatDate,
|
||||
@@ -573,6 +574,7 @@ export default function CustomerDetailPage() {
|
||||
<ChangeRequestPendingBadge companyId={company.id} />
|
||||
</Group>
|
||||
}
|
||||
action={<ResetPasswordAction company={company} />}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="overview">
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
CustomerPayment,
|
||||
PaginatedCompanies,
|
||||
ProfileStatus,
|
||||
ResetChannel,
|
||||
ResetPasswordResult,
|
||||
} from "@/types/customer";
|
||||
import {
|
||||
CreateDropdownOptionDto,
|
||||
@@ -2261,6 +2263,16 @@ export const api = {
|
||||
({ id }) => QUERY_KEYS.CUSTOMERS.payments(id),
|
||||
),
|
||||
|
||||
resetPassword: endpoint<
|
||||
{ companyId: string; channel: ResetChannel },
|
||||
ResetPasswordResult
|
||||
>(
|
||||
"customers",
|
||||
"resetPassword",
|
||||
({ companyId, channel }) =>
|
||||
customersService.resetPassword(companyId, channel),
|
||||
),
|
||||
|
||||
setProfileStatus: endpoint<
|
||||
{ profileId: string; status: ProfileStatus; note?: string },
|
||||
CompanyProfile
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
CustomerPayment,
|
||||
PaginatedCompanies,
|
||||
ProfileStatus,
|
||||
ResetChannel,
|
||||
ResetPasswordResult,
|
||||
} from "@/types/customer";
|
||||
|
||||
const cleanParams = (params: object) =>
|
||||
@@ -81,6 +83,22 @@ export const customersService = {
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a password-reset code to the company's primary contact. Staff never
|
||||
* receive a credential — the customer sets their own password from the code.
|
||||
*/
|
||||
resetPassword(
|
||||
companyId: string,
|
||||
channel: ResetChannel,
|
||||
): Promise<ResetPasswordResult> {
|
||||
return apiClient
|
||||
.post<ResetPasswordResult>(
|
||||
URL_CONSTANTS.COMPANIES.RESET_PASSWORD(companyId),
|
||||
{ channel },
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
setProfileStatus(
|
||||
profileId: string,
|
||||
status: ProfileStatus,
|
||||
|
||||
@@ -99,6 +99,15 @@ export interface CompanyChangeRequest {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** The channel a customer's password-reset code is delivered over. */
|
||||
export type ResetChannel = "email" | "phone";
|
||||
|
||||
export interface ResetPasswordResult {
|
||||
channel: ResetChannel;
|
||||
/** Where the code went, e.g. `+251•••4821` — safe to show to staff. */
|
||||
maskedTarget: string;
|
||||
}
|
||||
|
||||
/** Mirrors backend `Company` (+ its `companyProfiles`). */
|
||||
export interface Company {
|
||||
id: string;
|
||||
|
||||
@@ -32,6 +32,7 @@ import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
|
||||
import MyPortalPage from "./pages/MyPortalPage";
|
||||
import MySignaturePage from "./pages/MySignaturePage";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
import ForgotPasswordPage from "./pages/accounts/ForgotPasswordPage";
|
||||
import LoginPage from "./pages/accounts/LoginPage";
|
||||
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
|
||||
import SignupPage from "./pages/accounts/SignupPage";
|
||||
@@ -252,6 +253,7 @@ const App = () => {
|
||||
<Route element={<RedirectIfAuthed />}>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Signup-flow pages; reached while a session already exists */}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Alert, Button, PinInput, SegmentedControl, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Mail,
|
||||
RotateCw,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
} from "lucide-react";
|
||||
|
||||
import { maskEmail, maskPhone } from "@/utils/identifier";
|
||||
|
||||
export type OtpChannel = "phone" | "email";
|
||||
|
||||
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 {
|
||||
channel: OtpChannel;
|
||||
/** Raw email or phone the code went to; masked before display. */
|
||||
target: string;
|
||||
value: string;
|
||||
onChange: (otp: string) => void;
|
||||
onVerify: () => void;
|
||||
onBack: () => void;
|
||||
onResend: () => void;
|
||||
/** Seconds until resend is allowed; 0 enables the button. */
|
||||
resendIn: number;
|
||||
sending: boolean;
|
||||
verifying: boolean;
|
||||
error: string | null;
|
||||
title?: string;
|
||||
description?: string;
|
||||
submitLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "enter the code we sent you" stage. Shared by signup and the
|
||||
* forgot-password flow — both send through the same `/api/otp/*` service.
|
||||
*/
|
||||
export default function OtpChannelStep({
|
||||
channel,
|
||||
target,
|
||||
value,
|
||||
onChange,
|
||||
onVerify,
|
||||
onBack,
|
||||
onResend,
|
||||
resendIn,
|
||||
sending,
|
||||
verifying,
|
||||
error,
|
||||
title,
|
||||
description,
|
||||
submitLabel,
|
||||
}: OtpChannelStepProps) {
|
||||
const maskedTarget = channel === "email" ? maskEmail(target) : maskPhone(target);
|
||||
const busy = sending || verifying;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<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">
|
||||
<ShieldCheck size={22} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-center">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
{title ?? `Verify your ${channel === "email" ? "email" : "phone"}`}
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
We sent a {OTP_LENGTH}-digit code to{" "}
|
||||
<span className="font-medium text-gray-700">{maskedTarget}</span>.{" "}
|
||||
{description ?? "Enter it to continue."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Stack gap={6} align="center">
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Verification code
|
||||
</Text>
|
||||
<PinInput
|
||||
length={OTP_LENGTH}
|
||||
type="number"
|
||||
oneTimeCode
|
||||
value={value}
|
||||
placeholder="0"
|
||||
disabled={verifying}
|
||||
styles={{ input: { textAlign: "center" } }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
loading={verifying}
|
||||
disabled={verifying || value.trim().length !== OTP_LENGTH}
|
||||
onClick={onVerify}
|
||||
>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeft size={14} />}
|
||||
disabled={busy}
|
||||
onClick={onBack}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="edr-green"
|
||||
leftSection={<RotateCw size={14} />}
|
||||
disabled={resendIn > 0 || busy}
|
||||
onClick={onResend}
|
||||
>
|
||||
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Check, X } from "lucide-react";
|
||||
|
||||
import { passwordRequirements } from "@/utils/passwordSchema";
|
||||
|
||||
export interface PasswordChecklistProps {
|
||||
/** The current password value; the checklist hides itself when empty. */
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Live pass/fail list of the password rules, shown under a password field. */
|
||||
export default function PasswordChecklist({ value }: PasswordChecklistProps) {
|
||||
if (!value) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-1">
|
||||
{passwordRequirements.map((req) => {
|
||||
const met = req.test(value);
|
||||
return (
|
||||
<div key={req.label} className="flex items-center gap-2">
|
||||
<span
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
|
||||
met
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-gray-200 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{met ? (
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
) : (
|
||||
<X className="h-2.5 w-2.5" />
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
|
||||
{req.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ export const URL_CONSTANTS = {
|
||||
REFRESH_TOKEN: "/api/auth/refresh-token",
|
||||
LOGOUT: "/api/auth/logout",
|
||||
PROFILE: "/auth/profile",
|
||||
FORGOT_PASSWORD_REQUEST: "/api/auth/forgot-password/request",
|
||||
FORGOT_PASSWORD_VERIFY: "/api/auth/forgot-password/verify",
|
||||
},
|
||||
|
||||
USERS: {
|
||||
|
||||
24
apps/edr-freight-web/portal/src/hooks/useResendCooldown.ts
Normal file
24
apps/edr-freight-web/portal/src/hooks/useResendCooldown.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** Seconds a user must wait before another OTP can be requested. */
|
||||
const DEFAULT_COOLDOWN_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Countdown that gates the "Resend code" button. Ticks with setTimeout rather
|
||||
* than wall-clock arithmetic, so it needs no Date.now().
|
||||
*/
|
||||
export function useResendCooldown(seconds: number = DEFAULT_COOLDOWN_SECONDS) {
|
||||
const [secondsLeft, setSecondsLeft] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (secondsLeft <= 0) return;
|
||||
const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [secondsLeft]);
|
||||
|
||||
return {
|
||||
secondsLeft,
|
||||
start: () => setSecondsLeft(seconds),
|
||||
reset: () => setSecondsLeft(0),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||
import { AlertCircle, ArrowLeft, ArrowRight, KeyRound } from "lucide-react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import { useResendCooldown } from "@/hooks/useResendCooldown";
|
||||
import AuthShell from "@/components/auth/AuthShell";
|
||||
import OtpChannelStep, {
|
||||
OTP_LENGTH,
|
||||
OtpChannelSelect,
|
||||
type OtpChannel,
|
||||
} from "@/components/auth/OtpChannelStep";
|
||||
import PasswordChecklist from "@/components/auth/PasswordChecklist";
|
||||
import { api } from "@/services/api";
|
||||
import type { ResetTicket } from "@/types/auth";
|
||||
import { normaliseIdentifier } from "@/utils/identifier";
|
||||
import { meetsAllRequirements } from "@/utils/passwordSchema";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
type Stage = "identify" | "otp" | "password";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [stage, setStage] = useState<Stage>("identify");
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [channel, setChannel] = useState<OtpChannel>("phone");
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
// The reset ticket lives in memory only — persisting it would leave a
|
||||
// password-change credential sitting in localStorage.
|
||||
const [ticket, setTicket] = useState<ResetTicket | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
|
||||
const [sending, setSending] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const resendCooldown = useResendCooldown();
|
||||
|
||||
/** The identifier as the API will see it — normalised once, reused everywhere. */
|
||||
const normalised = normaliseIdentifier(identifier);
|
||||
|
||||
const sendCode = async () => {
|
||||
await api.auth.requestPasswordReset.call({ identifier: normalised, channel });
|
||||
setOtpCode("");
|
||||
resendCooldown.start();
|
||||
};
|
||||
|
||||
// Stage 1 — ask for a code. The API answers identically for unknown accounts,
|
||||
// so we always advance; a non-existent identifier simply never receives a code.
|
||||
const handleIdentify = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSending(true);
|
||||
try {
|
||||
await sendCode();
|
||||
setStage("otp");
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
setError(null);
|
||||
setSending(true);
|
||||
try {
|
||||
await sendCode();
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 2 — trade the code for a single-use ticket.
|
||||
const handleVerify = async () => {
|
||||
setError(null);
|
||||
if (otpCode.trim().length !== OTP_LENGTH) {
|
||||
setError(`Enter the ${OTP_LENGTH}-digit code we sent you.`);
|
||||
return;
|
||||
}
|
||||
setVerifying(true);
|
||||
try {
|
||||
const result = await api.auth.verifyPasswordResetOtp.call({
|
||||
identifier: normalised,
|
||||
channel,
|
||||
otp: otpCode.trim(),
|
||||
});
|
||||
setTicket(result);
|
||||
setStage("password");
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 3 — spend the ticket on IAM's set-password.
|
||||
const handleReset = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!ticket) {
|
||||
setError("Your reset session expired. Start again.");
|
||||
setStage("identify");
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
setVerifying(true);
|
||||
try {
|
||||
await api.auth.resetPassword.call({
|
||||
userId: ticket.userId,
|
||||
// The API matches this against email / username / phone, so the typed
|
||||
// identifier works regardless of which one it is.
|
||||
email: normalised,
|
||||
verificationCode: ticket.verificationCode,
|
||||
newPassword: password,
|
||||
confirmPassword,
|
||||
});
|
||||
navigate("/login", {
|
||||
replace: true,
|
||||
state: { passwordReset: true },
|
||||
});
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const identifierLabel =
|
||||
channel === "email" ? "the email on your account" : "the phone on your account";
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
tagline="Recover your account"
|
||||
taglineBody="Reset your EDR Freight password with a one-time code sent to your email or phone."
|
||||
>
|
||||
<div className="flex w-full flex-col">
|
||||
{stage === "identify" ? (
|
||||
<form onSubmit={handleIdentify} 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">
|
||||
Forgot your password?
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
Enter your email or phone number and we'll send you a code to
|
||||
reset it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Email or Phone"
|
||||
placeholder="name@company.com or 09XXXXXXXX"
|
||||
autoComplete="username"
|
||||
required
|
||||
disabled={sending}
|
||||
value={identifier}
|
||||
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">
|
||||
The code goes to {identifierLabel}, which may differ from what you
|
||||
typed above.
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
loading={sending}
|
||||
disabled={!identifier.trim()}
|
||||
rightSection={!sending ? <ArrowRight size={16} /> : undefined}
|
||||
>
|
||||
Send code
|
||||
</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>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{stage === "otp" ? (
|
||||
<OtpChannelStep
|
||||
channel={channel}
|
||||
target={normalised}
|
||||
value={otpCode}
|
||||
onChange={setOtpCode}
|
||||
onVerify={handleVerify}
|
||||
onBack={() => {
|
||||
setStage("identify");
|
||||
setError(null);
|
||||
}}
|
||||
onResend={handleResend}
|
||||
resendIn={resendCooldown.secondsLeft}
|
||||
sending={sending}
|
||||
verifying={verifying}
|
||||
error={error}
|
||||
title="Enter your reset code"
|
||||
description="Enter it to choose a new password."
|
||||
submitLabel="Verify code"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{stage === "password" ? (
|
||||
<form onSubmit={handleReset} className="flex w-full flex-col">
|
||||
<div className="mb-4 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">
|
||||
Pick something strong you haven't used before.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<PasswordInput
|
||||
label="New password"
|
||||
placeholder="Create a strong password"
|
||||
required
|
||||
disabled={verifying}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
<PasswordChecklist value={password} />
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
label="Confirm new password"
|
||||
placeholder="Re-enter your password"
|
||||
required
|
||||
disabled={verifying}
|
||||
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={verifying}
|
||||
disabled={
|
||||
verifying ||
|
||||
!meetsAllRequirements(password) ||
|
||||
password !== confirmPassword
|
||||
}
|
||||
>
|
||||
Reset password
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeft size={14} />}
|
||||
disabled={verifying}
|
||||
onClick={() => {
|
||||
setStage("otp");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -5,21 +5,11 @@ import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import AuthShell from "@/components/auth/AuthShell";
|
||||
import { normaliseIdentifier } from "@/utils/identifier";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
const EDR_LOGO = "/assets/edr-logo.png";
|
||||
|
||||
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
|
||||
function normaliseIdentifier(raw: string): string {
|
||||
const v = raw.trim();
|
||||
const digits = v.replace(/\D/g, "");
|
||||
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
|
||||
const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, "");
|
||||
return `+251${local}`;
|
||||
}
|
||||
return v.toLowerCase();
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -80,7 +70,7 @@ export default function LoginPage() {
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-800">Password</span>
|
||||
<Link
|
||||
to="#"
|
||||
to="/forgot-password"
|
||||
className="text-xs font-semibold text-primary hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
|
||||
@@ -8,30 +8,20 @@ import { z } from "zod";
|
||||
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import AuthLayout from "@/components/auth/AuthLayout";
|
||||
|
||||
const passwordRequirements = [
|
||||
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
||||
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
|
||||
{ label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) },
|
||||
{ label: "One number", test: (v: string) => /\d/.test(v) },
|
||||
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
|
||||
] as const;
|
||||
import {
|
||||
PASSWORD_MISMATCH,
|
||||
confirmPasswordField,
|
||||
passwordField,
|
||||
passwordRequirements,
|
||||
samePassword,
|
||||
} from "@/utils/passwordSchema";
|
||||
|
||||
const passwordSchema = z
|
||||
.object({
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.regex(/[A-Z]/, "Password must include an uppercase letter")
|
||||
.regex(/[a-z]/, "Password must include a lowercase letter")
|
||||
.regex(/\d/, "Password must include a number")
|
||||
.regex(/[^A-Za-z0-9]/, "Password must include a special character"),
|
||||
confirmPassword: z.string().min(1, "Please confirm your password"),
|
||||
password: passwordField,
|
||||
confirmPassword: confirmPasswordField,
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords do not match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
.refine(samePassword, PASSWORD_MISMATCH);
|
||||
|
||||
type FormData = z.infer<typeof passwordSchema>;
|
||||
|
||||
|
||||
@@ -1,50 +1,39 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
PasswordInput,
|
||||
PinInput,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Check,
|
||||
Mail,
|
||||
RotateCw,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { AlertCircle, ArrowRight } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
|
||||
import { userType } from "@/enums/userType";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { useResendCooldown } from "@/hooks/useResendCooldown";
|
||||
import type { SignupPayload } from "@/types/auth";
|
||||
import AuthShell from "@/components/auth/AuthShell";
|
||||
import OtpChannelStep, {
|
||||
OTP_LENGTH,
|
||||
OtpChannelSelect,
|
||||
type OtpChannel,
|
||||
} from "@/components/auth/OtpChannelStep";
|
||||
import PasswordChecklist from "@/components/auth/PasswordChecklist";
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
PASSWORD_MISMATCH,
|
||||
confirmPasswordField,
|
||||
passwordField,
|
||||
samePassword,
|
||||
} from "@/utils/passwordSchema";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
const passwordRequirements = [
|
||||
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
||||
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
|
||||
{ label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) },
|
||||
{ label: "One number", test: (v: string) => /\d/.test(v) },
|
||||
{
|
||||
label: "One special character",
|
||||
test: (v: string) => /[^A-Za-z0-9]/.test(v),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const userSchema = z
|
||||
.object({
|
||||
email: z.string().email("Invalid email address"),
|
||||
@@ -61,43 +50,20 @@ const userSchema = z
|
||||
en: z.string().min(2, "Name is required"),
|
||||
am: z.string().nullable(),
|
||||
}),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.regex(/[A-Z]/, "Password must include an uppercase letter")
|
||||
.regex(/[a-z]/, "Password must include a lowercase letter")
|
||||
.regex(/\d/, "Password must include a number")
|
||||
.regex(/[^A-Za-z0-9]/, "Password must include a special character"),
|
||||
confirmPassword: z.string().min(1, "Please confirm your password"),
|
||||
password: passwordField,
|
||||
confirmPassword: confirmPasswordField,
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords do not match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
.refine(samePassword, PASSWORD_MISMATCH);
|
||||
|
||||
type FormData = z.infer<typeof userSchema>;
|
||||
|
||||
/** Mask all but the first 7 chars of an E.164 phone for display. */
|
||||
const maskPhone = (p: string) =>
|
||||
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
|
||||
|
||||
/** Mask the local part of an email for display (j***e@example.com). */
|
||||
const maskEmail = (email: string) => {
|
||||
const [local, domain] = email.split("@");
|
||||
if (!local || !domain) return email;
|
||||
if (local.length <= 2) return `${local[0] ?? ""}***@${domain}`;
|
||||
return `${local[0]}***${local[local.length - 1]}@${domain}`;
|
||||
};
|
||||
|
||||
type OtpChannel = "phone" | "email";
|
||||
|
||||
export default function SignupPage() {
|
||||
const navigate = useNavigate();
|
||||
const { signup } = useAuth();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Two-stage signup: fill the form, then a mandatory SMS OTP challenge on the
|
||||
// phone number before the account is actually created. The account is only
|
||||
// Two-stage signup: fill the form, then a mandatory OTP challenge on the
|
||||
// chosen channel before the account is actually created. The account is only
|
||||
// created after the code is verified — the OTP is a hard requirement.
|
||||
const [stage, setStage] = useState<"form" | "otp">("form");
|
||||
const [pendingData, setPendingData] = useState<FormData | null>(null);
|
||||
@@ -109,14 +75,7 @@ export default function SignupPage() {
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
const [otpError, setOtpError] = useState<string | null>(null);
|
||||
const [resendIn, setResendIn] = useState(0);
|
||||
|
||||
// Resend cooldown countdown (pure setTimeout ticks — no Date.now needed).
|
||||
useEffect(() => {
|
||||
if (resendIn <= 0) return;
|
||||
const t = setTimeout(() => setResendIn((s) => s - 1), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [resendIn]);
|
||||
const resendCooldown = useResendCooldown();
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -170,7 +129,7 @@ export default function SignupPage() {
|
||||
setOtpChannel(channel);
|
||||
setOtpCode("");
|
||||
setOtpError(null);
|
||||
setResendIn(60);
|
||||
resendCooldown.start();
|
||||
setStage("otp");
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
@@ -190,7 +149,7 @@ export default function SignupPage() {
|
||||
: { phone: pendingData.phone },
|
||||
);
|
||||
setOtpCode("");
|
||||
setResendIn(60);
|
||||
resendCooldown.start();
|
||||
} catch (err) {
|
||||
setOtpError(extractApiError(err).message);
|
||||
} finally {
|
||||
@@ -202,8 +161,8 @@ export default function SignupPage() {
|
||||
const confirmOtp = async () => {
|
||||
if (!pendingData) return;
|
||||
setOtpError(null);
|
||||
if (otpCode.trim().length !== 6) {
|
||||
setOtpError("Enter the 6-digit code we sent you.");
|
||||
if (otpCode.trim().length !== OTP_LENGTH) {
|
||||
setOtpError(`Enter the ${OTP_LENGTH}-digit code we sent you.`);
|
||||
return;
|
||||
}
|
||||
setVerifying(true);
|
||||
@@ -298,35 +257,11 @@ export default function SignupPage() {
|
||||
disabled={sending}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Send verification code via
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
disabled={sending}
|
||||
value={channel}
|
||||
onChange={(v) => setChannel(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>
|
||||
<OtpChannelSelect
|
||||
value={channel}
|
||||
onChange={setChannel}
|
||||
disabled={sending}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<PasswordInput
|
||||
@@ -337,37 +272,7 @@ export default function SignupPage() {
|
||||
error={errors.password?.message}
|
||||
{...register("password")}
|
||||
/>
|
||||
{passwordValue.length > 0 ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{passwordRequirements.map((req) => {
|
||||
const met = req.test(passwordValue);
|
||||
return (
|
||||
<div
|
||||
key={req.label}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${met
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-gray-200 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{met ? (
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
) : (
|
||||
<X className="h-2.5 w-2.5" />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}
|
||||
>
|
||||
{req.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<PasswordChecklist value={passwordValue} />
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
@@ -412,87 +317,28 @@ export default function SignupPage() {
|
||||
</Stack>
|
||||
</form>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<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">
|
||||
<ShieldCheck size={22} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-center">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Verify your {otpChannel === "email" ? "email" : "phone"}
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
We sent a 6 - digit code to{" "}
|
||||
<span className="font-medium text-gray-700">
|
||||
{otpChannel === "email"
|
||||
? maskEmail(pendingData?.email ?? "")
|
||||
: maskPhone(pendingData?.phone ?? "")}
|
||||
</span>
|
||||
.Enter it to finish creating your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{otpError ? (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
icon={<AlertCircle size={18} />}
|
||||
>
|
||||
{otpError}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Stack gap={6} align="center">
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Verification code
|
||||
</Text>
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
oneTimeCode
|
||||
value={otpCode}
|
||||
placeholder="0"
|
||||
disabled={verifying}
|
||||
styles={{ input: { textAlign: "center" } }}
|
||||
onChange={setOtpCode}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
loading={verifying}
|
||||
disabled={verifying || otpCode.trim().length !== 6}
|
||||
onClick={confirmOtp}
|
||||
>
|
||||
Verify & create account
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeft size={14} />}
|
||||
disabled={sending || verifying}
|
||||
onClick={() => {
|
||||
setStage("form");
|
||||
setOtpError(null);
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="edr-green"
|
||||
leftSection={<RotateCw size={14} />}
|
||||
disabled={resendIn > 0 || sending || verifying}
|
||||
onClick={resendOtp}
|
||||
>
|
||||
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
<OtpChannelStep
|
||||
channel={otpChannel}
|
||||
target={
|
||||
otpChannel === "email"
|
||||
? (pendingData?.email ?? "")
|
||||
: (pendingData?.phone ?? "")
|
||||
}
|
||||
value={otpCode}
|
||||
onChange={setOtpCode}
|
||||
onVerify={confirmOtp}
|
||||
onBack={() => {
|
||||
setStage("form");
|
||||
setOtpError(null);
|
||||
}}
|
||||
onResend={resendOtp}
|
||||
resendIn={resendCooldown.secondsLeft}
|
||||
sending={sending}
|
||||
verifying={verifying}
|
||||
error={otpError}
|
||||
description="Enter it to finish creating your account."
|
||||
submitLabel="Verify & create account"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AuthShell>
|
||||
|
||||
@@ -77,6 +77,9 @@ import type {
|
||||
SetPasswordPayload,
|
||||
SignupPayload,
|
||||
SignupResponse,
|
||||
ForgotPasswordRequestPayload,
|
||||
ForgotPasswordVerifyPayload,
|
||||
ResetTicket,
|
||||
} from "@/types/auth";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -110,6 +113,21 @@ export const api = {
|
||||
"setPassword",
|
||||
authService.setPassword,
|
||||
),
|
||||
requestPasswordReset: endpoint<ForgotPasswordRequestPayload, void>(
|
||||
"auth",
|
||||
"requestPasswordReset",
|
||||
authService.requestPasswordReset,
|
||||
),
|
||||
verifyPasswordResetOtp: endpoint<ForgotPasswordVerifyPayload, ResetTicket>(
|
||||
"auth",
|
||||
"verifyPasswordResetOtp",
|
||||
authService.verifyPasswordResetOtp,
|
||||
),
|
||||
resetPassword: endpoint<SetPasswordPayload, void>(
|
||||
"auth",
|
||||
"resetPassword",
|
||||
authService.resetPassword,
|
||||
),
|
||||
checkAvailability: endpoint<CheckAvailabilityPayload, CheckAvailabilityResponse>(
|
||||
"auth",
|
||||
"checkAvailability",
|
||||
|
||||
@@ -3,11 +3,14 @@ import type {
|
||||
AuthUser,
|
||||
CheckAvailabilityPayload,
|
||||
CheckAvailabilityResponse,
|
||||
ForgotPasswordRequestPayload,
|
||||
ForgotPasswordVerifyPayload,
|
||||
GenerateVerificationCodePayload,
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
OtpPayload,
|
||||
OtpResponse,
|
||||
ResetTicket,
|
||||
SetPasswordPayload,
|
||||
SignupPayload,
|
||||
SignupResponse,
|
||||
@@ -53,6 +56,31 @@ export const authService = {
|
||||
return res.data.data;
|
||||
},
|
||||
|
||||
// The three calls below drive the unauthenticated forgot-password flow.
|
||||
// Responses under /api/auth are *flattened* by the API's response
|
||||
// interceptor ({ success, ...payload }), so there is no `.data.data` here.
|
||||
|
||||
requestPasswordReset: async (body: ForgotPasswordRequestPayload) => {
|
||||
await client.post(URL_CONSTANTS.AUTH.FORGOT_PASSWORD_REQUEST, body);
|
||||
},
|
||||
|
||||
verifyPasswordResetOtp: async (body: ForgotPasswordVerifyPayload) => {
|
||||
const res = await client.post<ResetTicket>(
|
||||
URL_CONSTANTS.AUTH.FORGOT_PASSWORD_VERIFY,
|
||||
body,
|
||||
);
|
||||
return { userId: res.data.userId, verificationCode: res.data.verificationCode };
|
||||
},
|
||||
|
||||
/**
|
||||
* Spend the reset ticket. Distinct from `setPassword` above, which the
|
||||
* authenticated post-signup flow drives through `useAuth` — this one carries
|
||||
* its own userId/verificationCode and never touches the session.
|
||||
*/
|
||||
resetPassword: async (body: SetPasswordPayload) => {
|
||||
await client.patch(URL_CONSTANTS.USERS.SET_PASSWORD, body);
|
||||
},
|
||||
|
||||
checkAvailability: async (params: CheckAvailabilityPayload) => {
|
||||
const res = await client.get<CheckAvailabilityResponse>(
|
||||
URL_CONSTANTS.USERS.CHECK_AVAILABILITY,
|
||||
|
||||
@@ -63,6 +63,25 @@ export interface SetPasswordPayload {
|
||||
verificationCode: string;
|
||||
}
|
||||
|
||||
/** The channel a password-reset code is delivered over. */
|
||||
export type ResetChannel = "email" | "phone";
|
||||
|
||||
export interface ForgotPasswordRequestPayload {
|
||||
/** Email, username, or E.164 phone — whatever the user typed, normalised. */
|
||||
identifier: string;
|
||||
channel: ResetChannel;
|
||||
}
|
||||
|
||||
export interface ForgotPasswordVerifyPayload extends ForgotPasswordRequestPayload {
|
||||
otp: string;
|
||||
}
|
||||
|
||||
/** Single-use ticket to spend on `PATCH /api/auth/set-password`. */
|
||||
export interface ResetTicket {
|
||||
userId: string;
|
||||
verificationCode: string;
|
||||
}
|
||||
|
||||
export interface GenerateVerificationCodePayload {
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
|
||||
22
apps/edr-freight-web/portal/src/utils/identifier.ts
Normal file
22
apps/edr-freight-web/portal/src/utils/identifier.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
|
||||
export function normaliseIdentifier(raw: string): string {
|
||||
const v = raw.trim();
|
||||
const digits = v.replace(/\D/g, "");
|
||||
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
|
||||
const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, "");
|
||||
return `+251${local}`;
|
||||
}
|
||||
return v.toLowerCase();
|
||||
}
|
||||
|
||||
/** Mask all but the first 7 chars of an E.164 phone for display. */
|
||||
export const maskPhone = (p: string) =>
|
||||
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
|
||||
|
||||
/** Mask the local part of an email for display (j***e@example.com). */
|
||||
export const maskEmail = (email: string) => {
|
||||
const [local, domain] = email.split("@");
|
||||
if (!local || !domain) return email;
|
||||
if (local.length <= 2) return `${local[0] ?? ""}***@${domain}`;
|
||||
return `${local[0]}***${local[local.length - 1]}@${domain}`;
|
||||
};
|
||||
36
apps/edr-freight-web/portal/src/utils/passwordSchema.ts
Normal file
36
apps/edr-freight-web/portal/src/utils/passwordSchema.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Live checklist shown under the password field. Mirrors {@link passwordField}. */
|
||||
export const passwordRequirements = [
|
||||
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
||||
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
|
||||
{ label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) },
|
||||
{ label: "One number", test: (v: string) => /\d/.test(v) },
|
||||
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Must stay in step with IAM's `@IsStrongPassword()` on `InitialResetPasswordDto`
|
||||
* — a password this accepts but the API rejects surfaces as an opaque 400.
|
||||
*/
|
||||
export const passwordField = z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.regex(/[A-Z]/, "Password must include an uppercase letter")
|
||||
.regex(/[a-z]/, "Password must include a lowercase letter")
|
||||
.regex(/\d/, "Password must include a number")
|
||||
.regex(/[^A-Za-z0-9]/, "Password must include a special character");
|
||||
|
||||
export const confirmPasswordField = z.string().min(1, "Please confirm your password");
|
||||
|
||||
export const samePassword = (data: { password: string; confirmPassword: string }) =>
|
||||
data.password === data.confirmPassword;
|
||||
|
||||
export const PASSWORD_MISMATCH = {
|
||||
message: "Passwords do not match",
|
||||
path: ["confirmPassword"],
|
||||
} as const;
|
||||
|
||||
/** Every requirement in {@link passwordRequirements} is satisfied. */
|
||||
export const meetsAllRequirements = (value: string) =>
|
||||
passwordRequirements.every((r) => r.test(value));
|
||||
Reference in New Issue
Block a user