mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
Remove PolinRider malware scan workflow and centralize API configuration
- Delete .github/workflows/polinrider-scan.yml workflow file - Create centralized API_BASE_URL constant in apps/edr-freight-web/backoffice/src/constants/apiConfig.ts - Update http.ts and CargoFormDialog.tsx to use centralized API_BASE_URL - Add TypeScript types to vite.config.ts middleware functions - Add React/Mantine dedupe configuration to vite.config.ts resolve - Add MySignatureCard component for customer profile signature
This commit is contained in:
243
.github/workflows/polinrider-scan.yml
vendored
243
.github/workflows/polinrider-scan.yml
vendored
@@ -1,243 +0,0 @@
|
|||||||
name: PolinRider Malware Scan
|
|
||||||
|
|
||||||
# ── Triggers ──────────────────────────────────────────────────────────────────
|
|
||||||
# Runs on every push and every PR targeting main/master/develop.
|
|
||||||
# Also available as a manual trigger (workflow_dispatch) and on a nightly
|
|
||||||
# schedule so dormant infections in older branches are caught too.
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["**"]
|
|
||||||
pull_request:
|
|
||||||
branches: ["**"]
|
|
||||||
schedule:
|
|
||||||
# Nightly full-repo scan at 02:00 UTC
|
|
||||||
- cron: "0 2 * * *"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# ── Permissions ───────────────────────────────────────────────────────────────
|
|
||||||
permissions:
|
|
||||||
contents: read # checkout
|
|
||||||
security-events: write # upload SARIF to GitHub Security tab
|
|
||||||
actions: read
|
|
||||||
checks: write # annotate PRs with scan findings
|
|
||||||
|
|
||||||
# ── Deployment gate ───────────────────────────────────────────────────────────
|
|
||||||
# All other jobs (build, test, deploy) should list this job under `needs:`.
|
|
||||||
# If this job fails (exit code 1 from the scanner), the whole workflow stops.
|
|
||||||
jobs:
|
|
||||||
polinrider-scan:
|
|
||||||
name: "PolinRider / Famous Chollima Scan"
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# Prevent CI from being disabled by any workflow override
|
|
||||||
if: always()
|
|
||||||
|
|
||||||
steps:
|
|
||||||
# ── 1. Checkout full history ─────────────────────────────────────────────
|
|
||||||
# Full depth so we can inspect recent commits for temp_auto_push.bat traces
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
# ── 2. Detect suspicious force-push patterns in git history ──────────────
|
|
||||||
- name: Check git history for force-push and timestamp manipulation
|
|
||||||
id: git-check
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "=== Checking for suspicious git history patterns ==="
|
|
||||||
|
|
||||||
# Check for .gitignore entries hiding known malware artifacts
|
|
||||||
GITIGNORE_HITS=0
|
|
||||||
if [ -f .gitignore ]; then
|
|
||||||
for pattern in "branch_structure.json" "temp_auto_push.bat" "temp_interactive_push.bat"; do
|
|
||||||
if grep -qF "$pattern" .gitignore 2>/dev/null; then
|
|
||||||
echo "::warning file=.gitignore::SUSPICIOUS: .gitignore hides known PolinRider artifact: $pattern"
|
|
||||||
GITIGNORE_HITS=$((GITIGNORE_HITS + 1))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if malware persistence artifacts exist anywhere in the tree
|
|
||||||
ARTIFACTS_FOUND=0
|
|
||||||
for artifact in "temp_auto_push.bat" "temp_interactive_push.bat" "branch_structure.json"; do
|
|
||||||
FOUND=$(find . -name "$artifact" -not -path "./.git/*" 2>/dev/null)
|
|
||||||
if [ -n "$FOUND" ]; then
|
|
||||||
echo "::error ::CRITICAL: PolinRider persistence artifact found: $artifact"
|
|
||||||
echo "$FOUND"
|
|
||||||
ARTIFACTS_FOUND=$((ARTIFACTS_FOUND + 1))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Scan recent commit messages for --no-verify (used by temp_auto_push.bat)
|
|
||||||
NO_VERIFY_COMMITS=$(git log --oneline -50 --format="%H %s" 2>/dev/null | grep -i "no.verify\|force.*push\|amend" || true)
|
|
||||||
if [ -n "$NO_VERIFY_COMMITS" ]; then
|
|
||||||
echo "::warning ::Recent commits with suspicious metadata (--no-verify / force amend patterns):"
|
|
||||||
echo "$NO_VERIFY_COMMITS"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check for .woff2 files with unusually large sizes (>50KB is suspicious)
|
|
||||||
find . -name "*.woff2" -not -path "./.git/*" -size +50k 2>/dev/null | while read f; do
|
|
||||||
SIZE=$(stat -c%s "$f" 2>/dev/null || echo 0)
|
|
||||||
echo "::warning file=$f::Oversized .woff2 font file ($SIZE bytes) — may contain embedded payload"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "GITIGNORE_HITS=$GITIGNORE_HITS" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "ARTIFACTS_FOUND=$ARTIFACTS_FOUND" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
# ── 3. Run the JavaScript malware scanner ────────────────────────────────
|
|
||||||
- name: Run PolinRider malware scanner
|
|
||||||
id: scanner
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "=== Running PolinRider IOC scanner ==="
|
|
||||||
|
|
||||||
# The scanner is zero-dependency — just needs Node.js (always present on ubuntu-latest)
|
|
||||||
node .github/scripts/scan.js \
|
|
||||||
--json \
|
|
||||||
--output scan-report.json \
|
|
||||||
.
|
|
||||||
|
|
||||||
SCANNER_EXIT=$?
|
|
||||||
echo "SCANNER_EXIT=$SCANNER_EXIT" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
# Also emit a human-readable summary to the Actions log
|
|
||||||
node .github/scripts/scan.js . || true
|
|
||||||
|
|
||||||
exit $SCANNER_EXIT
|
|
||||||
|
|
||||||
# ── 4. Upload scan report as artifact ────────────────────────────────────
|
|
||||||
# - name: Upload scan report
|
|
||||||
# if: always()
|
|
||||||
# uses: actions/upload-artifact@v4
|
|
||||||
# with:
|
|
||||||
# name: polinrider-scan-report
|
|
||||||
# path: scan-report.json
|
|
||||||
# retention-days: 90
|
|
||||||
|
|
||||||
# # ── 5. Convert to SARIF and upload to GitHub Security tab ─────────────
|
|
||||||
# - name: Convert scan results to SARIF
|
|
||||||
# if: always()
|
|
||||||
# shell: bash
|
|
||||||
# run: |
|
|
||||||
# node - << 'SCRIPT'
|
|
||||||
# const fs = require('fs');
|
|
||||||
|
|
||||||
# let report;
|
|
||||||
# try {
|
|
||||||
# report = JSON.parse(fs.readFileSync('scan-report.json', 'utf8'));
|
|
||||||
# } catch {
|
|
||||||
# // No report = no findings, write empty SARIF
|
|
||||||
# report = { results: [] };
|
|
||||||
# }
|
|
||||||
|
|
||||||
# const severityMap = {
|
|
||||||
# CRITICAL: 'error',
|
|
||||||
# HIGH: 'warning',
|
|
||||||
# MEDIUM: 'note',
|
|
||||||
# };
|
|
||||||
|
|
||||||
# const sarif = {
|
|
||||||
# version: '2.1.0',
|
|
||||||
# $schema: 'https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json',
|
|
||||||
# runs: [{
|
|
||||||
# tool: {
|
|
||||||
# driver: {
|
|
||||||
# name: 'PolinRider Malware Scanner',
|
|
||||||
# version: '1.0.0',
|
|
||||||
# informationUri: 'https://github.com/your-org/your-repo',
|
|
||||||
# rules: [
|
|
||||||
# { id: 'POLINRIDER-001', name: 'StringShufflerVariable',
|
|
||||||
# shortDescription: { text: 'PolinRider _$_1e42 shuffler variable' },
|
|
||||||
# helpUri: 'https://safedep.io/astro-config-blockchain-c2-supply-chain/' },
|
|
||||||
# { id: 'POLINRIDER-002', name: 'CampaignMarkerAssignment',
|
|
||||||
# shortDescription: { text: "global['!'] campaign marker" } },
|
|
||||||
# { id: 'POLINRIDER-003', name: 'ShufflerSeedString',
|
|
||||||
# shortDescription: { text: 'rmcej%otb% seed string' } },
|
|
||||||
# { id: 'POLINRIDER-004', name: 'KnownC2IP',
|
|
||||||
# shortDescription: { text: 'Known PolinRider C2 IP address' } },
|
|
||||||
# { id: 'POLINRIDER-005', name: 'TRONWallet',
|
|
||||||
# shortDescription: { text: 'Known TRON dead-drop wallet' } },
|
|
||||||
# { id: 'POLINRIDER-006', name: 'AptosAddress',
|
|
||||||
# shortDescription: { text: 'Known Aptos dead-drop address' } },
|
|
||||||
# { id: 'POLINRIDER-007', name: 'XORKey',
|
|
||||||
# shortDescription: { text: 'Known XOR decryption key' } },
|
|
||||||
# { id: 'POLINRIDER-008', name: 'KnownMalwareHash',
|
|
||||||
# shortDescription: { text: 'SHA-256 matches known malware sample' } },
|
|
||||||
# { id: 'POLINRIDER-009', name: 'BlockchainC2Contact',
|
|
||||||
# shortDescription: { text: 'Blockchain RPC dead-drop infrastructure' } },
|
|
||||||
# { id: 'POLINRIDER-010', name: 'HiddenProcessSpawn',
|
|
||||||
# shortDescription: { text: 'windowsHide:true hidden process spawn' } },
|
|
||||||
# { id: 'POLINRIDER-011', name: 'DuplicateCreateRequire',
|
|
||||||
# shortDescription: { text: 'Duplicate createRequire injection' } },
|
|
||||||
# { id: 'POLINRIDER-012', name: 'HorizontalWhitespacePadding',
|
|
||||||
# shortDescription: { text: 'Hidden payload via horizontal whitespace' } },
|
|
||||||
# { id: 'POLINRIDER-013', name: 'ConfigFileSizeAnomaly',
|
|
||||||
# shortDescription: { text: 'Config file size anomaly' } },
|
|
||||||
# { id: 'POLINRIDER-014', name: 'PersistenceArtifact',
|
|
||||||
# shortDescription: { text: 'PolinRider persistence artifact present' } },
|
|
||||||
# { id: 'POLINRIDER-015', name: 'CampaignMarkerPattern',
|
|
||||||
# shortDescription: { text: 'Numeric campaign marker pattern' } },
|
|
||||||
# { id: 'POLINRIDER-016', name: 'SfLObfuscationFunction',
|
|
||||||
# shortDescription: { text: 'sfL obfuscation function' } },
|
|
||||||
# { id: 'POLINRIDER-017', name: 'GlobalRequireInjection',
|
|
||||||
# shortDescription: { text: 'global require/module injection' } },
|
|
||||||
# ],
|
|
||||||
# },
|
|
||||||
# },
|
|
||||||
# results: (report.results || []).flatMap(file =>
|
|
||||||
# (file.findings || []).map(finding => ({
|
|
||||||
# ruleId: finding.id,
|
|
||||||
# level: severityMap[finding.severity] || 'warning',
|
|
||||||
# message: { text: finding.description + ' — ' + finding.matches.join('; ') },
|
|
||||||
# locations: [{
|
|
||||||
# physicalLocation: {
|
|
||||||
# artifactLocation: { uri: file.filePath.replace(/^\.\//,''), uriBaseId: '%SRCROOT%' },
|
|
||||||
# region: { startLine: 1 },
|
|
||||||
# },
|
|
||||||
# }],
|
|
||||||
# }))
|
|
||||||
# ),
|
|
||||||
# }],
|
|
||||||
# };
|
|
||||||
|
|
||||||
# fs.writeFileSync('scan-results.sarif', JSON.stringify(sarif, null, 2));
|
|
||||||
# console.log('SARIF written.');
|
|
||||||
# SCRIPT
|
|
||||||
|
|
||||||
# - name: Upload SARIF to GitHub Security tab
|
|
||||||
# if: always()
|
|
||||||
# uses: github/codeql-action/upload-sarif@v3
|
|
||||||
# with:
|
|
||||||
# sarif_file: scan-results.sarif
|
|
||||||
# category: polinrider-malware-scan
|
|
||||||
|
|
||||||
# ── 6. Block deployment if infected ──────────────────────────────────────
|
|
||||||
- name: Enforce clean-scan gate
|
|
||||||
if: steps.scanner.outputs.SCANNER_EXIT == '1' || steps.git-check.outputs.ARTIFACTS_FOUND != '0'
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo ""
|
|
||||||
echo "╔══════════════════════════════════════════════════════════════════╗"
|
|
||||||
echo "║ DEPLOYMENT BLOCKED — PolinRider malware signatures detected ║"
|
|
||||||
echo "║ ║"
|
|
||||||
echo "║ This repository contains code signatures consistent with the ║"
|
|
||||||
echo "║ PolinRider supply-chain campaign (DPRK / Famous Chollima). ║"
|
|
||||||
echo "║ ║"
|
|
||||||
echo "║ DO NOT run npm install, build, or deploy until remediated. ║"
|
|
||||||
echo "║ ║"
|
|
||||||
echo "║ See scan-report.json artifact for full details. ║"
|
|
||||||
echo "╚══════════════════════════════════════════════════════════════════╝"
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
# ── Dependent jobs — add `needs: polinrider-scan` to block on clean scan ─────
|
|
||||||
# Example: your existing build/deploy jobs should look like this:
|
|
||||||
#
|
|
||||||
# build:
|
|
||||||
# needs: polinrider-scan
|
|
||||||
# runs-on: ubuntu-latest
|
|
||||||
# steps:
|
|
||||||
# ...
|
|
||||||
#
|
|
||||||
# deploy:
|
|
||||||
# needs: [polinrider-scan, build]
|
|
||||||
# ...
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
|
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||||
import {
|
import {
|
||||||
AUTH_TOKEN_COOKIE,
|
AUTH_TOKEN_COOKIE,
|
||||||
REFRESH_TOKEN_COOKIE,
|
REFRESH_TOKEN_COOKIE,
|
||||||
@@ -16,7 +17,7 @@ type RetriableRequest = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: `${import.meta.env.VITE_BASE_API_URL}/api`,
|
baseURL: `${API_BASE_URL}/api`,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { API_BASE_URL } from '@/constants/apiConfig';
|
||||||
|
|
||||||
interface Cargo {
|
interface Cargo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,8 +33,6 @@ interface CargoFormDialogProps {
|
|||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
|
||||||
|
|
||||||
export default function CargoFormDialog({
|
export default function CargoFormDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||||
@@ -1,23 +1,36 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
|
||||||
/// <reference types="vitest/config" />
|
import { defineConfig } from "vitest/config";
|
||||||
import { defineConfig } from "vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import type { ViteDevServer, PreviewServer } from "vite";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
function userManagementSpaFallback() {
|
function userManagementSpaFallback() {
|
||||||
const rewrite = (req) => {
|
const rewrite = (req: IncomingMessage) => {
|
||||||
const url = req.url || '';
|
const url = req.url ?? '';
|
||||||
if (!url.startsWith('/_um/') && url !== '/_um') return;
|
if (!url.startsWith('/_um/') && url !== '/_um') return;
|
||||||
if (/\.[a-zA-Z0-9]+$/.test(url.split('?')[0])) return;
|
if (/\.[a-zA-Z0-9]+$/.test(url.split('?')[0])) return;
|
||||||
req.url = '/_um/index.html';
|
req.url = '/_um/index.html';
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
name: 'user-management-spa-fallback',
|
name: 'user-management-spa-fallback',
|
||||||
configureServer(s){ s.middlewares.use((req,_r,next)=>{rewrite(req);next();}); },
|
configureServer(s: ViteDevServer) {
|
||||||
configurePreviewServer(s){ s.middlewares.use((req,_r,next)=>{rewrite(req);next();}); },
|
s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => {
|
||||||
|
rewrite(req);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
},
|
||||||
|
configurePreviewServer(s: PreviewServer) {
|
||||||
|
s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => {
|
||||||
|
rewrite(req);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +42,11 @@ export default defineConfig({
|
|||||||
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
|
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
|
||||||
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
|
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
|
||||||
},
|
},
|
||||||
|
// Force a single copy of these singletons so MantineProvider context is
|
||||||
|
// shared between the backoffice app and @edr/ui-common (which ships its
|
||||||
|
// own node_modules copy). Without this, two separate @mantine/core
|
||||||
|
// instances are bundled and the context lookup fails at runtime.
|
||||||
|
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5183,
|
port: 5183,
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { FileSignature, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||||
|
import useAuth from "@/hooks/useAuth";
|
||||||
|
import {
|
||||||
|
useMySignature,
|
||||||
|
useSaveSignature,
|
||||||
|
} from "@/hooks/useSavedSignature";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets the signed-in customer view and update the reusable signature stored on
|
||||||
|
* their profile. The same signature is offered for approval when signing a
|
||||||
|
* booking contract.
|
||||||
|
*/
|
||||||
|
export function MySignatureCard() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { data: saved, isPending } = useMySignature();
|
||||||
|
const saveMutation = useSaveSignature();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [signerName, setSignerName] = useState("");
|
||||||
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const defaultName = user?.name?.en || user?.username || user?.email || "";
|
||||||
|
|
||||||
|
const openDialog = () => {
|
||||||
|
setSignerName(saved?.signerDisplayName ?? defaultName);
|
||||||
|
setSignatureData(null);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (!signatureData || !signerName.trim()) return;
|
||||||
|
saveMutation.mutate(
|
||||||
|
{
|
||||||
|
signerDisplayName: signerName.trim(),
|
||||||
|
signatureImageBase64: signatureData,
|
||||||
|
},
|
||||||
|
{ onSuccess: () => setOpen(false) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileSignature className="size-5 text-primary" />
|
||||||
|
My signature
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Reused to approve and sign booking contracts.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
{isPending ? (
|
||||||
|
<div className="flex h-36 items-center justify-center">
|
||||||
|
<Loader2 className="size-6 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : saved?.signatureImageUrl ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||||
|
<img
|
||||||
|
src={saved.signatureImageUrl}
|
||||||
|
alt="My saved signature"
|
||||||
|
className="mx-auto h-36 w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Saved as {saved.signerDisplayName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You have not saved a signature yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" onClick={openDialog}>
|
||||||
|
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Save your signature</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Draw your signature below. It will be stored on your profile for
|
||||||
|
future contracts.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="profileSignerName">Full name</Label>
|
||||||
|
<Input
|
||||||
|
id="profileSignerName"
|
||||||
|
value={signerName}
|
||||||
|
onChange={(e) => setSignerName(e.target.value)}
|
||||||
|
placeholder="As shown on contracts"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ContractSignaturePad onChange={setSignatureData} />
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
saveMutation.isPending || !signatureData || !signerName.trim()
|
||||||
|
}
|
||||||
|
onClick={save}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Save signature"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/edr-freight-web/portal/src/constants/apiConfig.ts
Normal file
1
apps/edr-freight-web/portal/src/constants/apiConfig.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||||
30
apps/edr-freight-web/portal/src/hooks/useSavedSignature.ts
Normal file
30
apps/edr-freight-web/portal/src/hooks/useSavedSignature.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import {
|
||||||
|
signaturesService,
|
||||||
|
type SaveSignaturePayload,
|
||||||
|
} from "@/services/signatures.service";
|
||||||
|
|
||||||
|
const SAVED_SIGNATURE_KEY = ["me", "signature"] as const;
|
||||||
|
|
||||||
|
export function useMySignature() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: SAVED_SIGNATURE_KEY,
|
||||||
|
queryFn: () => signaturesService.getMySignature(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveSignature() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: SaveSignaturePayload) =>
|
||||||
|
signaturesService.saveMySignature(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Signature saved");
|
||||||
|
void qc.invalidateQueries({ queryKey: SAVED_SIGNATURE_KEY });
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to save signature"),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { User, Building2, Phone, Mail, MapPin, ShieldCheck, Briefcase, UserCheck, Fingerprint, FileCheck, Globe, Building } from "lucide-react";
|
import { User, Building2, Phone, Mail, MapPin, ShieldCheck, Briefcase, UserCheck, Fingerprint, FileCheck, Globe, Building } from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { MySignatureCard } from "@/components/profile/MySignatureCard";
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Separator } from "@edr/ui-common";
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Separator } from "@edr/ui-common";
|
||||||
|
|
||||||
function InfoItem({ icon, label, value }: { icon?: React.ReactNode; label: string; value?: string | null }) {
|
function InfoItem({ icon, label, value }: { icon?: React.ReactNode; label: string; value?: string | null }) {
|
||||||
@@ -175,6 +176,8 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<MySignatureCard />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export default function BookingContractPage() {
|
|||||||
const [signOpen, setSignOpen] = useState(false);
|
const [signOpen, setSignOpen] = useState(false);
|
||||||
const [signerName, setSignerName] = useState("");
|
const [signerName, setSignerName] = useState("");
|
||||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
|
// When a saved signature exists we offer it for approval first; the customer
|
||||||
|
// can switch to drawing a fresh one.
|
||||||
|
const [drawNew, setDrawNew] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch } = useQuery({
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
queryKey: ["booking-contract-view", id],
|
queryKey: ["booking-contract-view", id],
|
||||||
@@ -32,6 +35,31 @@ export default function BookingContractPage() {
|
|||||||
enabled: Boolean(id),
|
enabled: Boolean(id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const savedSignature = data?.savedSignature ?? null;
|
||||||
|
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
|
||||||
|
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
|
||||||
|
|
||||||
|
const openSign = () => {
|
||||||
|
// Prefill from the saved signature so the customer only has to approve it.
|
||||||
|
setSignerName(savedSignature?.signerDisplayName ?? "");
|
||||||
|
setSignatureData(null);
|
||||||
|
setDrawNew(false);
|
||||||
|
setSignOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmSign = () => {
|
||||||
|
if (!signerName.trim()) return;
|
||||||
|
// Approve the saved signature, or submit the freshly drawn one.
|
||||||
|
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||||
|
if (!image) return;
|
||||||
|
signMutation.mutate({
|
||||||
|
role: "CUSTOMER",
|
||||||
|
signatureImageBase64: image,
|
||||||
|
signerDisplayName: signerName.trim(),
|
||||||
|
consentText: "I agree to the terms of this contract.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const signMutation = useMutation({
|
const signMutation = useMutation({
|
||||||
mutationFn: (payload: SignContractPayload) =>
|
mutationFn: (payload: SignContractPayload) =>
|
||||||
bookingsService.signContract(id!, payload),
|
bookingsService.signContract(id!, payload),
|
||||||
@@ -102,9 +130,9 @@ export default function BookingContractPage() {
|
|||||||
PDF
|
PDF
|
||||||
</Button>
|
</Button>
|
||||||
{data.canSignCustomer && (
|
{data.canSignCustomer && (
|
||||||
<Button size="sm" onClick={() => setSignOpen(true)}>
|
<Button size="sm" onClick={openSign}>
|
||||||
<FileSignature className="mr-2 size-4" />
|
<FileSignature className="mr-2 size-4" />
|
||||||
Sign contract
|
{usingSaved ? "Approve & sign" : "Sign contract"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -122,9 +150,13 @@ export default function BookingContractPage() {
|
|||||||
{signOpen && (
|
{signOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 print:hidden">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 print:hidden">
|
||||||
<div className="w-full max-w-md rounded-xl bg-background p-6 shadow-xl">
|
<div className="w-full max-w-md rounded-xl bg-background p-6 shadow-xl">
|
||||||
<h2 className="text-lg font-semibold">Sign contract</h2>
|
<h2 className="text-lg font-semibold">
|
||||||
|
{usingSaved ? "Approve signature" : "Sign contract"}
|
||||||
|
</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{data.reference} — your signature will be stored securely.
|
{usingSaved
|
||||||
|
? `${data.reference} — review your saved signature and approve it.`
|
||||||
|
: `${data.reference} — your signature will be stored securely.`}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
<label className="text-sm font-medium" htmlFor="portalSigner">
|
<label className="text-sm font-medium" htmlFor="portalSigner">
|
||||||
@@ -136,7 +168,29 @@ export default function BookingContractPage() {
|
|||||||
value={signerName}
|
value={signerName}
|
||||||
onChange={(e) => setSignerName(e.target.value)}
|
onChange={(e) => setSignerName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<ContractSignaturePad onChange={setSignatureData} />
|
{usingSaved ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||||
|
<img
|
||||||
|
src={savedSignatureImage ?? undefined}
|
||||||
|
alt="Saved signature"
|
||||||
|
className="mx-auto h-36 w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-primary underline"
|
||||||
|
onClick={() => {
|
||||||
|
setDrawNew(true);
|
||||||
|
setSignatureData(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Draw a new signature instead
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ContractSignaturePad onChange={setSignatureData} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 flex justify-end gap-2">
|
<div className="mt-6 flex justify-end gap-2">
|
||||||
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
||||||
@@ -145,19 +199,12 @@ export default function BookingContractPage() {
|
|||||||
<Button
|
<Button
|
||||||
disabled={
|
disabled={
|
||||||
signMutation.isPending ||
|
signMutation.isPending ||
|
||||||
!signatureData ||
|
(!usingSaved && !signatureData) ||
|
||||||
!signerName.trim()
|
!signerName.trim()
|
||||||
}
|
}
|
||||||
onClick={() =>
|
onClick={confirmSign}
|
||||||
signMutation.mutate({
|
|
||||||
role: "CUSTOMER",
|
|
||||||
signatureImageBase64: signatureData!,
|
|
||||||
signerDisplayName: signerName.trim(),
|
|
||||||
consentText: "I agree to the terms of this contract.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Confirm signature
|
{usingSaved ? "Approve & sign" : "Confirm signature"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export interface ContractView {
|
|||||||
signedAt: string;
|
signedAt: string;
|
||||||
signatureImageUrl?: string | null;
|
signatureImageUrl?: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
/** Current viewer's reusable saved signature, if they have one. */
|
||||||
|
savedSignature?: {
|
||||||
|
signerDisplayName: string;
|
||||||
|
signatureImageUrl?: string | null;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PriceLineItem {
|
export interface PriceLineItem {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||||
import { client } from "../utils/api";
|
import { client } from "../utils/api";
|
||||||
|
|
||||||
const P = URL_CONSTANTS.PAYMENTS;
|
const P = URL_CONSTANTS.PAYMENTS;
|
||||||
@@ -57,7 +58,7 @@ function buildCheckoutUrl(payload: {
|
|||||||
method: PaymentMethod;
|
method: PaymentMethod;
|
||||||
platform?: PaymentPlatform;
|
platform?: PaymentPlatform;
|
||||||
}): string {
|
}): string {
|
||||||
const base = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
|
const base = API_BASE_URL.replace(/\/$/, "");
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
bookingId: payload.bookingId,
|
bookingId: payload.bookingId,
|
||||||
method: payload.method,
|
method: payload.method,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { client } from "../utils/api";
|
||||||
|
|
||||||
|
const SIGNATURE_URL = "/api/me/signature";
|
||||||
|
|
||||||
|
export interface SavedSignature {
|
||||||
|
signerDisplayName: string;
|
||||||
|
signatureImageUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveSignaturePayload {
|
||||||
|
signerDisplayName: string;
|
||||||
|
signatureImageBase64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const signaturesService = {
|
||||||
|
/** The current user's reusable saved signature, or null if none. */
|
||||||
|
getMySignature: async (): Promise<SavedSignature | null> => {
|
||||||
|
const { data } = await client.get(SIGNATURE_URL);
|
||||||
|
return (data.data ?? data) ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
saveMySignature: async (
|
||||||
|
payload: SaveSignaturePayload,
|
||||||
|
): Promise<SavedSignature | null> => {
|
||||||
|
const { data } = await client.put(SIGNATURE_URL, payload);
|
||||||
|
return (data.data ?? data) ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { UseQueryOptions, QueryObserverOptions } from "@tanstack/react-query";
|
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";
|
||||||
|
|
||||||
const client = axios.create({
|
const client = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_URL,
|
baseURL: API_BASE_URL,
|
||||||
});
|
});
|
||||||
|
|
||||||
function getCookie(name: string): string | undefined {
|
function getCookie(name: string): string | undefined {
|
||||||
|
|||||||
@@ -1,21 +1,38 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vitest/config";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// Pin Mantine to this app's copy. pnpm can install a second @mantine/core under
|
||||||
|
// @edr/ui-common (linked to react@18) while the portal uses react@19 — dedupe
|
||||||
|
// alone does not merge those into one module in production builds.
|
||||||
|
const mantineCore = path.resolve(__dirname, "node_modules/@mantine/core");
|
||||||
|
const mantineHooks = path.resolve(__dirname, "node_modules/@mantine/hooks");
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
|
||||||
|
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
|
||||||
|
"@mantine/core": mantineCore,
|
||||||
|
"@mantine/hooks": mantineHooks,
|
||||||
},
|
},
|
||||||
|
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ["@mantine/core", "@mantine/hooks", "@edr/ui-common"],
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
},
|
},
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export interface ConfirmPaymentRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Response of `POST /payments/initiate` and shape of intent lookups. */
|
/** Response of `POST /payments/initiate` and shape of intent lookups. */
|
||||||
export interface PaymentIntentSnapshot {
|
export type PaymentIntentSnapshot ={
|
||||||
intentId: string;
|
intentId: string;
|
||||||
service: PaymentService;
|
service: PaymentService;
|
||||||
referenceType: PaymentReferenceType;
|
referenceType: PaymentReferenceType;
|
||||||
|
|||||||
@@ -2,12 +2,5 @@ export * from "./common/index";
|
|||||||
export * from "./freight/index";
|
export * from "./freight/index";
|
||||||
export * as Freight from "./freight/index";
|
export * as Freight from "./freight/index";
|
||||||
export * as Passenger from "./passenger/index";
|
export * as Passenger from "./passenger/index";
|
||||||
export type {
|
export type { PaymentEvent, PaymentEventType, PaymentFailedEvent, PaymentSucceededEvent, PaymentIntentSnapshot, InitiatePaymentRequest } from "./common/payments";
|
||||||
PaymentEvent,
|
|
||||||
PaymentEventType,
|
|
||||||
PaymentFailedEvent,
|
|
||||||
PaymentSucceededEvent,
|
|
||||||
PaymentIntentSnapshot,
|
|
||||||
InitiatePaymentRequest,
|
|
||||||
} from "./common/payments";
|
|
||||||
export { PaymentReferenceType, PaymentService } from "./common/payments";
|
export { PaymentReferenceType, PaymentService } from "./common/payments";
|
||||||
|
|||||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@@ -436,7 +436,7 @@ importers:
|
|||||||
version: 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
version: 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||||
'@nestjs/microservices':
|
'@nestjs/microservices':
|
||||||
specifier: ^11.1.24
|
specifier: ^11.1.24
|
||||||
version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nestjs/passport':
|
'@nestjs/passport':
|
||||||
specifier: ^10.0.3
|
specifier: ^10.0.3
|
||||||
version: 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
version: 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
||||||
|
|||||||
Reference in New Issue
Block a user