mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 16:40:56 +00:00
feat(auth): stage passenger sign-in on a single identifier field
This commit is contained in:
@@ -3,6 +3,7 @@ import { APP_FILTER } from "@nestjs/core";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { EventEmitterModule } from "@nestjs/event-emitter";
|
||||
import { ThrottlerModule } from "@nestjs/throttler";
|
||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
|
||||
import { IamModule as TriaIamModule } from "@tria-plc/iamapi-common/iam.module";
|
||||
@@ -82,6 +83,17 @@ import { EOtpType } from "@tria-plc/iamapi-common";
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
EventEmitterModule.forRoot(),
|
||||
// Named tiers only — no APP_GUARD is registered, so nothing is throttled until a
|
||||
// controller opts in with @UseGuards(ThrottlerGuard). AuthController is currently the
|
||||
// only one that does, because the staged sign-in exposes an account-existence lookup.
|
||||
ThrottlerModule.forRoot([
|
||||
// 20/min, not the 5/min the commented-out decorators suggested: the staged sign-in
|
||||
// legitimately costs 3-5 calls (lookup → request code → resend → complete → a retry
|
||||
// after a typo), and the throttler keys on IP, so users sharing a NAT or mobile CGNAT
|
||||
// address share the budget. 5 would lock real passengers out.
|
||||
{ name: "auth", limit: 20, ttl: 60_000 },
|
||||
{ name: "strict", limit: 20, ttl: 60_000 },
|
||||
]),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
||||
@@ -97,8 +109,11 @@ import { EOtpType } from "@tria-plc/iamapi-common";
|
||||
`Your EDR Passenger phone verification code is ${otp}. It will expire in 5 minutes.`,
|
||||
[EOtpType.RESET_PASSWORD]: ({ route }) =>
|
||||
`Reset your EDR Passenger password using this link: ${route}`,
|
||||
[EOtpType.SET_PASSWORD]: ({ route }) =>
|
||||
`Set your EDR Passenger password using this link: ${route}`,
|
||||
// Carries the bare code as well as the link: the staged sign-in asks for the code
|
||||
// inline, while the link is still what a `/set-password` deep link from an older SMS
|
||||
// relies on. `OtpMessageContext` supplies both.
|
||||
[EOtpType.SET_PASSWORD]: ({ otp, route }) =>
|
||||
`Your EDR Passenger code is ${otp}. Or set your password here: ${route}`,
|
||||
},
|
||||
}),
|
||||
// Replaces the package's DataSeeder. Shared with edr-freight-api, which
|
||||
|
||||
77
apps/edr-passenger-api/src/common/utils/phone.utils.ts
Normal file
77
apps/edr-passenger-api/src/common/utils/phone.utils.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Phone normalisation shared by any lookup that has to match a number a customer typed
|
||||
* against one already stored. Ethiopian numbers reach us in three interchangeable shapes
|
||||
* (+2519…, 2519…, 09…) depending on whether they came from IAM, a guest booking form or a
|
||||
* saved profile, so an exact-string match silently misses.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns all plausible normalised variants of a raw phone string so that the
|
||||
* DB query matches regardless of how the number was stored (local 09… vs international +251…).
|
||||
* Returns an empty array when the input is clearly invalid (< 7 digits).
|
||||
*/
|
||||
export function normalizePhoneVariants(raw: string): string[] {
|
||||
// Strip whitespace, dashes, dots, parentheses — keep digits and a leading +
|
||||
const stripped = raw.replace(/[^\d+]/g, '');
|
||||
const digits = stripped.replace(/^\+/, '');
|
||||
if (digits.length < 7) return [];
|
||||
|
||||
const variants = new Set<string>([stripped]);
|
||||
|
||||
if (stripped.startsWith('+251') && digits.length === 12) {
|
||||
// +251 9XXXXXXXX → 251 9XXXXXXXX (no +) → 09XXXXXXXX
|
||||
variants.add(digits); // 251XXXXXXXXX
|
||||
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
|
||||
} else if (stripped.startsWith('251') && digits.length === 12) {
|
||||
// 251 9XXXXXXXX → +251 9XXXXXXXX → 09XXXXXXXX
|
||||
variants.add('+' + stripped); // +251XXXXXXXXX
|
||||
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
|
||||
} else if (stripped.startsWith('0') && digits.length === 10) {
|
||||
// 09XXXXXXXX → +251 9XXXXXXXX → 251 9XXXXXXXX (no +)
|
||||
variants.add('+251' + digits.slice(1)); // +251XXXXXXXXX
|
||||
variants.add('251' + digits.slice(1)); // 251XXXXXXXXX
|
||||
} else if (!stripped.startsWith('+') && digits.length >= 9) {
|
||||
// bare international digits without +
|
||||
variants.add('+' + digits);
|
||||
}
|
||||
|
||||
return [...variants];
|
||||
}
|
||||
|
||||
/**
|
||||
* A sign-in identifier is a single free-text field: the passenger types either an email
|
||||
* address or a phone number and the server works out which. Phone is the default reading —
|
||||
* an email must contain an `@` with something either side of it, everything else is treated
|
||||
* as a number so that malformed emails don't silently fall through to a phone lookup that
|
||||
* can never match.
|
||||
*/
|
||||
export type ResolvedIdentifier = {
|
||||
kind: 'email' | 'phone';
|
||||
/** Lower-cased email, or null when the input is a phone number. */
|
||||
email: string | null;
|
||||
/** Every stored shape the number could have, or [] when the input is an email. */
|
||||
phoneVariants: string[];
|
||||
};
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function resolveIdentifier(raw: string): ResolvedIdentifier {
|
||||
const trimmed = raw.trim();
|
||||
if (EMAIL_RE.test(trimmed)) {
|
||||
return { kind: 'email', email: trimmed.toLowerCase(), phoneVariants: [] };
|
||||
}
|
||||
return { kind: 'phone', email: null, phoneVariants: normalizePhoneVariants(trimmed) };
|
||||
}
|
||||
|
||||
/**
|
||||
* `+251912345678` → `+2519****678`. Shown on the OTP screen so the passenger can tell which
|
||||
* number the code went to without the server handing back the full number to an unauthenticated
|
||||
* caller.
|
||||
*/
|
||||
export function maskPhone(phone: string): string {
|
||||
const stripped = phone.replace(/[^\d+]/g, '');
|
||||
if (stripped.length <= 7) return stripped;
|
||||
const head = stripped.slice(0, stripped.startsWith('+') ? 5 : 4);
|
||||
const tail = stripped.slice(-3);
|
||||
return `${head}${'*'.repeat(4)}${tail}`;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ApiBearerAuth,
|
||||
} from "@nestjs/swagger";
|
||||
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
|
||||
import { Throttle, ThrottlerGuard } from "@nestjs/throttler";
|
||||
import { PassengerAuthService } from "./passenger-auth.service";
|
||||
import {
|
||||
RegisterDto,
|
||||
@@ -28,12 +29,19 @@ import {
|
||||
ResendRegistrationCodeDto,
|
||||
FaydaRequestPasswordSetupDto,
|
||||
FaydaVerifyAndLoginDto,
|
||||
IdentifierLookupDto,
|
||||
PasswordSetupRequestDto,
|
||||
PasswordSetupCompleteDto,
|
||||
} from "./auth.dto";
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
|
||||
@ApiTags("Passenger Auth")
|
||||
@Controller("auth")
|
||||
// @Throttle({ auth: { limit: 5, ttl: 60_000 } })
|
||||
// Scoped to this controller rather than registered as a global APP_GUARD: the staged sign-in
|
||||
// exposes an account-existence lookup, and rate limiting is the mitigation for it. Applying the
|
||||
// guard app-wide would change the behaviour of every other module at the same time.
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@Throttle({ auth: { limit: 20, ttl: 60_000 } })
|
||||
export class AuthController {
|
||||
constructor(private passengerAuthService: PassengerAuthService) {}
|
||||
|
||||
@@ -189,6 +197,63 @@ export class AuthController {
|
||||
return this.passengerAuthService.resetUserPassword(id, body.tempPassword);
|
||||
}
|
||||
|
||||
@Post("identifier/lookup")
|
||||
@IsPublic()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
// Tighter than the rest of the controller: this is the endpoint that answers "does this
|
||||
// account exist", so it is the one worth making expensive to sweep. Still roomy enough
|
||||
// that a passenger correcting a typo two or three times is unaffected.
|
||||
@Throttle({ auth: { limit: 10, ttl: 60_000 } })
|
||||
@ApiOperation({
|
||||
summary: "Step 1 of sign-in — decide what to ask the user for next",
|
||||
description:
|
||||
"Takes a phone number or an email and reports whether the account exists and whether it " +
|
||||
"already has a password. PASSWORD → ask for the password. NEEDS_PASSWORD_SETUP → send a " +
|
||||
"code and let them set one. NOT_FOUND → sign them up.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "{ status, method?, maskedPhone? } — never returns email or user id",
|
||||
})
|
||||
@ApiBody({ type: IdentifierLookupDto })
|
||||
lookupIdentifier(@Body() dto: IdentifierLookupDto) {
|
||||
return this.passengerAuthService.lookupIdentifier(dto.identifier);
|
||||
}
|
||||
|
||||
@Post("password-setup/request")
|
||||
@IsPublic()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "Send the SMS code that lets an account with no password set one",
|
||||
description:
|
||||
"Covers Fayda-created accounts and abandoned registrations alike. Always returns " +
|
||||
"{ sent: true } regardless of whether the account exists.",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "{ sent: true }" })
|
||||
@ApiBody({ type: PasswordSetupRequestDto })
|
||||
requestPasswordSetup(@Body() dto: PasswordSetupRequestDto, @Request() req: any) {
|
||||
return this.passengerAuthService.requestPasswordSetup(dto.identifier, req);
|
||||
}
|
||||
|
||||
@Post("password-setup/complete")
|
||||
@IsPublic()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "Redeem the code, set the password, and sign in",
|
||||
description:
|
||||
"Accepts both set-password codes (from password-setup/request) and verify-phone-number " +
|
||||
"codes (from POST /auth/register), so one screen finishes both branches.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Same shape as POST /auth/login — token, refreshToken and user.",
|
||||
})
|
||||
@ApiResponse({ status: 401, description: "Invalid or expired code" })
|
||||
@ApiBody({ type: PasswordSetupCompleteDto })
|
||||
completePasswordSetup(@Body() dto: PasswordSetupCompleteDto) {
|
||||
return this.passengerAuthService.completePasswordSetup(dto);
|
||||
}
|
||||
|
||||
@Post("fayda/request-password-setup")
|
||||
@IsPublic()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { IsEmail, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
|
||||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsString,
|
||||
IsStrongPassword,
|
||||
Length,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
@@ -75,3 +82,55 @@ export class FaydaVerifyAndLoginDto {
|
||||
@IsString()
|
||||
otp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 of the staged sign-in. One field: the passenger types either their phone number
|
||||
* or their email and the server decides which of the three branches follows.
|
||||
*/
|
||||
export class IdentifierLookupDto {
|
||||
@ApiProperty({
|
||||
example: '+251912345678',
|
||||
description: 'Phone number or email address — the server detects which',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
/** Step 2a: ask for the SMS code that lets an account with no password set one. */
|
||||
export class PasswordSetupRequestDto {
|
||||
@ApiProperty({ example: '+251912345678', description: 'Phone number or email address' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
/** Step 2b: redeem the code, set the password, and receive a session in one call. */
|
||||
export class PasswordSetupCompleteDto {
|
||||
@ApiProperty({ example: '+251912345678', description: 'Phone number or email address' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
identifier: string;
|
||||
|
||||
@ApiProperty({ example: '123456', description: '6-digit code received via SMS' })
|
||||
@IsString()
|
||||
@Length(4, 10)
|
||||
otp: string;
|
||||
|
||||
// The credential is written directly against iam.user_credentials rather than through
|
||||
// the IAM's own set-password route, so the IAM's @IsStrongPassword rule has to be
|
||||
// restated here or weak passwords would slip in unvalidated.
|
||||
@ApiProperty({ example: 'Str0ng!Pass', format: 'password' })
|
||||
@IsStrongPassword({
|
||||
minLength: 8,
|
||||
minLowercase: 1,
|
||||
minUppercase: 1,
|
||||
minNumbers: 1,
|
||||
minSymbols: 1,
|
||||
})
|
||||
newPassword: string;
|
||||
|
||||
@ApiProperty({ example: 'Str0ng!Pass', format: 'password' })
|
||||
@IsString()
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
ConflictException,
|
||||
InternalServerErrorException,
|
||||
@@ -13,7 +14,22 @@ import { AuthService as IamAuthService } from '@tria-plc/iamapi-common/module/au
|
||||
import { EUserType } from '@tria-plc/api-common/utils/enums/user.enum';
|
||||
import { EOtpType } from '@tria-plc/iamapi-common/enums/otp.enum';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { RegisterDto, LoginDto } from './auth.dto';
|
||||
import { RegisterDto, LoginDto, PasswordSetupCompleteDto } from './auth.dto';
|
||||
import { maskPhone, resolveIdentifier } from '../../common/utils/phone.utils';
|
||||
|
||||
/**
|
||||
* The `iam.users` columns every sign-in branch needs. Kept separate from `IamUserRow`
|
||||
* (which is profile-shaped) because the auth branches key off credential state, not metadata.
|
||||
*/
|
||||
type IamAuthRow = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: { en: string; am: string } | null;
|
||||
username: string;
|
||||
phone_number: string | null;
|
||||
has_set_password: boolean;
|
||||
verified_by: string | null;
|
||||
};
|
||||
|
||||
type IamUserRow = {
|
||||
id: string;
|
||||
@@ -170,9 +186,19 @@ export class PassengerAuthService {
|
||||
async login(dto: LoginDto, req: any) {
|
||||
const iamAuthService = await this.resolveIamAuthService(req);
|
||||
|
||||
// `dto.email` may hold an email OR a phone number, in any of the shapes a passenger might
|
||||
// type. Resolve it to the exact string the IAM stores before handing it over: the IAM
|
||||
// matches the identifier literally, so someone entering `0912…` for a number stored as
|
||||
// `+2519…` would be told their credentials are invalid despite a correct password.
|
||||
const known = await this.findUserByIdentifier(dto.email);
|
||||
const loginIdentifier = known?.email ?? known?.phone_number ?? dto.email;
|
||||
|
||||
let iamResult: { token: string; refreshToken: string } | { mfaRequired: boolean };
|
||||
try {
|
||||
iamResult = await iamAuthService.login({ email: dto.email, password: dto.password });
|
||||
iamResult = await iamAuthService.login({
|
||||
email: loginIdentifier,
|
||||
password: dto.password,
|
||||
});
|
||||
} catch {
|
||||
this.eventEmitter.emit('auth.login.failed', { email: dto.email });
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
@@ -184,14 +210,16 @@ export class PassengerAuthService {
|
||||
|
||||
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[]>(
|
||||
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`,
|
||||
[dto.email],
|
||||
);
|
||||
const iamUser = iamRows[0];
|
||||
// `known` is the same row the identifier resolved to; only fall back to a fresh lookup if
|
||||
// the resolve missed but the IAM authenticated anyway.
|
||||
let iamUser: { id: string; email: string | null } | null = known;
|
||||
if (!iamUser) {
|
||||
const iamRows = await this.dataSource.query<IamUserRow[]>(
|
||||
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`,
|
||||
[loginIdentifier],
|
||||
);
|
||||
iamUser = iamRows[0] ?? null;
|
||||
}
|
||||
if (!iamUser) {
|
||||
throw new InternalServerErrorException('IAM user not found after successful authentication');
|
||||
}
|
||||
@@ -496,19 +524,26 @@ export class PassengerAuthService {
|
||||
}
|
||||
|
||||
async resetUserPassword(id: string, tempPassword: string) {
|
||||
await this.writeActiveCredential(id, tempPassword);
|
||||
return { success: true, message: 'Password reset successfully' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the user's active credential. The IAM keeps credential history and relies on
|
||||
* exactly one row per user having `is_active = true`, so the old row is deactivated in the
|
||||
* same call rather than deleted.
|
||||
*/
|
||||
private async writeActiveCredential(userId: string, password: string): Promise<void> {
|
||||
const { hashPassword } = await import('@tria-plc/api-common/utils/argon');
|
||||
const passwordHash = await hashPassword(tempPassword);
|
||||
// Deactivate existing credentials first (IAM keeps history, only one active at a time)
|
||||
const passwordHash = await hashPassword(password);
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.user_credentials SET is_active = false WHERE user_id = $1 AND is_active = true`,
|
||||
[id],
|
||||
[userId],
|
||||
);
|
||||
// Insert new active credential
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO iam.user_credentials (user_id, password, is_active) VALUES ($1, $2, true)`,
|
||||
[id, passwordHash],
|
||||
[userId, passwordHash],
|
||||
);
|
||||
return { success: true, message: 'Password reset successfully' };
|
||||
}
|
||||
|
||||
async requestFaydaPasswordSetup(phoneNumber: string, req: any): Promise<{ sent: boolean }> {
|
||||
@@ -554,15 +589,45 @@ export class PassengerAuthService {
|
||||
if (!users.length) throw new UnauthorizedException('Invalid phone number or OTP');
|
||||
const u = users[0];
|
||||
|
||||
await this.consumeSetupOtp(u.id, otp, 'Invalid phone number or OTP');
|
||||
|
||||
const { token, refreshToken } = await this.mintSession(
|
||||
{ ...u, verified_by: 'fayda' },
|
||||
'fayda-otp-setup',
|
||||
);
|
||||
|
||||
return { token, refreshToken, requiresPassword: !u.has_set_password, iamUserId: u.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies and burns a one-time code from `iam.user_verifications`.
|
||||
*
|
||||
* Both password-setup entry points land here: `set-password` codes come from
|
||||
* `password-setup/request`, `verify-phone-number` codes from `POST /auth/register`. Accepting
|
||||
* both is what lets a single screen finish the "existing account with no password" branch and
|
||||
* the "brand new signup" branch.
|
||||
*
|
||||
* Codes are argon2-hashed at rest, so this is a verify rather than an equality check. The
|
||||
* attempt counter is incremented *before* the comparison so a crash mid-verify still costs an
|
||||
* attempt, and the code is burned on the 6th try.
|
||||
*/
|
||||
private async consumeSetupOtp(
|
||||
userId: string,
|
||||
otp: string,
|
||||
failureMessage = 'Invalid or expired code',
|
||||
): Promise<void> {
|
||||
const verifications = await this.dataSource.query<{
|
||||
id: string; verification_code: string; attempt_count: number;
|
||||
}[]>(
|
||||
`SELECT id, verification_code, attempt_count FROM iam.user_verifications
|
||||
WHERE user_id = $1 AND otp_type = 'set-password' AND "isUsed" = false AND expires_at > NOW()
|
||||
WHERE user_id = $1
|
||||
AND otp_type IN ('set-password', 'verify-phone-number')
|
||||
AND "isUsed" = false
|
||||
AND expires_at > NOW()
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[u.id],
|
||||
[userId],
|
||||
);
|
||||
if (!verifications.length) throw new UnauthorizedException('Invalid phone number or OTP');
|
||||
if (!verifications.length) throw new UnauthorizedException(failureMessage);
|
||||
const v = verifications[0];
|
||||
|
||||
if (v.attempt_count >= 5) {
|
||||
@@ -578,12 +643,24 @@ export class PassengerAuthService {
|
||||
|
||||
const { verifyPassword } = await import('@tria-plc/api-common/utils/argon');
|
||||
const valid = await verifyPassword(otp, v.verification_code);
|
||||
if (!valid) throw new UnauthorizedException('Invalid phone number or OTP');
|
||||
if (!valid) throw new UnauthorizedException(failureMessage);
|
||||
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.user_verifications SET "isUsed" = true WHERE id = $1`, [v.id],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts (or refreshes) an `iam.sessions` row and mints the token pair for it. The JWT payload
|
||||
* is only the session id — `JwtGuard` resolves everything else from the table.
|
||||
*
|
||||
* `device` participates in a unique constraint on `(user_id, device)`, so each flow passes its
|
||||
* own value and none of them clobbers a session another flow established.
|
||||
*/
|
||||
private async mintSession(
|
||||
u: IamAuthRow,
|
||||
device: string,
|
||||
): Promise<{ token: string; refreshToken: string }> {
|
||||
const userInfo = {
|
||||
id: u.id,
|
||||
email: u.email ?? '',
|
||||
@@ -604,19 +681,183 @@ export class PassengerAuthService {
|
||||
const sessions = await this.dataSource.query<{ id: string }[]>(
|
||||
`INSERT INTO iam.sessions
|
||||
(id, email, device, "userInfo", expiry_time, refresh_count, status, user_id)
|
||||
VALUES (gen_random_uuid(), $1, 'fayda-otp-setup', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $4)
|
||||
ON CONFLICT (user_id, device) DO UPDATE
|
||||
SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo",
|
||||
expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW()
|
||||
RETURNING id`,
|
||||
[u.email ?? '', JSON.stringify(userInfo), u.id],
|
||||
[u.email ?? '', device, JSON.stringify(userInfo), u.id],
|
||||
);
|
||||
|
||||
const { generateToken, generateRefreshToken } = await import('@tria-plc/api-common/utils/token');
|
||||
const token = generateToken({ id: sessions[0].id });
|
||||
const refreshToken = generateRefreshToken({ id: sessions[0].id });
|
||||
return {
|
||||
token: generateToken({ id: sessions[0].id }),
|
||||
refreshToken: generateRefreshToken({ id: sessions[0].id }),
|
||||
};
|
||||
}
|
||||
|
||||
return { token, refreshToken, requiresPassword: !u.has_set_password, iamUserId: u.id };
|
||||
/**
|
||||
* Resolves the single sign-in identifier field to an `iam.users` row.
|
||||
*
|
||||
* A phone number reaches us in three interchangeable shapes (`+2519…`, `2519…`, `09…`)
|
||||
* depending on whether the account was created by IAM signup, a guest booking or Fayda, so
|
||||
* matching on one canonical form silently misses. `normalizePhoneVariants` produces every
|
||||
* shape and the query matches any of them.
|
||||
*
|
||||
* `ORDER BY has_set_password DESC` makes a fully-registered account win over a leftover
|
||||
* pending row that shares the same phone — otherwise a passenger with an abandoned signup
|
||||
* would be pushed into password setup for an account they already finished.
|
||||
*/
|
||||
private async findUserByIdentifier(identifier: string): Promise<IamAuthRow | null> {
|
||||
const resolved = resolveIdentifier(identifier);
|
||||
if (!resolved.email && resolved.phoneVariants.length === 0) return null;
|
||||
|
||||
const rows = await this.dataSource.query<IamAuthRow[]>(
|
||||
`SELECT id, email, name, username, phone_number, has_set_password, verified_by
|
||||
FROM iam.users
|
||||
WHERE ($1::text IS NOT NULL AND lower(email) = $1)
|
||||
OR phone_number = ANY($2::text[])
|
||||
ORDER BY has_set_password DESC
|
||||
LIMIT 1`,
|
||||
[resolved.email, resolved.phoneVariants],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 of the staged sign-in: decide which of the three branches the portal should render.
|
||||
*
|
||||
* This deliberately reports whether an account exists — the whole point of the flow is that the
|
||||
* passenger stops guessing — so it is a user-enumeration oracle by design. `POST
|
||||
* /v1/auth/forgot-password` already leaks the same fact by throwing `user_not_found`; the
|
||||
* mitigation here is the throttle on this controller, not secrecy. Nothing identifying is
|
||||
* returned: no email, no user id, and the phone only ever masked.
|
||||
*/
|
||||
async lookupIdentifier(identifier: string): Promise<{
|
||||
status: 'PASSWORD' | 'NEEDS_PASSWORD_SETUP' | 'NOT_FOUND';
|
||||
method?: 'fayda' | 'pending';
|
||||
maskedPhone?: string;
|
||||
}> {
|
||||
const user = await this.findUserByIdentifier(identifier);
|
||||
if (!user) return { status: 'NOT_FOUND' };
|
||||
if (user.has_set_password) return { status: 'PASSWORD' };
|
||||
|
||||
return {
|
||||
status: 'NEEDS_PASSWORD_SETUP',
|
||||
method: user.verified_by === 'fayda' ? 'fayda' : 'pending',
|
||||
maskedPhone: user.phone_number ? maskPhone(user.phone_number) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the SMS code that lets an account with no password set one. Covers both Fayda-created
|
||||
* accounts and abandoned registrations — the distinction only changes the copy the portal
|
||||
* shows, not what happens here.
|
||||
*
|
||||
* Always resolves `{ sent: true }`. Returning a real result would make this a cheaper
|
||||
* enumeration oracle than `lookupIdentifier`, which at least sits behind the same throttle.
|
||||
*/
|
||||
async requestPasswordSetup(identifier: string, req: any): Promise<{ sent: boolean }> {
|
||||
const user = await this.findUserByIdentifier(identifier);
|
||||
if (!user || user.has_set_password) return { sent: true };
|
||||
|
||||
if (!user.phone_number) {
|
||||
// OTP delivery is SMS + in-app only; there is no email channel. Every account-creation
|
||||
// path requires a phone, so this should be unreachable — log it rather than fail silently.
|
||||
this.logger.warn(
|
||||
`requestPasswordSetup: user ${user.id} has no phone number — no channel to send a code on`,
|
||||
);
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
const iamAuthService = await this.resolveIamAuthService(req);
|
||||
try {
|
||||
await iamAuthService.generateVerificationCode({
|
||||
// Both fields must match the stored row exactly: the IAM looks the user up with
|
||||
// `where: { phoneNumber, email }`, which is AND, not OR. Passing the values we just
|
||||
// read back guarantees the match — including a null email, which TypeORM renders as
|
||||
// `IS NULL` and which coercing to '' would break.
|
||||
email: user.email as string,
|
||||
phoneNumber: user.phone_number,
|
||||
type: EOtpType.SET_PASSWORD,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`[PassengerAuthService] password setup code failed for user ${user.id}`,
|
||||
(err as Error).message,
|
||||
);
|
||||
}
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeems the code, writes the password, and returns a session — the passenger lands signed in
|
||||
* rather than being bounced back to the login form.
|
||||
*
|
||||
* Returns the same shape as `login()` so the portal can store the result through one code path.
|
||||
*/
|
||||
async completePasswordSetup(dto: PasswordSetupCompleteDto): Promise<{
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
user: { id: string; iamUserId: string; email: string | null; passengerId: string };
|
||||
}> {
|
||||
if (dto.newPassword !== dto.confirmPassword) {
|
||||
throw new BadRequestException('Passwords do not match');
|
||||
}
|
||||
|
||||
const user = await this.findUserByIdentifier(dto.identifier);
|
||||
// Same message whether the account is missing or the code is wrong: the branch was already
|
||||
// disclosed by `lookupIdentifier`, but there is no reason to re-confirm it on every attempt.
|
||||
if (!user) throw new UnauthorizedException('Invalid or expired code');
|
||||
if (user.has_set_password) {
|
||||
throw new BadRequestException(
|
||||
'This account already has a password. Sign in with it instead.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.consumeSetupOtp(user.id, dto.otp);
|
||||
await this.writeActiveCredential(user.id, dto.newPassword);
|
||||
|
||||
// Redeeming the code proves ownership of the phone, which is what promotes a Fayda-created
|
||||
// `submitted` row or a pending signup to a usable account.
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.users
|
||||
SET has_set_password = true,
|
||||
status = 'accepted',
|
||||
is_active = true,
|
||||
is_phone_number_verified = true,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[user.id],
|
||||
);
|
||||
|
||||
const { token, refreshToken } = await this.mintSession(
|
||||
{ ...user, has_set_password: true },
|
||||
'password-setup',
|
||||
);
|
||||
|
||||
let passenger = await this.prisma.passenger.findUnique({
|
||||
where: { iamUserId: user.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!passenger) {
|
||||
const result = await this.provisionPassengerSatellite({
|
||||
iamUserId: user.id,
|
||||
auditAction: 'USER_AUTO_PROVISIONED',
|
||||
});
|
||||
passenger = { id: result.passengerId };
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
iamUserId: user.id,
|
||||
email: user.email,
|
||||
passengerId: passenger.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private standardizePhone(phone: string): string {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { CurrencyService } from '../currency/currency.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils';
|
||||
import { normalizePhoneVariants } from '../../common/utils/phone.utils';
|
||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
import { JourneyDirection } from '../seats/seats.dto';
|
||||
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
||||
@@ -46,39 +47,6 @@ function resolvePackageRoundTripTotal(
|
||||
return adultCount * adultFareMinor + paidChildren * adultFareMinor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all plausible normalised variants of a raw phone string so that the
|
||||
* DB query matches regardless of how the number was stored (local 09… vs international +251…).
|
||||
* Returns an empty array when the input is clearly invalid (< 7 digits).
|
||||
*/
|
||||
function normalizePhoneVariants(raw: string): string[] {
|
||||
// Strip whitespace, dashes, dots, parentheses — keep digits and a leading +
|
||||
const stripped = raw.replace(/[^\d+]/g, '');
|
||||
const digits = stripped.replace(/^\+/, '');
|
||||
if (digits.length < 7) return [];
|
||||
|
||||
const variants = new Set<string>([stripped]);
|
||||
|
||||
if (stripped.startsWith('+251') && digits.length === 12) {
|
||||
// +251 9XXXXXXXX → 251 9XXXXXXXX (no +) → 09XXXXXXXX
|
||||
variants.add(digits); // 251XXXXXXXXX
|
||||
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
|
||||
} else if (stripped.startsWith('251') && digits.length === 12) {
|
||||
// 251 9XXXXXXXX → +251 9XXXXXXXX → 09XXXXXXXX
|
||||
variants.add('+' + stripped); // +251XXXXXXXXX
|
||||
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
|
||||
} else if (stripped.startsWith('0') && digits.length === 10) {
|
||||
// 09XXXXXXXX → +251 9XXXXXXXX → 251 9XXXXXXXX (no +)
|
||||
variants.add('+251' + digits.slice(1)); // +251XXXXXXXXX
|
||||
variants.add('251' + digits.slice(1)); // 251XXXXXXXXX
|
||||
} else if (!stripped.startsWith('+') && digits.length >= 9) {
|
||||
// bare international digits without +
|
||||
variants.add('+' + digits);
|
||||
}
|
||||
|
||||
return [...variants];
|
||||
}
|
||||
|
||||
function calculateAge(dateOfBirth: Date): number {
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - dateOfBirth.getFullYear();
|
||||
|
||||
@@ -1,50 +1,295 @@
|
||||
'use client';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useState, Suspense } from 'react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
|
||||
import { useState, useRef, Suspense } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Train, ShieldCheck, Eye, EyeOff } from 'lucide-react';
|
||||
import { Train, Eye, EyeOff, Pencil } from 'lucide-react';
|
||||
|
||||
const loginSchema = z.object({
|
||||
// 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'),
|
||||
});
|
||||
/**
|
||||
* Staged sign-in.
|
||||
*
|
||||
* The passenger gives one identifier — phone or email — and the server decides which of three
|
||||
* things happens next. Previously this page asked for identifier *and* password up front and
|
||||
* offered three competing links underneath ("Create account", "Already verified with Fayda?",
|
||||
* "Forgot password?"), which made the user guess something only the server knows: whether their
|
||||
* number has an account, and whether that account has a password yet. Guessing wrong dead-ended.
|
||||
*
|
||||
* Now exactly one branch is ever on screen.
|
||||
*/
|
||||
type Step = 'identifier' | 'password' | 'setup' | 'signup';
|
||||
|
||||
type LoginForm = z.infer<typeof loginSchema>;
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
/**
|
||||
* Loose enough to accept every shape a passenger might type (`+2519…`, `2519…`, `09…`) and the
|
||||
* occasional foreign number, strict enough that free text never reaches the signup branch — an
|
||||
* identifier that is neither an email nor a number would otherwise be stored as a phone the SMS
|
||||
* code can never reach. Mirrors the 7-digit floor in the API's `normalizePhoneVariants`.
|
||||
*/
|
||||
const looksLikePhone = (v: string) => v.replace(/[^\d]/g, '').length >= 7;
|
||||
|
||||
function LoginContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const registerUser = useAuthStore((s) => s.register);
|
||||
const setUser = useAuthStore((s) => s.setUser);
|
||||
|
||||
const [step, setStep] = useState<Step>('identifier');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
|
||||
resolver: zodResolver(loginSchema as any),
|
||||
});
|
||||
// Step 1
|
||||
const [identifier, setIdentifier] = useState('');
|
||||
const identifierRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onSubmit = async (data: LoginForm) => {
|
||||
// Step 2 — sign in
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
// Step 3 — set a password (existing account with none, or a fresh signup)
|
||||
const [maskedPhone, setMaskedPhone] = useState('');
|
||||
const [setupMethod, setSetupMethod] = useState<'fayda' | 'pending' | 'new'>('pending');
|
||||
const [otp, setOtp] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [resendNote, setResendNote] = useState('');
|
||||
|
||||
// Step 4 — signup
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [secondaryContact, setSecondaryContact] = useState('');
|
||||
|
||||
const identifierIsEmail = EMAIL_RE.test(identifier.trim());
|
||||
|
||||
const finish = () => {
|
||||
const redirect = searchParams.get('redirect') || '/booking/search';
|
||||
router.push(redirect);
|
||||
};
|
||||
|
||||
const goBackToIdentifier = () => {
|
||||
setStep('identifier');
|
||||
setError('');
|
||||
setPassword('');
|
||||
setOtp('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setResendNote('');
|
||||
// Keep what they typed — they are usually fixing a typo, not starting over — but select
|
||||
// it, so typing replaces the value instead of appending to it. Without this, clicking
|
||||
// into a controlled input that still holds the old identifier silently concatenates.
|
||||
setTimeout(() => identifierRef.current?.select(), 0);
|
||||
};
|
||||
|
||||
const apiMessage = (err: any, fallback: string) =>
|
||||
err?.response?.data?.message || fallback;
|
||||
|
||||
// --- Step 1: who are you? ---------------------------------------------------
|
||||
const submitIdentifier = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const value = identifier.trim();
|
||||
if (!value) {
|
||||
setError('Enter your phone number or email');
|
||||
return;
|
||||
}
|
||||
if (!EMAIL_RE.test(value) && !looksLikePhone(value)) {
|
||||
setError('Enter a valid phone number or email address');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await login(data.email, data.password);
|
||||
const redirect = searchParams.get('redirect') || '/booking/search';
|
||||
router.push(redirect);
|
||||
const res = await iamAuthApi.lookupIdentifier(identifier.trim());
|
||||
const result = res.data.data;
|
||||
|
||||
if (result.status === 'PASSWORD') {
|
||||
setStep('password');
|
||||
return;
|
||||
}
|
||||
if (result.status === 'NEEDS_PASSWORD_SETUP') {
|
||||
setMaskedPhone(result.maskedPhone || '');
|
||||
setSetupMethod(result.method || 'pending');
|
||||
// Fire the code now so the next screen is already actionable. It resolves even for
|
||||
// an unknown identifier, so a failure here is a transport problem, not a verdict.
|
||||
await iamAuthApi.requestPasswordSetup(identifier.trim());
|
||||
setStep('setup');
|
||||
return;
|
||||
}
|
||||
setStep('signup');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Login failed. Please check your credentials.');
|
||||
setError(apiMessage(err, 'Something went wrong. Please try again.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Step 2: existing account, has a password -------------------------------
|
||||
const submitPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!password) {
|
||||
setError('Enter your password');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await login(identifier.trim(), password);
|
||||
finish();
|
||||
} catch (err: any) {
|
||||
setError(apiMessage(err, 'Incorrect password. Please try again.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Step 3: set a password with the SMS code -------------------------------
|
||||
const submitSetup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!otp.trim()) {
|
||||
setError('Enter the code we sent you');
|
||||
return;
|
||||
}
|
||||
if (!isStrongPassword(newPassword)) {
|
||||
setError(PASSWORD_RULE);
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await iamAuthApi.completePasswordSetup({
|
||||
identifier: identifier.trim(),
|
||||
otp: otp.trim(),
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
});
|
||||
const { token, user } = res.data.data;
|
||||
// The response carries a real session, so the user lands signed in instead of being
|
||||
// sent back to the form. `setUser` is the same action `login()` persists through.
|
||||
setUser(user as any, token);
|
||||
finish();
|
||||
} catch (err: any) {
|
||||
setError(apiMessage(err, 'That code is not valid. Please try again.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resend = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setResendNote('');
|
||||
try {
|
||||
await iamAuthApi.requestPasswordSetup(identifier.trim());
|
||||
setResendNote('We sent a new code.');
|
||||
} catch (err: any) {
|
||||
setError(apiMessage(err, 'Could not send a new code. Please try again.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Step 4: no account yet --------------------------------------------------
|
||||
const submitSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const name = fullName.trim();
|
||||
const other = secondaryContact.trim();
|
||||
if (name.length < 2) {
|
||||
setError('Enter your full name');
|
||||
return;
|
||||
}
|
||||
// A phone is always required — the verification code is sent by SMS and there is no
|
||||
// email channel for it. An email is optional.
|
||||
if (identifierIsEmail) {
|
||||
if (!other) {
|
||||
setError('Enter your phone number');
|
||||
return;
|
||||
}
|
||||
if (!looksLikePhone(other)) {
|
||||
setError('Enter a valid phone number — your verification code is sent by SMS');
|
||||
return;
|
||||
}
|
||||
} else if (other && !EMAIL_RE.test(other)) {
|
||||
// Only validate the shape when they actually typed something.
|
||||
setError('Enter a valid email address');
|
||||
return;
|
||||
}
|
||||
|
||||
const phone = identifierIsEmail ? other : identifier.trim();
|
||||
// The IAM requires a non-empty account identifier in its `email` field but never checks
|
||||
// that it is email-shaped, so a passenger with no email address signs up under their phone
|
||||
// number — the same fallback `/register` uses. Both then match on either identifier.
|
||||
const email = identifierIsEmail ? identifier.trim() : other || phone;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await registerUser({ fullName: name, email, phone });
|
||||
setMaskedPhone(phone);
|
||||
setSetupMethod('new');
|
||||
setStep('setup');
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 409) {
|
||||
setError('An account with this email or phone number already exists. Go back and sign in.');
|
||||
} else {
|
||||
setError(apiMessage(err, 'Could not create your account. Please try again.'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The identifier, shown on every step after the first, with one way back to change it.
|
||||
*
|
||||
* It is a real `autocomplete="username"` input rather than a `<span>`, and it is rendered
|
||||
* *inside* each form. That is what makes password managers behave: a password field sitting
|
||||
* alone in a form gives Chrome nothing to match a saved credential against, so it fills
|
||||
* whichever password it holds for the origin — a password belonging to some other account.
|
||||
* Pairing it with the username lets the manager fill the right credential, or none at all.
|
||||
*/
|
||||
const identifierChip = (
|
||||
<div className="flex items-center justify-between gap-3 mb-4 px-3 py-2 rounded bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||
<input
|
||||
type="text"
|
||||
value={identifier}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
autoComplete="username"
|
||||
aria-label="Signing in as"
|
||||
onFocus={(e) => e.currentTarget.blur()}
|
||||
className="flex-1 min-w-0 truncate bg-transparent border-0 p-0 text-sm text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-0 cursor-default"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBackToIdentifier}
|
||||
className="flex items-center gap-1 text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline shrink-0"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const heading = {
|
||||
identifier: { title: 'Sign in', subtitle: 'Enter your phone number or email to continue' },
|
||||
password: { title: 'Welcome back', subtitle: 'Enter your password to sign in' },
|
||||
setup: { title: 'Set your password', subtitle: 'Enter the code we sent, then choose a password' },
|
||||
signup: { title: 'Create your account', subtitle: 'We just need a couple of details' },
|
||||
}[step];
|
||||
|
||||
const setupBlurb =
|
||||
setupMethod === 'fayda'
|
||||
? 'Your Fayda-verified account does not have a password yet.'
|
||||
: setupMethod === 'new'
|
||||
? 'Your account is almost ready.'
|
||||
: 'You started signing up but never chose a password.';
|
||||
|
||||
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">
|
||||
@@ -54,86 +299,227 @@ function LoginContent() {
|
||||
<Train className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Sign in</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">Welcome back</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">{heading.title}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">{heading.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<form onSubmit={handleSubmit(onSubmit)} 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>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone or email</label>
|
||||
<input
|
||||
type="text"
|
||||
{...register('email')}
|
||||
className="input-field"
|
||||
placeholder="+251912345678 or your@email.com"
|
||||
autoComplete="username"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
|
||||
)}
|
||||
{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 mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Password</label>
|
||||
<div className="relative">
|
||||
{step === 'identifier' && (
|
||||
<form onSubmit={submitIdentifier} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
Phone number or email
|
||||
</label>
|
||||
<input
|
||||
ref={identifierRef}
|
||||
type="text"
|
||||
value={identifier}
|
||||
onChange={(e) => { setIdentifier(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder="+251912345678 or your@email.com"
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Checking...' : 'Continue'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 'password' && (
|
||||
<form onSubmit={submitPassword} className="space-y-4">
|
||||
{identifierChip}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); setError(''); }}
|
||||
className="input-field pr-10"
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
/>
|
||||
<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>
|
||||
<div className="flex justify-end mt-1">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 'setup' && (
|
||||
<form onSubmit={submitSetup} className="space-y-4">
|
||||
{identifierChip}
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{setupBlurb}{' '}
|
||||
{maskedPhone
|
||||
? <>We sent a code to <span className="font-medium">{maskedPhone}</span>.</>
|
||||
: 'We sent a code to your registered phone.'}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
Verification code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={otp}
|
||||
onChange={(e) => { setOtp(e.target.value); setError(''); }}
|
||||
className="input-field tracking-widest"
|
||||
placeholder="A1b2C3"
|
||||
// The IAM issues codes with generateRandomString(6): letters and digits,
|
||||
// and case-sensitive — so no numeric keypad and no autocapitalise.
|
||||
inputMode="text"
|
||||
autoComplete="one-time-code"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
New password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(e) => { setNewPassword(e.target.value); setError(''); }}
|
||||
className="input-field pr-10"
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<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>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{PASSWORD_RULE}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
{...register('password')}
|
||||
className="input-field pr-10"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<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 && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
|
||||
)}
|
||||
<div className="flex justify-end mt-1">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Setting password...' : 'Set password and sign in'}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
{resendNote ? (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">{resendNote}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resend}
|
||||
disabled={loading}
|
||||
className="text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline disabled:opacity-50"
|
||||
>
|
||||
Didn't get a code? Send it again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
{step === 'signup' && (
|
||||
<form onSubmit={submitSignup} className="space-y-4">
|
||||
{identifierChip}
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
We couldn't find an account for that {identifierIsEmail ? 'email' : 'number'}, so
|
||||
let's create one.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||
<div className="text-center">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Don't have an account? </span>
|
||||
<Link href="/register" className="text-sm font-medium text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline">
|
||||
Create account
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/fayda-setup"
|
||||
className="flex items-center justify-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-[rgb(20_113_76)] dark:hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
Already verified with Fayda? Set up your password
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
Full name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fullName}
|
||||
onChange={(e) => { setFullName(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder="e.g. Abebe Kebede"
|
||||
autoComplete="name"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
{identifierIsEmail ? 'Phone number' : 'Email address (optional)'}
|
||||
</label>
|
||||
<input
|
||||
type={identifierIsEmail ? 'tel' : 'email'}
|
||||
value={secondaryContact}
|
||||
onChange={(e) => { setSecondaryContact(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder={identifierIsEmail ? '+251912345678' : 'your@email.com'}
|
||||
autoComplete={identifierIsEmail ? 'tel' : 'email'}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{identifierIsEmail
|
||||
? "We'll text your verification code to this number."
|
||||
: "For receipts and booking confirmations. Your verification code is sent by SMS either way."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 text-center">
|
||||
<button
|
||||
onClick={() => router.push('/booking/search')}
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-primary dark:hover:text-primary-400"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Train, CheckCircle, ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const router = useRouter();
|
||||
@@ -24,8 +25,8 @@ function ResetPasswordContent() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (newPassword.length < 6) {
|
||||
setError('Password must be at least 6 characters.');
|
||||
if (!isStrongPassword(newPassword)) {
|
||||
setError(PASSWORD_RULE);
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
|
||||
@@ -6,18 +6,8 @@ import Link from 'next/link';
|
||||
import { Train, ArrowLeft, ShieldCheck } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { isStrongPassword } from '@/lib/password';
|
||||
|
||||
// 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 searchParams = useSearchParams();
|
||||
|
||||
@@ -192,18 +192,12 @@ export default function AppSidebar() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-1 pt-1">
|
||||
<div className="px-1 pt-1">
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex-1 text-center px-3 py-2 text-sm font-medium text-gray-100 hover:bg-white/10 rounded-lg transition-colors"
|
||||
className="block text-center px-3 py-2 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="flex-1 text-center px-3 py-2 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Register
|
||||
Sign in or register
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, CheckCircle } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -32,8 +33,8 @@ export default function ChangePasswordModal({ isOpen, onClose }: ChangePasswordM
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (newPassword.length < 6) {
|
||||
setError('New password must be at least 6 characters.');
|
||||
if (!isStrongPassword(newPassword)) {
|
||||
setError(PASSWORD_RULE);
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Train, ShieldCheck, CheckCircle, Info, ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
|
||||
|
||||
interface FaydaSetupWizardProps {
|
||||
// Prefilled OTP when landing from the SMS link (/set-password?verificationCode=...)
|
||||
@@ -41,8 +42,8 @@ export default function FaydaSetupWizard({ initialOtp }: FaydaSetupWizardProps)
|
||||
const handleSetPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (newPassword.length < 6) {
|
||||
setError('Password must be at least 6 characters.');
|
||||
if (!isStrongPassword(newPassword)) {
|
||||
setError(PASSWORD_RULE);
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
|
||||
@@ -36,6 +36,42 @@ export const iamAuthApi = {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('auth_token')}` },
|
||||
}),
|
||||
|
||||
// --- Staged sign-in (/login) -------------------------------------------------
|
||||
// Step 1: hand the server one field and let it say which branch follows. `identifier`
|
||||
// is a phone number or an email; the server works out which.
|
||||
lookupIdentifier: (identifier: string) =>
|
||||
axios.post<{
|
||||
success: boolean;
|
||||
data: {
|
||||
status: 'PASSWORD' | 'NEEDS_PASSWORD_SETUP' | 'NOT_FOUND';
|
||||
method?: 'fayda' | 'pending';
|
||||
maskedPhone?: string;
|
||||
};
|
||||
}>(`${API_URL}/auth/identifier/lookup`, { identifier }),
|
||||
|
||||
// Step 2a: SMS the code for an account that exists but has no password yet.
|
||||
// Always resolves — the server reports { sent: true } even for an unknown identifier.
|
||||
requestPasswordSetup: (identifier: string) =>
|
||||
axios.post(`${API_URL}/auth/password-setup/request`, { identifier }),
|
||||
|
||||
// Step 2b: redeem the code and set the password. Unlike the older Fayda dance this
|
||||
// returns a usable session directly, so the user lands signed in rather than back on
|
||||
// the login form. Same response shape as POST /auth/login.
|
||||
completePasswordSetup: (data: {
|
||||
identifier: string;
|
||||
otp: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}) =>
|
||||
axios.post<{
|
||||
success: boolean;
|
||||
data: {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
user: { id: string; iamUserId: string; email: string | null; passengerId: string };
|
||||
};
|
||||
}>(`${API_URL}/auth/password-setup/complete`, data),
|
||||
|
||||
faydaRequestPasswordSetup: (phoneNumber: string) =>
|
||||
axios.post(`${API_URL}/auth/fayda/request-password-setup`, { phoneNumber }),
|
||||
|
||||
|
||||
@@ -113,13 +113,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
login: async (email: string, password: string) => {
|
||||
const response: any = await apiClient.post('/auth/login', { email, password });
|
||||
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 });
|
||||
// `setUser` is the one place a session is persisted. The staged sign-in's
|
||||
// password-setup branch establishes a session without going through /auth/login,
|
||||
// so it calls the same action rather than duplicating the storage writes.
|
||||
get().setUser(user, token);
|
||||
},
|
||||
|
||||
register: async (data: RegisterData): Promise<RegisterResult> => {
|
||||
|
||||
21
apps/edr-passenger-web/portal/src/lib/password.ts
Normal file
21
apps/edr-passenger-web/portal/src/lib/password.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* The one password rule the portal enforces.
|
||||
*
|
||||
* It mirrors class-validator's `@IsStrongPassword` defaults, which is what the IAM applies on
|
||||
* `PATCH /v1/auth/set-password` and what `POST /auth/password-setup/complete` applies on the
|
||||
* passenger API. Screens that used a looser check (`length < 6`) accepted passwords the server
|
||||
* then rejected with an opaque 400, so every screen shares this instead.
|
||||
*/
|
||||
export 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)
|
||||
);
|
||||
}
|
||||
|
||||
/** The rule stated for humans. Shown as helper text and reused as the validation message. */
|
||||
export const PASSWORD_RULE =
|
||||
'Password must be at least 8 characters and include an upper-case letter, a lower-case letter, a number and a symbol.';
|
||||
Reference in New Issue
Block a user