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 ? ( + + ) : ( + { + 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={ + + } + /> + +
+
+ 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)}.`} +

+ )} + +
+
+ {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}.`}{" "} @@ -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 }) - }} - /> - -
-

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 + +
+
+ ))} + +
+ {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.`} + + +
+ )} {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. + + )}