mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
feat( auth ): make email optional in signup/login
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { IsEmail, IsString, ValidateNested } from 'class-validator';
|
import { IsEmail, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
@@ -13,8 +13,13 @@ export class NameDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class RegisterDto {
|
export class RegisterDto {
|
||||||
@ApiProperty({ example: 'kelemu@email.com' })
|
// Accepts an email OR a phone number. When a passenger signs up without an email,
|
||||||
@IsEmail()
|
// the portal passes the phone number here (and as `username`) — the IAM only requires
|
||||||
|
// a non-empty string, so a phone value is a valid account identifier. Kept as
|
||||||
|
// @IsString/@IsNotEmpty (not @IsEmail) so that phone-as-email passes the ValidationPipe.
|
||||||
|
@ApiProperty({ example: 'kelemu@email.com', description: 'Email or, when the user has no email, their phone number' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'kelemu.ketsela' })
|
@ApiProperty({ example: 'kelemu.ketsela' })
|
||||||
@@ -42,8 +47,12 @@ export class ResendRegistrationCodeDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class LoginDto {
|
export class LoginDto {
|
||||||
@ApiProperty({ example: 'kelemu@email.com' })
|
// Accepts an email OR a phone number in the same field. Passengers who registered
|
||||||
@IsEmail()
|
// without an email log in with their phone number, which the IAM matches. Kept as
|
||||||
|
// @IsString/@IsNotEmpty (not @IsEmail) so a phone value passes the ValidationPipe.
|
||||||
|
@ApiProperty({ example: 'kelemu@email.com', description: 'Email or phone number' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'password123', format: 'password' })
|
@ApiProperty({ example: 'password123', format: 'password' })
|
||||||
|
|||||||
@@ -184,8 +184,11 @@ export class PassengerAuthService {
|
|||||||
|
|
||||||
const { token, refreshToken } = iamResult as { token: string; refreshToken: string };
|
const { token, refreshToken } = iamResult as { token: string; refreshToken: string };
|
||||||
|
|
||||||
|
// `dto.email` may hold an email OR a phone number (passengers without an email log in
|
||||||
|
// with their phone). Match on either so the post-auth lookup works regardless of which
|
||||||
|
// identifier was used.
|
||||||
const iamRows = await this.dataSource.query<IamUserRow[]>(
|
const iamRows = await this.dataSource.query<IamUserRow[]>(
|
||||||
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 LIMIT 1`,
|
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`,
|
||||||
[dto.email],
|
[dto.email],
|
||||||
);
|
);
|
||||||
const iamUser = iamRows[0];
|
const iamUser = iamRows[0];
|
||||||
@@ -210,7 +213,7 @@ export class PassengerAuthService {
|
|||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
user: { id: iamUser.id, iamUserId: iamUser.id, email: dto.email, passengerId: passenger.id },
|
user: { id: iamUser.id, iamUserId: iamUser.id, email: iamUser.email, passengerId: passenger.id },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
|||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
import { useState, Suspense } from 'react';
|
import { useState, Suspense } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Train, ShieldCheck } from 'lucide-react';
|
import { Train, ShieldCheck, Eye, EyeOff } from 'lucide-react';
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
email: z.string().email('Invalid email address'),
|
// Accepts either an email or a phone number. Passengers who registered without an
|
||||||
|
// email sign in with their phone number, which is sent in the same `email` field —
|
||||||
|
// the IAM matches on either identifier.
|
||||||
|
email: z.string().min(1, 'Phone or email is required'),
|
||||||
password: z.string().min(6, 'Password must be at least 6 characters'),
|
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -22,6 +25,7 @@ function LoginContent() {
|
|||||||
const login = useAuthStore((s) => s.login);
|
const login = useAuthStore((s) => s.login);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
|
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
|
||||||
resolver: zodResolver(loginSchema as any),
|
resolver: zodResolver(loginSchema as any),
|
||||||
@@ -63,12 +67,13 @@ function LoginContent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone or email</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="text"
|
||||||
{...register('email')}
|
{...register('email')}
|
||||||
className="input-field"
|
className="input-field"
|
||||||
placeholder="your@email.com"
|
placeholder="+251912345678 or your@email.com"
|
||||||
|
autoComplete="username"
|
||||||
/>
|
/>
|
||||||
{errors.email && (
|
{errors.email && (
|
||||||
<p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
|
<p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
|
||||||
@@ -77,12 +82,23 @@ function LoginContent() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Password</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Password</label>
|
||||||
<input
|
<div className="relative">
|
||||||
type="password"
|
<input
|
||||||
{...register('password')}
|
type={showPassword ? 'text' : 'password'}
|
||||||
className="input-field"
|
{...register('password')}
|
||||||
placeholder="••••••••"
|
className="input-field pr-10"
|
||||||
/>
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
|
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{errors.password && (
|
{errors.password && (
|
||||||
<p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
|
<p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ import { Train, ShieldCheck } from 'lucide-react';
|
|||||||
|
|
||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
fullName: z.string().min(2, 'Full name is required'),
|
fullName: z.string().min(2, 'Full name is required'),
|
||||||
email: z.string().email('Invalid email address'),
|
// Email is optional. If provided it must be a valid address; if left blank we fall back
|
||||||
|
// to the phone number as the account identifier (see onSubmit).
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.email('Invalid email address')
|
||||||
|
.optional()
|
||||||
|
.or(z.literal('')),
|
||||||
phone: z.string().min(9, 'Phone number is required'),
|
phone: z.string().min(9, 'Phone number is required'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,9 +37,12 @@ export default function RegisterPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
|
// No email? Use the phone number as the account identifier. The IAM (and our
|
||||||
|
// relaxed RegisterDto) accept any non-empty string in the email field.
|
||||||
|
const email = data.email?.trim() ? data.email.trim() : data.phone;
|
||||||
const result = await registerUser({
|
const result = await registerUser({
|
||||||
fullName: data.fullName,
|
fullName: data.fullName,
|
||||||
email: data.email,
|
email,
|
||||||
phone: data.phone,
|
phone: data.phone,
|
||||||
});
|
});
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -89,7 +98,9 @@ export default function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||||
|
Email <span className="text-gray-400 font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
{...register('email')}
|
{...register('email')}
|
||||||
|
|||||||
Reference in New Issue
Block a user