diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 1e41076e7..e552fad8c 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -64,6 +64,7 @@ import { TasksModule } from './modules/tasks/tasks.module'; import { AppReleasesModule } from './modules/app-releases/app-releases.module'; import { ConfigurableFareModule } from './modules/configurable-fare/configurable-fare.module'; import { SegmentFareSeeder } from './seed/segment-fare.seeder'; +import { EOtpType } from "@tria-plc/iamapi-common"; @Module({ imports: [ @@ -97,6 +98,16 @@ import { SegmentFareSeeder } from './seed/segment-fare.seeder'; TriaIamModule.forRoot({ applications: [EDR_PASSENGER_APPLICATION], permissions: EDR_PASSENGER_PERMISSIONS, + otpMessages: { + [EOtpType.MFA_LOGIN]: ({ otp }) => + `Your EDR Passenger login code is ${otp}. It will expire in 5 minutes.`, + [EOtpType.VERIFY_PHONE_NUMBER]: ({ otp }) => + `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}`, + }, }), SharedAuthModule, PrismaModule, 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 1784261e5..2b15cdc7c 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 @@ -42,11 +42,7 @@ export class PassengerAuthService { } async register(dto: RegisterDto, req: any) { - const existing = await this.dataSource.query<{ id: string }[]>( - `SELECT id FROM iam.users WHERE email = $1 OR phone_number = $2 LIMIT 1`, - [dto.email, dto.phoneNumber], - ); - if (existing.length) throw new ConflictException('Email or phone already registered'); + await this.clearPendingOrConflict(dto.email, dto.phoneNumber); const iamAuthService = await this.resolveIamAuthService(req); @@ -101,11 +97,7 @@ export class PassengerAuthService { }, req: any, ): Promise<{ iamUserId: string; passengerId: string }> { - const existing = await this.dataSource.query<{ id: string }[]>( - `SELECT id FROM iam.users WHERE email = $1 OR phone_number = $2 LIMIT 1`, - [dto.email, dto.phoneNumber], - ); - if (existing.length) throw new ConflictException('Email or phone already registered'); + await this.clearPendingOrConflict(dto.email, dto.phoneNumber); const iamAuthService = await this.resolveIamAuthService(req); await iamAuthService.signupWithPassword({ @@ -600,6 +592,32 @@ export class PassengerAuthService { return `+${digits}`; } + /** + * Pre-signup uniqueness guard. Throws `ConflictException` only when a + * *fully-registered* account (`has_set_password = true`) already owns the + * email or phone. Abandoned PENDING signups — where the user received the OTP + * but never completed `set-password` — are deleted so this fresh attempt can + * re-create the account and re-send the code, instead of being blocked with a + * 409 forever. Matches `resendRegistrationCode`'s `has_set_password = false` + * notion of "still pending". + */ + private async clearPendingOrConflict(email: string, phoneNumber: string): Promise { + const matches = await this.dataSource.query< + { id: string; email: string; has_set_password: boolean }[] + >( + `SELECT id, email, has_set_password FROM iam.users WHERE email = $1 OR phone_number = $2`, + [email, phoneNumber], + ); + if (!matches.length) return; + if (matches.some((u) => u.has_set_password)) { + throw new ConflictException('Email or phone already registered'); + } + // Every match is an abandoned pending signup — clean it up so the caller can proceed. + for (const u of matches) { + await this.compensateIamSignup(u.email); + } + } + private async compensateIamSignup(email: string): Promise { try { const rows = await this.dataSource.query<{ id: string }[]>( diff --git a/apps/edr-passenger-web/portal/src/app/go/page.tsx b/apps/edr-passenger-web/portal/src/app/go/page.tsx index 255b3980a..d9f6f583f 100644 --- a/apps/edr-passenger-web/portal/src/app/go/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/go/page.tsx @@ -2,9 +2,25 @@ import { Suspense, useEffect, useMemo } from "react"; import { useSearchParams } from "next/navigation"; -import { Loader2, ShieldAlert, ExternalLink } from "lucide-react"; - +/** + * /go — payment redirect bounce page for D-Money web checkout. + * + * The redirect is done CLIENT-SIDE on purpose: the navigation must originate + * from the loaded https://edrpassenger.triaplc.com/go document so the browser + * sends `Referer: https://edrpassenger.triaplc.com` to D-Money. D-Money only + * whitelists that origin, so a server-side 307 (whose referrer on the redirect + * hop is browser-dependent and can be stripped) must NOT be used here. + * + * It fires immediately (no delay) and paints a bare white full-screen cover + * above the sticky header (z-[60]) — no portal chrome, no text on the happy + * path. A short message shows only when the link is missing/untrusted. + * + * `?url=` MUST be percent-encoded by the caller (Uri.encodeComponent() in the + * Flutter app / encodeURIComponent() on web); otherwise the query parser + * truncates the D-Money URL at its first `&` and merch_code/sign are lost. + * See scripts/test-go-redirect.mjs. + */ const ALLOWED_HOSTS = ( process.env.NEXT_PUBLIC_DMONEY_ALLOWED_HOSTS ?? "d-money.dj" @@ -29,8 +45,6 @@ function isTrustedDMoneyUrl(raw: string | null): raw is string { ); } -const REDIRECT_DELAY_MS = 1000; - function RedirectView() { const searchParams = useSearchParams(); const raw = searchParams.get("url"); @@ -38,63 +52,27 @@ function RedirectView() { useEffect(() => { if (!target) return; - const timer = setTimeout(() => { - - window.location.replace(target); - }, REDIRECT_DELAY_MS); - return () => clearTimeout(timer); + // Navigate from this document so the D-Money request carries + // Referer: https://edrpassenger.triaplc.com (the origin D-Money whitelists). + window.location.replace(target); }, [target]); - if (!target) { - return ( -
-
- -
-

- Can't continue -

-

- This link is missing a valid D-Money checkout address or points to an - untrusted destination. Please start the payment again from the app. -

-
- ); - } - return ( -
-
- -
-

- Redirecting to D-Money -

-

- Taking you to the secure D-Money checkout to complete your payment… -

- - - Continue to D-Money - - +
+ {!target && ( +

+ This payment link is invalid or has expired. Please start the payment + again from the app. +

+ )}
); } export default function GoPage() { return ( -
- - } - > - - -
+ }> + + ); }