Merge pull request #508 from Tria-plc/alpha

Alpha
This commit is contained in:
Abubeker Yasin
2026-07-07 15:16:00 +03:00
committed by GitHub
3 changed files with 70 additions and 63 deletions

View File

@@ -64,6 +64,7 @@ import { TasksModule } from './modules/tasks/tasks.module';
import { AppReleasesModule } from './modules/app-releases/app-releases.module'; import { AppReleasesModule } from './modules/app-releases/app-releases.module';
import { ConfigurableFareModule } from './modules/configurable-fare/configurable-fare.module'; import { ConfigurableFareModule } from './modules/configurable-fare/configurable-fare.module';
import { SegmentFareSeeder } from './seed/segment-fare.seeder'; import { SegmentFareSeeder } from './seed/segment-fare.seeder';
import { EOtpType } from "@tria-plc/iamapi-common";
@Module({ @Module({
imports: [ imports: [
@@ -97,6 +98,16 @@ import { SegmentFareSeeder } from './seed/segment-fare.seeder';
TriaIamModule.forRoot({ TriaIamModule.forRoot({
applications: [EDR_PASSENGER_APPLICATION], applications: [EDR_PASSENGER_APPLICATION],
permissions: EDR_PASSENGER_PERMISSIONS, 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, SharedAuthModule,
PrismaModule, PrismaModule,

View File

@@ -42,11 +42,7 @@ export class PassengerAuthService {
} }
async register(dto: RegisterDto, req: any) { async register(dto: RegisterDto, req: any) {
const existing = await this.dataSource.query<{ id: string }[]>( await this.clearPendingOrConflict(dto.email, dto.phoneNumber);
`SELECT id FROM iam.users WHERE email = $1 OR phone_number = $2 LIMIT 1`,
[dto.email, dto.phoneNumber],
);
if (existing.length) throw new ConflictException('Email or phone already registered');
const iamAuthService = await this.resolveIamAuthService(req); const iamAuthService = await this.resolveIamAuthService(req);
@@ -101,11 +97,7 @@ export class PassengerAuthService {
}, },
req: any, req: any,
): Promise<{ iamUserId: string; passengerId: string }> { ): Promise<{ iamUserId: string; passengerId: string }> {
const existing = await this.dataSource.query<{ id: string }[]>( await this.clearPendingOrConflict(dto.email, dto.phoneNumber);
`SELECT id FROM iam.users WHERE email = $1 OR phone_number = $2 LIMIT 1`,
[dto.email, dto.phoneNumber],
);
if (existing.length) throw new ConflictException('Email or phone already registered');
const iamAuthService = await this.resolveIamAuthService(req); const iamAuthService = await this.resolveIamAuthService(req);
await iamAuthService.signupWithPassword({ await iamAuthService.signupWithPassword({
@@ -600,6 +592,32 @@ export class PassengerAuthService {
return `+${digits}`; 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<void> {
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<void> { private async compensateIamSignup(email: string): Promise<void> {
try { try {
const rows = await this.dataSource.query<{ id: string }[]>( const rows = await this.dataSource.query<{ id: string }[]>(

View File

@@ -2,9 +2,25 @@
import { Suspense, useEffect, useMemo } from "react"; import { Suspense, useEffect, useMemo } from "react";
import { useSearchParams } from "next/navigation"; 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 = ( const ALLOWED_HOSTS = (
process.env.NEXT_PUBLIC_DMONEY_ALLOWED_HOSTS ?? "d-money.dj" 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() { function RedirectView() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const raw = searchParams.get("url"); const raw = searchParams.get("url");
@@ -38,63 +52,27 @@ function RedirectView() {
useEffect(() => { useEffect(() => {
if (!target) return; if (!target) return;
const timer = setTimeout(() => { // Navigate from this document so the D-Money request carries
// Referer: https://edrpassenger.triaplc.com (the origin D-Money whitelists).
window.location.replace(target); window.location.replace(target);
}, REDIRECT_DELAY_MS);
return () => clearTimeout(timer);
}, [target]); }, [target]);
if (!target) {
return ( return (
<div className="w-full max-w-sm text-center"> <div className="fixed inset-0 z-[100] flex items-center justify-center bg-white px-6 text-center">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-red-100"> {!target && (
<ShieldAlert className="h-8 w-8 text-red-600" />
</div>
<h1 className="mb-2 text-xl font-bold text-gray-900">
Can&apos;t continue
</h1>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
This link is missing a valid D-Money checkout address or points to an This payment link is invalid or has expired. Please start the payment
untrusted destination. Please start the payment again from the app. again from the app.
</p> </p>
</div> )}
);
}
return (
<div className="w-full max-w-sm text-center">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
<h1 className="mb-2 text-xl font-bold text-gray-900">
Redirecting to D-Money
</h1>
<p className="text-sm text-gray-500">
Taking you to the secure D-Money checkout to complete your payment
</p>
<a
href={target}
className="mt-6 inline-flex items-center justify-center gap-2 text-sm font-medium text-primary hover:underline"
>
Continue to D-Money
<ExternalLink className="h-4 w-4" />
</a>
</div> </div>
); );
} }
export default function GoPage() { export default function GoPage() {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white px-4"> <Suspense fallback={<div className="fixed inset-0 z-[100] bg-white" />}>
<Suspense
fallback={
<Loader2 className="h-8 w-8 animate-spin text-primary" aria-label="Loading" />
}
>
<RedirectView /> <RedirectView />
</Suspense> </Suspense>
</div>
); );
} }