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

View File

@@ -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<typeof registerSchema>;
@@ -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() {
</div>
</div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Create account</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">Book faster and manage your trips</p>
<p className="text-gray-600 dark:text-gray-400 mt-2">We&apos;ll text you a code to verify your phone</p>
</div>
<div className="card">
@@ -120,36 +116,8 @@ export default function RegisterPage() {
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Password</label>
<input
type="password"
{...register('password')}
className="input-field"
placeholder="••••••••"
autoComplete="new-password"
/>
{errors.password && (
<p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Confirm password</label>
<input
type="password"
{...register('confirmPassword')}
className="input-field"
placeholder="••••••••"
autoComplete="new-password"
/>
{errors.confirmPassword && (
<p className="text-red-500 text-sm mt-1">{errors.confirmPassword.message}</p>
)}
</div>
<button type="submit" className="btn-primary w-full" disabled={loading}>
{loading ? 'Creating account...' : 'Create account'}
{loading ? 'Sending code...' : 'Send verification code'}
</button>
</form>

View File

@@ -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 (
<div className="min-h-screen bg-gradient-to-br from-[rgb(20_113_76)] from-10% via-transparent to-[rgb(20_113_76)] to-90% dark:from-gray-900 dark:to-gray-800 flex items-center justify-center py-12 px-4">
<div className="max-w-md w-full">
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="w-12 h-12 bg-[rgb(20_113_76)] rounded-lg flex items-center justify-center">
<Train className="w-6 h-6 text-white" />
</div>
</div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Verify your account</h1>
{linkValid && (
<p className="text-gray-600 dark:text-gray-400 mt-2">
Enter the code we sent to your phone and choose a password for{' '}
<span className="font-medium">{email}</span>.
</p>
)}
</div>
<div className="card">
{!linkValid ? (
<div className="space-y-4">
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded">
This verification link is invalid or incomplete. Please start registration again.
</div>
<Link href="/register" className="btn-primary w-full flex items-center justify-center">
Back to registration
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded">
{error}
</div>
)}
{resent && !error && (
<div className="flex items-start gap-3 bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300 px-4 py-3 rounded">
<ShieldCheck className="w-5 h-5 flex-shrink-0 mt-0.5" />
<p className="text-sm">A new code has been sent to your phone.</p>
</div>
)}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Verification code</label>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={verificationCode}
onChange={(e) => { setVerificationCode(e.target.value); setError(''); }}
className="input-field tracking-widest"
placeholder="123456"
maxLength={6}
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">New password</label>
<input
type="password"
value={newPassword}
onChange={(e) => { setNewPassword(e.target.value); setError(''); }}
className="input-field"
placeholder="••••••••"
autoComplete="new-password"
minLength={8}
required
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
At least 8 characters with upper &amp; lower case, a number, and a symbol.
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Confirm password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
className="input-field"
placeholder="••••••••"
autoComplete="new-password"
minLength={8}
required
/>
</div>
<button
type="submit"
className="btn-primary w-full"
disabled={loading || !verificationCode || !newPassword || !confirmPassword}
>
{loading ? 'Verifying...' : 'Verify and continue'}
</button>
<button
type="button"
onClick={handleResend}
disabled={resending}
className="w-full text-sm text-gray-600 dark:text-gray-400 hover:text-[rgb(20_113_76)] dark:hover:text-emerald-400 transition-colors disabled:opacity-50"
>
{resending ? 'Resending...' : "Didn't get a code? Resend"}
</button>
<Link
href="/register"
className="flex items-center justify-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-[rgb(20_113_76)] dark:hover:text-emerald-400 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to registration
</Link>
</form>
)}
</div>
</div>
</div>
);
}
export default function VerifyAccountPage() {
return (
<Suspense fallback={null}>
<VerifyAccountContent />
</Suspense>
);
}

View File

@@ -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: {

View File

@@ -31,7 +31,7 @@ interface AuthState {
isAuthenticated: boolean;
isInitialized: boolean;
login: (email: string, password: string) => Promise<void>;
register: (data: RegisterData) => Promise<void>;
register: (data: RegisterData) => Promise<RegisterResult>;
logout: () => Promise<void>;
setUser: (user: User, token: string) => void;
updateUser: (userData: Partial<User>) => 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<AuthState>((set, get) => ({
@@ -118,25 +122,24 @@ export const useAuthStore = create<AuthState>((set, get) => ({
set({ user, token, isAuthenticated: true });
},
register: async (data: RegisterData) => {
register: async (data: RegisterData): Promise<RegisterResult> => {
// 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 () => {