From 979053ea4f4eb6c2b6782a1a40c8b41d22a7e711 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 27 Aug 2026 11:37:54 +0300 Subject: [PATCH 1/8] feat(auth): stage passenger sign-in on a single identifier field --- apps/edr-passenger-api/src/app.module.ts | 19 +- .../src/common/utils/phone.utils.ts | 77 +++ .../src/modules/auth/auth.controller.ts | 67 ++- .../src/modules/auth/auth.dto.ts | 61 +- .../modules/auth/passenger-auth.service.ts | 291 ++++++++- .../src/modules/bookings/bookings.service.ts | 34 +- .../portal/src/app/login/page.tsx | 560 +++++++++++++++--- .../portal/src/app/reset-password/page.tsx | 5 +- .../portal/src/app/verify-account/page.tsx | 12 +- .../portal/src/components/AppSidebar.tsx | 12 +- .../src/components/ChangePasswordModal.tsx | 5 +- .../src/components/FaydaSetupWizard.tsx | 5 +- .../portal/src/lib/api/auth.ts | 36 ++ .../portal/src/lib/auth-store.ts | 11 +- .../portal/src/lib/password.ts | 21 + 15 files changed, 1034 insertions(+), 182 deletions(-) create mode 100644 apps/edr-passenger-api/src/common/utils/phone.utils.ts create mode 100644 apps/edr-passenger-web/portal/src/lib/password.ts diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index bd8372b29..e3b85c21f 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -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 diff --git a/apps/edr-passenger-api/src/common/utils/phone.utils.ts b/apps/edr-passenger-api/src/common/utils/phone.utils.ts new file mode 100644 index 000000000..73ca8dc16 --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/phone.utils.ts @@ -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([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}`; +} diff --git a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts index 96ad8a178..ce076c970 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts @@ -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) diff --git a/apps/edr-passenger-api/src/modules/auth/auth.dto.ts b/apps/edr-passenger-api/src/modules/auth/auth.dto.ts index d7d80df94..76e748f93 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.dto.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts index 51c57fec8..ea0b201c6 100644 --- a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts +++ b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts @@ -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( - `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( + `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 { 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 { 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 { + const resolved = resolveIdentifier(identifier); + if (!resolved.email && resolved.phoneVariants.length === 0) return null; + + const rows = await this.dataSource.query( + `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 { diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index fc958be70..01367bdb1 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -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([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(); diff --git a/apps/edr-passenger-web/portal/src/app/login/page.tsx b/apps/edr-passenger-web/portal/src/app/login/page.tsx index 97a595244..5f7325072 100644 --- a/apps/edr-passenger-web/portal/src/app/login/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/login/page.tsx @@ -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; +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('identifier'); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const [showPassword, setShowPassword] = useState(false); - const { register, handleSubmit, formState: { errors } } = useForm({ - resolver: zodResolver(loginSchema as any), - }); + // Step 1 + const [identifier, setIdentifier] = useState(''); + const identifierRef = useRef(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 ``, 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 = ( +
+ 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" + /> + +
+ ); + + 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 (
@@ -54,86 +299,227 @@ function LoginContent() {
-

Sign in

-

Welcome back

+

{heading.title}

+

{heading.subtitle}

-
- {error && ( -
- {error} -
- )} - -
- - - {errors.email && ( -

{errors.email.message}

- )} + {error && ( +
+ {error}
+ )} -
- -
+ {step === 'identifier' && ( + +
+ + { setIdentifier(e.target.value); setError(''); }} + className="input-field" + placeholder="+251912345678 or your@email.com" + autoComplete="username" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + autoFocus + /> +
+ + + )} + + {step === 'password' && ( +
+ {identifierChip} +
+ +
+ { setPassword(e.target.value); setError(''); }} + className="input-field pr-10" + placeholder="••••••••" + autoComplete="current-password" + autoFocus + /> + +
+
+ + Forgot password? + +
+
+ +
+ )} + + {step === 'setup' && ( +
+ {identifierChip} +

+ {setupBlurb}{' '} + {maskedPhone + ? <>We sent a code to {maskedPhone}. + : 'We sent a code to your registered phone.'} +

+ +
+ + { 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 + /> +
+ +
+ +
+ { setNewPassword(e.target.value); setError(''); }} + className="input-field pr-10" + placeholder="••••••••" + autoComplete="new-password" + /> + +
+

{PASSWORD_RULE}

+
+ +
+ { setConfirmPassword(e.target.value); setError(''); }} + className="input-field" placeholder="••••••••" + autoComplete="new-password" /> -
- {errors.password && ( -

{errors.password.message}

- )} -
- - Forgot password? - + + + +
+ {resendNote ? ( + {resendNote} + ) : ( + + )}
-
+
+ )} - - + {step === 'signup' && ( +
+ {identifierChip} +

+ We couldn't find an account for that {identifierIsEmail ? 'email' : 'number'}, so + let's create one. +

-
-
- Don't have an account? - - Create account - -
- - - Already verified with Fayda? Set up your password - -
+
+ + { setFullName(e.target.value); setError(''); }} + className="input-field" + placeholder="e.g. Abebe Kebede" + autoComplete="name" + autoFocus + /> +
-
+
+ + { setSecondaryContact(e.target.value); setError(''); }} + className="input-field" + placeholder={identifierIsEmail ? '+251912345678' : 'your@email.com'} + autoComplete={identifierIsEmail ? 'tel' : 'email'} + /> +

+ {identifierIsEmail + ? "We'll text your verification code to this number." + : "For receipts and booking confirmations. Your verification code is sent by SMS either way."} +

+
+ + + + )} + +
) : ( -
+
- Sign in - - - Register + Sign in or register
)} diff --git a/apps/edr-passenger-web/portal/src/components/ChangePasswordModal.tsx b/apps/edr-passenger-web/portal/src/components/ChangePasswordModal.tsx index a20301e85..4edf30b64 100644 --- a/apps/edr-passenger-web/portal/src/components/ChangePasswordModal.tsx +++ b/apps/edr-passenger-web/portal/src/components/ChangePasswordModal.tsx @@ -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) { diff --git a/apps/edr-passenger-web/portal/src/components/FaydaSetupWizard.tsx b/apps/edr-passenger-web/portal/src/components/FaydaSetupWizard.tsx index 92f3843fc..535739c1d 100644 --- a/apps/edr-passenger-web/portal/src/components/FaydaSetupWizard.tsx +++ b/apps/edr-passenger-web/portal/src/components/FaydaSetupWizard.tsx @@ -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) { diff --git a/apps/edr-passenger-web/portal/src/lib/api/auth.ts b/apps/edr-passenger-web/portal/src/lib/api/auth.ts index b37361238..b7703d1b6 100644 --- a/apps/edr-passenger-web/portal/src/lib/api/auth.ts +++ b/apps/edr-passenger-web/portal/src/lib/api/auth.ts @@ -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 }), diff --git a/apps/edr-passenger-web/portal/src/lib/auth-store.ts b/apps/edr-passenger-web/portal/src/lib/auth-store.ts index 0d6e46fd9..443fa46a8 100644 --- a/apps/edr-passenger-web/portal/src/lib/auth-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/auth-store.ts @@ -113,13 +113,10 @@ export const useAuthStore = create((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 => { diff --git a/apps/edr-passenger-web/portal/src/lib/password.ts b/apps/edr-passenger-web/portal/src/lib/password.ts new file mode 100644 index 000000000..42b967d00 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/lib/password.ts @@ -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.'; From 1f5e6296b109cf5fb5b8eef89b6f85b7fc0a54e5 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 27 Aug 2026 09:28:52 +0000 Subject: [PATCH 2/8] feat(etrade): take the selected licence's trade name as the company name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A TIN routinely trades under a name that is not its registered one, and holds several licences with different trade names — of 58 TINs checked against eTrade, 8 had at least one licence whose trade name differs from the registered `BusinessName`, one of them across three licences. `extractRegistrationData` now resolves `companyName` from the selected licence's `TradeName`, falling back to `BusinessName` (16 of 309 licences carry a blank trade name, so the fallback is load-bearing). EIMS is pinned back to `BusinessName` for the seller's `LegalName`: an invoice is a MoR tax filing and must carry the legal entity, not the trade name. It is the only other caller. --- .../etrade-business-selection.spec.ts | 21 +++++++++++++++++++ .../companies/services/etrade.service.ts | 16 +++++++++----- .../modules/eims/eims-seller-cache.service.ts | 6 +++++- packages/types/src/freight/etrade.ts | 11 +++++++--- 4 files changed, 45 insertions(+), 9 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts b/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts index 86117d306..1400c069d 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts @@ -78,6 +78,27 @@ describe('ETradeService business selection', () => { expect(data.businesses?.[0].activity).toBe('Export trade in minerals'); }); + it("takes the selected licence's trade name as the company name", () => { + const { service } = build(); + const data = service.extractRegistrationData( + { + LicenceNumber: 'MT/AA/14/670/128936/2007', + TradeName: 'Pave Freight Forwarding', + } as ETradeBusinessInfo, + companyInfo(), + ); + expect(data.companyName).toBe('Pave Freight Forwarding'); + }); + + it('falls back to the registered name when the licence has no trade name', () => { + const { service } = build(); + const data = service.extractRegistrationData( + { LicenceNumber: 'x', TradeName: ' ' } as ETradeBusinessInfo, + companyInfo(), + ); + expect(data.companyName).toBe('PAVE LOGISTICS AND TRADING P L C'); + }); + it('lists every licence for the picker, code prefixes stripped', () => { const { service } = build(); const data = service.extractRegistrationData( diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts index fac57238a..5bbcefb7b 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -102,10 +102,16 @@ export class ETradeService { } /** - * `companyInfo` carries the registered organization name (`BusinessName`); - * `businessInfo` only carries the licence's `TradeName`. Pass both so the - * company name resolves to the legal entity rather than the trade name — and - * never to `ManagerNameEng`, which is the manager's personal name. + * `businessInfo` carries the selected licence's `TradeName`; `companyInfo` + * carries the registered organization name (`BusinessName`). The company name + * resolves to the trade name of the licence the customer picked — a TIN + * routinely trades under a name that is not its registered one, and the + * business they selected is the one they operate as here. `BusinessName` is + * the fallback, because eTrade leaves `TradeName` blank on plenty of licences. + * Never `ManagerNameEng`, which is the manager's personal name. + * + * Callers that need the legal entity (tax filings, EIMS seller details) must + * read `companyInfo.BusinessName` themselves — it is not this field. */ extractRegistrationData( businessInfo: ETradeBusinessInfo, @@ -115,7 +121,7 @@ export class ETradeService { return { companyName: - companyInfo?.BusinessName?.trim() || businessInfo.TradeName?.trim() || "", + businessInfo.TradeName?.trim() || companyInfo?.BusinessName?.trim() || "", licenceNumber: businessInfo.LicenceNumber, statusDescription: businessInfo.StatusDescription, dateRegistered: businessInfo.DateRegistered, diff --git a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts index be3e3aa8e..7afe644a1 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts @@ -118,7 +118,11 @@ export class EimsSellerCacheService implements OnModuleInit { woreda: data.woreda, }); this.cached = { - LegalName: data.companyName || undefined, + // The *legal* entity name, not the licence's trade name that + // `data.companyName` now carries — an EIMS seller is filed under its + // registered name. + LegalName: + companyInfo?.BusinessName?.trim() || data.companyName || undefined, Phone: data.mobilePhone || data.regularPhone || undefined, Region: geo?.Region, Wereda: geo?.Wereda, diff --git a/packages/types/src/freight/etrade.ts b/packages/types/src/freight/etrade.ts index c23492413..2af992b04 100644 --- a/packages/types/src/freight/etrade.ts +++ b/packages/types/src/freight/etrade.ts @@ -76,9 +76,14 @@ export interface ETradeBusinessOption { export interface CompanyRegistrationData { /** - * The registered organization name — `ETradeCompanyInfo.BusinessName`, falling - * back to the licence's `TradeName`. Never the manager/owner's personal name; - * that is {@link managerName}. + * The selected licence's trade name — `ETradeBusinessInfo.TradeName`, falling + * back to the registered organization name (`ETradeCompanyInfo.BusinessName`) + * when eTrade leaves the licence's trade name blank. Never the manager/owner's + * personal name; that is {@link managerName}. + * + * NOT the legal entity name: a TIN often trades under a different name, and + * some hold several licences with different trade names. Anything that needs + * the registered name (tax/EIMS) must read `BusinessName` directly. */ companyName: string; licenceNumber: string; From 604710bd2517304493a36b7aec1f15d40740dfc3 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 27 Aug 2026 09:29:15 +0000 Subject: [PATCH 3/8] feat(companies): attach an eTrade business to each company profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A TIN holds many business licences split by activity — export of coffee, freight forwarding, import of vehicles — but the company picked one for its whole record, so every operational role shared it. Each profile now names the business it actually operates as. Stored on `company_profiles.etrade_business` as a snapshot (licence number, trade name, activity, renewal) rather than a bare licence number, so the portal and backoffice can show it without an eTrade round-trip — that API is slow, serves a broken TLS chain and is regularly down. Not unique: one business may legitimately back several roles. The licence number is a client input, so it is never stored as sent — `ETradeService.findBusinessOption` looks it up under the company's own TIN and persists eTrade's record, which makes another company's licence simply unfindable. Choosing one is required wherever the customer adds a role with a TIN already on file. The onboarding wizard is the exception by necessity: it picks roles on its first step, before a TIN exists, so there is nothing to choose from yet. There it is enforced through `getOnboardingRequirements` instead — an unattached role is reported outstanding and blocks submission — and the picker sits on the documents step beside that role's licence upload. Lifted entirely for a co-operative or investment-licence company: eTrade holds no record for its TIN, so the requirement would be unsatisfiable. --- ...0000000000-CompanyProfileEtradeBusiness.ts | 36 +++++ .../modules/companies/companies.controller.ts | 36 ++++- .../companies.fayda-identity.spec.ts | 15 +- .../companies.poa-delegation.spec.ts | 17 +- .../companies.profile-etrade-business.spec.ts | 149 ++++++++++++++++++ .../modules/companies/companies.service.ts | 128 ++++++++++++++- .../companies/dto/add-company-profiles.dto.ts | 34 +++- .../dto/attach-etrade-business.dto.ts | 13 ++ .../dto/create-company-profile.dto.ts | 10 ++ .../dto/create-company-with-profile.dto.ts | 10 ++ .../onboarding-requirements-response.dto.ts | 6 + .../companies/dto/response-company.dto.ts | 8 + .../entities/company-profile.entity.ts | 18 +++ .../companies/services/etrade.service.ts | 63 ++++++-- .../onboarding/EtradeBusinessSelect.tsx | 110 +++++++++++++ .../onboarding/OnboardingWizardDialog.tsx | 1 + .../components/onboarding/RoleLicenseStep.tsx | 59 ++++++- .../portal/src/constants/URLS.ts | 3 + .../portal/src/hooks/useAuth.ts | 10 +- .../src/pages/contracts/NewContractPage.tsx | 34 +++- .../src/pages/settings/CompanyRolesCard.tsx | 137 +++++++++++++--- .../portal/src/services/api.ts | 28 +++- .../portal/src/services/companies.service.ts | 32 +++- .../cypress/e2e/flows/onboarding-utils.ts | 16 ++ .../e2e/flows/onboarding_ethiopian.cy.ts | 2 + .../e2e/flows/onboarding_switch_back.cy.ts | 2 + 26 files changed, 922 insertions(+), 55 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3760000000000-CompanyProfileEtradeBusiness.ts create mode 100644 apps/edr-freight-api/src/modules/companies/companies.profile-etrade-business.spec.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/attach-etrade-business.dto.ts create mode 100644 apps/edr-freight-web/portal/src/components/onboarding/EtradeBusinessSelect.tsx diff --git a/apps/edr-freight-api/src/migrations/3760000000000-CompanyProfileEtradeBusiness.ts b/apps/edr-freight-api/src/migrations/3760000000000-CompanyProfileEtradeBusiness.ts new file mode 100644 index 000000000..7e2db4eb3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3760000000000-CompanyProfileEtradeBusiness.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Attaches an eTrade business licence to each operational profile. + * + * A TIN routinely holds a dozen or more licences, split by activity ("Export + * trade in coffee", "Freight Forwarders"), and until now the company picked one + * for the whole record — every role shared it. Each profile now names the + * business it actually operates as. + * + * Stored as a snapshot ({@link ETradeBusinessOption}: licenceNumber, tradeName, + * activity, renewedTo) rather than a bare licence number, so the portal and the + * backoffice can show which business is attached without an eTrade round-trip — + * eTrade is slow, serves a broken TLS chain, and is regularly down. + * + * Nullable: existing profiles have none until the customer attaches one, and a + * co-operative or investor-licence company has no eTrade record at all. + * Deliberately NOT unique — one business can back several profiles. + */ +export class CompanyProfileEtradeBusiness3760000000000 implements MigrationInterface { + name = 'CompanyProfileEtradeBusiness3760000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.company_profiles + ADD COLUMN IF NOT EXISTS etrade_business jsonb + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.company_profiles + DROP COLUMN IF EXISTS etrade_business + `); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 5d29b32f6..000b0733f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -32,7 +32,9 @@ import { CreateCompanyDto } from "./dto/create-company.dto"; import { UpdateCompanyDto } from "./dto/update-company.dto"; import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; +import type { ETradeBusinessOption } from "@edr/types"; import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto"; +import { AttachEtradeBusinessDto } from "./dto/attach-etrade-business.dto"; import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto"; import { CompanyIdentityStateDto, @@ -260,11 +262,42 @@ export class CompaniesController { ): Promise { const profiles = await this.companiesService.addCompanyProfilesForUser( user.id, - dto.types, + dto.profiles, ); return profiles.map((p) => new ResponseCompanyProfileDto(p)); } + @Get("etrade-businesses") + @PortalCustomer() + @ApiOperation({ + summary: + "The eTrade business licences under this company's TIN, for attaching to its operational profiles", + }) + async listEtradeBusinesses( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.listEtradeBusinessesForUser(user.id); + } + + @Patch("company-profiles/:profileId/etrade-business") + @PortalCustomer() + @ApiOperation({ + summary: + "Attach one of the TIN's eTrade businesses to an operational profile (re-attaching refreshes the stored snapshot)", + }) + async attachEtradeBusiness( + @CurrentUser() user: CurrentIamUser, + @Param("profileId") profileId: string, + @Body() dto: AttachEtradeBusinessDto, + ): Promise { + const profile = await this.companiesService.attachEtradeBusinessToProfile( + user.id, + profileId, + dto.licenceNumber, + ); + return new ResponseCompanyProfileDto(profile); + } + @Post("onboarding/start") @PortalCustomer() @ApiOperation({ @@ -321,6 +354,7 @@ export class CompaniesController { user.id, dto.type, dto.businessLicense, + dto.licenceNumber, ); return new ResponseCompanyProfileDto(profile); } diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index 30c323ac3..da2497d90 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -162,7 +162,16 @@ function makeService(overrides: Partial = {}) { {} as never, deps.filesService as never, deps.fileUploadSettings as never, - {} as never, + // Only the business-licence lookup is exercised here: adding a role now + // resolves which eTrade business it operates as. + { + findBusinessOption: async (_tin: string, licenceNumber: string) => ({ + licenceNumber, + tradeName: "Test Trade Name", + activity: "Freight Forwarders", + renewedTo: "7/7/2026", + }), + } as never, deps.companyNotifier as never, {} as never, deps.verifayda as never, @@ -502,7 +511,9 @@ describe("the owner is checked against the eTrade licence", () => { describe("the freight-forwarder gate", () => { const addForwarder = (service: CompaniesService) => - service.addCompanyProfilesForUser("user-1", [ProfileType.freightForwarder]); + service.addCompanyProfilesForUser("user-1", [ + { type: ProfileType.freightForwarder, licenceNumber: "LIC-1" }, + ]); it("blocks the role while the representative is unverified", async () => { const { service } = makeService({ attributes: { poaDeclared: "yes" } }); diff --git a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts index 96dc3ff53..b801a793f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts @@ -133,7 +133,16 @@ function makeService(overrides: Partial = {}) { {} as never, deps.filesService as never, {} as never, - {} as never, + // Only the business-licence lookup is exercised here: adding a role now + // resolves which eTrade business it operates as. + { + findBusinessOption: async (_tin: string, licenceNumber: string) => ({ + licenceNumber, + tradeName: "Test Trade Name", + activity: "Freight Forwarders", + renewedTo: "7/7/2026", + }), + } as never, deps.companyNotifier as never, {} as never, {} as never, @@ -200,6 +209,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => { service.createCompanyProfileForUser( "user-1", ProfileType.freightForwarder, + undefined, + "LIC-1", ), ).rejects.toBeInstanceOf(BadRequestException); }); @@ -263,6 +274,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => { service.createCompanyProfileForUser( "user-1", ProfileType.freightForwarder, + undefined, + "LIC-1", ), ).rejects.toBeInstanceOf(BadRequestException); }); @@ -277,6 +290,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => { service.createCompanyProfileForUser( "user-1", ProfileType.freightForwarder, + undefined, + "LIC-1", ), ).resolves.toBeDefined(); }); diff --git a/apps/edr-freight-api/src/modules/companies/companies.profile-etrade-business.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.profile-etrade-business.spec.ts new file mode 100644 index 000000000..c04ce40f9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.profile-etrade-business.spec.ts @@ -0,0 +1,149 @@ +import { BadRequestException, NotFoundException } from "@nestjs/common"; +import { CompaniesService } from "./companies.service"; +import { ProfileType } from "./entities/company-profile.entity"; +import { COOPERATIVE_KEY } from "./entities/company.entity"; + +/** + * A TIN holds many business licences; each operational profile names the one it + * trades as. What matters here is that the stored business is always eTrade's + * own record, looked up under the company's own TIN — never the client's word + * for it — and that the requirement lifts for a company eTrade knows nothing + * about. + */ +const BUSINESSES = [ + { + licenceNumber: "MT/AA/14/670/128936/2007", + tradeName: "Pave Freight Forwarding", + activity: "Freight Forwarders", + renewedTo: "7/7/2026", + }, + { + licenceNumber: "MT/AA/14/670/11551235/2017", + tradeName: "Pave Minerals Export", + activity: "Export trade in minerals", + renewedTo: "7/7/2026", + }, +]; + +function makeService(attributes: Record = {}) { + const company = { + id: "company-1", + tin: "0045014036", + type: "customer", + attributes, + companyProfiles: [{ id: "profile-1", type: ProfileType.exporter }], + }; + + const created: Record[] = []; + const companyProfilesRepo = { + findByCompanyId: jest.fn(async () => created), + findByType: jest.fn(async () => null), + create: jest.fn(async (row: Record) => { + created.push({ id: `profile-${created.length + 2}`, ...row }); + return created[created.length - 1]; + }), + update: jest.fn(async (id: string, data: Record) => ({ + id, + ...data, + })), + }; + + const etradeService = { + listBusinessOptions: jest.fn(async () => BUSINESSES), + findBusinessOption: jest.fn(async (_tin: string, licenceNumber: string) => { + const match = BUSINESSES.find((b) => b.licenceNumber === licenceNumber); + if (!match) throw new BadRequestException("no such licence"); + return match; + }), + }; + + const service = new CompaniesService( + {} as never, + companyProfilesRepo as never, + {} as never, + {} as never, + { findByUserId: jest.fn(async () => ({ id: "ext-1", companyId: "company-1" })) } as never, + {} as never, + {} as never, + {} as never, + etradeService as never, + {} as never, + {} as never, + {} as never, + ); + + jest + .spyOn(service, "getCompanyInfoByUserId") + .mockImplementation(async () => ({ profile: {}, company }) as never); + // Private, but every add path goes through it; stubbing it keeps this spec on + // the business-attachment logic instead of the whole company lookup graph. + (service as unknown as Record).findCompanyById = async () => + company; + + return { service, companyProfilesRepo, etradeService }; +} + +describe("attaching an eTrade business to a company profile", () => { + it("stores eTrade's own record for the chosen licence, not the client's", async () => { + const { service, companyProfilesRepo } = makeService(); + const updated = await service.attachEtradeBusinessToProfile( + "user-1", + "profile-1", + "MT/AA/14/670/128936/2007", + ); + expect(companyProfilesRepo.update).toHaveBeenCalledWith("profile-1", { + etradeBusiness: BUSINESSES[0], + }); + expect(updated.etradeBusiness).toEqual(BUSINESSES[0]); + }); + + it("refuses a licence eTrade does not list under this TIN", async () => { + const { service } = makeService(); + await expect( + service.attachEtradeBusinessToProfile("user-1", "profile-1", "SOMEONE/ELSES/LICENCE"), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("refuses a profile belonging to another company", async () => { + const { service } = makeService(); + await expect( + service.attachEtradeBusinessToProfile("user-1", "not-mine", BUSINESSES[0].licenceNumber), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it("the same business may back more than one profile", async () => { + const { service, etradeService } = makeService(); + await service.addCompanyProfilesForUser("user-1", [ + { type: ProfileType.exporter, licenceNumber: BUSINESSES[0].licenceNumber }, + { type: ProfileType.importer, licenceNumber: BUSINESSES[0].licenceNumber }, + ]); + expect(etradeService.findBusinessOption).toHaveBeenCalledTimes(2); + }); +}); + +describe("choosing a business is required when the company has one to choose", () => { + it("rejects a role added without a licence", async () => { + const { service } = makeService(); + await expect( + service.addCompanyProfilesForUser("user-1", [{ type: ProfileType.exporter }]), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("lifts the requirement for a co-operative, which has no eTrade record", async () => { + const { service, companyProfilesRepo, etradeService } = makeService({ + [COOPERATIVE_KEY]: true, + }); + await service.addCompanyProfilesForUser("user-1", [ + { type: ProfileType.exporter }, + ]); + expect(etradeService.findBusinessOption).not.toHaveBeenCalled(); + expect(companyProfilesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ etradeBusiness: null }), + ); + }); + + it("offers a co-operative no businesses to pick from", async () => { + const { service } = makeService({ [COOPERATIVE_KEY]: true }); + await expect(service.listEtradeBusinessesForUser("user-1")).resolves.toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index d7151f200..1080ceb12 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -47,7 +47,7 @@ import { ETradeService } from "./services/etrade.service"; import { CompanyNotifierService } from "./company-notifier.service"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; -import type { CompanyRegistrationData } from "@edr/types"; +import type { CompanyRegistrationData, ETradeBusinessOption } from "@edr/types"; import { CreateCompanyDto } from "./dto/create-company.dto"; import { UpdateCompanyDto } from "./dto/update-company.dto"; import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; @@ -343,6 +343,11 @@ export class CompaniesService { companyId: company.id, type: input.type, businessLicense: input.businessLicense ?? null, + etradeBusiness: await this.resolveProfileBusiness( + company, + input.licenceNumber, + input.type, + ), status: ProfileStatus.Pending, }); } @@ -2149,8 +2154,9 @@ export class CompaniesService { */ async addCompanyProfilesForUser( userId: string, - types: ProfileType[], + inputs: Array<{ type: ProfileType; licenceNumber?: string }>, ): Promise { + const types = inputs.map((i) => i.type); const profile = await this.profilesRepo.findByUserId(userId); if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`); @@ -2185,11 +2191,21 @@ export class CompaniesService { ); } + // Which eTrade business this role operates as. Resolved (and rejected if + // absent) BEFORE the row is created, so a role never lands unattached on + // a company that has licences to pick from. + const etradeBusiness = await this.resolveProfileBusiness( + company, + inputs.find((i) => i.type === type)?.licenceNumber, + type, + ); + // Self-service role adds start Pending and carry no reference — a reference // is minted only when a backoffice reviewer approves the role. await this.companyProfilesRepo.create({ companyId, type, + etradeBusiness, status: ProfileStatus.Pending, }); } @@ -2207,6 +2223,7 @@ export class CompaniesService { userId: string, type: ProfileType, businessLicense?: string, + licenceNumber?: string, ): Promise { const profile = await this.profilesRepo.findByUserId(userId); if (!profile) @@ -2232,12 +2249,18 @@ export class CompaniesService { ); } if (!created) { + const etradeBusiness = await this.resolveProfileBusiness( + company, + licenceNumber, + type, + ); // New self-service roles start Pending (awaiting backoffice approval) and // carry no reference until approved. created = await this.companyProfilesRepo.create({ companyId, type, businessLicense: businessLicense ?? null, + etradeBusiness, status: ProfileStatus.Pending, }); } @@ -2320,6 +2343,7 @@ export class CompaniesService { type: p.type, reference: p.reference ?? "", uploaded: records.some((r) => r.code === LICENSE_CODE), + etradeBusiness: p.etradeBusiness ?? null, }; }), ); @@ -2331,6 +2355,19 @@ export class CompaniesService { ? [] : licenseProfiles.filter((p) => !p.uploaded); + // Which eTrade business each role operates as. Enforced here rather than at + // role creation because the wizard picks roles on its FIRST step, before a + // TIN has been entered — there is nothing to pick from yet. The customer + // attaches one on the documents step, alongside that role's licence file, + // and onboarding cannot be submitted until every role has one. + // + // Lifted for a company with no eTrade record at all: a co-operative or a + // foreign investor has no licence list, so the requirement would be + // unsatisfiable (see `usesManualRegistration`). + const missingBusinesses = usesManualRegistration(company) + ? [] + : licenseProfiles.filter((p) => !p.etradeBusiness); + // 4. Power of Attorney. Whether there is one at all is the company's own // declaration — the question the wizard asks outright — and that answer is // what decides whose identity gets verified, so an unanswered one is itself @@ -2378,6 +2415,10 @@ export class CompaniesService { (p) => `Upload a business license for your ${p.type.replace(/_/g, " ")} profile`, ), + ...missingBusinesses.map( + (p) => + `Choose which eTrade business your ${p.type.replace(/_/g, " ")} profile operates as`, + ), ...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`), ...(missingDelegation ? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`] @@ -2416,6 +2457,8 @@ export class CompaniesService { requiredInfo.length + requiredDocCount + (cooperative ? 0 : licenseProfiles.length) + + // One "which business?" item per role, on the same terms as the licences. + (usesManualRegistration(company) ? 0 : licenseProfiles.length) + poaItemCount + // The declaration and the verification it selects. 2; @@ -2424,6 +2467,7 @@ export class CompaniesService { (missingInfo.length + missingDocs.length + missingLicenses.length + + missingBusinesses.length + missingPoaFields.length + (missingDelegation || flaggedDelegation ? 1 : 0) + missingIdentityCount); @@ -3747,6 +3791,86 @@ export class CompaniesService { return match?.id ?? null; } + /** + * Resolve the eTrade business a new/updated profile is being attached to. + * + * The client sends a licence number; what gets stored is eTrade's own record + * of it, looked up under THIS company's TIN. That is the whole check — a + * licence belonging to someone else's TIN simply is not in the list, so a + * client cannot attach a profile to a business the company does not hold. + * + * Returns null (rather than throwing) for a company that registered without + * eTrade: a co-operative union or farm holds no business licence, and a + * foreign investor's licence is the Investment Commission's, not the trade + * registry's. There is no list for them to pick from, so the role is theirs + * to hold unattached — the reviewer checks their uploaded documents instead. + */ + private async resolveProfileBusiness( + company: Company, + licenceNumber: string | undefined, + type: ProfileType, + ): Promise { + if (usesManualRegistration(company)) return null; + if (!licenceNumber) { + throw new BadRequestException( + `Choose which of your eTrade business licences the ${type.replace(/_/g, " ")} profile operates as.`, + ); + } + return this.etradeService.findBusinessOption(company.tin, licenceNumber); + } + + /** + * The eTrade business licences the current user's company can attach to its + * operational profiles. Empty for a company that registered without eTrade. + */ + async listEtradeBusinessesForUser( + userId: string, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + if (usesManualRegistration(company)) return []; + return this.etradeService.listBusinessOptions(company.tin); + } + + /** + * Attach (or re-attach) one of the TIN's eTrade businesses to a profile. + * + * Separate from role creation because the onboarding wizard picks roles + * before the TIN is known — the business is chosen later, on the step that + * already collects each role's licence document. Re-attaching also refreshes + * the stored snapshot, which is how a renewed licence's new expiry lands. + */ + async attachEtradeBusinessToProfile( + userId: string, + profileId: string, + licenceNumber: string, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + const profile = (company.companyProfiles ?? []).find( + (p) => p.id === profileId, + ); + if (!profile) { + throw new NotFoundException( + `Company profile ${profileId} not found for this company`, + ); + } + if (usesManualRegistration(company)) { + throw new BadRequestException( + "This company is not registered with eTrade, so it has no business licences to attach.", + ); + } + const business = await this.etradeService.findBusinessOption( + company.tin, + licenceNumber, + ); + const updated = await this.companyProfilesRepo.update(profile.id, { + etradeBusiness: business, + }); + if (!updated) { + throw new NotFoundException(`Company profile ${profileId} not found`); + } + return updated; + } + /** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */ private async resolveEtradeRegistration( tin: string, diff --git a/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts index 838c42111..8809310cc 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts @@ -1,9 +1,37 @@ -import { IsArray, IsEnum, ArrayMinSize } from "class-validator"; +import { Type } from "class-transformer"; +import { + ArrayMinSize, + IsArray, + IsEnum, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from "class-validator"; import { ProfileType } from "../entities/company-profile.entity"; +export class AddCompanyProfileInputDto { + @IsEnum(ProfileType) + type!: ProfileType; + + /** + * Which of the TIN's eTrade business licences this role operates as. + * + * Optional at the DTO layer, required by the service for any company that + * HAS an eTrade record — a co-operative or investor-licence company has none + * to pick from, and rejecting them here would be wrong. See + * `CompaniesService.resolveProfileBusiness`. + */ + @IsOptional() + @IsString() + @MaxLength(120) + licenceNumber?: string; +} + export class AddCompanyProfilesDto { @IsArray() @ArrayMinSize(1) - @IsEnum(ProfileType, { each: true }) - types!: ProfileType[]; + @ValidateNested({ each: true }) + @Type(() => AddCompanyProfileInputDto) + profiles!: AddCompanyProfileInputDto[]; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/attach-etrade-business.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/attach-etrade-business.dto.ts new file mode 100644 index 000000000..3ee50b51a --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/attach-etrade-business.dto.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsString, MaxLength } from "class-validator"; + +export class AttachEtradeBusinessDto { + /** + * The eTrade licence number of the business this profile operates as. Checked + * against the licences eTrade lists under the company's own TIN, so an + * unknown or someone else's licence is rejected rather than stored. + */ + @IsString() + @IsNotEmpty() + @MaxLength(120) + licenceNumber!: string; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts index 9ac6c13b7..9bb5453ee 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts @@ -9,4 +9,14 @@ export class CreateCompanyProfileDto { @IsString() @MaxLength(100) businessLicense?: string; + + /** + * Which of the TIN's eTrade business licences this role operates as. Required + * by the service for any company that has an eTrade record; see + * `AddCompanyProfileInputDto.licenceNumber`. + */ + @IsOptional() + @IsString() + @MaxLength(120) + licenceNumber?: string; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts index d82093336..db58d0bd7 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts @@ -22,6 +22,16 @@ export class CompanyProfileInputDto { @IsString() @MaxLength(100) businessLicense?: string; + + /** + * Which of the TIN's eTrade business licences this role operates as. Required + * by the service for any company that has an eTrade record; see + * `CompaniesService.resolveProfileBusiness`. + */ + @IsOptional() + @IsString() + @MaxLength(120) + licenceNumber?: string; } export class CreateCompanyWithProfileDto { diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index 49dad4b8d..a31e517d5 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -8,6 +8,7 @@ * truth the wizard uses to auto-finish. */ +import type { ETradeBusinessOption } from "@edr/types"; import { CompanyIdentityStateDto, PoaDeclaration, @@ -38,6 +39,11 @@ export interface OnboardingLicenseProfile { reference: string; /** True when at least one business-license file is stored on the profile. */ uploaded: boolean; + /** + * The eTrade business this role operates as, once the customer has attached + * one. Null while outstanding — the wizard renders the picker off this. + */ + etradeBusiness: ETradeBusinessOption | null; } export interface OnboardingPoaState { diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 7c4348e4f..321d739e3 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -6,6 +6,7 @@ import { hasInvestorLicence, isCooperative, } from '../entities/company.entity'; +import type { ETradeBusinessOption } from '@edr/types'; import { CompanyProfile, ProfileLicenseFileView, @@ -31,6 +32,12 @@ export class ResponseCompanyProfileDto { */ licenseFiles: ProfileLicenseFileView[]; attributes?: Record | null; + /** + * The eTrade business licence this role operates as, or null when nothing is + * attached yet (or the company registered without eTrade). Snapshot — see + * `CompanyProfile.etradeBusiness`. + */ + etradeBusiness?: ETradeBusinessOption | null; /** Reviewer note when the role is rejected (drives the reapply prompt). */ reviewNote?: string | null; createdAt: Date; @@ -45,6 +52,7 @@ export class ResponseCompanyProfileDto { this.businessLicense = profile.businessLicense; this.licenseFiles = []; this.attributes = profile.attributes; + this.etradeBusiness = profile.etradeBusiness ?? null; this.reviewNote = profile.reviewNote ?? null; this.createdAt = profile.createdAt; this.updatedAt = profile.updatedAt; diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index 9bba9396e..0a16343f4 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -1,4 +1,5 @@ import { BaseEntity } from "@edr/api-common"; +import type { ETradeBusinessOption } from "@edr/types"; import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; import { Company } from "./company.entity"; @@ -126,6 +127,23 @@ export class CompanyProfile extends BaseEntity { @Column({ name: "business_license_files", type: "jsonb", nullable: true }) businessLicenseFiles?: BusinessLicenseFile[] | null; + /** + * Which of the TIN's eTrade business licences this profile operates as. + * + * A TIN holds many licences split by activity, so "exporter" and "freight + * forwarder" are usually two different businesses under one company. Stored + * as a snapshot rather than a bare licence number so the trade name and + * activity render without an eTrade call — that API is slow and regularly + * down, and this is display data, not a source of truth. Re-attaching + * refreshes it. + * + * NULL when nothing is attached yet, or when the company registered without + * eTrade at all (co-operative / investor licence — see + * {@link usesManualRegistration}). One business may back several profiles. + */ + @Column({ name: "etrade_business", type: "jsonb", nullable: true }) + etradeBusiness?: ETradeBusinessOption | null; + @Column({ name: "attributes", type: "jsonb", nullable: true }) attributes?: Record | null; diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts index 5bbcefb7b..9a258099c 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -5,6 +5,7 @@ import { firstValueFrom } from "rxjs"; import { ETradeCompanyInfo, ETradeBusinessInfo, + ETradeBusinessOption, CompanyRegistrationData, normalizeRegion, } from "@edr/types"; @@ -143,17 +144,57 @@ export class ETradeService { regularPhone: businessInfo.AddressInfo?.RegularPhone || "", managerName: primaryManager?.ManagerNameEng || "", managerPhone: primaryManager?.RegularPhone || "", - businesses: (companyInfo?.Businesses ?? []).map((b) => ({ - licenceNumber: b.LicenceNumber, - tradeName: b.TradesName?.trim() || "", - activity: (b.SubGroups ?? []) - // Some descriptions repeat the code inline ("(65611)Import trade …"). - // eTrade also puts null entries in this array, so every hop is optional. - .map((g) => g?.Description?.replace(/^\(\d+\)\s*/, "").trim()) - .filter(Boolean) - .join(", "), - renewedTo: b.RenewedTo || "", - })), + businesses: (companyInfo?.Businesses ?? []).map(toBusinessOption), }; } + + /** + * Every business licence held under a TIN, as the customer picks them. + * + * Split out from {@link extractRegistrationData} because attaching a business + * to a company profile needs the list alone — no licence detail fetch, so one + * eTrade call instead of two. + */ + async listBusinessOptions(tin: string): Promise { + const companyInfo = await this.getCompanyInfoByTin(tin); + return (companyInfo.Businesses ?? []).map(toBusinessOption); + } + + /** + * Resolve one of the TIN's licences, or throw if eTrade does not list it. + * + * This is the trust boundary for a client-supplied licence number: a profile + * may only ever be attached to a business eTrade actually holds under that + * TIN, so the snapshot that gets stored is eTrade's own data, never the + * client's. + */ + async findBusinessOption( + tin: string, + licenceNumber: string, + ): Promise { + const options = await this.listBusinessOptions(tin); + const match = options.find((b) => b.licenceNumber === licenceNumber); + if (!match) { + throw new BadRequestException( + `eTrade lists no business licence "${licenceNumber}" under TIN ${tin}.`, + ); + } + return match; + } +} + +function toBusinessOption( + b: ETradeCompanyInfo["Businesses"][number], +): ETradeBusinessOption { + return { + licenceNumber: b.LicenceNumber, + tradeName: b.TradesName?.trim() || "", + activity: (b.SubGroups ?? []) + // Some descriptions repeat the code inline ("(65611)Import trade …"). + // eTrade also puts null entries in this array, so every hop is optional. + .map((g) => g?.Description?.replace(/^\(\d+\)\s*/, "").trim()) + .filter(Boolean) + .join(", "), + renewedTo: b.RenewedTo || "", + }; } diff --git a/apps/edr-freight-web/portal/src/components/onboarding/EtradeBusinessSelect.tsx b/apps/edr-freight-web/portal/src/components/onboarding/EtradeBusinessSelect.tsx new file mode 100644 index 000000000..f1be4ab86 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/onboarding/EtradeBusinessSelect.tsx @@ -0,0 +1,110 @@ +import { Alert, Loader, Select, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { AlertCircle } from "lucide-react"; +import { useMemo } from "react"; + +import type { ETradeBusinessOption } from "@edr/types"; +import { api } from "@/services/api"; + +/** + * The eTrade business licences held under the signed-in company's TIN, cached + * for the session. Fetching goes out to eTrade, which is slow and regularly + * down, so this must not refetch on every mount of every role card. + */ +export function useEtradeBusinesses() { + return useQuery({ + ...api.companies.listEtradeBusinesses.queryOptions(), + staleTime: 5 * 60 * 1000, + retry: 1, + }); +} + +/** One licence, as it reads in the dropdown: trade name, then what it licenses. */ +export function businessLabel(b: ETradeBusinessOption): string { + const name = b.tradeName || "(no trade name on this licence)"; + return b.activity ? `${name} — ${b.activity}` : name; +} + +interface EtradeBusinessSelectProps { + /** Currently attached licence number, if any. */ + value: string | null; + onChange: (licenceNumber: string) => void; + label?: string; + error?: string; + disabled?: boolean; +} + +/** + * Which of the TIN's eTrade businesses a company profile operates as. + * + * A TIN routinely holds a dozen licences split by activity — export of coffee, + * freight forwarding, import of vehicles — so the role a customer signs up for + * corresponds to one specific business, not to the company as a whole. The same + * business may legitimately back several roles, so nothing is filtered out + * because it is already in use elsewhere. + */ +export default function EtradeBusinessSelect({ + value, + onChange, + label = "Which business does this profile operate as?", + error, + disabled, +}: EtradeBusinessSelectProps) { + const { data, isLoading, isError } = useEtradeBusinesses(); + + const options = useMemo( + () => + (data ?? []).map((b) => ({ + value: b.licenceNumber, + label: businessLabel(b), + })), + [data], + ); + + if (isLoading) { + return ( + + + {label} + + + + ); + } + + if (isError) { + return ( + }> + We couldn't reach eTrade to list your business licences. Try again in a + moment. + + ); + } + + if (options.length === 0) { + return ( + + eTrade lists no business licence under your TIN, so there is nothing to + attach here. + + ); + } + + return ( +