diff --git a/docs/design.md b/docs/design.md
index 0c64738..ea05f18 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -441,6 +441,11 @@ Rules that the templates above don't imply.
- **Signatures**: the request page puts the PDF viewer (pdf.js, so every browser renders the same) beside the
signers, delivery and activity cards; the signer's box is blue and draggable, other pending signers' boxes are dashed.
Signature boxes use the light palette in both themes, because they sit on white paper. "Sign with CIE" opens the
- PoliNetwork eSign's own page in a popup: the PIN is typed there, never in the dashboard.
+ PoliNetwork eSign's own page in a popup: the PIN is typed there, never in the dashboard. A person appears once
+ however many places they sign: their places are buttons under their name ("Signature · p. 2", "Mark · p. 1") whose
+ menu switches the kind, moves or removes the place and rings its box on the page; marks have a dashed border.
+ 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).
- **Account**: permissions are grouped by area (Dashboard, Telegram, WhatsApp, group labels, Microsoft 365, Web) with
the service icon.
diff --git a/docs/esign/TRACKER.md b/docs/esign/TRACKER.md
index 3ac16df..4ab006c 100644
--- a/docs/esign/TRACKER.md
+++ b/docs/esign/TRACKER.md
@@ -118,6 +118,12 @@ Legend: ☐ todo · ◐ in progress · ☑ done
`/tmp/esign-review-r2b/`): round-1 fixes confirmed; new findings fixed — ByteRange gap must be exactly /Contents,
change policy for uploaded signatures (`pdf/diff.ts`, checked against a pyHanko signature), retry lease, per-signer
email size budget, bounded OCSP read, fresh popup per signature, delivery polling, scope switch.
+- ☑ Several OneDrive/SharePoint drives (`ESIGN_DRIVES`, backend#74, admin#104).
+- ☑ Several places per signer: each place a full signature (one PDF signature each) or a mark (the first signature's
+ mark, covered by it); the person appears once in the dashboard (backend#75, admin PR, sign-agent#6).
+- ☑ Sign several documents with one PIN: "Sign N together" in To sign opens a document strip to check each, then one
+ agent window signs them all (agent protocol 2; older agents are asked to update).
+- ☐ Real-card check that one PIN can make several signatures in a session (`cie-mw`, `opencie`, built-in `cie`).
- ☐ Docs: user guide (install agent, pair CIE), ops (Graph permissions, IdP permissions, env).
## Manual steps for the maintainer (cannot be automated here)
@@ -151,6 +157,9 @@ Open `http://localhost:10431/dashboard/esign/requests` (use `localhost`, not `12
## Log
+- 2026-10-17: several places per signer and batch signing with one PIN (backend#75, sign-agent#6, admin PR); merge
+ order: backend → bump admin → agent release v0.2.0 together with the backend deploy (protocol 2).
+
- 2026-10-17: backend#73 merged (released @polinetwork/backend 1.7.0), admin#102 merged; agent v0.1.1; code-signing
pipeline merged (esign #4).
diff --git a/docs/esign/contracts.md b/docs/esign/contracts.md
index beee393..e60e510 100644
--- a/docs/esign/contracts.md
+++ b/docs/esign/contracts.md
@@ -32,13 +32,13 @@ Dashboard Admin BFF Backend
Listens on `127.0.0.1:47321` only. Every request: `Host` must be `127.0.0.1:47321` or `localhost:47321`.
-| Route | Caller | Notes |
-| ----------------- | -------------------- | --------------------------------------------------------------------------------------- |
-| `GET /` | anyone | "Agent is running" page |
-| `GET /sign` | popup navigation | Query `ticket` (43 chars base64url), `origin` (must be in the allowlist). Serves the UI |
-| `GET /api/job` | the agent's own page | Same-origin only (`Origin`/`Sec-Fetch-Site` checks). Job + card status |
-| `POST /api/sign` | the agent's own page | `{ticket, origin, pin}`; runs the whole signature; returns the outcome |
-| `GET /api/status` | the agent's own page | Providers, readers, card presence |
+| Route | Caller | Notes |
+| ----------------- | -------------------- | ------------------------------------------------------------------------------------ |
+| `GET /` | anyone | "Agent is running" page |
+| `GET /sign` | popup navigation | Query `ticket` repeated 1–10 times (43 chars base64url each), `origin` (allowlisted) |
+| `GET /api/job` | the agent's own page | Same-origin only (`Origin`/`Sec-Fetch-Site` checks). Job + card status |
+| `POST /api/sign` | the agent's own page | `{tickets, origin, pin}`: one card session signs every job; a result per ticket |
+| `GET /api/status` | the agent's own page | Providers, readers, card presence |
Allowed origins: `https://admin.polinetwork.org` by default, `http://localhost:*` with `--dev`, or the config file.
@@ -55,12 +55,12 @@ Providers (tried in order, first with a card present; never falls back once a PI
Agent → backend (through the dashboard origin, `{origin}/api/esign/agent/…`, proxied by the BFF to the backend's
`/esign/agent/…`). Authentication is the ticket in the path; no cookies, no bearer tokens.
-| Request | Body | Response |
-| ---------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
-| `GET /api/esign/agent/jobs/{ticket}` | | `{job: {id, status, expiresAt, document: {title, fileName, pages}, signer: {name, role}, placement: {page}, requestedBy}}` |
-| `POST /api/esign/agent/jobs/{ticket}/prepare` | `{certificate: b64 DER, chain?: b64[]}` | `{toBeSigned: b64 SHA-256, algorithm: "RSASSA-PKCS1-v1_5-SHA256"}` |
-| `POST /api/esign/agent/jobs/{ticket}/complete` | `{signature: b64}` | `{status: "completed", request: {status, remaining}}` |
-| `POST /api/esign/agent/jobs/{ticket}/fail` | `{code, message?}` | `{status}` — records cancellations / card errors for the dashboard |
+| Request | Body | Response |
+| ---------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `GET /api/esign/agent/jobs/{ticket}` | | `{job: {id, status, expiresAt, document: {title, fileName, pages}, signer: {name, role}, placement: {page}, requestedBy, signatures, marks, pages}}` |
+| `POST /api/esign/agent/jobs/{ticket}/prepare` | `{certificate: b64 DER, chain?: b64[], protocol: 2}` | `{toBeSigned: b64 SHA-256, algorithm: "RSASSA-PKCS1-v1_5-SHA256", step, steps}`; without `protocol: 2`, 426 `agent_outdated` |
+| `POST /api/esign/agent/jobs/{ticket}/complete` | `{signature: b64}` | `{status: "next" \| "completed", request: {status, remaining}}`: on `next`, prepare the job's next place with the same session |
+| `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).
@@ -77,11 +77,14 @@ browse OneDrive). Requests are visible to their signers and creator.
Item IDs are only unique within their drive, so every drive call names one.
- `esign.documents.upload(FormData{file})` / `esign.documents.fromDrive({driveId, itemId})` → `{documentId, fileName, pages: [{width,height}], signatures, sha256}`.
- `esign.documents.content({documentId})` → `{base64}` (a staged or request document version).
-- `esign.requests.create({title, documentId, signers: [{sub, role, placement}], ordered, output: {driveId, folderId, fileName}, email?: {to[], message?}})`.
+- `esign.requests.create({title, documentId, signers: [{sub, role, places: [{...placement, kind}]}], ordered, output: {driveId, folderId, fileName}, email?: {to[], message?}})`.
+ `kind` is `signature` (its own PDF signature) or `mark` (the first signature's mark, drawn in that signature's
+ update); each signer has 1–20 places, at least one a signature, and appears once in the request.
- `esign.requests.list({scope: "inbox"|"mine"|"all", status?})`, `esign.requests.get({id})`.
- `esign.requests.cancel({id})`, `esign.requests.decline({id, reason})`, `esign.requests.retryDelivery({id})`.
-- `esign.jobs.create({requestId, placement?})` → `{jobId, ticket, expiresAt, agentUrl}`; `esign.jobs.status({jobId})`;
- `esign.jobs.cancel({jobId})`.
+- `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})`.
Placement: `{page (0-based), x, y, width, height}` as fractions of the displayed page (crop box after rotation),
origin top-left.
diff --git a/package.json b/package.json
index c2ad976..83f346a 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
"dependencies": {
"@base-ui/react": "^1.6.0",
"@dnd-kit/react": "^0.4.0",
- "@polinetwork/backend": "^1.8.0",
+ "@polinetwork/backend": "^1.9.0",
"@t3-oss/env-core": "^0.13.10",
"@tanstack/charts": "1.1.0",
"@tanstack/react-charts": "1.1.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cece7da..b5f345b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,8 +26,8 @@ importers:
specifier: ^0.4.0
version: 0.4.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@polinetwork/backend':
- specifier: ^1.8.0
- version: 1.8.0
+ specifier: ^1.9.0
+ version: 1.9.0
'@t3-oss/env-core':
specifier: ^0.13.10
version: 0.13.11(typescript@6.0.3)(zod@4.3.5)
@@ -1034,8 +1034,8 @@ packages:
resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==}
engines: {node: ^14.18.0 || >=16.0.0}
- '@polinetwork/backend@1.8.0':
- resolution: {integrity: sha512-1cC3b4hhQaX0alPH/Q4pOiT3PBRTnzPwv6P3WtogAq27lSSEVdViIhIs5TL+cM/ML72LThV9UbYn0kgF9Z5BKQ==}
+ '@polinetwork/backend@1.9.0':
+ resolution: {integrity: sha512-lh8cLYOpEiPN3oqtmq0EWaxwKUjvaDqsBG0gaOg/2KrkICoF0jDj00LCIn66hf02Boe9jq0G7skyfCiUo2A+6w==}
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -5952,7 +5952,7 @@ snapshots:
'@pkgr/core@0.3.6': {}
- '@polinetwork/backend@1.8.0': {}
+ '@polinetwork/backend@1.9.0': {}
'@polka/url@1.0.0-next.29': {}
diff --git a/src/components/shell/nav.ts b/src/components/shell/nav.ts
index 1063ab8..fe112ab 100644
--- a/src/components/shell/nav.ts
+++ b/src/components/shell/nav.ts
@@ -85,6 +85,7 @@ export type PageMatch =
| { kind: "esign-requests" }
| { kind: "esign-request"; id: string }
| { kind: "esign-new" }
+ | { kind: "esign-sign" }
| { kind: "esign-setup" }
| { kind: "reports-open" }
| { kind: "reports-closed" }
@@ -355,6 +356,7 @@ export function matchPath(pathname: string): PageMatch {
if (second === "inbox") return { kind: "esign-inbox" }
if (second === "requests") return { kind: "esign-requests" }
if (second === "new") return { kind: "esign-new" }
+ if (second === "sign") return { kind: "esign-sign" }
if (second === "setup") return { kind: "esign-setup" }
}
if (second === "requests" && third !== undefined && /^[0-9a-f-]{36}$/i.test(third)) {
@@ -400,6 +402,7 @@ export function serviceFor(match: PageMatch): Service {
case "esign-requests":
case "esign-request":
case "esign-new":
+ case "esign-sign":
case "esign-setup":
return esign
case "reports-open":
@@ -443,6 +446,7 @@ export function sectionFor(match: PageMatch): Section | null {
case "web-category":
return section(web, "categories")
case "esign-inbox":
+ case "esign-sign":
return section(esign, "inbox")
case "esign-requests":
case "esign-request":
@@ -464,7 +468,8 @@ export function isDeepPage(match: PageMatch): boolean {
match.kind === "web-category" ||
match.kind === "web-tag" ||
match.kind === "esign-request" ||
- match.kind === "esign-new"
+ match.kind === "esign-new" ||
+ match.kind === "esign-sign"
)
}
diff --git a/src/features/esign/agent.ts b/src/features/esign/agent.ts
index 1350b72..069b499 100644
--- a/src/features/esign/agent.ts
+++ b/src/features/esign/agent.ts
@@ -7,9 +7,13 @@ export const AGENT_LAUNCH_URL = "polinetwork-esign://open"
export const AGENT_RELEASES_URL = "https://github.com/PoliNetworkOrg/esign/releases/latest"
-/** The agent's signing page for a job. `origin` is this dashboard, which the agent checks against its allowlist. */
-export function agentSignUrl(ticket: string, origin: string) {
- const params = new URLSearchParams({ ticket, origin })
+/**
+ * The agent's signing page for one or more jobs, signed with one PIN. `origin` is this dashboard, which the agent
+ * checks against its allowlist.
+ */
+export function agentSignUrl(tickets: string[], origin: string) {
+ const params = new URLSearchParams({ origin })
+ for (const ticket of tickets) params.append("ticket", ticket)
return `${AGENT_ORIGINS[0]}/sign?${params}`
}
diff --git a/src/features/esign/batch-page.tsx b/src/features/esign/batch-page.tsx
new file mode 100644
index 0000000..a490b83
--- /dev/null
+++ b/src/features/esign/batch-page.tsx
@@ -0,0 +1,296 @@
+import { Link, useNavigate } from "@tanstack/react-router"
+import { useServerFn } from "@tanstack/react-start"
+import { ArrowRight, Check, ExternalLink, FileSignature, Plus, X } from "lucide-react"
+import { useEffect, useState } from "react"
+
+import { EmptyState, IconButton, InlineAlert, SectionCard } from "@/components/primitives"
+import { PageBar, PageContent } from "@/components/shell"
+import { Button } from "@/components/ui/button"
+import type { EsignPdfInfo, EsignRequestDetail } from "@/lib/api/types"
+import { formatDateTime, pluralize } from "@/lib/format"
+import { cn } from "@/lib/utils"
+
+import { getEsignDocumentContent } from "./esign.functions"
+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"
+
+type Item = Pick & { pdf: EsignPdfInfo | null }
+
+const BACK = { label: "documents to sign", link: { to: "/dashboard/esign/inbox" } } as const
+
+/**
+ * Signs the documents waiting for the viewer together: a strip of documents to check one by one, where the viewer's
+ * boxes can still be moved, then one "Sign" that opens a single agent window and one PIN for all of them.
+ */
+export function EsignBatchPage({ items, more, me }: { items: Item[]; more: number; me: string }) {
+ const navigate = useNavigate()
+ const [activeId, setActiveId] = useState(items[0]?.request.id ?? null)
+ const [reviewed, setReviewed] = useState>(() => new Set(items[0] ? [items[0].request.id] : []))
+ const [leftOut, setLeftOut] = useState>(new Set())
+ /** The viewer's boxes per request, as they drag them. */
+ const [moved, setMoved] = useState>(new Map())
+
+ // Signed documents leave the batch when the page reloads.
+ 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))
+
+ function open(id: string) {
+ setActiveId(id)
+ setReviewed((current) => new Set(current).add(id))
+ }
+
+ function toggle(id: string) {
+ setLeftOut((current) => {
+ const next = new Set(current)
+ if (!next.delete(id)) next.add(id)
+ return next
+ })
+ }
+
+ if (!active) {
+ return (
+ <>
+
+
+ }>
+ Back to documents to sign
+
+ }
+ />
+
+ >
+ )
+ }
+
+ const signer = active.request.signers.find((s) => s.sub === me && s.status === "pending")
+ const places = moved.get(active.request.id) ?? signer?.places ?? []
+
+ const action =
+ unchecked.length > 0 ? (
+ open(unchecked[0].request.id)}>
+ Next document
+
+ ) : (
+ {
+ const mine = item.request.signers.find((s) => s.sub === me && s.status === "pending")
+ return {
+ requestId: item.request.id,
+ title: item.request.title,
+ places: mine?.signaturesDone === 0 ? moved.get(item.request.id) : undefined,
+ }
+ })}
+ onSigned={() => void navigate({ to: "/dashboard/esign/inbox" })}
+ >
+ {included.length === 1 ? "Sign with CIE" : `Sign ${included.length} documents`}
+
+ )
+
+ return (
+ <>
+ 0 ? action : undefined}
+ left={
+
+ {items.map((item, index) => {
+ const id = item.request.id
+ const out = leftOut.has(id)
+ const current = id === active.request.id
+ return (
+
+ open(id)}
+ className="flex h-full min-w-0 items-center gap-2 rounded-(--pn-r-2) px-2 text-left text-[13px] font-medium outline-none focus-visible:ring-3 focus-visible:ring-(--pn-focus)/40"
+ >
+ {reviewed.has(id) && !out ? (
+
+ ) : (
+
+ {index + 1}
+
+ )}
+ {item.request.title}
+
+ {items.length > 1 && (
+ toggle(id)}
+ />
+ )}
+
+ )
+ })}
+
+ }
+ />
+
+
+
+ setMoved((current) => new Map(current).set(active.request.id, next))}
+ />
+
+
+ {more > 0 && (
+
+ {pluralize(more, "more document")} {more === 1 ? "is" : "are"} waiting: sign these first, then come back
+ for the rest.
+
+ )}
+
+
+
+ {active.request.title}
+
+
+ Requested by {active.request.createdByName} on {formatDateTime(new Date(active.request.createdAt))}
+
+ {signer && (
+
+ {signer.role ? `As ${signer.role}, you sign` : "You sign"}{" "}
+ {places.length > 1
+ ? `in ${places.length} places: ${kindsLabel(places)} on ${pagesLabel(places)}.`
+ : `on ${pagesLabel(places)}.`}
+
+ )}
+
}
+ >
+
Open the request
+
+
+
+ {leftOut.has(active.request.id) ? (
+
toggle(active.request.id)}>
+ Sign it too
+
+ }
+ >
+ Left out: this document isn't signed now.
+
+ ) : (
+
+
+ {unchecked.length > 0
+ ? `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.`}
+
+ {active.signingNow && active.signingNow.sub !== me && (
+
+ {active.signingNow.name} is signing this document right now. Leave it out or try again later.
+
+ )}
+
+ )}
+
+
+
+ >
+ )
+}
+
+function DocumentViewer({
+ item,
+ me,
+ places,
+ onMove,
+}: {
+ item: Item
+ me: string
+ places: Place[]
+ onMove: (places: Place[]) => void
+}) {
+ const contentFn = useServerFn(getEsignDocumentContent)
+ const [base64, setBase64] = useState(null)
+ const [error, setError] = useState(null)
+ const bytes = usePdfBytes(base64)
+ const { request } = item
+
+ useEffect(() => {
+ if (!item.currentDocumentId) return
+ let cancelled = false
+ contentFn({ data: { documentId: item.currentDocumentId } })
+ .then((result) => {
+ if (!cancelled) setBase64(result.base64)
+ })
+ .catch((cause) => {
+ console.error(cause)
+ if (!cancelled) setError("Couldn't load the PDF.")
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [item.currentDocumentId, contentFn])
+
+ const mine = request.signers.find((s) => s.sub === me && s.status === "pending")
+ const first = mine ? placesLeft(places, mine.signaturesDone)[0] : undefined
+ if (error) return {error}
+ return (
+
+ request.signers.flatMap((signer, index) => {
+ if (signer.status !== "pending") return []
+ const isMine = signer.id === mine?.id
+ const left = placesLeft(
+ (isMine ? places : signer.places).map((place, at) => ({ ...place, at })),
+ signer.signaturesDone
+ )
+ return left
+ .filter((place) => place.page === page)
+ .map((place) => (
+ 1 ? index + 1 : undefined}
+ kind={place.kind}
+ state={isMine ? "active" : "pending"}
+ editable={isMine && signer.signaturesDone === 0}
+ onChange={(next) => onMove(places.map((p, at) => (at === place.at ? { ...next, kind: p.kind } : p)))}
+ />
+ ))
+ })
+ }
+ />
+ )
+}
diff --git a/src/features/esign/esign.functions.ts b/src/features/esign/esign.functions.ts
index eb46818..a65af5a 100644
--- a/src/features/esign/esign.functions.ts
+++ b/src/features/esign/esign.functions.ts
@@ -2,6 +2,7 @@ import { createServerFn } from "@tanstack/react-start"
import { z } from "zod"
import { esignReadMiddleware, esignSignWriteMiddleware } from "@/server/auth.middleware"
+import type { BackendClient } from "@/server/backend.server"
/** The audit payloads the backend writes; anything else is dropped. */
const eventData = z
@@ -44,22 +45,44 @@ export const getEsignPendingCount = createServerFn()
.middleware([esignReadMiddleware])
.handler(async ({ context }) => (await context.backend.esign.requests.list.query({ scope: "inbox" })).length)
+/** A request with its audit trail and the pages and signatures of its current PDF. */
+async function requestDetail(backend: BackendClient, id: string) {
+ const detail = await backend.esign.requests.get.query({ id })
+ const document = detail.currentDocumentId
+ ? await backend.esign.documents.get.query({ documentId: detail.currentDocumentId })
+ : null
+ const events = detail.events.map((event) => {
+ const parsed = eventData.safeParse(event.data ?? {})
+ return { ...event, data: parsed.success ? parsed.data : {} }
+ })
+ return { ...detail, events, pdf: document?.pdf ?? null }
+}
+
export const getEsignRequest = createServerFn()
.middleware([esignReadMiddleware])
.validator(z.object({ id: z.uuid() }))
.handler(async ({ data, context }) => {
- const [config, detail] = await Promise.all([
+ const [config, detail, inbox] = await Promise.all([
context.backend.esign.config.query(),
- context.backend.esign.requests.get.query({ id: data.id }),
+ requestDetail(context.backend, data.id),
+ context.backend.esign.requests.list.query({ scope: "inbox" }),
])
- const document = detail.currentDocumentId
- ? await context.backend.esign.documents.get.query({ documentId: detail.currentDocumentId })
- : null
- const events = detail.events.map((event) => {
- const parsed = eventData.safeParse(event.data ?? {})
- return { ...event, data: parsed.success ? parsed.data : {} }
- })
- return { config, ...detail, events, pdf: document?.pdf ?? null, me: context.user.sub }
+ /** Other documents waiting for the caller, which can be signed together with this one. */
+ const alsoWaiting = inbox.filter((r) => r.myTurn && r.id !== data.id).length
+ return { config, ...detail, me: context.user.sub, alsoWaiting }
+ })
+
+/** Documents signed together with one PIN, at most this many. */
+export const MAX_BATCH = 10
+
+/** The documents waiting for the caller's signature, in inbox order, for signing them together. */
+export const getEsignBatch = createServerFn()
+ .middleware([esignReadMiddleware])
+ .handler(async ({ context }) => {
+ const inbox = await context.backend.esign.requests.list.query({ scope: "inbox" })
+ const waiting = inbox.filter((r) => r.myTurn)
+ const items = await Promise.all(waiting.slice(0, MAX_BATCH).map((r) => requestDetail(context.backend, r.id)))
+ return { items, more: Math.max(waiting.length - MAX_BATCH, 0), me: context.user.sub }
})
/** The PDF bytes of a document version, base64-encoded, for the viewer and downloads. */
@@ -68,20 +91,32 @@ export const getEsignDocumentContent = createServerFn()
.validator(z.object({ documentId: z.uuid() }))
.handler(({ data, context }) => context.backend.esign.documents.content.query(data))
+const jobIds = z.object({ jobIds: z.array(z.uuid()).min(1).max(MAX_BATCH) })
+
export const getEsignJobStatus = createServerFn()
.middleware([esignReadMiddleware])
- .validator(z.object({ jobId: z.uuid() }))
- .handler(({ data, context }) => context.backend.esign.jobs.status.query(data))
+ .validator(jobIds)
+ .handler(({ data, context }) => context.backend.esign.jobs.statuses.query(data))
-/** Starts the caller's signature: returns the single-use ticket the agent's page needs. */
+/**
+ * Starts the caller's signatures of one or more requests: returns a single-use ticket per request, all opened in one
+ * agent window. `places` are the caller's boxes as they dragged them, one per place, in order.
+ */
export const startEsignSignature = createServerFn({ method: "POST" })
.middleware([esignSignWriteMiddleware])
- .validator(z.object({ requestId: z.uuid(), placement: placement.optional() }))
+ .validator(
+ z.object({
+ items: z
+ .array(z.object({ requestId: z.uuid(), places: z.array(placement).max(20).optional() }))
+ .min(1)
+ .max(MAX_BATCH),
+ })
+ )
.handler(({ data, context }) => context.backend.esign.jobs.create.mutate(data))
export const cancelEsignSignature = createServerFn({ method: "POST" })
.middleware([esignSignWriteMiddleware])
- .validator(z.object({ jobId: z.uuid() }))
+ .validator(jobIds)
.handler(({ data, context }) => context.backend.esign.jobs.cancel.mutate(data))
export const declineEsignRequest = createServerFn({ method: "POST" })
diff --git a/src/features/esign/new-request-page.tsx b/src/features/esign/new-request-page.tsx
index 8043ba0..fec5bc6 100644
--- a/src/features/esign/new-request-page.tsx
+++ b/src/features/esign/new-request-page.tsx
@@ -1,6 +1,19 @@
import { useNavigate } from "@tanstack/react-router"
import { useServerFn } from "@tanstack/react-start"
-import { ArrowDown, ArrowUp, Cloud, FileUp, MapPin, Search, Trash2, UserPlus } from "lucide-react"
+import {
+ ArrowDown,
+ ArrowUp,
+ ChevronDown,
+ Cloud,
+ FileUp,
+ MapPin,
+ PenLine,
+ Plus,
+ Search,
+ Stamp,
+ Trash2,
+ UserPlus,
+} from "lucide-react"
import { type DragEvent, useEffect, useId, useMemo, useState } from "react"
import {
@@ -12,6 +25,17 @@ import {
LoadingButton,
SectionCard,
} from "@/components/primitives"
+import {
+ Menu,
+ MenuContent,
+ MenuGroup,
+ MenuItem,
+ MenuLabel,
+ MenuRadioGroup,
+ MenuRadioItem,
+ MenuSeparator,
+ MenuTrigger,
+} from "@/components/primitives/menu"
import { appToast, PageBar, PageContent } from "@/components/shell"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
@@ -24,12 +48,18 @@ import { cn } from "@/lib/utils"
import { DriveBrowser, type DriveFolder } from "./drive-browser"
import { getEsignDocumentContent } from "./esign.functions"
import { extractTextAnchors, type PageSize, PdfViewer, sizeOnPage, type TextAnchor, usePdfBytes } from "./pdf-viewer"
+import { kindsLabel, type Place, pagesLabel, withSignature } from "./places"
import { createEsignRequest, importEsignDriveDocument, uploadEsignDocument } from "./requests.functions"
import { type BoxPlacement, SignatureBox } from "./signature-box"
type Staged = { id: string; fileName: string; pdf: EsignPdfInfo; folder: DriveFolder | null }
-/** `manual`: the user moved the box, so a role no longer places it. */
-type SignerDraft = { sub: string; name: string; role: string; placement: BoxPlacement; manual: boolean }
+/** `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 }
+/** 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 }
+
+/** Boxes one signer may have in a document (the backend's limit). */
+const MAX_PLACES = 20
const ROLES = ["Presidente", "Vicepresidente", "Segretario", "Tesoriere", "Consigliere", "Socio"]
/** A signature box of 190 × 62 pt, the size of a typical handwritten-signature space. */
@@ -110,7 +140,10 @@ export function NewEsignRequestPage({
}, [bytes])
const [signers, setSigners] = useState([])
- const [placing, setPlacing] = useState(null)
+ const [placing, setPlacing] = useState(null)
+ const [focus, setFocus] = useState<{ page: number; y: number } | null>(null)
+ /** The box whose menu is open, so it stands out among the same person's boxes. */
+ const [highlight, setHighlight] = useState<{ signer: string; place: number } | null>(null)
const [ordered, setOrdered] = useState(true)
const [search, setSearch] = useState("")
@@ -136,7 +169,9 @@ export function NewEsignRequestPage({
setTitle(baseName(next.fileName))
setFileName(`${baseName(next.fileName)} - firmato.pdf`)
if (next.folder) setFolder(next.folder)
- setSigners((current) => current.map((s, i) => ({ ...s, placement: defaultPlacement(i, next.pdf.pages) })))
+ setSigners((current) =>
+ current.map((s, i) => ({ ...s, places: [{ ...defaultPlacement(i, next.pdf.pages), kind: "signature" }] }))
+ )
}
async function upload(file: File) {
@@ -179,16 +214,12 @@ export function NewEsignRequestPage({
}
function addSigner(person: EsignProfile) {
+ const where = defaultPlacement(signers.length, pages)
setSigners((current) => [
...current,
- {
- sub: person.sub,
- name: person.name,
- role: "",
- placement: defaultPlacement(current.length, pages),
- manual: false,
- },
+ { sub: person.sub, name: person.name, role: "", places: [{ ...where, kind: "signature" }], manual: false },
])
+ setFocus({ page: where.page, y: where.y })
setSearch("")
}
@@ -205,19 +236,43 @@ export function NewEsignRequestPage({
})
}
+ function updatePlaces(index: number, update: (places: Place[]) => Place[], manual = true) {
+ setSigners((current) =>
+ current.map((s, i) =>
+ i === index ? { ...s, places: withSignature(update(s.places)), manual: s.manual || manual } : 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)
+ }
+
+ function removePlace(index: number, at: number) {
+ updatePlaces(index, (places) => places.filter((_, i) => i !== at), false)
+ }
+
function place(page: number, x: number, y: number) {
if (placing === null) return
const size = sizeOnPage(pages[page], BOX_PT.width, BOX_PT.height)
- updateSigner(placing, {
- placement: {
- page,
- width: size.width,
- height: size.height,
- x: Math.min(Math.max(x - size.width / 2, 0), 1 - size.width),
- y: Math.min(Math.max(y - size.height / 2, 0), 1 - size.height),
- },
- manual: true,
- })
+ const placement = {
+ page,
+ width: size.width,
+ height: size.height,
+ x: Math.min(Math.max(x - size.width / 2, 0), 1 - size.width),
+ y: Math.min(Math.max(y - size.height / 2, 0), 1 - size.height),
+ }
+ 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
+ )
setPlacing(null)
}
@@ -253,7 +308,7 @@ export function NewEsignRequestPage({
title: title.trim(),
documentId: staged.id,
ordered,
- signers: signers.map((s) => ({ sub: s.sub, role: s.role.trim() || null, placement: s.placement })),
+ signers: signers.map((s) => ({ sub: s.sub, role: s.role.trim() || null, places: s.places })),
output:
saveToDrive && folder ? { driveId: folder.driveId, folderId: folder.id, fileName: fileName.trim() } : null,
email: recipients.length ? { to: recipients, message: message.trim() || null } : null,
@@ -294,7 +349,9 @@ export function NewEsignRequestPage({
{placing !== null && (
- Click on the page where {signers[placing]?.name} signs.{" "}
+ {placing.place === null
+ ? `Click where ${signers[placing.signer]?.name} signs again.`
+ : `Click the new spot for ${signers[placing.signer]?.name}.`}{" "}
setPlacing(null)}>
Cancel
@@ -305,26 +362,28 @@ export function NewEsignRequestPage({
pages={pages}
cursor={placing !== null ? "crosshair" : "default"}
onPageClick={place}
- focus={
- signers.at(-1)
- ? { page: signers[signers.length - 1].placement.page, y: signers[signers.length - 1].placement.y }
- : null
- }
+ focus={focus}
className="h-[calc(100dvh-210px)] min-h-[480px]"
overlay={(page) =>
- signers.map((s, index) =>
- s.placement.page === page ? (
- updateSigner(index, { placement, manual: true })}
- />
- ) : null
+ signers.flatMap((s, index) =>
+ s.places.map((p, at) =>
+ p.page === page ? (
+ movePlace(index, at, placement)}
+ onRemove={s.places.length > 1 ? () => removePlace(index, at) : undefined}
+ />
+ ) : null
+ )
)
}
/>
@@ -459,30 +518,46 @@ export function NewEsignRequestPage({
onClick={() => setSigners((current) => current.filter((_, i) => i !== index))}
/>
-
- {
- const role = event.target.value
- const placed = s.manual ? null : placementForRole(role, anchors, pages)
- updateSigner(index, placed ? { role, placement: placed } : { role })
- }}
- />
- setPlacing(placing === index ? null : index)}
- >
- Place
-
-
- Page {s.placement.page + 1}
+ {
+ 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 }
+ )
+ }}
+ />
+ {
+ setHighlight(at === null ? null : { signer: s.sub, place: at })
+ const p = at === null ? undefined : s.places[at]
+ if (p) setFocus({ page: p.page, y: p.y + p.height / 2 })
+ }}
+ onKind={(at, kind) =>
+ updatePlaces(index, (places) => places.map((p, i) => (i === at ? { ...p, kind } : p)), false)
+ }
+ onMove={(at) => setPlacing({ signer: index, place: at })}
+ onRemove={(at) => removePlace(index, at)}
+ onAdd={() =>
+ setPlacing(
+ placing?.signer === index && placing.place === null ? null : { signer: index, place: null }
+ )
+ }
+ />
))}
@@ -642,3 +717,92 @@ export function NewEsignRequestPage({
>
)
}
+
+/** A signer's boxes, one control each, listed under their name: the person appears once however many places. */
+function PlacesEditor({
+ name,
+ places,
+ disabled,
+ adding,
+ onShow,
+ onKind,
+ onMove,
+ onRemove,
+ onAdd,
+}: {
+ name: string
+ places: Place[]
+ disabled: boolean
+ adding: boolean
+ /** A place's menu opened (its index) or closed (null). */
+ onShow: (at: number | null) => void
+ onKind: (at: number, kind: Place["kind"]) => void
+ onMove: (at: number) => void
+ onRemove: (at: number) => void
+ onAdd: () => void
+}) {
+ const signatures = places.filter((p) => p.kind === "signature").length
+ return (
+
+
+ {places.map((p, at) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: places have no identity but their order
+
onShow(open ? at : null)}>
+
+ }
+ >
+ {p.kind === "mark" ? : }
+ {p.kind === "mark" ? "Mark" : "Signature"} · p. {p.page + 1}
+
+
+
+
+ Page {p.page + 1}
+ onKind(at, value === "mark" ? "mark" : "signature")}
+ >
+ Full signature
+
+ Mark only
+
+
+
+ A mark shows the signature again without signing twice.
+
+
+
+ onMove(at)}>
+ Move to another spot
+
+ onRemove(at)}>
+ Remove
+
+
+
+ ))}
+
= MAX_PLACES}
+ onClick={onAdd}
+ >
+ Add place
+
+
+ {places.length > 1 && (
+
+ {kindsLabel(places)} on {pagesLabel(places)}, signed with one PIN.
+
+ )}
+
+ )
+}
diff --git a/src/features/esign/places.ts b/src/features/esign/places.ts
new file mode 100644
index 0000000..a786e91
--- /dev/null
+++ b/src/features/esign/places.ts
@@ -0,0 +1,34 @@
+import type { BoxPlacement } from "./signature-box"
+
+/** One of a signer's boxes: a full signature of its own, or a mark repeating their first signature. */
+export type Place = BoxPlacement & { kind: "signature" | "mark" }
+
+export function pagesLabel(places: Pick[]) {
+ const pages = [...new Set(places.map((p) => p.page + 1))].sort((a, b) => a - b)
+ if (pages.length === 1) return `page ${pages[0]}`
+ return `pages ${pages.slice(0, -1).join(", ")} and ${pages.at(-1)}`
+}
+
+/** "2 signatures and 1 mark", or "1 signature". */
+export function kindsLabel(places: Pick[]) {
+ const signatures = places.filter((p) => p.kind === "signature").length
+ const marks = places.length - signatures
+ const parts = [`${signatures} ${signatures === 1 ? "signature" : "signatures"}`]
+ if (marks > 0) parts.push(`${marks} ${marks === 1 ? "mark" : "marks"}`)
+ return parts.join(" and ")
+}
+
+/** Keeps at least one full signature: when none is left, the first place becomes one. */
+export function withSignature(places: Place[]): Place[] {
+ if (places.length === 0 || places.some((p) => p.kind === "signature")) return places
+ return places.map((p, i) => (i === 0 ? { ...p, kind: "signature" } : p))
+}
+
+/**
+ * The places still to sign: full signatures after the first `signaturesDone`, and the marks, which the first
+ * signature draws, while it isn't done.
+ */
+export function placesLeft>(places: T[], signaturesDone: number): T[] {
+ let signature = 0
+ return places.filter((p) => (p.kind === "mark" ? signaturesDone === 0 : signature++ >= signaturesDone))
+}
diff --git a/src/features/esign/request-page.tsx b/src/features/esign/request-page.tsx
index 0e71d75..a927e19 100644
--- a/src/features/esign/request-page.tsx
+++ b/src/features/esign/request-page.tsx
@@ -1,6 +1,6 @@
-import { useRouter } from "@tanstack/react-router"
+import { Link, useRouter } from "@tanstack/react-router"
import { useServerFn } from "@tanstack/react-start"
-import { Ban, Download, FolderUp, Mail, RotateCw, X } from "lucide-react"
+import { Ban, Download, Files, FolderUp, Mail, RotateCw, X } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import {
@@ -22,10 +22,11 @@ import { cn } from "@/lib/utils"
import { declineEsignRequest, getEsignDocumentContent, uploadSignedEsignDocument } from "./esign.functions"
import { PdfViewer, usePdfBytes } from "./pdf-viewer"
+import { kindsLabel, type Place, pagesLabel, placesLeft } from "./places"
import { RequestStatusBadge, SignerStatusBadge, signedProgress } from "./request-status"
import { cancelEsignRequest, retryEsignDelivery } from "./requests.functions"
import { SignWithCie } from "./sign-with-cie"
-import { type BoxPlacement, SignatureBox } from "./signature-box"
+import { SignatureBox } from "./signature-box"
type EventData = { sub?: string; name?: string; path?: string; to?: string[]; error?: string; reason?: string | null }
@@ -34,6 +35,8 @@ type Detail = Omit & {
pdf: EsignPdfInfo | null
me: string
config: { drives: EsignDrive[]; canManage: boolean }
+ /** Other documents waiting for the viewer's signature. */
+ alsoWaiting: number
}
function eventText(event: Detail["events"][number], names: Map) {
@@ -107,12 +110,18 @@ export function EsignRequestPage({ detail }: { detail: Detail }) {
const mySigner = request.signers.find((s) => s.sub === me && s.status === "pending")
const myTurn = request.myTurn && mySigner !== undefined
- const [placement, setPlacement] = useState(mySigner?.placement ?? null)
- useEffect(() => setPlacement(mySigner?.placement ?? null), [mySigner?.placement])
+ // 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])
+ const myDone = mySigner?.signaturesDone ?? 0
+ const myLeft = placesLeft(
+ myPlaces.map((place, at) => ({ ...place, at })),
+ myDone
+ )
const [focus, setFocus] = useState<{ page: number; y: number } | null>(null)
useEffect(() => {
- if (myTurn && mySigner)
- setFocus({ page: mySigner.placement.page, y: mySigner.placement.y + mySigner.placement.height / 2 })
+ const first = mySigner && placesLeft(mySigner.places, mySigner.signaturesDone)[0]
+ if (myTurn && first) setFocus({ page: first.page, y: first.y + first.height / 2 })
}, [myTurn, mySigner])
// Delivery (OneDrive, email) runs after the last signature: follow it until it settles.
@@ -210,8 +219,7 @@ export function EsignRequestPage({ detail }: { detail: Detail }) {
{myTurn && (
)}
@@ -238,23 +246,34 @@ export function EsignRequestPage({ detail }: { detail: Detail }) {
focus={focus}
className="h-[calc(100dvh-210px)] min-h-[480px]"
overlay={(page) =>
- request.signers.map((signer, index) => {
- if (signer.status !== "pending") return null
- const mine = myTurn && signer.id === mySigner?.id && placement
- const where = mine ? placement : signer.placement
- if (where.page !== page) return null
- return (
- 1 ? index + 1 : undefined}
- state={mine ? "active" : "pending"}
- editable={Boolean(mine)}
- onChange={setPlacement}
- />
- )
+ request.signers.flatMap((signer, index) => {
+ if (signer.status !== "pending") return []
+ const mine = myTurn && signer.id === mySigner?.id
+ const left = mine
+ ? myLeft
+ : placesLeft(
+ signer.places.map((place, at) => ({ ...place, at })),
+ signer.signaturesDone
+ )
+ return left
+ .filter((place) => place.page === page)
+ .map((place) => (
+ 1 ? index + 1 : undefined}
+ kind={place.kind}
+ state={mine ? "active" : "pending"}
+ editable={mine && myDone === 0}
+ onChange={(moved) =>
+ setMyPlaces((current) =>
+ current.map((p, at) => (at === place.at ? { ...moved, kind: p.kind } : p))
+ )
+ }
+ />
+ ))
})
}
/>
@@ -282,9 +301,30 @@ export function EsignRequestPage({ detail }: { detail: Detail }) {
{myTurn && (
- 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
+ ? `${myDone} of ${kindsLabel(myPlaces.filter((p) => p.kind === "signature"))} done. Choose Sign with CIE at the top to finish.`
+ : myPlaces.length > 1
+ ? `You sign in ${myPlaces.length} places: ${kindsLabel(myPlaces)} on ${pagesLabel(myPlaces)}. Drag the blue boxes to adjust them, then choose Sign with CIE at the top: one PIN signs them all.`
+ : "Drag the blue box to adjust where your signature goes, then choose Sign with CIE at the top."}{" "}
+ You need the card, its PIN and the NFC reader.
+ {detail.alsoWaiting > 0 && (
+
+
+ {detail.alsoWaiting === 1
+ ? "1 more document is waiting for you."
+ : `${detail.alsoWaiting} more documents are waiting for you.`}
+
+ }
+ >
+ Sign them together
+
+
+ )}
{signingNow && signingNow.sub !== me && (
{signingNow.name} is signing right now. Try again in a few minutes.
@@ -303,6 +343,13 @@ export function EsignRequestPage({ detail }: { detail: Detail }) {
Download the PDF, sign it with the official Software CIE or CieSign app, then upload the signed
file. It must be this exact version with your signature added.
+ {myPlaces.length > 1 && (
+
+ The official app adds a single signature, and uploading it completes your part: your other{" "}
+ {myPlaces.length - 1 === 1 ? "place stays" : "places stay"} empty. Use PoliNetwork eSign to sign
+ in every place.
+
+ )}
Signed PDF
- {signer.role ?? }
+ {signer.role ?? (signer.places.length > 1 ? null : )}
+ {signer.places.length > 1 && (
+ <>
+ {signer.role && " · "}
+ {kindsLabel(signer.places)}
+ >
+ )}
{signer.signedAt && <> · {formatDateTime(new Date(signer.signedAt))}>}
{signer.status === "signed" && signer.certificateIssuer && (
diff --git a/src/features/esign/requests-page.tsx b/src/features/esign/requests-page.tsx
index 75b5af7..b0710a2 100644
--- a/src/features/esign/requests-page.tsx
+++ b/src/features/esign/requests-page.tsx
@@ -1,5 +1,5 @@
import { Link, useNavigate } from "@tanstack/react-router"
-import { FileSignature, Plus } from "lucide-react"
+import { FileSignature, Files, Plus } from "lucide-react"
import { useDeferredValue, useMemo, useState } from "react"
import { AvatarGroup, DataTable, type DataTableColumn, EmptyState } from "@/components/primitives"
@@ -100,6 +100,7 @@ export function EsignRequestsPage({ requests, scope }: { requests: EsignRequest[
[requests, filter, deferred, inbox]
)
const narrowed = visible.length !== requests.length
+ const waiting = inbox ? requests.filter((r) => r.myTurn).length : 0
const description = inbox
? requests.length
@@ -111,7 +112,18 @@ export function EsignRequestsPage({ requests, scope }: { requests: EsignRequest[
<>
: undefined}
+ right={
+ canManage || waiting > 1 ? (
+ <>
+ {canManage && 1 ? "outline" : "default"} />}
+ {waiting > 1 && (
+ }>
+ Sign {waiting} together
+
+ )}
+ >
+ ) : undefined
+ }
left={
// Managers keep the Mine/Everyone switch even when they have no requests of their own.
requests.length > 0 || (!inbox && canManage) ? (
diff --git a/src/features/esign/requests.functions.ts b/src/features/esign/requests.functions.ts
index 62027ca..57c4688 100644
--- a/src/features/esign/requests.functions.ts
+++ b/src/features/esign/requests.functions.ts
@@ -60,7 +60,16 @@ export const createEsignRequest = createServerFn({ method: "POST" })
documentId: z.uuid(),
ordered: z.boolean(),
signers: z
- .array(z.object({ sub: z.string().min(1), role: z.string().trim().max(80).nullable(), placement }))
+ .array(
+ z.object({
+ sub: z.string().min(1),
+ role: z.string().trim().max(80).nullable(),
+ places: z
+ .array(placement.extend({ kind: z.enum(["signature", "mark"]) }))
+ .min(1)
+ .max(20),
+ })
+ )
.min(1)
.max(10),
output: z
diff --git a/src/features/esign/sign-with-cie.tsx b/src/features/esign/sign-with-cie.tsx
index 27f457c..d6248c0 100644
--- a/src/features/esign/sign-with-cie.tsx
+++ b/src/features/esign/sign-with-cie.tsx
@@ -1,6 +1,8 @@
import { useRouter } from "@tanstack/react-router"
import { useServerFn } from "@tanstack/react-start"
import {
+ Circle,
+ CircleAlert,
CircleCheck,
ExternalLink,
KeyRound,
@@ -22,23 +24,31 @@ import {
import { appToast } from "@/components/shell"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
+import { pluralize } from "@/lib/format"
import { cn } from "@/lib/utils"
import { AGENT_LAUNCH_URL, agentSignUrl, isAgentOrigin } from "./agent"
import { cancelEsignSignature, getEsignJobStatus, startEsignSignature } from "./esign.functions"
import type { BoxPlacement } from "./signature-box"
+/** A document to sign; `places` are the signer's boxes as they dragged them, one per place, in order. */
+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 }
+
type Phase =
| { kind: "starting" }
- | { kind: "waiting"; jobId: string; url: string; since: number }
- | { kind: "pin"; jobId: string; url: string; errorCode: string | null; errorMessage: string | null }
+ | { kind: "waiting"; jobs: Job[]; url: string; since: number }
+ | { kind: "signing"; jobs: Job[]; url: string }
| { kind: "ended"; reason: "cancelled" | "expired" | "failed"; message: string }
const POLL_MS = 1200
/** After this long without the agent fetching the job, it is probably not running. */
const AGENT_TIMEOUT_MS = 7000
+const OPEN = new Set(["pending", "prepared"])
-/** What the agent reported, by its error code (esign `internal/token`). */
+/** 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_locked", "The PIN is blocked. Unblock it with the PUK in the CIE ID app, then try again."],
@@ -52,8 +62,14 @@ const AGENT_ERRORS = new Map([
["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."],
])
+function errorText(state: JobState | undefined) {
+ if (!state?.errorCode || state.errorCode === "cancelled") return null
+ return AGENT_ERRORS.get(state.errorCode) ?? state.errorMessage ?? "The signature failed. Try again."
+}
+
function openPopup(): Window | null {
const width = 460
const height = 680
@@ -72,36 +88,35 @@ function openPopup(): Window | null {
}
/**
- * "Sign with CIE": starts a signature job, opens the agent's page in a popup (where the PIN is typed) and follows the
- * job until the signature is saved. The dashboard never talks to the agent: browsers block HTTPS pages from fetching
- * loopback addresses, but they allow opening a window there.
+ * "Sign with CIE": starts the signature of one or more documents, opens the agent's page in a popup (where one PIN
+ * signs them all) and follows the jobs until every signature is saved. The dashboard never talks to the agent:
+ * browsers block HTTPS pages from fetching loopback addresses, but they allow opening a window there.
*/
export function SignWithCie({
- requestId,
- placement,
+ items,
disabled,
- children = "Sign with CIE",
+ children,
+ onSigned,
}: {
- requestId: string
- placement?: BoxPlacement
+ items: SignItem[]
disabled?: boolean
children?: ReactNode
+ /** After the signatures are saved and the page data reloaded. */
+ onSigned?: () => void
}) {
const router = useRouter()
const startFn = useServerFn(startEsignSignature)
const cancelFn = useServerFn(cancelEsignSignature)
const statusFn = useServerFn(getEsignJobStatus)
const [phase, setPhase] = useState(null)
+ const [states, setStates] = useState>(new Map())
const [now, setNow] = useState(Date.now())
const [error, setError] = useState(null)
const popupRef = useRef(null)
const finishing = useRef(false)
+ const batch = items.length > 1
- const finish = useCallback(async () => {
- if (finishing.current) return
- finishing.current = true
- setPhase(null)
- appToast.success("Document signed.")
+ const reload = useCallback(async () => {
try {
await router.invalidate({ sync: true })
} catch (cause) {
@@ -109,40 +124,49 @@ export function SignWithCie({
}
}, [router])
- const jobId = phase && "jobId" in phase ? phase.jobId : null
+ const finish = useCallback(
+ async (count: number, total: number) => {
+ if (finishing.current) return
+ finishing.current = true
+ setPhase(null)
+ popupRef.current = null
+ if (count === total) appToast.success(count === 1 ? "Document signed." : `${count} documents signed.`)
+ else appToast.warning(`${count} of ${total} documents signed.`)
+ await reload()
+ onSigned?.()
+ },
+ [reload, onSigned]
+ )
+
+ const jobs = phase && "jobs" in phase ? phase.jobs : null
useEffect(() => {
- if (!jobId) return
+ if (!jobs) return
let stopped = false
const poll = async () => {
try {
- const status = await statusFn({ data: { jobId } })
+ const statuses = await statusFn({ data: { jobIds: jobs.map((j) => j.jobId) } })
if (stopped) return
- if (status.status === "completed") return void finish()
- if (status.status === "cancelled" || status.status === "expired" || status.status === "failed") {
+ 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: status.status,
+ reason,
message:
- status.status === "expired"
+ reason === "expired"
? "The signing link expired. Start again."
- : status.status === "cancelled"
+ : reason === "cancelled"
? "The signature was cancelled."
- : (status.errorMessage ?? "The signature failed."),
+ : (ended.errorMessage ?? "The signature failed."),
})
return
}
- setPhase((current) => {
- if (!current || !("jobId" in current) || current.jobId !== jobId) return current
- if (!status.seen) return current
- return {
- kind: "pin",
- jobId,
- url: current.url,
- errorCode: status.errorCode,
- errorMessage: status.errorMessage,
- }
- })
+ if (statuses.some((s) => s.seen))
+ setPhase((current) => (current?.kind === "waiting" ? { kind: "signing", jobs, url: current.url } : current))
} catch (cause) {
console.error(cause)
}
@@ -161,10 +185,11 @@ export function SignWithCie({
window.clearInterval(tick)
window.removeEventListener("message", onMessage)
}
- }, [jobId, statusFn, finish])
+ }, [jobs, statusFn, finish])
async function start() {
setError(null)
+ setStates(new Map())
finishing.current = false
popupRef.current?.close()
let popup: Window | null = null
@@ -173,10 +198,21 @@ export function SignWithCie({
popup = openPopup()
popupRef.current = popup
setPhase({ kind: "starting" })
- const job = await startFn({ data: { requestId, placement } })
- const url = agentSignUrl(job.ticket, window.location.origin)
+ const started = await startFn({
+ data: { items: items.map((item) => ({ requestId: item.requestId, places: item.places })) },
+ })
+ const url = agentSignUrl(
+ started.map((s) => s.ticket),
+ window.location.origin
+ )
if (popup && !popup.closed) popup.location.href = url
- setPhase({ kind: "waiting", jobId: job.jobId, url, since: Date.now() })
+ const titles = new Map(items.map((item) => [item.requestId, item.title]))
+ setPhase({
+ kind: "waiting",
+ jobs: started.map((s) => ({ jobId: s.jobId, requestId: s.requestId, title: titles.get(s.requestId) ?? "" })),
+ url,
+ since: Date.now(),
+ })
setNow(Date.now())
} catch (cause) {
console.error(cause)
@@ -186,17 +222,21 @@ export function SignWithCie({
}
}
- async function cancel() {
+ /** Closes the dialog, cancelling what isn't signed yet; reloads when part of a batch was signed. */
+ async function close() {
const current = phase
setPhase(null)
popupRef.current?.close()
- if (current && "jobId" in current) {
+ if (!current || !("jobs" in current)) return
+ const open = current.jobs.filter((j) => OPEN.has(states.get(j.jobId)?.status ?? "pending"))
+ if (open.length > 0) {
try {
- await cancelFn({ data: { jobId: current.jobId } })
+ await cancelFn({ data: { jobIds: open.map((j) => j.jobId) } })
} catch (cause) {
console.error(cause)
}
}
+ if (open.length < current.jobs.length) await reload()
}
function reopen(url: string) {
@@ -204,20 +244,26 @@ export function SignWithCie({
}
const silent = phase?.kind === "waiting" && now - phase.since > AGENT_TIMEOUT_MS
+ const signed = jobs?.filter((j) => states.get(j.jobId)?.status === "completed").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
return (
<>
- {children}
+ {children ?? (batch ? `Sign ${items.length} documents` : "Sign with CIE")}
{error && {error} }
- !open && void cancel()}>
+ !open && void close()}>
{phase?.kind === "waiting" && !silent && (
} title="Opening PoliNetwork eSign">
@@ -251,13 +297,24 @@ export function SignWithCie({
)}
- {phase?.kind === "pin" && (
+ {phase?.kind === "signing" && (
} title="Type your PIN in the agent window">
- Keep the CIE on the reader. The PIN goes only to the card, never to PoliNetwork.
- {phase.errorCode && phase.errorCode !== "cancelled" && (
-
- {AGENT_ERRORS.get(phase.errorCode) ?? phase.errorMessage ?? "The signature failed. Try again."}
-
+
+ Keep the CIE on the reader. {batch ? `One PIN signs all ${phase.jobs.length} documents. ` : ""}
+ The PIN goes only to the card, never to PoliNetwork.
+
+ {sharedError && {sharedError} }
+ {batch && (
+ <>
+
+ {signed} of {pluralize(phase.jobs.length, "document")} signed
+
+
+ {phase.jobs.map((job) => (
+
+ ))}
+
+ >
)}
reopen(phase.url)}>
Show the agent window
@@ -283,17 +340,15 @@ export function SignWithCie({
)}
{phase?.kind === "ended" ? (
-
{
- popupRef.current?.close()
- setPhase(null)
- }}
- >
+ void close()}>
Close
+ ) : signed > 0 ? (
+ void close()}>
+ Stop here
+
) : (
- void cancel()}>
+ void close()}>
Cancel signature
)}
@@ -304,6 +359,26 @@ export function SignWithCie({
)
}
+function JobRow({ job, state, hideError }: { job: Job; state: JobState | undefined; hideError: boolean }) {
+ const done = state?.status === "completed"
+ const failure = done || hideError ? null : errorText(state)
+ return (
+
+ {done ? (
+
+ ) : failure ? (
+
+ ) : (
+
+ )}
+
+ {job.title}
+ {failure && {failure} }
+
+
+ )
+}
+
function Status({
icon,
title,
diff --git a/src/features/esign/signature-box.tsx b/src/features/esign/signature-box.tsx
index aac5bb2..00f9869 100644
--- a/src/features/esign/signature-box.tsx
+++ b/src/features/esign/signature-box.tsx
@@ -12,9 +12,13 @@ type SignatureBoxProps = {
role?: string | null
/** 1-based order shown in the corner when several signers share a document. */
number?: number
+ /** A mark repeats the signer's first signature instead of being a signature of its own. */
+ kind?: "signature" | "mark"
state: "active" | "pending" | "signed"
/** Draggable, resizable and movable with the arrow keys. */
editable?: boolean
+ /** Drawn with a ring, e.g. while its menu is open elsewhere on the page. */
+ highlighted?: boolean
onChange?: (placement: BoxPlacement) => void
onRemove?: () => void
}
@@ -48,12 +52,16 @@ export function SignatureBox({
label,
role,
number,
+ kind = "signature",
state,
editable,
+ highlighted,
onChange,
onRemove,
}: SignatureBoxProps) {
const ref = useRef(null)
+ const detail = kind === "mark" ? (role ? `Mark · ${role}` : "Mark") : role
+ const what = kind === "mark" ? "Mark" : "Signature"
const drag = useRef<{ mode: "move" | "resize"; startX: number; startY: number; origin: BoxPlacement } | null>(null)
function start(event: PointerEvent, mode: "move" | "resize") {
@@ -113,8 +121,8 @@ export function SignatureBox({
tabIndex={editable ? 0 : -1}
aria-label={
editable
- ? `Signature of ${label}, page ${placement.page + 1}. Arrow keys move it, Alt with arrow keys resizes it.`
- : `Signature of ${label}, page ${placement.page + 1}`
+ ? `${what} of ${label}, page ${placement.page + 1}. Arrow keys move it, Alt with arrow keys resizes it.`
+ : `${what} of ${label}, page ${placement.page + 1}`
}
onKeyDown={onKeyDown}
onPointerDown={(event) => start(event, "move")}
@@ -130,6 +138,8 @@ export function SignatureBox({
state === "pending" &&
"border-dashed border-(--pn-n-500) bg-[color-mix(in_oklab,white_70%,transparent)] text-(--pn-n-600)",
state === "signed" && "border-(--pn-success-solid) bg-transparent text-(--pn-success-solid)",
+ kind === "mark" && state === "active" && "border-dashed",
+ highlighted && "ring-4 ring-(--pn-accent)/35",
editable && "cursor-move"
)}
style={{
@@ -149,7 +159,7 @@ export function SignatureBox({
)}
{label}
- {role && {role} }
+ {detail && {detail} }
>
)}
{state === "signed" && (
@@ -160,7 +170,7 @@ export function SignatureBox({
{editable && onRemove && (
event.stopPropagation()}
onClick={onRemove}
className="absolute top-0.5 right-0.5 grid size-6 place-items-center rounded-full text-(--pn-fg-muted) opacity-0 transition-opacity duration-120 group-hover/box:opacity-100 group-focus-within/box:opacity-100 hover:bg-(--pn-hover) hover:text-(--pn-accent)"
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index c344be3..667fc7e 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -29,6 +29,7 @@ import { Route as DashboardTelegramGroupsRouteImport } from './routes/dashboard/
import { Route as DashboardTelegramGrantsRouteImport } from './routes/dashboard/telegram/grants'
import { Route as DashboardReportsResolvedRouteImport } from './routes/dashboard/reports/resolved'
import { Route as DashboardReportsGroupLinksRouteImport } from './routes/dashboard/reports/group-links'
+import { Route as DashboardEsignSignRouteImport } from './routes/dashboard/esign/sign'
import { Route as DashboardEsignSetupRouteImport } from './routes/dashboard/esign/setup'
import { Route as DashboardEsignNewRouteImport } from './routes/dashboard/esign/new'
import { Route as DashboardEsignInboxRouteImport } from './routes/dashboard/esign/inbox'
@@ -146,6 +147,11 @@ const DashboardReportsGroupLinksRoute =
path: '/reports/group-links',
getParentRoute: () => DashboardRoute,
} as any)
+const DashboardEsignSignRoute = DashboardEsignSignRouteImport.update({
+ id: '/esign/sign',
+ path: '/esign/sign',
+ getParentRoute: () => DashboardRoute,
+} as any)
const DashboardEsignSetupRoute = DashboardEsignSetupRouteImport.update({
id: '/esign/setup',
path: '/esign/setup',
@@ -234,6 +240,7 @@ export interface FileRoutesByFullPath {
'/dashboard/esign/inbox': typeof DashboardEsignInboxRoute
'/dashboard/esign/new': typeof DashboardEsignNewRoute
'/dashboard/esign/setup': typeof DashboardEsignSetupRoute
+ '/dashboard/esign/sign': typeof DashboardEsignSignRoute
'/dashboard/reports/group-links': typeof DashboardReportsGroupLinksRoute
'/dashboard/reports/resolved': typeof DashboardReportsResolvedRoute
'/dashboard/telegram/grants': typeof DashboardTelegramGrantsRoute
@@ -268,6 +275,7 @@ export interface FileRoutesByTo {
'/dashboard/esign/inbox': typeof DashboardEsignInboxRoute
'/dashboard/esign/new': typeof DashboardEsignNewRoute
'/dashboard/esign/setup': typeof DashboardEsignSetupRoute
+ '/dashboard/esign/sign': typeof DashboardEsignSignRoute
'/dashboard/reports/group-links': typeof DashboardReportsGroupLinksRoute
'/dashboard/reports/resolved': typeof DashboardReportsResolvedRoute
'/dashboard/telegram/grants': typeof DashboardTelegramGrantsRoute
@@ -304,6 +312,7 @@ export interface FileRoutesById {
'/dashboard/esign/inbox': typeof DashboardEsignInboxRoute
'/dashboard/esign/new': typeof DashboardEsignNewRoute
'/dashboard/esign/setup': typeof DashboardEsignSetupRoute
+ '/dashboard/esign/sign': typeof DashboardEsignSignRoute
'/dashboard/reports/group-links': typeof DashboardReportsGroupLinksRoute
'/dashboard/reports/resolved': typeof DashboardReportsResolvedRoute
'/dashboard/telegram/grants': typeof DashboardTelegramGrantsRoute
@@ -341,6 +350,7 @@ export interface FileRouteTypes {
| '/dashboard/esign/inbox'
| '/dashboard/esign/new'
| '/dashboard/esign/setup'
+ | '/dashboard/esign/sign'
| '/dashboard/reports/group-links'
| '/dashboard/reports/resolved'
| '/dashboard/telegram/grants'
@@ -375,6 +385,7 @@ export interface FileRouteTypes {
| '/dashboard/esign/inbox'
| '/dashboard/esign/new'
| '/dashboard/esign/setup'
+ | '/dashboard/esign/sign'
| '/dashboard/reports/group-links'
| '/dashboard/reports/resolved'
| '/dashboard/telegram/grants'
@@ -410,6 +421,7 @@ export interface FileRouteTypes {
| '/dashboard/esign/inbox'
| '/dashboard/esign/new'
| '/dashboard/esign/setup'
+ | '/dashboard/esign/sign'
| '/dashboard/reports/group-links'
| '/dashboard/reports/resolved'
| '/dashboard/telegram/grants'
@@ -583,6 +595,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardReportsGroupLinksRouteImport
parentRoute: typeof DashboardRoute
}
+ '/dashboard/esign/sign': {
+ id: '/dashboard/esign/sign'
+ path: '/esign/sign'
+ fullPath: '/dashboard/esign/sign'
+ preLoaderRoute: typeof DashboardEsignSignRouteImport
+ parentRoute: typeof DashboardRoute
+ }
'/dashboard/esign/setup': {
id: '/dashboard/esign/setup'
path: '/esign/setup'
@@ -712,6 +731,7 @@ interface DashboardRouteChildren {
DashboardEsignInboxRoute: typeof DashboardEsignInboxRoute
DashboardEsignNewRoute: typeof DashboardEsignNewRoute
DashboardEsignSetupRoute: typeof DashboardEsignSetupRoute
+ DashboardEsignSignRoute: typeof DashboardEsignSignRoute
DashboardReportsGroupLinksRoute: typeof DashboardReportsGroupLinksRoute
DashboardReportsResolvedRoute: typeof DashboardReportsResolvedRoute
DashboardTelegramGrantsRoute: typeof DashboardTelegramGrantsRoute
@@ -732,6 +752,7 @@ const DashboardRouteChildren: DashboardRouteChildren = {
DashboardEsignInboxRoute: DashboardEsignInboxRoute,
DashboardEsignNewRoute: DashboardEsignNewRoute,
DashboardEsignSetupRoute: DashboardEsignSetupRoute,
+ DashboardEsignSignRoute: DashboardEsignSignRoute,
DashboardReportsGroupLinksRoute: DashboardReportsGroupLinksRoute,
DashboardReportsResolvedRoute: DashboardReportsResolvedRoute,
DashboardTelegramGrantsRoute: DashboardTelegramGrantsRoute,
diff --git a/src/routes/dashboard/esign/sign.tsx b/src/routes/dashboard/esign/sign.tsx
new file mode 100644
index 0000000..ddc1932
--- /dev/null
+++ b/src/routes/dashboard/esign/sign.tsx
@@ -0,0 +1,28 @@
+import { createFileRoute, redirect } from "@tanstack/react-router"
+
+import { RecordSkeleton } from "@/components/primitives"
+import { PageBar, PageContent } from "@/components/shell"
+import { EsignBatchPage } from "@/features/esign/batch-page"
+import { getEsignBatch } from "@/features/esign/esign.functions"
+import { hasPermission } from "@/server/permissions"
+
+export const Route = createFileRoute("/dashboard/esign/sign")({
+ beforeLoad: ({ context }) => {
+ if (!hasPermission(context.permissions, "esign:sign")) throw redirect({ to: "/unauthorized" })
+ },
+ loader: () => getEsignBatch(),
+ pendingComponent: () => (
+ <>
+
+
+
+
+ >
+ ),
+ component: SignRoute,
+})
+
+function SignRoute() {
+ const batch = Route.useLoaderData()
+ return
+}