mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: ( iam ) OTP-gate registration via IAM signup + set-password
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
|
||||
216
apps/edr-passenger-web/portal/src/app/verify-account/page.tsx
Normal file
216
apps/edr-passenger-web/portal/src/app/verify-account/page.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user