From e32e50693b1ddf383068347607f09e4d471c4ddf Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:24:06 +0200 Subject: [PATCH 01/37] fix(esign): walk first-time installers past Gatekeeper and SmartScreen The unsigned agent is always blocked on a new Mac or Windows laptop, and the setup page didn't say so. It now names the .dmg and .zip, the Open Anyway and Run anyway steps, and how to check and start PoliNetwork eSign. --- docs/esign/TRACKER.md | 6 ++-- src/features/esign/setup-page.tsx | 48 +++++++++++++++++++++---------- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/docs/esign/TRACKER.md b/docs/esign/TRACKER.md index 71a78e6..bad6d3b 100644 --- a/docs/esign/TRACKER.md +++ b/docs/esign/TRACKER.md @@ -148,8 +148,10 @@ Legend: ☐ todo · ◐ in progress · ☑ done - ☐ Code signing (pipeline ready, esign PR #4; steps in esign `docs/code-signing.md`): - macOS: upload `~/dev/polinetwork/.signing/esign/developer-id.csr` as a Developer ID Application certificate on the PoliNetwork APS Apple account (team 842636PS9J), create an App Store Connect API key, run - `scripts/setup-macos-signing.sh secrets …`, tag a release. - - Windows: apply to SignPath Foundation (free for open source), then set the SIGNPATH_* variables and token. + `scripts/setup-macos-signing.sh secrets …`, tag a release, then drop the Open Anyway sentence from + AGENT_STEPS.macos in src/features/esign/setup-page.tsx. + - Windows: apply to SignPath Foundation (free for open source), then set the SIGNPATH_* variables and token, then + drop the Run anyway sentence from AGENT_STEPS.windows. - ☑ Backend released as @polinetwork/backend 1.7.0 (backend#73 merged); admin uses it (vendored copy removed). ## Local end-to-end diff --git a/src/features/esign/setup-page.tsx b/src/features/esign/setup-page.tsx index dc0a9e0..84a16d7 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 + “PoliNetwork eSign” was blocked, 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 say it's 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. +

+ )} +
+
+

Page doesn't open? Start PoliNetwork eSign, then open the status page again.

From e8037f1f9fb0a606e4921a6ad68a0bbc9cc9bb31 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:25:38 +0200 Subject: [PATCH 02/37] fix(esign): keep the signing dialog open on a click outside it (E5) Clicking the dashboard behind the agent window used to land on the scrim, cancel the signature and close the PIN window. Only the footer buttons and Escape end it now. --- src/features/esign/sign-with-cie.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/features/esign/sign-with-cie.tsx b/src/features/esign/sign-with-cie.tsx index f026a6b..9ce87b0 100644 --- a/src/features/esign/sign-with-cie.tsx +++ b/src/features/esign/sign-with-cie.tsx @@ -287,7 +287,11 @@ export function SignWithCie({ {children ?? (items && items.length > 1 ? `Sign ${items.length} documents` : "Sign with CIE")} {error && {error}} - !open && void close()}> + !open && void close()} + > {phase?.kind === "waiting" && !silent && ( } title="Opening PoliNetwork eSign"> From 681a07c6f021d84be82e0c809832e127d65d82d8 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:25:57 +0200 Subject: [PATCH 03/37] fix(esign): name PoliNetwork eSign in the signing dialog copy (E32) Nothing on a signer's computer is called "the agent", and the dashboard no longer points to a missing button or repeats the PIN attempt count the eSign window shows. --- src/features/esign/sign-with-cie.tsx | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/features/esign/sign-with-cie.tsx b/src/features/esign/sign-with-cie.tsx index 9ce87b0..94c2390 100644 --- a/src/features/esign/sign-with-cie.tsx +++ b/src/features/esign/sign-with-cie.tsx @@ -50,19 +50,22 @@ const OPEN = new Set(["pending", "prepared"]) /** What the agent reported, by its error code (esign `internal/token`, backend `agent_outdated`). */ 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_incorrect", "Wrong PIN. Type it again in the PoliNetwork eSign window."], ["pin_locked", "The PIN is blocked. Unblock it with the PUK in the CIE ID app, then try again."], ["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."], + ["middleware_missing", "PoliNetwork eSign found no CIE software. Open the setup guide."], ["not_paired", "This CIE is not set up on this computer yet. Open the setup guide."], ["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."], + [ + "agent_outdated", + "This version of the signing app is too old. Install the latest PoliNetwork eSign from the setup guide.", + ], ]) function errorText(state: JobState | undefined) { @@ -295,7 +298,7 @@ export function SignWithCie({ {phase?.kind === "waiting" && !silent && ( } title="Opening PoliNetwork eSign"> - A window from PoliNetwork eSign opens on this computer. If it doesn't, open it again. + A PoliNetwork eSign window opens on this computer. )} {phase?.kind === "waiting" && silent && ( @@ -305,11 +308,11 @@ export function SignWithCie({ 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 PoliNetwork eSign, then open its window again. First time here? Install it from the setup guide.

)} From 506967f3d1e6d2dd3b8a3f485193254c38794895 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:26:35 +0200 Subject: [PATCH 04/37] feat(esign): point first-time signers to the setup guide A signer who has never signed on this computer learns about the one-time setup before pressing Sign, not after it fails. The link opens a new tab so moved boxes and batch review progress survive. --- src/features/esign/batch-page.tsx | 15 ++++++++++++++- src/features/esign/request-page.tsx | 16 ++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/features/esign/batch-page.tsx b/src/features/esign/batch-page.tsx index a9771db..52e85bb 100644 --- a/src/features/esign/batch-page.tsx +++ b/src/features/esign/batch-page.tsx @@ -208,7 +208,20 @@ 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 + + .

{active.signingNow && active.signingNow.sub !== me && ( diff --git a/src/features/esign/request-page.tsx b/src/features/esign/request-page.tsx index 7193942..8626593 100644 --- a/src/features/esign/request-page.tsx +++ b/src/features/esign/request-page.tsx @@ -314,6 +314,22 @@ export function EsignRequestPage({ detail }: { detail: Detail }) { ? `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."}{" "} 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 && (
From 3d970a4154504f1cc7d3f5963c57e7a86a384e8e Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:27:45 +0200 Subject: [PATCH 05/37] fix(esign): keep the role box under its label until the user moves it A role typed before the PDF or its labels loaded left the box at the default spot, and changing the document reset the title, file name and folder. The role now places its box whenever the labels arrive, and edits are kept. --- src/features/esign/new-request-page.tsx | 116 +++++++++++++++--------- src/features/esign/places.ts | 2 +- 2 files changed, 76 insertions(+), 42 deletions(-) diff --git a/src/features/esign/new-request-page.tsx b/src/features/esign/new-request-page.tsx index 0832b55..c5be006 100644 --- a/src/features/esign/new-request-page.tsx +++ b/src/features/esign/new-request-page.tsx @@ -57,8 +57,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 } @@ -105,9 +106,23 @@ 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({ @@ -165,10 +180,13 @@ 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) 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) @@ -181,14 +199,29 @@ export function NewEsignRequestPage({ const drivesName = config.drives.length === 1 ? config.drives[0].label : "OneDrive" const driveLabel = (id: string) => config.drives.find((d) => d.id === id)?.label ?? "OneDrive" + // 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) 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 }], + })) ) } @@ -238,10 +271,6 @@ export function NewEsignRequestPage({ setSearch("") } - function updateSigner(index: number, patch: Partial) { - setSigners((current) => current.map((s, i) => (i === index ? { ...s, ...patch } : s))) - } - function moveSigner(index: number, by: -1 | 1) { setSigners((current) => { const next = [...current] @@ -251,20 +280,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 +315,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) } @@ -349,7 +382,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, @@ -629,17 +666,7 @@ export function NewEsignRequestPage({ value={s.role} maxLength={80} className={cn(fieldControl, "h-9")} - onChange={(event) => { - 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 } - ) - }} + onChange={(event) => setRole(index, event.target.value)} /> {blockedDrive(s.sub) !== undefined && (

@@ -658,7 +685,7 @@ export function NewEsignRequestPage({ 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) + updatePlaces(index, (places) => places.map((p, i) => (i === at ? { ...p, kind } : p))) } onMove={(at) => setPlacing({ signer: index, place: at })} onRemove={(at) => removePlace(index, at)} @@ -764,7 +791,10 @@ export function NewEsignRequestPage({ className={fieldControl} value={title} maxLength={200} - onChange={(e) => setTitle(e.target.value)} + onChange={(e) => { + setTitle(e.target.value) + setTitleTouched(true) + }} /> {config.drives.length > 0 && ( @@ -798,6 +828,7 @@ export function NewEsignRequestPage({ className="h-72" onPickFolder={(picked) => { setFolder(picked) + setFolderTouched(true) setChoosingFolder(false) }} /> @@ -808,7 +839,10 @@ export function NewEsignRequestPage({ className={fieldControl} value={fileName} maxLength={200} - onChange={(e) => setFileName(e.target.value)} + onChange={(e) => { + setFileName(e.target.value) + setFileNameTouched(true) + }} /> diff --git a/src/features/esign/places.ts b/src/features/esign/places.ts index a786e91..f8c6b38 100644 --- a/src/features/esign/places.ts +++ b/src/features/esign/places.ts @@ -19,7 +19,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)) } From 4ac8f5a2dc6aa7edf9e7b93c131db24d84814e3f Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:28:47 +0200 Subject: [PATCH 06/37] fix(esign): stop promising delivery for cancelled and declined requests A stopped request no longer draws pending boxes, says "Waiting" or "Will be saved to". While delivery runs, Delivery shows Saving/Sending with a spinner and switches to Saved as soon as Activity does. --- src/features/esign/request-page.tsx | 107 ++++++++++++++++---------- src/features/esign/request-status.tsx | 11 ++- 2 files changed, 76 insertions(+), 42 deletions(-) diff --git a/src/features/esign/request-page.tsx b/src/features/esign/request-page.tsx index 8626593..48fb123 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" @@ -150,6 +151,14 @@ export function EsignRequestPage({ detail }: { detail: Detail }) { return map }, [request]) + const stopped = request.status === "cancelled" || request.status === "declined" + 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 @@ -254,7 +263,7 @@ export function EsignRequestPage({ detail }: { detail: Detail }) { focus={focus} 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 @@ -414,7 +423,11 @@ export function EsignRequestPage({ detail }: { detail: Detail }) { {signer.name} {signer.sub === me && } - +

{(signer.role || signer.places.length > 1 || signer.signedAt) && (
@@ -446,49 +459,61 @@ export function EsignRequestPage({ detail }: { detail: Detail }) { -
    -
  • - - - {request.output ? ( - request.delivery?.driveItem?.webUrl ? ( - - {request.output.folderPath ? `${request.output.folderPath}/` : ""} - {request.delivery.driveItem.name} - - ) : ( - - {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} - - ) + {stopped ? ( +

    Not delivered: the request was {request.status}.

    + ) : ( +
      +
    • + {saving ? ( + ) : ( - Kept in the dashboard only + )} - -
    • -
    • - - - {request.email?.to.length ? ( - <> - {request.delivery?.emailedTo.length ? "Emailed to " : "Will be emailed to "} - {request.email.to.join(", ")} - + + {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 ? ( + ) : ( - No email + )} - -
    • -
    + + {request.email?.to.length ? ( + <> + {emailed ? "Emailed to " : sending ? "Sending to " : "Will be emailed to "} + {request.email.to.join(", ")} + + ) : ( + No email + )} + +
  • +
+ )} {request.delivery?.status === "failed" && ( Cancelled } -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 } From bfe631ce67a33a04df3e8b82cd2dfc2d727d2c7a Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:29:06 +0200 Subject: [PATCH 07/37] fix(esign): give the signing dialog the house dialog shell and a finished state (E17) The dialog is 460px with a full-width footer and the shared motion, keeps its content while it fades out, and once everything is signed says so instead of 'Type your PIN' next to 'Stop here'. Escape no longer closes it while the page reloads. --- src/features/esign/sign-with-cie.tsx | 242 ++++++++++++++++----------- 1 file changed, 148 insertions(+), 94 deletions(-) diff --git a/src/features/esign/sign-with-cie.tsx b/src/features/esign/sign-with-cie.tsx index 94c2390..685760f 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" @@ -42,6 +44,7 @@ type Phase = | { kind: "waiting"; jobs: Job[]; url: string; since: number } | { kind: "signing"; jobs: Job[]; url: string } | { kind: "ended"; reason: "cancelled" | "expired" | "failed"; message: string } +type Shown = Exclude const POLL_MS = 1200 /** After this long without the agent fetching the job, it is probably not running. */ @@ -125,6 +128,8 @@ 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) @@ -154,7 +159,6 @@ export function SignWithCie({ ) const jobs = phase && "jobs" in phase ? phase.jobs : null - const batch = (jobs ?? items ?? []).length > 1 useEffect(() => { if (!jobs) return @@ -250,6 +254,8 @@ export function SignWithCie({ /** Closes the dialog, cancelling what isn't signed yet; reloads when part of a batch was signed. */ async function close() { + if (finishing.current) return + finishing.current = true const current = phase setPhase(null) popupRef.current?.close() @@ -270,13 +276,34 @@ export function SignWithCie({ popupRef.current = window.open(url, "pn-esign", "popup=yes,width=460,height=680") } - 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) + 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 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 total = active?.jobs.length ?? 0 + const jobList = active && ( +
    + {active.jobs.map((job) => ( + + ))} +
+ ) + const progress = batch && ( + <> +

+ {signed} of {pluralize(total, "document")} signed +

+ {jobList} + + ) return ( <> @@ -295,109 +322,135 @@ export function SignWithCie({ disablePointerDismissal onOpenChange={(open) => !open && void close()} > - - {phase?.kind === "waiting" && !silent && ( - } title="Opening PoliNetwork eSign"> - A PoliNetwork eSign window opens on this computer. - - )} - {phase?.kind === "waiting" && silent && ( - } - tone="warning" - title="PoliNetwork eSign isn't answering" - > -

- Start PoliNetwork eSign, then open its window again. First time here? Install it from the setup guide. -

-
- - + + + + {shown?.kind === "ended" ? ( + + ) : ( + + ) + } + tone={shown.reason === "failed" ? "danger" : "neutral"} + title={ + shown.reason === "expired" + ? "The link expired" + : shown.reason === "cancelled" + ? "Signature cancelled" + : "Signature failed" + } + > + {shown.message} + + ) : 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…"} + + ) : active?.kind === "waiting" && silent ? ( + } + tone="warning" + title="PoliNetwork eSign isn't answering" + > +

+ Start PoliNetwork eSign, then open its window again. First time here? Install it from the setup guide. +

+
+ + + +
+
+ ) : 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} +
+ ) : null} + {shown?.kind === "ended" ? ( +
+
- - )} - {phase?.kind === "signing" && ( - } title="Type your PIN in PoliNetwork eSign"> -

- 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) => ( - - ))} -
- - )} - -
- )} - {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" ? ( - - ) : signed > 0 ? ( - ) : ( - + !settled && ( +
+ +
+ ) )} -
- +
+
) } +function SetupGuideButton() { + return ( + + ) +} + 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 ? ( ) : ( @@ -418,15 +471,16 @@ function Status({ }: { icon: ReactNode title: string - tone?: "brand" | "warning" | "danger" | "neutral" + tone?: "brand" | "success" | "warning" | "danger" | "neutral" children: ReactNode }) { return ( -
    +
    Date: Wed, 21 Oct 2026 03:31:27 +0200 Subject: [PATCH 08/37] feat(esign): pick signers and roles from the keyboard Enter adds the highlighted person and moves focus to their role, whose suggestions now use the dashboard's own list instead of the browser's datalist. Reorder arrows appear only with two or more signers, and removing a signer keeps focus in the list. --- src/components/primitives/combobox.tsx | 35 +++- src/components/primitives/index.ts | 10 +- src/features/esign/new-request-page.tsx | 223 ++++++++++++++---------- 3 files changed, 172 insertions(+), 96 deletions(-) diff --git a/src/components/primitives/combobox.tsx b/src/components/primitives/combobox.tsx index 13fbe89..a3f437f 100644 --- a/src/components/primitives/combobox.tsx +++ b/src/components/primitives/combobox.tsx @@ -1,3 +1,4 @@ +import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete" import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox" import { ChevronDown } from "lucide-react" import type * as React from "react" @@ -20,24 +21,38 @@ type ComboboxInputProps = ComboboxPrimitive.Input.Props & { listLabel: string } -/** Combobox input: 36px field with a chevron trigger named after the list. */ +/** + * Combobox input: 36px field with a chevron trigger named after the list. The field draws the input's focus ring and + * anchors the list; the chevron is out of the Tab order, since the arrow keys open the list too. + */ export function ComboboxInput({ listLabel, disabled = false, className, ...props }: ComboboxInputProps) { return ( - - } {...props} /> + + } + > + } {...props} /> } disabled={disabled} + tabIndex={-1} aria-label={`Show ${listLabel}`} className="data-pressed:bg-transparent" > - + ) } @@ -46,12 +61,12 @@ export function ComboboxContent({ className, ...props }: React.ComponentProps } -/** 36px, 13px item highlighted on `--pn-muted`; selected check on the right. */ +/** 36px, 13px item highlighted on `--pn-hover` (pointer or arrow keys); selected check on the right. */ export function ComboboxItem({ className, ...props }: React.ComponentProps) { return ( } -export { Combobox, ComboboxList } +/** + * `Combobox` picks one of a fixed set of items; `Autocomplete` is free text with suggestions. Both take the same + * input, content, list, item and empty parts. + */ +const Autocomplete = AutocompletePrimitive.Root + +export { Autocomplete, Combobox, ComboboxList } diff --git a/src/components/primitives/index.ts b/src/components/primitives/index.ts index 497754b..d29ff59 100644 --- a/src/components/primitives/index.ts +++ b/src/components/primitives/index.ts @@ -2,7 +2,15 @@ export { AvatarGroup, initialsOf, type AvatarPerson } from "./avatar-group" export { Chip } from "./chip" export { ChipOverflow } from "./chip-overflow" export { ColorSwatchSelect } from "./color-swatch-select" -export { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "./combobox" +export { + Autocomplete, + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "./combobox" export { ConfirmDialog, dialogFooter, dialogFooterButton, dialogPanel } from "./confirm-dialog" export { CountBadge } from "./count-badge" export { DataTable, RowActions, type DataTableColumn, type DataTableError, type TableSort } from "./data-table" diff --git a/src/features/esign/new-request-page.tsx b/src/features/esign/new-request-page.tsx index c5be006..69fb50b 100644 --- a/src/features/esign/new-request-page.tsx +++ b/src/features/esign/new-request-page.tsx @@ -1,3 +1,4 @@ +import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox" import { useNavigate, useRouter } from "@tanstack/react-router" import { useServerFn } from "@tanstack/react-start" import { @@ -18,13 +19,20 @@ import { import { type DragEvent, useEffect, useId, useMemo, useRef, useState } from "react" import { + Autocomplete, checkboxControl, + Combobox, + ComboboxContent, + ComboboxInput, + ComboboxItem, + ComboboxList, FormField, fieldControl, IconButton, InlineAlert, LoadingButton, SectionCard, + useFocusAfterRemoval, } from "@/components/primitives" import { Menu, @@ -144,7 +152,7 @@ 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(), emails: useId(), message: useId(), search: useId() } const [source, setSource] = useState<"drive" | "upload">(config.drives.length > 0 ? "drive" : "upload") const [staged, setStaged] = useState(null) @@ -264,11 +272,29 @@ 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 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)) } function moveSigner(index: number, by: -1 | 1) { @@ -629,9 +655,13 @@ export function NewEsignRequestPage({ {signers.length > 0 && ( -
      +
        {signers.map((s, index) => ( -
      1. +
      2. {index + 1} @@ -640,34 +670,51 @@ export function NewEsignRequestPage({ {s.name} {s.sub === me && } - moveSigner(index, -1)} - /> - moveSigner(index, 1)} - /> + {signers.length > 1 && ( + <> + moveSigner(index, -1)} + /> + moveSigner(index, 1)} + /> + + )} setSigners((current) => current.filter((_, i) => i !== index))} + onClick={(event) => removeSigner(index, event.currentTarget)} />
        - setRole(index, event.target.value)} - /> + {/* 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} + + )} + + + {blockedDrive(s.sub) !== undefined && (

        @@ -699,71 +746,71 @@ export function NewEsignRequestPage({ ))}

      )} - - {ROLES.map((role) => ( - -
      -
      - - setSearch(event.target.value)} - /> -
      - {(matches.length > 0 || available.length === 0) && ( -
        - {matches.slice(0, 30).map((person) => ( -
      • - -
      • - ))} - {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 && (
    + ) : 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} + +
    ) : active?.kind === "waiting" ? ( } title="Opening PoliNetwork eSign"> A PoliNetwork eSign window opens on this computer. @@ -400,7 +450,7 @@ export function SignWithCie({ variant="ghost" size="sm" className={cn(buttonMotion, "mt-2 -ml-2")} - onClick={() => reopen(active.url)} + onClick={() => showWindow(active.url)} > Show the PoliNetwork eSign window diff --git a/tests/esign-mutation-refresh.test.mjs b/tests/esign-mutation-refresh.test.mjs index a8d0a9d..bb609f9 100644 --- a/tests/esign-mutation-refresh.test.mjs +++ b/tests/esign-mutation-refresh.test.mjs @@ -259,6 +259,53 @@ 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]), + } + 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.ok(!events.some((event) => event[0] === "open")) +}) + for (const outcome of ["delivered", "failed", "throws"]) { void test(`delivery retry waits for refresh before reporting ${outcome}`, async () => { const env = environment() From b07ec6df017206804461ae87098d85566a43665a Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:32:41 +0200 Subject: [PATCH 10/37] fix(esign): open the eSign window in the computer's colour scheme (C8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In dark mode the window no longer flashes a white 'Opening PoliNetwork eSign…' page before the agent's dark form: the placeholder uses the agent page's own colours. --- src/features/esign/sign-with-cie.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/features/esign/sign-with-cie.tsx b/src/features/esign/sign-with-cie.tsx index 4f5448c..7d859f7 100644 --- a/src/features/esign/sign-with-cie.tsx +++ b/src/features/esign/sign-with-cie.tsx @@ -90,8 +90,11 @@ function openPopup(): Window | null { 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 From 77bc8bb248008185a1e0a9f0b5a9bbe44f58e557 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:33:24 +0200 Subject: [PATCH 11/37] feat(esign): say whom a request waits on and keep the status filter on Back The requests list names the person an in-progress request waits on, To sign drops the Status column, ?status= survives opening a row, and the request page shows when each waiting signer was emailed or why they couldn't be. --- docs/design.md | 3 + src/features/esign/request-page.tsx | 132 +++++++++++------- src/features/esign/request-status.tsx | 10 ++ src/features/esign/requests-page.tsx | 55 +++++--- src/routes/dashboard/esign/inbox.tsx | 2 +- src/routes/dashboard/esign/requests/index.tsx | 23 ++- 6 files changed, 157 insertions(+), 68 deletions(-) diff --git a/docs/design.md b/docs/design.md index ac59c13..bba636c 100644 --- a/docs/design.md +++ b/docs/design.md @@ -447,6 +447,9 @@ Rules that the templates above don't imply. With several documents waiting, "Sign N together" opens a strip of the documents in the page bar's toolbar row: each is checked by opening it (a check replaces its number), can be left out, and the primary action stays "Next document" until all are checked, then becomes "Sign N documents" (one agent window, one PIN). + The requests list names whom an in-progress request waits on ("Waiting for X"); To sign has no Status column (every + row is the viewer's turn); `?status=` keeps the status segment across Back. The request page shows when each signer + whose turn it is was emailed, or why they weren't. Signatures › Drives (`esign:drives:manage`) has a card per drive with two rules, "Request signatures" and "Be asked to sign": chips for roles (people icon) and permissions (key icon), an inline picker listing both, a "Default: …" line while empty, and Save/Discard per drive. Unknown keys show as warning chips. On a new request, signers who may diff --git a/src/features/esign/request-page.tsx b/src/features/esign/request-page.tsx index 48fb123..977c5bf 100644 --- a/src/features/esign/request-page.tsx +++ b/src/features/esign/request-page.tsx @@ -48,6 +48,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": @@ -151,6 +157,32 @@ 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" const savedItem = request.delivery?.driveItem ?? null // Recorded right after the upload, before the delivery itself is saved: Activity may say "Saved" first. @@ -403,55 +435,59 @@ export function EsignRequestPage({ detail }: { detail: Detail }) {
      - {request.signers.map((signer, index) => ( -
    1. - - {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 ( +
    2. + + {index + 1} + +
      +
      + + {signer.name} + {signer.sub === me && } + +
      - )} - {signer.declineReason &&
      “{signer.declineReason}”
      } -
      -
    3. - ))} + {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.

    diff --git a/src/features/esign/request-status.tsx b/src/features/esign/request-status.tsx index 746b2a8..fb53398 100644 --- a/src/features/esign/request-status.tsx +++ b/src/features/esign/request-status.tsx @@ -34,6 +34,16 @@ export function SignerStatusBadge({ 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..c2c7cfb 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 waitingText(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,16 +67,20 @@ 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)} + {r.status === "in_progress" && ( + + {waitingText(r)} + + )} ), }, @@ -84,12 +93,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 +118,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 +162,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 +176,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 +189,7 @@ export function EsignRequestsPage({ requests, scope }: { requests: EsignRequest[ r.id} rowHref={(r) => `/dashboard/esign/requests/${r.id}`} @@ -196,7 +217,7 @@ export function EsignRequestsPage({ requests, scope }: { requests: EsignRequest[ variant="ghost" onClick={() => { setQuery("") - setFilter("all") + onStatusChange?.("all") }} > Clear filters 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, + }) + } + /> + ) } From 30c208a5757543fca7e16095fd0c5a34b8655797 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:34:03 +0200 Subject: [PATCH 12/37] fix(esign): warn that cancelling interrupts a signature in progress When someone is signing the request right now, the Cancel request dialog says they won't be able to finish. --- src/features/esign/request-page.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/features/esign/request-page.tsx b/src/features/esign/request-page.tsx index 977c5bf..355436a 100644 --- a/src/features/esign/request-page.tsx +++ b/src/features/esign/request-page.tsx @@ -582,7 +582,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" From 6191b27c88b72f5812299a7c4b9b34af489059c8 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:37:34 +0200 Subject: [PATCH 13/37] feat(esign): unlock Sign with CIE when the other signer finishes A co-signer blocked by someone signing the same request no longer has to reload: the request and batch pages check every 3 s while the tab is visible, keep the moved boxes, and enable signing as soon as the other signature ends. --- src/features/esign/batch-page.tsx | 9 ++++- src/features/esign/request-page.tsx | 39 +++++++++----------- src/features/esign/use-reload-while.ts | 22 +++++++++++ tests/esign-mutation-refresh.test.mjs | 51 +++++++++++++++++++++++++- 4 files changed, 98 insertions(+), 23 deletions(-) create mode 100644 src/features/esign/use-reload-while.ts diff --git a/src/features/esign/batch-page.tsx b/src/features/esign/batch-page.tsx index 52e85bb..8fc04ce 100644 --- a/src/features/esign/batch-page.tsx +++ b/src/features/esign/batch-page.tsx @@ -15,6 +15,7 @@ import { PdfViewer, usePdfBytes } from "./pdf-viewer" import { kindsLabel, type Place, pagesLabel, placesLeft } from "./places" import { SignWithCie } from "./sign-with-cie" import { SignatureBox } from "./signature-box" +import { useReloadWhile } from "./use-reload-while" type Item = Pick & { pdf: EsignPdfInfo | null } @@ -36,6 +37,11 @@ export function EsignBatchPage({ items, more, me }: { items: Item[]; more: numbe const active = items.find((item) => item.request.id === activeId) ?? items[0] const included = items.filter((item) => !leftOut.has(item.request.id)) const unchecked = included.filter((item) => !reviewed.has(item.request.id)) + // Follow documents another signer holds until they finish; reloads keep the review progress and moved boxes. + useReloadWhile( + included.some((item) => item.signingNow && item.signingNow.sub !== me), + 3000 + ) function open(id: string) { setActiveId(id) @@ -225,7 +231,8 @@ export function EsignBatchPage({ items, more, me }: { items: Item[]; more: numbe

    {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. )} diff --git a/src/features/esign/request-page.tsx b/src/features/esign/request-page.tsx index 355436a..21914fd 100644 --- a/src/features/esign/request-page.tsx +++ b/src/features/esign/request-page.tsx @@ -28,6 +28,7 @@ import { RequestStatusBadge, SignerStatusBadge, signedProgress, YouChip } from " 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 } @@ -119,9 +120,16 @@ 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 const myLeft = placesLeft( myPlaces.map((place, at) => ({ ...place, at })), @@ -135,21 +143,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])) @@ -269,7 +266,7 @@ export function EsignRequestPage({ detail }: { detail: Detail }) { {myTurn && ( )} @@ -389,9 +386,9 @@ 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. )}
    diff --git a/src/features/esign/use-reload-while.ts b/src/features/esign/use-reload-while.ts new file mode 100644 index 0000000..b12cec2 --- /dev/null +++ b/src/features/esign/use-reload-while.ts @@ -0,0 +1,22 @@ +import { useRouter } from "@tanstack/react-router" +import { useEffect } from "react" + +/** Reloads the page's data every `ms` while `active`, one reload at a time, skipping ticks while the tab is hidden. */ +export function useReloadWhile(active: boolean, ms: number) { + const router = useRouter() + useEffect(() => { + 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/tests/esign-mutation-refresh.test.mjs b/tests/esign-mutation-refresh.test.mjs index a8d0a9d..4e1be76 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 }) @@ -286,3 +286,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) +}) From 0915d173383b9c6d19da2112c8f20c6eec50d14f Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:37:49 +0200 Subject: [PATCH 14/37] fix(esign): show every composer problem at its field A blocked submit used to show one problem at a time in a banner that could render below the fold. Each problem now appears at its field, and focus moves to the first one; a refused create scrolls its error into view. --- src/features/esign/new-request-page.tsx | 302 +++++++++++++++--------- tests/esign-mutation-refresh.test.mjs | 20 +- 2 files changed, 212 insertions(+), 110 deletions(-) diff --git a/src/features/esign/new-request-page.tsx b/src/features/esign/new-request-page.tsx index 69fb50b..22727f8 100644 --- a/src/features/esign/new-request-page.tsx +++ b/src/features/esign/new-request-page.tsx @@ -5,6 +5,7 @@ import { ArrowDown, ArrowUp, ChevronDown, + CircleAlert, Cloud, FileUp, MapPin, @@ -16,7 +17,7 @@ import { TriangleAlert, UserPlus, } from "lucide-react" -import { type DragEvent, useEffect, useId, useMemo, useRef, useState } from "react" +import { type DragEvent, type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react" import { Autocomplete, @@ -28,6 +29,7 @@ import { ComboboxList, FormField, fieldControl, + fieldHintId, IconButton, InlineAlert, LoadingButton, @@ -152,7 +154,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() } + 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) @@ -200,7 +211,20 @@ export function NewEsignRequestPage({ 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". @@ -225,6 +249,7 @@ export function NewEsignRequestPage({ 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, @@ -376,15 +401,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) @@ -445,8 +481,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) @@ -457,8 +492,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 }] : [])) } @@ -501,7 +538,7 @@ export function NewEsignRequestPage({ } /> -
    +
    {staged ? (
    @@ -548,6 +585,8 @@ export function NewEsignRequestPage({ ) : (
    { event.preventDefault() setDragging(true) @@ -582,7 +621,7 @@ export function NewEsignRequestPage({
    )}
    - {docError && {docError}} + {(docError || attempted) && {docError ?? errors.document}} {source === "drive" && config.drives.length > 0 ? (
    - {attempted && problems.length > 0 && {problems[0]}} - {submitError && {submitError}} + {submitError && ( +
    + {submitError} +
    + )} {createdRequest && ( Request created.{" "} @@ -656,94 +698,106 @@ export function NewEsignRequestPage({ {signers.length > 0 && (
      - {signers.map((s, index) => ( -
    1. -
      - - {index + 1} - - - {s.name} - {s.sub === me && } - - {signers.length > 1 && ( - <> - moveSigner(index, -1)} - /> - moveSigner(index, 1)} - /> - - )} - 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) + {signers.map((s, index) => { + const blocked = blockedDrive(s.sub) + const blockedId = `${ids.signersError}-${index}` + return ( +
    2. +
      + + {index + 1} + + + {s.name} + {s.sub === me && } + + {signers.length > 1 && ( + <> + moveSigner(index, -1)} + /> + moveSigner(index, 1)} + /> + + )} + 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 }) }} - listLabel="roles" - aria-label={`Role of ${s.name}`} - placeholder="Role (optional), e.g. Segretario" - maxLength={80} + 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 } + ) + } /> - - - {(role: string) => ( - - {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))) - } - 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 } - ) - } - /> -
    3. - ))} + + ) + })}
    )} {/* The page filters people itself and lists them only once something is typed; the list stays in place. */} @@ -773,8 +827,11 @@ export function NewEsignRequestPage({ render={} 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) => ( @@ -832,16 +889,19 @@ export function NewEsignRequestPage({
    - + { setTitle(e.target.value) setTitleTouched(true) }} + onBlur={() => revalidate("title")} /> {config.drives.length > 0 && ( @@ -862,10 +922,17 @@ export function NewEsignRequestPage({ ? `${config.drives.length > 1 ? `${driveLabel(folder.driveId)}: ` : ""}/${folder.path}` : "No folder chosen"} -
    + {attempted && errors.folder && {errors.folder}} {choosingFolder && ( )} - + { setFileName(e.target.value) setFileNameTouched(true) }} + onBlur={() => revalidate("fileName")} /> @@ -901,6 +971,7 @@ export function NewEsignRequestPage({ htmlFor={ids.emails} optional hint="Addresses separated by commas. Sent when every signature is in." + error={shown.email} > setEmails(e.target.value)} + onBlur={() => revalidate("email")} /> {recipients.length > 0 && ( @@ -931,6 +1005,16 @@ export function NewEsignRequestPage({ ) } +/** 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/tests/esign-mutation-refresh.test.mjs b/tests/esign-mutation-refresh.test.mjs index a8d0a9d..7e89890 100644 --- a/tests/esign-mutation-refresh.test.mjs +++ b/tests/esign-mutation-refresh.test.mjs @@ -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, { From 216db00c641eb3d221e446d4ef1c2a0a2580cfee Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:38:53 +0200 Subject: [PATCH 15/37] feat(dashboard): show documents to sign on the Overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signatures › To sign on the Overview now carries the same count pill as the panel, so someone landing there sees waiting signatures without opening Signatures. Reports › Open uses the same shell count. --- docs/design.md | 3 +- src/components/shell/index.ts | 2 ++ src/components/shell/panel.tsx | 9 +++--- src/features/dashboard/overview-page.tsx | 37 +++++++++++++++--------- src/routes/dashboard/index.tsx | 7 +++-- 5 files changed, 37 insertions(+), 21 deletions(-) diff --git a/docs/design.md b/docs/design.md index bba636c..c0eef06 100644 --- a/docs/design.md +++ b/docs/design.md @@ -180,7 +180,8 @@ the selected range and a TanStack Charts line (3M per day, 1Y per week, All per grid, month labels, a dashed hover guide and a tooltip with the date, total and growth), and Telegram groups, Open reports and Active grants as links on its right (a row under it on mobile, numbers on one baseline, labelled "Groups" rather than "Telegram groups", and without the "To review" flag); then "Areas": one card per service whose whole surface leads where the rail icon does, with each section -as a 44px button (two per row on mobile). +as a 44px button (two per row on mobile). Reports › Open and Signatures › To sign carry the same accent count pill as the +panel, from the shell's pending counts; nothing at zero or on a failed load. **States.** While a route loads, its `pendingComponent` renders `PageBar`, `PageContent` and the template's skeleton (`TableSkeleton`, `CardsSkeleton`, `RecordSkeleton`, `SettingsListSkeleton`, labelled "Loading {things}…"), so nothing diff --git a/src/components/shell/index.ts b/src/components/shell/index.ts index 6cef163..7882dcb 100644 --- a/src/components/shell/index.ts +++ b/src/components/shell/index.ts @@ -10,6 +10,7 @@ export { type PendingCounts, type Section, sectionFor, + sectionPending, type Service, serviceById, type ServiceId, @@ -33,6 +34,7 @@ export { useInShell, useShellFrame, } from "./page-bar" +export { PendingCount } from "./panel" export { ServiceGlyph } from "./service-glyph" export { DashboardShell, type DashboardShellProps } from "./shell" export { type Theme, themeToggleLabel, useTheme } from "./theme" diff --git a/src/components/shell/panel.tsx b/src/components/shell/panel.tsx index 5fae4e0..235f7d2 100644 --- a/src/components/shell/panel.tsx +++ b/src/components/shell/panel.tsx @@ -1,6 +1,7 @@ import { Link } from "@tanstack/react-router" import { type KeyboardEvent, Suspense, use, useDeferredValue, useLayoutEffect, useRef } from "react" +import { formatNumber } from "@/lib/format" import { cn } from "@/lib/utils" import { @@ -24,14 +25,14 @@ const itemActiveClass = "bg-(--pn-nav-active) text-(--pn-fg) hover:bg-(--pn-nav- const itemCurrentClass = "before:absolute before:inset-y-2.5 before:-left-3 before:w-[3px] before:rounded-r-[3px] before:bg-(--pn-accent-solid)" -/** Pending reports as a small accent pill next to Reports › Open; zero (or a failed load) renders nothing. */ -function PendingReportsCount({ count: countPromise }: { count: Promise }) { +/** Pending items (open reports, documents to sign) as a small accent pill; zero, loading or a failed load renders nothing. */ +export function PendingCount({ count: countPromise }: { count: Promise }) { const count = use(useDeferredValue(countPromise)) if (!count) return null return ( , - {count} + {formatNumber(count)} pending ) @@ -96,7 +97,7 @@ export function SectionItems({ service, match, pending, variant = "panel" }: Sec /> {section.title} - + ) diff --git a/src/features/dashboard/overview-page.tsx b/src/features/dashboard/overview-page.tsx index 525e8a9..f7895b5 100644 --- a/src/features/dashboard/overview-page.tsx +++ b/src/features/dashboard/overview-page.tsx @@ -3,7 +3,17 @@ import { ArrowRight, ArrowUp, ChevronRight, Search } from "lucide-react" import { type ReactNode, Suspense, use, useState } from "react" import { InlineAlert, LoadingButton, SegmentedControl, useModifierKey } from "@/components/primitives" -import { type DashboardPath, PageBar, PageContent, type Service, ServiceGlyph, useShellFrame } from "@/components/shell" +import { + type DashboardPath, + PageBar, + PageContent, + PendingCount, + type PendingCounts, + sectionPending, + type Service, + ServiceGlyph, + useShellFrame, +} from "@/components/shell" import { useVisibleServices } from "@/components/shell/use-can" import { formatNumber } from "@/lib/format" import { cn } from "@/lib/utils" @@ -26,10 +36,12 @@ export type OverviewPageProps = { counts: OverviewCounts /** Streams in after the counts; the chart area holds its height meanwhile. */ userHistory: Promise + /** The shell's streamed counts, shown on the sections that have one. */ + pending: PendingCounts } /** Overview: the search, the Telegram users hero with its chart and side stats, then every service's sections. */ -export function DashboardOverviewPage({ counts, userHistory }: OverviewPageProps) { +export function DashboardOverviewPage({ counts, userHistory, pending }: OverviewPageProps) { const router = useRouter() const [retrying, setRetrying] = useState(false) const someFailed = Object.values(counts).some((count) => count === null) @@ -66,7 +78,7 @@ export function DashboardOverviewPage({ counts, userHistory }: OverviewPageProps ) : null} - +
    @@ -94,7 +106,7 @@ function OverviewSearch() { } /** Stream user growth and service counts into the overview hero, with shorter statistic labels on phones. */ -function Hero({ counts, userHistory }: OverviewPageProps) { +function Hero({ counts, userHistory }: Pick) { // `short` is the phone label: the hero already sits under "Telegram users", so "Groups" is unambiguous there. const stats: { label: string; short?: string; value: OverviewCount; to: DashboardPath; flag?: ReactNode }[] = [ { label: "Telegram groups", short: "Groups", value: counts.telegramGroups, to: "/dashboard/telegram/groups" }, @@ -267,7 +279,7 @@ function chartDescription(points: ReturnType["points"], ran } /** Every service the user can open: the whole card leads where its rail icon does, each section is a 44px button. */ -function Areas({ openReports }: { openReports: OverviewCount }) { +function Areas({ pending }: { pending: PendingCounts }) { const services = useVisibleServices().filter((service) => service.sections.length > 0) return (
    @@ -276,15 +288,15 @@ function Areas({ openReports }: { openReports: OverviewCount }) {
      {services.map((service) => ( - + ))}
    ) } -/** Link to a service and its sections, including the open-report count when the service is Reports. */ -function AreaCard({ service, openReports }: { service: Service; openReports: OverviewCount }) { +/** Link to a service and its sections, including the pending count on Reports › Open and Signatures › To sign. */ +function AreaCard({ service, pending }: { service: Service; pending: PendingCounts }) { const { serviceHref } = useShellFrame() const wide = service.sections.length > 3 return ( @@ -325,12 +337,9 @@ function AreaCard({ service, openReports }: { service: Service; openReports: Ove className="size-4.5 shrink-0 text-(--pn-fg-muted) group-hover/section:text-(--pn-fg)" /> {section.title} - {service.id === "reports" && section.id === "open" && openReports ? ( - - {formatNumber(openReports)} - open - - ) : null} + + + ))}
    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 } From 3a24c7ef88042ac92293de79cedc433040010970 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:38:57 +0200 Subject: [PATCH 16/37] fix(esign): say how a signature attempt ended (E19, E20, E30) Cancelling in the eSign window now just closes the dialog. A request cancelled or declined by someone else, a link that expired (with Start again) and an error that ends the job (PIN blocked, CIE not set up, with the setup guide) each say what happened, and closing reloads the page so it no longer offers a stale Sign button. --- docs/esign/contracts.md | 25 +++- src/features/esign/sign-with-cie.tsx | 176 +++++++++++++++++--------- tests/esign-mutation-refresh.test.mjs | 64 ++++++++++ 3 files changed, 206 insertions(+), 59 deletions(-) diff --git a/docs/esign/contracts.md b/docs/esign/contracts.md index f1eff8e..87ea83a 100644 --- a/docs/esign/contracts.md +++ b/docs/esign/contracts.md @@ -63,7 +63,23 @@ Agent → backend (through the dashboard origin, `{origin}/api/esign/agent/…`, | `POST /api/esign/agent/jobs/{ticket}/fail` | `{code, message?}` | `{status}` — records cancellations / card errors for the dashboard | Errors: `{error: {code, message}}` with codes `ticket_invalid` (404), `job_expired` (410), `job_done` (409), -`document_changed` (409), `certificate_invalid` (422), `signer_mismatch` (403), `signature_invalid` (422). +`job_cancelled` (409: the message names who cancelled or declined the request, else "This signature was cancelled."), +`job_failed` (409: the message of the error that ended the job), `document_changed` (409), `certificate_invalid` (422), +`signer_mismatch` (403), `signature_invalid` (422). + +An error is terminal or retryable, the same in the agent, the backend and the dashboard. A terminal error ends the job: +the backend moves it to `failed` with the code, whether the agent reported it through `/fail` or the backend raised it +itself, and the agent shows it without a PIN form. A retryable error keeps the job open in the same agent window. An +unknown code is retryable. + +- Terminal, reported by the agent: `pin_locked`, `card_untrusted`, `middleware_missing`, `not_paired`, + `certificate_not_found`. +- Terminal, raised by the backend: `signer_mismatch`, `certificate_invalid`, `signature_invalid`, `document_changed`, + `pdf_invalid`, `job_done`, `agent_outdated`. +- Retryable: `pin_incorrect`, `pin_invalid`, `pin_required`, `no_card`, `no_reader`, `card_removed`, `card_unsupported`, + `device_error`, `busy`, `backend_unreachable`. + +`/fail` on a job that already failed changes nothing and answers `{status: "failed"}`. ## Backend tRPC (`esign.*`) @@ -90,7 +106,12 @@ permissions}, updatedBySub, updatedAt}], catalog: {roles: [{key, name}], permiss record `drives` (source and output); `esign.config().drives` and `esign.signers.list()[].drives` are filtered. - `esign.jobs.create({items: [{requestId, places?}]})` (1–10, all or none) → `[{requestId, jobId, ticket, expiresAt}]`, opened together in one agent window; `places` moves the caller's boxes (one position per place) before signing. - `esign.jobs.statuses({jobIds})`, `esign.jobs.cancel({jobIds})`. +- `esign.jobs.statuses({jobIds})` → one entry per job: + `{jobId, requestId, status, errorCode, errorMessage, expiresAt, seen, signaturesLeft}`. `status` is `pending`, + `prepared`, `completed`, `failed` (a terminal error, its code in `errorCode`), `cancelled` or `expired`. A job + cancelled because its request was cancelled or declined has `errorCode` `request_cancelled` or `request_declined`, + and an `errorMessage` naming who did it (with the reason of a decline). +- `esign.jobs.cancel({jobIds})`. Placement: `{page (0-based), x, y, width, height}` as fractions of the displayed page (crop box after rotation), origin top-left. diff --git a/src/features/esign/sign-with-cie.tsx b/src/features/esign/sign-with-cie.tsx index 7d859f7..2f1fe78 100644 --- a/src/features/esign/sign-with-cie.tsx +++ b/src/features/esign/sign-with-cie.tsx @@ -39,11 +39,13 @@ 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 @@ -51,31 +53,82 @@ const POLL_MS = 1200 const AGENT_TIMEOUT_MS = 7000 const OPEN = new Set(["pending", "prepared"]) +const ENDED_TITLES = { + cancelled: "Request cancelled", + declined: "Request declined", + expired: "The link expired", + failed: "Signature failed", +} + /** What the agent reported, by its error code (esign `internal/token`, backend `agent_outdated`). */ const AGENT_ERRORS = new Map([ ["pin_incorrect", "Wrong PIN. Type it again in the PoliNetwork eSign window."], - ["pin_locked", "The PIN is blocked. Unblock it with the PUK in the CIE ID app, then try again."], + [ + "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", "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", "PoliNetwork eSign 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."], + ["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.", ], ]) +/** 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." } +/** + * 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.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 @@ -166,7 +219,18 @@ 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 preparing = prepare !== undefined useEffect(() => { if (!jobs) return @@ -179,22 +243,11 @@ export function SignWithCie({ 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) + return ending ? setPhase(ending) : dismiss() } // 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. @@ -219,9 +272,11 @@ 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) @@ -267,13 +322,19 @@ 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. + 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) { @@ -314,6 +375,7 @@ export function SignWithCie({ 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 && (
      @@ -353,23 +415,16 @@ export function SignWithCie({ {shown?.kind === "ended" ? ( - ) : ( - - ) - } + icon={} tone={shown.reason === "failed" ? "danger" : "neutral"} - title={ - shown.reason === "expired" - ? "The link expired" - : shown.reason === "cancelled" - ? "Signature cancelled" - : "Signature failed" - } + title={ENDED_TITLES[shown.reason]} > - {shown.message} +

      {shown.message}

      + {shown.code && SETUP_ERRORS.has(shown.code) && ( +
      + +
      + )}
      ) : settled ? ( {sharedError && {sharedError}} {batch && jobList} - +
      + + {needsSetup && } +
      ) : active?.kind === "waiting" ? ( } title="Opening PoliNetwork eSign"> @@ -449,21 +502,30 @@ export function SignWithCie({

      {sharedError && {sharedError}} {progress} - +
      + + {needsSetup && } +
      ) : null} {shown?.kind === "ended" ? (
      - + {shown.reason === "expired" && !preparing ? ( + <> + + + + ) : ( + + )}
      ) : ( !settled && ( diff --git a/tests/esign-mutation-refresh.test.mjs b/tests/esign-mutation-refresh.test.mjs index bb609f9..b5653d5 100644 --- a/tests/esign-mutation-refresh.test.mjs +++ b/tests/esign-mutation-refresh.test.mjs @@ -306,6 +306,70 @@ void test("opening the window again reloads the signing page in the open agent w assert.ok(!events.some((event) => event[0] === "open")) }) +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]]) +}) + +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\./) +}) + +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() From 3b6c5745253645d6134416d67296dd88c4232904 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:40:07 +0200 Subject: [PATCH 17/37] refactor(esign): build the requests list's signer text once per row --- src/features/esign/requests-page.tsx | 33 +++++++++++++++------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/features/esign/requests-page.tsx b/src/features/esign/requests-page.tsx index c2c7cfb..805a200 100644 --- a/src/features/esign/requests-page.tsx +++ b/src/features/esign/requests-page.tsx @@ -44,7 +44,7 @@ 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 waitingText(request: EsignRequest) { +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}` @@ -69,20 +69,23 @@ const columns: DataTableColumn[] = [ label: "Signers", width: 260, priority: 1, - cell: (r) => ( - - ({ id: s.id, name: s.name }))} - max={4} - listLabel={`Signers of ${r.title}`} - /> - {r.status === "in_progress" && ( - - {waitingText(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", From c6781fcd83fbd85f07e39abef18c1db5b873c053 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:40:23 +0200 Subject: [PATCH 18/37] fix(esign): show when PoliNetwork eSign stops answering mid-signature (E4) When the eSign window reports that the agent behind it died, the dialog swaps 'Type your PIN' for how to start it and open the window again. The message is documented in the contracts, as the first postMessage detail the dashboard acts on. --- docs/esign/contracts.md | 6 +++ src/features/esign/sign-with-cie.tsx | 66 +++++++++++++++------------ tests/esign-mutation-refresh.test.mjs | 6 ++- 3 files changed, 48 insertions(+), 30 deletions(-) diff --git a/docs/esign/contracts.md b/docs/esign/contracts.md index 87ea83a..8b89613 100644 --- a/docs/esign/contracts.md +++ b/docs/esign/contracts.md @@ -42,6 +42,12 @@ Listens on `127.0.0.1:47321` only. Every request: `Host` must be `127.0.0.1:4732 Allowed origins: `https://admin.polinetwork.org` by default, `http://localhost:*` with `--dev`, or the config file. +The signing page tells its opener what happened with `postMessage({type: "pn-esign", status, detail?}, origin)`, to the +dashboard's origin only: `status` is `completed`, `error` or `cancelled`, `detail` the agent error code, or +`agent_unreachable` when the page can no longer reach the agent behind it. The dashboard polls `esign.jobs.statuses` +again on every message and acts on a message itself only for `agent_unreachable`, because the agent can't report its own +death to the backend. Agents up to v0.1.x post type `pn-sign-agent`. + Providers (tried in order, first with a card present; never falls back once a PIN reached a module or card — `token.Error.PINSent`): diff --git a/src/features/esign/sign-with-cie.tsx b/src/features/esign/sign-with-cie.tsx index 2f1fe78..09addae 100644 --- a/src/features/esign/sign-with-cie.tsx +++ b/src/features/esign/sign-with-cie.tsx @@ -194,6 +194,7 @@ export function SignWithCie({ const [now, setNow] = useState(Date.now()) const [error, setError] = useState(null) const [windowClosed, setWindowClosed] = useState(false) + const [agentLost, setAgentLost] = useState(false) const popupRef = useRef(null) const finishing = useRef(false) @@ -263,6 +264,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) @@ -282,6 +285,7 @@ export function SignWithCie({ setError(null) setStates(new Map()) setWindowClosed(false) + setAgentLost(false) finishing.current = false popupRef.current?.close() let popup: Window | null = null @@ -361,6 +365,7 @@ export function SignWithCie({ const popup = popupRef.current if (popup && !popup.closed) popup.location.href = url showWindow(url) + setAgentLost(false) } if (phase && phase.kind !== "starting" && phase !== shown) setShown(phase) @@ -440,36 +445,15 @@ export function SignWithCie({ > {batch ? jobList : "Updating the page…"} - ) : active?.kind === "waiting" && silent ? ( - } - tone="warning" - title="PoliNetwork eSign isn't answering" + ) : active && (silent || agentLost) ? ( + openWindowAgain(active.url)} > -

      - Start PoliNetwork eSign, then open its window again. First time here? Install it from the setup guide. -

      -
      - - - -
      -
      + {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 ? ( } @@ -543,6 +527,30 @@ export function SignWithCie({ ) } +/** PoliNetwork eSign doesn't answer: start it, open its window again, or set it up. */ +function NotAnswering({ title, children, onOpenAgain }: { title: string; children: string; onOpenAgain: () => void }) { + return ( + } tone="warning" title={title}> +

      {children}

      +
      + + + +
      +
      + ) +} + function SetupGuideButton() { return (
    + { + 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?.()} + /> ) } From 764f9344eb9a8419b86317dc3a7aca2ce23db14c Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:45:18 +0200 Subject: [PATCH 21/37] feat(esign): name the output folder and reopen the last one The output row cut the folder name off after the drive label. It now shows the folder over where it is, and both drive browsers open in the folder used last instead of the drive's top folder. --- src/features/esign/drive-browser.tsx | 22 ++++++-- src/features/esign/new-request-page.tsx | 67 +++++++++++++++++++------ 2 files changed, 70 insertions(+), 19 deletions(-) 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 09b3816..7e6aaa1 100644 --- a/src/features/esign/new-request-page.tsx +++ b/src/features/esign/new-request-page.tsx @@ -31,6 +31,7 @@ import { FormField, fieldControl, fieldHintId, + Hint, IconButton, InlineAlert, LoadingButton, @@ -81,6 +82,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 { @@ -113,6 +116,17 @@ 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, "") } @@ -204,6 +218,17 @@ export function NewEsignRequestPage({ 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) @@ -231,6 +256,7 @@ export function NewEsignRequestPage({ // 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(() => { @@ -290,6 +316,9 @@ 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 } + window.localStorage.setItem(LAST_FOLDER_KEY, JSON.stringify(last)) + setLastFolder(last) } catch (error) { console.error(error) setDocError(error instanceof Error ? error.message : "Couldn't open the PDF.") @@ -639,14 +668,18 @@ export function NewEsignRequestPage({ {(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" + /> + ) ) : (