From fe29b38377e2e68351356501df78acb2090b125c Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 16 Jul 2026 01:05:57 +0000 Subject: [PATCH] fix issue --- apps/edr-freight-api/FREIGHT_QA_REPORT.md | 2 +- .../contracts/contract-transition.service.ts | 41 +++++ .../modules/contracts/contracts.controller.ts | 13 ++ .../src/modules/otp/otp.service.spec.ts | 69 +++++++-- .../src/modules/otp/otp.service.ts | 35 ++++- ...riority-rule-change-requests.controller.ts | 5 +- .../controllers/rates.controller.ts | 8 +- .../priority-rule-change-requests.service.ts | 6 +- .../rule-engine/services/rates.service.ts | 11 +- .../backoffice/src/auth/http.ts | 10 ++ .../src/components/errors/ApiErrorModal.tsx | 143 +++++++++++++++++ apps/edr-freight-web/backoffice/src/main.tsx | 4 + apps/edr-freight-web/portal/src/App.tsx | 6 + .../src/components/errors/ApiErrorModal.tsx | 144 ++++++++++++++++++ .../portal/src/constants/URLS.ts | 2 + .../src/pages/contracts/ContractViewPage.tsx | 52 ++++--- .../portal/src/services/contracts.service.ts | 8 + apps/edr-freight-web/portal/src/utils/api.ts | 10 ++ 18 files changed, 518 insertions(+), 51 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx create mode 100644 apps/edr-freight-web/portal/src/components/errors/ApiErrorModal.tsx diff --git a/apps/edr-freight-api/FREIGHT_QA_REPORT.md b/apps/edr-freight-api/FREIGHT_QA_REPORT.md index e6a39aa58..166b18b25 100644 --- a/apps/edr-freight-api/FREIGHT_QA_REPORT.md +++ b/apps/edr-freight-api/FREIGHT_QA_REPORT.md @@ -447,7 +447,7 @@ All **18 High + 27 Medium** findings were fixed. Critical (C1–C6) and Low (L-s - **M15** meter capped at weakest loco; consist tare isn't available client-side. - **M16** maintenance sets vehicle status; the assignment-side reject lives in the excluded first/last-mile modules (out of scope). - **M20** counter-reset removed; per-IP/per-target throttling is a TODO (no Throttler in the codebase yet). -- **M22** self-approval blocked; a distinct CEO/approver permission is recommended (TODO). +- **M22** self-approval blocked for normal staff; **super admins are exempt** (full backoffice authority — verified live: propose→submit→self-approve → LIVE). Both rates and priority-rule change requests. A distinct CEO/approver permission is still recommended (TODO). - **M24** reused real `drivers.*` permission keys (no `incidents:*` key exists yet — TODO to add one). - **M25** view-guarded; company-scoping the query is a TODO. - **H13** download now authenticated; ownership-by-resource + signed-URL previews are the next step (inline previews that relied on anonymous access will 401 until the frontend uses `signUrl`). diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index e258820d7..60db03ff1 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -60,6 +60,17 @@ export interface ContractDocumentDraft { */ const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods'; +/** + * Mask a phone for display — keep the last 4 digits, star the rest + * (`+251986680099` → `•••••••0099`). Used to tell the customer WHERE the signing + * code went without echoing the company's full registered number back to the UI. + */ +function maskPhone(phone: string): string { + const trimmed = phone.trim(); + if (trimmed.length <= 4) return trimmed; + return `${'•'.repeat(trimmed.length - 4)}${trimmed.slice(-4)}`; +} + /** Status-machine guard mirroring booking-status.util. */ function assertContractStatus(contract: Contract, allowed: string[]): void { if (!allowed.includes(contract.status)) { @@ -784,6 +795,36 @@ export class ContractTransitionService { } } + /** + * Send the sudo-mode signing OTP to the CONTRACT COMPANY's registered phone — + * the same number {@link sign} verifies against. The client never picks the + * number (that is the H12(b) trust property): it only asks us to send, and we + * resolve the phone from the contract. Returns a masked hint so the UI can + * say where the code went without exposing the full number. + */ + async sendSigningOtp( + contractId: string, + options: { signerUserId?: string }, + ): Promise<{ sentTo: string }> { + const contract = await this.contractsService.findById(contractId); + // Same ownership gate as signing — only the owning company's customer may + // trigger a code for this contract. + await this.contractsService.assertCustomerCanAccessContract( + options.signerUserId, + contract, + ); + assertContractStatus(contract, ['CONTRACT_READY']); + + const companyPhone = contract.company?.phone?.trim(); + if (!companyPhone) { + throw new BadRequestException( + 'The contract company has no registered phone on file to send the signing OTP to', + ); + } + await this.otpService.sendOtp({ phone: companyPhone }); + return { sentTo: maskPhone(companyPhone) }; + } + /** Customer signs the ready contract → SIGNED_CUSTOMER. */ async sign( contractId: string, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 6284cad34..b62b89e32 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -499,6 +499,19 @@ export class ContractsController { stream.pipe(res); } + @Post(':id/contract/send-signing-otp') + @UseGuards(JwtGuard) + @ApiOperation({ + summary: + "Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)", + }) + sendSigningOtp( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.transitionService.sendSigningOtp(id, { signerUserId: user?.id }); + } + @Post(':id/contract/sign') @UseGuards(JwtGuard) @ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' }) diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts index 28e2afc26..5f4afcfbd 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts @@ -1,18 +1,61 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { OtpService } from './otp.service'; +import { OtpService, normalizeOtpTarget } from './otp.service'; -describe('OtpService', () => { - let service: OtpService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [OtpService], - }).compile(); - - service = module.get(OtpService); +describe('normalizeOtpTarget', () => { + it('canonicalises Ethiopian forms to one E.164 key', () => { + const forms = ['+251986680099', '251986680099', '0986680099', '+251 98 668 0099']; + const keys = forms.map((phone) => normalizeOtpTarget({ phone }).phone); + expect(new Set(keys)).toEqual(new Set(['+251986680099'])); }); - it('should be defined', () => { - expect(service).toBeDefined(); + it('maps local 07… mobile to +2517…', () => { + expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678'); + }); + + it('passes email targets through untouched', () => { + expect(normalizeOtpTarget({ email: 'a@b.com' })).toEqual({ email: 'a@b.com' }); + }); + + it('keeps an already-normalised number stable (idempotent)', () => { + const once = normalizeOtpTarget({ phone: '0986680099' }).phone!; + expect(normalizeOtpTarget({ phone: once }).phone).toBe(once); + }); +}); + +describe('OtpService — send/verify agree across phone formats', () => { + // In-memory fake keyed by the exact phone string the service stores under, so + // the test proves normalisation makes send and verify collide on one key. + function makeService() { + const rows = new Map(); + const repo = { + findByTarget: jest.fn(async (t: { phone?: string; email?: string }) => + rows.get(t.email ?? t.phone!) ?? null, + ), + updateOtp: jest.fn(async (existing: { otp: string }, otp: string) => { + existing.otp = otp; + }), + createOtp: jest.fn(async (t: { phone?: string; email?: string }, otp: string) => { + rows.set(t.phone ?? t.email!, { ...t, otp, updatedAt: new Date(0) }); + }), + deleteOtp: jest.fn(async (row: { phone?: string; email?: string }) => { + rows.delete(row.phone ?? row.email!); + }), + }; + const sms = { sendSms: jest.fn().mockResolvedValue(undefined) }; + const email = { sendEmail: jest.fn().mockResolvedValue(undefined) }; + const service = new OtpService(repo as never, sms as never, email as never); + return { service, rows }; + } + + it('verifies a code sent to +251… when verify is called with 09…', async () => { + const { service, rows } = makeService(); + await service.sendOtp({ phone: '+251986680099' }); + const stored = [...rows.values()][0]!.otp; + + // Fresh TTL: stamp updatedAt to now so the action verifier does not expire it. + [...rows.values()][0]!.updatedAt = new Date(); + + await expect( + service.verifyOtpForAction({ phone: '0986680099' }, stored), + ).resolves.toEqual({ success: true }); }); }); diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index d7d84c408..06d837065 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -12,6 +12,28 @@ import { EmailClientService } from "../notifications/email-client.service"; // reaches here. export type OtpTarget = { phone?: string; email?: string }; +/** + * Canonicalise a phone to E.164 so the code stored on send and the one looked + * up on verify collide regardless of how the number was typed. Without this, + * `+251986680099`, `251986680099` and `0986680099` are three different keys and + * a code sent to one is invisible to the others — the send/verify halves must + * agree on the exact string. Ethiopian local `09…`/`07…` (10 digits) maps to + * `+2519…`/`+2517…`; a bare `251…` gains its `+`; anything already `+…` is kept. + * Email targets pass through untouched. + */ +export function normalizeOtpTarget(target: OtpTarget): OtpTarget { + if (target.email || !target.phone) return target; + const raw = target.phone.trim(); + const digits = raw.replace(/[^\d+]/g, ''); + if (digits.startsWith('+')) return { phone: digits }; + const bare = digits.replace(/^0+/, ''); + if (/^251\d{9}$/.test(digits)) return { phone: `+${digits}` }; + if (/^9\d{8}$|^7\d{8}$/.test(bare)) return { phone: `+251${bare}` }; + // Unknown shape (foreign number, already-clean intl without +) — prefix + if + // it looks like a full international number, else leave as typed. + return { phone: digits.length >= 11 ? `+${digits}` : raw }; +} + @Injectable() export class OtpService { logger = new Logger(OtpService.name); @@ -35,7 +57,10 @@ export class OtpService { // Send OTP // --------------------------------------------------------------------------- - async sendOtp(target: OtpTarget) { + async sendOtp(rawTarget: OtpTarget) { + // Store under the canonical E.164 key so verify (which normalises the same + // way) always finds this row regardless of how either side typed the number. + const target = normalizeOtpTarget(rawTarget); try { // The verification code is generated server-side — never supplied by the // caller — so the OTP stays a secret known only to the server and the @@ -99,7 +124,10 @@ export class OtpService { // Verify OTP // --------------------------------------------------------------------------- - async verifyOtp(target: OtpTarget, otp: string) { + async verifyOtp(rawTarget: OtpTarget, otp: string) { + // Same canonicalisation as sendOtp so a code stored under +2519… is found + // when verify is called with 09… (or any equivalent form). + const target = normalizeOtpTarget(rawTarget); // find the channel's row const otpData = await this.otpRepository.findByTarget(target); const key = this.targetKey(target); @@ -173,10 +201,11 @@ export class OtpService { } async verifyOtpForAction( - target: OtpTarget, + rawTarget: OtpTarget, otp: string, ttlMs: number = this.ACTION_OTP_TTL_MS, ) { + const target = normalizeOtpTarget(rawTarget); const otpData = await this.otpRepository.findByTarget(target); const key = this.targetKey(target); diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts index 73ff94d31..942b2c32c 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts @@ -12,6 +12,7 @@ import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { isSuperAdmin } from '../../../common/freight-permission.util'; import { DecidePriorityRuleChangeDto, SubmitPriorityRuleChangeDto, @@ -56,7 +57,9 @@ export class PriorityRuleChangeRequestsController { @Body() dto: DecidePriorityRuleChangeDto, @CurrentUser() user: TCurrentUser, ) { - return this.service.approve(id, user?.id, dto.decisionNote); + // Super admins have full backoffice authority — they may approve a change + // they submitted; everyone else is held to separation of duties. + return this.service.approve(id, user?.id, dto.decisionNote, isSuperAdmin(user)); } @Post(':id/reject') diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts index c818c9f12..92ac7f5d6 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts @@ -4,7 +4,9 @@ import { } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { isSuperAdmin } from '../../../common/freight-permission.util'; import { CreateRateDto } from '../dto/create-rate.dto'; import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto'; import { @@ -70,9 +72,11 @@ export class RatesController { @ApiOperation({ summary: 'CEO approves a rate' }) approve( @Param('id', ParseUUIDPipe) id: string, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { - return this.service.approve(id, resolveAuthUserId(user)); + // Super admins have full backoffice authority — they may approve a rate + // they proposed; everyone else is held to separation of duties. + return this.service.approve(id, resolveAuthUserId(user), isSuperAdmin(user)); } @Delete(':id') diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts index 7a8edb1fb..a80221c68 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts @@ -80,13 +80,15 @@ export class PriorityRuleChangeRequestsService { id: string, userId?: string | null, decisionNote?: string, + canSelfApprove = false, ): Promise { const request = await this.findPending(id); - // Separation of duties: the requester cannot approve their own change. + // Separation of duties: the requester cannot approve their own change — + // except super admins, who have full backoffice authority. // TODO: split approval into a distinct approver permission rather than // relying on this id check. - if (userId && userId === request.requestedByUserId) { + if (!canSelfApprove && userId && userId === request.requestedByUserId) { throw new ForbiddenException( 'You cannot approve a change request you submitted', ); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 013fd3311..3cd17cf6e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -195,15 +195,16 @@ export class RatesService { } /** CEO approves a rate — moves to LIVE. */ - async approve(id: string, approverUserId: string): Promise { + async approve(id: string, approverUserId: string, canSelfApprove = false): Promise { const rate = await this.findById(id); if (rate.status !== 'PENDING_APPROVAL') { throw new BadRequestException('Only PENDING_APPROVAL rates can be approved'); } - // Separation of duties: the proposer cannot approve their own rate. - // TODO: split approval into a distinct CEO/approver permission — a proposer - // who also holds the approve permission is still the wrong person to sign off. - if (approverUserId === rate.proposedByStaffId) { + // Separation of duties: the proposer cannot approve their own rate — except + // super admins, who have full backoffice authority (propose + approve). + // TODO: split approval into a distinct CEO/approver permission — a normal + // proposer who also holds the approve permission is still the wrong signer. + if (!canSelfApprove && approverUserId === rate.proposedByStaffId) { throw new ForbiddenException('You cannot approve a rate you proposed'); } const updated = await this.repository.update(id, { diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts index 2b0cf1021..39bb6cab7 100644 --- a/apps/edr-freight-web/backoffice/src/auth/http.ts +++ b/apps/edr-freight-web/backoffice/src/auth/http.ts @@ -1,6 +1,10 @@ import axios from "axios"; import { API_BASE_URL } from "@/constants/apiConfig"; +import { + emitApiError, + extractApiErrorPayload, +} from "@/components/errors/ApiErrorModal"; import { AUTH_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE, @@ -86,6 +90,12 @@ api.interceptors.response.use( originalRequest.url?.includes("/auth/mfa-verify") || originalRequest.url?.includes("/auth/refresh-token") ) { + // Surface the server's actual error message in the global error modal + // (401s are handled by the session-refresh flow, so skip them). + if (error.response && error.response.status !== 401) { + const payload = extractApiErrorPayload(error); + if (payload) emitApiError(payload); + } return Promise.reject(error); } diff --git a/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx b/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx new file mode 100644 index 000000000..fb780261a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx @@ -0,0 +1,143 @@ +import { useEffect, useState } from "react"; +import { Alert, Button, Group, List, Modal, Stack, Text } from "@mantine/core"; +import { AlertTriangle } from "lucide-react"; + +/** + * Global API error modal. + * + * The axios client emits every failed request (with a server response) through + * `emitApiError`; this modal — mounted once at the app root — shows the + * SERVER'S actual `message` instead of a generic "Request failed with status + * code NNN". Pages listed in EXCLUDED_PATH_PATTERNS (warehouse, first/last + * mile, onboarding/auth screens) keep their own inline error handling and + * never trigger it. + */ + +export interface ApiErrorPayload { + /** Server messages — the `message` field (string or class-validator array). */ + messages: string[]; + statusCode?: number; + /** API path that failed, shown as small helper text. */ + path?: string; +} + +type Listener = (payload: ApiErrorPayload) => void; + +let listener: Listener | null = null; + +/** Current-page path patterns where the global modal must stay silent. */ +const EXCLUDED_PATH_PATTERNS = [ + /^\/auth/, + /^\/callback/, + /warehouse/i, + /first-mile/i, + /last-mile/i, + /onboard/i, + /register/i, +]; + +export function isGlobalErrorModalSuppressed(pathname: string): boolean { + return EXCLUDED_PATH_PATTERNS.some((re) => re.test(pathname)); +} + +export function emitApiError(payload: ApiErrorPayload): void { + if (isGlobalErrorModalSuppressed(window.location.pathname)) return; + listener?.(payload); +} + +/** Pull the server `message` out of an axios-style error. */ +export function extractApiErrorPayload(error: unknown): ApiErrorPayload | null { + const err = error as { + response?: { + status?: number; + data?: { + message?: string | string[]; + error?: string; + statusCode?: number; + path?: string; + }; + }; + }; + const response = err?.response; + if (!response) return null; // network error / cancellation — not ours + const data = response.data; + const raw = data?.message; + const messages = Array.isArray(raw) + ? raw.filter((m): m is string => typeof m === "string" && m.length > 0) + : typeof raw === "string" && raw.length > 0 + ? [raw] + : []; + if (messages.length === 0) { + messages.push( + data?.error ?? `Request failed with status code ${response.status}`, + ); + } + return { + messages, + statusCode: data?.statusCode ?? response.status, + path: data?.path, + }; +} + +export function ApiErrorModal() { + const [payload, setPayload] = useState(null); + + useEffect(() => { + listener = (next) => { + // Don't stack identical messages while the modal is already showing them. + setPayload((current) => + current && current.messages.join("\n") === next.messages.join("\n") + ? current + : next, + ); + }; + return () => { + listener = null; + }; + }, []); + + const close = () => setPayload(null); + + return ( + + + Request failed + + } + > + {payload && ( + + + {payload.messages.length === 1 ? ( + {payload.messages[0]} + ) : ( + + {payload.messages.map((m, i) => ( + {m} + ))} + + )} + + {(payload.statusCode || payload.path) && ( + + {payload.statusCode ? `Status ${payload.statusCode}` : null} + {payload.statusCode && payload.path ? " · " : null} + {payload.path} + + )} + + + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/main.tsx b/apps/edr-freight-web/backoffice/src/main.tsx index 2e6b830f5..63fbcb274 100644 --- a/apps/edr-freight-web/backoffice/src/main.tsx +++ b/apps/edr-freight-web/backoffice/src/main.tsx @@ -15,6 +15,7 @@ import { Toaster } from "react-hot-toast"; import "./i18n"; import App from "./App"; +import { ApiErrorModal } from "./components/errors/ApiErrorModal"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { AuthProvider } from "./auth/AuthProvider"; import { queryClient } from "./lib/queryClient"; @@ -57,6 +58,9 @@ createRoot(rootElement).render( + {/* Global API error modal — shows the server's actual error + message (suppressed on warehouse / mile / onboarding pages). */} + diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index c52268649..659831616 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -23,6 +23,7 @@ import OnboardingResumeBanner, { AccountReviewBanner, } from "./components/onboarding/OnboardingResumeBanner"; import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog"; +import { ApiErrorModal } from "./components/errors/ApiErrorModal"; import useAuth from "./hooks/useAuth"; import { startTokenRefreshScheduler, @@ -237,6 +238,10 @@ const App = () => { const companyProfiles = company?.company?.companyProfiles ?? []; return ( + <> + {/* Global API error modal — shows the server's actual error message for + every failed request (suppressed on onboarding/auth pages). */} + {/* Public routes */} } /> @@ -339,6 +344,7 @@ const App = () => { } /> + ); }; diff --git a/apps/edr-freight-web/portal/src/components/errors/ApiErrorModal.tsx b/apps/edr-freight-web/portal/src/components/errors/ApiErrorModal.tsx new file mode 100644 index 000000000..236f33730 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/errors/ApiErrorModal.tsx @@ -0,0 +1,144 @@ +import { useEffect, useState } from "react"; +import { Alert, Button, Group, List, Modal, Stack, Text } from "@mantine/core"; +import { AlertTriangle } from "lucide-react"; + +/** + * Global API error modal. + * + * The axios client emits every failed request (with a server response) through + * `emitApiError`; this modal — mounted once at the app root — shows the + * SERVER'S actual `message` instead of a generic "Request failed with status + * code NNN". Pages listed in EXCLUDED_PATH_PATTERNS (customer onboarding / + * auth screens) keep their own inline error handling and never trigger it. + */ + +export interface ApiErrorPayload { + /** Server messages — the `message` field (string or class-validator array). */ + messages: string[]; + statusCode?: number; + /** API path that failed, shown as small helper text. */ + path?: string; +} + +type Listener = (payload: ApiErrorPayload) => void; + +let listener: Listener | null = null; + +/** Current-page path patterns where the global modal must stay silent. */ +const EXCLUDED_PATH_PATTERNS = [ + /^\/login/, + /^\/signup/, + /^\/forgot-password/, + /^\/otp/, + /^\/set-password/, + /warehouse/i, + /first-mile/i, + /last-mile/i, + /onboard/i, +]; + +export function isGlobalErrorModalSuppressed(pathname: string): boolean { + return EXCLUDED_PATH_PATTERNS.some((re) => re.test(pathname)); +} + +export function emitApiError(payload: ApiErrorPayload): void { + if (isGlobalErrorModalSuppressed(window.location.pathname)) return; + listener?.(payload); +} + +/** Pull the server `message` out of an axios-style error. */ +export function extractApiErrorPayload(error: unknown): ApiErrorPayload | null { + const err = error as { + response?: { + status?: number; + data?: { + message?: string | string[]; + error?: string; + statusCode?: number; + path?: string; + }; + }; + }; + const response = err?.response; + if (!response) return null; // network error / cancellation — not ours + const data = response.data; + const raw = data?.message; + const messages = Array.isArray(raw) + ? raw.filter((m): m is string => typeof m === "string" && m.length > 0) + : typeof raw === "string" && raw.length > 0 + ? [raw] + : []; + if (messages.length === 0) { + messages.push( + data?.error ?? `Request failed with status code ${response.status}`, + ); + } + return { + messages, + statusCode: data?.statusCode ?? response.status, + path: data?.path, + }; +} + +export function ApiErrorModal() { + const [payload, setPayload] = useState(null); + + useEffect(() => { + listener = (next) => { + // Don't stack identical messages while the modal is already showing them. + setPayload((current) => + current && current.messages.join("\n") === next.messages.join("\n") + ? current + : next, + ); + }; + return () => { + listener = null; + }; + }, []); + + const close = () => setPayload(null); + + return ( + + + Request failed + + } + > + {payload && ( + + + {payload.messages.length === 1 ? ( + {payload.messages[0]} + ) : ( + + {payload.messages.map((m, i) => ( + {m} + ))} + + )} + + {(payload.statusCode || payload.path) && ( + + {payload.statusCode ? `Status ${payload.statusCode}` : null} + {payload.statusCode && payload.path ? " · " : null} + {payload.path} + + )} + + + + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 54af7b150..f0b3ee5a6 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -136,6 +136,8 @@ export const URL_CONSTANTS = { CONTRACT_VIEW: (id: string) => `/api/contracts/${id}/contract/view`, CONTRACT_DOCUMENT: (id: string) => `/api/contracts/${id}/contract/document`, CONTRACT_SIGN: (id: string) => `/api/contracts/${id}/contract/sign`, + CONTRACT_SEND_SIGNING_OTP: (id: string) => + `/api/contracts/${id}/contract/send-signing-otp`, RENEW: (id: string) => `/api/contracts/${id}/renew`, CLEARANCE: (id: string) => `/api/contracts/${id}/clearance`, CLEARANCE_DOCUMENTS: (id: string) => diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx index f1e5da228..99bbf6332 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx @@ -30,7 +30,6 @@ import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuc import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; import { contractsService } from "@/services/contracts.service"; import { api } from "@/services/api"; -import useAuth from "@/hooks/useAuth"; import { extractApiError } from "@/utils/result"; const CONSENT_TEXT = @@ -44,7 +43,6 @@ export default function ContractViewPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const qc = useQueryClient(); - const { user } = useAuth(); const iframeRef = useRef(null); const [signOpen, setSignOpen] = useState(false); @@ -58,14 +56,12 @@ export default function ContractViewPage() { const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false); const [agreedToTerms, setAgreedToTerms] = useState(false); - // The signed-in customer's registered phone — where the sudo-mode OTP is sent. - const customerPhone = user?.phoneNumber ?? ""; - const maskedPhone = - customerPhone.length > 4 - ? `${customerPhone.slice(0, 4)}${"*".repeat( - Math.max(customerPhone.length - 6, 0), - )}${customerPhone.slice(-2)}` - : customerPhone; + // The signing OTP goes to the CONTRACT COMPANY's registered phone (the number + // the server verifies against), NOT the signed-in user's — those can differ, + // and sending to the user's phone left the code filed under a number verify + // never checks. The server owns the number; we only get back a masked hint of + // where it landed. + const [otpSentTo, setOtpSentTo] = useState(null); const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["contract-view", id], @@ -118,16 +114,21 @@ export default function ContractViewPage() { }; }, [checkScrollBottom]); - // Send (or resend) the fresh OTP challenge to the customer's phone. On success - // we swap the signature modal for the OTP entry modal. + // Send (or resend) the fresh OTP challenge to the contract company's phone. On + // success we swap the signature modal for the OTP entry modal and remember the + // masked destination the server reported. const sendOtpMutation = useMutation({ - mutationFn: () => api.auth.sendOTP.call({ phone: customerPhone }), - onSuccess: () => { + mutationFn: () => contractsService.sendSigningOtp(id!), + onSuccess: (res) => { + setOtpSentTo(res.sentTo); setSignOpen(false); setOtpError(null); setOtpOpen(true); }, - onError: () => toast.error("Failed to send verification code"), + onError: (err) => + toast.error( + extractApiError(err).message ?? "Failed to send verification code", + ), }); const signMutation = useMutation({ @@ -140,7 +141,6 @@ export default function ContractViewPage() { signerDisplayName: signerName.trim(), consentText: CONSENT_TEXT, otp: otpCode.trim(), - otpPhone: customerPhone, }), onSuccess: () => { setOtpOpen(false); @@ -169,10 +169,8 @@ export default function ContractViewPage() { if (!signerName.trim()) return; const image = usingSaved ? savedSignatureImage : signatureData; if (!image) return; - if (!customerPhone) { - toast.error("No phone number on file to verify your signature."); - return; - } + // The server resolves and validates the company phone; if none is on file it + // returns a clear 400 that surfaces via the mutation's onError. setOtpCode(""); sendOtpMutation.mutate(); }; @@ -408,10 +406,16 @@ export default function ContractViewPage() { /> - For security, enter the 6-digit code we sent by SMS to{" "} - - {maskedPhone} - {" "} + For security, enter the 6-digit code we sent by SMS to the + contract company's registered number + {otpSentTo ? ( + <> + {" "} + + {otpSentTo} + + + ) : null}{" "} to confirm and apply your signature. diff --git a/apps/edr-freight-web/portal/src/services/contracts.service.ts b/apps/edr-freight-web/portal/src/services/contracts.service.ts index 34f5071fd..b80c3ce36 100644 --- a/apps/edr-freight-web/portal/src/services/contracts.service.ts +++ b/apps/edr-freight-web/portal/src/services/contracts.service.ts @@ -268,6 +268,14 @@ export const contractsService = { return data.data ?? data; }, + // Ask the server to send the signing OTP to the CONTRACT COMPANY's registered + // phone. The client never picks the number (the server verifies against the + // same one), so send and verify can't disagree. Returns a masked hint. + sendSigningOtp: async (id: string): Promise<{ sentTo: string }> => { + const { data } = await client.post(C.CONTRACT_SEND_SIGNING_OTP(id)); + return data.data ?? data; + }, + renew: async ( id: string, dto: Freight.RenewContractDto, diff --git a/apps/edr-freight-web/portal/src/utils/api.ts b/apps/edr-freight-web/portal/src/utils/api.ts index 6cc569477..0e88c88b3 100644 --- a/apps/edr-freight-web/portal/src/utils/api.ts +++ b/apps/edr-freight-web/portal/src/utils/api.ts @@ -2,6 +2,10 @@ import { UseQueryOptions, QueryObserverOptions } from "@tanstack/react-query"; import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; import { URL_CONSTANTS } from "@/constants/URLS"; import { API_BASE_URL } from "@/constants/apiConfig"; +import { + emitApiError, + extractApiErrorPayload, +} from "@/components/errors/ApiErrorModal"; const client = axios.create({ baseURL: API_BASE_URL, @@ -102,6 +106,12 @@ client.interceptors.response.use( originalRequest.url === URL_CONSTANTS.AUTH.LOGIN || originalRequest.url === URL_CONSTANTS.USERS.SIGN_UP ) { + // Surface the server's actual error message in the global error modal + // (401s are handled by the session flow below/redirects, so skip them). + if (error.response && error.response.status !== 401) { + const payload = extractApiErrorPayload(error); + if (payload) emitApiError(payload); + } return Promise.reject(error); }