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 |
|---|---|---|
| ![QR
modal](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-7335-assets/shot-1-qr-modal.png)
| ![Phone draw
tab](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-7335-assets/shot-2-phone-draw.png)
| ![Signature
received](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-7335-assets/shot-3-desktop-received.png)
|

## 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:
Reece Browne
2026-08-11 16:36:51 +00:00
committed by GitHub
parent 35ac74b049
commit 34819ae502
27 changed files with 2822 additions and 422 deletions
@@ -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"
+11
View File
@@ -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" });
}
+11
View File
@@ -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"
+11
View File
@@ -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}