mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
feat: setup account page for customer and centeralize the otps and phone usages to use the iam user
This commit is contained in:
62
apps/edr-freight-api/src/modules/auth/account.controller.ts
Normal file
62
apps/edr-freight-api/src/modules/auth/account.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
226
apps/edr-freight-api/src/modules/auth/account.service.ts
Normal file
226
apps/edr-freight-api/src/modules/auth/account.service.ts
Normal 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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
apps/edr-freight-api/src/modules/auth/dto/account.dto.ts
Normal file
60
apps/edr-freight-api/src/modules/auth/dto/account.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user
|
|||||||
|
|
||||||
import { OtpService, OtpTarget } from "../otp/otp.service";
|
import { OtpService, OtpTarget } from "../otp/otp.service";
|
||||||
import { ResetChannel } from "./dto/forgot-password.dto";
|
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
|
* 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`. */
|
/** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */
|
||||||
maskTarget(target: OtpTarget): string {
|
maskTarget(target: OtpTarget): string {
|
||||||
if (target.email) {
|
return maskOtpTarget(target);
|
||||||
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)}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
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 { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
||||||
import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity';
|
import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity';
|
||||||
|
|
||||||
import { ExternalProfile } from '../companies/entities/external-profile.entity';
|
import { ExternalProfile } from '../companies/entities/external-profile.entity';
|
||||||
import { OtpModule } from '../otp/otp.module';
|
import { OtpModule } from '../otp/otp.module';
|
||||||
|
import { AccountController } from './account.controller';
|
||||||
|
import { AccountService } from './account.service';
|
||||||
import { CheckAvailabilityController } from './check-availability.controller';
|
import { CheckAvailabilityController } from './check-availability.controller';
|
||||||
import { CheckAvailabilityService } from './check-availability.service';
|
import { CheckAvailabilityService } from './check-availability.service';
|
||||||
import { CustomerResetController } from './customer-reset.controller';
|
import { CustomerResetController } from './customer-reset.controller';
|
||||||
@@ -17,17 +21,25 @@ import { FreightMeService } from './freight-me.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]),
|
TypeOrmModule.forFeature([
|
||||||
|
User,
|
||||||
|
UserVerification,
|
||||||
|
ExternalProfile,
|
||||||
|
Session,
|
||||||
|
Employee,
|
||||||
|
]),
|
||||||
OtpModule,
|
OtpModule,
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
FreightMeController,
|
FreightMeController,
|
||||||
|
AccountController,
|
||||||
CheckAvailabilityController,
|
CheckAvailabilityController,
|
||||||
ForgotPasswordController,
|
ForgotPasswordController,
|
||||||
CustomerResetController,
|
CustomerResetController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
FreightMeService,
|
FreightMeService,
|
||||||
|
AccountService,
|
||||||
CheckAvailabilityService,
|
CheckAvailabilityService,
|
||||||
ForgotPasswordService,
|
ForgotPasswordService,
|
||||||
CustomerResetService,
|
CustomerResetService,
|
||||||
|
|||||||
16
apps/edr-freight-api/src/modules/auth/mask-target.util.ts
Normal file
16
apps/edr-freight-api/src/modules/auth/mask-target.util.ts
Normal 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)}`;
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import {
|
import {
|
||||||
NotificationAudience,
|
NotificationAudience,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
@@ -8,6 +10,7 @@ import {
|
|||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.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
|
* Customer + staff notifications for the booking lifecycle: review, clearance
|
||||||
@@ -27,6 +30,8 @@ export class BookingLifecycleNotifierService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
private readonly inbox: NotificationInboxService,
|
private readonly inbox: NotificationInboxService,
|
||||||
|
@InjectDataSource()
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private ref(b: Booking): string {
|
private ref(b: Booking): string {
|
||||||
@@ -40,7 +45,9 @@ export class BookingLifecycleNotifierService {
|
|||||||
logLabel: string,
|
logLabel: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.log(`${logLabel} — ${this.ref(b)}`);
|
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;
|
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
|
||||||
|
|
||||||
if (phone) {
|
if (phone) {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { InjectDataSource } from "@nestjs/typeorm";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
import {
|
import {
|
||||||
NotificationAudience,
|
NotificationAudience,
|
||||||
NotificationPriority,
|
NotificationPriority,
|
||||||
@@ -8,6 +10,7 @@ import {
|
|||||||
import { Company, CompanyStatus } from "./entities/company.entity";
|
import { Company, CompanyStatus } from "./entities/company.entity";
|
||||||
import { NotificationsService } from "../notifications/notifications.service";
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.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. */
|
/** Account statuses that lock the customer out and therefore must be told to them. */
|
||||||
const PUNITIVE_STATUSES: readonly CompanyStatus[] = [
|
const PUNITIVE_STATUSES: readonly CompanyStatus[] = [
|
||||||
@@ -28,11 +31,13 @@ export class CompanyNotifierService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
private readonly inbox: NotificationInboxService,
|
private readonly inbox: NotificationInboxService,
|
||||||
|
@InjectDataSource()
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Send SMS + email to the company contact; log-only on failure. */
|
/** Send SMS + email to the company contact; log-only on failure. */
|
||||||
private async notifyContact(company: Company, message: string): Promise<void> {
|
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;
|
const email = company.email ?? company.generalManagerEmail ?? null;
|
||||||
|
|
||||||
if (phone) {
|
if (phone) {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import {
|
import {
|
||||||
NotificationAudience,
|
NotificationAudience,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
@@ -8,6 +10,7 @@ import {
|
|||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.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
|
* Customer + staff notifications for the contract lifecycle. Every customer
|
||||||
@@ -24,6 +27,8 @@ export class ContractNotifierService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
private readonly inbox: NotificationInboxService,
|
private readonly inbox: NotificationInboxService,
|
||||||
|
@InjectDataSource()
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private ref(c: Contract): string {
|
private ref(c: Contract): string {
|
||||||
@@ -37,7 +42,9 @@ export class ContractNotifierService {
|
|||||||
logLabel: string,
|
logLabel: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.log(`${logLabel} — ${this.ref(c)}`);
|
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;
|
const email = c.company?.email ?? c.company?.generalManagerEmail ?? null;
|
||||||
|
|
||||||
if (phone) {
|
if (phone) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||||
@@ -102,8 +104,41 @@ export class ContractTransitionService {
|
|||||||
private readonly notifier: ContractNotifierService,
|
private readonly notifier: ContractNotifierService,
|
||||||
private readonly contractTemplates: ContractTemplatesService,
|
private readonly contractTemplates: ContractTemplatesService,
|
||||||
private readonly clearanceFeeService: ClearanceFeeService,
|
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. */
|
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||||
async submit(contractId: string): Promise<Contract> {
|
async submit(contractId: string): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
@@ -796,10 +831,10 @@ export class ContractTransitionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send the sudo-mode signing OTP to the CONTRACT COMPANY's registered phone —
|
* Send the sudo-mode signing OTP to the SIGNER's own registered phone — the
|
||||||
* the same number {@link sign} verifies against. The client never picks the
|
* same number {@link sign} verifies against. The client never picks the number
|
||||||
* number (that is the H12(b) trust property): it only asks us to send, and we
|
* (that is the H12(b) trust property): it only asks us to send, and we resolve
|
||||||
* resolve the phone from the contract. Returns a masked hint so the UI can
|
* 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.
|
* say where the code went without exposing the full number.
|
||||||
*/
|
*/
|
||||||
async sendSigningOtp(
|
async sendSigningOtp(
|
||||||
@@ -815,14 +850,9 @@ export class ContractTransitionService {
|
|||||||
);
|
);
|
||||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||||
|
|
||||||
const companyPhone = contract.company?.phone?.trim();
|
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
|
||||||
if (!companyPhone) {
|
await this.otpService.sendOtp({ phone: signerPhone });
|
||||||
throw new BadRequestException(
|
return { sentTo: maskPhone(signerPhone) };
|
||||||
'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) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
|
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
|
||||||
@@ -848,20 +878,18 @@ export class ContractTransitionService {
|
|||||||
throw new BadRequestException('Customer has already signed this contract');
|
throw new BadRequestException('Customer has already signed this contract');
|
||||||
}
|
}
|
||||||
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
||||||
// signature is applied. H12(b): verify against the CONTRACT COMPANY's
|
// signature is applied. H12(b): verify against the SIGNER's own registered
|
||||||
// registered phone — never the caller-supplied dto.otpPhone, which an
|
// phone, resolved server-side from the authenticated user id — never a
|
||||||
// attacker could point at their own phone to sign someone else's
|
// caller-supplied number, which an attacker could point at their own
|
||||||
// contract. The OTP is issued to the company's registered number.
|
// phone. Ownership is already asserted above, so this proves the specific
|
||||||
const companyPhone = contract.company?.phone?.trim();
|
// person holding the account is present, not merely that someone reached a
|
||||||
if (!companyPhone) {
|
// shared company line. Must resolve identically to sendSigningOtp, or send
|
||||||
throw new BadRequestException(
|
// and verify would target different numbers.
|
||||||
'The contract company has no registered phone on file to verify the signing OTP against',
|
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!dto.otp) {
|
if (!dto.otp) {
|
||||||
throw new BadRequestException('OTP verification is required to sign the contract');
|
throw new BadRequestException('OTP verification is required to sign the contract');
|
||||||
}
|
}
|
||||||
await this.otpService.verifyOtpForAction({ phone: companyPhone }, dto.otp);
|
await this.otpService.verifyOtpForAction({ phone: signerPhone }, dto.otp);
|
||||||
await this.applySignature(contract, dto, options);
|
await this.applySignature(contract, dto, options);
|
||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'SIGNED_CUSTOMER',
|
status: 'SIGNED_CUSTOMER',
|
||||||
|
|||||||
@@ -28,17 +28,13 @@ export class SignContractDto {
|
|||||||
consentText?: string;
|
consentText?: string;
|
||||||
|
|
||||||
// Sudo-mode OTP challenge. Required when role=CUSTOMER: a fresh 6-digit code
|
// 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
|
// SMS'd to the signer's registered phone, verified server-side before the
|
||||||
// applied. `otpPhone` is the number the code was sent to (the signed-in
|
// signature is applied. The number itself is deliberately NOT part of this
|
||||||
// customer's registered phone).
|
// 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' })
|
@ApiPropertyOptional({ description: '6-digit OTP; required when role=CUSTOMER' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^\d{6}$/, { message: 'otp must be 6 digits' })
|
@Matches(/^\d{6}$/, { message: 'otp must be 6 digits' })
|
||||||
otp?: string;
|
otp?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Phone the OTP was sent to; required when role=CUSTOMER' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
otpPhone?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import { NotificationsService } from './notifications.service';
|
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
|
* Best-effort SMS + email fan-out to a company's contacts. Looks up the
|
||||||
@@ -15,9 +19,10 @@ export async function sendCompanyChannels(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const [contact]: Array<{ phone: string | null; email: string | null }> =
|
const [contact]: Array<{ phone: string | null; email: string | null }> =
|
||||||
await dataSource.query(
|
await dataSource.query(
|
||||||
`SELECT COALESCE(phone, etrade_phone) AS phone, email
|
`SELECT ${companyNotifyPhoneExpr('co')} AS phone, co.email
|
||||||
FROM freight.companies
|
FROM freight.companies co
|
||||||
WHERE id = $1 AND deleted_at IS NULL`,
|
${primaryContactUserJoin('co')}
|
||||||
|
WHERE co.id = $1 AND co.deleted_at IS NULL`,
|
||||||
[companyId],
|
[companyId],
|
||||||
);
|
);
|
||||||
if (contact?.phone) {
|
if (contact?.phone) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import {
|
import {
|
||||||
NotificationAudience,
|
NotificationAudience,
|
||||||
NotificationPriority,
|
NotificationPriority,
|
||||||
@@ -9,6 +11,7 @@ import {
|
|||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.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 { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||||
|
|
||||||
@@ -20,6 +23,8 @@ export class BookingNotifierService {
|
|||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
private readonly inbox: NotificationInboxService,
|
private readonly inbox: NotificationInboxService,
|
||||||
private readonly trainSchedules: TrainSchedulesRepository,
|
private readonly trainSchedules: TrainSchedulesRepository,
|
||||||
|
@InjectDataSource()
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,7 +65,9 @@ export class BookingNotifierService {
|
|||||||
logLabel: string,
|
logLabel: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.log(`${logLabel} — ${this.ref(b)}`);
|
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;
|
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
|
||||||
|
|
||||||
if (phone) {
|
if (phone) {
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity
|
|||||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.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 { BookingBatchService } from './booking-batch.service';
|
||||||
import { BookingWindowGateway } from './booking-window.gateway';
|
import { BookingWindowGateway } from './booking-window.gateway';
|
||||||
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
|
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
|
||||||
@@ -527,7 +531,7 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
}> = await this.dataSource.query(
|
}> = await this.dataSource.query(
|
||||||
`SELECT DISTINCT
|
`SELECT DISTINCT
|
||||||
c.company_id,
|
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
|
COALESCE(co.email, co.general_manager_email) AS email
|
||||||
FROM freight.contract_routes cr
|
FROM freight.contract_routes cr
|
||||||
JOIN freight.contracts c
|
JOIN freight.contracts c
|
||||||
@@ -535,6 +539,7 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
|
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
|
||||||
AND c.deleted_at IS NULL
|
AND c.deleted_at IS NULL
|
||||||
JOIN freight.companies co ON co.id = c.company_id
|
JOIN freight.companies co ON co.id = c.company_id
|
||||||
|
${primaryContactUserJoin('co')}
|
||||||
WHERE cr.origin_yard_id = $1
|
WHERE cr.origin_yard_id = $1
|
||||||
AND cr.destination_yard_id = $2
|
AND cr.destination_yard_id = $2
|
||||||
AND cr.deleted_at IS NULL`,
|
AND cr.deleted_at IS NULL`,
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import type { InterchangeDocument } from '../interchange-documents/entities/inte
|
|||||||
import { LastMileService } from '../last-mile/last-mile.service';
|
import { LastMileService } from '../last-mile/last-mile.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||||
|
import {
|
||||||
|
companyNotifyPhoneExpr,
|
||||||
|
primaryContactUserJoin,
|
||||||
|
} from '../notifications/resolve-company-phone.util';
|
||||||
import { SignaturesService } from '../signatures/signatures.service';
|
import { SignaturesService } from '../signatures/signatures.service';
|
||||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||||
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
||||||
@@ -1145,7 +1149,7 @@ export class WarehouseInventoryService {
|
|||||||
b.company_id AS "customerId",
|
b.company_id AS "customerId",
|
||||||
company.name AS "customer",
|
company.name AS "customer",
|
||||||
company.tin AS "customerTin",
|
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",
|
COALESCE(bcu.unit_numbers, bc.container_numbers) AS "containerNumber",
|
||||||
bcu.seal_numbers AS "sealNumbers",
|
bcu.seal_numbers AS "sealNumbers",
|
||||||
bc.container_quantity AS "containerQuantity",
|
bc.container_quantity AS "containerQuantity",
|
||||||
@@ -1183,6 +1187,7 @@ export class WarehouseInventoryService {
|
|||||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
|
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
|
||||||
FROM freight.bookings b
|
FROM freight.bookings b
|
||||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
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 oy ON oy.id = b.origin_yard_id
|
||||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_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
|
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",
|
b.cargo_total_weight_vgm AS "weight",
|
||||||
company.name AS "customer",
|
company.name AS "customer",
|
||||||
company.tin AS "customerTin",
|
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_numbers AS "containerNumber",
|
||||||
bc.container_quantity AS "containerQuantity",
|
bc.container_quantity AS "containerQuantity",
|
||||||
bc.container_packaging_type AS "containerPackagingType",
|
bc.container_packaging_type AS "containerPackagingType",
|
||||||
@@ -1317,6 +1322,7 @@ export class WarehouseInventoryService {
|
|||||||
OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile"
|
OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile"
|
||||||
FROM freight.bookings b
|
FROM freight.bookings b
|
||||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
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 oy ON oy.id = b.origin_yard_id
|
||||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_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
|
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||||
@@ -4765,7 +4771,7 @@ export class WarehouseInventoryService {
|
|||||||
`SELECT b.reference AS "reference",
|
`SELECT b.reference AS "reference",
|
||||||
company.name AS "customer",
|
company.name AS "customer",
|
||||||
company.tin AS "customerTin",
|
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",
|
b.cargo_total_weight_vgm AS "weight",
|
||||||
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
|
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
|
||||||
bc.container_numbers AS "containerNumber",
|
bc.container_numbers AS "containerNumber",
|
||||||
@@ -4782,6 +4788,7 @@ export class WarehouseInventoryService {
|
|||||||
v.vehicle_type AS "firstMileTruckType"
|
v.vehicle_type AS "firstMileTruckType"
|
||||||
FROM freight.bookings b
|
FROM freight.bookings b
|
||||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
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 freight.cargo_types cargo_type ON cargo_type.id = b.cargo_type_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ import {
|
|||||||
InvoiceDocumentService,
|
InvoiceDocumentService,
|
||||||
} from "../billing/documents/invoice-document.service";
|
} from "../billing/documents/invoice-document.service";
|
||||||
import { NotificationsService } from "../notifications/notifications.service";
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
|
import {
|
||||||
|
companyNotifyPhoneExpr,
|
||||||
|
primaryContactUserJoin,
|
||||||
|
} from "../notifications/resolve-company-phone.util";
|
||||||
import { WarehouseFeeService } from "./warehouse-fee.service";
|
import { WarehouseFeeService } from "./warehouse-fee.service";
|
||||||
import {
|
import {
|
||||||
WarehouseFeeInvoiceView,
|
WarehouseFeeInvoiceView,
|
||||||
@@ -879,7 +883,7 @@ export class WarehouseInvoiceService {
|
|||||||
const [row] = await this.dataSource.query(
|
const [row] = await this.dataSource.query(
|
||||||
`SELECT b.reference AS "bookingReference",
|
`SELECT b.reference AS "bookingReference",
|
||||||
company.name AS "customerName",
|
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(
|
COALESCE(
|
||||||
NULLIF(TRIM(CONCAT(COALESCE(last_driver.first_name, ''), ' ', COALESCE(last_driver.last_name, ''))), ''),
|
NULLIF(TRIM(CONCAT(COALESCE(last_driver.first_name, ''), ' ', COALESCE(last_driver.last_name, ''))), ''),
|
||||||
last_vehicle.assigned_driver_name,
|
last_vehicle.assigned_driver_name,
|
||||||
@@ -892,6 +896,7 @@ export class WarehouseInvoiceService {
|
|||||||
FROM freight.warehouse_inventory inv
|
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.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
|
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.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
|
||||||
LEFT JOIN freight.booking_container booking_container ON (
|
LEFT JOIN freight.booking_container booking_container ON (
|
||||||
booking_container.booking_id = b.id
|
booking_container.booking_id = b.id
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ export const URL_CONSTANTS = {
|
|||||||
CHECK_AVAILABILITY: "/api/auth/check-availability",
|
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: {
|
OTP: {
|
||||||
SEND: "/api/otp/send",
|
SEND: "/api/otp/send",
|
||||||
VERIFY: "/api/otp/verify",
|
VERIFY: "/api/otp/verify",
|
||||||
|
|||||||
@@ -29,17 +29,20 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
User,
|
User,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
|
UserCog,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import useAuth from "@/hooks/useAuth";
|
||||||
import { rolesForCompanyType } from "./settings/companyRoles";
|
import { rolesForCompanyType } from "./settings/companyRoles";
|
||||||
|
import TabAccount from "./settings/TabAccount";
|
||||||
import TabCompanyProfile from "./settings/TabCompanyProfile";
|
import TabCompanyProfile from "./settings/TabCompanyProfile";
|
||||||
import TabContactPerson from "./settings/TabContactPerson";
|
import TabContactPerson from "./settings/TabContactPerson";
|
||||||
import TabDocuments from "./settings/TabDocuments";
|
import TabDocuments from "./settings/TabDocuments";
|
||||||
import TabGeneralManager from "./settings/TabGeneralManager";
|
import TabGeneralManager from "./settings/TabGeneralManager";
|
||||||
import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
|
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. */
|
/** A section is "incomplete" when its required fields aren't filled in yet. */
|
||||||
function tabIncomplete(
|
function tabIncomplete(
|
||||||
@@ -62,6 +65,9 @@ function tabIncomplete(
|
|||||||
!profile.generalManagerEmail ||
|
!profile.generalManagerEmail ||
|
||||||
!profile.generalManagerPhone
|
!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 "poa":
|
||||||
case "documents":
|
case "documents":
|
||||||
return false;
|
return false;
|
||||||
@@ -69,6 +75,7 @@ function tabIncomplete(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
|
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: "company", label: "Company", icon: <Building2 size={16} /> },
|
||||||
{ id: "contact", label: "Contact Person", icon: <User size={16} /> },
|
{ id: "contact", label: "Contact Person", icon: <User size={16} /> },
|
||||||
{ id: "gm", label: "General Manager", icon: <Briefcase size={16} /> },
|
{ id: "gm", label: "General Manager", icon: <Briefcase size={16} /> },
|
||||||
@@ -175,6 +182,7 @@ function ProfileHeader({ profile }: { profile: ProfileResponse }) {
|
|||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { user } = useAuth();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const tab = (searchParams.get("tab") as SettingsTab) || "company";
|
const tab = (searchParams.get("tab") as SettingsTab) || "company";
|
||||||
|
|
||||||
@@ -313,6 +321,21 @@ export default function SettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</Tabs.List>
|
</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
|
{/* While a change request is pending, every panel's inputs + submit
|
||||||
buttons are disabled via the native fieldset; tab switching stays
|
buttons are disabled via the native fieldset; tab switching stays
|
||||||
enabled so the customer can still review what they submitted. */}
|
enabled so the customer can still review what they submitted. */}
|
||||||
|
|||||||
@@ -56,11 +56,10 @@ export default function ContractViewPage() {
|
|||||||
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
|
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
|
||||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||||
|
|
||||||
// The signing OTP goes to the CONTRACT COMPANY's registered phone (the number
|
// The signing OTP goes to the signed-in user's own registered phone, resolved
|
||||||
// the server verifies against), NOT the signed-in user's — those can differ,
|
// server-side from their account (the same number the server verifies
|
||||||
// and sending to the user's phone left the code filed under a number verify
|
// against). The client never picks the number, so send and verify can't
|
||||||
// never checks. The server owns the number; we only get back a masked hint of
|
// disagree; we only get back a masked hint of where it landed.
|
||||||
// where it landed.
|
|
||||||
const [otpSentTo, setOtpSentTo] = useState<string | null>(null);
|
const [otpSentTo, setOtpSentTo] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch } = useQuery({
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
|||||||
374
apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx
Normal file
374
apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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 & Save
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -81,6 +81,11 @@ import type {
|
|||||||
ForgotPasswordRequestPayload,
|
ForgotPasswordRequestPayload,
|
||||||
ForgotPasswordVerifyPayload,
|
ForgotPasswordVerifyPayload,
|
||||||
ResetTicket,
|
ResetTicket,
|
||||||
|
SendContactOtpPayload,
|
||||||
|
SendContactOtpResponse,
|
||||||
|
UpdateAccountNamePayload,
|
||||||
|
UpdateContactPayload,
|
||||||
|
UpdateContactResponse,
|
||||||
} from "@/types/auth";
|
} from "@/types/auth";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -147,6 +152,26 @@ export const api = {
|
|||||||
logout: endpoint<void, void>("auth", "logout", authService.logout),
|
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: {
|
companies: {
|
||||||
getInfo: endpoint<void, CompanyInfoResponse | null>(
|
getInfo: endpoint<void, CompanyInfoResponse | null>(
|
||||||
"companies",
|
"companies",
|
||||||
|
|||||||
@@ -11,11 +11,17 @@ import type {
|
|||||||
OtpPayload,
|
OtpPayload,
|
||||||
OtpResponse,
|
OtpResponse,
|
||||||
ResetTicket,
|
ResetTicket,
|
||||||
|
SendContactOtpPayload,
|
||||||
|
SendContactOtpResponse,
|
||||||
SetPasswordPayload,
|
SetPasswordPayload,
|
||||||
SignupPayload,
|
SignupPayload,
|
||||||
SignupResponse,
|
SignupResponse,
|
||||||
|
UpdateAccountNamePayload,
|
||||||
|
UpdateContactPayload,
|
||||||
|
UpdateContactResponse,
|
||||||
} from "@/types/auth";
|
} from "@/types/auth";
|
||||||
import { client } from "@/utils/api";
|
import { client } from "@/utils/api";
|
||||||
|
import { unwrap } from "@/utils/endpoint";
|
||||||
import { ApiResponse } from "@edr/types";
|
import { ApiResponse } from "@edr/types";
|
||||||
|
|
||||||
export const authService = {
|
export const authService = {
|
||||||
@@ -105,6 +111,35 @@ export const authService = {
|
|||||||
return res.data.data;
|
return res.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 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 () => {
|
refreshToken: async () => {
|
||||||
const refreshTokenCookie = document.cookie
|
const refreshTokenCookie = document.cookie
|
||||||
.split("; ")
|
.split("; ")
|
||||||
|
|||||||
@@ -128,8 +128,6 @@ export interface SignContractPayload {
|
|||||||
consentText?: string;
|
consentText?: string;
|
||||||
/** Sudo-mode OTP challenge; required when role=CUSTOMER. */
|
/** Sudo-mode OTP challenge; required when role=CUSTOMER. */
|
||||||
otp?: string;
|
otp?: string;
|
||||||
/** Phone the OTP was sent to; required when role=CUSTOMER. */
|
|
||||||
otpPhone?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApproveDeliveryResponse {
|
export interface ApproveDeliveryResponse {
|
||||||
|
|||||||
@@ -268,9 +268,10 @@ export const contractsService = {
|
|||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Ask the server to send the signing OTP to the CONTRACT COMPANY's registered
|
// Ask the server to send the signing OTP to the signer's own registered phone.
|
||||||
// phone. The client never picks the number (the server verifies against the
|
// The client never picks the number (the server resolves it from the
|
||||||
// same one), so send and verify can't disagree. Returns a masked hint.
|
// 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 }> => {
|
sendSigningOtp: async (id: string): Promise<{ sentTo: string }> => {
|
||||||
const { data } = await client.post(C.CONTRACT_SEND_SIGNING_OTP(id));
|
const { data } = await client.post(C.CONTRACT_SEND_SIGNING_OTP(id));
|
||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
|
|||||||
@@ -45,6 +45,34 @@ export interface OtpResponse {
|
|||||||
message: string;
|
message: 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 {
|
export interface CheckAvailabilityPayload {
|
||||||
email?: string;
|
email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user