mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Draw signatures on a phone via QR code in the Sign tool (#7335)
# Description of Changes Scan a QR code in the Sign tool, draw your signature on your phone, and it appears on your desktop ready to place. Rides the mobile scanner's existing transfer sessions — no new backend endpoints. **Desktop:** a **Mobile upload** button above the signature source selector shows a QR code. When the signature arrives, the modal closes, it lands in the matching source, and **placement activates automatically** — click the PDF to place. **Phone:** a new public `/mobile-sign` page with three tabs (same order as the desktop sources): - **Draw** → canvas signature. Touch-first pad (pointer events, DPR-aware, smoothed strokes, undo/clear, black/blue ink, 3 pen sizes), exported as a transparent PNG cropped to the ink. Compact layout in phone landscape. - **Photo** → image signature. "Take a photo" opens the camera directly; "From gallery" opens the picker. A preview of the current image signature now shows in the desktop's Image source (previously arrival was invisible until placement — also fixes this for saved image signatures). - **Type** → text signature. Travels as data (text + font + colour), so it stays *editable* on the desktop. Fonts are the sign tool's own text-mode list. **Security:** the transfer endpoints are unauthenticated by design (10-min sessions, files deleted after download — same model as the scanner). The desktop treats every arrival as untrusted: images only, and the text payload is clamped field by field. **Config:** new `system.enableMobileSignature` flag (default on), independent of `enableMobileScanner`; the shared endpoints accept either. The Tauri desktop app serves a self-contained `mobile-sign.html` (draw-only), mirroring `mobile-upload.html`. **Refactor:** the session lifecycle (create/poll/download/expiry) moved out of `MobileUploadModal` into a shared `useMobileTransferSession` hook; the scanner modal now uses it, behaviour unchanged. Also fixes two bugs hit along the way: the signature pad collapsing to its 150px intrinsic height (indefinite parent height), and a setState loop in `SignSettings` when text parameters are set programmatically (draft-sync effects ping-ponging). ## Screenshots | Desktop: QR entry | Phone: draw | Desktop: received | |---|---|---| |  |  |  | ## How to test 1. Open the app on an address your phone can reach (not `localhost`), Sign tool → **Mobile upload**, scan the QR. 2. Draw → **Send to computer** → it becomes the active canvas signature and placement is live: click the PDF to place. 3. Photo tab → arrives in the Image source with a preview. Type tab → arrives editable in the Text source. 4. Flags: `enableMobileSignature: false` hides the button; signature still works with the scanner disabled. Verified end-to-end (all three kinds, portrait/landscape/tablet) plus `task frontend:check` and the touched backend tests. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [x] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [x] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] I have run `task check` to verify linters, typechecks, and tests pass - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details.
This commit is contained in:
@@ -4993,6 +4993,34 @@ uploadSuccess = "Upload Successful!"
|
||||
uploadSuccessMessage = "Your images have been transferred."
|
||||
validating = "Validating session..."
|
||||
|
||||
[mobileSign]
|
||||
clear = "Clear"
|
||||
invalidSession = "Session expired"
|
||||
invalidSessionMessage = "This QR code is no longer valid. Open the Sign tool on your computer and scan the new code."
|
||||
penSizeLabel = "Pen size"
|
||||
send = "Send to computer"
|
||||
sendAnother = "Send another signature"
|
||||
sendError = "Could not send the signature. Check the connection and try again."
|
||||
sentMessage = "Signature sent to your computer. You can send another or close this page."
|
||||
tabsLabel = "Signature source"
|
||||
undo = "Undo"
|
||||
validating = "Checking session…"
|
||||
|
||||
[mobileSign.photo]
|
||||
fromGallery = "From gallery"
|
||||
hint = "Photograph a signature on white paper, or choose an existing image."
|
||||
invalidType = "Please choose an image file."
|
||||
takePhoto = "Take a photo"
|
||||
|
||||
[mobileSign.tab]
|
||||
draw = "Draw"
|
||||
photo = "Photo"
|
||||
type = "Type"
|
||||
|
||||
[mobileSign.type]
|
||||
placeholder = "Your name"
|
||||
previewPlaceholder = "Signature preview"
|
||||
|
||||
[mobileUpload]
|
||||
description = "Scan to upload photos. Images auto-convert to PDF."
|
||||
descriptionNoConvert = "Scan to upload photos from your mobile device."
|
||||
@@ -10254,6 +10282,7 @@ backgroundRemovalFailedTitle = "Background removal failed"
|
||||
hint = "Upload a PNG or JPG image of your signature"
|
||||
label = "Upload signature image"
|
||||
placeholder = "Select image file"
|
||||
previewAlt = "Current image signature"
|
||||
processing = "Processing image..."
|
||||
removeBackground = "Remove white background (make transparent)"
|
||||
|
||||
@@ -10267,6 +10296,17 @@ saved = "Select a saved signature above, then click anywhere on the PDF to place
|
||||
text = "After entering your name above, click anywhere on the PDF to place your signature."
|
||||
title = "How to add signature"
|
||||
|
||||
[sign.mobile]
|
||||
createFromPhone = "Mobile upload"
|
||||
description = "Scan this QR code with your phone or tablet, draw your signature, and it will appear here automatically."
|
||||
error = "Connection Error"
|
||||
expiryWarning = "QR Code Expiring Soon"
|
||||
expiryWarningMessage = "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically."
|
||||
instructions = "Open the camera app on your phone and scan this code. Keep this window open while you draw."
|
||||
pollingError = "Error checking for the signature"
|
||||
sessionCreateError = "Failed to create session"
|
||||
title = "Draw on your phone"
|
||||
|
||||
[sign.mode]
|
||||
move = "Move Signature"
|
||||
pause = "Pause placement"
|
||||
|
||||
@@ -9,6 +9,7 @@ import HomePage from "@app/pages/HomePage";
|
||||
import Onboarding from "@app/components/onboarding/Onboarding";
|
||||
|
||||
const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage"));
|
||||
const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage"));
|
||||
|
||||
// Import global styles
|
||||
import "@app/styles/tailwind.css";
|
||||
@@ -42,6 +43,16 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Mobile signature drawing - reached from the Sign tool QR code */}
|
||||
<Route
|
||||
path="/mobile-sign"
|
||||
element={
|
||||
<PublicRouteProviders>
|
||||
<MobileSignPage />
|
||||
</PublicRouteProviders>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* All other routes need AppProviders for backend integration */}
|
||||
<Route
|
||||
path="*"
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* Touch-first signature canvas for the phone `/mobile-sign` page.
|
||||
*
|
||||
* Pointer events (finger / stylus / mouse) with `touch-action: none`, sized to
|
||||
* its container at device-pixel-ratio resolution so strokes land exactly under
|
||||
* the finger — a fixed-size canvas stretched by CSS would offset them. Strokes
|
||||
* are kept as point lists in CSS-pixel space, so undo and resize redraw
|
||||
* losslessly, and export can crop to the inked region.
|
||||
*/
|
||||
|
||||
export interface MobileDrawCanvasHandle {
|
||||
/** Trimmed, transparent-background PNG of the ink, or null when empty. */
|
||||
exportPng: () => string | null;
|
||||
undo: () => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
interface Stroke {
|
||||
color: string;
|
||||
size: number;
|
||||
points: Array<{ x: number; y: number }>;
|
||||
}
|
||||
|
||||
interface MobileDrawCanvasProps {
|
||||
penColor: string;
|
||||
penSize: number;
|
||||
/** Fired when the canvas goes between empty and inked (gates Send/Undo). */
|
||||
onHasInkChange: (hasInk: boolean) => void;
|
||||
}
|
||||
|
||||
/** Padding kept around the ink when cropping the export, in CSS pixels. */
|
||||
const EXPORT_PADDING = 12;
|
||||
|
||||
function drawStroke(ctx: CanvasRenderingContext2D, stroke: Stroke) {
|
||||
const { points } = stroke;
|
||||
if (points.length === 0) return;
|
||||
|
||||
ctx.strokeStyle = stroke.color;
|
||||
ctx.fillStyle = stroke.color;
|
||||
ctx.lineWidth = stroke.size;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
|
||||
if (points.length === 1) {
|
||||
// A tap: render a dot, which a zero-length stroke would not show.
|
||||
ctx.beginPath();
|
||||
ctx.arc(points[0].x, points[0].y, stroke.size / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
return;
|
||||
}
|
||||
|
||||
// Quadratic midpoint smoothing: each point becomes the control point of a
|
||||
// curve to the midpoint of the next segment, turning jagged pointer samples
|
||||
// into a pen-like line.
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0].x, points[0].y);
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const midX = (points[i].x + points[i + 1].x) / 2;
|
||||
const midY = (points[i].y + points[i + 1].y) / 2;
|
||||
ctx.quadraticCurveTo(points[i].x, points[i].y, midX, midY);
|
||||
}
|
||||
const last = points[points.length - 1];
|
||||
ctx.lineTo(last.x, last.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
export const MobileDrawCanvas = forwardRef<
|
||||
MobileDrawCanvasHandle,
|
||||
MobileDrawCanvasProps
|
||||
>(function MobileDrawCanvas({ penColor, penSize, onHasInkChange }, ref) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const strokesRef = useRef<Stroke[]>([]);
|
||||
const activeStrokeRef = useRef<Stroke | null>(null);
|
||||
// Live styling for the stroke currently being drawn, without re-rendering
|
||||
const penRef = useRef({ color: penColor, size: penSize });
|
||||
penRef.current = { color: penColor, size: penSize };
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (!canvas || !ctx) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
|
||||
for (const stroke of strokesRef.current) drawStroke(ctx, stroke);
|
||||
if (activeStrokeRef.current) drawStroke(ctx, activeStrokeRef.current);
|
||||
}, []);
|
||||
|
||||
// Match the backing store to the element's CSS size × devicePixelRatio, and
|
||||
// re-match on resize/rotation. Strokes are CSS-space, so a redraw restores
|
||||
// them at the new size.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const resize = () => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.max(1, Math.round(rect.width * dpr));
|
||||
canvas.height = Math.max(1, Math.round(rect.height * dpr));
|
||||
redraw();
|
||||
};
|
||||
|
||||
resize();
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(canvas);
|
||||
return () => observer.disconnect();
|
||||
}, [redraw]);
|
||||
|
||||
const pointFromEvent = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
};
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
// One stroke at a time: a second touch while drawing would scribble.
|
||||
if (activeStrokeRef.current) return;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
activeStrokeRef.current = {
|
||||
color: penRef.current.color,
|
||||
size: penRef.current.size,
|
||||
points: [pointFromEvent(e)],
|
||||
};
|
||||
redraw();
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const stroke = activeStrokeRef.current;
|
||||
if (!stroke) return;
|
||||
// Coalesced events give the full sample train on high-rate digitizers,
|
||||
// where the per-frame synthetic event alone would drop curvature.
|
||||
const events =
|
||||
"getCoalescedEvents" in e.nativeEvent
|
||||
? (e.nativeEvent as PointerEvent).getCoalescedEvents()
|
||||
: [e.nativeEvent as PointerEvent];
|
||||
const rect = (e.currentTarget as HTMLCanvasElement).getBoundingClientRect();
|
||||
for (const ev of events) {
|
||||
stroke.points.push({
|
||||
x: ev.clientX - rect.left,
|
||||
y: ev.clientY - rect.top,
|
||||
});
|
||||
}
|
||||
redraw();
|
||||
};
|
||||
|
||||
const endStroke = () => {
|
||||
const stroke = activeStrokeRef.current;
|
||||
if (!stroke) return;
|
||||
activeStrokeRef.current = null;
|
||||
strokesRef.current = [...strokesRef.current, stroke];
|
||||
redraw();
|
||||
onHasInkChange(true);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
exportPng: () => {
|
||||
const strokes = strokesRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (strokes.length === 0 || !canvas) return null;
|
||||
|
||||
// Crop to the inked region so the signature stamps tightly, clamped to
|
||||
// what was actually visible.
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const stroke of strokes) {
|
||||
const reach = stroke.size / 2 + EXPORT_PADDING;
|
||||
for (const p of stroke.points) {
|
||||
minX = Math.min(minX, p.x - reach);
|
||||
minY = Math.min(minY, p.y - reach);
|
||||
maxX = Math.max(maxX, p.x + reach);
|
||||
maxY = Math.max(maxY, p.y + reach);
|
||||
}
|
||||
}
|
||||
minX = Math.max(0, minX);
|
||||
minY = Math.max(0, minY);
|
||||
maxX = Math.min(rect.width, maxX);
|
||||
maxY = Math.min(rect.height, maxY);
|
||||
const width = Math.max(1, maxX - minX);
|
||||
const height = Math.max(1, maxY - minY);
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const exportCanvas = document.createElement("canvas");
|
||||
exportCanvas.width = Math.round(width * dpr);
|
||||
exportCanvas.height = Math.round(height * dpr);
|
||||
const ctx = exportCanvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
// Translate args are device pixels; scale args map stroke space to them.
|
||||
ctx.setTransform(dpr, 0, 0, dpr, -minX * dpr, -minY * dpr);
|
||||
for (const stroke of strokes) drawStroke(ctx, stroke);
|
||||
return exportCanvas.toDataURL("image/png");
|
||||
},
|
||||
undo: () => {
|
||||
strokesRef.current = strokesRef.current.slice(0, -1);
|
||||
redraw();
|
||||
onHasInkChange(strokesRef.current.length > 0);
|
||||
},
|
||||
clear: () => {
|
||||
strokesRef.current = [];
|
||||
activeStrokeRef.current = null;
|
||||
redraw();
|
||||
onHasInkChange(false);
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={endStroke}
|
||||
onPointerCancel={endStroke}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "block",
|
||||
touchAction: "none",
|
||||
cursor: "crosshair",
|
||||
}}
|
||||
aria-label="Signature drawing area"
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Modal, Stack, Text, Box, Alert } from "@mantine/core";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import InfoRoundedIcon from "@mui/icons-material/InfoRounded";
|
||||
import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded";
|
||||
import WarningRoundedIcon from "@mui/icons-material/WarningRounded";
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
|
||||
import { useMobileTransferSession } from "@app/hooks/useMobileTransferSession";
|
||||
|
||||
/**
|
||||
* The QR modal shell every phone-to-desktop transfer feature shares: session
|
||||
* lifecycle, QR code, expiry/error alerts, and the fallback URL line. A
|
||||
* feature supplies its copy, its public route, and what a received file
|
||||
* means — the scanner converts to PDF, the sign tool routes it to a
|
||||
* signature source.
|
||||
*/
|
||||
interface MobileTransferModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/** SPA route the phone opens, without slashes: "mobile-scanner", "mobile-sign". */
|
||||
routePath: string;
|
||||
/** Called once per uploaded file; arrivals are untrusted, validate inside. */
|
||||
onFileReceived: (file: File) => void | Promise<void>;
|
||||
title: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
expiryWarningTitle: string;
|
||||
/** Interpolates the seconds remaining into the feature's warning copy. */
|
||||
formatExpiryWarning: (seconds: number) => string;
|
||||
errorTitle: string;
|
||||
sessionCreateErrorMessage: string;
|
||||
pollingErrorMessage: string;
|
||||
/** Rendered under the QR once files have arrived (e.g. a received-count badge). */
|
||||
renderReceived?: (count: number) => ReactNode;
|
||||
qrSize?: number;
|
||||
}
|
||||
|
||||
export default function MobileTransferModal({
|
||||
opened,
|
||||
onClose,
|
||||
routePath,
|
||||
onFileReceived,
|
||||
title,
|
||||
description,
|
||||
instructions,
|
||||
expiryWarningTitle,
|
||||
formatExpiryWarning,
|
||||
errorTitle,
|
||||
sessionCreateErrorMessage,
|
||||
pollingErrorMessage,
|
||||
renderReceived,
|
||||
qrSize = 240,
|
||||
}: MobileTransferModalProps) {
|
||||
const { config } = useAppConfig();
|
||||
|
||||
const { mobileUrl, filesReceived, error, timeRemaining, showExpiryWarning } =
|
||||
useMobileTransferSession({
|
||||
active: opened,
|
||||
routePath,
|
||||
onFileReceived,
|
||||
sessionCreateErrorMessage,
|
||||
pollingErrorMessage,
|
||||
// In dev the backend-advertised frontendUrl is the backend origin, which
|
||||
// serves no SPA — the phone must open the Vite origin this page runs on,
|
||||
// so let the URL builder fall back to it. An explicit server_url still
|
||||
// wins, as an escape hatch.
|
||||
configuredUrl:
|
||||
localStorage.getItem("server_url") ||
|
||||
(import.meta.env.DEV ? "" : config?.frontendUrl || ""),
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
centered
|
||||
size="md"
|
||||
radius="lg"
|
||||
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
|
||||
overlayProps={{ opacity: 0.35, blur: 2 }}
|
||||
styles={{ body: { paddingTop: "1.5rem" } }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<InfoRoundedIcon style={{ fontSize: "1rem" }} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
>
|
||||
<Text size="sm">{description}</Text>
|
||||
</Alert>
|
||||
|
||||
{showExpiryWarning && timeRemaining !== null && (
|
||||
<Alert
|
||||
icon={<WarningRoundedIcon style={{ fontSize: "1rem" }} />}
|
||||
title={expiryWarningTitle}
|
||||
color="orange"
|
||||
>
|
||||
<Text size="sm">
|
||||
{formatExpiryWarning(Math.ceil(timeRemaining / 1000))}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
icon={<ErrorRoundedIcon style={{ fontSize: "1rem" }} />}
|
||||
title={errorTitle}
|
||||
color="red"
|
||||
>
|
||||
<Text size="sm">{error}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
background: "white",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
|
||||
}}
|
||||
>
|
||||
<QRCodeSVG
|
||||
value={mobileUrl}
|
||||
size={qrSize}
|
||||
level="H"
|
||||
includeMargin
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{filesReceived > 0 && renderReceived?.(filesReceived)}
|
||||
|
||||
<Text size="xs" c="dimmed" ta="center" style={{ maxWidth: "300px" }}>
|
||||
{instructions}
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{
|
||||
wordBreak: "break-all",
|
||||
textAlign: "center",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{mobileUrl}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,10 @@
|
||||
import { useEffect, useCallback, useState, useRef } from "react";
|
||||
import { Modal, Stack, Text, Badge, Box, Alert } from "@mantine/core";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useCallback } from "react";
|
||||
import { Badge } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import InfoRoundedIcon from "@mui/icons-material/InfoRounded";
|
||||
import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded";
|
||||
import CheckRoundedIcon from "@mui/icons-material/CheckRounded";
|
||||
import WarningRoundedIcon from "@mui/icons-material/WarningRounded";
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import { buildMobileScannerUrl } from "@app/utils/mobileScannerUrl";
|
||||
import MobileTransferModal from "@app/components/shared/MobileTransferModal";
|
||||
import { convertImageToPdf, isImageFile } from "@app/utils/imageToPdfUtils";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
interface MobileUploadModalProps {
|
||||
opened: boolean;
|
||||
@@ -19,47 +12,6 @@ interface MobileUploadModalProps {
|
||||
onFilesReceived: (files: File[]) => void;
|
||||
}
|
||||
|
||||
// Generate a cryptographically secure UUID v4-like session ID
|
||||
function generateSessionId(): string {
|
||||
// Use Web Crypto API for cryptographically secure random values
|
||||
const cryptoObj =
|
||||
typeof crypto !== "undefined" ? crypto : (window as any).crypto;
|
||||
|
||||
if (cryptoObj && typeof cryptoObj.getRandomValues === "function") {
|
||||
const bytes = new Uint8Array(16);
|
||||
cryptoObj.getRandomValues(bytes);
|
||||
|
||||
// Set version (4) and variant bits per RFC 4122
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10
|
||||
|
||||
// Convert bytes to hex string in UUID format
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"));
|
||||
return [
|
||||
hex.slice(0, 4).join(""),
|
||||
hex.slice(4, 6).join(""),
|
||||
hex.slice(6, 8).join(""),
|
||||
hex.slice(8, 10).join(""),
|
||||
hex.slice(10, 16).join(""),
|
||||
].join("-");
|
||||
}
|
||||
|
||||
// If Web Crypto is not available, fail fast rather than using insecure randomness
|
||||
console.error(
|
||||
"Web Crypto API not available. Cannot generate secure session ID.",
|
||||
);
|
||||
throw new Error(
|
||||
"Web Crypto API not available. Cannot generate secure session ID.",
|
||||
);
|
||||
}
|
||||
|
||||
interface SessionInfo {
|
||||
sessionId: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* MobileUploadModal
|
||||
*
|
||||
@@ -73,371 +25,103 @@ export default function MobileUploadModal({
|
||||
}: MobileUploadModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const convertToPdf = config?.mobileScannerConvertToPdf !== false;
|
||||
|
||||
const [sessionId, setSessionId] = useState(() => generateSessionId());
|
||||
const [sessionInfo, setSessionInfo] = useState<SessionInfo | null>(null);
|
||||
const [filesReceived, setFilesReceived] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [timeRemaining, setTimeRemaining] = useState<number | null>(null);
|
||||
const [showExpiryWarning, setShowExpiryWarning] = useState(false);
|
||||
const pollIntervalRef = useRef<number | null>(null);
|
||||
const timerIntervalRef = useRef<number | null>(null);
|
||||
const processedFiles = useRef<Set<string>>(new Set());
|
||||
const handleFileReceived = useCallback(
|
||||
async (received: File) => {
|
||||
let file = received;
|
||||
|
||||
// Build the QR-code URL the phone opens. It must land on the public
|
||||
// /mobile-scanner route under the app's base path, otherwise the phone hits
|
||||
// the auth-gated catch-all route and is bounced to the login page.
|
||||
const mobileUrl = buildMobileScannerUrl({
|
||||
configuredUrl:
|
||||
localStorage.getItem("server_url") || config?.frontendUrl || "",
|
||||
sessionId,
|
||||
origin: window.location.origin,
|
||||
basePath: BASE_PATH,
|
||||
});
|
||||
|
||||
// Create session on backend
|
||||
const createSession = useCallback(
|
||||
async (newSessionId: string) => {
|
||||
try {
|
||||
const response = await apiClient.post<SessionInfo>(
|
||||
`/api/v1/mobile-scanner/create-session/${newSessionId}`,
|
||||
undefined,
|
||||
{
|
||||
responseType: "json",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.status || response.status !== 200) {
|
||||
throw new Error("Failed to create session");
|
||||
}
|
||||
|
||||
const data = response.data;
|
||||
setSessionInfo(data);
|
||||
setError(null);
|
||||
console.log("[MobileUploadModal] Session created:", data);
|
||||
} catch (err) {
|
||||
console.error("[MobileUploadModal] Failed to create session:", err);
|
||||
setError(
|
||||
t("mobileUpload.sessionCreateError", "Failed to create session"),
|
||||
);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Regenerate session (when expired or warned)
|
||||
const regenerateSession = useCallback(() => {
|
||||
const newSessionId = generateSessionId();
|
||||
setSessionId(newSessionId);
|
||||
setShowExpiryWarning(false);
|
||||
setFilesReceived(0);
|
||||
processedFiles.current.clear();
|
||||
createSession(newSessionId);
|
||||
}, [createSession]);
|
||||
|
||||
const pollForFiles = useCallback(async () => {
|
||||
if (!opened) return;
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/api/v1/mobile-scanner/files/${sessionId}`,
|
||||
);
|
||||
if (!response.status || response.status !== 200) {
|
||||
throw new Error("Failed to check for files");
|
||||
}
|
||||
|
||||
const data = response.data;
|
||||
const files = data.files || [];
|
||||
|
||||
// Download only files we haven't processed yet
|
||||
const newFiles = files.filter(
|
||||
(f: any) => !processedFiles.current.has(f.filename),
|
||||
);
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
for (const fileMetadata of newFiles) {
|
||||
try {
|
||||
const downloadResponse = await apiClient.get(
|
||||
`/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}`,
|
||||
{
|
||||
responseType: "blob",
|
||||
},
|
||||
);
|
||||
|
||||
if (downloadResponse.status === 200) {
|
||||
const blob = downloadResponse.data;
|
||||
let file = new File([blob], fileMetadata.filename, {
|
||||
type: fileMetadata.contentType || "image/jpeg",
|
||||
});
|
||||
|
||||
// Convert images to PDF if enabled
|
||||
if (
|
||||
isImageFile(file) &&
|
||||
config?.mobileScannerConvertToPdf !== false
|
||||
) {
|
||||
try {
|
||||
file = await convertImageToPdf(file, {
|
||||
imageResolution: config?.mobileScannerImageResolution as
|
||||
| "full"
|
||||
| "reduced"
|
||||
| undefined,
|
||||
pageFormat: config?.mobileScannerPageFormat as
|
||||
| "keep"
|
||||
| "A4"
|
||||
| "letter"
|
||||
| undefined,
|
||||
stretchToFit: config?.mobileScannerStretchToFit,
|
||||
});
|
||||
console.log(
|
||||
"[MobileUploadModal] Converted image to PDF:",
|
||||
file.name,
|
||||
);
|
||||
} catch (convertError) {
|
||||
console.warn(
|
||||
"[MobileUploadModal] Failed to convert image to PDF, using original file:",
|
||||
convertError,
|
||||
);
|
||||
// Continue with original image file if conversion fails
|
||||
}
|
||||
}
|
||||
|
||||
processedFiles.current.add(fileMetadata.filename);
|
||||
setFilesReceived((prev) => prev + 1);
|
||||
onFilesReceived([file]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[MobileUploadModal] Failed to download file:",
|
||||
fileMetadata.filename,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the entire session immediately after downloading all files
|
||||
// This ensures files are only on server for ~1 second
|
||||
// Convert images to PDF if enabled
|
||||
if (isImageFile(file) && convertToPdf) {
|
||||
try {
|
||||
await apiClient.delete(`/api/v1/mobile-scanner/session/${sessionId}`);
|
||||
console.log(
|
||||
"[MobileUploadModal] Session cleaned up after file download",
|
||||
);
|
||||
} catch (cleanupErr) {
|
||||
file = await convertImageToPdf(file, {
|
||||
imageResolution: config?.mobileScannerImageResolution as
|
||||
| "full"
|
||||
| "reduced"
|
||||
| undefined,
|
||||
pageFormat: config?.mobileScannerPageFormat as
|
||||
| "keep"
|
||||
| "A4"
|
||||
| "letter"
|
||||
| undefined,
|
||||
stretchToFit: config?.mobileScannerStretchToFit,
|
||||
});
|
||||
} catch (convertError) {
|
||||
console.warn(
|
||||
"[MobileUploadModal] Failed to cleanup session after download:",
|
||||
cleanupErr,
|
||||
"[MobileUploadModal] Failed to convert image to PDF, using original file:",
|
||||
convertError,
|
||||
);
|
||||
// Continue with original image file if conversion fails
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[MobileUploadModal] Error polling for files:", err);
|
||||
setError(t("mobileUpload.pollingError", "Error checking for files"));
|
||||
}
|
||||
}, [opened, sessionId, onFilesReceived, t]);
|
||||
|
||||
// Create session when modal opens
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
createSession(sessionId);
|
||||
setFilesReceived(0);
|
||||
setError(null);
|
||||
setShowExpiryWarning(false);
|
||||
processedFiles.current.clear();
|
||||
}
|
||||
}, [opened, sessionId]); // Only run when opened changes
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
|
||||
createSession(sessionId);
|
||||
setFilesReceived(0);
|
||||
setError(null);
|
||||
setShowExpiryWarning(false);
|
||||
processedFiles.current.clear();
|
||||
|
||||
return () => {
|
||||
console.log("Cleaning up session on unmount/close:", sessionId);
|
||||
apiClient
|
||||
.delete(`/api/v1/mobile-scanner/session/${sessionId}`)
|
||||
.catch((err) =>
|
||||
console.warn("[MobileUploadModal] Cleanup failed:", err),
|
||||
);
|
||||
};
|
||||
}, [opened, sessionId, createSession]);
|
||||
|
||||
// Start polling for files when modal opens
|
||||
useEffect(() => {
|
||||
if (opened && sessionInfo) {
|
||||
// Poll every 2 seconds
|
||||
pollIntervalRef.current = window.setInterval(pollForFiles, 2000);
|
||||
|
||||
// Initial poll
|
||||
pollForFiles();
|
||||
} else {
|
||||
// Stop polling when modal closes
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [opened, sessionInfo, pollForFiles]);
|
||||
|
||||
// Session timeout timer
|
||||
useEffect(() => {
|
||||
if (!opened || !sessionInfo) return;
|
||||
|
||||
const updateTimer = () => {
|
||||
const now = Date.now();
|
||||
const remaining = sessionInfo.expiresAt - now;
|
||||
|
||||
if (remaining <= 0) {
|
||||
// Session expired - regenerate
|
||||
setShowExpiryWarning(false);
|
||||
regenerateSession();
|
||||
} else if (remaining <= 60000 && !showExpiryWarning) {
|
||||
// Less than 1 minute remaining - show warning
|
||||
setShowExpiryWarning(true);
|
||||
}
|
||||
|
||||
setTimeRemaining(Math.max(0, remaining));
|
||||
};
|
||||
|
||||
// Update immediately
|
||||
updateTimer();
|
||||
|
||||
// Update every second
|
||||
timerIntervalRef.current = window.setInterval(updateTimer, 1000);
|
||||
|
||||
return () => {
|
||||
if (timerIntervalRef.current) {
|
||||
clearInterval(timerIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [opened, sessionInfo, showExpiryWarning, regenerateSession]);
|
||||
onFilesReceived([file]);
|
||||
},
|
||||
[config, convertToPdf, onFilesReceived],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<MobileTransferModal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
routePath="mobile-scanner"
|
||||
onFileReceived={handleFileReceived}
|
||||
qrSize={256}
|
||||
title={t("mobileUpload.title", "Upload from Mobile")}
|
||||
centered
|
||||
size="md"
|
||||
radius="lg"
|
||||
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
|
||||
overlayProps={{ opacity: 0.35, blur: 2 }}
|
||||
styles={{
|
||||
body: {
|
||||
paddingTop: "1.5rem",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<InfoRoundedIcon style={{ fontSize: "1rem" }} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
description={
|
||||
convertToPdf
|
||||
? t(
|
||||
"mobileUpload.description",
|
||||
"Scan this QR code with your mobile device to upload photos. Images will be automatically converted to PDF.",
|
||||
)
|
||||
: t(
|
||||
"mobileUpload.descriptionNoConvert",
|
||||
"Scan this QR code with your mobile device to upload photos.",
|
||||
)
|
||||
}
|
||||
instructions={
|
||||
convertToPdf
|
||||
? t(
|
||||
"mobileUpload.instructions",
|
||||
"Open the camera app on your phone and scan this code. Images will be automatically converted to PDF.",
|
||||
)
|
||||
: t(
|
||||
"mobileUpload.instructionsNoConvert",
|
||||
"Open the camera app on your phone and scan this code. Files will be uploaded through the server.",
|
||||
)
|
||||
}
|
||||
expiryWarningTitle={t(
|
||||
"mobileUpload.expiryWarning",
|
||||
"Session Expiring Soon",
|
||||
)}
|
||||
formatExpiryWarning={(seconds) =>
|
||||
t(
|
||||
"mobileUpload.expiryWarningMessage",
|
||||
"This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.",
|
||||
{ seconds },
|
||||
)
|
||||
}
|
||||
errorTitle={t("mobileUpload.error", "Connection Error")}
|
||||
sessionCreateErrorMessage={t(
|
||||
"mobileUpload.sessionCreateError",
|
||||
"Failed to create session",
|
||||
)}
|
||||
pollingErrorMessage={t(
|
||||
"mobileUpload.pollingError",
|
||||
"Error checking for files",
|
||||
)}
|
||||
renderReceived={(count) => (
|
||||
<Badge
|
||||
variant="filled"
|
||||
color="green"
|
||||
size="lg"
|
||||
leftSection={<CheckRoundedIcon style={{ fontSize: "1rem" }} />}
|
||||
>
|
||||
<Text size="sm">
|
||||
{config?.mobileScannerConvertToPdf !== false
|
||||
? t(
|
||||
"mobileUpload.description",
|
||||
"Scan this QR code with your mobile device to upload photos. Images will be automatically converted to PDF.",
|
||||
)
|
||||
: t(
|
||||
"mobileUpload.descriptionNoConvert",
|
||||
"Scan this QR code with your mobile device to upload photos.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{showExpiryWarning && timeRemaining !== null && (
|
||||
<Alert
|
||||
icon={<WarningRoundedIcon style={{ fontSize: "1rem" }} />}
|
||||
title={t("mobileUpload.expiryWarning", "Session Expiring Soon")}
|
||||
color="orange"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"mobileUpload.expiryWarningMessage",
|
||||
"This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.",
|
||||
{ seconds: Math.ceil(timeRemaining / 1000) },
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
icon={<ErrorRoundedIcon style={{ fontSize: "1rem" }} />}
|
||||
title={t("mobileUpload.error", "Connection Error")}
|
||||
color="red"
|
||||
>
|
||||
<Text size="sm">{error}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
background: "white",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
|
||||
}}
|
||||
>
|
||||
<QRCodeSVG value={mobileUrl} size={256} level="H" includeMargin />
|
||||
</Box>
|
||||
|
||||
{filesReceived > 0 && (
|
||||
<Badge
|
||||
variant="filled"
|
||||
color="green"
|
||||
size="lg"
|
||||
leftSection={<CheckRoundedIcon style={{ fontSize: "1rem" }} />}
|
||||
>
|
||||
{t("mobileUpload.filesReceived", "{{count}} file(s) received", {
|
||||
count: filesReceived,
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Text size="xs" c="dimmed" ta="center" style={{ maxWidth: "300px" }}>
|
||||
{config?.mobileScannerConvertToPdf !== false
|
||||
? t(
|
||||
"mobileUpload.instructions",
|
||||
"Open the camera app on your phone and scan this code. Images will be automatically converted to PDF.",
|
||||
)
|
||||
: t(
|
||||
"mobileUpload.instructionsNoConvert",
|
||||
"Open the camera app on your phone and scan this code. Files will be uploaded through the server.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{
|
||||
wordBreak: "break-all",
|
||||
textAlign: "center",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{mobileUrl}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Modal>
|
||||
{t("mobileUpload.filesReceived", "{{count}} file(s) received", {
|
||||
count,
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Receive-flow contract for the phone-signature QR modal.
|
||||
*
|
||||
* The transfer session's upload endpoint accepts any file from anyone holding
|
||||
* the QR URL, so the modal must treat arrivals as untrusted. Images become
|
||||
* draw/photo payloads by filename prefix, a signature-text JSON payload is
|
||||
* parsed and clamped field by field, and anything else is ignored.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import MobileSignatureModal, {
|
||||
type MobileSignaturePayload,
|
||||
} from "@app/components/tools/sign/MobileSignatureModal";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { expectConsole } from "@app/tests/failOnConsole";
|
||||
|
||||
// Render the English fallbacks (the test i18n instance has no loaded locale).
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: unknown) =>
|
||||
typeof fallback === "string" ? fallback : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@app/services/apiClient", () => ({
|
||||
default: {
|
||||
defaults: { baseURL: "http://localhost:8080" },
|
||||
post: vi.fn(),
|
||||
get: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@app/contexts/AppConfigContext", () => ({
|
||||
useAppConfig: () => ({ config: { enableMobileSignature: true } }),
|
||||
}));
|
||||
|
||||
const mockedApi = vi.mocked(apiClient, true);
|
||||
|
||||
const SESSION_INFO = {
|
||||
sessionId: "s",
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + 600_000,
|
||||
timeoutMs: 600_000,
|
||||
};
|
||||
|
||||
function primeSession(
|
||||
files: Array<{ filename: string; contentType: string; body?: string }>,
|
||||
) {
|
||||
mockedApi.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: SESSION_INFO,
|
||||
} as never);
|
||||
mockedApi.delete.mockResolvedValue({ status: 200 } as never);
|
||||
mockedApi.get.mockImplementation(((url: string, config?: unknown) => {
|
||||
if (url.includes("/files/")) {
|
||||
return Promise.resolve({ status: 200, data: { files } } as never);
|
||||
}
|
||||
if (url.includes("/download/")) {
|
||||
const filename = url.split("/").pop() ?? "";
|
||||
const meta = files.find((f) => f.filename === filename);
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
data: new Blob([meta?.body ?? "fake-bytes"], {
|
||||
type: meta?.contentType,
|
||||
}),
|
||||
config,
|
||||
} as never);
|
||||
}
|
||||
return Promise.reject(new Error(`unexpected GET ${url}`));
|
||||
}) as never);
|
||||
}
|
||||
|
||||
function renderModal(
|
||||
onSignatureReceived: (payload: MobileSignaturePayload) => void,
|
||||
onClose: () => void,
|
||||
) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<MobileSignatureModal
|
||||
opened
|
||||
onClose={onClose}
|
||||
onSignatureReceived={onSignatureReceived}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MobileSignatureModal", () => {
|
||||
// clearAllMocks (not restoreAllMocks): the hook's unmount cleanup still
|
||||
// calls apiClient.delete during test teardown, so implementations must
|
||||
// survive until React Testing Library's auto-cleanup has unmounted.
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("hands a drawn signature to the caller as a draw payload and closes", async () => {
|
||||
primeSession([
|
||||
{ filename: "signature-draw-1.png", contentType: "image/png" },
|
||||
]);
|
||||
const onSignatureReceived = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
|
||||
renderModal(onSignatureReceived, onClose);
|
||||
|
||||
await waitFor(() => expect(onSignatureReceived).toHaveBeenCalledTimes(1));
|
||||
const payload = onSignatureReceived.mock.calls[0][0];
|
||||
expect(payload.kind).toBe("draw");
|
||||
expect(payload.dataUrl).toMatch(/^data:image\/png/);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes a photographed signature as a photo payload", async () => {
|
||||
primeSession([
|
||||
{ filename: "signature-photo-1.jpg", contentType: "image/jpeg" },
|
||||
]);
|
||||
const onSignatureReceived = vi.fn();
|
||||
|
||||
renderModal(onSignatureReceived, vi.fn());
|
||||
|
||||
await waitFor(() => expect(onSignatureReceived).toHaveBeenCalledTimes(1));
|
||||
expect(onSignatureReceived.mock.calls[0][0].kind).toBe("photo");
|
||||
});
|
||||
|
||||
it("parses a typed signature as text, clamping unknown font and colour", async () => {
|
||||
primeSession([
|
||||
{
|
||||
filename: "signature-text-1.json",
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
text: " Reece ",
|
||||
fontFamily: "Wingdings",
|
||||
color: "javascript:alert(1)",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
const onSignatureReceived = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
|
||||
renderModal(onSignatureReceived, onClose);
|
||||
|
||||
await waitFor(() => expect(onSignatureReceived).toHaveBeenCalledTimes(1));
|
||||
expect(onSignatureReceived.mock.calls[0][0]).toEqual({
|
||||
kind: "text",
|
||||
text: "Reece",
|
||||
fontFamily: "Helvetica",
|
||||
color: "#000000",
|
||||
});
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a non-image upload instead of setting it as the signature", async () => {
|
||||
// Rejecting the upload logs a warning - that's the contract under test.
|
||||
expectConsole.warn(/Ignoring non-image upload/);
|
||||
primeSession([{ filename: "evil.html", contentType: "text/html" }]);
|
||||
const onSignatureReceived = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
|
||||
renderModal(onSignatureReceived, onClose);
|
||||
|
||||
// The poll + download cycle must have run before we assert the negative.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
mockedApi.get.mock.calls.some(([url]) =>
|
||||
String(url).includes("/download/"),
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(onSignatureReceived).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import MobileTransferModal from "@app/components/shared/MobileTransferModal";
|
||||
|
||||
/**
|
||||
* What the phone sent, routed to the matching signature source: ink and
|
||||
* photos as pixels, typed signatures as data so they stay editable text.
|
||||
*/
|
||||
export type MobileSignaturePayload =
|
||||
| { kind: "draw"; dataUrl: string }
|
||||
| { kind: "photo"; dataUrl: string }
|
||||
| { kind: "text"; text: string; fontFamily: string; color: string };
|
||||
|
||||
/** Fonts the sign tool's text mode offers; anything else falls back. */
|
||||
const TEXT_FONTS = new Set([
|
||||
"Helvetica",
|
||||
"Times-Roman",
|
||||
"Courier",
|
||||
"Arial",
|
||||
"Georgia",
|
||||
]);
|
||||
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||
const MAX_TEXT_LENGTH = 200;
|
||||
|
||||
interface MobileSignatureModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSignatureReceived: (payload: MobileSignaturePayload) => void;
|
||||
}
|
||||
|
||||
/** FileReader-based (rather than File.text/arrayBuffer, absent in jsdom). */
|
||||
function readFileAsText(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result ?? ""));
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* QR modal for creating a signature on a phone or tablet. The phone opens the
|
||||
* public `/mobile-sign` page; the first valid arrival becomes the signature
|
||||
* and the modal closes.
|
||||
*/
|
||||
export default function MobileSignatureModal({
|
||||
opened,
|
||||
onClose,
|
||||
onSignatureReceived,
|
||||
}: MobileSignatureModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// The session endpoints accept any upload from anyone holding the QR URL,
|
||||
// so nothing here is trusted: images pass as pixels, a typed signature is
|
||||
// parsed and clamped field by field, everything else is ignored.
|
||||
const handleFileReceived = useCallback(
|
||||
async (file: File) => {
|
||||
if (
|
||||
file.type === "application/json" &&
|
||||
file.name.startsWith("signature-text")
|
||||
) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(await readFileAsText(file));
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const text =
|
||||
typeof record?.text === "string"
|
||||
? record.text.trim().slice(0, MAX_TEXT_LENGTH)
|
||||
: "";
|
||||
if (!text) return;
|
||||
onSignatureReceived({
|
||||
kind: "text",
|
||||
text,
|
||||
fontFamily: TEXT_FONTS.has(record.fontFamily as string)
|
||||
? (record.fontFamily as string)
|
||||
: "Helvetica",
|
||||
color: HEX_COLOR.test(record.color as string)
|
||||
? (record.color as string)
|
||||
: "#000000",
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
console.warn(
|
||||
"[MobileSignatureModal] Ignoring malformed text payload",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
console.warn(
|
||||
"[MobileSignatureModal] Ignoring non-image upload:",
|
||||
file.type,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const dataUrl = event.target?.result;
|
||||
if (typeof dataUrl === "string") {
|
||||
onSignatureReceived({
|
||||
kind: file.name.startsWith("signature-photo") ? "photo" : "draw",
|
||||
dataUrl,
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
reader.onerror = () => resolve();
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
},
|
||||
[onSignatureReceived, onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<MobileTransferModal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
routePath="mobile-sign"
|
||||
onFileReceived={handleFileReceived}
|
||||
qrSize={220}
|
||||
title={t("sign.mobile.title", "Draw on your phone")}
|
||||
description={t(
|
||||
"sign.mobile.description",
|
||||
"Scan this QR code with your phone or tablet, draw your signature, and it will appear here automatically.",
|
||||
)}
|
||||
instructions={t(
|
||||
"sign.mobile.instructions",
|
||||
"Open the camera app on your phone and scan this code. Keep this window open while you draw.",
|
||||
)}
|
||||
expiryWarningTitle={t(
|
||||
"sign.mobile.expiryWarning",
|
||||
"QR Code Expiring Soon",
|
||||
)}
|
||||
formatExpiryWarning={(seconds) =>
|
||||
t(
|
||||
"sign.mobile.expiryWarningMessage",
|
||||
"This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.",
|
||||
{ seconds },
|
||||
)
|
||||
}
|
||||
errorTitle={t("sign.mobile.error", "Connection Error")}
|
||||
sessionCreateErrorMessage={t(
|
||||
"sign.mobile.sessionCreateError",
|
||||
"Failed to create session",
|
||||
)}
|
||||
pollingErrorMessage={t(
|
||||
"sign.mobile.pollingError",
|
||||
"Error checking for the signature",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,11 @@ import {
|
||||
AddSignatureResult,
|
||||
} from "@app/hooks/tools/sign/useSavedSignatures";
|
||||
import { SavedSignaturesSection } from "@app/components/tools/sign/SavedSignaturesSection";
|
||||
import MobileSignatureModal, {
|
||||
type MobileSignaturePayload,
|
||||
} from "@app/components/tools/sign/MobileSignatureModal";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { buildSignaturePreview } from "@app/utils/signaturePreview";
|
||||
|
||||
type SignatureDrafts = {
|
||||
@@ -116,6 +121,12 @@ const SignSettings = ({
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [isPlacementManuallyPaused, setPlacementManuallyPaused] =
|
||||
useState(false);
|
||||
const [isMobileSignModalOpen, setIsMobileSignModalOpen] = useState(false);
|
||||
const { config } = useAppConfig();
|
||||
const isMobileViewport = useIsMobile();
|
||||
// Drawing on a phone needs a second device, so the QR entry is desktop-only.
|
||||
const canDrawOnPhone =
|
||||
Boolean(config?.enableMobileSignature) && !isMobileViewport;
|
||||
|
||||
// State for different signature types
|
||||
const [canvasSignatureData, setCanvasSignatureData] = useState<
|
||||
@@ -625,6 +636,71 @@ const SignSettings = ({
|
||||
[onActivateSignaturePlacement],
|
||||
);
|
||||
|
||||
// Route a signature made on a phone to the matching source, mirroring
|
||||
// handleUseSavedSignature: ink is a canvas signature, a photo is an image
|
||||
// signature, and typed text stays editable text rather than baked pixels.
|
||||
const handleMobileSignatureReceived = useCallback(
|
||||
(payload: MobileSignaturePayload) => {
|
||||
// Receiving a signature is as clear an intent to place it as drawing
|
||||
// one, so placement goes live even if it was paused beforehand.
|
||||
setPlacementManuallyPaused(false);
|
||||
lastAppliedPlacementKey.current = null;
|
||||
if (payload.kind === "draw") {
|
||||
if (parameters.signatureType !== "canvas") {
|
||||
onParameterChange("signatureType", "canvas");
|
||||
}
|
||||
handleCanvasSignatureChange(payload.dataUrl);
|
||||
} else if (payload.kind === "photo") {
|
||||
if (parameters.signatureType !== "image") {
|
||||
onParameterChange("signatureType", "image");
|
||||
}
|
||||
setImageSignatureData(payload.dataUrl);
|
||||
} else {
|
||||
if (parameters.signatureType !== "text") {
|
||||
onParameterChange("signatureType", "text");
|
||||
}
|
||||
onParameterChange("signerName", payload.text);
|
||||
onParameterChange("fontFamily", payload.fontFamily);
|
||||
onParameterChange("textColor", payload.color);
|
||||
// Move the draft mirror in the same commit as the parameters. The
|
||||
// record/restore effect pair otherwise sees them one commit apart and
|
||||
// ping-pongs old draft against new params, wiping the received text
|
||||
// and looping until React aborts the update depth.
|
||||
const nextDraft = {
|
||||
signerName: payload.text,
|
||||
fontSize: parameters.fontSize ?? 16,
|
||||
fontFamily: payload.fontFamily,
|
||||
textColor: payload.color,
|
||||
};
|
||||
lastSyncedTextDraft.current = nextDraft;
|
||||
setSignatureDrafts((prev) => ({ ...prev, text: nextDraft }));
|
||||
}
|
||||
// Activate directly for every kind: the canvas-change handler only
|
||||
// activates when the data actually changed, and the auto-activate
|
||||
// effect only reacts to state transitions - neither fires for a
|
||||
// repeat of the same signature. Fired twice because the first shot can
|
||||
// land between the receive commit and the ready-state settling, where
|
||||
// the placement effect immediately deactivates it; the second shot is
|
||||
// after everything has settled, and re-activating is idempotent.
|
||||
if (typeof window !== "undefined") {
|
||||
window.setTimeout(
|
||||
() => onActivateSignaturePlacement?.(),
|
||||
PLACEMENT_ACTIVATION_DELAY,
|
||||
);
|
||||
window.setTimeout(() => onActivateSignaturePlacement?.(), 500);
|
||||
} else {
|
||||
onActivateSignaturePlacement?.();
|
||||
}
|
||||
},
|
||||
[
|
||||
parameters.signatureType,
|
||||
parameters.fontSize,
|
||||
onParameterChange,
|
||||
handleCanvasSignatureChange,
|
||||
onActivateSignaturePlacement,
|
||||
],
|
||||
);
|
||||
|
||||
const hasCanvasSignature = useMemo(
|
||||
() => Boolean(canvasSignatureData),
|
||||
[canvasSignatureData],
|
||||
@@ -980,6 +1056,29 @@ const SignSettings = ({
|
||||
if (signatureSource === "image") {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{imageSignatureData && (
|
||||
<Box
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-default-border)",
|
||||
borderRadius: "var(--mantine-radius-default)",
|
||||
// Signatures are stamped on paper-white pages, so preview on white.
|
||||
background: "white",
|
||||
padding: "0.5rem",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={imageSignatureData}
|
||||
alt={translate("image.previewAlt", "Current image signature")}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: 120,
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<ImageUploader
|
||||
onImageChange={handleImageChange}
|
||||
disabled={disabled}
|
||||
@@ -1161,6 +1260,24 @@ const SignSettings = ({
|
||||
"Choose how you want to create the signature",
|
||||
)}
|
||||
</Text>
|
||||
{canDrawOnPhone && (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
disabled={disabled}
|
||||
leftSection={<LocalIcon icon="qr-code-rounded" width="1rem" />}
|
||||
onClick={() => setIsMobileSignModalOpen(true)}
|
||||
>
|
||||
{t("sign.mobile.createFromPhone", "Mobile upload")}
|
||||
</Button>
|
||||
<MobileSignatureModal
|
||||
opened={isMobileSignModalOpen}
|
||||
onClose={() => setIsMobileSignModalOpen(false)}
|
||||
onSignatureReceived={handleMobileSignatureReceived}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{sourceOptions.length > 1 && (
|
||||
<SegmentedControl
|
||||
value={signatureSource}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import { buildMobileRouteUrl } from "@app/utils/mobileScannerUrl";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
/**
|
||||
* Session lifecycle for phone-to-desktop transfer over the mobile-scanner
|
||||
* backend (`/api/v1/mobile-scanner/*`): one temporary session, a QR-encodable
|
||||
* URL for the phone, polling for uploads, and cleanup.
|
||||
*
|
||||
* Consumers decide what a received file means — the scanner converts images to
|
||||
* PDF, the signature flow treats them as ink — so files are delivered raw, one
|
||||
* at a time, via `onFileReceived`.
|
||||
*/
|
||||
|
||||
// Generate a cryptographically secure UUID v4-like session ID
|
||||
function generateSessionId(): string {
|
||||
// Use Web Crypto API for cryptographically secure random values
|
||||
const cryptoObj =
|
||||
typeof crypto !== "undefined" ? crypto : (window as any).crypto;
|
||||
|
||||
if (cryptoObj && typeof cryptoObj.getRandomValues === "function") {
|
||||
const bytes = new Uint8Array(16);
|
||||
cryptoObj.getRandomValues(bytes);
|
||||
|
||||
// Set version (4) and variant bits per RFC 4122
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10
|
||||
|
||||
// Convert bytes to hex string in UUID format
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"));
|
||||
return [
|
||||
hex.slice(0, 4).join(""),
|
||||
hex.slice(4, 6).join(""),
|
||||
hex.slice(6, 8).join(""),
|
||||
hex.slice(8, 10).join(""),
|
||||
hex.slice(10, 16).join(""),
|
||||
].join("-");
|
||||
}
|
||||
|
||||
// If Web Crypto is not available, fail fast rather than using insecure randomness
|
||||
throw new Error(
|
||||
"Web Crypto API not available. Cannot generate secure session ID.",
|
||||
);
|
||||
}
|
||||
|
||||
export interface MobileTransferSessionInfo {
|
||||
sessionId: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
interface UseMobileTransferSessionParams {
|
||||
/** Session exists and polling runs only while true (modal open). */
|
||||
active: boolean;
|
||||
/** SPA route the phone opens, without slashes: "mobile-scanner", "mobile-sign". */
|
||||
routePath: string;
|
||||
/** Called once per newly uploaded file, in upload order. */
|
||||
onFileReceived: (file: File) => void | Promise<void>;
|
||||
/** Message shown when the backend refuses to create a session. */
|
||||
sessionCreateErrorMessage: string;
|
||||
/** Message shown when polling for uploads fails. */
|
||||
pollingErrorMessage: string;
|
||||
/** Host the phone should reach, when configured (server_url / frontendUrl). */
|
||||
configuredUrl?: string;
|
||||
}
|
||||
|
||||
export function useMobileTransferSession({
|
||||
active,
|
||||
routePath,
|
||||
onFileReceived,
|
||||
sessionCreateErrorMessage,
|
||||
pollingErrorMessage,
|
||||
configuredUrl,
|
||||
}: UseMobileTransferSessionParams) {
|
||||
const [sessionId, setSessionId] = useState(() => generateSessionId());
|
||||
const [sessionInfo, setSessionInfo] =
|
||||
useState<MobileTransferSessionInfo | null>(null);
|
||||
const [filesReceived, setFilesReceived] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [timeRemaining, setTimeRemaining] = useState<number | null>(null);
|
||||
const [showExpiryWarning, setShowExpiryWarning] = useState(false);
|
||||
const pollIntervalRef = useRef<number | null>(null);
|
||||
const timerIntervalRef = useRef<number | null>(null);
|
||||
const processedFiles = useRef<Set<string>>(new Set());
|
||||
|
||||
// The QR-code URL the phone opens. It must land on the public route under
|
||||
// the app's base path, otherwise the phone hits the auth-gated catch-all
|
||||
// route and is bounced to the login page.
|
||||
const mobileUrl = buildMobileRouteUrl({
|
||||
configuredUrl: configuredUrl ?? "",
|
||||
sessionId,
|
||||
origin: window.location.origin,
|
||||
basePath: BASE_PATH,
|
||||
routePath,
|
||||
});
|
||||
|
||||
const createSession = useCallback(
|
||||
async (newSessionId: string) => {
|
||||
try {
|
||||
const response = await apiClient.post<MobileTransferSessionInfo>(
|
||||
`/api/v1/mobile-scanner/create-session/${newSessionId}`,
|
||||
undefined,
|
||||
{ responseType: "json" },
|
||||
);
|
||||
|
||||
if (!response.status || response.status !== 200) {
|
||||
throw new Error("Failed to create session");
|
||||
}
|
||||
|
||||
setSessionInfo(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("[useMobileTransferSession] create failed:", err);
|
||||
setError(sessionCreateErrorMessage);
|
||||
}
|
||||
},
|
||||
[sessionCreateErrorMessage],
|
||||
);
|
||||
|
||||
// Regenerate session (when expired or warned)
|
||||
const regenerateSession = useCallback(() => {
|
||||
const newSessionId = generateSessionId();
|
||||
setSessionId(newSessionId);
|
||||
setShowExpiryWarning(false);
|
||||
setFilesReceived(0);
|
||||
processedFiles.current.clear();
|
||||
createSession(newSessionId);
|
||||
}, [createSession]);
|
||||
|
||||
const pollForFiles = useCallback(async () => {
|
||||
if (!active) return;
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/api/v1/mobile-scanner/files/${sessionId}`,
|
||||
);
|
||||
if (!response.status || response.status !== 200) {
|
||||
throw new Error("Failed to check for files");
|
||||
}
|
||||
|
||||
const files = response.data.files || [];
|
||||
|
||||
// Download only files we haven't processed yet
|
||||
const newFiles = files.filter(
|
||||
(f: any) => !processedFiles.current.has(f.filename),
|
||||
);
|
||||
if (newFiles.length === 0) return;
|
||||
|
||||
for (const fileMetadata of newFiles) {
|
||||
try {
|
||||
const downloadResponse = await apiClient.get(
|
||||
`/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}`,
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
|
||||
if (downloadResponse.status === 200) {
|
||||
const file = new File(
|
||||
[downloadResponse.data],
|
||||
fileMetadata.filename,
|
||||
{ type: fileMetadata.contentType || "image/jpeg" },
|
||||
);
|
||||
processedFiles.current.add(fileMetadata.filename);
|
||||
setFilesReceived((prev) => prev + 1);
|
||||
await onFileReceived(file);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[useMobileTransferSession] download failed:",
|
||||
fileMetadata.filename,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the entire session immediately after downloading, so uploads
|
||||
// sit on the server only for the seconds between polls.
|
||||
try {
|
||||
await apiClient.delete(`/api/v1/mobile-scanner/session/${sessionId}`);
|
||||
} catch (cleanupErr) {
|
||||
console.warn(
|
||||
"[useMobileTransferSession] post-download cleanup failed:",
|
||||
cleanupErr,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[useMobileTransferSession] polling failed:", err);
|
||||
setError(pollingErrorMessage);
|
||||
}
|
||||
}, [active, sessionId, onFileReceived, pollingErrorMessage]);
|
||||
|
||||
// Create the session while active; delete it when deactivated/unmounted.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
createSession(sessionId);
|
||||
setFilesReceived(0);
|
||||
setError(null);
|
||||
setShowExpiryWarning(false);
|
||||
processedFiles.current.clear();
|
||||
|
||||
return () => {
|
||||
apiClient
|
||||
.delete(`/api/v1/mobile-scanner/session/${sessionId}`)
|
||||
.catch((err) =>
|
||||
console.warn("[useMobileTransferSession] cleanup failed:", err),
|
||||
);
|
||||
};
|
||||
}, [active, sessionId, createSession]);
|
||||
|
||||
// Poll for uploads while the session is live
|
||||
useEffect(() => {
|
||||
if (active && sessionInfo) {
|
||||
pollIntervalRef.current = window.setInterval(pollForFiles, 2000);
|
||||
pollForFiles();
|
||||
} else if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [active, sessionInfo, pollForFiles]);
|
||||
|
||||
// Session timeout timer: warn under a minute, regenerate on expiry
|
||||
useEffect(() => {
|
||||
if (!active || !sessionInfo) return;
|
||||
|
||||
const updateTimer = () => {
|
||||
const now = Date.now();
|
||||
const remaining = sessionInfo.expiresAt - now;
|
||||
|
||||
if (remaining <= 0) {
|
||||
setShowExpiryWarning(false);
|
||||
regenerateSession();
|
||||
} else if (remaining <= 60000 && !showExpiryWarning) {
|
||||
setShowExpiryWarning(true);
|
||||
}
|
||||
|
||||
setTimeRemaining(Math.max(0, remaining));
|
||||
};
|
||||
|
||||
updateTimer();
|
||||
timerIntervalRef.current = window.setInterval(updateTimer, 1000);
|
||||
|
||||
return () => {
|
||||
if (timerIntervalRef.current) {
|
||||
clearInterval(timerIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [active, sessionInfo, showExpiryWarning, regenerateSession]);
|
||||
|
||||
return {
|
||||
/** URL to encode in the QR code. */
|
||||
mobileUrl,
|
||||
sessionInfo,
|
||||
/** Count of files received this session (resets on regenerate). */
|
||||
filesReceived,
|
||||
error,
|
||||
/** Milliseconds until the session expires, once known. */
|
||||
timeRemaining,
|
||||
/** True inside the final minute before expiry. */
|
||||
showExpiryWarning,
|
||||
regenerateSession,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Session-state contract for the phone-side signature page: a missing or
|
||||
* expired session shows one clear error instead of a canvas whose Send would
|
||||
* fail; a valid session shows the draw/type/photo tabs.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import MobileSignPage from "@app/pages/MobileSignPage";
|
||||
|
||||
vi.mock("@app/services/apiClient", () => ({
|
||||
default: { defaults: { baseURL: "http://localhost:8080" } },
|
||||
}));
|
||||
|
||||
// Render the English fallbacks the assertions read (the test i18n instance
|
||||
// has no loaded locale, so bare t() would render raw keys).
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: unknown) =>
|
||||
typeof fallback === "string" ? fallback : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Branding components pull theme preferences from providers this page doesn't
|
||||
// need for its session-state contract.
|
||||
vi.mock("@app/components/shared/LogoIcon", () => ({
|
||||
LogoIcon: () => <span data-testid="logo" />,
|
||||
}));
|
||||
vi.mock("@app/components/shared/Wordmark", () => ({
|
||||
Wordmark: () => <span data-testid="wordmark" />,
|
||||
}));
|
||||
|
||||
function renderAt(path: string) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<MobileSignPage />
|
||||
</MemoryRouter>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MobileSignPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
// jsdom has no ResizeObserver; the draw canvas sizes itself with one.
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
// jsdom's canvas has no real 2d context (and logs an error when asked);
|
||||
// the draw canvas tolerates a null context.
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("shows the expired-session error when the URL has no session", async () => {
|
||||
renderAt("/mobile-sign");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Session expired/i)).toBeInTheDocument(),
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the expired-session error when the backend rejects the session", async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ valid: false }),
|
||||
} as Response);
|
||||
|
||||
renderAt("/mobile-sign?session=stale-session");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Session expired/i)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the signature tabs once the session validates", async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ valid: true }),
|
||||
} as Response);
|
||||
|
||||
renderAt("/mobile-sign?session=good-session");
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Draw")).toBeInTheDocument());
|
||||
expect(screen.getByText("Type")).toBeInTheDocument();
|
||||
expect(screen.getByText("Photo")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Send to computer/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,554 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Image,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import { Button as DSButton } from "@app/ui/Button";
|
||||
import { SegmentedControl } from "@app/ui/SegmentedControl";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LogoIcon } from "@app/components/shared/LogoIcon";
|
||||
import { Wordmark } from "@app/components/shared/Wordmark";
|
||||
import CheckCircleRoundedIcon from "@mui/icons-material/CheckCircleRounded";
|
||||
import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded";
|
||||
import UndoRoundedIcon from "@mui/icons-material/UndoRounded";
|
||||
import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
|
||||
import AddPhotoAlternateRoundedIcon from "@mui/icons-material/AddPhotoAlternateRounded";
|
||||
import PhotoCameraRoundedIcon from "@mui/icons-material/PhotoCameraRounded";
|
||||
import SendRoundedIcon from "@mui/icons-material/SendRounded";
|
||||
import {
|
||||
MobileDrawCanvas,
|
||||
type MobileDrawCanvasHandle,
|
||||
} from "@app/components/mobileSign/MobileDrawCanvas";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
// Use the configured API base (e.g. api.stirling.com), not the page origin.
|
||||
const API_BASE = (apiClient.defaults.baseURL ?? "").replace(/\/+$/, "");
|
||||
|
||||
type SignatureTab = "draw" | "type" | "photo";
|
||||
|
||||
// Ink pigments, not UI theme colours: they are baked into the exported PNG
|
||||
// and transferred to the desktop, so they must be fixed literals.
|
||||
const INK_COLORS = [
|
||||
{ value: "#101010", label: "black" }, // theme-allow-color ink pigment, serialized into the signature
|
||||
{ value: "#1d4ed8", label: "blue" }, // theme-allow-color ink pigment, serialized into the signature
|
||||
];
|
||||
|
||||
const PEN_SIZES = [
|
||||
{ value: 2, label: "S" },
|
||||
{ value: 3.5, label: "M" },
|
||||
{ value: 6, label: "L" },
|
||||
];
|
||||
|
||||
/**
|
||||
* The sign tool's own text-mode fonts, so a typed signature transfers as
|
||||
* data and stays editable there. `css` approximates each for the on-phone
|
||||
* preview; `value` is what the desktop's font parameter understands.
|
||||
*/
|
||||
const TYPE_FONTS = [
|
||||
{
|
||||
value: "Helvetica",
|
||||
css: "Helvetica, Arial, sans-serif",
|
||||
label: "Helvetica",
|
||||
},
|
||||
{
|
||||
value: "Times-Roman",
|
||||
css: "'Times New Roman', Times, serif",
|
||||
label: "Times",
|
||||
},
|
||||
{
|
||||
value: "Courier",
|
||||
css: "'Courier New', Courier, monospace",
|
||||
label: "Courier",
|
||||
},
|
||||
{ value: "Arial", css: "Arial, sans-serif", label: "Arial" },
|
||||
{ value: "Georgia", css: "Georgia, serif", label: "Georgia" },
|
||||
];
|
||||
|
||||
async function dataUrlToBlob(dataUrl: string): Promise<Blob> {
|
||||
const response = await fetch(dataUrl);
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
/**
|
||||
* MobileSignPage
|
||||
*
|
||||
* Phone-side page for sending a signature to the desktop: draw one (the main
|
||||
* path), type one, or photograph one. Reached by scanning the QR code shown in
|
||||
* the editor's Sign tool; the session comes from the QR URL and rides the same
|
||||
* transfer backend as the mobile scanner.
|
||||
*/
|
||||
export default function MobileSignPage() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const sessionId = searchParams.get("session");
|
||||
// Landscape phones (not tablets — hence the height cap) get a compact
|
||||
// layout: branding hidden, tighter padding, shorter pad, so the canvas and
|
||||
// the Send button fit on screen together.
|
||||
const compactLandscape =
|
||||
useMediaQuery("(orientation: landscape) and (max-height: 32rem)") ?? false;
|
||||
|
||||
const [sessionValid, setSessionValid] = useState<boolean | null>(null);
|
||||
const [tab, setTab] = useState<SignatureTab>("draw");
|
||||
const [hasInk, setHasInk] = useState(false);
|
||||
const [typedText, setTypedText] = useState("");
|
||||
const [typeFont, setTypeFont] = useState(TYPE_FONTS[0].value);
|
||||
const [inkColor, setInkColor] = useState(INK_COLORS[0].value);
|
||||
const [penSize, setPenSize] = useState(PEN_SIZES[1].value);
|
||||
const [photoDataUrl, setPhotoDataUrl] = useState<string | null>(null);
|
||||
const [photoError, setPhotoError] = useState<string | null>(null);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [sentCount, setSentCount] = useState(0);
|
||||
const [justSent, setJustSent] = useState(false);
|
||||
|
||||
const canvasHandle = useRef<MobileDrawCanvasHandle>(null);
|
||||
const photoInputRef = useRef<HTMLInputElement>(null);
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Validate the session up front, so a stale QR shows one clear error rather
|
||||
// than a canvas whose Send fails.
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setSessionValid(false);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/v1/mobile-scanner/validate-session/${sessionId}`,
|
||||
);
|
||||
const data = response.ok ? await response.json() : null;
|
||||
setSessionValid(Boolean(data?.valid));
|
||||
} catch {
|
||||
setSessionValid(false);
|
||||
}
|
||||
})();
|
||||
}, [sessionId]);
|
||||
|
||||
const handlePhotoSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setPhotoError(
|
||||
t("mobileSign.photo.invalidType", "Please choose an image file."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setPhotoError(null);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) =>
|
||||
setPhotoDataUrl((event.target?.result as string) ?? null);
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
/**
|
||||
* What this tab sends: ink and photos as image files, typed signatures as a
|
||||
* JSON payload (text + font + colour) so the desktop keeps them editable in
|
||||
* the sign tool's text mode. The filename prefix tells the desktop which
|
||||
* source the signature belongs to.
|
||||
*/
|
||||
const buildUpload = useCallback(async (): Promise<{
|
||||
blob: Blob;
|
||||
filename: string;
|
||||
} | null> => {
|
||||
if (tab === "draw") {
|
||||
const dataUrl = canvasHandle.current?.exportPng();
|
||||
if (!dataUrl) return null;
|
||||
return {
|
||||
blob: await dataUrlToBlob(dataUrl),
|
||||
filename: `signature-draw-${Date.now()}.png`,
|
||||
};
|
||||
}
|
||||
if (tab === "type") {
|
||||
const text = typedText.trim();
|
||||
if (!text) return null;
|
||||
const payload = JSON.stringify({
|
||||
text,
|
||||
fontFamily: typeFont,
|
||||
color: inkColor,
|
||||
});
|
||||
return {
|
||||
blob: new Blob([payload], { type: "application/json" }),
|
||||
filename: `signature-text-${Date.now()}.json`,
|
||||
};
|
||||
}
|
||||
if (!photoDataUrl) return null;
|
||||
const blob = await dataUrlToBlob(photoDataUrl);
|
||||
const extension = blob.type === "image/jpeg" ? "jpg" : "png";
|
||||
return {
|
||||
blob,
|
||||
filename: `signature-photo-${Date.now()}.${extension}`,
|
||||
};
|
||||
}, [tab, typedText, typeFont, inkColor, photoDataUrl]);
|
||||
|
||||
const canSend =
|
||||
(tab === "draw" && hasInk) ||
|
||||
(tab === "type" && typedText.trim().length > 0) ||
|
||||
(tab === "photo" && photoDataUrl !== null);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!sessionId) return;
|
||||
|
||||
setIsSending(true);
|
||||
setSendError(null);
|
||||
try {
|
||||
const upload = await buildUpload();
|
||||
if (!upload) return;
|
||||
const formData = new FormData();
|
||||
formData.append("files", upload.blob, upload.filename);
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/v1/mobile-scanner/upload/${sessionId}`,
|
||||
{ method: "POST", body: formData },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
setSentCount((count) => count + 1);
|
||||
setJustSent(true);
|
||||
// Reset the inputs so "send another" starts clean
|
||||
canvasHandle.current?.clear();
|
||||
setTypedText("");
|
||||
setPhotoDataUrl(null);
|
||||
if (photoInputRef.current) photoInputRef.current.value = "";
|
||||
if (cameraInputRef.current) cameraInputRef.current.value = "";
|
||||
} catch (err) {
|
||||
console.error("[MobileSignPage] upload failed:", err);
|
||||
setSendError(
|
||||
t(
|
||||
"mobileSign.sendError",
|
||||
"Could not send the signature. Check the connection and try again.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const header = (
|
||||
<Group justify="center" gap="xs" py="md">
|
||||
<LogoIcon style={{ width: 28, height: 28 }} />
|
||||
<Wordmark style={{ height: 18 }} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
if (sessionValid === null) {
|
||||
return (
|
||||
<Box p="md">
|
||||
{header}
|
||||
<Text ta="center" c="dimmed">
|
||||
{t("mobileSign.validating", "Checking session…")}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!sessionValid) {
|
||||
return (
|
||||
<Box p="md" maw={480} mx="auto">
|
||||
{header}
|
||||
<Alert
|
||||
icon={<ErrorRoundedIcon style={{ fontSize: "1rem" }} />}
|
||||
color="red"
|
||||
title={t("mobileSign.invalidSession", "Session expired")}
|
||||
>
|
||||
{t(
|
||||
"mobileSign.invalidSessionMessage",
|
||||
"This QR code is no longer valid. Open the Sign tool on your computer and scan the new code.",
|
||||
)}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={compactLandscape ? "xs" : "md"}
|
||||
maw={640}
|
||||
mx="auto"
|
||||
style={{ minHeight: "100dvh", display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
{!compactLandscape && header}
|
||||
|
||||
{justSent && (
|
||||
<Alert
|
||||
icon={<CheckCircleRoundedIcon style={{ fontSize: "1rem" }} />}
|
||||
color="green"
|
||||
mb="sm"
|
||||
withCloseButton
|
||||
onClose={() => setJustSent(false)}
|
||||
>
|
||||
{t(
|
||||
"mobileSign.sentMessage",
|
||||
"Signature sent to your computer. You can send another or close this page.",
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
{sendError && (
|
||||
<Alert
|
||||
icon={<ErrorRoundedIcon style={{ fontSize: "1rem" }} />}
|
||||
color="red"
|
||||
mb="sm"
|
||||
>
|
||||
{sendError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box mb="sm">
|
||||
<SegmentedControl<SignatureTab>
|
||||
fullWidth
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
ariaLabel={t("mobileSign.tabsLabel", "Signature source")}
|
||||
options={[
|
||||
// Same order as the sign tool's sources: canvas, image, text
|
||||
{ value: "draw", label: t("mobileSign.tab.draw", "Draw") },
|
||||
{ value: "photo", label: t("mobileSign.tab.photo", "Photo") },
|
||||
{ value: "type", label: t("mobileSign.tab.type", "Type") },
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{tab === "draw" && (
|
||||
<Stack gap="sm" style={{ flex: 1 }}>
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p={0}
|
||||
style={{
|
||||
// A DEFINITE height, not flex/min-height: the canvas inside is
|
||||
// sized `height: 100%`, which resolves to the 150px intrinsic
|
||||
// canvas default when the parent's height is indefinite —
|
||||
// leaving most of the visible pad ignoring input. Landscape
|
||||
// phones get a shorter, wider pad that still fits on screen.
|
||||
height: compactLandscape
|
||||
? "min(50dvh, 14rem)"
|
||||
: "min(48dvh, 26rem)",
|
||||
flexShrink: 0,
|
||||
// Checkerboard-free plain white: signatures are stamped on paper-
|
||||
// white pages, so drawing on white shows what you will get.
|
||||
background: "white",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<MobileDrawCanvas
|
||||
ref={canvasHandle}
|
||||
penColor={inkColor}
|
||||
penSize={penSize}
|
||||
onHasInkChange={setHasInk}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{INK_COLORS.map((color) => (
|
||||
<Box
|
||||
key={color.value}
|
||||
component="button"
|
||||
onClick={() => setInkColor(color.value)}
|
||||
aria-label={color.label}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
background: color.value,
|
||||
cursor: "pointer",
|
||||
border:
|
||||
inkColor === color.value
|
||||
? "3px solid var(--mantine-color-blue-4)"
|
||||
: "3px solid transparent",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={String(penSize)}
|
||||
onChange={(value) => setPenSize(Number(value))}
|
||||
ariaLabel={t("mobileSign.penSizeLabel", "Pen size")}
|
||||
options={PEN_SIZES.map((size) => ({
|
||||
value: String(size.value),
|
||||
label: size.label,
|
||||
}))}
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<DSButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!hasInk}
|
||||
onClick={() => canvasHandle.current?.undo()}
|
||||
leftSection={<UndoRoundedIcon style={{ fontSize: 16 }} />}
|
||||
>
|
||||
{t("mobileSign.undo", "Undo")}
|
||||
</DSButton>
|
||||
<DSButton
|
||||
variant="secondary"
|
||||
accent="danger"
|
||||
size="sm"
|
||||
disabled={!hasInk}
|
||||
onClick={() => canvasHandle.current?.clear()}
|
||||
leftSection={
|
||||
<DeleteOutlineRoundedIcon style={{ fontSize: 16 }} />
|
||||
}
|
||||
>
|
||||
{t("mobileSign.clear", "Clear")}
|
||||
</DSButton>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{tab === "type" && (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
size="lg"
|
||||
value={typedText}
|
||||
onChange={(e) => setTypedText(e.target.value)}
|
||||
placeholder={t("mobileSign.type.placeholder", "Your name")}
|
||||
autoComplete="name"
|
||||
/>
|
||||
<Select
|
||||
value={typeFont}
|
||||
onChange={(value) => value && setTypeFont(value)}
|
||||
data={TYPE_FONTS.map(({ value, label }) => ({ value, label }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
{INK_COLORS.map((color) => (
|
||||
<Box
|
||||
key={color.value}
|
||||
component="button"
|
||||
onClick={() => setInkColor(color.value)}
|
||||
aria-label={color.label}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
background: color.value,
|
||||
cursor: "pointer",
|
||||
border:
|
||||
inkColor === color.value
|
||||
? "3px solid var(--mantine-color-blue-4)"
|
||||
: "3px solid transparent",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
style={{
|
||||
background: "white",
|
||||
minHeight: 120,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily:
|
||||
TYPE_FONTS.find((font) => font.value === typeFont)?.css ??
|
||||
"sans-serif",
|
||||
fontSize: "2.5rem",
|
||||
color: inkColor,
|
||||
lineHeight: 1.2,
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{typedText ||
|
||||
t("mobileSign.type.previewPlaceholder", "Signature preview")}
|
||||
</Text>
|
||||
</Card>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{tab === "photo" && (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"mobileSign.photo.hint",
|
||||
"Photograph a signature on white paper, or choose an existing image.",
|
||||
)}
|
||||
</Text>
|
||||
{photoDataUrl ? (
|
||||
<Card withBorder radius="md" style={{ background: "white" }}>
|
||||
<Image
|
||||
src={photoDataUrl}
|
||||
alt="Signature"
|
||||
fit="contain"
|
||||
mah={220}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
<Group grow>
|
||||
<DSButton
|
||||
variant="secondary"
|
||||
onClick={() => cameraInputRef.current?.click()}
|
||||
leftSection={<PhotoCameraRoundedIcon style={{ fontSize: 18 }} />}
|
||||
>
|
||||
{t("mobileSign.photo.takePhoto", "Take a photo")}
|
||||
</DSButton>
|
||||
<DSButton
|
||||
variant="secondary"
|
||||
onClick={() => photoInputRef.current?.click()}
|
||||
leftSection={
|
||||
<AddPhotoAlternateRoundedIcon style={{ fontSize: 18 }} />
|
||||
}
|
||||
>
|
||||
{t("mobileSign.photo.fromGallery", "From gallery")}
|
||||
</DSButton>
|
||||
</Group>
|
||||
{photoError && (
|
||||
<Text size="sm" c="red">
|
||||
{photoError}
|
||||
</Text>
|
||||
)}
|
||||
{/* Two inputs, not one: `capture` makes the OS open the camera
|
||||
directly, but an input carrying it never offers the gallery,
|
||||
so each source needs its own. */}
|
||||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
style={{ display: "none" }}
|
||||
onChange={handlePhotoSelect}
|
||||
/>
|
||||
<input
|
||||
ref={photoInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handlePhotoSelect}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Box mt="md">
|
||||
<DSButton
|
||||
fullWidth
|
||||
size="lg"
|
||||
disabled={!canSend}
|
||||
loading={isSending}
|
||||
onClick={handleSend}
|
||||
leftSection={<SendRoundedIcon style={{ fontSize: 18 }} />}
|
||||
>
|
||||
{sentCount > 0
|
||||
? t("mobileSign.sendAnother", "Send another signature")
|
||||
: t("mobileSign.send", "Send to computer")}
|
||||
</DSButton>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export interface AppConfig {
|
||||
serverCertificateEnabled?: boolean;
|
||||
hardwareSigningAvailable?: boolean;
|
||||
enableMobileScanner?: boolean;
|
||||
enableMobileSignature?: boolean;
|
||||
mobileScannerConvertToPdf?: boolean;
|
||||
mobileScannerImageResolution?: string;
|
||||
mobileScannerPageFormat?: string;
|
||||
|
||||
@@ -9,10 +9,51 @@
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { buildMobileScannerUrl } from "@app/utils/mobileScannerUrl";
|
||||
import {
|
||||
buildMobileRouteUrl,
|
||||
buildMobileScannerUrl,
|
||||
} from "@app/utils/mobileScannerUrl";
|
||||
|
||||
const sessionId = "abc-123";
|
||||
|
||||
describe("buildMobileRouteUrl", () => {
|
||||
test("routes other mobile pages (mobile-sign) with the base path", () => {
|
||||
expect(
|
||||
buildMobileRouteUrl({
|
||||
configuredUrl: "https://app.stirlingpdf.com",
|
||||
sessionId,
|
||||
origin: "https://app.stirlingpdf.com",
|
||||
basePath: "/app",
|
||||
routePath: "mobile-sign",
|
||||
}),
|
||||
).toBe("https://app.stirlingpdf.com/app/mobile-sign?session=abc-123");
|
||||
});
|
||||
|
||||
test("configured URL with subpath keeps the route un-doubled", () => {
|
||||
expect(
|
||||
buildMobileRouteUrl({
|
||||
configuredUrl: "https://host.example/app/",
|
||||
sessionId,
|
||||
origin: "https://elsewhere.example",
|
||||
basePath: "/app",
|
||||
routePath: "mobile-sign",
|
||||
}),
|
||||
).toBe("https://host.example/app/mobile-sign?session=abc-123");
|
||||
});
|
||||
|
||||
test("no configured URL falls back to origin + base path", () => {
|
||||
expect(
|
||||
buildMobileRouteUrl({
|
||||
configuredUrl: "",
|
||||
sessionId,
|
||||
origin: "http://192.168.1.20:8080",
|
||||
basePath: "",
|
||||
routePath: "mobile-sign",
|
||||
}),
|
||||
).toBe("http://192.168.1.20:8080/mobile-sign?session=abc-123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMobileScannerUrl", () => {
|
||||
test("origin-only frontendUrl keeps the app base path (SaaS web regression)", () => {
|
||||
expect(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Build the URL a phone opens (via the QR code) to reach the SPA's
|
||||
* `/mobile-scanner` route.
|
||||
* Build the URL a phone opens (via a QR code) to reach one of the SPA's
|
||||
* public mobile routes (`/mobile-scanner`, `/mobile-sign`).
|
||||
*
|
||||
* That route is a public, top-level route. It lives under the app's base path,
|
||||
* which is the router's `basename`. If the generated URL omits the base path,
|
||||
* the phone loads a path the router can't match, falls through to the
|
||||
* These routes are public, top-level routes. They live under the app's base
|
||||
* path, which is the router's `basename`. If the generated URL omits the base
|
||||
* path, the phone loads a path the router can't match, falls through to the
|
||||
* auth-gated catch-all route, and gets bounced to the login page. So the base
|
||||
* path must always be present.
|
||||
*
|
||||
@@ -18,15 +18,17 @@
|
||||
*
|
||||
* With no usable configured URL, fall back to the current origin + base path.
|
||||
*/
|
||||
export function buildMobileScannerUrl(params: {
|
||||
export function buildMobileRouteUrl(params: {
|
||||
configuredUrl: string;
|
||||
sessionId: string;
|
||||
origin: string;
|
||||
basePath: string;
|
||||
/** Route under the SPA base, without slashes: "mobile-scanner", "mobile-sign". */
|
||||
routePath: string;
|
||||
}): string {
|
||||
const { configuredUrl, sessionId, origin, basePath } = params;
|
||||
const { configuredUrl, sessionId, origin, basePath, routePath } = params;
|
||||
const query = `?session=${sessionId}`;
|
||||
const route = `${basePath}/mobile-scanner`;
|
||||
const route = `${basePath}/${routePath}`;
|
||||
|
||||
const trimmed = configuredUrl.trim();
|
||||
if (trimmed) {
|
||||
@@ -35,7 +37,7 @@ export function buildMobileScannerUrl(params: {
|
||||
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
||||
const subpath = parsed.pathname.replace(/\/+$/, "");
|
||||
return subpath
|
||||
? `${parsed.origin}${subpath}/mobile-scanner${query}`
|
||||
? `${parsed.origin}${subpath}/${routePath}${query}`
|
||||
: `${parsed.origin}${route}${query}`;
|
||||
}
|
||||
} catch {
|
||||
@@ -45,3 +47,13 @@ export function buildMobileScannerUrl(params: {
|
||||
|
||||
return `${origin}${route}${query}`;
|
||||
}
|
||||
|
||||
/** The `/mobile-scanner` QR URL. See {@link buildMobileRouteUrl}. */
|
||||
export function buildMobileScannerUrl(params: {
|
||||
configuredUrl: string;
|
||||
sessionId: string;
|
||||
origin: string;
|
||||
basePath: string;
|
||||
}): string {
|
||||
return buildMobileRouteUrl({ ...params, routePath: "mobile-scanner" });
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import Onboarding from "@app/components/onboarding/Onboarding";
|
||||
import WatchedFoldersRegistration from "@app/components/watchedFolders/WatchedFoldersRegistration";
|
||||
|
||||
const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage"));
|
||||
const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage"));
|
||||
import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags";
|
||||
import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions";
|
||||
import { LoginLandingRedirect } from "@app/components/LoginLandingRedirect";
|
||||
@@ -59,6 +60,16 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Mobile signature drawing - reached from the Sign tool QR code */}
|
||||
<Route
|
||||
path="/mobile-sign"
|
||||
element={
|
||||
<PublicRouteProviders>
|
||||
<MobileSignPage />
|
||||
</PublicRouteProviders>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Participant signing — public, token-gated, no auth required */}
|
||||
<Route
|
||||
path="/workflow/sign/:token"
|
||||
|
||||
@@ -23,6 +23,7 @@ import UsageLimitModalHost from "@app/components/UsageLimitModalHost";
|
||||
import { LoginLandingRedirect } from "@app/components/LoginLandingRedirect";
|
||||
|
||||
const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage"));
|
||||
const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage"));
|
||||
|
||||
// Import global styles
|
||||
import "@app/styles/tailwind.css";
|
||||
@@ -83,6 +84,16 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Mobile signature drawing - reached from the Sign tool QR code */}
|
||||
<Route
|
||||
path="/mobile-sign"
|
||||
element={
|
||||
<PublicRouteProviders>
|
||||
<MobileSignPage />
|
||||
</PublicRouteProviders>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Admin-only route-set (the portal): its own top-level shell, mounted
|
||||
before the catch-all. */}
|
||||
{getAdminRouteExtensions()}
|
||||
|
||||
@@ -34,6 +34,11 @@ import {
|
||||
AddSignatureResult,
|
||||
} from "@app/hooks/tools/sign/useSavedSignatures";
|
||||
import { SavedSignaturesSection } from "@app/components/tools/sign/SavedSignaturesSection";
|
||||
import MobileSignatureModal, {
|
||||
type MobileSignaturePayload,
|
||||
} from "@app/components/tools/sign/MobileSignatureModal";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { buildSignaturePreview } from "@app/utils/signaturePreview";
|
||||
|
||||
type SignatureDrafts = {
|
||||
@@ -116,6 +121,12 @@ const SignSettings = ({
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [isPlacementManuallyPaused, setPlacementManuallyPaused] =
|
||||
useState(false);
|
||||
const [isMobileSignModalOpen, setIsMobileSignModalOpen] = useState(false);
|
||||
const { config } = useAppConfig();
|
||||
const isMobileViewport = useIsMobile();
|
||||
// Drawing on a phone needs a second device, so the QR entry is desktop-only.
|
||||
const canDrawOnPhone =
|
||||
Boolean(config?.enableMobileSignature) && !isMobileViewport;
|
||||
|
||||
// State for different signature types
|
||||
const [canvasSignatureData, setCanvasSignatureData] = useState<
|
||||
@@ -665,6 +676,71 @@ const SignSettings = ({
|
||||
[onActivateSignaturePlacement],
|
||||
);
|
||||
|
||||
// Route a signature made on a phone to the matching source, mirroring
|
||||
// handleUseSavedSignature: ink is a canvas signature, a photo is an image
|
||||
// signature, and typed text stays editable text rather than baked pixels.
|
||||
const handleMobileSignatureReceived = useCallback(
|
||||
(payload: MobileSignaturePayload) => {
|
||||
// Receiving a signature is as clear an intent to place it as drawing
|
||||
// one, so placement goes live even if it was paused beforehand.
|
||||
setPlacementManuallyPaused(false);
|
||||
lastAppliedPlacementKey.current = null;
|
||||
if (payload.kind === "draw") {
|
||||
if (parameters.signatureType !== "canvas") {
|
||||
onParameterChange("signatureType", "canvas");
|
||||
}
|
||||
handleCanvasSignatureChange(payload.dataUrl);
|
||||
} else if (payload.kind === "photo") {
|
||||
if (parameters.signatureType !== "image") {
|
||||
onParameterChange("signatureType", "image");
|
||||
}
|
||||
setImageSignatureData(payload.dataUrl);
|
||||
} else {
|
||||
if (parameters.signatureType !== "text") {
|
||||
onParameterChange("signatureType", "text");
|
||||
}
|
||||
onParameterChange("signerName", payload.text);
|
||||
onParameterChange("fontFamily", payload.fontFamily);
|
||||
onParameterChange("textColor", payload.color);
|
||||
// Move the draft mirror in the same commit as the parameters. The
|
||||
// record/restore effect pair otherwise sees them one commit apart and
|
||||
// ping-pongs old draft against new params, wiping the received text
|
||||
// and looping until React aborts the update depth.
|
||||
const nextDraft = {
|
||||
signerName: payload.text,
|
||||
fontSize: parameters.fontSize ?? 16,
|
||||
fontFamily: payload.fontFamily,
|
||||
textColor: payload.color,
|
||||
};
|
||||
lastSyncedTextDraft.current = nextDraft;
|
||||
setSignatureDrafts((prev) => ({ ...prev, text: nextDraft }));
|
||||
}
|
||||
// Activate directly for every kind: the canvas-change handler only
|
||||
// activates when the data actually changed, and the auto-activate
|
||||
// effect only reacts to state transitions - neither fires for a
|
||||
// repeat of the same signature. Fired twice because the first shot can
|
||||
// land between the receive commit and the ready-state settling, where
|
||||
// the placement effect immediately deactivates it; the second shot is
|
||||
// after everything has settled, and re-activating is idempotent.
|
||||
if (typeof window !== "undefined") {
|
||||
window.setTimeout(
|
||||
() => onActivateSignaturePlacement?.(),
|
||||
PLACEMENT_ACTIVATION_DELAY,
|
||||
);
|
||||
window.setTimeout(() => onActivateSignaturePlacement?.(), 500);
|
||||
} else {
|
||||
onActivateSignaturePlacement?.();
|
||||
}
|
||||
},
|
||||
[
|
||||
parameters.signatureType,
|
||||
parameters.fontSize,
|
||||
onParameterChange,
|
||||
handleCanvasSignatureChange,
|
||||
onActivateSignaturePlacement,
|
||||
],
|
||||
);
|
||||
|
||||
const hasCanvasSignature = useMemo(
|
||||
() => Boolean(canvasSignatureData),
|
||||
[canvasSignatureData],
|
||||
@@ -1019,6 +1095,29 @@ const SignSettings = ({
|
||||
if (signatureSource === "image") {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{imageSignatureData && (
|
||||
<Box
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-default-border)",
|
||||
borderRadius: "var(--mantine-radius-default)",
|
||||
// Signatures are stamped on paper-white pages, so preview on white.
|
||||
background: "white",
|
||||
padding: "0.5rem",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={imageSignatureData}
|
||||
alt={translate("image.previewAlt", "Current image signature")}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: 120,
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<ImageUploader
|
||||
onImageChange={handleImageChange}
|
||||
disabled={disabled}
|
||||
@@ -1193,6 +1292,24 @@ const SignSettings = ({
|
||||
"Choose how you want to create the signature",
|
||||
)}
|
||||
</Text>
|
||||
{canDrawOnPhone && (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
disabled={disabled}
|
||||
leftSection={<LocalIcon icon="qr-code-rounded" width="1rem" />}
|
||||
onClick={() => setIsMobileSignModalOpen(true)}
|
||||
>
|
||||
{t("sign.mobile.createFromPhone", "Mobile upload")}
|
||||
</Button>
|
||||
<MobileSignatureModal
|
||||
opened={isMobileSignModalOpen}
|
||||
onClose={() => setIsMobileSignModalOpen(false)}
|
||||
onSignatureReceived={handleMobileSignatureReceived}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{sourceOptions.length > 1 && (
|
||||
<SegmentedControl
|
||||
value={signatureSource}
|
||||
|
||||
Reference in New Issue
Block a user