mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 07:22:53 +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:
@@ -1,4 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
@@ -8,6 +10,7 @@ import {
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
|
||||
|
||||
/**
|
||||
* Customer + staff notifications for the contract lifecycle. Every customer
|
||||
@@ -24,6 +27,8 @@ export class ContractNotifierService {
|
||||
constructor(
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
private ref(c: Contract): string {
|
||||
@@ -37,7 +42,9 @@ export class ContractNotifierService {
|
||||
logLabel: string,
|
||||
): Promise<void> {
|
||||
this.logger.log(`${logLabel} — ${this.ref(c)}`);
|
||||
const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null;
|
||||
const phone = c.companyId
|
||||
? await resolveCompanyNotifyPhone(this.dataSource, c.companyId)
|
||||
: null;
|
||||
const email = c.company?.email ?? c.company?.generalManagerEmail ?? null;
|
||||
|
||||
if (phone) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Readable } from 'stream';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
@@ -102,8 +104,41 @@ export class ContractTransitionService {
|
||||
private readonly notifier: ContractNotifierService,
|
||||
private readonly contractTemplates: ContractTemplatesService,
|
||||
private readonly clearanceFeeService: ClearanceFeeService,
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The phone the signing OTP is sent to and verified against: the signer's own
|
||||
* IAM account number.
|
||||
*
|
||||
* H12(b): resolved server-side from the authenticated user id, never from the
|
||||
* request body — a caller-supplied number would let an attacker point the code
|
||||
* at their own phone. Ownership is already gated separately by
|
||||
* {@link ContractsService.assertCustomerCanAccessContract}, so this binds the
|
||||
* signature to the *person* signing rather than to a company landline that may
|
||||
* be shared, stale, or imported from eTrade.
|
||||
*/
|
||||
private async resolveSignerPhone(signerUserId?: string): Promise<string> {
|
||||
if (!signerUserId) {
|
||||
// Unreachable in practice (the ownership gate rejects a missing user
|
||||
// first), but never fall back to another number if it ever changes.
|
||||
throw new BadRequestException('Authentication required to sign');
|
||||
}
|
||||
const rows: Array<{ phone_number: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT phone_number FROM iam.users WHERE id = $1 AND is_active = true`,
|
||||
[signerUserId],
|
||||
);
|
||||
const phone = rows[0]?.phone_number?.trim();
|
||||
if (!phone) {
|
||||
throw new BadRequestException(
|
||||
'Your account has no registered phone number. Add one in Settings → Account before signing.',
|
||||
);
|
||||
}
|
||||
return phone;
|
||||
}
|
||||
|
||||
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||
async submit(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
@@ -796,10 +831,10 @@ export class ContractTransitionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the sudo-mode signing OTP to the CONTRACT COMPANY's registered phone —
|
||||
* the same number {@link sign} verifies against. The client never picks the
|
||||
* number (that is the H12(b) trust property): it only asks us to send, and we
|
||||
* resolve the phone from the contract. Returns a masked hint so the UI can
|
||||
* Send the sudo-mode signing OTP to the SIGNER's own registered phone — the
|
||||
* same number {@link sign} verifies against. The client never picks the number
|
||||
* (that is the H12(b) trust property): it only asks us to send, and we resolve
|
||||
* the phone from the authenticated user id. Returns a masked hint so the UI can
|
||||
* say where the code went without exposing the full number.
|
||||
*/
|
||||
async sendSigningOtp(
|
||||
@@ -815,14 +850,9 @@ export class ContractTransitionService {
|
||||
);
|
||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||
|
||||
const companyPhone = contract.company?.phone?.trim();
|
||||
if (!companyPhone) {
|
||||
throw new BadRequestException(
|
||||
'The contract company has no registered phone on file to send the signing OTP to',
|
||||
);
|
||||
}
|
||||
await this.otpService.sendOtp({ phone: companyPhone });
|
||||
return { sentTo: maskPhone(companyPhone) };
|
||||
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
|
||||
await this.otpService.sendOtp({ phone: signerPhone });
|
||||
return { sentTo: maskPhone(signerPhone) };
|
||||
}
|
||||
|
||||
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
|
||||
@@ -848,20 +878,18 @@ export class ContractTransitionService {
|
||||
throw new BadRequestException('Customer has already signed this contract');
|
||||
}
|
||||
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
||||
// signature is applied. H12(b): verify against the CONTRACT COMPANY's
|
||||
// registered phone — never the caller-supplied dto.otpPhone, which an
|
||||
// attacker could point at their own phone to sign someone else's
|
||||
// contract. The OTP is issued to the company's registered number.
|
||||
const companyPhone = contract.company?.phone?.trim();
|
||||
if (!companyPhone) {
|
||||
throw new BadRequestException(
|
||||
'The contract company has no registered phone on file to verify the signing OTP against',
|
||||
);
|
||||
}
|
||||
// signature is applied. H12(b): verify against the SIGNER's own registered
|
||||
// phone, resolved server-side from the authenticated user id — never a
|
||||
// caller-supplied number, which an attacker could point at their own
|
||||
// phone. Ownership is already asserted above, so this proves the specific
|
||||
// person holding the account is present, not merely that someone reached a
|
||||
// shared company line. Must resolve identically to sendSigningOtp, or send
|
||||
// and verify would target different numbers.
|
||||
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
|
||||
if (!dto.otp) {
|
||||
throw new BadRequestException('OTP verification is required to sign the contract');
|
||||
}
|
||||
await this.otpService.verifyOtpForAction({ phone: companyPhone }, dto.otp);
|
||||
await this.otpService.verifyOtpForAction({ phone: signerPhone }, dto.otp);
|
||||
await this.applySignature(contract, dto, options);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SIGNED_CUSTOMER',
|
||||
|
||||
@@ -28,17 +28,13 @@ export class SignContractDto {
|
||||
consentText?: string;
|
||||
|
||||
// Sudo-mode OTP challenge. Required when role=CUSTOMER: a fresh 6-digit code
|
||||
// SMS'd to the signer's phone, verified server-side before the signature is
|
||||
// applied. `otpPhone` is the number the code was sent to (the signed-in
|
||||
// customer's registered phone).
|
||||
// SMS'd to the signer's registered phone, verified server-side before the
|
||||
// signature is applied. The number itself is deliberately NOT part of this
|
||||
// DTO — the server resolves it from the authenticated user id, so a caller
|
||||
// cannot redirect the challenge to a phone they control.
|
||||
@ApiPropertyOptional({ description: '6-digit OTP; required when role=CUSTOMER' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^\d{6}$/, { message: 'otp must be 6 digits' })
|
||||
otp?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Phone the OTP was sent to; required when role=CUSTOMER' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
otpPhone?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user