Merge pull request #752 from Tria-plc/freight/feat/fixes-v1

Freight/feat/fixes v1
This commit is contained in:
Nathnael Wondisha
2026-07-17 10:01:44 +03:00
committed by GitHub
27 changed files with 1275 additions and 62 deletions

View File

@@ -0,0 +1,62 @@
import { Body, Controller, Patch, Post, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { AccountService } from "./account.service";
import {
SendContactOtpDto,
UpdateAccountNameDto,
UpdateContactDto,
} from "./dto/account.dto";
/**
* The caller's own account record. Everything here is scoped to the JWT's user
* id — there is no `:id` parameter to tamper with, so these routes need no
* permission key beyond being authenticated.
*/
@ApiTags("auth")
@Controller("me")
@ApiBearerAuth()
@UseGuards(JwtGuard)
export class AccountController {
constructor(private readonly accountService: AccountService) {}
@Post("contact/otp")
@ApiOperation({
summary: "Send a verification code to a new email/phone before changing it",
description:
"The code goes to the NEW value supplied here, proving the caller controls " +
"it. Returns the target masked — an unverified caller never gets it back in full.",
})
sendContactOtp(
@CurrentUser() user: TCurrentUser,
@Body() dto: SendContactOtpDto,
): Promise<{ sentTo: string }> {
return this.accountService.sendContactOtp(user.id, dto);
}
@Patch("contact")
@ApiOperation({
summary: "Change the account's email or phone, gated by a verification code",
description:
"Verifies the code and writes the new value in one call, so the API never " +
"has to take a client's word that verification happened.",
})
updateContact(
@CurrentUser() user: TCurrentUser,
@Body() dto: UpdateContactDto,
): Promise<{ success: true; value: string }> {
return this.accountService.updateContact(user.id, dto);
}
@Patch("name")
@ApiOperation({ summary: "Change the account's display name" })
updateName(
@CurrentUser() user: TCurrentUser,
@Body() dto: UpdateAccountNameDto,
): Promise<{ success: true }> {
return this.accountService.updateName(user.id, dto);
}
}

View File

@@ -0,0 +1,226 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
} from "@nestjs/common";
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
import { DataSource, EntityManager, Repository } from "typeorm";
import { isValidPhoneNumber } from "libphonenumber-js";
import { EUserVerifiedBy } from "@tria-plc/api-common/utils/enums/user.enum";
import type { TCurrentTokenUser } from "@tria-plc/iamapi-common/types/current-user.type";
import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity";
import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
import { OtpService, OtpTarget } from "../otp/otp.service";
import {
ContactChannel,
SendContactOtpDto,
UpdateAccountNameDto,
UpdateContactDto,
} from "./dto/account.dto";
import { maskOtpTarget } from "./mask-target.util";
/** How long a contact-change code stays valid before it must be re-requested. */
const CONTACT_OTP_TTL_MS = 10 * 60 * 1000;
/** Postgres unique-violation SQLSTATE. */
const PG_UNIQUE_VIOLATION = "23505";
/**
* Self-serve management of the caller's own IAM user record.
*
* IAM ships `PATCH /api/auth/update-profile`, but it takes email + username +
* phone + name all at once (every field `@IsNotEmpty`) and performs no
* verification — it will move an account's phone to any number the caller
* types. These routes exist so a contact change is *proven*: the code goes to
* the NEW address and the write only lands once it comes back.
*/
@Injectable()
export class AccountService {
private readonly logger = new Logger(AccountService.name);
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly otpService: OtpService,
) {}
/**
* Send a code to the address the caller wants to move TO. Sending to the new
* value (rather than the one on file) is the whole point — it proves control
* of the destination before anything is written.
*/
async sendContactOtp(
userId: string,
dto: SendContactOtpDto,
): Promise<{ sentTo: string }> {
const value = this.normalize(dto.channel, dto.value);
await this.assertNotTaken(dto.channel, value, userId);
const target = this.targetFor(dto.channel, value);
await this.otpService.sendOtp(target);
return { sentTo: maskOtpTarget(target) };
}
/**
* Verify the code, then write the new contact value. The verify and the write
* are one call: the API never has to trust that a client "already verified"
* — unlike the signup flow, where the OTP is client-orchestrated and
* `POST /api/otp/verify` is a separate public route the client may simply skip.
*/
async updateContact(
userId: string,
dto: UpdateContactDto,
): Promise<{ success: true; value: string }> {
const value = this.normalize(dto.channel, dto.value);
await this.assertNotTaken(dto.channel, value, userId);
await this.otpService.verifyOtpForAction(
this.targetFor(dto.channel, value),
dto.otp,
CONTACT_OTP_TTL_MS,
);
const isEmail = dto.channel === ContactChannel.Email;
const userPatch = isEmail
? { email: value }
: {
phoneNumber: value,
// The number just passed an OTP, which is exactly what IAM's own
// phone-verification flag means. Set it here so the freight app stops
// needing its own parallel "verified phone" bookkeeping.
isPhoneNumberVerified: true,
verifiedBy: EUserVerifiedBy.PHONE_NUMBER,
};
const sessionPatch: Partial<TCurrentTokenUser> = isEmail
? { email: value }
: { phoneNumber: value, isPhoneNumberVerified: true };
try {
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(User).update({ id: userId }, userPatch);
await this.refreshSessions(manager, userId, sessionPatch);
});
} catch (error) {
throw this.asConflict(error, dto.channel);
}
this.logger.log(`Account ${dto.channel} updated for user ${userId}`);
return { success: true, value };
}
/** Rename the account. No OTP — a name change proves nothing and grants nothing. */
async updateName(
userId: string,
dto: UpdateAccountNameDto,
): Promise<{ success: true }> {
const en = dto.name.en?.trim();
const name = { am: dto.name.am.trim(), ...(en ? { en } : {}) };
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(User).update({ id: userId }, { name });
// IAM mirrors the name onto the employee row. Portal customers are
// `individual` users with no employee row at all, so this is a no-op for
// them — hence an unconditional update() rather than a lookup-then-write.
await manager.getRepository(Employee).update({ userId }, { name });
await this.refreshSessions(manager, userId, { name });
});
return { success: true };
}
/**
* `GET /api/auth/me` serves `session.userInfo` — a snapshot IAM writes only
* when a session is created at login. Without patching it here, a saved change
* stays invisible to /me (and to anything reading the token's claims) until the
* user logs out and back in, which reads as "my edit didn't save".
*/
private async refreshSessions(
manager: EntityManager,
userId: string,
patch: Partial<TCurrentTokenUser>,
): Promise<void> {
const repo = manager.getRepository(Session);
const sessions = await repo.find({ where: { userId } });
await Promise.all(
sessions.map((session) =>
repo.update(
{ id: session.id },
{ userInfo: { ...session.userInfo, ...patch } },
),
),
);
}
/** Canonicalise for the channel and reject anything malformed up front. */
private normalize(channel: ContactChannel, value: string): string {
const raw = value.trim();
if (channel === ContactChannel.Email) {
const email = raw.toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw new BadRequestException("A valid email address is required");
}
return email;
}
if (!isValidPhoneNumber(raw)) {
throw new BadRequestException(
"A valid international phone number is required (E.164, e.g. +251911223344)",
);
}
// Store the same canonical form the OTP is keyed by, so the code sent here
// is findable on verify regardless of how the number was typed.
return normalizeE164(raw) as string;
}
private targetFor(channel: ContactChannel, value: string): OtpTarget {
return channel === ContactChannel.Email ? { email: value } : { phone: value };
}
/**
* `iam.users.email` and `.phone_number` are each independently UNIQUE, so a
* collision would otherwise surface as a raw 500 at write time. This is a
* courtesy check, not the guard — it races, so {@link asConflict} still has to
* catch the violation.
*/
private async assertNotTaken(
channel: ContactChannel,
value: string,
userId: string,
): Promise<void> {
const existing = await this.userRepository.findOne({
where:
channel === ContactChannel.Email
? { email: value }
: { phoneNumber: value },
select: { id: true },
});
if (existing && existing.id !== userId) {
throw this.takenError(channel);
}
}
private asConflict(error: unknown, channel: ContactChannel): Error {
const code = (error as { code?: string } | null)?.code;
if (code === PG_UNIQUE_VIOLATION) return this.takenError(channel);
return error as Error;
}
private takenError(channel: ContactChannel): ConflictException {
return new ConflictException(
channel === ContactChannel.Email
? "That email address is already registered to another account"
: "That phone number is already registered to another account",
);
}
}

View File

@@ -0,0 +1,60 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsEnum,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
/** The contact channel being changed on the caller's own account. */
export enum ContactChannel {
Email = "email",
Phone = "phone",
}
export class SendContactOtpDto {
@ApiProperty({ enum: ContactChannel })
@IsEnum(ContactChannel)
channel!: ContactChannel;
@ApiProperty({
description:
"The NEW email or phone to verify. The code is sent here, not to the " +
"address currently on the account — that is what proves the caller " +
"controls the number/inbox they are moving to.",
example: "+251911223344",
})
@IsString()
@IsNotEmpty()
value!: string;
}
export class UpdateContactDto extends SendContactOtpDto {
@ApiProperty({ description: "The 6-digit code sent to the new value" })
@IsString()
@IsNotEmpty()
otp!: string;
}
export class AccountNameDto {
@ApiProperty({ description: "Amharic name", example: "አበበ በቀለ" })
@IsString()
@IsNotEmpty()
am!: string;
@ApiPropertyOptional({ description: "English name", example: "Abebe Bekele" })
@IsOptional()
@IsString()
en?: string;
}
export class UpdateAccountNameDto {
@ApiProperty({ type: AccountNameDto })
@IsObject()
@ValidateNested()
@Type(() => AccountNameDto)
name!: AccountNameDto;
}

View File

@@ -11,6 +11,7 @@ import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user
import { OtpService, OtpTarget } from "../otp/otp.service";
import { ResetChannel } from "./dto/forgot-password.dto";
import { maskOtpTarget } from "./mask-target.util";
/**
* How long the reset ticket minted for `PATCH /api/auth/set-password` stays
@@ -158,12 +159,6 @@ export class ForgotPasswordService {
/** `+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)}`;
return maskOtpTarget(target);
}
}

View File

@@ -1,11 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Employee } from '@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity';
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.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 { ExternalProfile } from '../companies/entities/external-profile.entity';
import { OtpModule } from '../otp/otp.module';
import { AccountController } from './account.controller';
import { AccountService } from './account.service';
import { CheckAvailabilityController } from './check-availability.controller';
import { CheckAvailabilityService } from './check-availability.service';
import { CustomerResetController } from './customer-reset.controller';
@@ -17,17 +21,25 @@ import { FreightMeService } from './freight-me.service';
@Module({
imports: [
TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]),
TypeOrmModule.forFeature([
User,
UserVerification,
ExternalProfile,
Session,
Employee,
]),
OtpModule,
],
controllers: [
FreightMeController,
AccountController,
CheckAvailabilityController,
ForgotPasswordController,
CustomerResetController,
],
providers: [
FreightMeService,
AccountService,
CheckAvailabilityService,
ForgotPasswordService,
CustomerResetService,

View File

@@ -0,0 +1,16 @@
import { OtpTarget } from "../otp/otp.service";
/**
* Mask an OTP target for echoing back to the caller: `+251911234567` ->
* `+251•••••4567`; `ab@x.com` -> `a•@x.com`. Never return an unmasked target to
* a caller who has not yet proven possession of the channel.
*/
export function maskOtpTarget(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)}`;
}

View File

@@ -1,4 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import {
NotificationAudience,
NotificationType,
@@ -8,6 +10,7 @@ import {
import { Booking } from './entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
/**
* Customer + staff notifications for the booking lifecycle: review, clearance
@@ -27,6 +30,8 @@ export class BookingLifecycleNotifierService {
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
private ref(b: Booking): string {
@@ -40,7 +45,9 @@ export class BookingLifecycleNotifierService {
logLabel: string,
): Promise<void> {
this.logger.log(`${logLabel}${this.ref(b)}`);
const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null;
const phone = b.companyId
? await resolveCompanyNotifyPhone(this.dataSource, b.companyId)
: null;
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
if (phone) {

View File

@@ -1,4 +1,6 @@
import { Injectable, Logger } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } from "typeorm";
import {
NotificationAudience,
NotificationPriority,
@@ -8,6 +10,7 @@ import {
import { Company, CompanyStatus } from "./entities/company.entity";
import { NotificationsService } from "../notifications/notifications.service";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util";
/** Account statuses that lock the customer out and therefore must be told to them. */
const PUNITIVE_STATUSES: readonly CompanyStatus[] = [
@@ -28,11 +31,13 @@ export class CompanyNotifierService {
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
/** Send SMS + email to the company contact; log-only on failure. */
private async notifyContact(company: Company, message: string): Promise<void> {
const phone = company.contactPersonPhone ?? company.phone ?? null;
const phone = await resolveCompanyNotifyPhone(this.dataSource, company.id);
const email = company.email ?? company.generalManagerEmail ?? null;
if (phone) {

View File

@@ -1,4 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import {
NotificationAudience,
NotificationType,
@@ -8,6 +10,7 @@ import {
import { Contract } from './entities/contract.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
/**
* Customer + staff notifications for the contract lifecycle. Every customer
@@ -24,6 +27,8 @@ export class ContractNotifierService {
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
private ref(c: Contract): string {
@@ -37,7 +42,9 @@ export class ContractNotifierService {
logLabel: string,
): Promise<void> {
this.logger.log(`${logLabel}${this.ref(c)}`);
const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null;
const phone = c.companyId
? await resolveCompanyNotifyPhone(this.dataSource, c.companyId)
: null;
const email = c.company?.email ?? c.company?.generalManagerEmail ?? null;
if (phone) {

View File

@@ -4,6 +4,8 @@ import {
Injectable,
Logger,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { randomUUID } from 'node:crypto';
import { Readable } from 'stream';
import { insertWithGeneratedReference } from '@edr/api-common';
@@ -102,8 +104,41 @@ export class ContractTransitionService {
private readonly notifier: ContractNotifierService,
private readonly contractTemplates: ContractTemplatesService,
private readonly clearanceFeeService: ClearanceFeeService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
/**
* The phone the signing OTP is sent to and verified against: the signer's own
* IAM account number.
*
* H12(b): resolved server-side from the authenticated user id, never from the
* request body — a caller-supplied number would let an attacker point the code
* at their own phone. Ownership is already gated separately by
* {@link ContractsService.assertCustomerCanAccessContract}, so this binds the
* signature to the *person* signing rather than to a company landline that may
* be shared, stale, or imported from eTrade.
*/
private async resolveSignerPhone(signerUserId?: string): Promise<string> {
if (!signerUserId) {
// Unreachable in practice (the ownership gate rejects a missing user
// first), but never fall back to another number if it ever changes.
throw new BadRequestException('Authentication required to sign');
}
const rows: Array<{ phone_number: string | null }> =
await this.dataSource.query(
`SELECT phone_number FROM iam.users WHERE id = $1 AND is_active = true`,
[signerUserId],
);
const phone = rows[0]?.phone_number?.trim();
if (!phone) {
throw new BadRequestException(
'Your account has no registered phone number. Add one in Settings → Account before signing.',
);
}
return phone;
}
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
async submit(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
@@ -804,10 +839,10 @@ export class ContractTransitionService {
}
/**
* Send the sudo-mode signing OTP to the CONTRACT COMPANY's registered phone —
* the same number {@link sign} verifies against. The client never picks the
* number (that is the H12(b) trust property): it only asks us to send, and we
* resolve the phone from the contract. Returns a masked hint so the UI can
* Send the sudo-mode signing OTP to the SIGNER's own registered phone — the
* same number {@link sign} verifies against. The client never picks the number
* (that is the H12(b) trust property): it only asks us to send, and we resolve
* the phone from the authenticated user id. Returns a masked hint so the UI can
* say where the code went without exposing the full number.
*/
async sendSigningOtp(
@@ -823,14 +858,9 @@ export class ContractTransitionService {
);
assertContractStatus(contract, ['CONTRACT_READY']);
const companyPhone = contract.company?.phone?.trim();
if (!companyPhone) {
throw new BadRequestException(
'The contract company has no registered phone on file to send the signing OTP to',
);
}
await this.otpService.sendOtp({ phone: companyPhone });
return { sentTo: maskPhone(companyPhone) };
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
await this.otpService.sendOtp({ phone: signerPhone });
return { sentTo: maskPhone(signerPhone) };
}
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
@@ -856,20 +886,18 @@ export class ContractTransitionService {
throw new BadRequestException('Customer has already signed this contract');
}
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
// signature is applied. H12(b): verify against the CONTRACT COMPANY's
// registered phone — never the caller-supplied dto.otpPhone, which an
// attacker could point at their own phone to sign someone else's
// contract. The OTP is issued to the company's registered number.
const companyPhone = contract.company?.phone?.trim();
if (!companyPhone) {
throw new BadRequestException(
'The contract company has no registered phone on file to verify the signing OTP against',
);
}
// signature is applied. H12(b): verify against the SIGNER's own registered
// phone, resolved server-side from the authenticated user id — never a
// caller-supplied number, which an attacker could point at their own
// phone. Ownership is already asserted above, so this proves the specific
// person holding the account is present, not merely that someone reached a
// shared company line. Must resolve identically to sendSigningOtp, or send
// and verify would target different numbers.
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
if (!dto.otp) {
throw new BadRequestException('OTP verification is required to sign the contract');
}
await this.otpService.verifyOtpForAction({ phone: companyPhone }, dto.otp);
await this.otpService.verifyOtpForAction({ phone: signerPhone }, dto.otp);
await this.applySignature(contract, dto, options);
await this.contractsRepository.update(contractId, {
status: 'SIGNED_CUSTOMER',

View File

@@ -28,17 +28,13 @@ export class SignContractDto {
consentText?: string;
// Sudo-mode OTP challenge. Required when role=CUSTOMER: a fresh 6-digit code
// SMS'd to the signer's phone, verified server-side before the signature is
// applied. `otpPhone` is the number the code was sent to (the signed-in
// customer's registered phone).
// SMS'd to the signer's registered phone, verified server-side before the
// signature is applied. The number itself is deliberately NOT part of this
// DTO — the server resolves it from the authenticated user id, so a caller
// cannot redirect the challenge to a phone they control.
@ApiPropertyOptional({ description: '6-digit OTP; required when role=CUSTOMER' })
@IsOptional()
@IsString()
@Matches(/^\d{6}$/, { message: 'otp must be 6 digits' })
otp?: string;
@ApiPropertyOptional({ description: 'Phone the OTP was sent to; required when role=CUSTOMER' })
@IsOptional()
@IsString()
otpPhone?: string;
}

View File

@@ -1,6 +1,10 @@
import { DataSource } from 'typeorm';
import { NotificationsService } from './notifications.service';
import {
companyNotifyPhoneExpr,
primaryContactUserJoin,
} from './resolve-company-phone.util';
/**
* Best-effort SMS + email fan-out to a company's contacts. Looks up the
@@ -15,9 +19,10 @@ export async function sendCompanyChannels(
): Promise<void> {
const [contact]: Array<{ phone: string | null; email: string | null }> =
await dataSource.query(
`SELECT COALESCE(phone, etrade_phone) AS phone, email
FROM freight.companies
WHERE id = $1 AND deleted_at IS NULL`,
`SELECT ${companyNotifyPhoneExpr('co')} AS phone, co.email
FROM freight.companies co
${primaryContactUserJoin('co')}
WHERE co.id = $1 AND co.deleted_at IS NULL`,
[companyId],
);
if (contact?.phone) {

View File

@@ -0,0 +1,60 @@
import { DataSource, EntityManager } from "typeorm";
/**
* Where a customer-facing SMS actually goes.
*
* The person who signs up, logs in, and receives OTPs is an IAM user, and
* `iam.users.phone_number` is the number they control and can change themselves
* (see the account settings flow). A company's own `phone` is business contact
* data — often a landline, a shared desk, or a stale eTrade import — so it is
* the fallback, not the source.
*
* `companies.contact_person_phone` is deliberately NOT consulted: the live write
* path stores that value in the `attributes` jsonb and has never populated the
* column, so every reader of it was silently falling through to `phone` anyway.
*/
/**
* LEFT JOIN a company alias to its primary contact's IAM user, exposing
* `pc.phone_number`.
*
* LATERAL + LIMIT 1 rather than a plain join: nothing in the schema stops a
* company having two `is_primary_contact` rows, and a plain join would then
* duplicate the company row — which in a fan-out query means sending the same
* customer the same SMS twice.
*
* `alias` is always a code-controlled literal, never caller input.
*/
export function primaryContactUserJoin(alias: string): string {
return `
LEFT JOIN LATERAL (
SELECT u.phone_number
FROM freight.external_profiles ep
JOIN iam.users u ON u.id = ep.user_id AND u.is_active = true
WHERE ep.company_id = ${alias}.id
AND ep.is_primary_contact = true
AND ep.deleted_at IS NULL
ORDER BY ep.created_at
LIMIT 1
) pc ON true`;
}
/** SQL expression for the company's SMS number, given the joined `pc` alias. */
export function companyNotifyPhoneExpr(alias: string): string {
return `COALESCE(pc.phone_number, ${alias}.phone)`;
}
/** The SMS number for one company, or null when neither source has one. */
export async function resolveCompanyNotifyPhone(
db: DataSource | EntityManager,
companyId: string,
): Promise<string | null> {
const rows: Array<{ phone: string | null }> = await db.query(
`SELECT ${companyNotifyPhoneExpr("co")} AS phone
FROM freight.companies co
${primaryContactUserJoin("co")}
WHERE co.id = $1 AND co.deleted_at IS NULL`,
[companyId],
);
return rows[0]?.phone ?? null;
}

View File

@@ -1,4 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import {
NotificationAudience,
NotificationPriority,
@@ -9,6 +11,7 @@ import {
import { Booking } from '../bookings/entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { BATCH_TIMEZONE } from './booking-batch.constants';
@@ -20,6 +23,8 @@ export class BookingNotifierService {
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
private readonly trainSchedules: TrainSchedulesRepository,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
/**
@@ -60,7 +65,9 @@ export class BookingNotifierService {
logLabel: string,
): Promise<void> {
this.logger.log(`${logLabel}${this.ref(b)}`);
const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null;
const phone = b.companyId
? await resolveCompanyNotifyPhone(this.dataSource, b.companyId)
: null;
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
if (phone) {

View File

@@ -13,6 +13,10 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import {
companyNotifyPhoneExpr,
primaryContactUserJoin,
} from '../notifications/resolve-company-phone.util';
import { BookingBatchService } from './booking-batch.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
@@ -527,7 +531,7 @@ export class BookingWindowService implements OnModuleInit {
}> = await this.dataSource.query(
`SELECT DISTINCT
c.company_id,
COALESCE(co.contact_person_phone, co.phone) AS phone,
${companyNotifyPhoneExpr('co')} AS phone,
COALESCE(co.email, co.general_manager_email) AS email
FROM freight.contract_routes cr
JOIN freight.contracts c
@@ -535,6 +539,7 @@ export class BookingWindowService implements OnModuleInit {
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
AND c.deleted_at IS NULL
JOIN freight.companies co ON co.id = c.company_id
${primaryContactUserJoin('co')}
WHERE cr.origin_yard_id = $1
AND cr.destination_yard_id = $2
AND cr.deleted_at IS NULL`,

View File

@@ -13,6 +13,10 @@ import type { InterchangeDocument } from '../interchange-documents/entities/inte
import { LastMileService } from '../last-mile/last-mile.service';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
import {
companyNotifyPhoneExpr,
primaryContactUserJoin,
} from '../notifications/resolve-company-phone.util';
import { SignaturesService } from '../signatures/signatures.service';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
@@ -1145,7 +1149,7 @@ export class WarehouseInventoryService {
b.company_id AS "customerId",
company.name AS "customer",
company.tin AS "customerTin",
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
${companyNotifyPhoneExpr('company')} AS "customerPhone",
COALESCE(bcu.unit_numbers, bc.container_numbers) AS "containerNumber",
bcu.seal_numbers AS "sealNumbers",
bc.container_quantity AS "containerQuantity",
@@ -1183,6 +1187,7 @@ export class WarehouseInventoryService {
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
${primaryContactUserJoin('company')}
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
@@ -1288,7 +1293,7 @@ export class WarehouseInventoryService {
b.cargo_total_weight_vgm AS "weight",
company.name AS "customer",
company.tin AS "customerTin",
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
${companyNotifyPhoneExpr('company')} AS "customerPhone",
bc.container_numbers AS "containerNumber",
bc.container_quantity AS "containerQuantity",
bc.container_packaging_type AS "containerPackagingType",
@@ -1317,6 +1322,7 @@ export class WarehouseInventoryService {
OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
${primaryContactUserJoin('company')}
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
@@ -4946,7 +4952,7 @@ export class WarehouseInventoryService {
`SELECT b.reference AS "reference",
company.name AS "customer",
company.tin AS "customerTin",
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
${companyNotifyPhoneExpr('company')} AS "customerPhone",
b.cargo_total_weight_vgm AS "weight",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
bc.container_numbers AS "containerNumber",
@@ -4963,6 +4969,7 @@ export class WarehouseInventoryService {
v.vehicle_type AS "firstMileTruckType"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
${primaryContactUserJoin('company')}
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = b.cargo_type_id
LEFT JOIN LATERAL (
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,

View File

@@ -25,6 +25,10 @@ import {
InvoiceDocumentService,
} from "../billing/documents/invoice-document.service";
import { NotificationsService } from "../notifications/notifications.service";
import {
companyNotifyPhoneExpr,
primaryContactUserJoin,
} from "../notifications/resolve-company-phone.util";
import { WarehouseFeeService } from "./warehouse-fee.service";
import {
WarehouseFeeInvoiceView,
@@ -879,7 +883,7 @@ export class WarehouseInvoiceService {
const [row] = await this.dataSource.query(
`SELECT b.reference AS "bookingReference",
company.name AS "customerName",
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
${companyNotifyPhoneExpr('company')} AS "customerPhone",
COALESCE(
NULLIF(TRIM(CONCAT(COALESCE(last_driver.first_name, ''), ' ', COALESCE(last_driver.last_name, ''))), ''),
last_vehicle.assigned_driver_name,
@@ -892,6 +896,7 @@ export class WarehouseInvoiceService {
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
${primaryContactUserJoin('company')}
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id

View File

@@ -5,6 +5,7 @@ export const URL_CONSTANTS = {
REFRESH_TOKEN: "/api/auth/refresh-token",
LOGOUT: "/api/auth/logout",
PROFILE: "/auth/profile",
CHANGE_PASSWORD: "/api/auth/change-password",
FORGOT_PASSWORD_REQUEST: "/api/auth/forgot-password/request",
FORGOT_PASSWORD_VERIFY: "/api/auth/forgot-password/verify",
},
@@ -19,6 +20,15 @@ export const URL_CONSTANTS = {
CHECK_AVAILABILITY: "/api/auth/check-availability",
},
// The signed-in user's own account record. Distinct from COMPANIES_API.PROFILE,
// which is the company's business profile — these are the identity fields that
// OTPs and SMS notifications are actually delivered to.
ACCOUNT: {
CONTACT_OTP: "/api/me/contact/otp",
CONTACT: "/api/me/contact",
NAME: "/api/me/name",
},
OTP: {
SEND: "/api/otp/send",
VERIFY: "/api/otp/verify",

View File

@@ -29,17 +29,20 @@ import {
ShieldCheck,
User,
UserCheck,
UserCog,
} from "lucide-react";
import { useCallback, useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import useAuth from "@/hooks/useAuth";
import { rolesForCompanyType } from "./settings/companyRoles";
import TabAccount from "./settings/TabAccount";
import TabCompanyProfile from "./settings/TabCompanyProfile";
import TabContactPerson from "./settings/TabContactPerson";
import TabDocuments from "./settings/TabDocuments";
import TabGeneralManager from "./settings/TabGeneralManager";
import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
type SettingsTab = "company" | "contact" | "gm" | "poa" | "documents";
type SettingsTab = "account" | "company" | "contact" | "gm" | "poa" | "documents";
/** A section is "incomplete" when its required fields aren't filled in yet. */
function tabIncomplete(
@@ -62,6 +65,9 @@ function tabIncomplete(
!profile.generalManagerEmail ||
!profile.generalManagerPhone
);
case "account":
// Account fields live on the IAM user, not the company profile, and are
// always populated (signup requires them) — nothing to nag about here.
case "poa":
case "documents":
return false;
@@ -69,6 +75,7 @@ function tabIncomplete(
}
const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
{ id: "account", label: "Account", icon: <UserCog size={16} /> },
{ id: "company", label: "Company", icon: <Building2 size={16} /> },
{ id: "contact", label: "Contact Person", icon: <User size={16} /> },
{ id: "gm", label: "General Manager", icon: <Briefcase size={16} /> },
@@ -175,6 +182,7 @@ function ProfileHeader({ profile }: { profile: ProfileResponse }) {
export default function SettingsPage() {
const queryClient = useQueryClient();
const { user } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const tab = (searchParams.get("tab") as SettingsTab) || "company";
@@ -313,6 +321,21 @@ export default function SettingsPage() {
))}
</Tabs.List>
{/* Deliberately NOT wrapped in the `locked` fieldset below: that lock
is for company-profile edits awaiting review. Account identity is
the user's own login/notification details — they must stay editable
even mid-review, or a customer whose phone changed while pending
would be locked out of their own OTPs. */}
<Tabs.Panel value="account">
{user ? (
<TabAccount user={user} />
) : (
<Center py="xl">
<Loader color="edr-green" />
</Center>
)}
</Tabs.Panel>
{/* While a change request is pending, every panel's inputs + submit
buttons are disabled via the native fieldset; tab switching stays
enabled so the customer can still review what they submitted. */}

View File

@@ -56,11 +56,10 @@ export default function ContractViewPage() {
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
const [agreedToTerms, setAgreedToTerms] = useState(false);
// The signing OTP goes to the CONTRACT COMPANY's registered phone (the number
// the server verifies against), NOT the signed-in user's — those can differ,
// and sending to the user's phone left the code filed under a number verify
// never checks. The server owns the number; we only get back a masked hint of
// where it landed.
// The signing OTP goes to the signed-in user's own registered phone, resolved
// server-side from their account (the same number the server verifies
// against). The client never picks the number, so send and verify can't
// disagree; we only get back a masked hint of where it landed.
const [otpSentTo, setOtpSentTo] = useState<string | null>(null);
const { data, isLoading, isError, refetch } = useQuery({

View File

@@ -0,0 +1,165 @@
import { useMutation } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { CheckCircle2, KeyRound, Save, XCircle } from "lucide-react";
import {
Button,
Card,
Group,
PasswordInput,
Stack,
Text,
Title,
} from "@mantine/core";
import { api } from "@/services/api";
/**
* Mirrors IAM's own `IsStrongPassword` rule on ChangePasswordDto — minLength 8,
* ≥1 lowercase, ≥1 number, ≥1 symbol, uppercase NOT required. Kept in step with
* the server so the user gets a precise message inline instead of a generic 400.
*/
const strongPassword = z
.string()
.min(8, "At least 8 characters")
.regex(/[a-z]/, "Include a lowercase letter")
.regex(/\d/, "Include a number")
.regex(/[^A-Za-z0-9]/, "Include a symbol");
const schema = z
.object({
oldPassword: z.string().min(1, "Current password is required"),
newPassword: strongPassword,
confirmPassword: z.string().min(1, "Confirm your new password"),
})
.refine((d) => d.newPassword === d.confirmPassword, {
path: ["confirmPassword"],
message: "Passwords do not match",
})
.refine((d) => d.newPassword !== d.oldPassword, {
path: ["newPassword"],
message: "New password must be different from your current one",
});
type FormData = z.infer<typeof schema>;
/** IAM returns bare error codes; turn them into something a customer can act on. */
const MESSAGES: Record<string, string> = {
unable_to_change_password: "Your current password is incorrect.",
new_password_same_as_old:
"New password must be different from your current one.",
new_passwords_do_not_match: "The new passwords do not match.",
user_credentials_not_found: "This account has no password set.",
};
/**
* Password changes go straight to IAM's `PATCH /api/auth/change-password`, which
* verifies the old password and owns the credential write (argon hashing,
* retiring the previous credential). The freight app deliberately implements no
* part of that — it only collects the fields.
*/
export default function ChangePasswordCard() {
const {
register,
handleSubmit,
reset,
formState: { errors, isDirty },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { oldPassword: "", newPassword: "", confirmPassword: "" },
});
const mutation = useMutation({
mutationFn: (data: FormData) => api.auth.changePassword.call(data),
// Never leave the old password sitting in component state after a change.
onSuccess: () => reset(),
});
const errorMessage = (err: unknown): string => {
const raw = (
err as { response?: { data?: { message?: string | string[] } } }
)?.response?.data?.message;
const code = Array.isArray(raw) ? raw[0] : raw;
if (!code) return "Could not change your password. Please try again.";
return MESSAGES[code] ?? code;
};
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
<KeyRound size={20} />
<Title order={3}>Password</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
Change the password you use to sign in. You'll need your current one.
</Text>
<form onSubmit={handleSubmit((d) => mutation.mutate(d))}>
<Stack gap="md">
<PasswordInput
label="Current Password"
autoComplete="current-password"
error={errors.oldPassword?.message}
{...register("oldPassword")}
/>
<PasswordInput
label="New Password"
description="At least 8 characters, with a lowercase letter, a number and a symbol."
autoComplete="new-password"
error={errors.newPassword?.message}
{...register("newPassword")}
/>
<PasswordInput
label="Confirm New Password"
autoComplete="new-password"
error={errors.confirmPassword?.message}
{...register("confirmPassword")}
/>
</Stack>
<Group
justify="space-between"
mt="xl"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap="xs">
{mutation.isSuccess && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>
Password changed
</Text>
</Group>
)}
{mutation.isError && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>
{errorMessage(mutation.error)}
</Text>
</Group>
)}
</Group>
<Group gap="md">
<Button
type="button"
variant="outline"
disabled={mutation.isPending || !isDirty}
onClick={() => reset()}
>
Reset
</Button>
<Button
type="submit"
leftSection={<Save size={16} />}
loading={mutation.isPending}
>
Change Password
</Button>
</Group>
</Group>
</form>
</Card>
);
}

View File

@@ -0,0 +1,399 @@
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
CheckCircle2,
Save,
ShieldCheck,
UserCog,
XCircle,
} from "lucide-react";
import {
Alert,
Button,
Card,
Group,
Modal,
PinInput,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { api } from "@/services/api";
import {
ControlledPhoneField,
isValidPhone,
toEthiopianE164,
} from "@/components/PhoneField";
import type { AuthUser, ContactChannel } from "@/types/auth";
import ChangePasswordCard from "./ChangePasswordCard";
const schema = z.object({
phoneNumber: z
.string()
.min(1, "Phone number is required")
.refine(isValidPhone, "Enter a valid phone number"),
email: z.string().min(1, "Email is required").email("Enter a valid email"),
nameEn: z.string().min(1, "Name is required"),
nameAm: z.string().min(1, "Amharic name is required"),
});
type FormData = z.infer<typeof schema>;
/** A contact change that still needs its code entered. */
interface PendingChange {
channel: ContactChannel;
value: string;
}
const CHANNEL_LABEL: Record<ContactChannel, string> = {
phone: "phone number",
email: "email address",
};
const normaliseEmail = (v: string) => v.trim().toLowerCase();
interface TabAccountProps {
user: AuthUser;
}
/**
* The signed-in user's own account — the phone and email that OTPs and SMS
* notifications are actually delivered to. Distinct from the company profile
* tabs, which hold business contact details for the organisation.
*
* Changing phone or email is verified: the server sends a code to the NEW value
* and only writes it once the code comes back, so a typo'd number can never
* silently take over the account's notifications. Each code is bound to a single
* channel, so changing both walks the user through one verification per channel.
*/
export default function TabAccount({ user }: TabAccountProps) {
const queryClient = useQueryClient();
// Head of the queue is the change currently being verified. Changing phone AND
// email in one save enqueues both — a code proves one channel, never two.
const [queue, setQueue] = useState<PendingChange[]>([]);
const [sentTo, setSentTo] = useState<string | null>(null);
const [completed, setCompleted] = useState<ContactChannel[]>([]);
const [otp, setOtp] = useState("");
const current = queue[0] ?? null;
const step = completed.length + 1;
const totalSteps = completed.length + queue.length;
const defaultValues = useMemo(
(): FormData => ({
// Normalise to the same E.164 shape the phone input emits. Some accounts
// store a local `0911…`; comparing raw against the field's `+2519…` would
// read as "changed" on every save and hijack the email's turn.
phoneNumber: toEthiopianE164(user.phoneNumber),
email: user.email ?? "",
nameEn: user.name?.en ?? "",
nameAm: user.name?.am ?? "",
}),
[user],
);
const {
register,
control,
handleSubmit,
reset,
formState: { errors, isDirty },
} = useForm<FormData>({
resolver: zodResolver(schema),
values: defaultValues,
// Verifying one channel refetches the user, which re-syncs `values`. Without
// keepDirtyValues that resync would silently discard an edit the user has
// typed into the *other* field but not yet verified.
resetOptions: { keepDirtyValues: true },
});
const refreshUser = () =>
queryClient.invalidateQueries({ queryKey: api.auth.getMyInfo.queryKey() });
/** Name needs no proof of possession, so it saves straight through. */
const nameMutation = useMutation({
mutationFn: (data: FormData) =>
api.account.updateName.call({
name: { en: data.nameEn, am: data.nameAm },
}),
onSuccess: refreshUser,
});
/** Step 1 of a contact change: ask the server to code the new value. */
const otpMutation = useMutation({
mutationFn: (change: PendingChange) =>
api.account.sendContactOtp.call(change),
onSuccess: (res) => {
setOtp("");
setSentTo(res.sentTo);
},
onError: () => {
// Could not even send — drop the flow rather than strand the user in a
// modal asking for a code that was never issued.
setQueue([]);
setSentTo(null);
},
});
/** Step 2: hand the code back; the server verifies and writes atomically. */
const contactMutation = useMutation({
mutationFn: (body: PendingChange & { otp: string }) =>
api.account.updateContact.call(body),
onSuccess: async (_res, body) => {
setOtp("");
setSentTo(null);
setCompleted((prev) => [...prev, body.channel]);
await refreshUser();
// Advance to the next queued channel, keeping the modal open so a
// both-changed save is one continuous flow.
const rest = queue.slice(1);
setQueue(rest);
if (rest[0]) otpMutation.mutate(rest[0]);
},
});
const startQueue = (changes: PendingChange[]) => {
setCompleted([]);
setQueue(changes);
otpMutation.mutate(changes[0]);
};
const cancelQueue = () => {
setQueue([]);
setSentTo(null);
setOtp("");
contactMutation.reset();
};
const onSubmit = (data: FormData) => {
setCompleted([]);
const changes: PendingChange[] = [];
if (toEthiopianE164(data.phoneNumber) !== defaultValues.phoneNumber) {
changes.push({ channel: "phone", value: data.phoneNumber });
}
if (normaliseEmail(data.email) !== normaliseEmail(defaultValues.email)) {
changes.push({ channel: "email", value: normaliseEmail(data.email) });
}
// Name carries no verification, so it saves alongside rather than queueing.
if (
data.nameEn !== defaultValues.nameEn ||
data.nameAm !== defaultValues.nameAm
) {
nameMutation.mutate(data);
}
if (changes.length) startQueue(changes);
};
const errorMessage = (err: unknown): string => {
const res = (
err as { response?: { data?: { message?: string | string[] } } }
)?.response?.data?.message;
if (Array.isArray(res)) return res[0];
return res ?? "Something went wrong. Please try again.";
};
const busy =
otpMutation.isPending ||
contactMutation.isPending ||
nameMutation.isPending;
const savedSummary =
completed.length && !queue.length
? `Your ${completed.map((c) => CHANNEL_LABEL[c]).join(" and ")} ${
completed.length > 1 ? "were" : "was"
} verified and updated`
: null;
return (
<Stack gap="lg">
<Card padding="lg">
<Group gap="sm" mb="xs">
<UserCog size={20} />
<Title order={3}>Account</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
Your login details. Verification codes and SMS notifications are sent
to the phone number below.
</Text>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<ControlledPhoneField
control={control}
name="phoneNumber"
label="Phone Number"
required
/>
<TextInput
label="Email"
placeholder="you@company.com"
error={errors.email?.message}
{...register("email")}
/>
<TextInput
label="Full Name"
placeholder="Abebe Bekele"
error={errors.nameEn?.message}
{...register("nameEn")}
/>
<TextInput
label="Full Name (Amharic)"
placeholder="አበበ በቀለ"
error={errors.nameAm?.message}
{...register("nameAm")}
/>
</Stack>
<Text c="edr-muted" size="xs" mt="sm">
Changing your phone number or email requires a verification code
sent to the new one.
</Text>
<Group
justify="space-between"
mt="xl"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap="xs">
{savedSummary && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>
{savedSummary}
</Text>
</Group>
)}
{nameMutation.isSuccess && !savedSummary && !queue.length && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>
Saved successfully
</Text>
</Group>
)}
{(otpMutation.isError || nameMutation.isError) && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>
{errorMessage(otpMutation.error ?? nameMutation.error)}
</Text>
</Group>
)}
</Group>
<Group gap="md">
<Button
type="button"
variant="outline"
disabled={busy || !isDirty}
onClick={() => reset()}
>
Reset
</Button>
<Button
type="submit"
leftSection={<Save size={16} />}
loading={busy}
>
Save Changes
</Button>
</Group>
</Group>
</form>
<Modal
opened={current !== null}
onClose={cancelQueue}
title="Verify your new contact details"
centered
>
{current && (
<Stack gap="md">
{totalSteps > 1 && (
<Text size="sm" c="edr-muted">
Step {step} of {totalSteps}
</Text>
)}
<Alert icon={<ShieldCheck size={16} />} color="blue">
{sentTo ? (
<>
We sent a 6-digit code to <b>{sentTo}</b>. Enter it to
confirm your new {CHANNEL_LABEL[current.channel]}.
</>
) : (
<>
Sending a code to your new {CHANNEL_LABEL[current.channel]}
</>
)}
</Alert>
{completed.length > 0 && queue.length > 0 && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm">
{CHANNEL_LABEL[completed[completed.length - 1]]} updated
one more to confirm.
</Text>
</Group>
)}
<PinInput
length={6}
type="number"
oneTimeCode
value={otp}
onChange={setOtp}
disabled={!sentTo || otpMutation.isPending}
aria-label="Verification code"
/>
{contactMutation.isError && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm">{errorMessage(contactMutation.error)}</Text>
</Group>
)}
<Group justify="space-between">
<Button
variant="subtle"
disabled={otpMutation.isPending || !sentTo}
onClick={() => {
// Clear any "invalid code" error first: the user is asking for
// a fresh code, not retrying the old one, so leaving the
// failure on screen would describe a request they didn't make.
contactMutation.reset();
otpMutation.mutate(current);
}}
>
Resend code
</Button>
<Button
loading={contactMutation.isPending}
disabled={otp.length !== 6 || !sentTo}
onClick={() => contactMutation.mutate({ ...current, otp })}
>
Verify &amp; Save
</Button>
</Group>
</Stack>
)}
</Modal>
</Card>
<ChangePasswordCard />
</Stack>
);
}

View File

@@ -68,6 +68,7 @@ import type {
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import type {
AuthUser,
ChangePasswordPayload,
CheckAvailabilityPayload,
CheckAvailabilityResponse,
GenerateVerificationCodePayload,
@@ -81,6 +82,11 @@ import type {
ForgotPasswordRequestPayload,
ForgotPasswordVerifyPayload,
ResetTicket,
SendContactOtpPayload,
SendContactOtpResponse,
UpdateAccountNamePayload,
UpdateContactPayload,
UpdateContactResponse,
} from "@/types/auth";
// ---------------------------------------------------------------------------
@@ -144,9 +150,34 @@ export const api = {
"verifyOTP",
authService.verifyOTP,
),
changePassword: endpoint<ChangePasswordPayload, void>(
"auth",
"changePassword",
authService.changePassword,
),
logout: endpoint<void, void>("auth", "logout", authService.logout),
},
// The signed-in user's own IAM account — the phone/email that OTPs and SMS
// actually go to. Separate from `companies`, which is business profile data.
account: {
sendContactOtp: endpoint<SendContactOtpPayload, SendContactOtpResponse>(
"account",
"sendContactOtp",
authService.sendContactOtp,
),
updateContact: endpoint<UpdateContactPayload, UpdateContactResponse>(
"account",
"updateContact",
authService.updateContact,
),
updateName: endpoint<UpdateAccountNamePayload, { success: true }>(
"account",
"updateName",
authService.updateAccountName,
),
},
companies: {
getInfo: endpoint<void, CompanyInfoResponse | null>(
"companies",

View File

@@ -1,6 +1,7 @@
import { URL_CONSTANTS } from "@/constants/URLS";
import type {
AuthUser,
ChangePasswordPayload,
CheckAvailabilityPayload,
CheckAvailabilityResponse,
ForgotPasswordRequestPayload,
@@ -11,11 +12,17 @@ import type {
OtpPayload,
OtpResponse,
ResetTicket,
SendContactOtpPayload,
SendContactOtpResponse,
SetPasswordPayload,
SignupPayload,
SignupResponse,
UpdateAccountNamePayload,
UpdateContactPayload,
UpdateContactResponse,
} from "@/types/auth";
import { client } from "@/utils/api";
import { unwrap } from "@/utils/endpoint";
import { ApiResponse } from "@edr/types";
export const authService = {
@@ -105,6 +112,44 @@ export const authService = {
return res.data.data;
},
/**
* Change the signed-in user's password via IAM's own route: it verifies the
* old password with argon and owns the credential write (deactivating the
* previous one), so this app never touches password material.
*/
changePassword: async (body: ChangePasswordPayload) => {
await client.patch(URL_CONSTANTS.AUTH.CHANGE_PASSWORD, body);
},
// The three calls below manage the signed-in user's own account record
// (`/api/me`), which is what OTPs and SMS notifications are delivered to.
// Changing phone/email is OTP-gated server-side: the code goes to the NEW
// value, and the write only lands once it is verified.
sendContactOtp: async (body: SendContactOtpPayload) => {
const res = await client.post<ApiResponse<SendContactOtpResponse>>(
URL_CONSTANTS.ACCOUNT.CONTACT_OTP,
body,
);
return unwrap(res.data);
},
updateContact: async (body: UpdateContactPayload) => {
const res = await client.patch<ApiResponse<UpdateContactResponse>>(
URL_CONSTANTS.ACCOUNT.CONTACT,
body,
);
return unwrap(res.data);
},
updateAccountName: async (body: UpdateAccountNamePayload) => {
const res = await client.patch<ApiResponse<{ success: true }>>(
URL_CONSTANTS.ACCOUNT.NAME,
body,
);
return unwrap(res.data);
},
refreshToken: async () => {
const refreshTokenCookie = document.cookie
.split("; ")

View File

@@ -128,8 +128,6 @@ export interface SignContractPayload {
consentText?: string;
/** Sudo-mode OTP challenge; required when role=CUSTOMER. */
otp?: string;
/** Phone the OTP was sent to; required when role=CUSTOMER. */
otpPhone?: string;
}
export interface ApproveDeliveryResponse {

View File

@@ -268,9 +268,10 @@ export const contractsService = {
return data.data ?? data;
},
// Ask the server to send the signing OTP to the CONTRACT COMPANY's registered
// phone. The client never picks the number (the server verifies against the
// same one), so send and verify can't disagree. Returns a masked hint.
// Ask the server to send the signing OTP to the signer's own registered phone.
// The client never picks the number (the server resolves it from the
// authenticated user and verifies against the same one), so send and verify
// can't disagree. Returns a masked hint.
sendSigningOtp: async (id: string): Promise<{ sentTo: string }> => {
const { data } = await client.post(C.CONTRACT_SEND_SIGNING_OTP(id));
return data.data ?? data;

View File

@@ -45,6 +45,45 @@ export interface OtpResponse {
message: string;
}
/**
* Change the signed-in user's password. The old password is the proof of
* possession — IAM verifies it server-side and owns the credential write, so the
* freight app never hashes or stores a password itself.
*/
export interface ChangePasswordPayload {
oldPassword: string;
newPassword: string;
confirmPassword: string;
}
/** The contact channel being changed on the signed-in user's own account. */
export type ContactChannel = "email" | "phone";
export interface SendContactOtpPayload {
channel: ContactChannel;
/** The NEW value being moved to — the code is sent here, not to the old one. */
value: string;
}
export interface SendContactOtpResponse {
/** Masked hint of where the code landed, e.g. `+251•••••4567`. */
sentTo: string;
}
export interface UpdateContactPayload extends SendContactOtpPayload {
otp: string;
}
export interface UpdateContactResponse {
success: true;
/** The canonical stored value (E.164 for phone, lowercased for email). */
value: string;
}
export interface UpdateAccountNamePayload {
name: { am: string; en?: string };
}
export interface CheckAvailabilityPayload {
email?: string;
phone?: string;