mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix issue
This commit is contained in:
@@ -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.
|
- **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).
|
- **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).
|
- **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).
|
- **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.
|
- **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`).
|
- **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`).
|
||||||
|
|||||||
@@ -60,6 +60,17 @@ export interface ContractDocumentDraft {
|
|||||||
*/
|
*/
|
||||||
const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods';
|
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. */
|
/** Status-machine guard mirroring booking-status.util. */
|
||||||
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
||||||
if (!allowed.includes(contract.status)) {
|
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. */
|
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
|
||||||
async sign(
|
async sign(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
|
|||||||
@@ -499,6 +499,19 @@ export class ContractsController {
|
|||||||
stream.pipe(res);
|
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')
|
@Post(':id/contract/sign')
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
||||||
|
|||||||
@@ -1,18 +1,61 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { OtpService, normalizeOtpTarget } from './otp.service';
|
||||||
import { OtpService } from './otp.service';
|
|
||||||
|
|
||||||
describe('OtpService', () => {
|
describe('normalizeOtpTarget', () => {
|
||||||
let service: OtpService;
|
it('canonicalises Ethiopian forms to one E.164 key', () => {
|
||||||
|
const forms = ['+251986680099', '251986680099', '0986680099', '+251 98 668 0099'];
|
||||||
beforeEach(async () => {
|
const keys = forms.map((phone) => normalizeOtpTarget({ phone }).phone);
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
expect(new Set(keys)).toEqual(new Set(['+251986680099']));
|
||||||
providers: [OtpService],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = module.get<OtpService>(OtpService);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('maps local 07… mobile to +2517…', () => {
|
||||||
expect(service).toBeDefined();
|
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<string, { phone?: string; email?: string; otp: string; updatedAt: Date }>();
|
||||||
|
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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,28 @@ import { EmailClientService } from "../notifications/email-client.service";
|
|||||||
// reaches here.
|
// reaches here.
|
||||||
export type OtpTarget = { phone?: string; email?: string };
|
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()
|
@Injectable()
|
||||||
export class OtpService {
|
export class OtpService {
|
||||||
logger = new Logger(OtpService.name);
|
logger = new Logger(OtpService.name);
|
||||||
@@ -35,7 +57,10 @@ export class OtpService {
|
|||||||
// Send OTP
|
// 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 {
|
try {
|
||||||
// The verification code is generated server-side — never supplied by the
|
// 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
|
// caller — so the OTP stays a secret known only to the server and the
|
||||||
@@ -99,7 +124,10 @@ export class OtpService {
|
|||||||
// Verify OTP
|
// 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
|
// find the channel's row
|
||||||
const otpData = await this.otpRepository.findByTarget(target);
|
const otpData = await this.otpRepository.findByTarget(target);
|
||||||
const key = this.targetKey(target);
|
const key = this.targetKey(target);
|
||||||
@@ -173,10 +201,11 @@ export class OtpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async verifyOtpForAction(
|
async verifyOtpForAction(
|
||||||
target: OtpTarget,
|
rawTarget: OtpTarget,
|
||||||
otp: string,
|
otp: string,
|
||||||
ttlMs: number = this.ACTION_OTP_TTL_MS,
|
ttlMs: number = this.ACTION_OTP_TTL_MS,
|
||||||
) {
|
) {
|
||||||
|
const target = normalizeOtpTarget(rawTarget);
|
||||||
const otpData = await this.otpRepository.findByTarget(target);
|
const otpData = await this.otpRepository.findByTarget(target);
|
||||||
const key = this.targetKey(target);
|
const key = this.targetKey(target);
|
||||||
|
|
||||||
|
|||||||
@@ -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 type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
|
import { isSuperAdmin } from '../../../common/freight-permission.util';
|
||||||
import {
|
import {
|
||||||
DecidePriorityRuleChangeDto,
|
DecidePriorityRuleChangeDto,
|
||||||
SubmitPriorityRuleChangeDto,
|
SubmitPriorityRuleChangeDto,
|
||||||
@@ -56,7 +57,9 @@ export class PriorityRuleChangeRequestsController {
|
|||||||
@Body() dto: DecidePriorityRuleChangeDto,
|
@Body() dto: DecidePriorityRuleChangeDto,
|
||||||
@CurrentUser() user: TCurrentUser,
|
@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')
|
@Post(':id/reject')
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentUser } from '@edr/api-common';
|
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 { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
|
import { isSuperAdmin } from '../../../common/freight-permission.util';
|
||||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import {
|
import {
|
||||||
@@ -70,9 +72,11 @@ export class RatesController {
|
|||||||
@ApiOperation({ summary: 'CEO approves a rate' })
|
@ApiOperation({ summary: 'CEO approves a rate' })
|
||||||
approve(
|
approve(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@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')
|
@Delete(':id')
|
||||||
|
|||||||
@@ -80,13 +80,15 @@ export class PriorityRuleChangeRequestsService {
|
|||||||
id: string,
|
id: string,
|
||||||
userId?: string | null,
|
userId?: string | null,
|
||||||
decisionNote?: string,
|
decisionNote?: string,
|
||||||
|
canSelfApprove = false,
|
||||||
): Promise<PriorityRuleChangeRequest> {
|
): Promise<PriorityRuleChangeRequest> {
|
||||||
const request = await this.findPending(id);
|
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
|
// TODO: split approval into a distinct approver permission rather than
|
||||||
// relying on this id check.
|
// relying on this id check.
|
||||||
if (userId && userId === request.requestedByUserId) {
|
if (!canSelfApprove && userId && userId === request.requestedByUserId) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
'You cannot approve a change request you submitted',
|
'You cannot approve a change request you submitted',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -195,15 +195,16 @@ export class RatesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** CEO approves a rate — moves to LIVE. */
|
/** CEO approves a rate — moves to LIVE. */
|
||||||
async approve(id: string, approverUserId: string): Promise<Rate> {
|
async approve(id: string, approverUserId: string, canSelfApprove = false): Promise<Rate> {
|
||||||
const rate = await this.findById(id);
|
const rate = await this.findById(id);
|
||||||
if (rate.status !== 'PENDING_APPROVAL') {
|
if (rate.status !== 'PENDING_APPROVAL') {
|
||||||
throw new BadRequestException('Only PENDING_APPROVAL rates can be approved');
|
throw new BadRequestException('Only PENDING_APPROVAL rates can be approved');
|
||||||
}
|
}
|
||||||
// Separation of duties: the proposer cannot approve their own rate.
|
// Separation of duties: the proposer cannot approve their own rate — except
|
||||||
// TODO: split approval into a distinct CEO/approver permission — a proposer
|
// super admins, who have full backoffice authority (propose + approve).
|
||||||
// who also holds the approve permission is still the wrong person to sign off.
|
// TODO: split approval into a distinct CEO/approver permission — a normal
|
||||||
if (approverUserId === rate.proposedByStaffId) {
|
// 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');
|
throw new ForbiddenException('You cannot approve a rate you proposed');
|
||||||
}
|
}
|
||||||
const updated = await this.repository.update(id, {
|
const updated = await this.repository.update(id, {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||||
|
import {
|
||||||
|
emitApiError,
|
||||||
|
extractApiErrorPayload,
|
||||||
|
} from "@/components/errors/ApiErrorModal";
|
||||||
import {
|
import {
|
||||||
AUTH_TOKEN_COOKIE,
|
AUTH_TOKEN_COOKIE,
|
||||||
REFRESH_TOKEN_COOKIE,
|
REFRESH_TOKEN_COOKIE,
|
||||||
@@ -86,6 +90,12 @@ api.interceptors.response.use(
|
|||||||
originalRequest.url?.includes("/auth/mfa-verify") ||
|
originalRequest.url?.includes("/auth/mfa-verify") ||
|
||||||
originalRequest.url?.includes("/auth/refresh-token")
|
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);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<ApiErrorPayload | null>(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 (
|
||||||
|
<Modal
|
||||||
|
opened={payload !== null}
|
||||||
|
onClose={close}
|
||||||
|
centered
|
||||||
|
radius="md"
|
||||||
|
title={
|
||||||
|
<Group gap="xs">
|
||||||
|
<AlertTriangle size={18} color="var(--mantine-color-red-6)" />
|
||||||
|
<Text fw={700}>Request failed</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{payload && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert color="red" variant="light">
|
||||||
|
{payload.messages.length === 1 ? (
|
||||||
|
<Text size="sm">{payload.messages[0]}</Text>
|
||||||
|
) : (
|
||||||
|
<List size="sm" spacing={4}>
|
||||||
|
{payload.messages.map((m, i) => (
|
||||||
|
<List.Item key={i}>{m}</List.Item>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
{(payload.statusCode || payload.path) && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{payload.statusCode ? `Status ${payload.statusCode}` : null}
|
||||||
|
{payload.statusCode && payload.path ? " · " : null}
|
||||||
|
{payload.path}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={close}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { Toaster } from "react-hot-toast";
|
|||||||
import "./i18n";
|
import "./i18n";
|
||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { ApiErrorModal } from "./components/errors/ApiErrorModal";
|
||||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
import { AuthProvider } from "./auth/AuthProvider";
|
import { AuthProvider } from "./auth/AuthProvider";
|
||||||
import { queryClient } from "./lib/queryClient";
|
import { queryClient } from "./lib/queryClient";
|
||||||
@@ -57,6 +58,9 @@ createRoot(rootElement).render(
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<App />
|
<App />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
{/* Global API error modal — shows the server's actual error
|
||||||
|
message (suppressed on warehouse / mile / onboarding pages). */}
|
||||||
|
<ApiErrorModal />
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import OnboardingResumeBanner, {
|
|||||||
AccountReviewBanner,
|
AccountReviewBanner,
|
||||||
} from "./components/onboarding/OnboardingResumeBanner";
|
} from "./components/onboarding/OnboardingResumeBanner";
|
||||||
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
|
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
|
||||||
|
import { ApiErrorModal } from "./components/errors/ApiErrorModal";
|
||||||
import useAuth from "./hooks/useAuth";
|
import useAuth from "./hooks/useAuth";
|
||||||
import {
|
import {
|
||||||
startTokenRefreshScheduler,
|
startTokenRefreshScheduler,
|
||||||
@@ -237,6 +238,10 @@ const App = () => {
|
|||||||
const companyProfiles = company?.company?.companyProfiles ?? [];
|
const companyProfiles = company?.company?.companyProfiles ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{/* Global API error modal — shows the server's actual error message for
|
||||||
|
every failed request (suppressed on onboarding/auth pages). */}
|
||||||
|
<ApiErrorModal />
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public routes */}
|
{/* Public routes */}
|
||||||
<Route index element={<LandingRoute />} />
|
<Route index element={<LandingRoute />} />
|
||||||
@@ -339,6 +344,7 @@ const App = () => {
|
|||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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<ApiErrorPayload | null>(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 (
|
||||||
|
<Modal
|
||||||
|
opened={payload !== null}
|
||||||
|
onClose={close}
|
||||||
|
centered
|
||||||
|
radius="md"
|
||||||
|
title={
|
||||||
|
<Group gap="xs">
|
||||||
|
<AlertTriangle size={18} color="var(--mantine-color-red-6)" />
|
||||||
|
<Text fw={700}>Request failed</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{payload && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert color="red" variant="light">
|
||||||
|
{payload.messages.length === 1 ? (
|
||||||
|
<Text size="sm">{payload.messages[0]}</Text>
|
||||||
|
) : (
|
||||||
|
<List size="sm" spacing={4}>
|
||||||
|
{payload.messages.map((m, i) => (
|
||||||
|
<List.Item key={i}>{m}</List.Item>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
{(payload.statusCode || payload.path) && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{payload.statusCode ? `Status ${payload.statusCode}` : null}
|
||||||
|
{payload.statusCode && payload.path ? " · " : null}
|
||||||
|
{payload.path}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={close}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -136,6 +136,8 @@ export const URL_CONSTANTS = {
|
|||||||
CONTRACT_VIEW: (id: string) => `/api/contracts/${id}/contract/view`,
|
CONTRACT_VIEW: (id: string) => `/api/contracts/${id}/contract/view`,
|
||||||
CONTRACT_DOCUMENT: (id: string) => `/api/contracts/${id}/contract/document`,
|
CONTRACT_DOCUMENT: (id: string) => `/api/contracts/${id}/contract/document`,
|
||||||
CONTRACT_SIGN: (id: string) => `/api/contracts/${id}/contract/sign`,
|
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`,
|
RENEW: (id: string) => `/api/contracts/${id}/renew`,
|
||||||
CLEARANCE: (id: string) => `/api/contracts/${id}/clearance`,
|
CLEARANCE: (id: string) => `/api/contracts/${id}/clearance`,
|
||||||
CLEARANCE_DOCUMENTS: (id: string) =>
|
CLEARANCE_DOCUMENTS: (id: string) =>
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuc
|
|||||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import useAuth from "@/hooks/useAuth";
|
|
||||||
import { extractApiError } from "@/utils/result";
|
import { extractApiError } from "@/utils/result";
|
||||||
|
|
||||||
const CONSENT_TEXT =
|
const CONSENT_TEXT =
|
||||||
@@ -44,7 +43,6 @@ export default function ContractViewPage() {
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { user } = useAuth();
|
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
|
||||||
const [signOpen, setSignOpen] = useState(false);
|
const [signOpen, setSignOpen] = useState(false);
|
||||||
@@ -58,14 +56,12 @@ export default function ContractViewPage() {
|
|||||||
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
|
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
|
||||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||||
|
|
||||||
// The signed-in customer's registered phone — where the sudo-mode OTP is sent.
|
// The signing OTP goes to the CONTRACT COMPANY's registered phone (the number
|
||||||
const customerPhone = user?.phoneNumber ?? "";
|
// the server verifies against), NOT the signed-in user's — those can differ,
|
||||||
const maskedPhone =
|
// and sending to the user's phone left the code filed under a number verify
|
||||||
customerPhone.length > 4
|
// never checks. The server owns the number; we only get back a masked hint of
|
||||||
? `${customerPhone.slice(0, 4)}${"*".repeat(
|
// where it landed.
|
||||||
Math.max(customerPhone.length - 6, 0),
|
const [otpSentTo, setOtpSentTo] = useState<string | null>(null);
|
||||||
)}${customerPhone.slice(-2)}`
|
|
||||||
: customerPhone;
|
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch } = useQuery({
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
queryKey: ["contract-view", id],
|
queryKey: ["contract-view", id],
|
||||||
@@ -118,16 +114,21 @@ export default function ContractViewPage() {
|
|||||||
};
|
};
|
||||||
}, [checkScrollBottom]);
|
}, [checkScrollBottom]);
|
||||||
|
|
||||||
// Send (or resend) the fresh OTP challenge to the customer's phone. On success
|
// Send (or resend) the fresh OTP challenge to the contract company's phone. On
|
||||||
// we swap the signature modal for the OTP entry modal.
|
// success we swap the signature modal for the OTP entry modal and remember the
|
||||||
|
// masked destination the server reported.
|
||||||
const sendOtpMutation = useMutation({
|
const sendOtpMutation = useMutation({
|
||||||
mutationFn: () => api.auth.sendOTP.call({ phone: customerPhone }),
|
mutationFn: () => contractsService.sendSigningOtp(id!),
|
||||||
onSuccess: () => {
|
onSuccess: (res) => {
|
||||||
|
setOtpSentTo(res.sentTo);
|
||||||
setSignOpen(false);
|
setSignOpen(false);
|
||||||
setOtpError(null);
|
setOtpError(null);
|
||||||
setOtpOpen(true);
|
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({
|
const signMutation = useMutation({
|
||||||
@@ -140,7 +141,6 @@ export default function ContractViewPage() {
|
|||||||
signerDisplayName: signerName.trim(),
|
signerDisplayName: signerName.trim(),
|
||||||
consentText: CONSENT_TEXT,
|
consentText: CONSENT_TEXT,
|
||||||
otp: otpCode.trim(),
|
otp: otpCode.trim(),
|
||||||
otpPhone: customerPhone,
|
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setOtpOpen(false);
|
setOtpOpen(false);
|
||||||
@@ -169,10 +169,8 @@ export default function ContractViewPage() {
|
|||||||
if (!signerName.trim()) return;
|
if (!signerName.trim()) return;
|
||||||
const image = usingSaved ? savedSignatureImage : signatureData;
|
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||||
if (!image) return;
|
if (!image) return;
|
||||||
if (!customerPhone) {
|
// The server resolves and validates the company phone; if none is on file it
|
||||||
toast.error("No phone number on file to verify your signature.");
|
// returns a clear 400 that surfaces via the mutation's onError.
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOtpCode("");
|
setOtpCode("");
|
||||||
sendOtpMutation.mutate();
|
sendOtpMutation.mutate();
|
||||||
};
|
};
|
||||||
@@ -408,10 +406,16 @@ export default function ContractViewPage() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
For security, enter the 6-digit code we sent by SMS to{" "}
|
For security, enter the 6-digit code we sent by SMS to the
|
||||||
<Text span fw={600} c="edr-text">
|
contract company's registered number
|
||||||
{maskedPhone}
|
{otpSentTo ? (
|
||||||
</Text>{" "}
|
<>
|
||||||
|
{" "}
|
||||||
|
<Text span fw={600} c="edr-text">
|
||||||
|
{otpSentTo}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : null}{" "}
|
||||||
to confirm and apply your signature.
|
to confirm and apply your signature.
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -268,6 +268,14 @@ export const contractsService = {
|
|||||||
return data.data ?? data;
|
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 (
|
renew: async (
|
||||||
id: string,
|
id: string,
|
||||||
dto: Freight.RenewContractDto,
|
dto: Freight.RenewContractDto,
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { UseQueryOptions, QueryObserverOptions } from "@tanstack/react-query";
|
|||||||
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||||
|
import {
|
||||||
|
emitApiError,
|
||||||
|
extractApiErrorPayload,
|
||||||
|
} from "@/components/errors/ApiErrorModal";
|
||||||
|
|
||||||
const client = axios.create({
|
const client = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
@@ -102,6 +106,12 @@ client.interceptors.response.use(
|
|||||||
originalRequest.url === URL_CONSTANTS.AUTH.LOGIN ||
|
originalRequest.url === URL_CONSTANTS.AUTH.LOGIN ||
|
||||||
originalRequest.url === URL_CONSTANTS.USERS.SIGN_UP
|
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);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user