mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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 { API_BASE_URL } from "@/constants/apiConfig";
|
||||
import {
|
||||
AUTH_TOKEN_COOKIE,
|
||||
REFRESH_TOKEN_COOKIE,
|
||||
@@ -16,7 +17,7 @@ type RetriableRequest = {
|
||||
};
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: `${import.meta.env.VITE_BASE_API_URL}/api`,
|
||||
baseURL: `${API_BASE_URL}/api`,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { API_BASE_URL } from '@/constants/apiConfig';
|
||||
|
||||
interface Cargo {
|
||||
id: string;
|
||||
@@ -32,8 +33,6 @@ interface CargoFormDialogProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
export default function CargoFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
@@ -1,23 +1,36 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import type { ViteDevServer, PreviewServer } from "vite";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function userManagementSpaFallback() {
|
||||
const rewrite = (req) => {
|
||||
const url = req.url || '';
|
||||
const rewrite = (req: IncomingMessage) => {
|
||||
const url = req.url ?? '';
|
||||
if (!url.startsWith('/_um/') && url !== '/_um') return;
|
||||
if (/\.[a-zA-Z0-9]+$/.test(url.split('?')[0])) return;
|
||||
req.url = '/_um/index.html';
|
||||
};
|
||||
return {
|
||||
name: 'user-management-spa-fallback',
|
||||
configureServer(s){ s.middlewares.use((req,_r,next)=>{rewrite(req);next();}); },
|
||||
configurePreviewServer(s){ s.middlewares.use((req,_r,next)=>{rewrite(req);next();}); },
|
||||
configureServer(s: ViteDevServer) {
|
||||
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).
|
||||
"@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: {
|
||||
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 { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { MySignatureCard } from "@/components/profile/MySignatureCard";
|
||||
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 }) {
|
||||
@@ -175,6 +176,8 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<MySignatureCard />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,9 @@ export default function BookingContractPage() {
|
||||
const [signOpen, setSignOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
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({
|
||||
queryKey: ["booking-contract-view", id],
|
||||
@@ -32,6 +35,31 @@ export default function BookingContractPage() {
|
||||
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({
|
||||
mutationFn: (payload: SignContractPayload) =>
|
||||
bookingsService.signContract(id!, payload),
|
||||
@@ -102,9 +130,9 @@ export default function BookingContractPage() {
|
||||
PDF
|
||||
</Button>
|
||||
{data.canSignCustomer && (
|
||||
<Button size="sm" onClick={() => setSignOpen(true)}>
|
||||
<Button size="sm" onClick={openSign}>
|
||||
<FileSignature className="mr-2 size-4" />
|
||||
Sign contract
|
||||
{usingSaved ? "Approve & sign" : "Sign contract"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -122,9 +150,13 @@ export default function BookingContractPage() {
|
||||
{signOpen && (
|
||||
<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">
|
||||
<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">
|
||||
{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>
|
||||
<div className="mt-4 space-y-3">
|
||||
<label className="text-sm font-medium" htmlFor="portalSigner">
|
||||
@@ -136,7 +168,29 @@ export default function BookingContractPage() {
|
||||
value={signerName}
|
||||
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 className="mt-6 flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
||||
@@ -145,19 +199,12 @@ export default function BookingContractPage() {
|
||||
<Button
|
||||
disabled={
|
||||
signMutation.isPending ||
|
||||
!signatureData ||
|
||||
(!usingSaved && !signatureData) ||
|
||||
!signerName.trim()
|
||||
}
|
||||
onClick={() =>
|
||||
signMutation.mutate({
|
||||
role: "CUSTOMER",
|
||||
signatureImageBase64: signatureData!,
|
||||
signerDisplayName: signerName.trim(),
|
||||
consentText: "I agree to the terms of this contract.",
|
||||
})
|
||||
}
|
||||
onClick={confirmSign}
|
||||
>
|
||||
Confirm signature
|
||||
{usingSaved ? "Approve & sign" : "Confirm signature"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,11 @@ export interface ContractView {
|
||||
signedAt: string;
|
||||
signatureImageUrl?: string | null;
|
||||
}>;
|
||||
/** Current viewer's reusable saved signature, if they have one. */
|
||||
savedSignature?: {
|
||||
signerDisplayName: string;
|
||||
signatureImageUrl?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface PriceLineItem {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
import { client } from "../utils/api";
|
||||
|
||||
const P = URL_CONSTANTS.PAYMENTS;
|
||||
@@ -57,7 +58,7 @@ function buildCheckoutUrl(payload: {
|
||||
method: PaymentMethod;
|
||||
platform?: PaymentPlatform;
|
||||
}): string {
|
||||
const base = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
|
||||
const base = API_BASE_URL.replace(/\/$/, "");
|
||||
const params = new URLSearchParams({
|
||||
bookingId: payload.bookingId,
|
||||
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 axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
baseURL: API_BASE_URL,
|
||||
});
|
||||
|
||||
function getCookie(name: string): string | undefined {
|
||||
|
||||
@@ -1,21 +1,38 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
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({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": 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: {
|
||||
port: 5173,
|
||||
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. */
|
||||
export interface PaymentIntentSnapshot {
|
||||
export type PaymentIntentSnapshot ={
|
||||
intentId: string;
|
||||
service: PaymentService;
|
||||
referenceType: PaymentReferenceType;
|
||||
|
||||
@@ -2,12 +2,5 @@ export * from "./common/index";
|
||||
export * from "./freight/index";
|
||||
export * as Freight from "./freight/index";
|
||||
export * as Passenger from "./passenger/index";
|
||||
export type {
|
||||
PaymentEvent,
|
||||
PaymentEventType,
|
||||
PaymentFailedEvent,
|
||||
PaymentSucceededEvent,
|
||||
PaymentIntentSnapshot,
|
||||
InitiatePaymentRequest,
|
||||
} from "./common/payments";
|
||||
export type { PaymentEvent, PaymentEventType, PaymentFailedEvent, PaymentSucceededEvent, PaymentIntentSnapshot, InitiatePaymentRequest } 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))
|
||||
'@nestjs/microservices':
|
||||
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':
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user