@@ -208,11 +264,30 @@ export function EsignBatchPage({ items, more, me }: { items: Item[]; more: numbe
? `Check where you sign in each document, moving the blue boxes if needed. ${pluralize(unchecked.length, "document")} left to check.`
: included.length === 1
? "Choose Sign with CIE at the top. You need the card, its PIN and the NFC reader."
- : `All ${included.length} documents checked. Choose Sign at the top: one PIN signs them all. You need the card and the NFC reader.`}
+ : `All ${included.length} documents checked. Choose Sign at the top: one PIN signs them all. You need the card, its PIN and the NFC reader.`}
+
+ First time signing on this computer?{" "}
+ {/* A new tab, because the review progress and moved boxes live only in this page. */}
+
+ Set it up first
+
+ .
+
+ {signer && signer.signaturesDone === 0 && (
+
+
+
+ )}
{active.signingNow && active.signingNow.sub !== me && (
- {active.signingNow.name} is signing this document right now. Leave it out or try again later.
+ {active.signingNow.name} is signing this document right now. Leave it out, or wait: it's ready as
+ soon as they finish.
)}
@@ -227,15 +302,23 @@ export function EsignBatchPage({ items, more, me }: { items: Item[]; more: numbe
function DocumentViewer({
item,
me,
+ loadPdf,
places,
onMove,
+ placing,
+ onPlace,
+ onCancelPlacing,
}: {
item: Item
me: string
+ loadPdf: (documentId: string) => Promise
places: Place[]
onMove: (places: Place[]) => void
+ /** The place waiting for a click on its new spot. */
+ placing: number | null
+ onPlace: (page: number, x: number, y: number) => void
+ onCancelPlacing: () => void
}) {
- const contentFn = useServerFn(getEsignDocumentContent)
const [base64, setBase64] = useState(null)
const [error, setError] = useState(null)
const bytes = usePdfBytes(base64)
@@ -244,9 +327,9 @@ function DocumentViewer({
useEffect(() => {
if (!item.currentDocumentId) return
let cancelled = false
- contentFn({ data: { documentId: item.currentDocumentId } })
- .then((result) => {
- if (!cancelled) setBase64(result.base64)
+ loadPdf(item.currentDocumentId)
+ .then((pdf) => {
+ if (!cancelled) setBase64(pdf)
})
.catch((cause) => {
console.error(cause)
@@ -255,16 +338,24 @@ function DocumentViewer({
return () => {
cancelled = true
}
- }, [item.currentDocumentId, contentFn])
+ }, [item.currentDocumentId, loadPdf])
const mine = request.signers.find((s) => s.sub === me && s.status === "pending")
- const first = mine ? placesLeft(places, mine.signaturesDone)[0] : undefined
+ // Once per document (it remounts for each): following the box as it moves would scroll it away from the pointer.
+ const [focus] = useState(() => {
+ const first = mine ? placesLeft(places, mine.signaturesDone)[0] : undefined
+ return first ? { page: first.page, y: first.y + first.height / 2 } : null
+ })
if (error) return {error}
return (
}
overlay={(page) =>
request.signers.flatMap((signer, index) => {
if (signer.status !== "pending") return []
diff --git a/src/features/esign/drive-browser.tsx b/src/features/esign/drive-browser.tsx
index c06ac8c..144e59d 100644
--- a/src/features/esign/drive-browser.tsx
+++ b/src/features/esign/drive-browser.tsx
@@ -1,6 +1,6 @@
import { useServerFn } from "@tanstack/react-start"
import { ArrowUp, FileText, Folder, LoaderCircle } from "lucide-react"
-import { useEffect, useState } from "react"
+import { useEffect, useRef, useState } from "react"
import { IconButton, InlineAlert } from "@/components/primitives"
import { floatingMotion, raisedSurface } from "@/components/primitives/motion"
@@ -24,6 +24,8 @@ type DriveBrowserProps = {
drives: EsignDrive[]
/** The drive opened first, the first one by default. */
initialDriveId?: string
+ /** The folder opened first in the initial drive, its top folder by default; ignored when initialDriveId is not one of drives. */
+ initialFolderId?: string
/** A PDF, with the folder it is in. */
onPickFile?: (item: EsignDriveItem, folder: DriveFolder) => void
onPickFolder?: (folder: DriveFolder) => void
@@ -44,14 +46,18 @@ export function DriveBrowser({
label,
drives,
initialDriveId,
+ initialFolderId,
onPickFile,
onPickFolder,
busyId,
className,
}: DriveBrowserProps) {
const listFn = useServerFn(listEsignDrive)
- const [driveId, setDriveId] = useState(drives.some((d) => d.id === initialDriveId) ? initialDriveId : drives[0]?.id)
- const [folderId, setFolderId] = useState(undefined)
+ const initialDrive = drives.some((d) => d.id === initialDriveId)
+ const [driveId, setDriveId] = useState(initialDrive ? initialDriveId : drives[0]?.id)
+ const [folderId, setFolderId] = useState(initialDrive ? initialFolderId : undefined)
+ /** The initial folder until a listing loads: if it was moved or deleted, the drive's top folder opens instead. */
+ const initialFolder = useRef(folderId)
const drive = drives.find((d) => d.id === driveId)
const [listing, setListing] = useState(null)
const [error, setError] = useState(null)
@@ -64,11 +70,17 @@ export function DriveBrowser({
setError(null)
listFn({ data: { driveId, folderId } })
.then((result) => {
- if (!cancelled) setListing(result)
+ if (cancelled) return
+ initialFolder.current = undefined
+ setListing(result)
})
.catch((cause: unknown) => {
console.error(cause)
- if (!cancelled) setError(cause instanceof Error ? cause.message : "Couldn't open the folder.")
+ if (cancelled) return
+ if (folderId !== undefined && folderId === initialFolder.current) {
+ initialFolder.current = undefined
+ setFolderId(undefined)
+ } else setError(cause instanceof Error ? cause.message : "Couldn't open the folder.")
})
.finally(() => {
if (!cancelled) setLoading(false)
diff --git a/src/features/esign/new-request-page.tsx b/src/features/esign/new-request-page.tsx
index 0832b55..5c64f8d 100644
--- a/src/features/esign/new-request-page.tsx
+++ b/src/features/esign/new-request-page.tsx
@@ -1,9 +1,11 @@
-import { useNavigate, useRouter } from "@tanstack/react-router"
+import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox"
+import { useBlocker, useNavigate, useRouter } from "@tanstack/react-router"
import { useServerFn } from "@tanstack/react-start"
import {
ArrowDown,
ArrowUp,
ChevronDown,
+ CircleAlert,
Cloud,
FileUp,
MapPin,
@@ -15,16 +17,26 @@ import {
TriangleAlert,
UserPlus,
} from "lucide-react"
-import { type DragEvent, useEffect, useId, useMemo, useRef, useState } from "react"
+import { type DragEvent, type ReactNode, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"
import {
+ Autocomplete,
checkboxControl,
+ Combobox,
+ ComboboxContent,
+ ComboboxInput,
+ ComboboxItem,
+ ComboboxList,
+ ConfirmDialog,
FormField,
fieldControl,
+ fieldHintId,
+ Hint,
IconButton,
InlineAlert,
LoadingButton,
SectionCard,
+ useFocusAfterRemoval,
} from "@/components/primitives"
import {
Menu,
@@ -50,6 +62,7 @@ import { DriveBrowser, type DriveFolder } from "./drive-browser"
import { getEsignDocumentContent } from "./esign.functions"
import { extractTextAnchors, type PageSize, PdfViewer, sizeOnPage, type TextAnchor, usePdfBytes } from "./pdf-viewer"
import { kindsLabel, type Place, pagesLabel, withSignature } from "./places"
+import { PlacingCancel } from "./placing"
import { YouChip } from "./request-status"
import { createEsignRequest, importEsignDriveDocument, uploadEsignDocument } from "./requests.functions"
import { SignWithCie } from "./sign-with-cie"
@@ -57,8 +70,9 @@ import { type BoxPlacement, SignatureBox } from "./signature-box"
import { useFillHeight } from "./use-fill-height"
type Staged = { id: string; fileName: string; pdf: EsignPdfInfo; folder: DriveFolder | null }
-/** `manual`: the user moved the first box, so a role no longer places it. */
-type SignerDraft = { sub: string; name: string; role: string; places: Place[]; manual: boolean }
+/** `byRole`: the box the signer's role places under its label, until the user moves it. */
+type DraftPlace = Place & { byRole?: true }
+type SignerDraft = { sub: string; name: string; role: string; places: DraftPlace[] }
/** Where the next click on a page puts a box: a signer's existing place, or a new one when `place` is null. */
type Placing = { signer: number; place: number | null }
@@ -69,6 +83,8 @@ const ROLES = ["Presidente", "Vicepresidente", "Segretario", "Tesoriere", "Consi
/** A signature box of 190 × 62 pt, the size of a typical handwritten-signature space. */
const BOX_PT = { width: 190, height: 62 }
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+/** The folder the last PDF was picked from, `{ driveId, id }`, where the drive browser opens next time. */
+const LAST_FOLDER_KEY = "pn-admin:esign-last-folder"
/** Default places: the last page's bottom, two per row, from the bottom-right corner. */
function defaultPlacement(index: number, pages: PageSize[]): BoxPlacement {
@@ -101,13 +117,38 @@ function placementForRole(role: string, anchors: TextAnchor[], pages: PageSize[]
return { page: anchor.page, x, y, ...size }
}
+/** A folder's name and where it is: "2026" in "OneDrive / Segreteria / Verbali"; a drive's top folder by its drive. */
+function folderLines(folder: DriveFolder, drive: string) {
+ const segments = folder.path.split("/").filter(Boolean)
+ const name = segments.pop()
+ return {
+ name: name ?? drive,
+ place: name === undefined ? "Top folder" : [drive, ...segments].join(" / "),
+ full: `${drive}: /${folder.path}`,
+ }
+}
+
function baseName(fileName: string) {
return fileName.replace(/\.pdf$/i, "")
}
+/**
+ * Moves the signer's role box under the label naming their role. The same signer when there is nothing to move: no
+ * role box, a role the document doesn't name (the box stays where it is), or a box already there.
+ */
+function placeByRole(signer: SignerDraft, anchors: TextAnchor[], pages: PageSize[]): SignerDraft {
+ const at = signer.places.findIndex((p) => p.byRole)
+ const box = signer.places[at]
+ const placed = box ? placementForRole(signer.role, anchors, pages) : null
+ if (!box || !placed) return signer
+ const keys = ["page", "x", "y", "width", "height"] as const
+ if (keys.every((key) => placed[key] === box[key])) return signer
+ return { ...signer, places: signer.places.map((p, i) => (i === at ? { ...placed, kind: p.kind, byRole: true } : p)) }
+}
+
function draftOf(person: EsignProfile, index: number, pages: PageSize[]): SignerDraft {
const where = defaultPlacement(index, pages)
- return { sub: person.sub, name: person.name, role: "", places: [{ ...where, kind: "signature" }], manual: false }
+ return { sub: person.sub, name: person.name, role: "", places: [{ ...where, kind: "signature", byRole: true }] }
}
export function NewEsignRequestPage({
@@ -129,7 +170,16 @@ export function NewEsignRequestPage({
const contentFn = useServerFn(getEsignDocumentContent)
const createFn = useServerFn(createEsignRequest)
const dropFill = useFillHeight()
- const ids = { title: useId(), file: useId(), emails: useId(), message: useId(), search: useId(), roles: useId() }
+ const ids = {
+ title: useId(),
+ file: useId(),
+ fileName: useId(),
+ folderError: useId(),
+ emails: useId(),
+ message: useId(),
+ search: useId(),
+ signersError: useId(),
+ }
const [source, setSource] = useState<"drive" | "upload">(config.drives.length > 0 ? "drive" : "upload")
const [staged, setStaged] = useState(null)
@@ -165,30 +215,74 @@ export function NewEsignRequestPage({
const [search, setSearch] = useState("")
const [title, setTitle] = useState("")
+ const [titleTouched, setTitleTouched] = useState(false)
const [saveToDrive, setSaveToDrive] = useState(config.drives.length > 0)
const [folder, setFolder] = useState(null)
const [choosingFolder, setChoosingFolder] = useState(false)
+ /** Undefined until local storage is read after mount, so the server and the first client render agree. */
+ const [lastFolder, setLastFolder] = useState | null>()
+ useEffect(() => {
+ let stored: Pick | null = null
+ try {
+ stored = JSON.parse(window.localStorage.getItem(LAST_FOLDER_KEY) ?? "null")
+ } catch (error) {
+ console.error(error)
+ }
+ setLastFolder(stored)
+ }, [])
const [fileName, setFileName] = useState("")
+ const [fileNameTouched, setFileNameTouched] = useState(false)
+ const [folderTouched, setFolderTouched] = useState(false)
const [emails, setEmails] = useState("")
const [message, setMessage] = useState("")
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState(null)
const [createdRequest, setCreatedRequest] = useState(null)
+ /** A submit was blocked: problems fixed with a click (document, signers, folder) show from then on. */
const [attempted, setAttempted] = useState(false)
+ /** Text-field errors on screen: set by a blocked submit, then re-checked when the field loses focus. */
+ const [shown, setShown] = useState<{ title?: string; fileName?: string; email?: string }>({})
+ /** Each blocked submit focuses the first problem once its message has rendered. */
+ const [blockedSubmits, setBlockedSubmits] = useState(0)
+ const form = useRef(null)
+ useEffect(() => {
+ if (blockedSubmits > 0) form.current?.querySelector('[aria-invalid="true"], [data-invalid]')?.focus()
+ }, [blockedSubmits])
+ const submitAlert = useRef(null)
+ useEffect(() => {
+ if (submitError) submitAlert.current?.scrollIntoView({ block: "nearest" })
+ }, [submitError])
const pages = staged?.pdf.pages ?? []
// One drive goes by its own name; several are browsed together under "OneDrive".
const drivesName = config.drives.length === 1 ? config.drives[0].label : "OneDrive"
const driveLabel = (id: string) => config.drives.find((d) => d.id === id)?.label ?? "OneDrive"
+ const output = folder && folderLines(folder, driveLabel(folder.driveId))
+
+ // The labels of the document on screen place the role boxes, including roles typed before they were read.
+ useEffect(() => {
+ if (!staged || anchors.length === 0) return
+ setSigners((current) => {
+ const next = current.map((s) => placeByRole(s, anchors, staged.pdf.pages))
+ return next.some((s, i) => s !== current[i]) ? next : current
+ })
+ }, [anchors, staged])
function adopt(next: Staged, content: string) {
setStaged(next)
setBase64(content)
- setTitle(baseName(next.fileName))
- setFileName(`${baseName(next.fileName)} - firmato.pdf`)
- if (next.folder) setFolder(next.folder)
+ // The same PDF picked again keeps its labels: its bytes aren't read again.
+ if (content !== base64) setAnchors([])
+ const name = baseName(next.fileName)
+ if (!titleTouched || title.trim() === "") setTitle(name)
+ if (!fileNameTouched || fileName.trim() === "") setFileName(`${name} - firmato.pdf`)
+ if (next.folder && (!folderTouched || !folder)) setFolder(next.folder)
+ setShown(({ email }) => ({ email }))
setSigners((current) =>
- current.map((s, i) => ({ ...s, places: [{ ...defaultPlacement(i, next.pdf.pages), kind: "signature" }] }))
+ current.map((s, i) => ({
+ ...s,
+ places: [{ ...defaultPlacement(i, next.pdf.pages), kind: "signature", byRole: true }],
+ }))
)
}
@@ -223,6 +317,14 @@ export function NewEsignRequestPage({
{ id: result.document.id, fileName: result.document.fileName, pdf: result.pdf, folder: from },
content.base64
)
+ const last = { driveId: from.driveId, id: from.id }
+ setLastFolder(last)
+ try {
+ window.localStorage.setItem(LAST_FOLDER_KEY, JSON.stringify(last))
+ } catch (error) {
+ // Remembering the folder is best-effort: the document is already staged.
+ console.error(error)
+ }
} catch (error) {
console.error(error)
setDocError(error instanceof Error ? error.message : "Couldn't open the PDF.")
@@ -231,18 +333,57 @@ export function NewEsignRequestPage({
}
}
+ const signerList = useFocusAfterRemoval("[data-signer-row]")
+ const searchRef = useRef(null)
+ const roleInputs = useRef(new Map())
+ /** The signer just added, whose role takes the focus once their row renders. */
+ const focusRole = useRef(null)
+ useEffect(() => {
+ if (focusRole.current === null) return
+ roleInputs.current.get(focusRole.current)?.focus()
+ focusRole.current = null
+ }, [signers])
+
function addSigner(person: EsignProfile) {
const draft = draftOf(person, signers.length, pages)
setSigners((current) => [...current, draft])
setFocus({ page: draft.places[0].page, y: draft.places[0].y })
setSearch("")
+ focusRole.current = person.sub
}
- function updateSigner(index: number, patch: Partial) {
- setSigners((current) => current.map((s, i) => (i === index ? { ...s, ...patch } : s)))
+ // Placing refers to a signer by position, so it ends when the positions change.
+ function removeSigner(index: number, button: HTMLElement) {
+ if (signers.length === 1) searchRef.current?.focus()
+ else signerList.capture(button, { restore: true })
+ setSigners((current) => current.filter((_, i) => i !== index))
+ setPlacing(null)
}
- function moveSigner(index: number, by: -1 | 1) {
+ const moveButtons = useRef(new Map())
+ function moveButtonRef(key: string) {
+ return (button: HTMLButtonElement | null) => {
+ if (button) moveButtons.current.set(key, button)
+ else moveButtons.current.delete(key)
+ }
+ }
+ /** The signer just moved by the arrow that had the focus, which keeps it once the row lands. */
+ const focusMove = useRef<{ sub: string; by: -1 | 1 } | null>(null)
+ // A layout effect, so the focus is back before the browser drops it from the moved row or the disabled arrow.
+ useLayoutEffect(() => {
+ const moved = focusMove.current
+ if (moved === null) return
+ focusMove.current = null
+ const index = signers.findIndex((s) => s.sub === moved.sub)
+ // The arrow that just disabled itself (first row going up, last going down) hands the focus to the other one.
+ const atEnd = moved.by === -1 ? index === 0 : index === signers.length - 1
+ const up = moved.by === -1 ? !atEnd : atEnd
+ moveButtons.current.get(`${moved.sub}:${up ? "up" : "down"}`)?.focus()
+ }, [signers])
+
+ function moveSigner(index: number, by: -1 | 1, button: HTMLElement) {
+ if (button === document.activeElement) focusMove.current = { sub: signers[index].sub, by }
+ setPlacing(null)
setSigners((current) => {
const next = [...current]
const [item] = next.splice(index, 1)
@@ -251,20 +392,27 @@ export function NewEsignRequestPage({
})
}
- function updatePlaces(index: number, update: (places: Place[]) => Place[], manual = true) {
+ function setRole(index: number, role: string) {
+ const s = signers[index]
+ const next = placeByRole({ ...s, role }, anchors, pages)
+ const placed = next.places.find((p) => p.byRole)
+ if (next.places !== s.places && placed) setFocus({ page: placed.page, y: placed.y })
+ setSigners((current) => current.map((c, i) => (i === index ? next : c)))
+ }
+
+ function updatePlaces(index: number, update: (places: DraftPlace[]) => DraftPlace[]) {
setSigners((current) =>
- current.map((s, i) =>
- i === index ? { ...s, places: withSignature(update(s.places)), manual: s.manual || manual } : s
- )
+ current.map((s, i) => (i === index ? { ...s, places: withSignature(update(s.places)) } : s))
)
}
- function movePlace(index: number, at: number, placement: BoxPlacement) {
- updatePlaces(index, (places) => places.map((p, i) => (i === at ? { ...placement, kind: p.kind } : p)), at === 0)
+ // The box comes back with every field it was given: rebuilt without `byRole`, it no longer follows the role.
+ function movePlace(index: number, at: number, { page, x, y, width, height }: BoxPlacement) {
+ updatePlaces(index, (places) => places.map((p, i) => (i === at ? { page, x, y, width, height, kind: p.kind } : p)))
}
function removePlace(index: number, at: number) {
- updatePlaces(index, (places) => places.filter((_, i) => i !== at), false)
+ updatePlaces(index, (places) => places.filter((_, i) => i !== at))
}
function place(page: number, x: number, y: number) {
@@ -279,14 +427,11 @@ export function NewEsignRequestPage({
}
const at = placing.place
// Clicked places keep reading order (page, then top to bottom), which is also the order they are signed in.
- updatePlaces(
- placing.signer,
- (places) =>
- (at === null
- ? [...places, { ...placement, kind: "signature" as const }]
- : places.map((p, i) => (i === at ? { ...placement, kind: p.kind } : p))
- ).sort((a, b) => a.page - b.page || a.y - b.y || a.x - b.x),
- at === 0
+ updatePlaces(placing.signer, (places) =>
+ (at === null
+ ? [...places, { ...placement, kind: "signature" as const }]
+ : places.map((p, i) => (i === at ? { ...placement, kind: p.kind } : p))
+ ).sort((a, b) => a.page - b.page || a.y - b.y || a.x - b.x)
)
setPlacing(null)
}
@@ -317,15 +462,26 @@ export function NewEsignRequestPage({
.map((e) => e.trim())
.filter(Boolean)
const badEmail = recipients.find((e) => !EMAIL.test(e))
- const problems = [
- !staged && "Choose the document.",
- signers.length === 0 && "Add at least one signer.",
- title.trim() === "" && "Give the request a title.",
- saveToDrive && !folder && "Choose the OneDrive folder.",
- saveToDrive && fileName.trim() === "" && "Name the signed file.",
- badEmail && `“${badEmail}” isn't an email address.`,
- ...outsiders.map((s) => `${s.name} can't sign documents of ${driveLabel(blockedDrive(s.sub) ?? "")}.`),
- ].filter((p): p is string => Boolean(p))
+ const errors = {
+ document: staged ? undefined : "Choose the document.",
+ signers: signers.length > 0 ? undefined : "Add at least one signer.",
+ title: title.trim() === "" ? "Give the request a title." : undefined,
+ folder: saveToDrive && !folder ? "Choose the OneDrive folder." : undefined,
+ fileName: saveToDrive && fileName.trim() === "" ? "Name the signed file." : undefined,
+ email: badEmail ? `“${badEmail}” isn't an email address.` : undefined,
+ }
+ // Signers who may not sign these documents block the request too; their rows say why.
+ const problems = outsiders.length > 0 || Object.values(errors).some(Boolean)
+
+ function revalidate(field: keyof typeof shown) {
+ if (shown[field]) setShown((current) => ({ ...current, [field]: errors[field] }))
+ }
+
+ function showProblems() {
+ setAttempted(true)
+ setShown({ title: errors.title, fileName: errors.fileName, email: errors.email })
+ setBlockedSubmits((count) => count + 1)
+ }
// When the viewer signs first, creating the request and signing it are one step.
const myIndex = signers.findIndex((s) => s.sub === me)
@@ -337,6 +493,21 @@ export function NewEsignRequestPage({
const creating = useRef(false)
+ // "Sign a document" starts with the viewer as signer: that alone is nothing to lose.
+ const untouched =
+ staged === null &&
+ title.trim() === "" &&
+ emails.trim() === "" &&
+ message.trim() === "" &&
+ signers.every((s) => withMe && s.sub === me && s.role.trim() === "")
+ const dirty = createdRequest === null && !untouched
+ // Reads the ref when it runs, so moving to the request just created is never blocked.
+ const leave = useBlocker({
+ shouldBlockFn: () => dirty && created.current === null,
+ enableBeforeUnload: () => dirty && created.current === null,
+ withResolver: true,
+ })
+
async function create(documentId: string) {
if (created.current) return created.current
if (creating.current) return null
@@ -349,7 +520,11 @@ export function NewEsignRequestPage({
title: title.trim(),
documentId,
ordered,
- signers: signers.map((s) => ({ sub: s.sub, role: s.role.trim() || null, places: s.places })),
+ signers: signers.map((s) => ({
+ sub: s.sub,
+ role: s.role.trim() || null,
+ places: s.places.map(({ page, x, y, width, height, kind }) => ({ page, x, y, width, height, kind })),
+ })),
output:
saveToDrive && folder ? { driveId: folder.driveId, folderId: folder.id, fileName: fileName.trim() } : null,
email: recipients.length ? { to: recipients, message: message.trim() || null } : null,
@@ -382,8 +557,7 @@ export function NewEsignRequestPage({
async function submit() {
if (submitting || created.current) return
- setAttempted(true)
- if (problems.length > 0 || !staged) return
+ if (problems || !staged) return showProblems()
setSubmitting(true)
const request = await create(staged.id)
if (!request) return setSubmitting(false)
@@ -394,8 +568,10 @@ export function NewEsignRequestPage({
function createAndSign() {
if (submitting || created.current) return null
- setAttempted(true)
- if (problems.length > 0 || !staged) return null
+ if (problems || !staged) {
+ showProblems()
+ return null
+ }
return create(staged.id).then((request) => (request ? [{ requestId: request.id, title: request.title }] : []))
}
@@ -438,53 +614,51 @@ export function NewEsignRequestPage({
}
/>
-
+
{staged ? (
-
- {placing !== null && (
-
- {placing.place === null
+ setPlacing(null)}>
- Cancel
-
-
- )}
-
- signers.flatMap((s, index) =>
- s.places.map((p, at) =>
- p.page === page ? (
- movePlace(index, at, placement)}
- onRemove={s.places.length > 1 ? () => removePlace(index, at) : undefined}
- />
- ) : null
- )
+ : `Click the new spot for ${signers[placing.signer]?.name}.`
+ }
+ statusAction={placing !== null && setPlacing(null)} />}
+ cursor={placing !== null ? "crosshair" : "default"}
+ onPageClick={place}
+ focus={focus}
+ overlay={(page) =>
+ signers.flatMap((s, index) =>
+ s.places.map((p, at) =>
+ p.page === page ? (
+ movePlace(index, at, placement)}
+ onRemove={s.places.length > 1 ? () => removePlace(index, at) : undefined}
+ />
+ ) : null
)
- }
- />
-
+ )
+ }
+ />
) : (
{
event.preventDefault()
setDragging(true)
@@ -519,16 +693,20 @@ export function NewEsignRequestPage({
)}
- {docError &&
{docError} }
+ {(docError || attempted) &&
{docError ?? errors.document} }
{source === "drive" && config.drives.length > 0 ? (
-
void importFromDrive(item.id, from)}
- className="flex-1"
- />
+ lastFolder !== undefined && (
+ void importFromDrive(item.id, from)}
+ className="flex-1"
+ />
+ )
) : (
- {attempted && problems.length > 0 &&
{problems[0]} }
- {submitError &&
{submitError} }
+ {submitError && (
+
+ {submitError}
+
+ )}
{createdRequest && (
Request created.{" "}
@@ -592,151 +773,179 @@ export function NewEsignRequestPage({
{signers.length > 0 && (
-
- {signers.map((s, index) => (
-
-
-
- {index + 1}
-
-
- {s.name}
- {s.sub === me && }
-
-
moveSigner(index, -1)}
- />
- moveSigner(index, 1)}
- />
- setSigners((current) => current.filter((_, i) => i !== index))}
+
+ {signers.map((s, index) => {
+ const blocked = blockedDrive(s.sub)
+ const blockedId = `${ids.signersError}-${index}`
+ return (
+
+
+
+ {index + 1}
+
+
+ {s.name}
+ {s.sub === me && }
+
+ {signers.length > 1 && (
+ <>
+ moveSigner(index, -1, event.currentTarget)}
+ />
+ moveSigner(index, 1, event.currentTarget)}
+ />
+ >
+ )}
+ removeSigner(index, event.currentTarget)}
+ />
+
+ {/* No autoHighlight: Enter keeps a role typed by hand instead of taking the first suggestion. */}
+ setRole(index, role)}>
+ {
+ if (input) roleInputs.current.set(s.sub, input)
+ else roleInputs.current.delete(s.sub)
+ }}
+ listLabel="roles"
+ aria-label={`Role of ${s.name}`}
+ placeholder="Role (optional), e.g. Segretario"
+ maxLength={80}
+ />
+
+
+ {(role: string) => (
+
+ {role}
+
+ )}
+
+
+
+ {blocked !== undefined &&
+ (attempted ? (
+ Can't sign documents of {driveLabel(blocked)}
+ ) : (
+
+
+ Can't sign documents of {driveLabel(blocked)}
+
+ ))}
+ {
+ setHighlight(at === null ? null : { signer: s.sub, place: at })
+ const p = at === null ? undefined : s.places[at]
+ if (p) setFocus({ page: p.page, y: p.y + p.height / 2 })
+ }}
+ onKind={(at, kind) =>
+ updatePlaces(index, (places) => places.map((p, i) => (i === at ? { ...p, kind } : p)))
+ }
+ onMove={(at) => setPlacing({ signer: index, place: at })}
+ onRemove={(at) => removePlace(index, at)}
+ onAdd={() =>
+ setPlacing(
+ placing?.signer === index && placing.place === null
+ ? null
+ : { signer: index, place: null }
+ )
+ }
/>
-
- {
- const role = event.target.value
- const placed = s.manual ? null : placementForRole(role, anchors, pages)
- if (placed) setFocus({ page: placed.page, y: placed.y })
- updateSigner(
- index,
- placed
- ? { role, places: s.places.map((p, at) => (at === 0 ? { ...placed, kind: p.kind } : p)) }
- : { role }
- )
- }}
- />
- {blockedDrive(s.sub) !== undefined && (
-
-
- Can't sign documents of {driveLabel(blockedDrive(s.sub) ?? "")}
-
- )}
- {
- setHighlight(at === null ? null : { signer: s.sub, place: at })
- const p = at === null ? undefined : s.places[at]
- if (p) setFocus({ page: p.page, y: p.y + p.height / 2 })
- }}
- onKind={(at, kind) =>
- updatePlaces(index, (places) => places.map((p, i) => (i === at ? { ...p, kind } : p)), false)
- }
- onMove={(at) => setPlacing({ signer: index, place: at })}
- onRemove={(at) => removePlace(index, at)}
- onAdd={() =>
- setPlacing(
- placing?.signer === index && placing.place === null ? null : { signer: index, place: null }
- )
- }
- />
-
- ))}
+
+ )
+ })}
)}
-
- {ROLES.map((role) => (
-
- ))}
-
-
-
-
- setSearch(event.target.value)}
- />
-
- {(matches.length > 0 || available.length === 0) && (
-
- {matches.slice(0, 30).map((person) => (
-
- addSigner(person)}
- className="flex min-h-10 w-full items-center gap-2 rounded-(--pn-r-2) px-2 py-1.5 text-left hover:bg-(--pn-hover)"
- >
-
-
-
- {person.name}
- {person.sub === me && }
-
- {person.email && (
-
- {person.email}
-
- )}
+ {/* The page filters people itself and lists them only once something is typed; the list stays in place. */}
+ person.name}
+ value={null}
+ inputValue={search}
+ onInputValueChange={(value, { reason }) => {
+ // Picking a person would otherwise put their name in the search.
+ if (reason !== "item-press") setSearch(value)
+ }}
+ onValueChange={(person) => person && addSigner(person)}
+ autoHighlight
+ inline
+ open
+ >
+
+
+
+ }
+ aria-label="Find a person to add"
+ placeholder="Add a signer…"
+ aria-invalid={(attempted && errors.signers !== undefined) || undefined}
+ aria-describedby={attempted && errors.signers ? ids.signersError : undefined}
+ />
+
+ {attempted && errors.signers &&
{errors.signers} }
+
+ {(person: EsignProfile) => (
+
+
+
+
+ {person.name}
+ {person.sub === me && }
-
-
- ))}
- {available.length === 0 && (
-
- {search.trim() !== ""
- ? "Nobody matches."
- : eligible.length === 0 && usedDrives.length > 0
- ? `Nobody can sign documents of ${usedDrives.map(driveLabel).join(" and ")}. Who can is set in Signatures › Drives.`
- : eligible.length === 0
- ? "No registered people currently have permission to sign."
- : usedDrives.length > 0
- ? "Everyone allowed to sign these documents is added."
- : "Everyone who can sign is added."}
-
+ {person.email && (
+ {person.email}
+ )}
+
+
)}
-
- )}
- {matches.length > 0 && eligible.length < people.length && (
-
- Only people allowed to sign documents of {usedDrives.map(driveLabel).join(" and ")} are listed.
-
- )}
-
+
+ {available.length === 0 && (
+
+ {search.trim() !== ""
+ ? "Nobody matches."
+ : eligible.length === 0 && usedDrives.length > 0
+ ? `Nobody can sign documents of ${usedDrives.map(driveLabel).join(" and ")}. Who can is set in Signatures › Drives.`
+ : eligible.length === 0
+ ? "No registered people currently have permission to sign."
+ : usedDrives.length > 0
+ ? "Everyone allowed to sign these documents is added."
+ : "Everyone who can sign is added."}
+
+ )}
+ {matches.length > 0 && eligible.length < people.length && (
+
+ Only people allowed to sign documents of {usedDrives.map(driveLabel).join(" and ")} are listed.
+
+ )}
+
+
{signers.length > 1 && (
+ {
+ if (!open) leave.reset?.()
+ }}
+ title="Discard changes?"
+ description="The document, signers and boxes you set up here will be lost."
+ confirmLabel="Discard"
+ cancelLabel="Keep editing"
+ onConfirm={async () => leave.proceed?.()}
+ />
>
)
}
+/** A problem shown at the control it is about, styled like `FormField`'s error. */
+function FieldError({ id, children }: { id: string; children: ReactNode }) {
+ return (
+
+
+ {children}
+
+ )
+}
+
/** A signer's boxes, one control each, listed under their name: the person appears once however many places. */
function PlacesEditor({
name,
diff --git a/src/features/esign/pdf-viewer.tsx b/src/features/esign/pdf-viewer.tsx
index 6ac1b82..e92d70f 100644
--- a/src/features/esign/pdf-viewer.tsx
+++ b/src/features/esign/pdf-viewer.tsx
@@ -1,7 +1,7 @@
import { Minus, Plus, ScanLine } from "lucide-react"
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from "pdfjs-dist"
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"
-import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"
+import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import { IconButton, InlineAlert } from "@/components/primitives"
import { cn } from "@/lib/utils"
@@ -23,6 +23,13 @@ type PdfViewerProps = {
cursor?: "default" | "crosshair"
/** A point to bring to the middle of the viewer when it changes: a page and a fraction of its height. */
focus?: { page: number; y: number } | null
+ /**
+ * Replaces "Page n of total" in the toolbar while set, e.g. what the next click on a page does; announced politely.
+ * Below 448px of toolbar the zoom controls hide meanwhile.
+ */
+ status?: string | null
+ /** A control after the status, e.g. Cancel while placing a box. */
+ statusAction?: ReactNode
}
const ZOOMS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const
@@ -45,7 +52,16 @@ function loadPdfjs() {
* The dashboard's own PDF viewer (pdf.js), so every browser shows documents the same way and signatures can be placed
* on them. Pages render lazily as they near the viewport, at the device pixel ratio; signature appearances are drawn.
*/
-export function PdfViewer({ data, pages, overlay, onPageClick, cursor = "default", focus }: PdfViewerProps) {
+export function PdfViewer({
+ data,
+ pages,
+ overlay,
+ onPageClick,
+ cursor = "default",
+ focus,
+ status,
+ statusAction,
+}: PdfViewerProps) {
const scrollRef = useRef(null)
const fill = useFillHeight()
const [containerWidth, setContainerWidth] = useState(0)
@@ -92,18 +108,27 @@ export function PdfViewer({ data, pages, overlay, onPageClick, cursor = "default
const fitScale = containerWidth > 0 ? Math.min((containerWidth - 32) / maxWidth, 1.5) : 0
const scale = zoom === "fit" ? fitScale : zoom
+ /** The point in the middle of the viewer, which stays there when the scale changes. */
+ const reading = useRef<{ page: number; y: number } | null>(null)
+ const previousScale = useRef(0)
+ useLayoutEffect(() => {
+ const container = scrollRef.current
+ if (!container) return
+ if (previousScale.current > 0 && reading.current) centerOn(container, reading.current.page, reading.current.y)
+ previousScale.current = scale
+ reading.current = middleOf(container)
+ }, [scale])
+
+ // After the scale's effect, so a focus that changes with the scale wins; zooming alone doesn't bring it back. Both
+ // are layout effects, so the first frame with the pages already shows the right spot.
+ const laidOut = scale > 0
const focusPage = focus?.page
const focusY = focus?.y ?? 0.5
- useEffect(() => {
- if (focusPage === undefined || scale <= 0) return
- // Scroll the viewer only: scrollIntoView would also move the page around it.
+ useLayoutEffect(() => {
const container = scrollRef.current
- const element = container?.querySelector(`[data-page="${focusPage}"]`)
- if (!container || !element) return
- const offset = element.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop
- const top = offset + element.offsetHeight * focusY - container.clientHeight / 2
- container.scrollTo({ top: Math.max(0, top), behavior: "smooth" })
- }, [focusPage, focusY, scale])
+ if (focusPage === undefined || !laidOut || !container) return
+ centerOn(container, focusPage, focusY)
+ }, [focusPage, focusY, laidOut])
function step(direction: 1 | -1) {
const current = zoom === "fit" ? fitScale : zoom
@@ -118,11 +143,20 @@ export function PdfViewer({ data, pages, overlay, onPageClick, cursor = "default
style={fill.style}
className="flex h-[calc(100dvh-210px)] min-h-[480px] flex-col overflow-hidden rounded-(--pn-r-5) border border-(--pn-line)"
>
-
-
- Page {Math.min(currentPage + 1, pages.length)} of {pages.length}
-
-
+
+
+
+ {status || `Page ${Math.min(currentPage + 1, pages.length)} of ${pages.length}`}
+
+ {statusAction &&
{statusAction}
}
+
+
step(-1)} disabled={scale <= ZOOMS[0] + 0.01} />
{scale > 0 ? `${Math.round(scale * 100)}%` : ""}
@@ -136,7 +170,14 @@ export function PdfViewer({ data, pages, overlay, onPageClick, cursor = "default
/>
-
+
{
+ reading.current = middleOf(event.currentTarget)
+ }}
+ >
{error ? (
{error}
@@ -167,6 +208,30 @@ export function PdfViewer({ data, pages, overlay, onPageClick, cursor = "default
)
}
+/**
+ * Scrolls the viewer, not the page around it as scrollIntoView would, so a point of a page (a fraction of its height)
+ * is in the middle. Never smoothly: like navigation, it doesn't animate.
+ */
+function centerOn(container: HTMLElement, page: number, y: number) {
+ const element = container.querySelector
(`[data-page="${page}"]`)
+ if (!element) return
+ const offset = element.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop
+ const top = offset + element.offsetHeight * y - container.clientHeight / 2
+ container.scrollTo({ top: Math.max(0, top), behavior: "instant" })
+}
+
+/** The point of a page in the middle of the viewer, the inverse of `centerOn`. */
+function middleOf(container: HTMLElement) {
+ const middle = container.getBoundingClientRect().top + container.clientHeight / 2
+ let point: { page: number; y: number } | null = null
+ for (const element of container.querySelectorAll("[data-page]")) {
+ const box = element.getBoundingClientRect()
+ point = { page: Number(element.dataset.page), y: (middle - box.top) / box.height }
+ if (middle < box.bottom + PAGE_GAP / 2) break
+ }
+ return point
+}
+
type PdfPageProps = {
index: number
size: PageSize
diff --git a/src/features/esign/places.ts b/src/features/esign/places.ts
index a786e91..a55146f 100644
--- a/src/features/esign/places.ts
+++ b/src/features/esign/places.ts
@@ -1,3 +1,4 @@
+import type { PageSize } from "./pdf-viewer"
import type { BoxPlacement } from "./signature-box"
/** One of a signer's boxes: a full signature of its own, or a mark repeating their first signature. */
@@ -19,7 +20,7 @@ export function kindsLabel(places: Pick[]) {
}
/** Keeps at least one full signature: when none is left, the first place becomes one. */
-export function withSignature(places: Place[]): Place[] {
+export function withSignature(places: T[]): T[] {
if (places.length === 0 || places.some((p) => p.kind === "signature")) return places
return places.map((p, i) => (i === 0 ? { ...p, kind: "signature" } : p))
}
@@ -32,3 +33,22 @@ export function placesLeft>(places: T[], signature
let signature = 0
return places.filter((p) => (p.kind === "mark" ? signaturesDone === 0 : signature++ >= signaturesDone))
}
+
+const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
+
+/**
+ * The place moved to a click on `page` (fractions of that page): centred on the click, inside the page, and the same
+ * size in points, so it doesn't grow or shrink on a page of another size.
+ */
+export function placeAt(place: Place, pages: PageSize[], page: number, x: number, y: number): Place {
+ const width = clamp((place.width * pages[place.page].width) / pages[page].width, 0.04, 1)
+ const height = clamp((place.height * pages[place.page].height) / pages[page].height, 0.04, 1)
+ return {
+ kind: place.kind,
+ page,
+ width,
+ height,
+ x: clamp(x - width / 2, 0, 1 - width),
+ y: clamp(y - height / 2, 0, 1 - height),
+ }
+}
diff --git a/src/features/esign/placing.tsx b/src/features/esign/placing.tsx
new file mode 100644
index 0000000..f594425
--- /dev/null
+++ b/src/features/esign/placing.tsx
@@ -0,0 +1,80 @@
+import { MapPin } from "lucide-react"
+import { useEffect } from "react"
+
+import { buttonMotion } from "@/components/primitives"
+import { Button } from "@/components/ui/button"
+import { cn } from "@/lib/utils"
+
+import type { Place } from "./places"
+
+/** Cancel in the viewer toolbar while the next click on a page places a box. Esc does the same while it is shown. */
+export function PlacingCancel({ onCancel }: { onCancel: () => void }) {
+ useEffect(() => {
+ function cancel(event: KeyboardEvent) {
+ if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return
+ // An open menu or dialog takes Escape for itself.
+ if (document.querySelector('[role="dialog"], [role="alertdialog"], [role="menu"]')) return
+ event.preventDefault()
+ onCancel()
+ }
+ window.addEventListener("keydown", cancel)
+ return () => window.removeEventListener("keydown", cancel)
+ }, [onCancel])
+
+ return (
+
+ Cancel
+
+ Esc
+
+
+ )
+}
+
+/**
+ * The signer's "Move to another spot", one button per place when there are several: the next click on any page puts
+ * the box there. Choosing the place being moved again cancels.
+ */
+export function MovePlaceButtons({
+ places,
+ placing,
+ onPlacing,
+}: {
+ places: Place[]
+ placing: number | null
+ onPlacing: (at: number | null) => void
+}) {
+ return places.map((p, at) => {
+ const what = p.kind === "mark" ? "mark" : "signature"
+ return (
+ 1 ? `Move the ${what} on page ${p.page + 1} to another spot` : undefined}
+ onClick={() => onPlacing(placing === at ? null : at)}
+ >
+
+ {places.length > 1 ? `${p.kind === "mark" ? "Mark" : "Signature"} · p. ${p.page + 1}` : "Move to another spot"}
+
+ )
+ })
+}
+
+/** The viewer's status while one of the signer's places waits for its new spot. */
+export function placingStatus(places: Place[], at: number) {
+ const place = places[at]
+ if (places.length === 1) return "Click the new spot for your signature."
+ return `Click the new spot for your ${place.kind} on page ${place.page + 1}.`
+}
diff --git a/src/features/esign/request-page.tsx b/src/features/esign/request-page.tsx
index 7193942..58ebaac 100644
--- a/src/features/esign/request-page.tsx
+++ b/src/features/esign/request-page.tsx
@@ -11,6 +11,7 @@ import {
InlineAlert,
LoadingButton,
SectionCard,
+ Spinner,
} from "@/components/primitives"
import { appToast, PageBar, PageContent, useCan } from "@/components/shell"
import { Button } from "@/components/ui/button"
@@ -22,11 +23,13 @@ import { cn } from "@/lib/utils"
import { declineEsignRequest, getEsignDocumentContent, uploadSignedEsignDocument } from "./esign.functions"
import { PdfViewer, usePdfBytes } from "./pdf-viewer"
-import { kindsLabel, type Place, pagesLabel, placesLeft } from "./places"
+import { kindsLabel, type Place, pagesLabel, placeAt, placesLeft } from "./places"
+import { MovePlaceButtons, PlacingCancel, placingStatus } from "./placing"
import { RequestStatusBadge, SignerStatusBadge, signedProgress, YouChip } from "./request-status"
import { cancelEsignRequest, deleteEsignRequest, retryEsignDelivery } from "./requests.functions"
import { SignWithCie } from "./sign-with-cie"
import { SignatureBox } from "./signature-box"
+import { useReloadWhile } from "./use-reload-while"
type EventData = { sub?: string; name?: string; path?: string; to?: string[]; error?: string; reason?: string | null }
@@ -47,6 +50,12 @@ function eventText(event: Detail["events"][number], names: Map)
return `${who} created the request`
case "notified":
return `Emailed ${(data.sub && names.get(data.sub)) ?? "a signer"} that it's their turn`
+ case "notify_failed": {
+ const name = (data.sub && names.get(data.sub)) ?? "a signer"
+ return data.reason === "no_email"
+ ? `Couldn't email ${name} that it's their turn: no email address`
+ : `Couldn't email ${name} that it's their turn`
+ }
case "signed":
return `${data.name ?? who} signed with CIE`
case "completed":
@@ -112,10 +121,20 @@ export function EsignRequestPage({ detail }: { detail: Detail }) {
const mySigner = request.signers.find((s) => s.sub === me && s.status === "pending")
const myTurn = request.myTurn && mySigner !== undefined
- // The viewer's boxes as they drag them; fixed once some of them are signed.
- const [myPlaces, setMyPlaces] = useState(mySigner?.places ?? [])
- useEffect(() => setMyPlaces(mySigner?.places ?? []), [mySigner?.places])
+ // The viewer's boxes as they drag them; fixed once some of them are signed. Reloads bring the saved places as new
+ // arrays, so the dragged ones reset only when the saved ones change.
+ const savedPlaces = mySigner?.places ?? []
+ const savedKey = JSON.stringify(savedPlaces)
+ const [myPlaces, setMyPlaces] = useState(savedPlaces)
+ const [placesKey, setPlacesKey] = useState(savedKey)
+ if (placesKey !== savedKey) {
+ setPlacesKey(savedKey)
+ setMyPlaces(savedPlaces)
+ }
const myDone = mySigner?.signaturesDone ?? 0
+ /** The place waiting for a click on its new spot; placing ends by itself once the boxes are fixed. */
+ const [placing, setPlacing] = useState(null)
+ const placingAt = myTurn && myDone === 0 ? placing : null
const myLeft = placesLeft(
myPlaces.map((place, at) => ({ ...place, at })),
myDone
@@ -128,21 +147,10 @@ export function EsignRequestPage({ detail }: { detail: Detail }) {
// Delivery (OneDrive, email) runs after the last signature: follow it until it settles.
const delivering = request.status === "completed" && request.delivery?.status === "pending"
- useEffect(() => {
- if (!delivering) return
- let busy = false
- const timer = window.setInterval(() => {
- if (busy) return
- busy = true
- router
- .invalidate()
- .catch((error) => console.error(error))
- .finally(() => {
- busy = false
- })
- }, 2000)
- return () => window.clearInterval(timer)
- }, [delivering, router])
+ useReloadWhile(delivering, 2000)
+ // Another signer holds the request: follow it until they finish, so Sign with CIE turns on by itself.
+ const lockedBy = myTurn && signingNow && signingNow.sub !== me ? signingNow : null
+ useReloadWhile(lockedBy !== null, 3000)
const names = useMemo(() => {
const map = new Map(request.signers.map((s) => [s.sub, s.name]))
@@ -150,6 +158,42 @@ export function EsignRequestPage({ detail }: { detail: Detail }) {
return map
}, [request])
+ // For each signer whose turn it is, other than the viewer: when they were emailed, why they couldn't be, or else
+ // since when it has been their turn.
+ const turnNotes = useMemo(() => {
+ const emailed = new Map()
+ const failed = new Map()
+ for (const event of events) {
+ if (!event.data.sub) continue
+ if (event.type === "notified") emailed.set(event.data.sub, new Date(event.at))
+ if (event.type === "notify_failed") failed.set(event.data.sub, event.data.reason)
+ }
+ const signedTimes = request.signers.flatMap((s) => (s.signedAt ? [new Date(s.signedAt).getTime()] : []))
+ const since = new Date(request.ordered && signedTimes.length > 0 ? Math.max(...signedTimes) : request.createdAt)
+ const notes = new Map()
+ for (const sub of request.turn) {
+ if (sub === me) continue
+ const at = emailed.get(sub)
+ if (at) notes.set(sub, { note: `Emailed ${formatDateTime(at)}` })
+ else if (failed.has(sub))
+ notes.set(sub, {
+ warning: failed.get(sub) === "no_email" ? "Couldn't email them: no email address" : "Couldn't email them",
+ })
+ else notes.set(sub, { note: `Their turn since ${formatDateTime(since)}` })
+ }
+ return notes
+ }, [events, request, me])
+
+ const stopped = request.status === "cancelled" || request.status === "declined"
+ // A stopped request without an output folder or an email had nothing to deliver: it has no Delivery card.
+ const deliveryPlanned = request.output !== null || (request.email?.to.length ?? 0) > 0
+ const savedItem = request.delivery?.driveItem ?? null
+ // Recorded right after the upload, before the delivery itself is saved: Activity may say "Saved" first.
+ const uploaded = events.findLast((e) => e.type === "uploaded")
+ const saving = delivering && request.output !== null && !savedItem && !uploaded
+ const emailed = (request.delivery?.emailedTo.length ?? 0) > 0
+ const sending = delivering && (request.email?.to.length ?? 0) > 0 && !emailed
+
const canCancel = request.status === "in_progress" && canManage
// A request in progress is cancelled first; a pending delivery still needs the request.
const canDelete = request.status !== "in_progress" && request.delivery?.status !== "pending" && canManage
@@ -228,7 +272,7 @@ export function EsignRequestPage({ detail }: { detail: Detail }) {
{myTurn && (
)}
>
@@ -252,9 +296,19 @@ export function EsignRequestPage({ detail }: { detail: Detail }) {
data={bytes}
pages={pages}
focus={focus}
+ cursor={placingAt !== null ? "crosshair" : "default"}
+ onPageClick={(page, x, y) => {
+ if (placingAt === null) return
+ setMyPlaces((current) =>
+ current.map((p, at) => (at === placingAt ? placeAt(p, pages, page, x, y) : p))
+ )
+ setPlacing(null)
+ }}
+ status={placingAt !== null ? placingStatus(myPlaces, placingAt) : null}
+ statusAction={placingAt !== null && setPlacing(null)} />}
overlay={(page) =>
request.signers.flatMap((signer, index) => {
- if (signer.status !== "pending") return []
+ if (request.status !== "in_progress" || signer.status !== "pending") return []
const mine = myTurn && signer.id === mySigner?.id
const left = mine
? myLeft
@@ -312,8 +366,24 @@ export function EsignRequestPage({ detail }: { detail: Detail }) {
? `${myDone} of ${kindsLabel(myPlaces.filter((p) => p.kind === "signature"))} done. Choose Sign with CIE at the top to finish.`
: myPlaces.length > 1
? `You sign in ${myPlaces.length} places: ${kindsLabel(myPlaces)} on ${pagesLabel(myPlaces)}. Drag the blue boxes to adjust them, then choose Sign with CIE at the top: one PIN signs them all.`
- : "Drag the blue box to adjust where your signature goes, then choose Sign with CIE at the top."}{" "}
+ : `Your signature goes on ${pagesLabel(myPlaces)}. Drag the blue box to adjust it, then choose Sign with CIE at the top.`}{" "}
You need the card, its PIN and the NFC reader.
+ {myDone === 0 && (
+ <>
+ {" "}
+ First time signing on this computer?{" "}
+ {/* A new tab, because the boxes moved here aren't saved yet. */}
+
+ Set it up first
+
+ .
+ >
+ )}
{detail.alsoWaiting > 0 && (
@@ -332,12 +402,13 @@ export function EsignRequestPage({ detail }: { detail: Detail }) {
)}
- {signingNow && signingNow.sub !== me && (
+ {lockedBy && (
- {signingNow.name} is signing right now. Try again in a few minutes.
+ {lockedBy.name} is signing right now. Sign with CIE turns on as soon as they finish.
)}
+ {myDone === 0 &&
}
setDeclineOpen(true)}>
Decline
@@ -378,116 +449,138 @@ export function EsignRequestPage({ detail }: { detail: Detail }) {
- {request.signers.map((signer, index) => (
-
-
- {index + 1}
-
-
-
-
- {signer.name}
- {signer.sub === me && }
-
-
-
- {(signer.role || signer.places.length > 1 || signer.signedAt) && (
-
- {[
- signer.role,
- signer.places.length > 1 && kindsLabel(signer.places),
- signer.signedAt && formatDateTime(new Date(signer.signedAt)),
- ]
- .filter(Boolean)
- .join(" · ")}
-
- )}
- {signer.status === "signed" && signer.certificateIssuer && (
-
- CIE · {signer.fiscalCode ?? signer.certificateIssuer}
+ {request.signers.map((signer, index) => {
+ const turnNote = turnNotes.get(signer.sub)
+ const meta = [
+ signer.role,
+ signer.places.length > 1 && kindsLabel(signer.places),
+ signer.signedAt && formatDateTime(new Date(signer.signedAt)),
+ turnNote?.note,
+ ].filter(Boolean)
+ return (
+
+
+ {index + 1}
+
+
+
+
+ {signer.name}
+ {signer.sub === me && }
+
+
- )}
- {signer.declineReason &&
“{signer.declineReason}”
}
-
-
- ))}
+ {meta.length > 0 &&
{meta.join(" · ")}
}
+ {turnNote?.warning && (
+
{turnNote.warning}
+ )}
+ {signer.status === "signed" && signer.certificateIssuer && (
+
+ CIE · {signer.fiscalCode ?? signer.certificateIssuer}
+
+ )}
+ {signer.declineReason && (
+
“{signer.declineReason}”
+ )}
+
+
+ )
+ })}
{!request.ordered && request.signers.length > 1 && (
Signers can sign in any order.
)}
-
-
-
-
-
- {request.output ? (
- request.delivery?.driveItem?.webUrl ? (
-
- {request.output.folderPath ? `${request.output.folderPath}/` : ""}
- {request.delivery.driveItem.name}
-
+ {(!stopped || deliveryPlanned) && (
+
+ {stopped ? (
+ Not delivered: the request was {request.status}.
+ ) : (
+
+
+ {saving ? (
+
) : (
-
- {request.delivery?.driveItem ? "Saved to " : "Will be saved to "}
- {detail.config.drives.find((d) => d.id === request.output?.driveId)?.label ?? "OneDrive"}:{" "}
- {request.output.folderPath || "/"}
- {request.output.folderPath ? "/" : ""}
- {request.delivery?.driveItem?.name ?? request.output.fileName}
-
- )
- ) : (
- Kept in the dashboard only
- )}
-
-
-
-
-
- {request.email?.to.length ? (
- <>
- {request.delivery?.emailedTo.length ? "Emailed to " : "Will be emailed to "}
- {request.email.to.join(", ")}
- >
- ) : (
- No email
- )}
-
-
-
- {request.delivery?.status === "failed" && (
-
- Retry
-
- ) : undefined
- }
- >
- {request.delivery.error ?? "The document couldn't be delivered."}
-
- )}
-
+
+ )}
+
+ {request.output ? (
+ savedItem?.webUrl ? (
+
+ {request.output.folderPath ? `${request.output.folderPath}/` : ""}
+ {savedItem.name}
+
+ ) : (
+
+ {savedItem || uploaded ? "Saved to " : saving ? "Saving to " : "Will be saved to "}
+ {detail.config.drives.find((d) => d.id === request.output?.driveId)?.label ?? "OneDrive"}:{" "}
+ {request.output.folderPath || "/"}
+ {request.output.folderPath ? "/" : ""}
+ {savedItem?.name ?? uploaded?.data.name ?? request.output.fileName}
+
+ )
+ ) : (
+ Kept in the dashboard only
+ )}
+
+
+
+ {sending ? (
+
+ ) : (
+
+ )}
+
+ {request.email?.to.length ? (
+ <>
+ {emailed ? "Emailed to " : sending ? "Sending to " : "Will be emailed to "}
+ {request.email.to.join(", ")}
+ >
+ ) : (
+ No email
+ )}
+
+
+
+ )}
+ {request.delivery?.status === "failed" && (
+
+ Retry
+
+ ) : undefined
+ }
+ >
+ {request.delivery.error ?? "The document couldn't be delivered."}
+
+ )}
+
+ )}
@@ -505,7 +598,14 @@ export function EsignRequestPage({ detail }: { detail: Detail }) {
{canCancel && (
+ Nobody will be able to sign it anymore. Signatures already added stay in the document.
+ {signingNow &&
+ signingNow.sub !== me &&
+ ` ${signingNow.name} is signing it right now and won't be able to finish.`}
+ >
+ }
confirmLabel="Cancel request"
cancelLabel="Keep request"
tone="danger"
diff --git a/src/features/esign/request-status.tsx b/src/features/esign/request-status.tsx
index d1aa8f3..fb53398 100644
--- a/src/features/esign/request-status.tsx
+++ b/src/features/esign/request-status.tsx
@@ -19,12 +19,31 @@ export function RequestStatusBadge({ request }: { request: PickCancelled
}
-export function SignerStatusBadge({ signer, turn }: { signer: EsignSigner; turn: boolean }) {
+export function SignerStatusBadge({
+ signer,
+ turn,
+ requestStatus,
+}: {
+ signer: EsignSigner
+ turn: boolean
+ requestStatus: EsignRequest["status"]
+}) {
if (signer.status === "signed") return Signed
if (signer.status === "declined") return Declined
+ if (requestStatus !== "in_progress") return Not signed
return turn ? To sign : Waiting
}
+const STATUS_FILTERS = ["all", "in_progress", "completed", "closed"] as const
+
+/** The requests list's status segment; "closed" is cancelled or declined. */
+export type StatusFilter = (typeof STATUS_FILTERS)[number]
+
+/** The `?status=` search param; an unknown value falls back to the default instead of failing the route. */
+export function parseStatusFilter(value: string | undefined): StatusFilter | undefined {
+ return STATUS_FILTERS.find((filter) => filter === value)
+}
+
/** "1 of 2 signed". */
export function signedProgress(request: Pick) {
return `${request.signedCount} of ${request.signers.length} signed`
diff --git a/src/features/esign/requests-page.tsx b/src/features/esign/requests-page.tsx
index 930fcb3..805a200 100644
--- a/src/features/esign/requests-page.tsx
+++ b/src/features/esign/requests-page.tsx
@@ -2,16 +2,14 @@ import { Link, useNavigate } from "@tanstack/react-router"
import { FileSignature, Files, PenLine, Plus } from "lucide-react"
import { useDeferredValue, useMemo, useState } from "react"
-import { AvatarGroup, DataTable, type DataTableColumn, EmptyState } from "@/components/primitives"
+import { AvatarGroup, DataTable, type DataTableColumn, EmptyState, Hint } from "@/components/primitives"
import { SegmentedControl } from "@/components/primitives/segmented-control"
import { Count, PageBar, PageContent, Toolbar, useCan } from "@/components/shell"
import { Button } from "@/components/ui/button"
import type { EsignRequest } from "@/lib/api/types"
import { formatDate, pluralize } from "@/lib/format"
-import { RequestStatusBadge, signedProgress } from "./request-status"
-
-type StatusFilter = "all" | "in_progress" | "completed" | "closed"
+import { RequestStatusBadge, type StatusFilter, signedProgress } from "./request-status"
const SCOPE_ITEMS: { value: "mine" | "all"; label: string }[] = [
{ value: "mine", label: "Mine" },
@@ -45,6 +43,13 @@ function NewRequestButton({ variant = "default", self }: { variant?: "default" |
)
}
+/** Whom a request in progress waits on; the viewer's own turn shows as a badge, so it keeps the progress. */
+function turnText(request: EsignRequest) {
+ const name = request.signers.find((s) => s.sub === request.turn[0])?.name
+ if (request.myTurn || !name) return signedProgress(request)
+ return request.turn.length > 1 ? `Waiting for ${name} and ${request.turn.length - 1} more` : `Waiting for ${name}`
+}
+
const columns: DataTableColumn[] = [
{
id: "title",
@@ -62,18 +67,25 @@ const columns: DataTableColumn[] = [
{
id: "signers",
label: "Signers",
- width: 220,
+ width: 260,
priority: 1,
- cell: (r) => (
-
- ({ id: s.id, name: s.name }))}
- max={4}
- listLabel={`Signers of ${r.title}`}
- />
- {signedProgress(r)}
-
- ),
+ cell: (r) => {
+ const text = r.status === "in_progress" ? turnText(r) : null
+ return (
+
+ ({ id: s.id, name: s.name }))}
+ max={4}
+ listLabel={`Signers of ${r.title}`}
+ />
+ {text && (
+
+ {text}
+
+ )}
+
+ )
+ },
},
{
id: "created",
@@ -84,12 +96,24 @@ const columns: DataTableColumn[] = [
},
]
-/** The signatures inbox ("To sign") and the requests list share this page. */
-export function EsignRequestsPage({ requests, scope }: { requests: EsignRequest[]; scope: "inbox" | "mine" | "all" }) {
+// Every document in To sign is the viewer's turn.
+const inboxColumns = columns.filter((column) => column.id !== "status")
+
+/** The signatures inbox ("To sign") and the requests list share this page; the list's status lives in the URL. */
+export function EsignRequestsPage({
+ requests,
+ scope,
+ status = "all",
+ onStatusChange,
+}: {
+ requests: EsignRequest[]
+ scope: "inbox" | "mine" | "all"
+ status?: StatusFilter
+ onStatusChange?: (status: StatusFilter) => void
+}) {
const canManage = useCan("esign:requests:manage")
const navigate = useNavigate()
const [query, setQuery] = useState("")
- const [filter, setFilter] = useState("all")
const deferred = useDeferredValue(query.trim().toLocaleLowerCase())
const inbox = scope === "inbox"
@@ -97,13 +121,13 @@ export function EsignRequestsPage({ requests, scope }: { requests: EsignRequest[
() =>
requests.filter(
(r) =>
- inFilter(r, inbox ? "all" : filter) &&
+ inFilter(r, status) &&
(deferred === "" ||
`${r.title} ${r.createdByName} ${r.signers.map((s) => s.name).join(" ")}`
.toLocaleLowerCase()
.includes(deferred))
),
- [requests, filter, deferred, inbox]
+ [requests, status, deferred]
)
const narrowed = visible.length !== requests.length
const waiting = inbox ? requests.filter((r) => r.myTurn).length : 0
@@ -141,7 +165,7 @@ export function EsignRequestsPage({ requests, scope }: { requests: EsignRequest[
items={SCOPE_ITEMS}
value={scope}
onValueChange={(value) =>
- void navigate({ to: "/dashboard/esign/requests", search: { scope: value } })
+ void navigate({ to: "/dashboard/esign/requests", search: (prev) => ({ ...prev, scope: value }) })
}
/>
) : undefined
@@ -155,8 +179,8 @@ export function EsignRequestsPage({ requests, scope }: { requests: EsignRequest[
...item,
count: requests.filter((r) => inFilter(r, item.value)).length,
}))}
- value={filter}
- onValueChange={setFilter}
+ value={status}
+ onValueChange={(next) => onStatusChange?.(next)}
/>
)
}
@@ -168,7 +192,7 @@ export function EsignRequestsPage({ requests, scope }: { requests: EsignRequest[
r.id}
rowHref={(r) => `/dashboard/esign/requests/${r.id}`}
@@ -196,7 +220,7 @@ export function EsignRequestsPage({ requests, scope }: { requests: EsignRequest[
variant="ghost"
onClick={() => {
setQuery("")
- setFilter("all")
+ onStatusChange?.("all")
}}
>
Clear filters
diff --git a/src/features/esign/setup-page.tsx b/src/features/esign/setup-page.tsx
index dc0a9e0..1f043d3 100644
--- a/src/features/esign/setup-page.tsx
+++ b/src/features/esign/setup-page.tsx
@@ -1,4 +1,4 @@
-import { CreditCard, Download, ExternalLink, MonitorCheck, Nfc } from "lucide-react"
+import { CreditCard, Download, ExternalLink, MonitorCheck, Nfc, Play } from "lucide-react"
import { type ReactNode, useEffect, useState } from "react"
import { SectionCard } from "@/components/primitives"
@@ -6,7 +6,7 @@ import { SegmentedControl } from "@/components/primitives/segmented-control"
import { PageBar, PageContent } from "@/components/shell"
import { Button } from "@/components/ui/button"
-import { AGENT_ORIGINS, AGENT_RELEASES_URL } from "./agent"
+import { AGENT_LAUNCH_URL, AGENT_ORIGINS, AGENT_RELEASES_URL } from "./agent"
type Os = "macos" | "windows" | "linux"
@@ -55,16 +55,20 @@ const CIE_SOFTWARE = {
} satisfies Record
const AGENT_STEPS = {
+ // Drop the Open Anyway sentence once macOS releases are notarized (docs/esign/TRACKER.md, Code signing).
macos: (
<>
- Download the macOS app, unzip it and open PoliNetwork eSign . It moves itself to your Applications
- folder and starts whenever you log in.
+ Download the macOS .dmg, open it and double-click PoliNetwork eSign . The first time, macOS says
+ it can't verify the app: choose Done, open System Settings › Privacy & Security, choose Open Anyway next to the
+ message about PoliNetwork eSign, and confirm with your password. It then installs itself and starts whenever you
+ log in.
>
),
+ // Drop the Run anyway sentence once Windows releases are signed (docs/esign/TRACKER.md, Code signing).
windows: (
<>
- Download the Windows archive, extract it and double-click esign.exe . It installs itself and
- starts whenever you log in.
+ Download the Windows .zip, extract it and double-click esign.exe . If Windows says “Windows
+ protected your PC”, choose More info, then Run anyway. It then installs itself and starts whenever you log in.
>
),
linux: (
@@ -129,8 +133,9 @@ export function EsignSetupPage() {
- The CIE is contactless: you need a USB NFC reader that works as a standard smart-card reader (PC/SC), such
- as the ones listed by the Ministry of the Interior for the CIE. Phones can't be used here.
+ The CIE is contactless: you need a USB NFC reader that works as a standard smart-card reader (PC/SC). If
+ you don't have one yet, get one sold as compatible with the CIE 3.0 before the next steps. Phones can't be
+ used here.
@@ -149,7 +154,7 @@ export function EsignSetupPage() {
- Alternatively the agent works with the open-source{" "}
+ Alternatively PoliNetwork eSign works with the open-source{" "}
}
>
- Download the agent
+ Download PoliNetwork eSign
- Your PIN is typed in the agent's own window on this computer and goes only to the card. PoliNetwork never
- receives it.
+ Your PIN is typed in the PoliNetwork eSign window on this computer and goes only to the card. PoliNetwork
+ never receives it.
- Open the agent's page: it should say “The agent is running”. Then sign a document from Signatures.
-
+
+ Put your CIE on the reader and open the PoliNetwork eSign status page: it should show that PoliNetwork
+ eSign is running and ready to sign. Then sign a document from Signatures.
+
+ {os === "macos" && (
+
+ When you sign, macOS may ask whether PoliNetwork eSign can access data from other apps: choose Allow, so
+ it can read the card pairing made in Software CIE.
+
+ )}
+
}
>
-
Open the agent
+
Open the status page
+
+
}>
+
Start PoliNetwork eSign
+
Page doesn't open? Start PoliNetwork eSign, then open the status page again.
diff --git a/src/features/esign/sign-with-cie.tsx b/src/features/esign/sign-with-cie.tsx
index f026a6b..99b6e96 100644
--- a/src/features/esign/sign-with-cie.tsx
+++ b/src/features/esign/sign-with-cie.tsx
@@ -1,3 +1,4 @@
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { useRouter } from "@tanstack/react-router"
import { useServerFn } from "@tanstack/react-start"
import {
@@ -14,16 +15,17 @@ import {
} from "lucide-react"
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"
-import { InlineAlert, LoadingButton } from "@/components/primitives"
+import { buttonMotion, dialogMotion, InlineAlert, LoadingButton, scrimClasses } from "@/components/primitives"
import {
dialogDescriptionText,
dialogFooter,
+ dialogFooterButton,
dialogPanel,
dialogTitleText,
} from "@/components/primitives/confirm-dialog"
import { appToast } from "@/components/shell"
import { Button } from "@/components/ui/button"
-import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
+import { Dialog, DialogDescription, DialogOverlay, DialogPortal, DialogTitle } from "@/components/ui/dialog"
import { pluralize } from "@/lib/format"
import { cn } from "@/lib/utils"
@@ -37,51 +39,122 @@ export type SignItem = { requestId: string; title: string; places?: BoxPlacement
type Job = { jobId: string; requestId: string; title: string }
type JobState = { status: string; errorCode: string | null; errorMessage: string | null; seen: boolean }
+/** Nothing was signed and the attempt can't go on; `code` is the job's error code. */
+type Ending = { kind: "ended"; reason: keyof typeof ENDED_TITLES; code: string | null; message: string }
type Phase =
| { kind: "starting" }
| { kind: "waiting"; jobs: Job[]; url: string; since: number }
| { kind: "signing"; jobs: Job[]; url: string }
- | { kind: "ended"; reason: "cancelled" | "expired" | "failed"; message: string }
+ | Ending
+type Shown = Exclude
const POLL_MS = 1200
/** After this long without the agent fetching the job, it is probably not running. */
const AGENT_TIMEOUT_MS = 7000
const OPEN = new Set(["pending", "prepared"])
-/** What the agent reported, by its error code (esign `internal/token`, backend `agent_outdated`). */
+const ENDED_TITLES = {
+ cancelled: "Request cancelled",
+ declined: "Request declined",
+ expired: "The link expired",
+ failed: "Signature failed",
+}
+
+/**
+ * What the agent or the backend reported, by its error code (esign `internal/token`; the backend's `agent_outdated`,
+ * `document_changed` and `job_done`, whose own messages are written for the agent window).
+ */
const AGENT_ERRORS = new Map([
- ["pin_incorrect", "Wrong PIN. Type it again in the agent window; after 3 wrong attempts the card blocks it."],
- ["pin_locked", "The PIN is blocked. Unblock it with the PUK in the CIE ID app, then try again."],
+ ["pin_incorrect", "Wrong PIN. Type it again in the PoliNetwork eSign window."],
+ [
+ "pin_locked",
+ "The PIN is blocked. Unblock it with your PUK in the CieID app on your phone or in CIE ID on this computer, then sign again. The PUK's first half is on the receipt from the Comune, the second half in the letter that came with the card.",
+ ],
["pin_invalid", "The PIN must be the 8 digits of your CIE PIN."],
- ["no_card", "The agent can't see the card. Place the CIE on the reader."],
- ["no_reader", "The agent can't find an NFC reader. Connect it and try again."],
+ ["no_card", "PoliNetwork eSign can't see the card. Place the CIE on the reader."],
+ ["no_reader", "PoliNetwork eSign can't find an NFC reader. Connect it and try again."],
["card_removed", "The card moved away from the reader. Keep it still and try again."],
- ["middleware_missing", "The agent found no CIE software. Open the setup guide."],
- ["not_paired", "This CIE is not set up on this computer yet. Open the setup guide."],
+ ["middleware_missing", "PoliNetwork eSign found no CIE software on this computer."],
+ ["not_paired", "This CIE isn't set up on this computer yet."],
["card_unsupported", "This card isn't a supported CIE."],
["card_untrusted", "The card couldn't be verified as a genuine CIE, so the PIN wasn't sent to it."],
["device_error", "The card couldn't complete the signature. Try again."],
- ["signature_invalid", "The card's signature didn't match. Try again."],
- ["agent_outdated", "This version of PoliNetwork eSign is too old. Install the latest one from the setup guide."],
+ ["signature_invalid", "The card's signature didn't match. Sign again."],
+ [
+ "agent_outdated",
+ "This version of the signing app is too old. Install the latest PoliNetwork eSign from the setup guide.",
+ ],
+ ["document_changed", "Someone else signed in the meantime, so the document changed. Sign it again."],
+ ["job_done", "This request changed while you were signing and no longer needs your signature."],
])
+/** Errors the setup guide resolves; the dialog links it next to them. */
+const SETUP_ERRORS = new Set(["middleware_missing", "not_paired", "card_untrusted", "agent_outdated"])
+
function errorText(state: JobState | undefined) {
if (!state?.errorCode || state.errorCode === "cancelled") return null
return AGENT_ERRORS.get(state.errorCode) ?? state.errorMessage ?? "The signature failed. Try again."
}
-function openPopup(): Window | null {
+/**
+ * How an attempt that signed nothing ended, once none of its jobs is open: someone else cancelled or declined the
+ * request, the link expired, or a job failed for good. Null when every job was cancelled, which is the signer's own
+ * cancel (in the agent window or another tab) and needs no message.
+ */
+function endingOf(statuses: (JobState & { jobId: string })[], jobs: Job[], preparing: boolean): Ending | null {
+ const titled = (status: { jobId: string }, message: string) => {
+ const title = jobs.length > 1 && jobs.find((j) => j.jobId === status.jobId)?.title
+ return title ? `${title}: ${message}` : message
+ }
+ const foreign = statuses.find(
+ (s) => s.status === "cancelled" && (s.errorCode === "request_cancelled" || s.errorCode === "request_declined")
+ )
+ if (foreign)
+ return {
+ kind: "ended",
+ reason: foreign.errorCode === "request_declined" ? "declined" : "cancelled",
+ code: foreign.errorCode,
+ message: titled(foreign, foreign.errorMessage ?? "Nothing was signed."),
+ }
+ if (statuses.some((s) => s.status === "expired"))
+ return {
+ kind: "ended",
+ reason: "expired",
+ code: null,
+ message: preparing
+ ? "Close to open the request and sign it from there."
+ : "The PoliNetwork eSign window waited too long for the PIN. Start again to open a new one.",
+ }
+ const failed = statuses.find((s) => s.status === "failed")
+ if (!failed) return null
+ const known = failed.errorCode && AGENT_ERRORS.get(failed.errorCode)
+ return {
+ kind: "ended",
+ reason: "failed",
+ code: failed.errorCode,
+ message: titled(failed, known || failed.errorMessage || "The signature failed."),
+ }
+}
+
+/** The agent window's size, centered horizontally over the dashboard window. */
+function popupFeatures() {
const width = 460
const height = 680
const left = Math.max(0, window.screenX + (window.outerWidth - width) / 2)
const top = Math.max(0, window.screenY + (window.outerHeight - height) / 3)
+ return `popup=yes,width=${width},height=${height},left=${left},top=${top}`
+}
+
+function openPopup(): Window | null {
// A new name each time: a window still showing the agent's page is cross-origin, and writing to it would throw.
- const name = `pn-esign-${Date.now()}`
- const popup = window.open("", name, `popup=yes,width=${width},height=${height},left=${left},top=${top}`)
+ const popup = window.open("", `pn-esign-${Date.now()}`, popupFeatures())
if (popup) {
popup.document.title = "PoliNetwork eSign"
+ // The agent page's canvas and muted text (sign-agent internal/server/web/assets/style.css), following the OS
+ // scheme as that page does rather than the dashboard's theme, so the page takes over without a change of colour.
+ popup.document.documentElement.style.colorScheme = "light dark"
popup.document.body.style.cssText =
- "font:15px system-ui,sans-serif;color:#58626c;display:grid;place-items:center;height:90vh;margin:0"
+ "font:15px system-ui,sans-serif;background:#f6f7f9;background:light-dark(#f6f7f9,#060e16);color:#58626c;color:light-dark(#58626c,#9da6ae);display:grid;place-items:center;height:90vh;margin:0"
popup.document.body.textContent = "Opening PoliNetwork eSign…"
}
return popup
@@ -122,9 +195,15 @@ export function SignWithCie({
const cancelFn = useServerFn(cancelEsignSignature)
const statusFn = useServerFn(getEsignJobStatus)
const [phase, setPhase] = useState(null)
+ // The last phase the dialog showed: its content stays while the dialog animates out.
+ const [shown, setShown] = useState(null)
const [states, setStates] = useState>(new Map())
const [now, setNow] = useState(Date.now())
const [error, setError] = useState(null)
+ const [windowClosed, setWindowClosed] = useState(false)
+ const [agentLost, setAgentLost] = useState(false)
+ // An ended attempt's Close is reloading the page before the dialog closes; stays set while it fades out.
+ const [closing, setClosing] = useState(false)
const popupRef = useRef(null)
const finishing = useRef(false)
@@ -150,34 +229,42 @@ export function SignWithCie({
[reload, onSigned]
)
+ /** Closes the dialog after the signer's own cancel: no message, no reload, nothing left to cancel. */
+ const dismiss = useCallback(() => {
+ if (finishing.current) return
+ finishing.current = true
+ setPhase(null)
+ popupRef.current?.close()
+ popupRef.current = null
+ onStopped?.(null)
+ }, [onStopped])
+
const jobs = phase && "jobs" in phase ? phase.jobs : null
- const batch = (jobs ?? items ?? []).length > 1
+ const preparing = prepare !== undefined
useEffect(() => {
if (!jobs) return
let stopped = false
const poll = async () => {
+ // Readable only while the dashboard keeps its opener relationship with the window: the agent sends no COOP
+ // header, and the dashboard must not send `Cross-Origin-Opener-Policy: same-origin`.
+ const closedBefore = popupRef.current?.closed === true
try {
const statuses = await statusFn({ data: { jobIds: jobs.map((j) => j.jobId) } })
if (stopped) return
setStates(new Map(statuses.map((s) => [s.jobId, s])))
- const done = statuses.filter((s) => s.status === "completed").length
- const ended = statuses.find((s) => s.status !== "completed" && !OPEN.has(s.status))
if (statuses.every((s) => !OPEN.has(s.status))) {
- if (done > 0 || !ended) return void finish(done, jobs.length)
- const reason = ended.status === "expired" ? "expired" : ended.status === "cancelled" ? "cancelled" : "failed"
- setPhase({
- kind: "ended",
- reason,
- message:
- reason === "expired"
- ? "The signing link expired. Start again."
- : reason === "cancelled"
- ? "The signature was cancelled."
- : (ended.errorMessage ?? "The signature failed."),
- })
- return
+ const done = statuses.filter((s) => s.status === "completed").length
+ if (done > 0) return void finish(done, jobs.length)
+ const ending = endingOf(statuses, jobs, preparing)
+ if (!ending) return dismiss()
+ // Someone else ended the request: the agent window would keep asking for a PIN that can sign nothing.
+ if (ending.reason === "cancelled" || ending.reason === "declined") popupRef.current?.close()
+ return setPhase(ending)
}
+ // Closed before and after the request, so a window opened again meanwhile counts as open. The agent closes
+ // its window on cancel or success only after the backend recorded it, so those end above.
+ setWindowClosed(closedBefore && popupRef.current?.closed === true)
if (statuses.some((s) => s.seen))
setPhase((current) => (current?.kind === "waiting" ? { kind: "signing", jobs, url: current.url } : current))
} catch (cause) {
@@ -189,6 +276,8 @@ export function SignWithCie({
function onMessage(event: MessageEvent) {
// Agents up to v0.1.x, named PoliNetwork Sign Agent, post "pn-sign-agent".
if (!isAgentOrigin(event.origin) || !["pn-esign", "pn-sign-agent"].includes(event.data?.type)) return
+ // The page lost the agent behind it: the one outcome the backend can't know about.
+ setAgentLost(event.data?.status === "error" && event.data?.detail === "agent_unreachable")
void poll()
}
window.addEventListener("message", onMessage)
@@ -198,13 +287,18 @@ export function SignWithCie({
window.clearInterval(tick)
window.removeEventListener("message", onMessage)
}
- }, [jobs, statusFn, finish])
+ }, [jobs, preparing, statusFn, finish, dismiss])
async function start() {
+ // The dialog is on its way out: its Close is reloading the page.
+ if (finishing.current && phase) return
const prepared = prepare ? prepare() : Promise.resolve(items ?? [])
if (!prepared) return
setError(null)
setStates(new Map())
+ setWindowClosed(false)
+ setAgentLost(false)
+ setClosing(false)
finishing.current = false
popupRef.current?.close()
let popup: Window | null = null
@@ -245,11 +339,20 @@ export function SignWithCie({
}
}
- /** Closes the dialog, cancelling what isn't signed yet; reloads when part of a batch was signed. */
+ /** Closes the dialog, cancelling what isn't signed yet; reloads when part of a batch was signed or it ended. */
async function close() {
+ if (finishing.current) return
+ finishing.current = true
const current = phase
- setPhase(null)
popupRef.current?.close()
+ if (current?.kind === "ended") {
+ // Reloaded while the dialog is still open, as finish() does: the request may have been cancelled meanwhile.
+ setClosing(true)
+ await reload()
+ setPhase(null)
+ return onStopped?.(null)
+ }
+ setPhase(null)
if (!current || !("jobs" in current)) return onStopped?.(null)
const open = current.jobs.filter((j) => OPEN.has(states.get(j.jobId)?.status ?? "pending"))
if (open.length > 0) {
@@ -263,17 +366,54 @@ export function SignWithCie({
onStopped?.(null)
}
- function reopen(url: string) {
- popupRef.current = window.open(url, "pn-esign", "popup=yes,width=460,height=680")
+ /** Brings the agent window to the front without reloading it (the PIN may be half typed), or opens it again. */
+ function showWindow(url: string) {
+ const popup = popupRef.current
+ if (popup && !popup.closed) popup.focus()
+ else popupRef.current = window.open(url, `pn-esign-${Date.now()}`, popupFeatures())
+ setWindowClosed(false)
+ }
+
+ /** Loads the signing page again in the agent window, opening a new one if it was closed. */
+ function openWindowAgain(url: string) {
+ const popup = popupRef.current
+ if (popup && !popup.closed) popup.location.href = url
+ showWindow(url)
+ setAgentLost(false)
}
- const silent = phase?.kind === "waiting" && now - phase.since > AGENT_TIMEOUT_MS
- const signed = jobs?.filter((j) => states.get(j.jobId)?.status === "completed").length ?? 0
+ if (phase && phase.kind !== "starting" && phase !== shown) setShown(phase)
+ // An expired link can start again from the dialog; a prepared document is signed from its request page instead.
+ const restartable = shown?.kind === "ended" && shown.reason === "expired" && !preparing
+ const active = shown && shown.kind !== "ended" ? shown : null
+ const batch = (active?.jobs ?? items ?? []).length > 1
+ const silent = active?.kind === "waiting" && now - active.since > AGENT_TIMEOUT_MS
+ const notAnswering = active !== null && (silent || agentLost)
+ const signed = active?.jobs.filter((j) => states.get(j.jobId)?.status === "completed").length ?? 0
+ const openJobs = active?.jobs.filter((j) => OPEN.has(states.get(j.jobId)?.status ?? "pending")) ?? []
+ // Every document is signed or ended: the poll is finishing and the page reloads.
+ const settled = active !== null && signed > 0 && openJobs.length === 0
// A failure every open document shares (a wrong PIN, no card) is shown once, above the list.
- const open = jobs?.filter((j) => OPEN.has(states.get(j.jobId)?.status ?? "pending")) ?? []
- const firstCode = open[0] && states.get(open[0].jobId)?.errorCode
- const sharedCode = firstCode && open.every((j) => states.get(j.jobId)?.errorCode === firstCode) ? firstCode : null
- const sharedError = sharedCode && open[0] ? errorText(states.get(open[0].jobId)) : null
+ const firstCode = openJobs[0] && states.get(openJobs[0].jobId)?.errorCode
+ const sharedCode = firstCode && openJobs.every((j) => states.get(j.jobId)?.errorCode === firstCode) ? firstCode : null
+ const sharedError = sharedCode && openJobs[0] ? errorText(states.get(openJobs[0].jobId)) : null
+ const needsSetup = sharedCode !== null && SETUP_ERRORS.has(sharedCode)
+ const total = active?.jobs.length ?? 0
+ const jobList = active && (
+
+ {active.jobs.map((job) => (
+
+ ))}
+
+ )
+ const progress = batch && (
+ <>
+
+ {signed} of {pluralize(total, "document")} signed
+
+ {jobList}
+ >
+ )
return (
<>
@@ -287,110 +427,175 @@ export function SignWithCie({
{children ?? (items && items.length > 1 ? `Sign ${items.length} documents` : "Sign with CIE")}
{error && {error} }
- !open && void close()}>
-
- {phase?.kind === "waiting" && !silent && (
- } title="Opening PoliNetwork eSign">
- A window from PoliNetwork eSign opens on this computer. If it doesn't, open it again.
-
- )}
- {phase?.kind === "waiting" && silent && (
- }
- tone="warning"
- title="PoliNetwork eSign isn't answering"
- >
-
- Start the agent, then open the signing window again. First time here? Install it from the setup guide.
-
-
-
}>
-
Start the agent
-
-
reopen(phase.url)}>
- Open the window again
-
-
}
+
!open && void close()}
+ >
+
+
+
+ {shown?.kind === "ended" ? (
+ }
+ tone={shown.reason === "failed" ? "danger" : "neutral"}
+ title={ENDED_TITLES[shown.reason]}
+ >
+ {shown.message}
+ {shown.code && SETUP_ERRORS.has(shown.code) && (
+
+
+
+ )}
+
+ ) : settled ? (
+ : }
+ tone={signed === total ? "success" : "warning"}
+ title={
+ signed < total
+ ? `${signed} of ${pluralize(total, "document")} signed`
+ : total === 1
+ ? "Document signed"
+ : `${pluralize(total, "document")} signed`
+ }
+ >
+ {batch ? jobList : "Updating the page…"}
+
+ ) : notAnswering ? (
+
+ {silent
+ ? "Start PoliNetwork eSign, then open its window again. First time here? Install it from the setup guide."
+ : "Start PoliNetwork eSign, then open its window again."}
+
+ ) : active && windowClosed ? (
+ }
+ tone="warning"
+ title="The PoliNetwork eSign window was closed"
+ >
+
+ {signed === 0
+ ? "Nothing was signed. Open the window again to type your PIN, or cancel."
+ : `${signed} of ${pluralize(total, "document")} are signed. Open the window again to sign the others, or stop here.`}
+
+ {sharedError && {sharedError} }
+ {batch && jobList}
+ {needsSetup && (
+
+
+
+ )}
+
+ ) : active?.kind === "waiting" ? (
+ } title="Opening PoliNetwork eSign">
+ A PoliNetwork eSign window opens on this computer.
+
+ ) : active ? (
+ } title="Type your PIN in PoliNetwork eSign">
+
+ Keep the CIE on the reader. {batch ? `One PIN signs all ${total} documents. ` : ""}
+ The PIN goes only to the card, never to PoliNetwork.
+
+ {sharedError && {sharedError} }
+ {progress}
+
+ showWindow(active.url)}>
+ Show the PoliNetwork eSign window
+
+ {needsSetup && }
+
+
+ ) : null}
+ {shown?.kind === "ended" ? (
+
+ void close()}
>
- Setup guide
-
+ Close
+
+ {restartable && (
+
+ Start again
+
+ )}
-
- )}
- {phase?.kind === "signing" && (
- } title="Type your PIN in the agent window">
-
- Keep the CIE on the reader. {batch ? `One PIN signs all ${phase.jobs.length} documents. ` : ""}
- The PIN goes only to the card, never to PoliNetwork.
-
- {sharedError && {sharedError} }
- {batch && (
- <>
-
- {signed} of {pluralize(phase.jobs.length, "document")} signed
-
-
- {phase.jobs.map((job) => (
-
- ))}
-
- >
- )}
- reopen(phase.url)}>
- Show the agent window
-
-
- )}
- {phase?.kind === "ended" && (
- :
- }
- tone={phase.reason === "failed" ? "danger" : "neutral"}
- title={
- phase.reason === "expired"
- ? "The link expired"
- : phase.reason === "cancelled"
- ? "Signature cancelled"
- : "Signature failed"
- }
- >
- {phase.message}
-
- )}
-
- {phase?.kind === "ended" ? (
-
void close()}>
- Close
-
- ) : signed > 0 ? (
-
void close()}>
- Stop here
-
) : (
-
void close()}>
- Cancel signature
-
+ !settled && (
+
+ void close()}>
+ {signed > 0 ? "Stop here" : "Cancel signature"}
+
+ {active && (notAnswering || windowClosed) && (
+ (notAnswering ? openWindowAgain(active.url) : showWindow(active.url))}
+ >
+ Open the window again
+
+ )}
+
+ )
)}
-
-
+
+
>
)
}
+/**
+ * PoliNetwork eSign doesn't answer: start it (or set it up) here, then open its window again, the footer's primary
+ * action, so the buttons read in the order of the steps.
+ */
+function NotAnswering({ title, children }: { title: string; children: string }) {
+ return (
+
} tone="warning" title={title}>
+
{children}
+
+
}
+ >
+
Start PoliNetwork eSign
+
+
+
+
+ )
+}
+
+function SetupGuideButton() {
+ return (
+
}
+ >
+
Setup guide
+
+ )
+}
+
function JobRow({ job, state, hideError }: { job: Job; state: JobState | undefined; hideError: boolean }) {
const done = state?.status === "completed"
const failure = done || hideError ? null : errorText(state)
+ const notSigned = failure !== null || (state !== undefined && !done && !OPEN.has(state.status))
return (
{done ? (
- ) : failure ? (
+ ) : notSigned ? (
) : (
@@ -411,15 +616,16 @@ function Status({
}: {
icon: ReactNode
title: string
- tone?: "brand" | "warning" | "danger" | "neutral"
+ tone?: "brand" | "success" | "warning" | "danger" | "neutral"
children: ReactNode
}) {
return (
-
+
{
+ if (!active) return
+ let busy = false
+ const timer = window.setInterval(() => {
+ if (busy || document.visibilityState === "hidden") return
+ busy = true
+ router
+ .invalidate()
+ .catch((error) => console.error(error))
+ .finally(() => {
+ busy = false
+ })
+ }, ms)
+ return () => window.clearInterval(timer)
+ }, [active, ms, router])
+}
diff --git a/src/routes/dashboard/esign/inbox.tsx b/src/routes/dashboard/esign/inbox.tsx
index 9186f21..71db368 100644
--- a/src/routes/dashboard/esign/inbox.tsx
+++ b/src/routes/dashboard/esign/inbox.tsx
@@ -15,7 +15,7 @@ export const Route = createFileRoute("/dashboard/esign/inbox")({
<>
-
+
>
),
diff --git a/src/routes/dashboard/esign/requests/index.tsx b/src/routes/dashboard/esign/requests/index.tsx
index 555f314..359dc54 100644
--- a/src/routes/dashboard/esign/requests/index.tsx
+++ b/src/routes/dashboard/esign/requests/index.tsx
@@ -4,14 +4,19 @@ import { z } from "zod"
import { TableSkeleton } from "@/components/primitives"
import { PageBar, PageContent } from "@/components/shell"
import { getEsignHome } from "@/features/esign/esign.functions"
+import { parseStatusFilter } from "@/features/esign/request-status"
import { EsignRequestsPage } from "@/features/esign/requests-page"
import { hasPermission } from "@/server/permissions"
export const Route = createFileRoute("/dashboard/esign/requests/")({
- validateSearch: z.object({ scope: z.enum(["mine", "all"]).optional() }),
+ validateSearch: z.object({
+ scope: z.enum(["mine", "all"]).optional(),
+ status: z.string().optional().transform(parseStatusFilter),
+ }),
beforeLoad: ({ context }) => {
if (!hasPermission(context.permissions, "esign:sign")) throw redirect({ to: "/unauthorized" })
},
+ // `status` filters in the page, so changing it doesn't reload.
loaderDeps: ({ search }) => ({ scope: search.scope ?? "mine" }),
// Without the manage permission everyone only sees their own requests.
loader: async ({ deps, context }) => {
@@ -32,5 +37,19 @@ export const Route = createFileRoute("/dashboard/esign/requests/")({
function RequestsRoute() {
const { requests, scope } = Route.useLoaderData()
- return
+ const { status } = Route.useSearch()
+ const navigate = Route.useNavigate()
+ return (
+
+ void navigate({
+ search: (previous) => ({ ...previous, status: next === "all" ? undefined : next }),
+ replace: true,
+ })
+ }
+ />
+ )
}
diff --git a/src/routes/dashboard/index.tsx b/src/routes/dashboard/index.tsx
index ac02513..79f342d 100644
--- a/src/routes/dashboard/index.tsx
+++ b/src/routes/dashboard/index.tsx
@@ -1,10 +1,12 @@
-import { createFileRoute } from "@tanstack/react-router"
+import { createFileRoute, getRouteApi } from "@tanstack/react-router"
import { CardsSkeleton } from "@/components/primitives"
import { PageBar, PageContent } from "@/components/shell"
import { DashboardOverviewPage } from "@/features/dashboard/overview-page"
import { getOverviewCounts, getTelegramUserHistory } from "@/features/dashboard/overview.functions"
+const dashboardRoute = getRouteApi("/dashboard")
+
export const Route = createFileRoute("/dashboard/")({
// Both start at once; the history is not awaited, so the hero renders with the counts and its chart streams in.
loader: async () => {
@@ -28,5 +30,6 @@ export const Route = createFileRoute("/dashboard/")({
function OverviewRoute() {
const { counts, userHistory } = Route.useLoaderData()
- return
+ const pending = dashboardRoute.useLoaderData({ select: (data) => data.pending })
+ return
}
diff --git a/tests/esign-mutation-refresh.test.mjs b/tests/esign-mutation-refresh.test.mjs
index a8d0a9d..4d9583b 100644
--- a/tests/esign-mutation-refresh.test.mjs
+++ b/tests/esign-mutation-refresh.test.mjs
@@ -19,7 +19,7 @@ async function handler(path, name, dependencies) {
}
visit(source)
assert.ok(found, `${name} not found in ${path}`)
- const compiled = ts.transpileModule(`const handler = ${found.getText()}`, {
+ const compiled = ts.transpileModule(`const handler = ${found.getText().replace(/^export /, "")}`, {
compilerOptions: { target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.None },
}).outputText
return runInNewContext(`${compiled}; handler`, { ...dependencies })
@@ -70,10 +70,15 @@ async function composer(env, overrides = {}) {
created: { current: null },
creating: { current: false },
setAttempted: env.setter("attempted"),
+ setShown: env.setter("shown"),
+ setBlockedSubmits: (update) => {
+ env.state.blockedSubmits = update(env.state.blockedSubmits ?? 0)
+ },
setSubmitting: env.setter("submitting"),
setSubmitError: env.setter("error"),
setCreatedRequest: env.setter("createdRequest"),
- problems: [],
+ problems: false,
+ errors: {},
staged: { id: "document-1" },
title: "Request",
ordered: true,
@@ -91,6 +96,7 @@ async function composer(env, overrides = {}) {
...overrides,
}
dependencies.create = await handler(path, "create", dependencies)
+ dependencies.showProblems = await handler(path, "showProblems", dependencies)
dependencies.openRequest = await handler(path, "openRequest", dependencies)
return {
submit: await handler(path, "submit", dependencies),
@@ -112,6 +118,18 @@ void test("creation waits for loader refresh before navigation and notification"
assert.deepEqual(env.events, ["create", "reload", ["navigate", "request-1"], ["success", "Request created."]])
})
+void test("a blocked submit shows every problem without creating or reloading", async () => {
+ const env = environment()
+ const errors = { title: "Give the request a title.", fileName: undefined, email: "“x” isn't an email address." }
+ const { submit, createAndSign } = await composer(env, { problems: true, errors })
+ await submit()
+ assert.equal(createAndSign(), null)
+ assert.deepEqual(env.events, [])
+ assert.equal(env.state.attempted, true)
+ assert.deepEqual({ ...env.state.shown }, errors)
+ assert.equal(env.state.blockedSubmits, 2)
+})
+
void test("a failed create stays retryable without navigation or a success notification", async () => {
const env = environment()
const { submit } = await composer(env, {
@@ -259,6 +277,171 @@ for (const refreshFails of [false, true]) {
})
}
+async function agentWindow(popup) {
+ const events = []
+ const dependencies = {
+ Date,
+ popupRef: { current: popup },
+ popupFeatures: () => "popup=yes",
+ window: {
+ open: (url, name) => {
+ events.push(["open", url, name])
+ return { closed: false }
+ },
+ },
+ setWindowClosed: (closed) => events.push(["closed", closed]),
+ setAgentLost: (lost) => events.push(["lost", lost]),
+ }
+ const path = "src/features/esign/sign-with-cie.tsx"
+ dependencies.showWindow = await handler(path, "showWindow", dependencies)
+ dependencies.openWindowAgain = await handler(path, "openWindowAgain", dependencies)
+ return { events, ...dependencies }
+}
+
+void test("showing the open agent window focuses it without reloading or duplicating it", async () => {
+ const popup = { closed: false, location: { href: "pin-form" }, focus: () => (popup.focused = true) }
+ const { events, showWindow } = await agentWindow(popup)
+ showWindow("sign-url")
+ assert.equal(popup.focused, true)
+ assert.equal(popup.location.href, "pin-form")
+ assert.deepEqual(events, [["closed", false]])
+})
+
+void test("a closed agent window opens again under a new name", async () => {
+ const { events, popupRef, showWindow } = await agentWindow({ closed: true })
+ showWindow("sign-url")
+ assert.equal(events[0][0], "open")
+ assert.equal(events[0][1], "sign-url")
+ assert.match(events[0][2], /^pn-esign-\d+$/)
+ assert.equal(popupRef.current.closed, false)
+})
+
+void test("opening the window again reloads the signing page in the open agent window", async () => {
+ const popup = { closed: false, location: { href: "error-page" }, focus: () => (popup.focused = true) }
+ const { events, openWindowAgain } = await agentWindow(popup)
+ openWindowAgain("sign-url")
+ assert.equal(popup.location.href, "sign-url")
+ assert.equal(popup.focused, true)
+ assert.deepEqual(events, [
+ ["closed", false],
+ ["lost", false],
+ ])
+})
+
+void test("the signer's own cancel closes the dialog once, without a reload", async () => {
+ const env = environment()
+ const popup = { close: () => env.events.push("popup closed") }
+ const dismiss = await handler("src/features/esign/sign-with-cie.tsx", "dismiss", {
+ ...env.dependencies,
+ finishing: { current: false },
+ setPhase: env.setter("phase"),
+ popupRef: { current: popup },
+ onStopped: (error) => env.events.push(["stopped", error]),
+ })
+ dismiss()
+ dismiss()
+ assert.equal(env.state.phase, null)
+ assert.deepEqual(env.events, ["popup closed", ["stopped", null]])
+})
+
+for (const refreshFails of [false, true]) {
+ void test(`closing an ended attempt shows it is busy until the refresh settles, failure=${refreshFails}`, async () => {
+ const env = environment()
+ const dependencies = {
+ ...env.dependencies,
+ finishing: { current: false },
+ phase: { kind: "ended", reason: "expired", code: null, message: "The link expired." },
+ states: new Map(),
+ setPhase: env.setter("phase"),
+ setClosing: env.setter("closing"),
+ popupRef: { current: { close: () => env.events.push("popup closed") } },
+ cancelFn: async () => env.events.push("cancel"),
+ onStopped: (error) => env.events.push(["stopped", error]),
+ }
+ dependencies.reload = await handler("src/features/esign/sign-with-cie.tsx", "reload", dependencies)
+ const close = await handler("src/features/esign/sign-with-cie.tsx", "close", dependencies)
+ const pending = close()
+ assert.equal(env.state.closing, true)
+ assert.equal(env.state.phase, undefined)
+ assert.deepEqual(env.events, ["popup closed", "reload"])
+ await close()
+ assert.deepEqual(env.events, ["popup closed", "reload"])
+ if (refreshFails) env.reload.reject(new Error("Refresh failed"))
+ else env.reload.resolve()
+ await pending
+ assert.equal(env.state.phase, null)
+ // Still busy while the dialog fades out, so the button doesn't flash back to idle.
+ assert.equal(env.state.closing, true)
+ assert.deepEqual(env.events.at(-1), ["stopped", null])
+ assert.ok(!env.events.includes("cancel"))
+ })
+}
+
+async function endingOf() {
+ const path = "src/features/esign/sign-with-cie.tsx"
+ const AGENT_ERRORS = await handler(path, "AGENT_ERRORS", {})
+ return handler(path, "endingOf", { AGENT_ERRORS })
+}
+
+function jobStatus(jobId, status, errorCode = null, errorMessage = null) {
+ return { jobId, status, errorCode, errorMessage, seen: true }
+}
+
+void test("an attempt ends quietly only when the signer cancelled every job", async () => {
+ const ending = await endingOf()
+ const jobs = [{ jobId: "a", requestId: "r", title: "Verbale" }]
+ assert.equal(ending([jobStatus("a", "cancelled", "pin_incorrect", "Wrong PIN.")], jobs, false), null)
+ const declined = ending(
+ [jobStatus("a", "cancelled", "request_declined", "Mario Rossi declined this request.")],
+ jobs,
+ false
+ )
+ assert.equal(declined.reason, "declined")
+ assert.equal(declined.message, "Mario Rossi declined this request.")
+ const failed = ending([jobStatus("a", "failed", "pin_locked", "PIN blocked")], jobs, false)
+ assert.equal(failed.reason, "failed")
+ assert.equal(failed.code, "pin_locked")
+ assert.match(failed.message, /^The PIN is blocked\./)
+ // A code reserved for cancels and declines counts only on a cancelled job, as in the backend.
+ const reserved = ending([jobStatus("a", "failed", "request_cancelled", "Cancelled.")], jobs, false)
+ assert.equal(reserved.reason, "failed")
+})
+
+void test("an attempt the backend ended is worded for the dashboard, not the agent window", async () => {
+ const ending = await endingOf()
+ const jobs = [{ jobId: "a", requestId: "r", title: "Verbale" }]
+ for (const [code, message] of [
+ ["document_changed", "Someone else signed in the meantime. Start again from the dashboard."],
+ ["job_done", "This request no longer needs your signature"],
+ ]) {
+ const failed = ending([jobStatus("a", "failed", code, message)], jobs, false)
+ assert.equal(failed.reason, "failed")
+ assert.notEqual(failed.message, message)
+ assert.doesNotMatch(failed.message, /dashboard/)
+ }
+})
+
+void test("an ended batch names the document, and an expired link points where to sign again", async () => {
+ const ending = await endingOf()
+ const jobs = [
+ { jobId: "a", requestId: "r1", title: "Verbale" },
+ { jobId: "b", requestId: "r2", title: "Contratto" },
+ ]
+ const cancelled = ending(
+ [
+ jobStatus("a", "cancelled"),
+ jobStatus("b", "cancelled", "request_cancelled", "Anna Bianchi cancelled this request."),
+ ],
+ jobs,
+ false
+ )
+ assert.equal(cancelled.reason, "cancelled")
+ assert.equal(cancelled.message, "Contratto: Anna Bianchi cancelled this request.")
+ const statuses = [jobStatus("a", "expired"), jobStatus("b", "cancelled")]
+ assert.match(ending(statuses, jobs, false).message, /Start again/)
+ assert.match(ending(statuses, jobs, true).message, /^Close to open the request/)
+})
+
for (const outcome of ["delivered", "failed", "throws"]) {
void test(`delivery retry waits for refresh before reporting ${outcome}`, async () => {
const env = environment()
@@ -286,3 +469,52 @@ for (const outcome of ["delivered", "failed", "throws"]) {
)
})
}
+
+void test("background reloads run one at a time, only while active and the tab is visible", async () => {
+ const timers = new Map()
+ const reloads = []
+ const logged = []
+ let cleanup
+ const dependencies = {
+ useRouter: () => ({
+ invalidate: () => {
+ const reload = deferred()
+ reloads.push(reload)
+ return reload.promise
+ },
+ }),
+ useEffect: (effect) => {
+ cleanup = effect()
+ },
+ window: {
+ setInterval: (tick, ms) => timers.set(timers.size + 1, { tick, ms }).size,
+ clearInterval: (id) => timers.delete(id),
+ },
+ document: { visibilityState: "visible" },
+ console: { error: (error) => logged.push(error.message) },
+ }
+ const useReloadWhile = await handler("src/features/esign/use-reload-while.ts", "useReloadWhile", dependencies)
+
+ useReloadWhile(false, 3000)
+ assert.equal(timers.size, 0)
+
+ useReloadWhile(true, 3000)
+ const [timer] = timers.values()
+ assert.equal(timer.ms, 3000)
+ timer.tick()
+ timer.tick()
+ assert.equal(reloads.length, 1)
+ reloads[0].reject(new Error("Reload failed"))
+ await setImmediate()
+ assert.deepEqual(logged, ["Reload failed"])
+
+ dependencies.document.visibilityState = "hidden"
+ timer.tick()
+ assert.equal(reloads.length, 1)
+ dependencies.document.visibilityState = "visible"
+ timer.tick()
+ assert.equal(reloads.length, 2)
+
+ cleanup()
+ assert.equal(timers.size, 0)
+})
diff --git a/tests/esign-places.test.mjs b/tests/esign-places.test.mjs
new file mode 100644
index 0000000..4041f30
--- /dev/null
+++ b/tests/esign-places.test.mjs
@@ -0,0 +1,30 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+
+import { placeAt } from "../src/features/esign/places.ts"
+
+const A4 = { width: 595, height: 842 }
+const A4_LANDSCAPE = { width: 842, height: 595 }
+
+void test("a moved place is centred on the click, on the clicked page, with its kind", () => {
+ const mark = { kind: "mark", page: 1, x: 0.6, y: 0.8, width: 0.3, height: 0.1 }
+ const moved = placeAt(mark, [A4, A4], 0, 0.5, 0.4)
+ assert.equal(moved.kind, "mark")
+ assert.equal(moved.page, 0)
+ assert.ok(Math.abs(moved.width - 0.3) < 1e-9 && Math.abs(moved.height - 0.1) < 1e-9)
+ assert.ok(Math.abs(moved.x - 0.35) < 1e-9 && Math.abs(moved.y - 0.35) < 1e-9)
+})
+
+void test("a place moved to a page of another size keeps its size in points", () => {
+ const place = { kind: "signature", page: 0, x: 0.1, y: 0.1, width: 0.3, height: 0.08 }
+ const moved = placeAt(place, [A4, A4_LANDSCAPE], 1, 0.5, 0.5)
+ assert.ok(Math.abs(moved.width * A4_LANDSCAPE.width - 0.3 * A4.width) < 1e-9)
+ assert.ok(Math.abs(moved.height * A4_LANDSCAPE.height - 0.08 * A4.height) < 1e-9)
+})
+
+void test("a click near the edge keeps the whole place on the page", () => {
+ const place = { kind: "signature", page: 0, x: 0.1, y: 0.1, width: 0.3, height: 0.08 }
+ const corner = placeAt(place, [A4], 0, 0.99, 0.01)
+ assert.ok(Math.abs(corner.x - 0.7) < 1e-9)
+ assert.equal(corner.y, 0)
+})