diff --git a/docs/design.md b/docs/design.md index ac59c13..d877357 100644 --- a/docs/design.md +++ b/docs/design.md @@ -180,7 +180,8 @@ the selected range and a TanStack Charts line (3M per day, 1Y per week, All per grid, month labels, a dashed hover guide and a tooltip with the date, total and growth), and Telegram groups, Open reports and Active grants as links on its right (a row under it on mobile, numbers on one baseline, labelled "Groups" rather than "Telegram groups", and without the "To review" flag); then "Areas": one card per service whose whole surface leads where the rail icon does, with each section -as a 44px button (two per row on mobile). +as a 44px button (two per row on mobile). Reports › Open and Signatures › To sign carry the same accent count pill as the +panel, from the shell's pending counts; nothing at zero or on a failed load. **States.** While a route loads, its `pendingComponent` renders `PageBar`, `PageContent` and the template's skeleton (`TableSkeleton`, `CardsSkeleton`, `RecordSkeleton`, `SettingsListSkeleton`, labelled "Loading {things}…"), so nothing @@ -440,13 +441,22 @@ Rules that the templates above don't imply. their own icon. The rail and bottom nav show a dot on Reports while any report is open. - **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. + Until their first signature, the signer's card (and "Before you sign" in a batch) offers "Move to another spot", one + button per place when there are several ("Signature · p. 2"). It works like the composer's: the next click on any + page puts the box there, the viewer's toolbar says so in place of the page counter, and Esc or Cancel stops it. + The viewer jumps to a box without scrolling smoothly, and zooming keeps the middle of the view in place. 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. A person appears once + PoliNetwork eSign's own page in a popup: the PIN is typed there, never in the dashboard. When that window was closed + or PoliNetwork eSign doesn't answer, the dialog's footer primary is "Open the window again" beside "Cancel + signature" ("Stop here" once part of a batch is signed), and "Start PoliNetwork eSign" (outline) and "Setup guide" (ghost) sit in the body. 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). + The requests list names whom an in-progress request waits on ("Waiting for X"); To sign has no Status column (every + row is the viewer's turn); `?status=` keeps the status segment across Back. The request page shows when each signer + whose turn it is was emailed, or why they weren't. Signatures › Drives (`esign:drives:manage`) has a card per drive with two rules, "Request signatures" and "Be asked to sign": chips for roles (people icon) and permissions (key icon), an inline picker listing both, a "Default: …" line while empty, and Save/Discard per drive. Unknown keys show as warning chips. On a new request, signers who may diff --git a/docs/esign/TRACKER.md b/docs/esign/TRACKER.md index 71a78e6..396a9c9 100644 --- a/docs/esign/TRACKER.md +++ b/docs/esign/TRACKER.md @@ -133,6 +133,10 @@ Legend: ☐ todo · ◐ in progress · ☑ done - ☐ Create the IdP permission `esign:drives:manage` and grant it to whoever sets drive access (master admins get it). - ☐ 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). +- ☐ Setup step 4 (`src/features/esign/setup-page.tsx`) says the status page "should show that PoliNetwork eSign is + running and ready to sign", which fits both v0.3.0 ("The agent is running") and the readiness status page of the next + agent release ("Ready to sign" once reader, card and software check out). Once that release is out, the step can + quote "Ready to sign". ## Manual steps for the maintainer (cannot be automated here) @@ -148,8 +152,10 @@ Legend: ☐ todo · ◐ in progress · ☑ done - ☐ Code signing (pipeline ready, esign PR #4; steps in esign `docs/code-signing.md`): - macOS: upload `~/dev/polinetwork/.signing/esign/developer-id.csr` as a Developer ID Application certificate on the PoliNetwork APS Apple account (team 842636PS9J), create an App Store Connect API key, run - `scripts/setup-macos-signing.sh secrets …`, tag a release. - - Windows: apply to SignPath Foundation (free for open source), then set the SIGNPATH_* variables and token. + `scripts/setup-macos-signing.sh secrets …`, tag a release, then drop the Open Anyway sentence from + AGENT_STEPS.macos in src/features/esign/setup-page.tsx. + - Windows: apply to SignPath Foundation (free for open source), then set the SIGNPATH_* variables and token, then + drop the Run anyway sentence from AGENT_STEPS.windows. - ☑ Backend released as @polinetwork/backend 1.7.0 (backend#73 merged); admin uses it (vendored copy removed). ## Local end-to-end @@ -165,6 +171,12 @@ Open `http://localhost:10431/dashboard/esign/requests` (use `localhost`, not `12 ## Log +- 2026-10-21: UX pass (admin `feat/esign-ux-pass`, backend `fix/esign-ux`, agent `fix/sign-page-ux`; not pushed): the + signing dialog says how an attempt ended and notices a closed or silent eSign window, jobs and agent windows end on + errors a retry can't fix, plus composer, request, batch, setup and Overview polish. Admin follow-ups after the backend + release: the lock alert's "by HH:MM" from `signingNow.until`; `lostAccess` on the signer card and the drive-access + impact confirmation before saving a sign rule. Open product decisions: `~/dev/polinetwork/esign-ux-decisions-2026-10-21.html` (outside the repo). + - 2026-10-17: merged backend#75 (released @polinetwork/backend 1.9.0), sign-agent#6 (released PoliNetwork eSign v0.2.0) and admin#105; the upload fallback warns signers with several places. Flux rolls out the new images. diff --git a/docs/esign/contracts.md b/docs/esign/contracts.md index f1eff8e..8d67c74 100644 --- a/docs/esign/contracts.md +++ b/docs/esign/contracts.md @@ -42,6 +42,18 @@ Listens on `127.0.0.1:47321` only. Every request: `Host` must be `127.0.0.1:4732 Allowed origins: `https://admin.polinetwork.org` by default, `http://localhost:*` with `--dev`, or the config file. +The signing page tells its opener what happened with `postMessage({type: "pn-esign", status, detail?}, origin)`, to the +dashboard's origin only: `status` is `completed`, `error` or `cancelled`, `detail` the agent error code, or +`agent_unreachable` when the page can no longer reach the agent behind it. The dashboard polls `esign.jobs.statuses` +again on every message and acts on a message itself only for `agent_unreachable`, because the agent can't report its own +death to the backend. Only the next agent release (after v0.3.0) posts `agent_unreachable`: with v0.3.0 and older the +dashboard doesn't learn that the agent stopped. Agents up to v0.1.x post type `pn-sign-agent`. + +The dashboard notices a closed agent window by reading `popup.closed` on every poll. That works only while the two +windows keep their opener relationship, so neither the dashboard nor the agent page may send a +`Cross-Origin-Opener-Policy` header: `same-origin` on either side cuts the link, the dashboard then reads the window as +closed at once, and the page's `window.opener` is gone, so its messages stop too. + Providers (tried in order, first with a card present; never falls back once a PIN reached a module or card — `token.Error.PINSent`): @@ -63,7 +75,24 @@ Agent → backend (through the dashboard origin, `{origin}/api/esign/agent/…`, | `POST /api/esign/agent/jobs/{ticket}/fail` | `{code, message?}` | `{status}` — records cancellations / card errors for the dashboard | Errors: `{error: {code, message}}` with codes `ticket_invalid` (404), `job_expired` (410), `job_done` (409), -`document_changed` (409), `certificate_invalid` (422), `signer_mismatch` (403), `signature_invalid` (422). +`job_cancelled` (409: the message names who cancelled or declined the request, else "This signature was cancelled."), +`job_failed` (409: the message of the error that ended the job), `document_changed` (409), `certificate_invalid` (422), +`signer_mismatch` (403), `signature_invalid` (422). + +The agent, the backend and the dashboard agree on which errors are terminal. A terminal error ends the job: the backend +moves it to `failed` with the code, whether the agent reported it through `/fail` or the backend raised it itself, and +the agent shows it without a PIN form. Any other code, an unknown one included, leaves the job open in the backend. A +retryable error returns to the PIN form in the same agent window. Any other per-document error ends the agent window +while the backend keeps the job open; once the signer closes that window, the dashboard offers to open it again. + +- Terminal, reported by the agent: `pin_locked`, `card_untrusted`, `middleware_missing`, `not_paired`, + `certificate_not_found`. +- Terminal, raised by the backend: `signer_mismatch`, `certificate_invalid`, `signature_invalid`, `document_changed`, + `pdf_invalid`, `job_done`, `agent_outdated`. +- Retryable, back to the PIN form: `pin_incorrect`, `pin_invalid`, `pin_required`, `no_card`, `no_reader`, + `card_removed`, `card_unsupported`, `device_error`, `busy`, `backend_unreachable`. + +`/fail` on a job that already failed changes nothing and answers `{status: "failed"}`. ## Backend tRPC (`esign.*`) @@ -80,7 +109,13 @@ browse OneDrive). Requests are visible to their signers and creator. - `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.list({scope: "inbox"|"mine"|"all", status?})` → request views. Each of a view's `signers[]` has + `lostAccess`: for a pending signer who may no longer sign the documents of one of the request's drives (its "sign" + rule changed, or the IdP took a role away), that drive's label; else null. `myTurn` is true when it is the caller's + turn and the caller's `lostAccess` is null (`turn` still lists every signer whose turn it is). `inbox` holds the + requests where `myTurn` is true, so a request the caller can no longer sign stays reachable under `mine`. +- `esign.requests.get({id})` → `{request, events, versions, currentDocumentId, signingNow}`. `signingNow` is + `{sub, name, until}` while a signer has a signature open, `until` being when it expires at the latest; else null. - `esign.requests.cancel({id})`, `esign.requests.decline({id, reason})`, `esign.requests.retryDelivery({id})`. - `esign.drive.access()` (`esign:drives:manage`) → `{drives: [{id, label, request: {roles, permissions}, sign: {roles, permissions}, updatedBySub, updatedAt}], catalog: {roles: [{key, name}], permissions: [{key, name}]}}`; @@ -88,9 +123,17 @@ permissions}, updatedBySub, updatedAt}], catalog: {roles: [{key, name}], permiss empty rule keeps the default (request: `esign:requests:manage`; sign: everyone who signs). `request` covers browsing the drive, using it in a request and seeing its requests; `sign` covers being chosen as signer and signing. Requests record `drives` (source and output); `esign.config().drives` and `esign.signers.list()[].drives` are filtered. + `esign.drive.accessImpact({driveId, sign})` (`esign:drives:manage`) → `[{id, title, signers: [name]}]`: the requests + in progress on that drive with pending signers who may sign under the current rule but not under the proposed `sign` + rule (the same empty-rule default); empty when the change strands nobody. - `esign.jobs.create({items: [{requestId, places?}]})` (1–10, all or none) → `[{requestId, jobId, ticket, expiresAt}]`, opened together in one agent window; `places` moves the caller's boxes (one position per place) before signing. - `esign.jobs.statuses({jobIds})`, `esign.jobs.cancel({jobIds})`. +- `esign.jobs.statuses({jobIds})` → one entry per job: + `{jobId, requestId, status, errorCode, errorMessage, expiresAt, seen, signaturesLeft}`. `status` is `pending`, + `prepared`, `completed`, `failed` (a terminal error, its code in `errorCode`), `cancelled` or `expired`. A job + cancelled because its request was cancelled or declined has `errorCode` `request_cancelled` or `request_declined`, + and an `errorMessage` naming who did it (with the reason of a decline). +- `esign.jobs.cancel({jobIds})`. Placement: `{page (0-based), x, y, width, height}` as fractions of the displayed page (crop box after rotation), origin top-left. diff --git a/src/components/primitives/combobox.tsx b/src/components/primitives/combobox.tsx index 13fbe89..a3f437f 100644 --- a/src/components/primitives/combobox.tsx +++ b/src/components/primitives/combobox.tsx @@ -1,3 +1,4 @@ +import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete" import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox" import { ChevronDown } from "lucide-react" import type * as React from "react" @@ -20,24 +21,38 @@ type ComboboxInputProps = ComboboxPrimitive.Input.Props & { listLabel: string } -/** Combobox input: 36px field with a chevron trigger named after the list. */ +/** + * Combobox input: 36px field with a chevron trigger named after the list. The field draws the input's focus ring and + * anchors the list; the chevron is out of the Tab order, since the arrow keys open the list too. + */ export function ComboboxInput({ listLabel, disabled = false, className, ...props }: ComboboxInputProps) { return ( - - } {...props} /> + + } + > + } {...props} /> } disabled={disabled} + tabIndex={-1} aria-label={`Show ${listLabel}`} className="data-pressed:bg-transparent" > - + ) } @@ -46,12 +61,12 @@ export function ComboboxContent({ className, ...props }: React.ComponentProps } -/** 36px, 13px item highlighted on `--pn-muted`; selected check on the right. */ +/** 36px, 13px item highlighted on `--pn-hover` (pointer or arrow keys); selected check on the right. */ export function ComboboxItem({ className, ...props }: React.ComponentProps) { return ( } -export { Combobox, ComboboxList } +/** + * `Combobox` picks one of a fixed set of items; `Autocomplete` is free text with suggestions. Both take the same + * input, content, list, item and empty parts. + */ +const Autocomplete = AutocompletePrimitive.Root + +export { Autocomplete, Combobox, ComboboxList } diff --git a/src/components/primitives/index.ts b/src/components/primitives/index.ts index 497754b..d29ff59 100644 --- a/src/components/primitives/index.ts +++ b/src/components/primitives/index.ts @@ -2,7 +2,15 @@ export { AvatarGroup, initialsOf, type AvatarPerson } from "./avatar-group" export { Chip } from "./chip" export { ChipOverflow } from "./chip-overflow" export { ColorSwatchSelect } from "./color-swatch-select" -export { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "./combobox" +export { + Autocomplete, + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "./combobox" export { ConfirmDialog, dialogFooter, dialogFooterButton, dialogPanel } from "./confirm-dialog" export { CountBadge } from "./count-badge" export { DataTable, RowActions, type DataTableColumn, type DataTableError, type TableSort } from "./data-table" diff --git a/src/components/shell/index.ts b/src/components/shell/index.ts index 6cef163..7882dcb 100644 --- a/src/components/shell/index.ts +++ b/src/components/shell/index.ts @@ -10,6 +10,7 @@ export { type PendingCounts, type Section, sectionFor, + sectionPending, type Service, serviceById, type ServiceId, @@ -33,6 +34,7 @@ export { useInShell, useShellFrame, } from "./page-bar" +export { PendingCount } from "./panel" export { ServiceGlyph } from "./service-glyph" export { DashboardShell, type DashboardShellProps } from "./shell" export { type Theme, themeToggleLabel, useTheme } from "./theme" diff --git a/src/components/shell/panel.tsx b/src/components/shell/panel.tsx index 5fae4e0..235f7d2 100644 --- a/src/components/shell/panel.tsx +++ b/src/components/shell/panel.tsx @@ -1,6 +1,7 @@ import { Link } from "@tanstack/react-router" import { type KeyboardEvent, Suspense, use, useDeferredValue, useLayoutEffect, useRef } from "react" +import { formatNumber } from "@/lib/format" import { cn } from "@/lib/utils" import { @@ -24,14 +25,14 @@ const itemActiveClass = "bg-(--pn-nav-active) text-(--pn-fg) hover:bg-(--pn-nav- const itemCurrentClass = "before:absolute before:inset-y-2.5 before:-left-3 before:w-[3px] before:rounded-r-[3px] before:bg-(--pn-accent-solid)" -/** Pending reports as a small accent pill next to Reports › Open; zero (or a failed load) renders nothing. */ -function PendingReportsCount({ count: countPromise }: { count: Promise }) { +/** Pending items (open reports, documents to sign) as a small accent pill; zero, loading or a failed load renders nothing. */ +export function PendingCount({ count: countPromise }: { count: Promise }) { const count = use(useDeferredValue(countPromise)) if (!count) return null return ( , - {count} + {formatNumber(count)} pending ) @@ -96,7 +97,7 @@ export function SectionItems({ service, match, pending, variant = "panel" }: Sec /> {section.title} - + ) diff --git a/src/features/dashboard/overview-page.tsx b/src/features/dashboard/overview-page.tsx index 525e8a9..f7895b5 100644 --- a/src/features/dashboard/overview-page.tsx +++ b/src/features/dashboard/overview-page.tsx @@ -3,7 +3,17 @@ import { ArrowRight, ArrowUp, ChevronRight, Search } from "lucide-react" import { type ReactNode, Suspense, use, useState } from "react" import { InlineAlert, LoadingButton, SegmentedControl, useModifierKey } from "@/components/primitives" -import { type DashboardPath, PageBar, PageContent, type Service, ServiceGlyph, useShellFrame } from "@/components/shell" +import { + type DashboardPath, + PageBar, + PageContent, + PendingCount, + type PendingCounts, + sectionPending, + type Service, + ServiceGlyph, + useShellFrame, +} from "@/components/shell" import { useVisibleServices } from "@/components/shell/use-can" import { formatNumber } from "@/lib/format" import { cn } from "@/lib/utils" @@ -26,10 +36,12 @@ export type OverviewPageProps = { counts: OverviewCounts /** Streams in after the counts; the chart area holds its height meanwhile. */ userHistory: Promise + /** The shell's streamed counts, shown on the sections that have one. */ + pending: PendingCounts } /** Overview: the search, the Telegram users hero with its chart and side stats, then every service's sections. */ -export function DashboardOverviewPage({ counts, userHistory }: OverviewPageProps) { +export function DashboardOverviewPage({ counts, userHistory, pending }: OverviewPageProps) { const router = useRouter() const [retrying, setRetrying] = useState(false) const someFailed = Object.values(counts).some((count) => count === null) @@ -66,7 +78,7 @@ export function DashboardOverviewPage({ counts, userHistory }: OverviewPageProps ) : null} - + @@ -94,7 +106,7 @@ function OverviewSearch() { } /** Stream user growth and service counts into the overview hero, with shorter statistic labels on phones. */ -function Hero({ counts, userHistory }: OverviewPageProps) { +function Hero({ counts, userHistory }: Pick) { // `short` is the phone label: the hero already sits under "Telegram users", so "Groups" is unambiguous there. const stats: { label: string; short?: string; value: OverviewCount; to: DashboardPath; flag?: ReactNode }[] = [ { label: "Telegram groups", short: "Groups", value: counts.telegramGroups, to: "/dashboard/telegram/groups" }, @@ -267,7 +279,7 @@ function chartDescription(points: ReturnType["points"], ran } /** Every service the user can open: the whole card leads where its rail icon does, each section is a 44px button. */ -function Areas({ openReports }: { openReports: OverviewCount }) { +function Areas({ pending }: { pending: PendingCounts }) { const services = useVisibleServices().filter((service) => service.sections.length > 0) return (
@@ -276,15 +288,15 @@ function Areas({ openReports }: { openReports: OverviewCount }) {
    {services.map((service) => ( - + ))}
) } -/** Link to a service and its sections, including the open-report count when the service is Reports. */ -function AreaCard({ service, openReports }: { service: Service; openReports: OverviewCount }) { +/** Link to a service and its sections, including the pending count on Reports › Open and Signatures › To sign. */ +function AreaCard({ service, pending }: { service: Service; pending: PendingCounts }) { const { serviceHref } = useShellFrame() const wide = service.sections.length > 3 return ( @@ -325,12 +337,9 @@ function AreaCard({ service, openReports }: { service: Service; openReports: Ove className="size-4.5 shrink-0 text-(--pn-fg-muted) group-hover/section:text-(--pn-fg)" /> {section.title} - {service.id === "reports" && section.id === "open" && openReports ? ( - - {formatNumber(openReports)} - open - - ) : null} + + + ))} diff --git a/src/features/esign/batch-page.tsx b/src/features/esign/batch-page.tsx index a9771db..b63ead6 100644 --- a/src/features/esign/batch-page.tsx +++ b/src/features/esign/batch-page.tsx @@ -1,7 +1,7 @@ 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 { useCallback, useEffect, useRef, useState } from "react" import { EmptyState, IconButton, InlineAlert, SectionCard } from "@/components/primitives" import { PageBar, PageContent } from "@/components/shell" @@ -12,9 +12,11 @@ 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 { kindsLabel, type Place, pagesLabel, placeAt, placesLeft } from "./places" +import { MovePlaceButtons, PlacingCancel, placingStatus } from "./placing" import { SignWithCie } from "./sign-with-cie" import { SignatureBox } from "./signature-box" +import { useReloadWhile } from "./use-reload-while" type Item = Pick & { pdf: EsignPdfInfo | null } @@ -26,23 +28,67 @@ const BACK = { label: "documents to sign", link: { to: "/dashboard/esign/inbox" */ export function EsignBatchPage({ items, more, me }: { items: Item[]; more: number; me: string }) { const navigate = useNavigate() + const contentFn = useServerFn(getEsignDocumentContent) + /** PDF contents by document id (a version never changes), so Next opens a document fetched in advance. */ + const pdfs = useRef(new Map>()) + const loadPdf = useCallback( + (documentId: string) => { + let pdf = pdfs.current.get(documentId) + if (!pdf) { + pdf = contentFn({ data: { documentId } }).then((result) => result.base64) + pdfs.current.set(documentId, pdf) + // Opening the document again tries again. + void pdf.catch((error) => { + console.error(error) + pdfs.current.delete(documentId) + }) + } + return pdf + }, + [contentFn] + ) 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()) + /** The open document's place waiting for a click on its new spot. */ + const [placing, setPlacing] = useState(null) // 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)) + // Follow documents another signer holds until they finish; reloads keep the review progress and moved boxes. + useReloadWhile( + included.some((item) => item.signingNow && item.signingNow.sub !== me), + 3000 + ) + + const activeDocument = active?.currentDocumentId + const nextDocument = unchecked[0]?.currentDocumentId + useEffect(() => { + if (!activeDocument || !nextDocument) return + let cancelled = false + // Once the open document has arrived, fetch the one Next opens. + loadPdf(activeDocument) + .then(() => { + if (!cancelled) void loadPdf(nextDocument) + }) + .catch((error) => console.error(error)) + return () => { + cancelled = true + } + }, [activeDocument, nextDocument, loadPdf]) function open(id: string) { setActiveId(id) + setPlacing(null) setReviewed((current) => new Set(current).add(id)) } function toggle(id: string) { + if (id === active?.request.id) setPlacing(null) setLeftOut((current) => { const next = new Set(current) if (!next.delete(id)) next.add(id) @@ -152,8 +198,18 @@ export function EsignBatchPage({ items, more, me }: { items: Item[]; more: numbe key={active.request.id} item={active} me={me} + loadPdf={loadPdf} places={places} onMove={(next) => setMoved((current) => new Map(current).set(active.request.id, next))} + placing={placing} + onPlace={(page, x, y) => { + const next = places.map((p, at) => + at === placing ? placeAt(p, active.pdf?.pages ?? [], page, x, y) : p + ) + setMoved((current) => new Map(current).set(active.request.id, next)) + setPlacing(null) + }} + onCancelPlacing={() => setPlacing(null)} />
@@ -208,11 +264,30 @@ export function EsignBatchPage({ items, more, me }: { items: Item[]; more: numbe ? `Check where you sign in each document, moving the blue boxes if needed. ${pluralize(unchecked.length, "document")} left to check.` : included.length === 1 ? "Choose Sign with CIE at the top. You need the card, its PIN and the NFC reader." - : `All ${included.length} documents checked. Choose Sign at the top: one PIN signs them all. You need the card and the NFC reader.`} + : `All ${included.length} documents checked. Choose Sign at the top: one PIN signs them all. You need the card, its PIN and the NFC reader.`}

+

+ First time signing on this computer?{" "} + {/* A new tab, because the review progress and moved boxes live only in this page. */} + + Set it up first + + . +

+ {signer && signer.signaturesDone === 0 && ( +
+ +
+ )} {active.signingNow && active.signingNow.sub !== me && ( - {active.signingNow.name} is signing this document right now. Leave it out or try again later. + {active.signingNow.name} is signing this document right now. Leave it out, or wait: it's ready as + soon as they finish. )} @@ -227,15 +302,23 @@ export function EsignBatchPage({ items, more, me }: { items: Item[]; more: numbe function DocumentViewer({ item, me, + loadPdf, places, onMove, + placing, + onPlace, + onCancelPlacing, }: { item: Item me: string + loadPdf: (documentId: string) => Promise places: Place[] onMove: (places: Place[]) => void + /** The place waiting for a click on its new spot. */ + placing: number | null + onPlace: (page: number, x: number, y: number) => void + onCancelPlacing: () => void }) { - const contentFn = useServerFn(getEsignDocumentContent) const [base64, setBase64] = useState(null) const [error, setError] = useState(null) const bytes = usePdfBytes(base64) @@ -244,9 +327,9 @@ function DocumentViewer({ useEffect(() => { if (!item.currentDocumentId) return let cancelled = false - contentFn({ data: { documentId: item.currentDocumentId } }) - .then((result) => { - if (!cancelled) setBase64(result.base64) + loadPdf(item.currentDocumentId) + .then((pdf) => { + if (!cancelled) setBase64(pdf) }) .catch((cause) => { console.error(cause) @@ -255,16 +338,24 @@ function DocumentViewer({ return () => { cancelled = true } - }, [item.currentDocumentId, contentFn]) + }, [item.currentDocumentId, loadPdf]) const mine = request.signers.find((s) => s.sub === me && s.status === "pending") - const first = mine ? placesLeft(places, mine.signaturesDone)[0] : undefined + // Once per document (it remounts for each): following the box as it moves would scroll it away from the pointer. + const [focus] = useState(() => { + const first = mine ? placesLeft(places, mine.signaturesDone)[0] : undefined + return first ? { page: first.page, y: first.y + first.height / 2 } : null + }) if (error) return {error} return ( } overlay={(page) => request.signers.flatMap((signer, index) => { if (signer.status !== "pending") return [] diff --git a/src/features/esign/drive-browser.tsx b/src/features/esign/drive-browser.tsx index c06ac8c..144e59d 100644 --- a/src/features/esign/drive-browser.tsx +++ b/src/features/esign/drive-browser.tsx @@ -1,6 +1,6 @@ import { useServerFn } from "@tanstack/react-start" import { ArrowUp, FileText, Folder, LoaderCircle } from "lucide-react" -import { useEffect, useState } from "react" +import { useEffect, useRef, useState } from "react" import { IconButton, InlineAlert } from "@/components/primitives" import { floatingMotion, raisedSurface } from "@/components/primitives/motion" @@ -24,6 +24,8 @@ type DriveBrowserProps = { drives: EsignDrive[] /** The drive opened first, the first one by default. */ initialDriveId?: string + /** The folder opened first in the initial drive, its top folder by default; ignored when initialDriveId is not one of drives. */ + initialFolderId?: string /** A PDF, with the folder it is in. */ onPickFile?: (item: EsignDriveItem, folder: DriveFolder) => void onPickFolder?: (folder: DriveFolder) => void @@ -44,14 +46,18 @@ export function DriveBrowser({ label, drives, initialDriveId, + initialFolderId, onPickFile, onPickFolder, busyId, className, }: DriveBrowserProps) { const listFn = useServerFn(listEsignDrive) - const [driveId, setDriveId] = useState(drives.some((d) => d.id === initialDriveId) ? initialDriveId : drives[0]?.id) - const [folderId, setFolderId] = useState(undefined) + const initialDrive = drives.some((d) => d.id === initialDriveId) + const [driveId, setDriveId] = useState(initialDrive ? initialDriveId : drives[0]?.id) + const [folderId, setFolderId] = useState(initialDrive ? initialFolderId : undefined) + /** The initial folder until a listing loads: if it was moved or deleted, the drive's top folder opens instead. */ + const initialFolder = useRef(folderId) const drive = drives.find((d) => d.id === driveId) const [listing, setListing] = useState(null) const [error, setError] = useState(null) @@ -64,11 +70,17 @@ export function DriveBrowser({ setError(null) listFn({ data: { driveId, folderId } }) .then((result) => { - if (!cancelled) setListing(result) + if (cancelled) return + initialFolder.current = undefined + setListing(result) }) .catch((cause: unknown) => { console.error(cause) - if (!cancelled) setError(cause instanceof Error ? cause.message : "Couldn't open the folder.") + if (cancelled) return + if (folderId !== undefined && folderId === initialFolder.current) { + initialFolder.current = undefined + setFolderId(undefined) + } else setError(cause instanceof Error ? cause.message : "Couldn't open the folder.") }) .finally(() => { if (!cancelled) setLoading(false) diff --git a/src/features/esign/new-request-page.tsx b/src/features/esign/new-request-page.tsx index 0832b55..5c64f8d 100644 --- a/src/features/esign/new-request-page.tsx +++ b/src/features/esign/new-request-page.tsx @@ -1,9 +1,11 @@ -import { useNavigate, useRouter } from "@tanstack/react-router" +import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox" +import { useBlocker, useNavigate, useRouter } from "@tanstack/react-router" import { useServerFn } from "@tanstack/react-start" import { ArrowDown, ArrowUp, ChevronDown, + CircleAlert, Cloud, FileUp, MapPin, @@ -15,16 +17,26 @@ import { TriangleAlert, UserPlus, } from "lucide-react" -import { type DragEvent, useEffect, useId, useMemo, useRef, useState } from "react" +import { type DragEvent, type ReactNode, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react" import { + Autocomplete, checkboxControl, + Combobox, + ComboboxContent, + ComboboxInput, + ComboboxItem, + ComboboxList, + ConfirmDialog, FormField, fieldControl, + fieldHintId, + Hint, IconButton, InlineAlert, LoadingButton, SectionCard, + useFocusAfterRemoval, } from "@/components/primitives" import { Menu, @@ -50,6 +62,7 @@ import { DriveBrowser, type DriveFolder } from "./drive-browser" import { getEsignDocumentContent } from "./esign.functions" import { extractTextAnchors, type PageSize, PdfViewer, sizeOnPage, type TextAnchor, usePdfBytes } from "./pdf-viewer" import { kindsLabel, type Place, pagesLabel, withSignature } from "./places" +import { PlacingCancel } from "./placing" import { YouChip } from "./request-status" import { createEsignRequest, importEsignDriveDocument, uploadEsignDocument } from "./requests.functions" import { SignWithCie } from "./sign-with-cie" @@ -57,8 +70,9 @@ import { type BoxPlacement, SignatureBox } from "./signature-box" import { useFillHeight } from "./use-fill-height" type Staged = { id: string; fileName: string; pdf: EsignPdfInfo; folder: DriveFolder | null } -/** `manual`: the user moved the first box, so a role no longer places it. */ -type SignerDraft = { sub: string; name: string; role: string; places: Place[]; manual: boolean } +/** `byRole`: the box the signer's role places under its label, until the user moves it. */ +type DraftPlace = Place & { byRole?: true } +type SignerDraft = { sub: string; name: string; role: string; places: DraftPlace[] } /** Where the next click on a page puts a box: a signer's existing place, or a new one when `place` is null. */ type Placing = { signer: number; place: number | null } @@ -69,6 +83,8 @@ const ROLES = ["Presidente", "Vicepresidente", "Segretario", "Tesoriere", "Consi /** A signature box of 190 × 62 pt, the size of a typical handwritten-signature space. */ const BOX_PT = { width: 190, height: 62 } const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ +/** The folder the last PDF was picked from, `{ driveId, id }`, where the drive browser opens next time. */ +const LAST_FOLDER_KEY = "pn-admin:esign-last-folder" /** Default places: the last page's bottom, two per row, from the bottom-right corner. */ function defaultPlacement(index: number, pages: PageSize[]): BoxPlacement { @@ -101,13 +117,38 @@ function placementForRole(role: string, anchors: TextAnchor[], pages: PageSize[] return { page: anchor.page, x, y, ...size } } +/** A folder's name and where it is: "2026" in "OneDrive / Segreteria / Verbali"; a drive's top folder by its drive. */ +function folderLines(folder: DriveFolder, drive: string) { + const segments = folder.path.split("/").filter(Boolean) + const name = segments.pop() + return { + name: name ?? drive, + place: name === undefined ? "Top folder" : [drive, ...segments].join(" / "), + full: `${drive}: /${folder.path}`, + } +} + function baseName(fileName: string) { return fileName.replace(/\.pdf$/i, "") } +/** + * Moves the signer's role box under the label naming their role. The same signer when there is nothing to move: no + * role box, a role the document doesn't name (the box stays where it is), or a box already there. + */ +function placeByRole(signer: SignerDraft, anchors: TextAnchor[], pages: PageSize[]): SignerDraft { + const at = signer.places.findIndex((p) => p.byRole) + const box = signer.places[at] + const placed = box ? placementForRole(signer.role, anchors, pages) : null + if (!box || !placed) return signer + const keys = ["page", "x", "y", "width", "height"] as const + if (keys.every((key) => placed[key] === box[key])) return signer + return { ...signer, places: signer.places.map((p, i) => (i === at ? { ...placed, kind: p.kind, byRole: true } : p)) } +} + function draftOf(person: EsignProfile, index: number, pages: PageSize[]): SignerDraft { const where = defaultPlacement(index, pages) - return { sub: person.sub, name: person.name, role: "", places: [{ ...where, kind: "signature" }], manual: false } + return { sub: person.sub, name: person.name, role: "", places: [{ ...where, kind: "signature", byRole: true }] } } export function NewEsignRequestPage({ @@ -129,7 +170,16 @@ export function NewEsignRequestPage({ const contentFn = useServerFn(getEsignDocumentContent) const createFn = useServerFn(createEsignRequest) const dropFill = useFillHeight() - const ids = { title: useId(), file: useId(), emails: useId(), message: useId(), search: useId(), roles: useId() } + const ids = { + title: useId(), + file: useId(), + fileName: useId(), + folderError: useId(), + emails: useId(), + message: useId(), + search: useId(), + signersError: useId(), + } const [source, setSource] = useState<"drive" | "upload">(config.drives.length > 0 ? "drive" : "upload") const [staged, setStaged] = useState(null) @@ -165,30 +215,74 @@ export function NewEsignRequestPage({ const [search, setSearch] = useState("") const [title, setTitle] = useState("") + const [titleTouched, setTitleTouched] = useState(false) const [saveToDrive, setSaveToDrive] = useState(config.drives.length > 0) const [folder, setFolder] = useState(null) const [choosingFolder, setChoosingFolder] = useState(false) + /** Undefined until local storage is read after mount, so the server and the first client render agree. */ + const [lastFolder, setLastFolder] = useState | null>() + useEffect(() => { + let stored: Pick | null = null + try { + stored = JSON.parse(window.localStorage.getItem(LAST_FOLDER_KEY) ?? "null") + } catch (error) { + console.error(error) + } + setLastFolder(stored) + }, []) const [fileName, setFileName] = useState("") + const [fileNameTouched, setFileNameTouched] = useState(false) + const [folderTouched, setFolderTouched] = useState(false) const [emails, setEmails] = useState("") const [message, setMessage] = useState("") const [submitting, setSubmitting] = useState(false) const [submitError, setSubmitError] = useState(null) const [createdRequest, setCreatedRequest] = useState(null) + /** A submit was blocked: problems fixed with a click (document, signers, folder) show from then on. */ const [attempted, setAttempted] = useState(false) + /** Text-field errors on screen: set by a blocked submit, then re-checked when the field loses focus. */ + const [shown, setShown] = useState<{ title?: string; fileName?: string; email?: string }>({}) + /** Each blocked submit focuses the first problem once its message has rendered. */ + const [blockedSubmits, setBlockedSubmits] = useState(0) + const form = useRef(null) + useEffect(() => { + if (blockedSubmits > 0) form.current?.querySelector('[aria-invalid="true"], [data-invalid]')?.focus() + }, [blockedSubmits]) + const submitAlert = useRef(null) + useEffect(() => { + if (submitError) submitAlert.current?.scrollIntoView({ block: "nearest" }) + }, [submitError]) const pages = staged?.pdf.pages ?? [] // One drive goes by its own name; several are browsed together under "OneDrive". const drivesName = config.drives.length === 1 ? config.drives[0].label : "OneDrive" const driveLabel = (id: string) => config.drives.find((d) => d.id === id)?.label ?? "OneDrive" + const output = folder && folderLines(folder, driveLabel(folder.driveId)) + + // The labels of the document on screen place the role boxes, including roles typed before they were read. + useEffect(() => { + if (!staged || anchors.length === 0) return + setSigners((current) => { + const next = current.map((s) => placeByRole(s, anchors, staged.pdf.pages)) + return next.some((s, i) => s !== current[i]) ? next : current + }) + }, [anchors, staged]) function adopt(next: Staged, content: string) { setStaged(next) setBase64(content) - setTitle(baseName(next.fileName)) - setFileName(`${baseName(next.fileName)} - firmato.pdf`) - if (next.folder) setFolder(next.folder) + // The same PDF picked again keeps its labels: its bytes aren't read again. + if (content !== base64) setAnchors([]) + const name = baseName(next.fileName) + if (!titleTouched || title.trim() === "") setTitle(name) + if (!fileNameTouched || fileName.trim() === "") setFileName(`${name} - firmato.pdf`) + if (next.folder && (!folderTouched || !folder)) setFolder(next.folder) + setShown(({ email }) => ({ email })) setSigners((current) => - current.map((s, i) => ({ ...s, places: [{ ...defaultPlacement(i, next.pdf.pages), kind: "signature" }] })) + current.map((s, i) => ({ + ...s, + places: [{ ...defaultPlacement(i, next.pdf.pages), kind: "signature", byRole: true }], + })) ) } @@ -223,6 +317,14 @@ export function NewEsignRequestPage({ { id: result.document.id, fileName: result.document.fileName, pdf: result.pdf, folder: from }, content.base64 ) + const last = { driveId: from.driveId, id: from.id } + setLastFolder(last) + try { + window.localStorage.setItem(LAST_FOLDER_KEY, JSON.stringify(last)) + } catch (error) { + // Remembering the folder is best-effort: the document is already staged. + console.error(error) + } } catch (error) { console.error(error) setDocError(error instanceof Error ? error.message : "Couldn't open the PDF.") @@ -231,18 +333,57 @@ export function NewEsignRequestPage({ } } + const signerList = useFocusAfterRemoval("[data-signer-row]") + const searchRef = useRef(null) + const roleInputs = useRef(new Map()) + /** The signer just added, whose role takes the focus once their row renders. */ + const focusRole = useRef(null) + useEffect(() => { + if (focusRole.current === null) return + roleInputs.current.get(focusRole.current)?.focus() + focusRole.current = null + }, [signers]) + function addSigner(person: EsignProfile) { const draft = draftOf(person, signers.length, pages) setSigners((current) => [...current, draft]) setFocus({ page: draft.places[0].page, y: draft.places[0].y }) setSearch("") + focusRole.current = person.sub } - function updateSigner(index: number, patch: Partial) { - setSigners((current) => current.map((s, i) => (i === index ? { ...s, ...patch } : s))) + // Placing refers to a signer by position, so it ends when the positions change. + function removeSigner(index: number, button: HTMLElement) { + if (signers.length === 1) searchRef.current?.focus() + else signerList.capture(button, { restore: true }) + setSigners((current) => current.filter((_, i) => i !== index)) + setPlacing(null) } - function moveSigner(index: number, by: -1 | 1) { + const moveButtons = useRef(new Map()) + function moveButtonRef(key: string) { + return (button: HTMLButtonElement | null) => { + if (button) moveButtons.current.set(key, button) + else moveButtons.current.delete(key) + } + } + /** The signer just moved by the arrow that had the focus, which keeps it once the row lands. */ + const focusMove = useRef<{ sub: string; by: -1 | 1 } | null>(null) + // A layout effect, so the focus is back before the browser drops it from the moved row or the disabled arrow. + useLayoutEffect(() => { + const moved = focusMove.current + if (moved === null) return + focusMove.current = null + const index = signers.findIndex((s) => s.sub === moved.sub) + // The arrow that just disabled itself (first row going up, last going down) hands the focus to the other one. + const atEnd = moved.by === -1 ? index === 0 : index === signers.length - 1 + const up = moved.by === -1 ? !atEnd : atEnd + moveButtons.current.get(`${moved.sub}:${up ? "up" : "down"}`)?.focus() + }, [signers]) + + function moveSigner(index: number, by: -1 | 1, button: HTMLElement) { + if (button === document.activeElement) focusMove.current = { sub: signers[index].sub, by } + setPlacing(null) setSigners((current) => { const next = [...current] const [item] = next.splice(index, 1) @@ -251,20 +392,27 @@ export function NewEsignRequestPage({ }) } - function updatePlaces(index: number, update: (places: Place[]) => Place[], manual = true) { + function setRole(index: number, role: string) { + const s = signers[index] + const next = placeByRole({ ...s, role }, anchors, pages) + const placed = next.places.find((p) => p.byRole) + if (next.places !== s.places && placed) setFocus({ page: placed.page, y: placed.y }) + setSigners((current) => current.map((c, i) => (i === index ? next : c))) + } + + function updatePlaces(index: number, update: (places: DraftPlace[]) => DraftPlace[]) { setSigners((current) => - current.map((s, i) => - i === index ? { ...s, places: withSignature(update(s.places)), manual: s.manual || manual } : s - ) + current.map((s, i) => (i === index ? { ...s, places: withSignature(update(s.places)) } : s)) ) } - function movePlace(index: number, at: number, placement: BoxPlacement) { - updatePlaces(index, (places) => places.map((p, i) => (i === at ? { ...placement, kind: p.kind } : p)), at === 0) + // The box comes back with every field it was given: rebuilt without `byRole`, it no longer follows the role. + function movePlace(index: number, at: number, { page, x, y, width, height }: BoxPlacement) { + updatePlaces(index, (places) => places.map((p, i) => (i === at ? { page, x, y, width, height, kind: p.kind } : p))) } function removePlace(index: number, at: number) { - updatePlaces(index, (places) => places.filter((_, i) => i !== at), false) + updatePlaces(index, (places) => places.filter((_, i) => i !== at)) } function place(page: number, x: number, y: number) { @@ -279,14 +427,11 @@ export function NewEsignRequestPage({ } const at = placing.place // Clicked places keep reading order (page, then top to bottom), which is also the order they are signed in. - updatePlaces( - placing.signer, - (places) => - (at === null - ? [...places, { ...placement, kind: "signature" as const }] - : places.map((p, i) => (i === at ? { ...placement, kind: p.kind } : p)) - ).sort((a, b) => a.page - b.page || a.y - b.y || a.x - b.x), - at === 0 + updatePlaces(placing.signer, (places) => + (at === null + ? [...places, { ...placement, kind: "signature" as const }] + : places.map((p, i) => (i === at ? { ...placement, kind: p.kind } : p)) + ).sort((a, b) => a.page - b.page || a.y - b.y || a.x - b.x) ) setPlacing(null) } @@ -317,15 +462,26 @@ export function NewEsignRequestPage({ .map((e) => e.trim()) .filter(Boolean) const badEmail = recipients.find((e) => !EMAIL.test(e)) - const problems = [ - !staged && "Choose the document.", - signers.length === 0 && "Add at least one signer.", - title.trim() === "" && "Give the request a title.", - saveToDrive && !folder && "Choose the OneDrive folder.", - saveToDrive && fileName.trim() === "" && "Name the signed file.", - badEmail && `“${badEmail}” isn't an email address.`, - ...outsiders.map((s) => `${s.name} can't sign documents of ${driveLabel(blockedDrive(s.sub) ?? "")}.`), - ].filter((p): p is string => Boolean(p)) + const errors = { + document: staged ? undefined : "Choose the document.", + signers: signers.length > 0 ? undefined : "Add at least one signer.", + title: title.trim() === "" ? "Give the request a title." : undefined, + folder: saveToDrive && !folder ? "Choose the OneDrive folder." : undefined, + fileName: saveToDrive && fileName.trim() === "" ? "Name the signed file." : undefined, + email: badEmail ? `“${badEmail}” isn't an email address.` : undefined, + } + // Signers who may not sign these documents block the request too; their rows say why. + const problems = outsiders.length > 0 || Object.values(errors).some(Boolean) + + function revalidate(field: keyof typeof shown) { + if (shown[field]) setShown((current) => ({ ...current, [field]: errors[field] })) + } + + function showProblems() { + setAttempted(true) + setShown({ title: errors.title, fileName: errors.fileName, email: errors.email }) + setBlockedSubmits((count) => count + 1) + } // When the viewer signs first, creating the request and signing it are one step. const myIndex = signers.findIndex((s) => s.sub === me) @@ -337,6 +493,21 @@ export function NewEsignRequestPage({ const creating = useRef(false) + // "Sign a document" starts with the viewer as signer: that alone is nothing to lose. + const untouched = + staged === null && + title.trim() === "" && + emails.trim() === "" && + message.trim() === "" && + signers.every((s) => withMe && s.sub === me && s.role.trim() === "") + const dirty = createdRequest === null && !untouched + // Reads the ref when it runs, so moving to the request just created is never blocked. + const leave = useBlocker({ + shouldBlockFn: () => dirty && created.current === null, + enableBeforeUnload: () => dirty && created.current === null, + withResolver: true, + }) + async function create(documentId: string) { if (created.current) return created.current if (creating.current) return null @@ -349,7 +520,11 @@ export function NewEsignRequestPage({ title: title.trim(), documentId, ordered, - signers: signers.map((s) => ({ sub: s.sub, role: s.role.trim() || null, places: s.places })), + signers: signers.map((s) => ({ + sub: s.sub, + role: s.role.trim() || null, + places: s.places.map(({ page, x, y, width, height, kind }) => ({ page, x, y, width, height, kind })), + })), output: saveToDrive && folder ? { driveId: folder.driveId, folderId: folder.id, fileName: fileName.trim() } : null, email: recipients.length ? { to: recipients, message: message.trim() || null } : null, @@ -382,8 +557,7 @@ export function NewEsignRequestPage({ async function submit() { if (submitting || created.current) return - setAttempted(true) - if (problems.length > 0 || !staged) return + if (problems || !staged) return showProblems() setSubmitting(true) const request = await create(staged.id) if (!request) return setSubmitting(false) @@ -394,8 +568,10 @@ export function NewEsignRequestPage({ function createAndSign() { if (submitting || created.current) return null - setAttempted(true) - if (problems.length > 0 || !staged) return null + if (problems || !staged) { + showProblems() + return null + } return create(staged.id).then((request) => (request ? [{ requestId: request.id, title: request.title }] : [])) } @@ -438,53 +614,51 @@ export function NewEsignRequestPage({ } /> -
+
{staged ? ( -
- {placing !== null && ( - - {placing.place === null + setPlacing(null)}> - Cancel - - - )} - - signers.flatMap((s, index) => - s.places.map((p, at) => - p.page === page ? ( - movePlace(index, at, placement)} - onRemove={s.places.length > 1 ? () => removePlace(index, at) : undefined} - /> - ) : null - ) + : `Click the new spot for ${signers[placing.signer]?.name}.` + } + statusAction={placing !== null && setPlacing(null)} />} + cursor={placing !== null ? "crosshair" : "default"} + onPageClick={place} + focus={focus} + overlay={(page) => + signers.flatMap((s, index) => + s.places.map((p, at) => + p.page === page ? ( + movePlace(index, at, placement)} + onRemove={s.places.length > 1 ? () => removePlace(index, at) : undefined} + /> + ) : null ) - } - /> -
+ ) + } + /> ) : (
{ event.preventDefault() setDragging(true) @@ -519,16 +693,20 @@ export function NewEsignRequestPage({
)}
- {docError && {docError}} + {(docError || attempted) && {docError ?? errors.document}} {source === "drive" && config.drives.length > 0 ? ( - void importFromDrive(item.id, from)} - className="flex-1" - /> + lastFolder !== undefined && ( + void importFromDrive(item.id, from)} + className="flex-1" + /> + ) ) : (
+ { + if (!open) leave.reset?.() + }} + title="Discard changes?" + description="The document, signers and boxes you set up here will be lost." + confirmLabel="Discard" + cancelLabel="Keep editing" + onConfirm={async () => leave.proceed?.()} + /> ) } +/** A problem shown at the control it is about, styled like `FormField`'s error. */ +function FieldError({ id, children }: { id: string; children: ReactNode }) { + return ( +

+ + {children} +

+ ) +} + /** A signer's boxes, one control each, listed under their name: the person appears once however many places. */ function PlacesEditor({ name, diff --git a/src/features/esign/pdf-viewer.tsx b/src/features/esign/pdf-viewer.tsx index 6ac1b82..e92d70f 100644 --- a/src/features/esign/pdf-viewer.tsx +++ b/src/features/esign/pdf-viewer.tsx @@ -1,7 +1,7 @@ import { Minus, Plus, ScanLine } from "lucide-react" import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from "pdfjs-dist" import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url" -import { type ReactNode, useEffect, useMemo, useRef, useState } from "react" +import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" import { IconButton, InlineAlert } from "@/components/primitives" import { cn } from "@/lib/utils" @@ -23,6 +23,13 @@ type PdfViewerProps = { cursor?: "default" | "crosshair" /** A point to bring to the middle of the viewer when it changes: a page and a fraction of its height. */ focus?: { page: number; y: number } | null + /** + * Replaces "Page n of total" in the toolbar while set, e.g. what the next click on a page does; announced politely. + * Below 448px of toolbar the zoom controls hide meanwhile. + */ + status?: string | null + /** A control after the status, e.g. Cancel while placing a box. */ + statusAction?: ReactNode } const ZOOMS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const @@ -45,7 +52,16 @@ function loadPdfjs() { * The dashboard's own PDF viewer (pdf.js), so every browser shows documents the same way and signatures can be placed * on them. Pages render lazily as they near the viewport, at the device pixel ratio; signature appearances are drawn. */ -export function PdfViewer({ data, pages, overlay, onPageClick, cursor = "default", focus }: PdfViewerProps) { +export function PdfViewer({ + data, + pages, + overlay, + onPageClick, + cursor = "default", + focus, + status, + statusAction, +}: PdfViewerProps) { const scrollRef = useRef(null) const fill = useFillHeight() const [containerWidth, setContainerWidth] = useState(0) @@ -92,18 +108,27 @@ export function PdfViewer({ data, pages, overlay, onPageClick, cursor = "default const fitScale = containerWidth > 0 ? Math.min((containerWidth - 32) / maxWidth, 1.5) : 0 const scale = zoom === "fit" ? fitScale : zoom + /** The point in the middle of the viewer, which stays there when the scale changes. */ + const reading = useRef<{ page: number; y: number } | null>(null) + const previousScale = useRef(0) + useLayoutEffect(() => { + const container = scrollRef.current + if (!container) return + if (previousScale.current > 0 && reading.current) centerOn(container, reading.current.page, reading.current.y) + previousScale.current = scale + reading.current = middleOf(container) + }, [scale]) + + // After the scale's effect, so a focus that changes with the scale wins; zooming alone doesn't bring it back. Both + // are layout effects, so the first frame with the pages already shows the right spot. + const laidOut = scale > 0 const focusPage = focus?.page const focusY = focus?.y ?? 0.5 - useEffect(() => { - if (focusPage === undefined || scale <= 0) return - // Scroll the viewer only: scrollIntoView would also move the page around it. + useLayoutEffect(() => { const container = scrollRef.current - const element = container?.querySelector(`[data-page="${focusPage}"]`) - if (!container || !element) return - const offset = element.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop - const top = offset + element.offsetHeight * focusY - container.clientHeight / 2 - container.scrollTo({ top: Math.max(0, top), behavior: "smooth" }) - }, [focusPage, focusY, scale]) + if (focusPage === undefined || !laidOut || !container) return + centerOn(container, focusPage, focusY) + }, [focusPage, focusY, laidOut]) function step(direction: 1 | -1) { const current = zoom === "fit" ? fitScale : zoom @@ -118,11 +143,20 @@ export function PdfViewer({ data, pages, overlay, onPageClick, cursor = "default style={fill.style} className="flex h-[calc(100dvh-210px)] min-h-[480px] flex-col overflow-hidden rounded-(--pn-r-5) border border-(--pn-line)" > -
- - Page {Math.min(currentPage + 1, pages.length)} of {pages.length} - -
+
+
+ + {status || `Page ${Math.min(currentPage + 1, pages.length)} of ${pages.length}`} + + {statusAction &&
{statusAction}
} +
+
step(-1)} disabled={scale <= ZOOMS[0] + 0.01} /> {scale > 0 ? `${Math.round(scale * 100)}%` : ""} @@ -136,7 +170,14 @@ export function PdfViewer({ data, pages, overlay, onPageClick, cursor = "default />
-
+
{ + reading.current = middleOf(event.currentTarget) + }} + > {error ? (
{error} @@ -167,6 +208,30 @@ export function PdfViewer({ data, pages, overlay, onPageClick, cursor = "default ) } +/** + * Scrolls the viewer, not the page around it as scrollIntoView would, so a point of a page (a fraction of its height) + * is in the middle. Never smoothly: like navigation, it doesn't animate. + */ +function centerOn(container: HTMLElement, page: number, y: number) { + const element = container.querySelector(`[data-page="${page}"]`) + if (!element) return + const offset = element.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop + const top = offset + element.offsetHeight * y - container.clientHeight / 2 + container.scrollTo({ top: Math.max(0, top), behavior: "instant" }) +} + +/** The point of a page in the middle of the viewer, the inverse of `centerOn`. */ +function middleOf(container: HTMLElement) { + const middle = container.getBoundingClientRect().top + container.clientHeight / 2 + let point: { page: number; y: number } | null = null + for (const element of container.querySelectorAll("[data-page]")) { + const box = element.getBoundingClientRect() + point = { page: Number(element.dataset.page), y: (middle - box.top) / box.height } + if (middle < box.bottom + PAGE_GAP / 2) break + } + return point +} + type PdfPageProps = { index: number size: PageSize diff --git a/src/features/esign/places.ts b/src/features/esign/places.ts index a786e91..a55146f 100644 --- a/src/features/esign/places.ts +++ b/src/features/esign/places.ts @@ -1,3 +1,4 @@ +import type { PageSize } from "./pdf-viewer" import type { BoxPlacement } from "./signature-box" /** One of a signer's boxes: a full signature of its own, or a mark repeating their first signature. */ @@ -19,7 +20,7 @@ export function kindsLabel(places: Pick[]) { } /** Keeps at least one full signature: when none is left, the first place becomes one. */ -export function withSignature(places: Place[]): Place[] { +export function withSignature(places: T[]): T[] { if (places.length === 0 || places.some((p) => p.kind === "signature")) return places return places.map((p, i) => (i === 0 ? { ...p, kind: "signature" } : p)) } @@ -32,3 +33,22 @@ export function placesLeft>(places: T[], signature let signature = 0 return places.filter((p) => (p.kind === "mark" ? signaturesDone === 0 : signature++ >= signaturesDone)) } + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max) + +/** + * The place moved to a click on `page` (fractions of that page): centred on the click, inside the page, and the same + * size in points, so it doesn't grow or shrink on a page of another size. + */ +export function placeAt(place: Place, pages: PageSize[], page: number, x: number, y: number): Place { + const width = clamp((place.width * pages[place.page].width) / pages[page].width, 0.04, 1) + const height = clamp((place.height * pages[place.page].height) / pages[page].height, 0.04, 1) + return { + kind: place.kind, + page, + width, + height, + x: clamp(x - width / 2, 0, 1 - width), + y: clamp(y - height / 2, 0, 1 - height), + } +} diff --git a/src/features/esign/placing.tsx b/src/features/esign/placing.tsx new file mode 100644 index 0000000..f594425 --- /dev/null +++ b/src/features/esign/placing.tsx @@ -0,0 +1,80 @@ +import { MapPin } from "lucide-react" +import { useEffect } from "react" + +import { buttonMotion } from "@/components/primitives" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +import type { Place } from "./places" + +/** Cancel in the viewer toolbar while the next click on a page places a box. Esc does the same while it is shown. */ +export function PlacingCancel({ onCancel }: { onCancel: () => void }) { + useEffect(() => { + function cancel(event: KeyboardEvent) { + if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return + // An open menu or dialog takes Escape for itself. + if (document.querySelector('[role="dialog"], [role="alertdialog"], [role="menu"]')) return + event.preventDefault() + onCancel() + } + window.addEventListener("keydown", cancel) + return () => window.removeEventListener("keydown", cancel) + }, [onCancel]) + + return ( + + ) +} + +/** + * The signer's "Move to another spot", one button per place when there are several: the next click on any page puts + * the box there. Choosing the place being moved again cancels. + */ +export function MovePlaceButtons({ + places, + placing, + onPlacing, +}: { + places: Place[] + placing: number | null + onPlacing: (at: number | null) => void +}) { + return places.map((p, at) => { + const what = p.kind === "mark" ? "mark" : "signature" + return ( + + ) + }) +} + +/** The viewer's status while one of the signer's places waits for its new spot. */ +export function placingStatus(places: Place[], at: number) { + const place = places[at] + if (places.length === 1) return "Click the new spot for your signature." + return `Click the new spot for your ${place.kind} on page ${place.page + 1}.` +} diff --git a/src/features/esign/request-page.tsx b/src/features/esign/request-page.tsx index 7193942..58ebaac 100644 --- a/src/features/esign/request-page.tsx +++ b/src/features/esign/request-page.tsx @@ -11,6 +11,7 @@ import { InlineAlert, LoadingButton, SectionCard, + Spinner, } from "@/components/primitives" import { appToast, PageBar, PageContent, useCan } from "@/components/shell" import { Button } from "@/components/ui/button" @@ -22,11 +23,13 @@ import { cn } from "@/lib/utils" import { declineEsignRequest, getEsignDocumentContent, uploadSignedEsignDocument } from "./esign.functions" import { PdfViewer, usePdfBytes } from "./pdf-viewer" -import { kindsLabel, type Place, pagesLabel, placesLeft } from "./places" +import { kindsLabel, type Place, pagesLabel, placeAt, placesLeft } from "./places" +import { MovePlaceButtons, PlacingCancel, placingStatus } from "./placing" import { RequestStatusBadge, SignerStatusBadge, signedProgress, YouChip } from "./request-status" import { cancelEsignRequest, deleteEsignRequest, retryEsignDelivery } from "./requests.functions" import { SignWithCie } from "./sign-with-cie" import { SignatureBox } from "./signature-box" +import { useReloadWhile } from "./use-reload-while" type EventData = { sub?: string; name?: string; path?: string; to?: string[]; error?: string; reason?: string | null } @@ -47,6 +50,12 @@ function eventText(event: Detail["events"][number], names: Map) return `${who} created the request` case "notified": return `Emailed ${(data.sub && names.get(data.sub)) ?? "a signer"} that it's their turn` + case "notify_failed": { + const name = (data.sub && names.get(data.sub)) ?? "a signer" + return data.reason === "no_email" + ? `Couldn't email ${name} that it's their turn: no email address` + : `Couldn't email ${name} that it's their turn` + } case "signed": return `${data.name ?? who} signed with CIE` case "completed": @@ -112,10 +121,20 @@ export function EsignRequestPage({ detail }: { detail: Detail }) { const mySigner = request.signers.find((s) => s.sub === me && s.status === "pending") const myTurn = request.myTurn && mySigner !== undefined - // The viewer's boxes as they drag them; fixed once some of them are signed. - const [myPlaces, setMyPlaces] = useState(mySigner?.places ?? []) - useEffect(() => setMyPlaces(mySigner?.places ?? []), [mySigner?.places]) + // The viewer's boxes as they drag them; fixed once some of them are signed. Reloads bring the saved places as new + // arrays, so the dragged ones reset only when the saved ones change. + const savedPlaces = mySigner?.places ?? [] + const savedKey = JSON.stringify(savedPlaces) + const [myPlaces, setMyPlaces] = useState(savedPlaces) + const [placesKey, setPlacesKey] = useState(savedKey) + if (placesKey !== savedKey) { + setPlacesKey(savedKey) + setMyPlaces(savedPlaces) + } const myDone = mySigner?.signaturesDone ?? 0 + /** The place waiting for a click on its new spot; placing ends by itself once the boxes are fixed. */ + const [placing, setPlacing] = useState(null) + const placingAt = myTurn && myDone === 0 ? placing : null const myLeft = placesLeft( myPlaces.map((place, at) => ({ ...place, at })), myDone @@ -128,21 +147,10 @@ export function EsignRequestPage({ detail }: { detail: Detail }) { // Delivery (OneDrive, email) runs after the last signature: follow it until it settles. const delivering = request.status === "completed" && request.delivery?.status === "pending" - useEffect(() => { - if (!delivering) return - let busy = false - const timer = window.setInterval(() => { - if (busy) return - busy = true - router - .invalidate() - .catch((error) => console.error(error)) - .finally(() => { - busy = false - }) - }, 2000) - return () => window.clearInterval(timer) - }, [delivering, router]) + useReloadWhile(delivering, 2000) + // Another signer holds the request: follow it until they finish, so Sign with CIE turns on by itself. + const lockedBy = myTurn && signingNow && signingNow.sub !== me ? signingNow : null + useReloadWhile(lockedBy !== null, 3000) const names = useMemo(() => { const map = new Map(request.signers.map((s) => [s.sub, s.name])) @@ -150,6 +158,42 @@ export function EsignRequestPage({ detail }: { detail: Detail }) { return map }, [request]) + // For each signer whose turn it is, other than the viewer: when they were emailed, why they couldn't be, or else + // since when it has been their turn. + const turnNotes = useMemo(() => { + const emailed = new Map() + const failed = new Map() + for (const event of events) { + if (!event.data.sub) continue + if (event.type === "notified") emailed.set(event.data.sub, new Date(event.at)) + if (event.type === "notify_failed") failed.set(event.data.sub, event.data.reason) + } + const signedTimes = request.signers.flatMap((s) => (s.signedAt ? [new Date(s.signedAt).getTime()] : [])) + const since = new Date(request.ordered && signedTimes.length > 0 ? Math.max(...signedTimes) : request.createdAt) + const notes = new Map() + for (const sub of request.turn) { + if (sub === me) continue + const at = emailed.get(sub) + if (at) notes.set(sub, { note: `Emailed ${formatDateTime(at)}` }) + else if (failed.has(sub)) + notes.set(sub, { + warning: failed.get(sub) === "no_email" ? "Couldn't email them: no email address" : "Couldn't email them", + }) + else notes.set(sub, { note: `Their turn since ${formatDateTime(since)}` }) + } + return notes + }, [events, request, me]) + + const stopped = request.status === "cancelled" || request.status === "declined" + // A stopped request without an output folder or an email had nothing to deliver: it has no Delivery card. + const deliveryPlanned = request.output !== null || (request.email?.to.length ?? 0) > 0 + const savedItem = request.delivery?.driveItem ?? null + // Recorded right after the upload, before the delivery itself is saved: Activity may say "Saved" first. + const uploaded = events.findLast((e) => e.type === "uploaded") + const saving = delivering && request.output !== null && !savedItem && !uploaded + const emailed = (request.delivery?.emailedTo.length ?? 0) > 0 + const sending = delivering && (request.email?.to.length ?? 0) > 0 && !emailed + const canCancel = request.status === "in_progress" && canManage // A request in progress is cancelled first; a pending delivery still needs the request. const canDelete = request.status !== "in_progress" && request.delivery?.status !== "pending" && canManage @@ -228,7 +272,7 @@ export function EsignRequestPage({ detail }: { detail: Detail }) { {myTurn && ( )} @@ -252,9 +296,19 @@ export function EsignRequestPage({ detail }: { detail: Detail }) { data={bytes} pages={pages} focus={focus} + cursor={placingAt !== null ? "crosshair" : "default"} + onPageClick={(page, x, y) => { + if (placingAt === null) return + setMyPlaces((current) => + current.map((p, at) => (at === placingAt ? placeAt(p, pages, page, x, y) : p)) + ) + setPlacing(null) + }} + status={placingAt !== null ? placingStatus(myPlaces, placingAt) : null} + statusAction={placingAt !== null && setPlacing(null)} />} overlay={(page) => request.signers.flatMap((signer, index) => { - if (signer.status !== "pending") return [] + if (request.status !== "in_progress" || signer.status !== "pending") return [] const mine = myTurn && signer.id === mySigner?.id const left = mine ? myLeft @@ -312,8 +366,24 @@ export function EsignRequestPage({ detail }: { detail: Detail }) { ? `${myDone} of ${kindsLabel(myPlaces.filter((p) => p.kind === "signature"))} done. Choose Sign with CIE at the top to finish.` : myPlaces.length > 1 ? `You sign in ${myPlaces.length} places: ${kindsLabel(myPlaces)} on ${pagesLabel(myPlaces)}. Drag the blue boxes to adjust them, then choose Sign with CIE at the top: one PIN signs them all.` - : "Drag the blue box to adjust where your signature goes, then choose Sign with CIE at the top."}{" "} + : `Your signature goes on ${pagesLabel(myPlaces)}. Drag the blue box to adjust it, then choose Sign with CIE at the top.`}{" "} You need the card, its PIN and the NFC reader. + {myDone === 0 && ( + <> + {" "} + First time signing on this computer?{" "} + {/* A new tab, because the boxes moved here aren't saved yet. */} + + Set it up first + + . + + )}

{detail.alsoWaiting > 0 && (
@@ -332,12 +402,13 @@ export function EsignRequestPage({ detail }: { detail: Detail }) {
)} - {signingNow && signingNow.sub !== me && ( + {lockedBy && ( - {signingNow.name} is signing right now. Try again in a few minutes. + {lockedBy.name} is signing right now. Sign with CIE turns on as soon as they finish. )}
+ {myDone === 0 && } @@ -378,116 +449,138 @@ export function EsignRequestPage({ detail }: { detail: Detail }) {
    - {request.signers.map((signer, index) => ( -
  1. - - {index + 1} - -
    -
    - - {signer.name} - {signer.sub === me && } - - -
    - {(signer.role || signer.places.length > 1 || signer.signedAt) && ( -
    - {[ - signer.role, - signer.places.length > 1 && kindsLabel(signer.places), - signer.signedAt && formatDateTime(new Date(signer.signedAt)), - ] - .filter(Boolean) - .join(" · ")} -
    - )} - {signer.status === "signed" && signer.certificateIssuer && ( -
    - CIE · {signer.fiscalCode ?? signer.certificateIssuer} + {request.signers.map((signer, index) => { + const turnNote = turnNotes.get(signer.sub) + const meta = [ + signer.role, + signer.places.length > 1 && kindsLabel(signer.places), + signer.signedAt && formatDateTime(new Date(signer.signedAt)), + turnNote?.note, + ].filter(Boolean) + return ( +
  2. + + {index + 1} + +
    +
    + + {signer.name} + {signer.sub === me && } + +
    - )} - {signer.declineReason &&
    “{signer.declineReason}”
    } -
    -
  3. - ))} + {meta.length > 0 &&
    {meta.join(" · ")}
    } + {turnNote?.warning && ( +
    {turnNote.warning}
    + )} + {signer.status === "signed" && signer.certificateIssuer && ( +
    + CIE · {signer.fiscalCode ?? signer.certificateIssuer} +
    + )} + {signer.declineReason && ( +
    “{signer.declineReason}”
    + )} +
+ + ) + })} {!request.ordered && request.signers.length > 1 && (

Signers can sign in any order.

)} - -
    -
  • - - - {request.output ? ( - request.delivery?.driveItem?.webUrl ? ( - - {request.output.folderPath ? `${request.output.folderPath}/` : ""} - {request.delivery.driveItem.name} - + {(!stopped || deliveryPlanned) && ( + + {stopped ? ( +

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

    + ) : ( +
      +
    • + {saving ? ( + ) : ( - - {request.delivery?.driveItem ? "Saved to " : "Will be saved to "} - {detail.config.drives.find((d) => d.id === request.output?.driveId)?.label ?? "OneDrive"}:{" "} - {request.output.folderPath || "/"} - {request.output.folderPath ? "/" : ""} - {request.delivery?.driveItem?.name ?? request.output.fileName} - - ) - ) : ( - Kept in the dashboard only - )} - -
    • -
    • - - - {request.email?.to.length ? ( - <> - {request.delivery?.emailedTo.length ? "Emailed to " : "Will be emailed to "} - {request.email.to.join(", ")} - - ) : ( - No email - )} - -
    • -
    - {request.delivery?.status === "failed" && ( - - Retry - - ) : undefined - } - > - {request.delivery.error ?? "The document couldn't be delivered."} - - )} -
    + + )} + + {request.output ? ( + savedItem?.webUrl ? ( + + {request.output.folderPath ? `${request.output.folderPath}/` : ""} + {savedItem.name} + + ) : ( + + {savedItem || uploaded ? "Saved to " : saving ? "Saving to " : "Will be saved to "} + {detail.config.drives.find((d) => d.id === request.output?.driveId)?.label ?? "OneDrive"}:{" "} + {request.output.folderPath || "/"} + {request.output.folderPath ? "/" : ""} + {savedItem?.name ?? uploaded?.data.name ?? request.output.fileName} + + ) + ) : ( + Kept in the dashboard only + )} + +
  • +
  • + {sending ? ( + + ) : ( + + )} + + {request.email?.to.length ? ( + <> + {emailed ? "Emailed to " : sending ? "Sending to " : "Will be emailed to "} + {request.email.to.join(", ")} + + ) : ( + No email + )} + +
  • +
+ )} + {request.delivery?.status === "failed" && ( + + Retry + + ) : undefined + } + > + {request.delivery.error ?? "The document couldn't be delivered."} + + )} +
+ )}
    @@ -505,7 +598,14 @@ export function EsignRequestPage({ detail }: { detail: Detail }) { {canCancel && ( + Nobody will be able to sign it anymore. Signatures already added stay in the document. + {signingNow && + signingNow.sub !== me && + ` ${signingNow.name} is signing it right now and won't be able to finish.`} + + } confirmLabel="Cancel request" cancelLabel="Keep request" tone="danger" diff --git a/src/features/esign/request-status.tsx b/src/features/esign/request-status.tsx index d1aa8f3..fb53398 100644 --- a/src/features/esign/request-status.tsx +++ b/src/features/esign/request-status.tsx @@ -19,12 +19,31 @@ export function RequestStatusBadge({ request }: { request: PickCancelled } -export function SignerStatusBadge({ signer, turn }: { signer: EsignSigner; turn: boolean }) { +export function SignerStatusBadge({ + signer, + turn, + requestStatus, +}: { + signer: EsignSigner + turn: boolean + requestStatus: EsignRequest["status"] +}) { if (signer.status === "signed") return Signed if (signer.status === "declined") return Declined + if (requestStatus !== "in_progress") return Not signed return turn ? To sign : Waiting } +const STATUS_FILTERS = ["all", "in_progress", "completed", "closed"] as const + +/** The requests list's status segment; "closed" is cancelled or declined. */ +export type StatusFilter = (typeof STATUS_FILTERS)[number] + +/** The `?status=` search param; an unknown value falls back to the default instead of failing the route. */ +export function parseStatusFilter(value: string | undefined): StatusFilter | undefined { + return STATUS_FILTERS.find((filter) => filter === value) +} + /** "1 of 2 signed". */ export function signedProgress(request: Pick) { return `${request.signedCount} of ${request.signers.length} signed` diff --git a/src/features/esign/requests-page.tsx b/src/features/esign/requests-page.tsx index 930fcb3..805a200 100644 --- a/src/features/esign/requests-page.tsx +++ b/src/features/esign/requests-page.tsx @@ -2,16 +2,14 @@ import { Link, useNavigate } from "@tanstack/react-router" import { FileSignature, Files, PenLine, Plus } from "lucide-react" import { useDeferredValue, useMemo, useState } from "react" -import { AvatarGroup, DataTable, type DataTableColumn, EmptyState } from "@/components/primitives" +import { AvatarGroup, DataTable, type DataTableColumn, EmptyState, Hint } from "@/components/primitives" import { SegmentedControl } from "@/components/primitives/segmented-control" import { Count, PageBar, PageContent, Toolbar, useCan } from "@/components/shell" import { Button } from "@/components/ui/button" import type { EsignRequest } from "@/lib/api/types" import { formatDate, pluralize } from "@/lib/format" -import { RequestStatusBadge, signedProgress } from "./request-status" - -type StatusFilter = "all" | "in_progress" | "completed" | "closed" +import { RequestStatusBadge, type StatusFilter, signedProgress } from "./request-status" const SCOPE_ITEMS: { value: "mine" | "all"; label: string }[] = [ { value: "mine", label: "Mine" }, @@ -45,6 +43,13 @@ function NewRequestButton({ variant = "default", self }: { variant?: "default" | ) } +/** Whom a request in progress waits on; the viewer's own turn shows as a badge, so it keeps the progress. */ +function turnText(request: EsignRequest) { + const name = request.signers.find((s) => s.sub === request.turn[0])?.name + if (request.myTurn || !name) return signedProgress(request) + return request.turn.length > 1 ? `Waiting for ${name} and ${request.turn.length - 1} more` : `Waiting for ${name}` +} + const columns: DataTableColumn[] = [ { id: "title", @@ -62,18 +67,25 @@ const columns: DataTableColumn[] = [ { id: "signers", label: "Signers", - width: 220, + width: 260, priority: 1, - cell: (r) => ( - - ({ id: s.id, name: s.name }))} - max={4} - listLabel={`Signers of ${r.title}`} - /> - {signedProgress(r)} - - ), + cell: (r) => { + const text = r.status === "in_progress" ? turnText(r) : null + return ( + + ({ id: s.id, name: s.name }))} + max={4} + listLabel={`Signers of ${r.title}`} + /> + {text && ( + + {text} + + )} + + ) + }, }, { id: "created", @@ -84,12 +96,24 @@ const columns: DataTableColumn[] = [ }, ] -/** The signatures inbox ("To sign") and the requests list share this page. */ -export function EsignRequestsPage({ requests, scope }: { requests: EsignRequest[]; scope: "inbox" | "mine" | "all" }) { +// Every document in To sign is the viewer's turn. +const inboxColumns = columns.filter((column) => column.id !== "status") + +/** The signatures inbox ("To sign") and the requests list share this page; the list's status lives in the URL. */ +export function EsignRequestsPage({ + requests, + scope, + status = "all", + onStatusChange, +}: { + requests: EsignRequest[] + scope: "inbox" | "mine" | "all" + status?: StatusFilter + onStatusChange?: (status: StatusFilter) => void +}) { const canManage = useCan("esign:requests:manage") const navigate = useNavigate() const [query, setQuery] = useState("") - const [filter, setFilter] = useState("all") const deferred = useDeferredValue(query.trim().toLocaleLowerCase()) const inbox = scope === "inbox" @@ -97,13 +121,13 @@ export function EsignRequestsPage({ requests, scope }: { requests: EsignRequest[ () => requests.filter( (r) => - inFilter(r, inbox ? "all" : filter) && + inFilter(r, status) && (deferred === "" || `${r.title} ${r.createdByName} ${r.signers.map((s) => s.name).join(" ")}` .toLocaleLowerCase() .includes(deferred)) ), - [requests, filter, deferred, inbox] + [requests, status, deferred] ) const narrowed = visible.length !== requests.length const waiting = inbox ? requests.filter((r) => r.myTurn).length : 0 @@ -141,7 +165,7 @@ export function EsignRequestsPage({ requests, scope }: { requests: EsignRequest[ items={SCOPE_ITEMS} value={scope} onValueChange={(value) => - void navigate({ to: "/dashboard/esign/requests", search: { scope: value } }) + void navigate({ to: "/dashboard/esign/requests", search: (prev) => ({ ...prev, scope: value }) }) } /> ) : undefined @@ -155,8 +179,8 @@ export function EsignRequestsPage({ requests, scope }: { requests: EsignRequest[ ...item, count: requests.filter((r) => inFilter(r, item.value)).length, }))} - value={filter} - onValueChange={setFilter} + value={status} + onValueChange={(next) => onStatusChange?.(next)} /> ) } @@ -168,7 +192,7 @@ export function EsignRequestsPage({ requests, scope }: { requests: EsignRequest[ r.id} rowHref={(r) => `/dashboard/esign/requests/${r.id}`} @@ -196,7 +220,7 @@ export function EsignRequestsPage({ requests, scope }: { requests: EsignRequest[ variant="ghost" onClick={() => { setQuery("") - setFilter("all") + onStatusChange?.("all") }} > Clear filters diff --git a/src/features/esign/setup-page.tsx b/src/features/esign/setup-page.tsx index dc0a9e0..1f043d3 100644 --- a/src/features/esign/setup-page.tsx +++ b/src/features/esign/setup-page.tsx @@ -1,4 +1,4 @@ -import { CreditCard, Download, ExternalLink, MonitorCheck, Nfc } from "lucide-react" +import { CreditCard, Download, ExternalLink, MonitorCheck, Nfc, Play } from "lucide-react" import { type ReactNode, useEffect, useState } from "react" import { SectionCard } from "@/components/primitives" @@ -6,7 +6,7 @@ import { SegmentedControl } from "@/components/primitives/segmented-control" import { PageBar, PageContent } from "@/components/shell" import { Button } from "@/components/ui/button" -import { AGENT_ORIGINS, AGENT_RELEASES_URL } from "./agent" +import { AGENT_LAUNCH_URL, AGENT_ORIGINS, AGENT_RELEASES_URL } from "./agent" type Os = "macos" | "windows" | "linux" @@ -55,16 +55,20 @@ const CIE_SOFTWARE = { } satisfies Record const AGENT_STEPS = { + // Drop the Open Anyway sentence once macOS releases are notarized (docs/esign/TRACKER.md, Code signing). macos: ( <> - Download the macOS app, unzip it and open PoliNetwork eSign. It moves itself to your Applications - folder and starts whenever you log in. + Download the macOS .dmg, open it and double-click PoliNetwork eSign. The first time, macOS says + it can't verify the app: choose Done, open System Settings › Privacy & Security, choose Open Anyway next to the + message about PoliNetwork eSign, and confirm with your password. It then installs itself and starts whenever you + log in. ), + // Drop the Run anyway sentence once Windows releases are signed (docs/esign/TRACKER.md, Code signing). windows: ( <> - Download the Windows archive, extract it and double-click esign.exe. It installs itself and - starts whenever you log in. + Download the Windows .zip, extract it and double-click esign.exe. If Windows says “Windows + protected your PC”, choose More info, then Run anyway. It then installs itself and starts whenever you log in. ), linux: ( @@ -129,8 +133,9 @@ export function EsignSetupPage() {

    - The CIE is contactless: you need a USB NFC reader that works as a standard smart-card reader (PC/SC), such - as the ones listed by the Ministry of the Interior for the CIE. Phones can't be used here. + The CIE is contactless: you need a USB NFC reader that works as a standard smart-card reader (PC/SC). If + you don't have one yet, get one sold as compatible with the CIE 3.0 before the next steps. Phones can't be + used here.

    @@ -149,7 +154,7 @@ export function EsignSetupPage() {

    - Alternatively the agent works with the open-source{" "} + Alternatively PoliNetwork eSign works with the open-source{" "} } > - Download the agent + Download PoliNetwork eSign

- Your PIN is typed in the agent's own window on this computer and goes only to the card. PoliNetwork never - receives it. + Your PIN is typed in the PoliNetwork eSign window on this computer and goes only to the card. PoliNetwork + never receives it.

-

Open the agent's page: it should say “The agent is running”. Then sign a document from Signatures.

-
+

+ Put your CIE on the reader and open the PoliNetwork eSign status page: it should show that PoliNetwork + eSign is running and ready to sign. Then sign a document from Signatures. +

+ {os === "macos" && ( +

+ When you sign, macOS may ask whether PoliNetwork eSign can access data from other apps: choose Allow, so + it can read the card pairing made in Software CIE. +

+ )} +
+
+

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

diff --git a/src/features/esign/sign-with-cie.tsx b/src/features/esign/sign-with-cie.tsx index f026a6b..99b6e96 100644 --- a/src/features/esign/sign-with-cie.tsx +++ b/src/features/esign/sign-with-cie.tsx @@ -1,3 +1,4 @@ +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" import { useRouter } from "@tanstack/react-router" import { useServerFn } from "@tanstack/react-start" import { @@ -14,16 +15,17 @@ import { } from "lucide-react" import { type ReactNode, useCallback, useEffect, useRef, useState } from "react" -import { InlineAlert, LoadingButton } from "@/components/primitives" +import { buttonMotion, dialogMotion, InlineAlert, LoadingButton, scrimClasses } from "@/components/primitives" import { dialogDescriptionText, dialogFooter, + dialogFooterButton, dialogPanel, dialogTitleText, } from "@/components/primitives/confirm-dialog" import { appToast } from "@/components/shell" import { Button } from "@/components/ui/button" -import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog" +import { Dialog, DialogDescription, DialogOverlay, DialogPortal, DialogTitle } from "@/components/ui/dialog" import { pluralize } from "@/lib/format" import { cn } from "@/lib/utils" @@ -37,51 +39,122 @@ export type SignItem = { requestId: string; title: string; places?: BoxPlacement type Job = { jobId: string; requestId: string; title: string } type JobState = { status: string; errorCode: string | null; errorMessage: string | null; seen: boolean } +/** Nothing was signed and the attempt can't go on; `code` is the job's error code. */ +type Ending = { kind: "ended"; reason: keyof typeof ENDED_TITLES; code: string | null; message: string } type Phase = | { kind: "starting" } | { kind: "waiting"; jobs: Job[]; url: string; since: number } | { kind: "signing"; jobs: Job[]; url: string } - | { kind: "ended"; reason: "cancelled" | "expired" | "failed"; message: string } + | Ending +type Shown = Exclude const POLL_MS = 1200 /** After this long without the agent fetching the job, it is probably not running. */ const AGENT_TIMEOUT_MS = 7000 const OPEN = new Set(["pending", "prepared"]) -/** What the agent reported, by its error code (esign `internal/token`, backend `agent_outdated`). */ +const ENDED_TITLES = { + cancelled: "Request cancelled", + declined: "Request declined", + expired: "The link expired", + failed: "Signature failed", +} + +/** + * What the agent or the backend reported, by its error code (esign `internal/token`; the backend's `agent_outdated`, + * `document_changed` and `job_done`, whose own messages are written for the agent window). + */ const AGENT_ERRORS = new Map([ - ["pin_incorrect", "Wrong PIN. Type it again in the agent window; after 3 wrong attempts the card blocks it."], - ["pin_locked", "The PIN is blocked. Unblock it with the PUK in the CIE ID app, then try again."], + ["pin_incorrect", "Wrong PIN. Type it again in the PoliNetwork eSign window."], + [ + "pin_locked", + "The PIN is blocked. Unblock it with your PUK in the CieID app on your phone or in CIE ID on this computer, then sign again. The PUK's first half is on the receipt from the Comune, the second half in the letter that came with the card.", + ], ["pin_invalid", "The PIN must be the 8 digits of your CIE PIN."], - ["no_card", "The agent can't see the card. Place the CIE on the reader."], - ["no_reader", "The agent can't find an NFC reader. Connect it and try again."], + ["no_card", "PoliNetwork eSign can't see the card. Place the CIE on the reader."], + ["no_reader", "PoliNetwork eSign can't find an NFC reader. Connect it and try again."], ["card_removed", "The card moved away from the reader. Keep it still and try again."], - ["middleware_missing", "The agent found no CIE software. Open the setup guide."], - ["not_paired", "This CIE is not set up on this computer yet. Open the setup guide."], + ["middleware_missing", "PoliNetwork eSign found no CIE software on this computer."], + ["not_paired", "This CIE isn't set up on this computer yet."], ["card_unsupported", "This card isn't a supported CIE."], ["card_untrusted", "The card couldn't be verified as a genuine CIE, so the PIN wasn't sent to it."], ["device_error", "The card couldn't complete the signature. Try again."], - ["signature_invalid", "The card's signature didn't match. Try again."], - ["agent_outdated", "This version of PoliNetwork eSign is too old. Install the latest one from the setup guide."], + ["signature_invalid", "The card's signature didn't match. Sign again."], + [ + "agent_outdated", + "This version of the signing app is too old. Install the latest PoliNetwork eSign from the setup guide.", + ], + ["document_changed", "Someone else signed in the meantime, so the document changed. Sign it again."], + ["job_done", "This request changed while you were signing and no longer needs your signature."], ]) +/** Errors the setup guide resolves; the dialog links it next to them. */ +const SETUP_ERRORS = new Set(["middleware_missing", "not_paired", "card_untrusted", "agent_outdated"]) + function errorText(state: JobState | undefined) { if (!state?.errorCode || state.errorCode === "cancelled") return null return AGENT_ERRORS.get(state.errorCode) ?? state.errorMessage ?? "The signature failed. Try again." } -function openPopup(): Window | null { +/** + * How an attempt that signed nothing ended, once none of its jobs is open: someone else cancelled or declined the + * request, the link expired, or a job failed for good. Null when every job was cancelled, which is the signer's own + * cancel (in the agent window or another tab) and needs no message. + */ +function endingOf(statuses: (JobState & { jobId: string })[], jobs: Job[], preparing: boolean): Ending | null { + const titled = (status: { jobId: string }, message: string) => { + const title = jobs.length > 1 && jobs.find((j) => j.jobId === status.jobId)?.title + return title ? `${title}: ${message}` : message + } + const foreign = statuses.find( + (s) => s.status === "cancelled" && (s.errorCode === "request_cancelled" || s.errorCode === "request_declined") + ) + if (foreign) + return { + kind: "ended", + reason: foreign.errorCode === "request_declined" ? "declined" : "cancelled", + code: foreign.errorCode, + message: titled(foreign, foreign.errorMessage ?? "Nothing was signed."), + } + if (statuses.some((s) => s.status === "expired")) + return { + kind: "ended", + reason: "expired", + code: null, + message: preparing + ? "Close to open the request and sign it from there." + : "The PoliNetwork eSign window waited too long for the PIN. Start again to open a new one.", + } + const failed = statuses.find((s) => s.status === "failed") + if (!failed) return null + const known = failed.errorCode && AGENT_ERRORS.get(failed.errorCode) + return { + kind: "ended", + reason: "failed", + code: failed.errorCode, + message: titled(failed, known || failed.errorMessage || "The signature failed."), + } +} + +/** The agent window's size, centered horizontally over the dashboard window. */ +function popupFeatures() { const width = 460 const height = 680 const left = Math.max(0, window.screenX + (window.outerWidth - width) / 2) const top = Math.max(0, window.screenY + (window.outerHeight - height) / 3) + return `popup=yes,width=${width},height=${height},left=${left},top=${top}` +} + +function openPopup(): Window | null { // A new name each time: a window still showing the agent's page is cross-origin, and writing to it would throw. - const name = `pn-esign-${Date.now()}` - const popup = window.open("", name, `popup=yes,width=${width},height=${height},left=${left},top=${top}`) + const popup = window.open("", `pn-esign-${Date.now()}`, popupFeatures()) if (popup) { popup.document.title = "PoliNetwork eSign" + // The agent page's canvas and muted text (sign-agent internal/server/web/assets/style.css), following the OS + // scheme as that page does rather than the dashboard's theme, so the page takes over without a change of colour. + popup.document.documentElement.style.colorScheme = "light dark" popup.document.body.style.cssText = - "font:15px system-ui,sans-serif;color:#58626c;display:grid;place-items:center;height:90vh;margin:0" + "font:15px system-ui,sans-serif;background:#f6f7f9;background:light-dark(#f6f7f9,#060e16);color:#58626c;color:light-dark(#58626c,#9da6ae);display:grid;place-items:center;height:90vh;margin:0" popup.document.body.textContent = "Opening PoliNetwork eSign…" } return popup @@ -122,9 +195,15 @@ export function SignWithCie({ const cancelFn = useServerFn(cancelEsignSignature) const statusFn = useServerFn(getEsignJobStatus) const [phase, setPhase] = useState(null) + // The last phase the dialog showed: its content stays while the dialog animates out. + const [shown, setShown] = useState(null) const [states, setStates] = useState>(new Map()) const [now, setNow] = useState(Date.now()) const [error, setError] = useState(null) + const [windowClosed, setWindowClosed] = useState(false) + const [agentLost, setAgentLost] = useState(false) + // An ended attempt's Close is reloading the page before the dialog closes; stays set while it fades out. + const [closing, setClosing] = useState(false) const popupRef = useRef(null) const finishing = useRef(false) @@ -150,34 +229,42 @@ export function SignWithCie({ [reload, onSigned] ) + /** Closes the dialog after the signer's own cancel: no message, no reload, nothing left to cancel. */ + const dismiss = useCallback(() => { + if (finishing.current) return + finishing.current = true + setPhase(null) + popupRef.current?.close() + popupRef.current = null + onStopped?.(null) + }, [onStopped]) + const jobs = phase && "jobs" in phase ? phase.jobs : null - const batch = (jobs ?? items ?? []).length > 1 + const preparing = prepare !== undefined useEffect(() => { if (!jobs) return let stopped = false const poll = async () => { + // Readable only while the dashboard keeps its opener relationship with the window: the agent sends no COOP + // header, and the dashboard must not send `Cross-Origin-Opener-Policy: same-origin`. + const closedBefore = popupRef.current?.closed === true try { const statuses = await statusFn({ data: { jobIds: jobs.map((j) => j.jobId) } }) if (stopped) return setStates(new Map(statuses.map((s) => [s.jobId, s]))) - const done = statuses.filter((s) => s.status === "completed").length - const ended = statuses.find((s) => s.status !== "completed" && !OPEN.has(s.status)) if (statuses.every((s) => !OPEN.has(s.status))) { - if (done > 0 || !ended) return void finish(done, jobs.length) - const reason = ended.status === "expired" ? "expired" : ended.status === "cancelled" ? "cancelled" : "failed" - setPhase({ - kind: "ended", - reason, - message: - reason === "expired" - ? "The signing link expired. Start again." - : reason === "cancelled" - ? "The signature was cancelled." - : (ended.errorMessage ?? "The signature failed."), - }) - return + const done = statuses.filter((s) => s.status === "completed").length + if (done > 0) return void finish(done, jobs.length) + const ending = endingOf(statuses, jobs, preparing) + if (!ending) return dismiss() + // Someone else ended the request: the agent window would keep asking for a PIN that can sign nothing. + if (ending.reason === "cancelled" || ending.reason === "declined") popupRef.current?.close() + return setPhase(ending) } + // Closed before and after the request, so a window opened again meanwhile counts as open. The agent closes + // its window on cancel or success only after the backend recorded it, so those end above. + setWindowClosed(closedBefore && popupRef.current?.closed === true) if (statuses.some((s) => s.seen)) setPhase((current) => (current?.kind === "waiting" ? { kind: "signing", jobs, url: current.url } : current)) } catch (cause) { @@ -189,6 +276,8 @@ export function SignWithCie({ function onMessage(event: MessageEvent) { // Agents up to v0.1.x, named PoliNetwork Sign Agent, post "pn-sign-agent". if (!isAgentOrigin(event.origin) || !["pn-esign", "pn-sign-agent"].includes(event.data?.type)) return + // The page lost the agent behind it: the one outcome the backend can't know about. + setAgentLost(event.data?.status === "error" && event.data?.detail === "agent_unreachable") void poll() } window.addEventListener("message", onMessage) @@ -198,13 +287,18 @@ export function SignWithCie({ window.clearInterval(tick) window.removeEventListener("message", onMessage) } - }, [jobs, statusFn, finish]) + }, [jobs, preparing, statusFn, finish, dismiss]) async function start() { + // The dialog is on its way out: its Close is reloading the page. + if (finishing.current && phase) return const prepared = prepare ? prepare() : Promise.resolve(items ?? []) if (!prepared) return setError(null) setStates(new Map()) + setWindowClosed(false) + setAgentLost(false) + setClosing(false) finishing.current = false popupRef.current?.close() let popup: Window | null = null @@ -245,11 +339,20 @@ export function SignWithCie({ } } - /** Closes the dialog, cancelling what isn't signed yet; reloads when part of a batch was signed. */ + /** Closes the dialog, cancelling what isn't signed yet; reloads when part of a batch was signed or it ended. */ async function close() { + if (finishing.current) return + finishing.current = true const current = phase - setPhase(null) popupRef.current?.close() + if (current?.kind === "ended") { + // Reloaded while the dialog is still open, as finish() does: the request may have been cancelled meanwhile. + setClosing(true) + await reload() + setPhase(null) + return onStopped?.(null) + } + setPhase(null) if (!current || !("jobs" in current)) return onStopped?.(null) const open = current.jobs.filter((j) => OPEN.has(states.get(j.jobId)?.status ?? "pending")) if (open.length > 0) { @@ -263,17 +366,54 @@ export function SignWithCie({ onStopped?.(null) } - function reopen(url: string) { - popupRef.current = window.open(url, "pn-esign", "popup=yes,width=460,height=680") + /** Brings the agent window to the front without reloading it (the PIN may be half typed), or opens it again. */ + function showWindow(url: string) { + const popup = popupRef.current + if (popup && !popup.closed) popup.focus() + else popupRef.current = window.open(url, `pn-esign-${Date.now()}`, popupFeatures()) + setWindowClosed(false) + } + + /** Loads the signing page again in the agent window, opening a new one if it was closed. */ + function openWindowAgain(url: string) { + const popup = popupRef.current + if (popup && !popup.closed) popup.location.href = url + showWindow(url) + setAgentLost(false) } - const silent = phase?.kind === "waiting" && now - phase.since > AGENT_TIMEOUT_MS - const signed = jobs?.filter((j) => states.get(j.jobId)?.status === "completed").length ?? 0 + if (phase && phase.kind !== "starting" && phase !== shown) setShown(phase) + // An expired link can start again from the dialog; a prepared document is signed from its request page instead. + const restartable = shown?.kind === "ended" && shown.reason === "expired" && !preparing + const active = shown && shown.kind !== "ended" ? shown : null + const batch = (active?.jobs ?? items ?? []).length > 1 + const silent = active?.kind === "waiting" && now - active.since > AGENT_TIMEOUT_MS + const notAnswering = active !== null && (silent || agentLost) + const signed = active?.jobs.filter((j) => states.get(j.jobId)?.status === "completed").length ?? 0 + const openJobs = active?.jobs.filter((j) => OPEN.has(states.get(j.jobId)?.status ?? "pending")) ?? [] + // Every document is signed or ended: the poll is finishing and the page reloads. + const settled = active !== null && signed > 0 && openJobs.length === 0 // A failure every open document shares (a wrong PIN, no card) is shown once, above the list. - const open = jobs?.filter((j) => OPEN.has(states.get(j.jobId)?.status ?? "pending")) ?? [] - const firstCode = open[0] && states.get(open[0].jobId)?.errorCode - const sharedCode = firstCode && open.every((j) => states.get(j.jobId)?.errorCode === firstCode) ? firstCode : null - const sharedError = sharedCode && open[0] ? errorText(states.get(open[0].jobId)) : null + const firstCode = openJobs[0] && states.get(openJobs[0].jobId)?.errorCode + const sharedCode = firstCode && openJobs.every((j) => states.get(j.jobId)?.errorCode === firstCode) ? firstCode : null + const sharedError = sharedCode && openJobs[0] ? errorText(states.get(openJobs[0].jobId)) : null + const needsSetup = sharedCode !== null && SETUP_ERRORS.has(sharedCode) + const total = active?.jobs.length ?? 0 + const jobList = active && ( +
    + {active.jobs.map((job) => ( + + ))} +
+ ) + const progress = batch && ( + <> +

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

+ {jobList} + + ) return ( <> @@ -287,110 +427,175 @@ export function SignWithCie({ {children ?? (items && items.length > 1 ? `Sign ${items.length} documents` : "Sign with CIE")} {error && {error}} - !open && void close()}> - - {phase?.kind === "waiting" && !silent && ( - } title="Opening PoliNetwork eSign"> - A window from PoliNetwork eSign opens on this computer. If it doesn't, open it again. - - )} - {phase?.kind === "waiting" && silent && ( - } - tone="warning" - title="PoliNetwork eSign isn't answering" - > -

- Start the agent, then open the signing window again. First time here? Install it from the setup guide. -

-
- - - + {needsSetup && } +
+
+ ) : null} + {shown?.kind === "ended" ? ( +
+ void close()} > - Setup guide - + Close + + {restartable && ( + + )}
- - )} - {phase?.kind === "signing" && ( - } title="Type your PIN in the agent window"> -

- Keep the CIE on the reader. {batch ? `One PIN signs all ${phase.jobs.length} documents. ` : ""} - The PIN goes only to the card, never to PoliNetwork. -

- {sharedError && {sharedError}} - {batch && ( - <> -

- {signed} of {pluralize(phase.jobs.length, "document")} signed -

-
    - {phase.jobs.map((job) => ( - - ))} -
- - )} - -
- )} - {phase?.kind === "ended" && ( - : - } - tone={phase.reason === "failed" ? "danger" : "neutral"} - title={ - phase.reason === "expired" - ? "The link expired" - : phase.reason === "cancelled" - ? "Signature cancelled" - : "Signature failed" - } - > - {phase.message} - - )} -
- {phase?.kind === "ended" ? ( - - ) : signed > 0 ? ( - ) : ( - + !settled && ( +
+ + {active && (notAnswering || windowClosed) && ( + + )} +
+ ) )} -
-
+ +
) } +/** + * PoliNetwork eSign doesn't answer: start it (or set it up) here, then open its window again, the footer's primary + * action, so the buttons read in the order of the steps. + */ +function NotAnswering({ title, children }: { title: string; children: string }) { + return ( + } tone="warning" title={title}> +

{children}

+
+ + +
+
+ ) +} + +function SetupGuideButton() { + return ( + + ) +} + function JobRow({ job, state, hideError }: { job: Job; state: JobState | undefined; hideError: boolean }) { const done = state?.status === "completed" const failure = done || hideError ? null : errorText(state) + const notSigned = failure !== null || (state !== undefined && !done && !OPEN.has(state.status)) return (
  • {done ? ( - ) : failure ? ( + ) : notSigned ? ( ) : ( @@ -411,15 +616,16 @@ function Status({ }: { icon: ReactNode title: string - tone?: "brand" | "warning" | "danger" | "neutral" + tone?: "brand" | "success" | "warning" | "danger" | "neutral" children: ReactNode }) { return ( -
    +
    { + if (!active) return + let busy = false + const timer = window.setInterval(() => { + if (busy || document.visibilityState === "hidden") return + busy = true + router + .invalidate() + .catch((error) => console.error(error)) + .finally(() => { + busy = false + }) + }, ms) + return () => window.clearInterval(timer) + }, [active, ms, router]) +} diff --git a/src/routes/dashboard/esign/inbox.tsx b/src/routes/dashboard/esign/inbox.tsx index 9186f21..71db368 100644 --- a/src/routes/dashboard/esign/inbox.tsx +++ b/src/routes/dashboard/esign/inbox.tsx @@ -15,7 +15,7 @@ export const Route = createFileRoute("/dashboard/esign/inbox")({ <> - + ), diff --git a/src/routes/dashboard/esign/requests/index.tsx b/src/routes/dashboard/esign/requests/index.tsx index 555f314..359dc54 100644 --- a/src/routes/dashboard/esign/requests/index.tsx +++ b/src/routes/dashboard/esign/requests/index.tsx @@ -4,14 +4,19 @@ import { z } from "zod" import { TableSkeleton } from "@/components/primitives" import { PageBar, PageContent } from "@/components/shell" import { getEsignHome } from "@/features/esign/esign.functions" +import { parseStatusFilter } from "@/features/esign/request-status" import { EsignRequestsPage } from "@/features/esign/requests-page" import { hasPermission } from "@/server/permissions" export const Route = createFileRoute("/dashboard/esign/requests/")({ - validateSearch: z.object({ scope: z.enum(["mine", "all"]).optional() }), + validateSearch: z.object({ + scope: z.enum(["mine", "all"]).optional(), + status: z.string().optional().transform(parseStatusFilter), + }), beforeLoad: ({ context }) => { if (!hasPermission(context.permissions, "esign:sign")) throw redirect({ to: "/unauthorized" }) }, + // `status` filters in the page, so changing it doesn't reload. loaderDeps: ({ search }) => ({ scope: search.scope ?? "mine" }), // Without the manage permission everyone only sees their own requests. loader: async ({ deps, context }) => { @@ -32,5 +37,19 @@ export const Route = createFileRoute("/dashboard/esign/requests/")({ function RequestsRoute() { const { requests, scope } = Route.useLoaderData() - return + const { status } = Route.useSearch() + const navigate = Route.useNavigate() + return ( + + void navigate({ + search: (previous) => ({ ...previous, status: next === "all" ? undefined : next }), + replace: true, + }) + } + /> + ) } diff --git a/src/routes/dashboard/index.tsx b/src/routes/dashboard/index.tsx index ac02513..79f342d 100644 --- a/src/routes/dashboard/index.tsx +++ b/src/routes/dashboard/index.tsx @@ -1,10 +1,12 @@ -import { createFileRoute } from "@tanstack/react-router" +import { createFileRoute, getRouteApi } from "@tanstack/react-router" import { CardsSkeleton } from "@/components/primitives" import { PageBar, PageContent } from "@/components/shell" import { DashboardOverviewPage } from "@/features/dashboard/overview-page" import { getOverviewCounts, getTelegramUserHistory } from "@/features/dashboard/overview.functions" +const dashboardRoute = getRouteApi("/dashboard") + export const Route = createFileRoute("/dashboard/")({ // Both start at once; the history is not awaited, so the hero renders with the counts and its chart streams in. loader: async () => { @@ -28,5 +30,6 @@ export const Route = createFileRoute("/dashboard/")({ function OverviewRoute() { const { counts, userHistory } = Route.useLoaderData() - return + const pending = dashboardRoute.useLoaderData({ select: (data) => data.pending }) + return } diff --git a/tests/esign-mutation-refresh.test.mjs b/tests/esign-mutation-refresh.test.mjs index a8d0a9d..4d9583b 100644 --- a/tests/esign-mutation-refresh.test.mjs +++ b/tests/esign-mutation-refresh.test.mjs @@ -19,7 +19,7 @@ async function handler(path, name, dependencies) { } visit(source) assert.ok(found, `${name} not found in ${path}`) - const compiled = ts.transpileModule(`const handler = ${found.getText()}`, { + const compiled = ts.transpileModule(`const handler = ${found.getText().replace(/^export /, "")}`, { compilerOptions: { target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.None }, }).outputText return runInNewContext(`${compiled}; handler`, { ...dependencies }) @@ -70,10 +70,15 @@ async function composer(env, overrides = {}) { created: { current: null }, creating: { current: false }, setAttempted: env.setter("attempted"), + setShown: env.setter("shown"), + setBlockedSubmits: (update) => { + env.state.blockedSubmits = update(env.state.blockedSubmits ?? 0) + }, setSubmitting: env.setter("submitting"), setSubmitError: env.setter("error"), setCreatedRequest: env.setter("createdRequest"), - problems: [], + problems: false, + errors: {}, staged: { id: "document-1" }, title: "Request", ordered: true, @@ -91,6 +96,7 @@ async function composer(env, overrides = {}) { ...overrides, } dependencies.create = await handler(path, "create", dependencies) + dependencies.showProblems = await handler(path, "showProblems", dependencies) dependencies.openRequest = await handler(path, "openRequest", dependencies) return { submit: await handler(path, "submit", dependencies), @@ -112,6 +118,18 @@ void test("creation waits for loader refresh before navigation and notification" assert.deepEqual(env.events, ["create", "reload", ["navigate", "request-1"], ["success", "Request created."]]) }) +void test("a blocked submit shows every problem without creating or reloading", async () => { + const env = environment() + const errors = { title: "Give the request a title.", fileName: undefined, email: "“x” isn't an email address." } + const { submit, createAndSign } = await composer(env, { problems: true, errors }) + await submit() + assert.equal(createAndSign(), null) + assert.deepEqual(env.events, []) + assert.equal(env.state.attempted, true) + assert.deepEqual({ ...env.state.shown }, errors) + assert.equal(env.state.blockedSubmits, 2) +}) + void test("a failed create stays retryable without navigation or a success notification", async () => { const env = environment() const { submit } = await composer(env, { @@ -259,6 +277,171 @@ for (const refreshFails of [false, true]) { }) } +async function agentWindow(popup) { + const events = [] + const dependencies = { + Date, + popupRef: { current: popup }, + popupFeatures: () => "popup=yes", + window: { + open: (url, name) => { + events.push(["open", url, name]) + return { closed: false } + }, + }, + setWindowClosed: (closed) => events.push(["closed", closed]), + setAgentLost: (lost) => events.push(["lost", lost]), + } + const path = "src/features/esign/sign-with-cie.tsx" + dependencies.showWindow = await handler(path, "showWindow", dependencies) + dependencies.openWindowAgain = await handler(path, "openWindowAgain", dependencies) + return { events, ...dependencies } +} + +void test("showing the open agent window focuses it without reloading or duplicating it", async () => { + const popup = { closed: false, location: { href: "pin-form" }, focus: () => (popup.focused = true) } + const { events, showWindow } = await agentWindow(popup) + showWindow("sign-url") + assert.equal(popup.focused, true) + assert.equal(popup.location.href, "pin-form") + assert.deepEqual(events, [["closed", false]]) +}) + +void test("a closed agent window opens again under a new name", async () => { + const { events, popupRef, showWindow } = await agentWindow({ closed: true }) + showWindow("sign-url") + assert.equal(events[0][0], "open") + assert.equal(events[0][1], "sign-url") + assert.match(events[0][2], /^pn-esign-\d+$/) + assert.equal(popupRef.current.closed, false) +}) + +void test("opening the window again reloads the signing page in the open agent window", async () => { + const popup = { closed: false, location: { href: "error-page" }, focus: () => (popup.focused = true) } + const { events, openWindowAgain } = await agentWindow(popup) + openWindowAgain("sign-url") + assert.equal(popup.location.href, "sign-url") + assert.equal(popup.focused, true) + assert.deepEqual(events, [ + ["closed", false], + ["lost", false], + ]) +}) + +void test("the signer's own cancel closes the dialog once, without a reload", async () => { + const env = environment() + const popup = { close: () => env.events.push("popup closed") } + const dismiss = await handler("src/features/esign/sign-with-cie.tsx", "dismiss", { + ...env.dependencies, + finishing: { current: false }, + setPhase: env.setter("phase"), + popupRef: { current: popup }, + onStopped: (error) => env.events.push(["stopped", error]), + }) + dismiss() + dismiss() + assert.equal(env.state.phase, null) + assert.deepEqual(env.events, ["popup closed", ["stopped", null]]) +}) + +for (const refreshFails of [false, true]) { + void test(`closing an ended attempt shows it is busy until the refresh settles, failure=${refreshFails}`, async () => { + const env = environment() + const dependencies = { + ...env.dependencies, + finishing: { current: false }, + phase: { kind: "ended", reason: "expired", code: null, message: "The link expired." }, + states: new Map(), + setPhase: env.setter("phase"), + setClosing: env.setter("closing"), + popupRef: { current: { close: () => env.events.push("popup closed") } }, + cancelFn: async () => env.events.push("cancel"), + onStopped: (error) => env.events.push(["stopped", error]), + } + dependencies.reload = await handler("src/features/esign/sign-with-cie.tsx", "reload", dependencies) + const close = await handler("src/features/esign/sign-with-cie.tsx", "close", dependencies) + const pending = close() + assert.equal(env.state.closing, true) + assert.equal(env.state.phase, undefined) + assert.deepEqual(env.events, ["popup closed", "reload"]) + await close() + assert.deepEqual(env.events, ["popup closed", "reload"]) + if (refreshFails) env.reload.reject(new Error("Refresh failed")) + else env.reload.resolve() + await pending + assert.equal(env.state.phase, null) + // Still busy while the dialog fades out, so the button doesn't flash back to idle. + assert.equal(env.state.closing, true) + assert.deepEqual(env.events.at(-1), ["stopped", null]) + assert.ok(!env.events.includes("cancel")) + }) +} + +async function endingOf() { + const path = "src/features/esign/sign-with-cie.tsx" + const AGENT_ERRORS = await handler(path, "AGENT_ERRORS", {}) + return handler(path, "endingOf", { AGENT_ERRORS }) +} + +function jobStatus(jobId, status, errorCode = null, errorMessage = null) { + return { jobId, status, errorCode, errorMessage, seen: true } +} + +void test("an attempt ends quietly only when the signer cancelled every job", async () => { + const ending = await endingOf() + const jobs = [{ jobId: "a", requestId: "r", title: "Verbale" }] + assert.equal(ending([jobStatus("a", "cancelled", "pin_incorrect", "Wrong PIN.")], jobs, false), null) + const declined = ending( + [jobStatus("a", "cancelled", "request_declined", "Mario Rossi declined this request.")], + jobs, + false + ) + assert.equal(declined.reason, "declined") + assert.equal(declined.message, "Mario Rossi declined this request.") + const failed = ending([jobStatus("a", "failed", "pin_locked", "PIN blocked")], jobs, false) + assert.equal(failed.reason, "failed") + assert.equal(failed.code, "pin_locked") + assert.match(failed.message, /^The PIN is blocked\./) + // A code reserved for cancels and declines counts only on a cancelled job, as in the backend. + const reserved = ending([jobStatus("a", "failed", "request_cancelled", "Cancelled.")], jobs, false) + assert.equal(reserved.reason, "failed") +}) + +void test("an attempt the backend ended is worded for the dashboard, not the agent window", async () => { + const ending = await endingOf() + const jobs = [{ jobId: "a", requestId: "r", title: "Verbale" }] + for (const [code, message] of [ + ["document_changed", "Someone else signed in the meantime. Start again from the dashboard."], + ["job_done", "This request no longer needs your signature"], + ]) { + const failed = ending([jobStatus("a", "failed", code, message)], jobs, false) + assert.equal(failed.reason, "failed") + assert.notEqual(failed.message, message) + assert.doesNotMatch(failed.message, /dashboard/) + } +}) + +void test("an ended batch names the document, and an expired link points where to sign again", async () => { + const ending = await endingOf() + const jobs = [ + { jobId: "a", requestId: "r1", title: "Verbale" }, + { jobId: "b", requestId: "r2", title: "Contratto" }, + ] + const cancelled = ending( + [ + jobStatus("a", "cancelled"), + jobStatus("b", "cancelled", "request_cancelled", "Anna Bianchi cancelled this request."), + ], + jobs, + false + ) + assert.equal(cancelled.reason, "cancelled") + assert.equal(cancelled.message, "Contratto: Anna Bianchi cancelled this request.") + const statuses = [jobStatus("a", "expired"), jobStatus("b", "cancelled")] + assert.match(ending(statuses, jobs, false).message, /Start again/) + assert.match(ending(statuses, jobs, true).message, /^Close to open the request/) +}) + for (const outcome of ["delivered", "failed", "throws"]) { void test(`delivery retry waits for refresh before reporting ${outcome}`, async () => { const env = environment() @@ -286,3 +469,52 @@ for (const outcome of ["delivered", "failed", "throws"]) { ) }) } + +void test("background reloads run one at a time, only while active and the tab is visible", async () => { + const timers = new Map() + const reloads = [] + const logged = [] + let cleanup + const dependencies = { + useRouter: () => ({ + invalidate: () => { + const reload = deferred() + reloads.push(reload) + return reload.promise + }, + }), + useEffect: (effect) => { + cleanup = effect() + }, + window: { + setInterval: (tick, ms) => timers.set(timers.size + 1, { tick, ms }).size, + clearInterval: (id) => timers.delete(id), + }, + document: { visibilityState: "visible" }, + console: { error: (error) => logged.push(error.message) }, + } + const useReloadWhile = await handler("src/features/esign/use-reload-while.ts", "useReloadWhile", dependencies) + + useReloadWhile(false, 3000) + assert.equal(timers.size, 0) + + useReloadWhile(true, 3000) + const [timer] = timers.values() + assert.equal(timer.ms, 3000) + timer.tick() + timer.tick() + assert.equal(reloads.length, 1) + reloads[0].reject(new Error("Reload failed")) + await setImmediate() + assert.deepEqual(logged, ["Reload failed"]) + + dependencies.document.visibilityState = "hidden" + timer.tick() + assert.equal(reloads.length, 1) + dependencies.document.visibilityState = "visible" + timer.tick() + assert.equal(reloads.length, 2) + + cleanup() + assert.equal(timers.size, 0) +}) diff --git a/tests/esign-places.test.mjs b/tests/esign-places.test.mjs new file mode 100644 index 0000000..4041f30 --- /dev/null +++ b/tests/esign-places.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { placeAt } from "../src/features/esign/places.ts" + +const A4 = { width: 595, height: 842 } +const A4_LANDSCAPE = { width: 842, height: 595 } + +void test("a moved place is centred on the click, on the clicked page, with its kind", () => { + const mark = { kind: "mark", page: 1, x: 0.6, y: 0.8, width: 0.3, height: 0.1 } + const moved = placeAt(mark, [A4, A4], 0, 0.5, 0.4) + assert.equal(moved.kind, "mark") + assert.equal(moved.page, 0) + assert.ok(Math.abs(moved.width - 0.3) < 1e-9 && Math.abs(moved.height - 0.1) < 1e-9) + assert.ok(Math.abs(moved.x - 0.35) < 1e-9 && Math.abs(moved.y - 0.35) < 1e-9) +}) + +void test("a place moved to a page of another size keeps its size in points", () => { + const place = { kind: "signature", page: 0, x: 0.1, y: 0.1, width: 0.3, height: 0.08 } + const moved = placeAt(place, [A4, A4_LANDSCAPE], 1, 0.5, 0.5) + assert.ok(Math.abs(moved.width * A4_LANDSCAPE.width - 0.3 * A4.width) < 1e-9) + assert.ok(Math.abs(moved.height * A4_LANDSCAPE.height - 0.08 * A4.height) < 1e-9) +}) + +void test("a click near the edge keeps the whole place on the page", () => { + const place = { kind: "signature", page: 0, x: 0.1, y: 0.1, width: 0.3, height: 0.08 } + const corner = placeAt(place, [A4], 0, 0.99, 0.01) + assert.ok(Math.abs(corner.x - 0.7) < 1e-9) + assert.equal(corner.y, 0) +})