feat: ( iam ) OTP-gate registration via IAM signup + set-password

This commit is contained in:
Abubeker Yasin
2026-07-04 10:10:26 +03:00
parent 077667610a
commit 1c625cfb82
8 changed files with 370 additions and 84 deletions

View File

@@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nes
import { Throttle, SkipThrottle } from '@nestjs/throttler';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { PassengerAuthService } from './passenger-auth.service';
import { RegisterDto, LoginDto, FaydaRequestPasswordSetupDto, FaydaVerifyAndLoginDto } from './auth.dto';
import { RegisterDto, LoginDto, ResendRegistrationCodeDto, FaydaRequestPasswordSetupDto, FaydaVerifyAndLoginDto } from './auth.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Passenger Auth')
@@ -14,14 +14,28 @@ export class AuthController {
@Post('register')
@IsPublic()
@ApiOperation({ summary: 'Register new passenger account' })
@ApiResponse({ status: 201, description: 'Account created. Returns token + user.' })
@ApiOperation({ summary: 'Register new passenger account (sends SMS verification code)' })
@ApiResponse({
status: 201,
description:
'Account created as pending. A verification code is sent via SMS — complete signup via PATCH /v1/auth/set-password.',
})
@ApiResponse({ status: 409, description: 'Email or phone already registered' })
@ApiBody({ type: RegisterDto })
register(@Request() req: any, @Body() dto: RegisterDto) {
return this.passengerAuthService.register(dto, req);
}
@Post('register/resend-code')
@IsPublic()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Resend the registration verification code for a pending account' })
@ApiResponse({ status: 200, description: 'Verification code re-sent if the account is pending.' })
@ApiBody({ type: ResendRegistrationCodeDto })
resendRegistrationCode(@Request() req: any, @Body() dto: ResendRegistrationCodeDto) {
return this.passengerAuthService.resendRegistrationCode(dto, req);
}
@Post('login')
@IsPublic()
@HttpCode(HttpStatus.OK)

View File

@@ -1,6 +1,6 @@
import { IsEmail, IsString, MinLength, ValidateNested } from 'class-validator';
import { IsEmail, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiProperty } from '@nestjs/swagger';
export class NameDto {
@ApiProperty({ example: 'ቀለሙ ቀጸላ' })
@@ -29,15 +29,16 @@ export class RegisterDto {
@ValidateNested()
@Type(() => NameDto)
name: NameDto;
}
@ApiProperty({ example: 'SecurePass123', minLength: 8, format: 'password' })
@IsString()
@MinLength(8)
password: string;
export class ResendRegistrationCodeDto {
@ApiProperty({ example: 'kelemu@email.com' })
@IsEmail()
email: string;
@ApiProperty({ example: 'SecurePass123', format: 'password' })
@ApiProperty({ example: '+251912345678' })
@IsString()
confirmPassword: string;
phoneNumber: string;
}
export class LoginDto {

View File

@@ -50,14 +50,17 @@ export class PassengerAuthService {
const iamAuthService = await this.resolveIamAuthService(req);
const { token, refreshToken } = await iamAuthService.signupWithPassword({
// IAM `signup` creates the user as PENDING/isActive=false with NO credential and
// SMS-sends a 6-digit verification code. The account cannot log in until the code is
// redeemed via PATCH /v1/auth/set-password. We intentionally discard the session
// token `signup` returns — the account is not verified yet, so it must never reach
// the client.
await iamAuthService.signup({
email: dto.email,
username: dto.username,
phoneNumber: dto.phoneNumber,
userType: EUserType.INDIVIDUAL,
name: dto.name,
password: dto.password,
confirmPassword: dto.confirmPassword,
});
const iamRows = await this.dataSource.query<IamUserRow[]>(
@@ -70,20 +73,98 @@ export class PassengerAuthService {
}
const iamUserId = iamRows[0].id;
let passengerId: string;
// The Prisma "passenger satellite" (Passenger + wallet + loyalty) is NOT provisioned
// here — `login()` lazy-provisions it on first successful login, so satellites exist
// only for verified users who complete set-password and sign in.
return {
iamUserId,
email: dto.email,
phoneNumber: dto.phoneNumber,
requiresPasswordSetup: true,
};
}
/**
* Immediate-activation account creation used by the payment-gated guest-checkout
* "create account" path only. Unlike the public `register()` (OTP-gated), this creates a
* ready-to-use account from the password entered at checkout and provisions the passenger
* satellite synchronously so the booking can attach to it. Do NOT wire this to the public
* registration form — that flow must stay behind SMS verification.
*/
async registerWithPassword(
dto: {
email: string;
username: string;
phoneNumber: string;
name: { en: string; am: string };
password: string;
},
req: any,
): Promise<{ iamUserId: string; passengerId: string }> {
const existing = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.users WHERE email = $1 OR phone_number = $2 LIMIT 1`,
[dto.email, dto.phoneNumber],
);
if (existing.length) throw new ConflictException('Email or phone already registered');
const iamAuthService = await this.resolveIamAuthService(req);
await iamAuthService.signupWithPassword({
email: dto.email,
username: dto.username,
phoneNumber: dto.phoneNumber,
userType: EUserType.INDIVIDUAL,
name: dto.name,
password: dto.password,
confirmPassword: dto.password,
});
const iamRows = await this.dataSource.query<IamUserRow[]>(
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 LIMIT 1`,
[dto.email],
);
if (!iamRows.length) {
await this.compensateIamSignup(dto.email);
throw new InternalServerErrorException('Account creation failed. Please try again.');
}
const iamUserId = iamRows[0].id;
try {
const result = await this.provisionPassengerSatellite({ iamUserId, auditAction: 'USER_REGISTERED' });
passengerId = result.passengerId;
return { iamUserId, passengerId: result.passengerId };
} catch {
await this.compensateIamSignup(dto.email);
throw new InternalServerErrorException('Account creation failed. Please try again.');
}
}
return {
token,
refreshToken,
user: { id: iamUserId, iamUserId, email: dto.email, fullName: dto.name.en, passengerId },
};
async resendRegistrationCode(
dto: { email: string; phoneNumber: string },
req: any,
): Promise<{ sent: boolean }> {
// Only regenerate for accounts still pending password setup. A fully-registered user
// should use forgot-password instead. Always return { sent: true } to avoid leaking
// whether the email/phone maps to a pending account (enumeration guard).
const users = await this.dataSource.query<{ email: string; phone_number: string }[]>(
`SELECT email, phone_number FROM iam.users
WHERE email = $1 AND phone_number = $2 AND has_set_password = false LIMIT 1`,
[dto.email, dto.phoneNumber],
);
if (!users.length) return { sent: true };
const iamAuthService = await this.resolveIamAuthService(req);
try {
await iamAuthService.generateVerificationCode({
email: users[0].email,
phoneNumber: users[0].phone_number,
type: EOtpType.VERIFY_PHONE_NUMBER,
});
} catch (err) {
this.logger.error(
`[PassengerAuthService] resend registration code failed for ${dto.email}`,
(err as Error).message,
);
}
return { sent: true };
}
async login(dto: LoginDto, req: any) {

View File

@@ -886,18 +886,17 @@ export class GuestBookingService {
): Promise<{ guestPassengerId: string; iamUserId: string | null; createdAccount: boolean }> {
if (dto.createAccount && firstPassenger.email && dto.password) {
const guestName = firstPassenger.passengerName ?? 'Guest';
const result = await this.passengerAuthService.register(
const result = await this.passengerAuthService.registerWithPassword(
{
email: firstPassenger.email,
username: firstPassenger.email,
phoneNumber: firstPassenger.phone || `+251900000000`,
name: { en: guestName, am: guestName },
password: dto.password,
confirmPassword: dto.password,
},
req,
);
return { guestPassengerId: result.user.passengerId, iamUserId: result.user.iamUserId, createdAccount: true };
return { guestPassengerId: result.passengerId, iamUserId: result.iamUserId, createdAccount: true };
}
// Create guest passenger with basic profile