mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 21:20:57 +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 { 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user