mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -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,
|
||||||
|
|||||||
@@ -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 }[]>(
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<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-red-100">
|
|
||||||
<ShieldAlert className="h-8 w-8 text-red-600" />
|
|
||||||
</div>
|
|
||||||
<h1 className="mb-2 text-xl font-bold text-gray-900">
|
|
||||||
Can't continue
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
This link is missing a valid D-Money checkout address or points to an
|
|
||||||
untrusted destination. Please start the payment again from the app.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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-primary/10">
|
{!target && (
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
<p className="text-sm text-gray-500">
|
||||||
</div>
|
This payment link is invalid or has expired. Please start the payment
|
||||||
<h1 className="mb-2 text-xl font-bold text-gray-900">
|
again from the app.
|
||||||
Redirecting to D-Money
|
</p>
|
||||||
</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
|
<RedirectView />
|
||||||
fallback={
|
</Suspense>
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" aria-label="Loading" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<RedirectView />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user