diff --git a/docs/design.md b/docs/design.md index d877357..b7c6a3a 100644 --- a/docs/design.md +++ b/docs/design.md @@ -138,6 +138,7 @@ does not move when data arrives. | Browser | `wide` | Categories root and nodes, tag page | | Tree | `tree` | Labels, Freshman guide | | Record | `record` | Telegram user | +| Workspace | `record` | New signature request (`PageContent fill`) | | Settings | `settings` | Account | | Overview | `overview` | Overview | @@ -172,6 +173,14 @@ in session storage; search force-expands. then `SectionCard`s. The Telegram user puts recent messages and grants side by side from 1000px (1.55 : 1), with the audit log full width below; no summary tiles. +**Workspace.** A page that fills the window instead of scrolling. `PageContent fill` makes its bottom padding the side +gutter (24px), and `useFillHeight` sizes the workspace from its top down to it, at least 480px. From 900px of container +width a document pane sits beside a 380px column (400px from 1040px); narrower, they stack and the page scrolls. The +column is as tall as its content, up to the pane's height, with its primary action in a footer right under the last +row: the one page whose primary isn't in the page header, because the primary belongs under the last thing checked +before it. A longer column scrolls inside under the pinned footer. Stacked, the footer is sticky at the bottom of the +window and every control in the column has an 80px bottom scroll margin, so what takes the focus stops above it. + **Settings.** `SectionCard`s in one column, each with a title and an action slot. The destructive card goes last, further apart, without a red border. @@ -229,7 +238,9 @@ Headings `text-wrap: balance`, body `pretty`. Truncate with an ellipsis plus a ` are 16px on coarse pointers (no iOS zoom). **Focus.** One global ring (`:focus-visible`, `--pn-focus`) in `styles.css`; inputs swap it for a border plus soft ring; -invalid fields show the danger border. Opt out only with `data-focus-ring="none"` when the element draws its own. +invalid fields show the danger border. Opt out only with `data-focus-ring="none"` when the element draws its own. When +a blocked submit moves the focus to a control that isn't a field (a list row), it sets `data-problem-focus` until the +control blurs, which rings it on `:focus`: a click or ⌘↵ doesn't count as keyboard focus. ## 5. Components @@ -292,7 +303,9 @@ Labels are verb + object in sentence case ("Add group", "Publish 12 groups"). **Dialogs.** Forms use `FormDialog` (`md` one column, `lg` two columns or steppers); confirmations use `ConfirmDialog` (460px `AlertDialog`, no close button, no outside-click close, focus on Cancel). 14px panel radius; header: 18px title and 14px description, no eyebrow; confirmations lead with a 40px icon tile (danger-tinted for destructive ones, a -warning sign unless the action has its own icon). Footer right-aligned, buttons side by side at every width: `Cancel` +warning sign unless the action has its own icon). A form that confirms something destructive (declining a signature +request, with its optional reason) is a `FormDialog` with `tone="danger"`: the same tile before its title and a solid +danger submit. Footer right-aligned, buttons side by side at every width: `Cancel` (`outline`) then the primary (solid danger for destructive confirmations). Below 760px every dialog is a bottom sheet: full width, rounded top, sliding up above the navigation, with a scrolling body and a visible footer. Microsoft 365 member pickers use the same 18px title, then radio, avatar and name with a monospace email below; names and emails @@ -303,7 +316,9 @@ toast fires and the dialog closes: `ConfirmDialog` closes itself, `FormDialog` d after the mutation, and keep the dialog's record in state after closing so its content survives the exit animation. A dirty form intercepts every close with the discard confirmation (`FormDialog` does it when the dialog passes `dirty`). Key a dialog body with `useOpenGeneration` so every opening starts clean. Autofocus the first field on fine pointers -only. +only. The one exception to the success toast is a page that states the outcome in place: on a signature request, +signing, Decline, Cancel request and Retry delivery change the action card's sentence, so they show no toast and return +focus to that sentence (`finalFocus`, on `ConfirmDialog`, `FormDialog` and the signing dialog). **Popover or dialog.** Popover only when all hold: not destructive, at most one input or a picker, at most 320px wide, and closing without acting is harmless. Otherwise a dialog. Every delete is a `ConfirmDialog`. Group label filters use compact selected chips and an Add picker, one @@ -330,7 +345,10 @@ the language name over a field-like box that holds the `bare` input. Single-line "(optional)". Hints 12px under the field. Validate on submit, then on blur for fields that have errored; never per keystroke. Errors: danger border, 12px danger text with an icon, `aria-invalid` + `aria-describedby`. Read-only values are text in a `KeyValueList`, never disabled inputs. Length counters (`FieldCounter`) appear only in the last 20% of the -limit. +limit. A quiet field (`quietField`, the composer's title and a signer's role) has no box at rest: it reads as text, +shows the hover line with a text cursor, and gets the normal border and ring when focused or invalid, with an +`aria-label` in place of the visible label. Use it only for a value edited in place inside a working surface, where a +box around every value would be clutter; forms keep boxed fields. **Alerts and toasts.** `InlineAlert` for what persists or needs action: load errors, dialog submit errors, partial saves. It defaults to `tone="danger"` and `role="alert"`, so pass another tone for notices that aren't errors. Toasts @@ -339,9 +357,12 @@ three, never infinite, no "Undo". **Accessibility.** Every input has a label; focus is never removed; dialogs trap focus and return it to the trigger; when a row disappears (optimistic removal, confirmed delete) `useFocusAfterRemoval` keeps focus in the list. Color is -never the only signal. Hit areas are at least 36px, including mobile pagination, segment and chart-range options, copyable IDs/tags, PDF -links, label chips that remove a selection and tree expanders. Enlarge the clickable area independently of the icon. -DOM order matches visual order. +never the only signal: a link inside a sentence is underlined at rest (`inlineLink`). Hit areas are at least 36px, +including mobile pagination, segment and chart-range options, copyable IDs/tags, PDF links, label chips that remove a +selection and tree expanders. Enlarge the clickable area independently of the icon. +DOM order matches visual order, with one exception: in their two-column layout the signature request and batch pages +keep the action card first in the DOM though it sits right of the viewer, so its primary is the first stop after the +page bar, as it is in the single column. ## 6. Motion @@ -362,11 +383,14 @@ Shared classes live in `primitives/motion.ts` (`buttonMotion`, `floatingMotion`, | Copy feedback (invite link) | icon swap with a short bounce-free spring | | Chart tooltip and hover guide | none: they follow the pointer or keyboard focus | | Skeleton | opacity pulse 1.4s; the only loop besides spinners | +| Empty signature spot filled (composer) | the same box changes border, fill and text colour, 150ms ease-out; the dashed border snaps to solid | +| Signer order number appearing (composer) | opacity 0→1, 150ms ease-out, in the column the rows already keep for it | | Theme toggle | light-bulb reveal: a View Transition `clip-path` circle from the toggle (700ms to light, 520ms to dark), reversible mid-way; element transitions are off while it runs (`data-theme-switching`). Go through `useTheme()` | +| Signature box after signing | opacity 1→0, 200ms `--pn-ease-move`, once the signing dialog's exit ends; the stamp is already drawn underneath, hidden by the leaving box's opaque fill. A new PDF version replaces each page without a fade | | Navigation, sort, filter, paging, inline-edit swap, command palette, active indicators | none | Reduced motion: the global rule in `styles.css` zeroes CSS durations; `motion/react` code checks `useReducedMotion()`; -the theme reveal and the skeleton pulse switch off. +the theme reveal and the skeleton pulse switch off; the signed box is removed at once. ## 7. Copy @@ -377,7 +401,7 @@ the theme reveal and the skeleton pulse switch off. (danger for destructive, primary for publish). Discard prompts use `Keep editing` / `Discard`. - **Toasts:** success "{Object} {past participle}." ("Group deleted.", "Grant created for {name}."); error "Couldn't {verb} {object}." plus at most one next step; partial "{What happened}, but {what didn't}." Server messages that carry - information are shown verbatim. + information are shown verbatim. No success toast where the page already says the outcome in place (§5 Dialogs). - **Empty states:** title "No {things} yet" (nothing exists), "No {things} match" (filtered), or a scoped title when the view is a subset ("No open reports", "Nothing needs attention"); one sentence on what will appear and how, or how to widen the filter. Action: the page's create action as `outline` (the header primary stays), or a `ghost` "Clear @@ -439,29 +463,115 @@ Rules that the templates above don't imply. oldest-first, Closed newest-first. Closed columns are Reference, Status, Issue, Details, Date; mobile status and issue sit at the top right of the reference, and absent details render nothing. Report links are monospace; missing groups have 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. +- **Signatures**: the request and batch pages put the PDF viewer (pdf.js, so every browser renders the same) left of + a 360px column once the page content is 896px wide (a container query, so the viewer keeps about 520px; windows + from about 1270px with the panel). There the page doesn't scroll: the viewer fills the window, the action card sits + at the top of the column and the cards under it scroll by themselves in a "Request details" region, a tab stop only + while it has something to scroll; narrower, the page stacks card, viewer, rest and scrolls. + The action card (`EsignActionCard`; every state comes from `requestCardState` in `request-card.ts`, unit-tested) holds + the page's `h1` and its primary, so the page bar has neither the title nor "Sign with CIE": the title, a status line + (badge, then "Segretario · p. 1" or "1 of 2 signed", never "0 of 1", here or in the lists), one sentence on where + things stand ("Check the + blue box, then sign with CIE.", "Waiting for Mario Rossi.", "You signed at 10:25. Mario Rossi signs next; we emailed + them."; a completed request's "Saving to…" lines become "Saved to…" in place), at most one full-width primary (Sign + with CIE, Next to sign, Send again) and a footer row: one ghost on the left ("Sign N together", N counting this + document and the others the batch opens with, at most ten, as To sign's button does), the one red outline + button on the right (Decline for a pending signer, Cancel request for its creator, Delete request once declined or + cancelled). A completed request keeps Delete at the end of the column, where red doesn't read as an alarm. + The sentence is a focus target without a ring. Order numbers show, on the boxes and the Signers rows, only where the + order holds (ordered, two or more signers); otherwise a row shows the signer's initials. + Signers is left out when the viewer signs alone; others whose turn is open read "Their turn", and whether they were + emailed is said once: in the card when one person's turn is open, otherwise on their row. Delivery shows only while + the request is in progress, and Activity leaves out what the card and the rows already say. A PDF signed elsewhere + can't be uploaded: PoliNetwork eSign is the only way to sign. + Phones and tablets review only: every way to sign gives way to "Signing needs your computer with the card reader and + PoliNetwork eSign. This document will be waiting in To sign.", the box doesn't drag and no signature starts. The + server render goes by the User-Agent; a Mac's may be an iPad's, so there both variants render until hydration, behind + `(hover: none) and (pointer: coarse)`. + The signer's box is blue and draggable, other pending signers' boxes are dashed. Until their first signature, "Move + box" in the viewer's toolbar (a menu of "Signature · p. 2" with several places; only the icon below a 420px toolbar) + works like the composer's: the next click on any page puts the box there, the toolbar says so in place of the page + counter, and Esc or Cancel stops it and returns focus to Move box. The viewer opens on the spot that matters (the + viewer's box, the spot they just signed, else the box of whoever's turn it is), jumps 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. 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). + PoliNetwork eSign's own page in a popup: the PIN is typed there, never in the dashboard. The dialog behind it + (`useSignWithCie`, rules in `signing-state.ts`) has one 40px tile, one title, at most one line or a batch's list, and + a footer of "Cancel signature" ("Stop here" once part of a batch is signed) or "Close" and one primary; states swap + without motion inside one live region; a quiet action under the words (a ghost button with its icon, in the text + colour) is the other way on, never part of the description. It waits for the agent's `ready` (or an older agent + fetching its job): without it after 2.5 s (4 s in a browser that has never run it) it closes the window and says + PoliNetwork eSign isn't running, offering "Start PoliNetwork eSign" to a browser that has run it and "Install + PoliNetwork eSign" with "It's already installed: start it" otherwise; a Start that got no answer says "PoliNetwork + eSign didn't start" with Try again. An agent too old to sign gets "Update PoliNetwork eSign" with the download for + the computer's system (on Linux, the package or the download page). Errors the agent window can retry stay there; the dialog shows + only what ends the attempt. Once everything is signed the page updates behind the dialog: the card turns into the + outcome, the viewer stays on the signed spot and draws the new version, then the agent window and the dialog close + together (1 to 4 s after the signature) and the blue box dissolves into the stamp. A batch that signed only some + documents stays open until Close. New request and the batch hand off to another page, so their dialog lives above the + routes (`EsignSigningHost`): the batch opens To sign under it and toasts once it closes; New request opens the new + request's page under it, with the request and its signed PDF fetched first, and closes once that page has drawn the + signed spot, where the box then dissolves as above, with no toast and focus on the card's sentence. + The signing dialog is 520px wide, wider than a confirmation, so every title holds one line. + 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 up to ten documents in the page bar's toolbar row, + led by the one it came from, already checked and with the boxes moved there. Each is checked by opening it (a check + replaces its number) and can be left out; the card's primary stays "Next document" until all are checked, then + becomes "Sign N documents" (one agent window, one PIN). The icon beside the title opens the request in a new tab, + because the review lives only in this page. 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. + row is the viewer's turn) and no way to start a request: "New request" lives in Requests. `?status=` keeps the + status segment across Back. Phones and tablets can't sign (`useCanSignHere`): on them an info note takes the place of + "Sign N together". Setup preselects the computer's system. Its install step opens with the primary, which downloads + that system's installer at once ("Download for macOS", release page for Linux), and a quiet "Other systems and + versions" link on the same row; what to do with the file follows. On a phone an info alert leads: open the page on + the computer, or choose its system to read the steps; no system is preselected and the download is an outline + button. A Mac's User-Agent may be an iPad's, so the server renders both until hydration, as the request page does. 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 - not sign the documents of the chosen drives are left out of the list, with a line saying why. The signer picker - lists people only once a search is typed, each with the email under the name; the viewer carries a blue "You" chip - here and in signer lists. + not sign the documents of the chosen drives are left out of the list, which says why when that leaves nobody. The + signer picker lists people only once a search is typed, each with the email under the name; the viewer is the + exception, listed as soon as the list opens, and carries a blue "You" chip here and in signer lists. + New request is a workspace (§3). Before there is a document it offers only ways to choose one: the drive browser + where the viewer will be and a dropzone where the column will be. Then the PDF fills the left ("Change PDF" in its + toolbar opens the same choice in a dialog) and the column holds the title as a quiet field, the signers and the + delivery, with no headings or hints. Signers are always open: one 40px row each (the order number while ordered + with two or more people, in a column kept from the second row on so the roles never move, the role, the name, and a + "More actions" menu for moving, adding a place and removing); + place buttons show only with several places. Hovering a row rings its boxes and hovering a box tints its row. The + signature labels in the PDF's text ("Il Segretario", "Il Presidente") become spots: a row with the label as its role + and a "Choose signer…" picker, and a dashed box on the label's signature line, under the label or above it, else + beside it on its line, that covers no text, no other label and no signature already on the page and never sits past + another label; labels stacked too close for a box between them get their boxes beside them, lined up; clicking the box opens the picker, and picking + a person turns it into their box. Labels match whole and keep their qualifiers, also when a narrow cell wraps them + ("Il Vice" over "Presidente"): "Vicepresidente" and "Vice Presidente" are never the Presidente spot. A label with no + room to sign, a field filled in on its line ("Presidente: Mario Rossi"), a sentence ending on a line of its own and + a label already signed are not spots. The focus starts in the first spot and moves on to the next one. "Add signer" + under the spots, and the free picker when there are none, add someone without a spot; a free row's role that names + an empty spot moves the person into it, and one that names a label elsewhere moves their box under it, only where it + covers nothing. Boxes of people without a spot share the foot of the last page in signing order, laid out again as + people are added. The picker highlights its first person, so Enter picks the viewer before anything is typed. "Me" + at the end of the first empty spot ("Add me as Segretario"), else of the free picker, adds the viewer in one click. + An empty spot blocks sending until it is filled or removed. Delivery is two summary rows in the request page's words + ("Will be saved to OneDrive: Verbali/… - firmato.pdf", always in full, and "No email" or the recipients): each opens + its fields under it, and opens by itself when its check fails. After an upload the save row starts open on the + folder browser at the drive root ("Choose where to save the signed PDF", with "Save here" demoted to outline and + "Don't save to OneDrive"). A blocked submit shows each problem at its control, never in a banner, and focuses the + first one (the folder browser's first row for a missing folder). The primary reads "Sign with CIE" when the viewer is + the only signer and "Sign and send" when the viewer signs first, creating the request and opening PoliNetwork eSign + in one click; otherwise, and on phones and tablets, "Send request". It carries a `Kbd` hint: ⌘/Ctrl+Enter runs it + from anywhere on the page, after an open field has finished what its blur does, and plain Enter never submits. + `?from=` starts New request from an earlier request in any status ("Send again"): title, signers in + order with their roles, the order, folder, file name and email come filled in, and leaving asks to discard only after + an edit. The PDF is the same OneDrive file read again, so a corrected file saved over the original is the one sent; + an upload, or a file OneDrive no longer gives, is the request's first version. While it opens the viewer shows blank + pages with "Opening the PDF…", the signers group keeps its final height and the primary waits. Under the title a + declined request says who declined and why, then "Same PDF as before." when nothing changed ("Check again" re-reads + a OneDrive file); a cancelled one says nothing. A signer who may no longer sign reads "Can't sign anymore." and + blocks sending until removed. A request that can't be read opens the empty composer with "Couldn't load that + request.". - **Account**: permissions are grouped by area (Dashboard, Telegram, WhatsApp, group labels, Microsoft 365, Web) with the service icon. diff --git a/docs/esign/TRACKER.md b/docs/esign/TRACKER.md index 396a9c9..d405865 100644 --- a/docs/esign/TRACKER.md +++ b/docs/esign/TRACKER.md @@ -113,6 +113,8 @@ Legend: ☐ todo · ◐ in progress · ☑ done - ☑ Review round 1 (Sol 6.1): 15 findings (4 high), all fixed with regression tests; Postgres smoke script (`backend/scripts/esign-store-smoke.ts`) caught a jsonb double-encoding issue in the new queries. - ☑ Fallback: upload a PDF signed with the official app (verified as current version + one CIE signature). + Removed from the dashboard on 2026-10-21: CIE ID rewrites a PDF that has no signature yet, so it never worked for + the first signer. The backend's `esign.requests.uploadSigned` is no longer called and can be removed. - ☑ Smart placement (box under "Il Presidente"/role label), navigation badge for documents to sign. - ☑ Review round 2 (Sol 6.1, Personal account; its safety filter cut the final report, partial notes in `/tmp/esign-review-r2b/`): round-1 fixes confirmed; new findings fixed — ByteRange gap must be exactly /Contents, @@ -127,9 +129,27 @@ Legend: ☐ todo · ◐ in progress · ☑ done sign its documents; default unchanged (auth#62 roles + catalog in the snapshot, auth-kit#2, backend, admin). - ☑ Signer picker lists every registered IdP user with `esign:sign` (filtered by the drives' sign rules), not only people who opened Signatures; their profile is created with the request (auth#63, backend#79, admin#113). -- ☑ Sign it yourself: when the creator signs first (or is the only signer), the composer's "Create and sign" / "Sign - with CIE" creates the request and opens the agent in the same click; To sign has "Sign a document", which starts - with the creator as first signer (admin only). +- ☑ Sign it yourself, inside New request: "Me" adds the creator in one click, and when the creator signs first (or is + the only signer) the primary, "Sign and send" / "Sign with CIE", creates the request and opens the agent in the same + click ("Send request" on a phone or tablet). The backend doesn't email the creator about a turn that starts as the + request is created. To sign's "Sign a document" and the composer's `?self=true` are gone (admin only). +- ☑ Signature spots in New request: the labels in the PDF's text become rows to fill, each with a box near its label + in clear space (on the signature line when there is one, else under the label or above it), covering no text, no + label and no signature already on the page. Labels match whole and keep qualifiers apart, also wrapped over two + lines ("Vice Presidente" is never "Presidente"); several labels on a line, rotated pages, the first 40 and last 10 + pages, and labels at the page bottom are handled. Field captions ("Presidente: Mario Rossi"), sentences ending on a + line of their own, labels without room to sign and labels already signed are not spots. Contracts without labels + keep the free picker, whose boxes share the foot of the last page in signing order (admin only, `places.ts` + tested). +- ☑ Send again: New request takes `?from=` (any status, so "Duplicate" is one link) and fills in the + title, the signers in order with their roles, the order, the folder, the file name and the email. The PDF is the same + OneDrive item read again, so a corrected file saved over the original is picked up, and its spots are found again + (people keep the spot their role names, the others their places). An uploaded PDF, or a OneDrive file that can no + longer be opened (the composer says so), is the request's first version, before anyone signed, fetched and uploaded + again as a new document. A declined request whose PDF has the same sha256 as that version says "Same PDF as before.", + with "Check again" for a OneDrive file (admin only). +- ☐ After a backend release, optional: reuse a version as a new request's document, so Send again of an uploaded PDF + doesn't take the bytes through the browser and opening it leaves no staged document behind. - ☐ 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). @@ -137,6 +157,8 @@ Legend: ☐ todo · ◐ in progress · ☑ done 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". +- ☑ Setup page: its inline links (the ArchWiki guide, OpenCIE) are underlined at rest with `inlineLink`, like the + request and batch pages. ## Manual steps for the maintainer (cannot be automated here) @@ -157,6 +179,22 @@ Legend: ☐ todo · ◐ in progress · ☑ done - 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). +- ☐ Before deploying Setup's "Download for macOS" / "Download for Windows" and the signing dialog's "Update + PoliNetwork eSign" state (agents too old to sign): make `releases/latest` carry `PoliNetwork-eSign-macos.dmg`, + `PoliNetwork-eSign-macos.zip` and `PoliNetwork-eSign-windows-amd64.zip`. Either publish the agent release built from + `feat/sign-page-decisions` (esign "publish the macOS and Windows downloads under fixed names"), tagged `vX.Y.Z` with + no suffix so it is not a pre-release, or, to deploy the dashboard first, upload copies of v0.3.0's + `PoliNetwork-eSign-v0.3.0-macos.dmg`, `-macos.zip` and `-windows-amd64.zip` under those names to the v0.3.0 release + and regenerate its SHA256SUMS. Then check that each name answers 200: + `curl -sL -o /dev/null -w '%{http_code}\n' https://github.com/PoliNetworkOrg/esign/releases/latest/download/`. + Until then they answer 404, and these links, which open in the same tab (a real download leaves the page in place), + replace the dashboard, and the signing dialog with it, with GitHub's 404 page. +- ☐ Deploy order of the decisions round (admin `feat/esign-ux-decisions`, backend `feat/esign-ux-decisions`, agent + `feat/sign-page-decisions`): release and deploy the backend before the dashboard. The dashboard's decline dialog + says "{creator} gets an email with your reason", which only the new backend sends ("Request update" emails); the + version gate works in any order (the backend lets an unknown User-Agent through, the old dashboard doesn't forward + it). The agent release (above) goes out before or with the dashboard; its `ready` and fatal messages need no + dashboard change to be safe, and older agents keep working through `seen`. ## Local end-to-end @@ -171,11 +209,51 @@ Open `http://localhost:10431/dashboard/esign/requests` (use `localhost`, not `12 ## Log +- 2026-10-22: decisions round integrated (admin `feat/esign-ux-decisions`, backend `feat/esign-ux-decisions`, agent + `feat/sign-page-decisions`; not pushed). The 17 product decisions of the UX pass are decided and built: + - Admin: the signing chain below, plus New request rebuilt as a viewer and one column with the PDF's signature spots + as rows to fill (boxes beside a stack of labels too close for one between them), signing your own PDF inside New + request (`?self` and To sign's "Sign a document" gone), Send again (`?from=`), Setup per system with a direct + download and CIE ID pairing as the check, phones and tablets review only (no flash for an iPad, whose User-Agent is + a Mac's). The New request and batch hand-offs keep their dialog above the routes (`EsignSigningHost`), so the page + they land on loads under it: New request closes it once the new request's page has drawn the signed spot. The + proxy forwards the agent's User-Agent, also the pre-rename `PoliNetwork-Sign-Agent/…`. + - Backend: the version gate (426 `agent_outdated` when the job loads, below 0.2.0, the job failed and seen), + "Request update" emails to the creator (declined, completed by someone else, delivery failed, also to who can + retry), the signed document emailed to externals in Italian with Reply-To set to the requester. + - Agent: fixed asset names for the macOS and Windows downloads, CIE ID named everywhere, the version in the pages' + footer, `ready` and fatal messages to the dashboard, the update screen instead of the PIN form, the card before the + PIN (held while no card is detected, never when presence is unknown), the wrong-PIN count with a warning before the + last attempt and "show PIN", no text on where the PIN comes from. + - Kept on purpose, against the option's wording: the action card's batch link reads "Sign N together", N counting + this document and the others the batch opens with (at most ten, as To sign's button), because that is what one PIN + signs; "Sign the other N together" would undercount it. + - Release order: see the maintainer steps (backend before the dashboard; agent release or renamed v0.3.0 copies + before Setup's downloads). + +- 2026-10-21: decisions round, request and batch pages (admin `feat/esign-dec-signing`; not pushed): one action card + holds the title, the status, one sentence and the request's actions, and on desktop the right column stays put while + the cards under it scroll; "Move box" moved into the viewer's toolbar; phones and tablets only review; declined and + cancelled requests offer "Send again" (`/dashboard/esign/new?from=`, which the composer branch fills in); the upload + fallback is gone; the agent proxy forwards PoliNetwork eSign's User-Agent, so the backend can refuse versions too old + to sign. The signing dialog listens for the agent's `ready` and fatal errors (older agents through the job's `seen`), + says within 2.5 s when PoliNetwork eSign isn't running (Start for a browser that has run it, Install otherwise), asks + for an update with a direct download when the agent is too old, and hands off after signing: the page updates + behind it, then the agent window and the dialog close and the box dissolves into the stamp. The `ready` and fatal + messages need the next agent release; until then the dashboard follows `seen`. Copy kept on purpose: a missing + CIE ID module reads "CIE ID isn't set up on this computer" / "Follow the setup guide, then sign again.", as the + agent window says it, not "isn't installed" (CIE ID can be installed with its module out of reach, e.g. without + Rosetta); the lock line is one line ("Mario Rossi is signing right now."), so the card keeps its height; the To sign + and requests lists keep "Requested by {name}", the only place a row says who asks (removed from the request and + batch pages only). + - 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). + impact confirmation before saving a sign rule. The open product decisions it listed + (`~/dev/polinetwork/esign-ux-decisions-2026-10-21.html`, outside the repo) were decided on 2026-10-21 and built in + the decisions round above. - 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 8d67c74..fba0570 100644 --- a/docs/esign/contracts.md +++ b/docs/esign/contracts.md @@ -32,24 +32,61 @@ Dashboard Admin BFF Backend Listens on `127.0.0.1:47321` only. Every request: `Host` must be `127.0.0.1:47321` or `localhost:47321`. -| Route | Caller | Notes | -| ----------------- | -------------------- | ------------------------------------------------------------------------------------ | -| `GET /` | anyone | "Agent is running" page | -| `GET /sign` | popup navigation | Query `ticket` repeated 1–10 times (43 chars base64url each), `origin` (allowlisted) | -| `GET /api/job` | the agent's own page | Same-origin only (`Origin`/`Sec-Fetch-Site` checks). Job + card status | -| `POST /api/sign` | the agent's own page | `{tickets, origin, pin}`: one card session signs every job; a result per ticket | -| `GET /api/status` | the agent's own page | Providers, readers, card presence | +| Route | Caller | Notes | +| ------------------ | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET /` | anyone | Status page: running, and ready to sign once reader, card and software check out | +| `GET /sign` | popup navigation | Query `ticket` repeated 1–10 times (43 chars base64url each), `origin` (allowlisted) | +| `POST /api/job` | the agent's own page | `{tickets, origin}`. Same-origin only (`Origin`/`Sec-Fetch-Site` checks). The jobs, the remembered PIN, wrong attempts in a row | +| `POST /api/sign` | the agent's own page | `{tickets, origin, pin, remember?}`: one card session signs every job, a result per ticket; an empty `pin` uses the PIN this browser remembered | +| `POST /api/cancel` | the agent's own page | `{tickets, origin}`: the signer cancelled; each job is reported to the backend as `cancelled` | +| `POST /api/forget` | the agent's own page | Forgets the remembered PIN | +| `GET /api/status` | the agent's own page | Providers, readers, card presence, and `card`: `present`, `absent` or `unknown` (the PIN and Sign are held only while `absent`) | 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 +The signing page tells its opener what happened with `postMessage({type: "pn-esign", status, detail?, …}, origin)`, +to the dashboard's origin only (agents up to v0.1.x post type `pn-sign-agent`): + +| `status` | Other fields | When | +| ----------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ready` | `detail`: the agent's version | First thing the page's script does, before any request. From the release after v0.3.0. | +| `error` | `detail`: code, `message`: its words, `fatal: true` | The page can't go on (job load failed, backend unreachable, link expired, `agent_outdated`, `agent_unreachable` when its own job call can't reach the agent) and shows the error instead of the PIN form. Same release. | +| `error` | `detail`: code | A signature failed and the page went back to the PIN form; `agent_unreachable` when the page can no longer reach the agent behind it. | +| `completed` | | Every document of the window is signed. | +| `cancelled` | | The signer cancelled in the agent window. | + +The dashboard polls `esign.jobs.statuses` every 1.2 s and at once on every message. A job's terminal status from the +poll wins over a message. Messages the dashboard acts on by themselves: + +- **`ready`.** The agent is running: the dialog asks for the PIN and never closes the window by itself from then on, + except to hand off after success, when someone else cancels or declines the request, or on the signer's cancel. + The browser remembers it in `localStorage["pn-esign-agent"] = {"version": "", "at": ISO}`. +- **Older agents** never say `ready`: the job's `seen` (the agent fetched it) stands in for it, and the flag is stored + with `"version": null`. +- **No answer.** Without `ready` or `seen` 2.5 s after the window is sent to the agent, the dashboard polls once more, + then closes the window and says PoliNetwork eSign isn't running: "Start PoliNetwork eSign" + (`polinetwork-esign://open`, then the window opens again) when the flag exists, otherwise "Install PoliNetwork + eSign" (the setup page) with "It's already installed: start it". The wait is 6 s when the flag's version is null, and + 4 s when there is no flag: that browser may hold an old agent, which shows only once the backend has served its job + (`seen` is set after the agent's job request went agent → dashboard → backend). The trade-off: a running old agent + whose job takes longer than that to load is still closed, as "isn't running"; Start or "It's already installed" + then stores the flag, and the next attempts wait 6 s. A window closed before any answer means the same, at once. + A dashboard origin the agent doesn't sign for gets no message at all, not even `ready`, and its job is never + fetched, so it reads as "isn't running" too: the agent logs `refused dashboard origin`, and the fix is the agent's + `allowedOrigins` setting (`esign doctor` shows it). +- **Fatal errors.** `agent_outdated` shows the update (a direct download of the newest version, the release page on + Linux), `backend_unreachable` "can't connect" and `job_expired` the timed-out state at once; any other code waits + for the poll the message starts, in case the job ended with words of its own, then shows the message. The window + stays open: it shows the same error with its own buttons. A job that failed with `agent_outdated` closes the window + only when the agent neither said `ready` nor posted a fatal message (an agent too old to post either), whose page + shows only plain text: the backend fails the job before the agent's page learns why, so a poll can see the failure + first. +- **`agent_unreachable`**, fatal or not, after the agent answered: it stopped, and only the page can tell; the dialog + offers "Start PoliNetwork eSign". Older agents (v0.3.0 and before) don't post it. + +The dashboard notices a closed agent window by reading `popup.closed` on every poll, and acts once two polls in a row +read it closed: it keeps waiting while a job is `prepared` (the card is signing), shows which documents were signed when +some were, and otherwise cancels the open jobs and closes the dialog without a message. 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. @@ -67,23 +104,33 @@ Providers (tried in order, first with a card present; never falls back once a PI Agent → backend (through the dashboard origin, `{origin}/api/esign/agent/…`, proxied by the BFF to the backend's `/esign/agent/…`). Authentication is the ticket in the path; no cookies, no bearer tokens. -| Request | Body | Response | -| ---------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET /api/esign/agent/jobs/{ticket}` | | `{job: {id, status, expiresAt, document: {title, fileName, pages}, signer: {name, role}, placement: {page}, requestedBy, signatures, marks, pages}}` | -| `POST /api/esign/agent/jobs/{ticket}/prepare` | `{certificate: b64 DER, chain?: b64[], protocol: 2}` | `{toBeSigned: b64 SHA-256, algorithm: "RSASSA-PKCS1-v1_5-SHA256", step, steps}`; without `protocol: 2`, 426 `agent_outdated` | -| `POST /api/esign/agent/jobs/{ticket}/complete` | `{signature: b64}` | `{status: "next" \| "completed", request: {status, remaining}}`: on `next`, prepare the job's next place with the same session | -| `POST /api/esign/agent/jobs/{ticket}/fail` | `{code, message?}` | `{status}` — records cancellations / card errors for the dashboard | +The agent sends `User-Agent: PoliNetwork-eSign/` (`PoliNetwork-Sign-Agent/` before the rename, up to +v0.1.x). The BFF proxy forwards a User-Agent only when it matches +`^PoliNetwork-(?:eSign|Sign-Agent)/[0-9A-Za-z.+-]{1,32}$` (`agentUserAgent` in `src/features/esign/agent.ts`) and +drops any other. The backend refuses versions below `MIN_AGENT_VERSION` (0.2.0, the first with protocol 2; backend +`src/esign/agent-version.ts`): loading the job (and preparing it) answers 426 `agent_outdated`, and the job ends +`failed` with that code and `seen`, so the dashboard shows the update instead of waiting. A missing or foreign +User-Agent, `dev` and source builds (`v0.3.0-12-gabc`) and anything unparsable pass, so the dashboard, the backend and +the agent can be deployed in any order. Raising the minimum makes every older agent update before its PIN. + +| Request | Body | Response | +| ---------------------------------------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET /api/esign/agent/jobs/{ticket}` | | `{job: {id, status, expiresAt, document: {title, fileName, pages}, signer: {name, role}, placement: {page}, requestedBy, signatures, marks, pages}}`; 426 `agent_outdated` below the minimum version (the job fails) | +| `POST /api/esign/agent/jobs/{ticket}/prepare` | `{certificate: b64 DER, chain?: b64[], protocol: 2}` | `{toBeSigned: b64 SHA-256, algorithm: "RSASSA-PKCS1-v1_5-SHA256", step, steps}`; 426 `agent_outdated` below the minimum version or without `protocol: 2` | +| `POST /api/esign/agent/jobs/{ticket}/complete` | `{signature: b64}` | `{status: "next" \| "completed", request: {status, remaining}}`: on `next`, prepare the job's next place with the same session | +| `POST /api/esign/agent/jobs/{ticket}/fail` | `{code, message?}` | `{status}` — records cancellations / card errors for the dashboard | Errors: `{error: {code, message}}` with codes `ticket_invalid` (404), `job_expired` (410), `job_done` (409), `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). +`signer_mismatch` (403), `signature_invalid` (422), `agent_outdated` (426: the message says to update). 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. +while the backend keeps the job open; once the signer closes that window, the dashboard cancels the open jobs and +closes its dialog, so signing again is one click away. - Terminal, reported by the agent: `pin_locked`, `card_untrusted`, `middleware_missing`, `not_paired`, `certificate_not_found`. diff --git a/src/components/primitives/confirm-dialog.tsx b/src/components/primitives/confirm-dialog.tsx index 8fa80d5..381f80c 100644 --- a/src/components/primitives/confirm-dialog.tsx +++ b/src/components/primitives/confirm-dialog.tsx @@ -35,6 +35,10 @@ export const dialogDescriptionText = "text-[14px] leading-5 text-pretty text-(-- export const FALLBACK_ERROR = "Something went wrong. Try again." +/** The icon tile before a destructive confirmation's title. */ +export const dangerTile = + "grid size-10 shrink-0 place-items-center rounded-(--pn-r-4) border border-[color-mix(in_oklab,var(--pn-danger-fg)_30%,transparent)] bg-(--pn-danger-bg) text-(--pn-danger-fg)" + type ConfirmDialogProps = { /** Omit with `trigger` to let the dialog manage its own open state. */ open?: boolean @@ -120,12 +124,11 @@ export function ConfirmDialog({ {Icon ? ( diff --git a/src/components/primitives/form-dialog.tsx b/src/components/primitives/form-dialog.tsx index 4cb219f..1654875 100644 --- a/src/components/primitives/form-dialog.tsx +++ b/src/components/primitives/form-dialog.tsx @@ -1,5 +1,5 @@ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" -import { X } from "lucide-react" +import { type LucideIcon, TriangleAlert, X } from "lucide-react" import { type KeyboardEvent, type ReactElement, type ReactNode, useRef, useState } from "react" import { Button } from "@/components/ui/button" @@ -15,6 +15,7 @@ import { cn } from "@/lib/utils" import { ConfirmDialog, + dangerTile, dialogDescriptionText, dialogFooter, dialogFooterButton, @@ -64,6 +65,14 @@ type FormDialogProps = { /** Immediate filters may replace Cancel/Submit with their clear action and count. */ footer?: ReactNode trigger?: ReactElement + /** Element to focus on close instead of the trigger, e.g. the text that now states the outcome. */ + finalFocus?: () => HTMLElement | null + /** + * "danger" for a form that confirms something destructive (declining a request): the confirmation's danger tile + * before the title, `icon` in it, and a red submit. + */ + tone?: "danger" + icon?: LucideIcon children: ReactNode } @@ -85,6 +94,9 @@ export function FormDialog({ footerStart, footer, trigger, + finalFocus, + tone, + icon: Icon = TriangleAlert, children, }: FormDialogProps) { const [pending, setPending] = useState(false) @@ -145,11 +157,19 @@ export function FormDialog({ finalFocus() ?? true : undefined} className={cn(dialogPanel, dialogMotion, sizes[size])} > -
- {title} - {description && {description}} +
+ {tone === "danger" && ( + + + + )} +
+ {title} + {description && {description}} +
{submitLabel !== undefined && ( - + {submitLabel} )} diff --git a/src/components/primitives/index.ts b/src/components/primitives/index.ts index d29ff59..02620df 100644 --- a/src/components/primitives/index.ts +++ b/src/components/primitives/index.ts @@ -24,7 +24,9 @@ export { GroupLabelBadges } from "./group-label-badges" export { Hint } from "./hint" export { IconButton } from "./icon-button" export { InlineAlert, type AlertTone } from "./inline-alert" +export { inlineLink } from "./inline-link" export { InlineEditCard, InlineEditInput, InlineEditRow, InlineEditTextarea } from "./inline-edit" +export { Kbd } from "./kbd" export { CopyableText } from "./copyable-text" export { InviteLinkActions } from "./invite-link-actions" export { KeyValueList, type KeyValueItem } from "./key-value-list" @@ -52,7 +54,15 @@ export { SectionCard } from "./section-card" export { SectionEmpty } from "./section-empty" export { SectionHeading } from "./section-heading" export { SegmentedControl, type SegmentedItem } from "./segmented-control" -export { CardsSkeleton, RecordSkeleton, SettingsListSkeleton, SkeletonRows, TableSkeleton } from "./skeletons" +export { + BarSkeleton, + CardsSkeleton, + RecordSkeleton, + SettingsListSkeleton, + SkeletonRows, + TableSkeleton, + WorkspaceSkeleton, +} from "./skeletons" export { Spinner } from "./spinner" export { StatusBadge, type StatusTone } from "./status-badge" export { PAGE_SIZES, TablePaginationBar, type TablePagination } from "./table-pagination" diff --git a/src/components/primitives/inline-edit.tsx b/src/components/primitives/inline-edit.tsx index 750b027..e452666 100644 --- a/src/components/primitives/inline-edit.tsx +++ b/src/components/primitives/inline-edit.tsx @@ -11,6 +11,7 @@ import { RowActions } from "./data-table" import { FieldCounter, isCounterVisible } from "./field-counter" import { IconButton } from "./icon-button" import { InlineAlert } from "./inline-alert" +import { Kbd } from "./kbd" import { LoadingButton } from "./loading-button" import { buttonMotion } from "./motion" import { useModifierKey } from "./use-modifier-key" @@ -134,23 +135,6 @@ function EditFooter({ onCancel, onSave, dirty, valid, saving, error, message }: ) } -/** A shortcut hint inside a footer button; hidden on coarse pointers, where there is no keyboard. */ -function Kbd({ children, onSolid = false }: { children: ReactNode; onSolid?: boolean }) { - return ( - - {children} - - ) -} - function ViewActions({ onEdit, leadingActions, diff --git a/src/components/primitives/inline-link.ts b/src/components/primitives/inline-link.ts new file mode 100644 index 0000000..99f0838 --- /dev/null +++ b/src/components/primitives/inline-link.ts @@ -0,0 +1,6 @@ +/** + * A link inside a sentence: accent text underlined at rest, so color isn't the only thing that sets it apart, and a + * full-strength line on hover. Links that stand on their own (a row's title, a card's footer link) keep their style. + */ +export const inlineLink = + "text-(--pn-accent) underline decoration-(--pn-accent)/40 underline-offset-2 transition-[color,text-decoration-color] duration-120 hover:text-(--pn-accent-hover) hover:decoration-current" diff --git a/src/components/primitives/kbd.tsx b/src/components/primitives/kbd.tsx new file mode 100644 index 0000000..6dbf52a --- /dev/null +++ b/src/components/primitives/kbd.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from "react" + +import { cn } from "@/lib/utils" + +/** A shortcut hint inside a button; hidden on coarse pointers, where there is no keyboard. */ +export function Kbd({ children, onSolid = false }: { children: ReactNode; onSolid?: boolean }) { + return ( + + {children} + + ) +} diff --git a/src/components/primitives/skeletons.tsx b/src/components/primitives/skeletons.tsx index a4edeb2..961b80b 100644 --- a/src/components/primitives/skeletons.tsx +++ b/src/components/primitives/skeletons.tsx @@ -8,13 +8,24 @@ function Block({ className }: { className?: string }) { } /** One region-wide pulse (1 → .6 → 1 over 1.4s); static under reduced motion. */ -function Pulse({ label, className, children }: { label: string; className?: string; children: ReactNode }) { +function Pulse({ + label, + className, + bodyClassName, + children, +}: { + label: string + className?: string + bodyClassName?: string + children: ReactNode +}) { const reduceMotion = useReducedMotion() return (
{label} @@ -153,3 +164,77 @@ export function SettingsListSkeleton({ rows = 2, label = "Loading…", className ) } + +/** + * A workspace that fills the window, as the signature request composer does: a document surface and, from 900px of + * width, a column beside it. Without `document` it is still to be chosen (a file list and a drop zone); with it, a page + * and the request's column, as when the composer starts from an earlier request. + */ +export function WorkspaceSkeleton({ + label = "Loading…", + document = false, + className, +}: { + label?: string + document?: boolean + className?: string +}) { + return ( + +
+
+ +
+ {document ? ( +
+
+
+ ) : ( + ["w-1/3", "w-1/4", "w-2/5"].map((width) => ( +
+ + +
+ )) + )} +
+ {document ? ( +
+ + {["w-1/2", "w-2/5"].map((width) => ( +
+ + +
+ ))} + +
+ ) : ( +
+ )} + + ) +} + +/** Bars in place of lines still being worked out, e.g. a list read from a document; they share the height, 4px apart. */ +export function BarSkeleton({ + label = "Loading…", + bars = 1, + className, +}: { + label?: string + bars?: number + className?: string +}) { + return ( + + {Array.from({ length: bars }, (_, bar) => ( + + ))} + + ) +} diff --git a/src/components/shell/page-bar.tsx b/src/components/shell/page-bar.tsx index cb545cc..054c904 100644 --- a/src/components/shell/page-bar.tsx +++ b/src/components/shell/page-bar.tsx @@ -61,10 +61,22 @@ export function useRenderedPathname() { const gutter = "px-4 shell:px-6 min-[1200px]:px-9" -/** Centers the page content at the template width with the shell's side padding, below the page header. */ -export function PageContent({ width = "wide", children }: { width?: ContentWidth; children: ReactNode }) { +/** + * Centers the page content at the template width with the shell's side padding, below the page header. `fill` is for + * a workspace that fills the window instead of scrolling (the signature request composer): its bottom padding matches + * the side gutter. + */ +export function PageContent({ + width = "wide", + fill = false, + children, +}: { + width?: ContentWidth + fill?: boolean + children: ReactNode +}) { return ( -
+
{children}
) diff --git a/src/features/esign/action-card.tsx b/src/features/esign/action-card.tsx new file mode 100644 index 0000000..571bcc5 --- /dev/null +++ b/src/features/esign/action-card.tsx @@ -0,0 +1,126 @@ +import type { ReactNode, Ref } from "react" + +import { SectionCard, Spinner } from "@/components/primitives" +import { cn } from "@/lib/utils" + +import type { SigningDevice } from "./signing-device" + +/** + * Before hydration the device is unknown, so both variants render and CSS picks one: signing controls are hidden on + * touch-only screens, the phone note everywhere else. + */ +export const computerOnly = "[@media(hover:none)_and_(pointer:coarse)]:hidden" +export const phoneOnly = "hidden [@media(hover:none)_and_(pointer:coarse)]:block" + +type EsignActionCardProps = { + title: string + /** Beside the title, e.g. an icon button that opens the request. */ + titleAction?: ReactNode + badge?: ReactNode + /** After the badge, e.g. "Segretario · p. 1". */ + meta?: string | null + /** The one sentence about where things stand. */ + body?: ReactNode + /** Under the sentence, inside the same live region: delivery lines, an alert, a button. */ + children?: ReactNode + /** The body is where focus goes after an action changes it. */ + bodyId: string + bodyRef?: Ref + /** Replaces the signing controls on phones and tablets. */ + note?: string | null + /** One muted line under the body. */ + muted?: ReactNode + /** Why the primary is disabled; `lockId` is for its `aria-describedby`. */ + lock?: string | null + lockId?: string + /** One full-width button. */ + primary?: ReactNode + /** The primary signs or leads to signing, so phones don't get it. */ + primarySigns?: boolean + /** Under the primary, e.g. how many more documents wait; it goes with a signing primary on phones. */ + after?: ReactNode + /** Footer row, left: one quiet button that leads to signing. */ + footerStart?: ReactNode + /** Footer row, right: the card's one red button. */ + footerEnd?: ReactNode + device: SigningDevice +} + +/** + * The top of the right column on the request and batch pages: the title, a status line, the one sentence that says + * where things stand, and the one thing to do next. Presentational: the page decides what goes in each slot. + */ +export function EsignActionCard({ + title, + titleAction, + badge, + meta, + body, + children, + bodyId, + bodyRef, + note, + muted, + lock, + lockId, + primary, + primarySigns = false, + after, + footerStart, + footerEnd, + device, +}: EsignActionCardProps) { + // On a phone the page passes no signing controls; while unknown it passes both, and CSS shows one. + const signing = device === "unknown" ? computerOnly : undefined + const footer = (footerStart || footerEnd) && ( +
+ {footerStart &&
{footerStart}
} + {footerEnd &&
{footerEnd}
} +
+ ) + return ( + +
+

+ {title} +

+ {titleAction} +
+ {(badge || meta) && ( +
+ {badge} + {meta && {meta}} +
+ )} + {/* Focus lands here after an action, for screen readers; it is text, so it draws no ring. */} +
+ {body &&

{body}

} + {note && ( +

+ {note} +

+ )} + {children} +
+ {muted &&

{muted}

} + {/* In the muted line's place and with its spacing, so the card's height holds as the lock comes and goes. */} + {lock && ( +

+ + {lock} +

+ )} + {primary &&
{primary}
} + {after && ( +
{after}
+ )} +
+ ) +} diff --git a/src/features/esign/agent.ts b/src/features/esign/agent.ts index 069b499..3864936 100644 --- a/src/features/esign/agent.ts +++ b/src/features/esign/agent.ts @@ -2,11 +2,26 @@ export const AGENT_PORT = 47321 export const AGENT_ORIGINS = [`http://127.0.0.1:${AGENT_PORT}`, `http://localhost:${AGENT_PORT}`] +/** Documents one agent window signs with one PIN, at most. */ +export const MAX_BATCH = 10 + +/** + * Set in localStorage once PoliNetwork eSign has answered in this browser: `{"version": string | null, "at": ISO}`, + * a null version for agents that don't announce themselves. + */ +export const AGENT_FLAG_KEY = "pn-esign-agent" + /** Starts an installed agent that isn't running. */ export const AGENT_LAUNCH_URL = "polinetwork-esign://open" export const AGENT_RELEASES_URL = "https://github.com/PoliNetworkOrg/esign/releases/latest" +/** Release assets with stable names, so these links always download the latest version. Linux uses the release page. */ +export const AGENT_DOWNLOAD_URLS = { + macos: `${AGENT_RELEASES_URL}/download/PoliNetwork-eSign-macos.dmg`, + windows: `${AGENT_RELEASES_URL}/download/PoliNetwork-eSign-windows-amd64.zip`, +} as const + /** * The agent's signing page for one or more jobs, signed with one PIN. `origin` is this dashboard, which the agent * checks against its allowlist. @@ -20,3 +35,17 @@ export function agentSignUrl(tickets: string[], origin: string) { export function isAgentOrigin(origin: string) { return AGENT_ORIGINS.includes(origin) } + +/** + * PoliNetwork eSign's User-Agent: `PoliNetwork-eSign/` from 0.2.0, `PoliNetwork-Sign-Agent/` for + * the agents released before the rename (0.1.x). + */ +const AGENT_USER_AGENT = /^PoliNetwork-(?:eSign|Sign-Agent)\/[0-9A-Za-z.+-]{1,32}$/ + +/** + * The User-Agent the agent proxy forwards to the backend, which turns away versions too old to sign before the PIN. + * Anything that isn't the agent's own is dropped. + */ +export function agentUserAgent(header: string | null) { + return header !== null && AGENT_USER_AGENT.test(header) ? header : null +} diff --git a/src/features/esign/batch-page.tsx b/src/features/esign/batch-page.tsx index b63ead6..d28adab 100644 --- a/src/features/esign/batch-page.tsx +++ b/src/features/esign/batch-page.tsx @@ -1,21 +1,24 @@ -import { Link, useNavigate } from "@tanstack/react-router" +import { Link, useNavigate, useRouter } from "@tanstack/react-router" import { useServerFn } from "@tanstack/react-start" import { ArrowRight, Check, ExternalLink, FileSignature, Plus, X } from "lucide-react" -import { useCallback, useEffect, useRef, useState } from "react" +import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react" -import { EmptyState, IconButton, InlineAlert, SectionCard } from "@/components/primitives" -import { PageBar, PageContent } from "@/components/shell" +import { buttonMotion, EmptyState, IconButton, InlineAlert, inlineLink } from "@/components/primitives" +import { appToast, PageBar, PageContent } from "@/components/shell" import { Button } from "@/components/ui/button" import type { EsignPdfInfo, EsignRequestDetail } from "@/lib/api/types" -import { formatDateTime, pluralize } from "@/lib/format" import { cn } from "@/lib/utils" +import { computerOnly, EsignActionCard } from "./action-card" import { getEsignDocumentContent } from "./esign.functions" import { PdfViewer, usePdfBytes } from "./pdf-viewer" -import { kindsLabel, type Place, pagesLabel, placeAt, placesLeft } from "./places" -import { MovePlaceButtons, PlacingCancel, placingStatus } from "./placing" -import { SignWithCie } from "./sign-with-cie" +import { type Place, pagesShort, placeAt, placesLeft } from "./places" +import { MoveBoxButton, PlacingCancel, placingStatus } from "./placing" +import { PHONE_NOTE } from "./request-card" +import { SignTrigger } from "./sign-with-cie" import { SignatureBox } from "./signature-box" +import { useKnownBrowser, useSigningDevice } from "./signing-device" +import { useHostedSignWithCie } from "./signing-host" import { useReloadWhile } from "./use-reload-while" type Item = Pick & { pdf: EsignPdfInfo | null } @@ -26,9 +29,25 @@ const BACK = { label: "documents to sign", link: { to: "/dashboard/esign/inbox" * Signs the documents waiting for the viewer together: a strip of documents to check one by one, where the viewer's * boxes can still be moved, then one "Sign" that opens a single agent window and one PIN for all of them. */ -export function EsignBatchPage({ items, more, me }: { items: Item[]; more: number; me: string }) { +export function EsignBatchPage({ + items, + more, + me, + moved: carried, +}: { + items: Item[] + more: number + me: string + /** Boxes already moved on the request page the viewer came from, by request id. */ + moved?: Record +}) { + const router = useRouter() const navigate = useNavigate() + const device = useSigningDevice() + const knownBrowser = useKnownBrowser() const contentFn = useServerFn(getEsignDocumentContent) + const bodyId = useId() + const lockId = useId() /** 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( @@ -51,10 +70,21 @@ export function EsignBatchPage({ items, more, me }: { items: Item[]; more: numbe 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()) + const [moved, setMoved] = useState>(() => new Map(Object.entries(carried ?? {}))) /** The open document's place waiting for a click on its new spot. */ const [placing, setPlacing] = useState(null) + // After placing, by a click on a page or Esc, focus returns to Move box. + const moveRef = useRef(null) + const wasPlacing = useRef(false) + useEffect(() => { + if (placing !== null) wasPlacing.current = true + else if (wasPlacing.current) { + wasPlacing.current = false + moveRef.current?.focus({ preventScroll: true }) + } + }, [placing]) + // 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)) @@ -65,6 +95,36 @@ export function EsignBatchPage({ items, more, me }: { items: Item[]; more: numbe 3000 ) + /** + * To sign opens behind the dialog, so it is there when the dialog lets go; the dialog lives above the routes. Then + * every loader reloads, so the shell's To sign count agrees with the page. + */ + async function handOff() { + await navigate({ to: "/dashboard/esign/inbox" }) + try { + await router.invalidate({ sync: true }) + } catch (error) { + console.error(error) + } + } + + // Above the routes: To sign opens under the dialog once every document is signed. Closing a batch that signed only + // some reloads this page, which may leave nothing to sign and no card. + const signing = useHostedSignWithCie({ + items: included.map((item) => { + const mine = item.request.signers.find((s) => s.sub === me && s.status === "pending") + return { + requestId: item.request.id, + title: item.request.title, + places: mine?.signaturesDone === 0 ? moved.get(item.request.id) : undefined, + } + }), + handOff, + // To sign only loses the documents: the toast says they were signed. + onSigned: () => + appToast.success(included.length === 1 ? "Document signed." : `${included.length} documents signed.`), + }) + const activeDocument = active?.currentDocumentId const nextDocument = unchecked[0]?.currentDocumentId useEffect(() => { @@ -118,26 +178,30 @@ export function EsignBatchPage({ items, more, me }: { items: Item[]; more: numbe const signer = active.request.signers.find((s) => s.sub === me && s.status === "pending") const places = moved.get(active.request.id) ?? signer?.places ?? [] + // Phones review only: the box stays where it is. + const editable = signer?.signaturesDone === 0 && device !== "phone" + const out = leftOut.has(active.request.id) + const lockedBy = active.signingNow && active.signingNow.sub !== me ? active.signingNow : null + const signs = device !== "phone" + const gate = device === "unknown" ? computerOnly : undefined + const boxes = places.length > 1 ? "boxes" : "box" + const body = out + ? "Left out: it won't be signed now." + : unchecked.length > 0 + ? `Check the blue ${boxes}, then open the next one.` + : included.length === 1 + ? `Check the blue ${boxes}, then sign with CIE.` + : `All checked. One PIN signs all ${included.length}.` - const action = - unchecked.length > 0 ? ( - ) : ( - { - const mine = item.request.signers.find((s) => s.sub === me && s.status === "pending") - return { - requestId: item.request.id, - title: item.request.title, - places: mine?.signaturesDone === 0 ? moved.get(item.request.id) : undefined, - } - })} - onSigned={() => void navigate({ to: "/dashboard/esign/inbox" })} - > + {included.length === 1 ? "Sign with CIE" : `Sign ${included.length} documents`} - + ) return ( @@ -146,12 +210,11 @@ export function EsignBatchPage({ items, more, me }: { items: Item[]; more: numbe width="record" back={BACK} context="Sign together" - right={included.length > 0 ? action : undefined} left={