From 1c625cfb827aaf4d6894c50cc54a45935691e0a8 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Sat, 4 Jul 2026 10:10:26 +0300 Subject: [PATCH] feat: ( iam ) OTP-gate registration via IAM signup + set-password --- .../src/modules/auth/auth.controller.ts | 20 +- .../src/modules/auth/auth.dto.ts | 17 +- .../modules/auth/passenger-auth.service.ts | 101 +++++++- .../modules/bookings/guest-booking.service.ts | 5 +- .../portal/src/app/register/page.tsx | 60 ++--- .../portal/src/app/verify-account/page.tsx | 216 ++++++++++++++++++ .../portal/src/lib/api/auth.ts | 4 + .../portal/src/lib/auth-store.ts | 31 +-- 8 files changed, 370 insertions(+), 84 deletions(-) create mode 100644 apps/edr-passenger-web/portal/src/app/verify-account/page.tsx diff --git a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts index d53ef40d7..a6f596bf6 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts @@ -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) diff --git a/apps/edr-passenger-api/src/modules/auth/auth.dto.ts b/apps/edr-passenger-api/src/modules/auth/auth.dto.ts index 6f43e7212..d0a691b0f 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.dto.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.dto.ts @@ -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 { diff --git a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts index 0213bb8f6..1784261e5 100644 --- a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts +++ b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts @@ -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( @@ -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( + `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) { diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 6907d14a3..d2c65db42 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -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 diff --git a/apps/edr-passenger-web/portal/src/app/register/page.tsx b/apps/edr-passenger-web/portal/src/app/register/page.tsx index c335d9e98..a39810be1 100644 --- a/apps/edr-passenger-web/portal/src/app/register/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/register/page.tsx @@ -9,18 +9,11 @@ import { useAuthStore } from '@/lib/auth-store'; import { useState } from 'react'; import { Train, ShieldCheck } from 'lucide-react'; -const registerSchema = z - .object({ - fullName: z.string().min(2, 'Full name is required'), - email: z.string().email('Invalid email address'), - phone: z.string().min(9, 'Phone number is required'), - password: z.string().min(8, 'Password must be at least 8 characters'), - confirmPassword: z.string(), - }) - .refine((data) => data.password === data.confirmPassword, { - message: 'Passwords do not match', - path: ['confirmPassword'], - }); +const registerSchema = z.object({ + fullName: z.string().min(2, 'Full name is required'), + email: z.string().email('Invalid email address'), + phone: z.string().min(9, 'Phone number is required'), +}); type RegisterForm = z.infer; @@ -38,14 +31,17 @@ export default function RegisterPage() { setLoading(true); setError(''); try { - await registerUser({ + const result = await registerUser({ fullName: data.fullName, email: data.email, phone: data.phone, - password: data.password, - confirmPassword: data.confirmPassword, }); - router.push('/booking/search'); + const params = new URLSearchParams({ + email: result.email, + userId: result.iamUserId, + phone: result.phoneNumber, + }); + router.push(`/verify-account?${params.toString()}`); } catch (err: any) { if (err.response?.status === 409) { setError('An account with this email or phone number already exists.'); @@ -67,7 +63,7 @@ export default function RegisterPage() {

Create account

-

Book faster and manage your trips

+

We'll text you a code to verify your phone

@@ -120,36 +116,8 @@ export default function RegisterPage() { )}
-
- - - {errors.password && ( -

{errors.password.message}

- )} -
- -
- - - {errors.confirmPassword && ( -

{errors.confirmPassword.message}

- )} -
- diff --git a/apps/edr-passenger-web/portal/src/app/verify-account/page.tsx b/apps/edr-passenger-web/portal/src/app/verify-account/page.tsx new file mode 100644 index 000000000..f9f0b61c9 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/verify-account/page.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { Suspense, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { Train, ArrowLeft, ShieldCheck } from 'lucide-react'; +import { iamAuthApi } from '@/lib/api/auth'; +import { useAuthStore } from '@/lib/auth-store'; + +// Mirrors the IAM set-password requirement (class-validator @IsStrongPassword defaults): +// min length 8, with lower- and upper-case letters, a number, and a symbol. +function isStrongPassword(pw: string): boolean { + return ( + pw.length >= 8 && + /[a-z]/.test(pw) && + /[A-Z]/.test(pw) && + /[0-9]/.test(pw) && + /[^A-Za-z0-9]/.test(pw) + ); +} + +function VerifyAccountContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const login = useAuthStore((s) => s.login); + + const email = searchParams.get('email') || ''; + const userId = searchParams.get('userId') || ''; + const phone = searchParams.get('phone') || ''; + const linkValid = Boolean(email && userId); + + const [verificationCode, setVerificationCode] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [resending, setResending] = useState(false); + const [resent, setResent] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + if (!verificationCode.trim()) { + setError('Enter the verification code sent to your phone.'); + return; + } + if (!isStrongPassword(newPassword)) { + setError('Password must be at least 8 characters and include upper- and lower-case letters, a number, and a symbol.'); + return; + } + if (newPassword !== confirmPassword) { + setError('Passwords do not match.'); + return; + } + setLoading(true); + try { + // Completes signup: PATCH /v1/auth/set-password with the SMS code, which activates + // the account and sets the password. + await iamAuthApi.resetPassword({ + userId, + email, + verificationCode: verificationCode.trim(), + newPassword, + confirmPassword, + }); + // Auto-login with the freshly-set password; login lazy-provisions the passenger record. + await login(email, newPassword); + router.push('/booking/search'); + } catch (err: any) { + const msg = err.response?.data?.message || err.message || ''; + setError(msg || 'Could not verify your account. Check the code and try again, or resend it.'); + setLoading(false); + } + }; + + const handleResend = async () => { + setError(''); + setResent(false); + setResending(true); + try { + await iamAuthApi.resendRegistrationCode({ email, phoneNumber: phone }); + setResent(true); + } catch { + setError('Could not resend the code. Please try again in a moment.'); + } finally { + setResending(false); + } + }; + + return ( +
+
+
+
+
+ +
+
+

Verify your account

+ {linkValid && ( +

+ Enter the code we sent to your phone and choose a password for{' '} + {email}. +

+ )} +
+ +
+ {!linkValid ? ( +
+
+ This verification link is invalid or incomplete. Please start registration again. +
+ + Back to registration + +
+ ) : ( +
+ {error && ( +
+ {error} +
+ )} + {resent && !error && ( +
+ +

A new code has been sent to your phone.

+
+ )} + +
+ + { setVerificationCode(e.target.value); setError(''); }} + className="input-field tracking-widest" + placeholder="123456" + maxLength={6} + required + /> +
+ +
+ + { setNewPassword(e.target.value); setError(''); }} + className="input-field" + placeholder="••••••••" + autoComplete="new-password" + minLength={8} + required + /> +

+ At least 8 characters with upper & lower case, a number, and a symbol. +

+
+ +
+ + { setConfirmPassword(e.target.value); setError(''); }} + className="input-field" + placeholder="••••••••" + autoComplete="new-password" + minLength={8} + required + /> +
+ + + + + + + + Back to registration + + + )} +
+
+
+ ); +} + +export default function VerifyAccountPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/portal/src/lib/api/auth.ts b/apps/edr-passenger-web/portal/src/lib/api/auth.ts index aa5272173..14e9a16a1 100644 --- a/apps/edr-passenger-web/portal/src/lib/api/auth.ts +++ b/apps/edr-passenger-web/portal/src/lib/api/auth.ts @@ -11,6 +11,10 @@ export const iamAuthApi = { forgotPassword: (email: string) => axios.post(`${API_URL}/v1/auth/forgot-password`, { email }), + // Re-sends the registration verification code for a still-pending account. + resendRegistrationCode: (data: { email: string; phoneNumber: string }) => + axios.post(`${API_URL}/auth/register/resend-code`, data), + // Completes the forgot-password flow using the link sent via SMS: // ${FE_BASE_URL}/reset-password?email=..&userId=..&verificationCode=.. resetPassword: (data: { diff --git a/apps/edr-passenger-web/portal/src/lib/auth-store.ts b/apps/edr-passenger-web/portal/src/lib/auth-store.ts index 2157cfc0a..0d6e46fd9 100644 --- a/apps/edr-passenger-web/portal/src/lib/auth-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/auth-store.ts @@ -31,7 +31,7 @@ interface AuthState { isAuthenticated: boolean; isInitialized: boolean; login: (email: string, password: string) => Promise; - register: (data: RegisterData) => Promise; + register: (data: RegisterData) => Promise; logout: () => Promise; setUser: (user: User, token: string) => void; updateUser: (userData: Partial) => void; @@ -43,8 +43,12 @@ interface RegisterData { fullName: string; email: string; phone: string; - password: string; - confirmPassword: string; +} + +interface RegisterResult { + iamUserId: string; + email: string; + phoneNumber: string; } export const useAuthStore = create((set, get) => ({ @@ -118,25 +122,24 @@ export const useAuthStore = create((set, get) => ({ set({ user, token, isAuthenticated: true }); }, - register: async (data: RegisterData) => { + register: async (data: RegisterData): Promise => { // Shape required by the passenger-api RegisterDto; username = email by convention. + // Registration no longer takes a password — the account is created as pending and + // an SMS verification code is sent. The user completes signup on the verify-account + // page (set-password). No token is issued here; the user is NOT logged in yet. const payload = { email: data.email, username: data.email, phoneNumber: data.phone, name: { en: data.fullName, am: data.fullName }, - password: data.password, - confirmPassword: data.confirmPassword, }; const response: any = await apiClient.post('/auth/register', payload); - const { token, user } = response.data || response; - - if (typeof window !== 'undefined') { - localStorage.setItem('auth_token', token); - localStorage.setItem('auth_user', JSON.stringify(user)); - } - - set({ user, token, isAuthenticated: true }); + const result = response.data || response; + return { + iamUserId: result.iamUserId, + email: result.email, + phoneNumber: result.phoneNumber, + }; }, logout: async () => {