From 95e31aefe695d180d3f4b33296e7db537052225a Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sat, 17 Oct 2026 18:00:22 +0200 Subject: [PATCH] feat(esign): sign a document yourself in the same step as creating it When the creator signs first (or alone), the composer's button becomes "Create and sign" / "Sign with CIE": it creates the request and opens the eSign agent in the same click, so the browser doesn't block the popup. If signing stops or fails, the request stays and the creator lands on its page to sign later. To sign offers "Sign a document", which starts with the creator as first signer, and the signer picker lists the creator first. Co-Authored-By: Claude Opus 5.5 --- docs/esign/TRACKER.md | 5 ++ src/features/esign/new-request-page.tsx | 105 ++++++++++++++++++------ src/features/esign/requests-page.tsx | 18 ++-- src/features/esign/sign-with-cie.tsx | 42 ++++++++-- src/routes/dashboard/esign/new.tsx | 6 +- 5 files changed, 137 insertions(+), 39 deletions(-) diff --git a/docs/esign/TRACKER.md b/docs/esign/TRACKER.md index 4282a81..71a78e6 100644 --- a/docs/esign/TRACKER.md +++ b/docs/esign/TRACKER.md @@ -125,6 +125,11 @@ Legend: ☐ todo · ◐ in progress · ☑ done agent window signs them all (agent protocol 2; older agents are asked to update). - ☑ Drive access by role or permission (Signatures › Drives): who may request with each drive and who may be asked to 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). - ☐ 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). diff --git a/src/features/esign/new-request-page.tsx b/src/features/esign/new-request-page.tsx index 077448e..61835b4 100644 --- a/src/features/esign/new-request-page.tsx +++ b/src/features/esign/new-request-page.tsx @@ -15,7 +15,7 @@ import { TriangleAlert, UserPlus, } from "lucide-react" -import { type DragEvent, useEffect, useId, useMemo, useState } from "react" +import { type DragEvent, useEffect, useId, useMemo, useRef, useState } from "react" import { checkboxControl, @@ -51,6 +51,7 @@ import { getEsignDocumentContent } from "./esign.functions" import { extractTextAnchors, type PageSize, PdfViewer, sizeOnPage, type TextAnchor, usePdfBytes } from "./pdf-viewer" import { kindsLabel, type Place, pagesLabel, withSignature } from "./places" import { createEsignRequest, importEsignDriveDocument, uploadEsignDocument } from "./requests.functions" +import { SignWithCie } from "./sign-with-cie" import { type BoxPlacement, SignatureBox } from "./signature-box" type Staged = { id: string; fileName: string; pdf: EsignPdfInfo; folder: DriveFolder | null } @@ -102,14 +103,22 @@ function baseName(fileName: string) { return fileName.replace(/\.pdf$/i, "") } +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 } +} + export function NewEsignRequestPage({ config, signers: people, me, + withMe, }: { config: EsignConfig signers: EsignProfile[] me: string + /** Start with the viewer as the first signer ("Sign a document"). */ + withMe: boolean }) { const navigate = useNavigate() const uploadFn = useServerFn(uploadEsignDocument) @@ -140,7 +149,10 @@ export function NewEsignRequestPage({ } }, [bytes]) - const [signers, setSigners] = useState([]) + const [signers, setSigners] = useState(() => { + const self = withMe ? people.find((p) => p.sub === me) : undefined + return self ? [draftOf(self, 0, [])] : [] + }) const [placing, setPlacing] = useState(null) const [focus, setFocus] = useState<{ page: number; y: number } | null>(null) /** The box whose menu is open, so it stands out among the same person's boxes. */ @@ -215,12 +227,9 @@ export function NewEsignRequestPage({ } function addSigner(person: EsignProfile) { - const where = defaultPlacement(signers.length, pages) - setSigners((current) => [ - ...current, - { sub: person.sub, name: person.name, role: "", places: [{ ...where, kind: "signature" }], manual: false }, - ]) - setFocus({ page: where.page, y: where.y }) + const draft = draftOf(person, signers.length, pages) + setSigners((current) => [...current, draft]) + setFocus({ page: draft.places[0].page, y: draft.places[0].y }) setSearch("") } @@ -291,7 +300,8 @@ export function NewEsignRequestPage({ .filter((p) => !signers.some((s) => s.sub === p.sub)) .filter((p) => usedDrives.every((d) => p.drives.includes(d))) .filter((p) => needle === "" || `${p.name} ${p.email ?? ""}`.toLocaleLowerCase().includes(needle)) - }, [people, signers, search, usedDrives.join()]) + .sort((a, b) => Number(b.sub === me) - Number(a.sub === me)) + }, [people, signers, search, me, usedDrives.join()]) const outsiders = signers.filter((s) => blockedDrive(s.sub) !== undefined) const eligible = people.filter((p) => usedDrives.every((d) => p.drives.includes(d))) @@ -310,16 +320,22 @@ export function NewEsignRequestPage({ ...outsiders.map((s) => `${s.name} can't sign documents of ${driveLabel(blockedDrive(s.sub) ?? "")}.`), ].filter((p): p is string => Boolean(p)) - async function submit() { - setAttempted(true) - if (problems.length > 0 || !staged) return - setSubmitting(true) + // When the viewer signs first, creating the request and signing it are one step. + const myIndex = signers.findIndex((s) => s.sub === me) + const signsFirst = myIndex === 0 || (myIndex > 0 && !ordered) + const onlyMe = signsFirst && signers.length === 1 + + /** Creates the request once: a signature that doesn't start leaves it on its page, ready to sign again. */ + const created = useRef<{ id: string; title: string } | null>(null) + + async function create(documentId: string) { + if (created.current) return created.current setSubmitError(null) try { const request = await createFn({ data: { title: title.trim(), - documentId: staged.id, + documentId, ordered, signers: signers.map((s) => ({ sub: s.sub, role: s.role.trim() || null, places: s.places })), output: @@ -327,15 +343,43 @@ export function NewEsignRequestPage({ email: recipients.length ? { to: recipients, message: message.trim() || null } : null, }, }) - appToast.success("Request created.") - await navigate({ to: "/dashboard/esign/requests/$requestId", params: { requestId: request.id } }) + created.current = { id: request.id, title: request.title } + return created.current } catch (error) { console.error(error) setSubmitError(error instanceof Error ? error.message : "Couldn't create the request.") - setSubmitting(false) + return null } } + function openRequest(id: string) { + return navigate({ to: "/dashboard/esign/requests/$requestId", params: { requestId: id } }) + } + + async function submit() { + setAttempted(true) + if (problems.length > 0 || !staged) return + setSubmitting(true) + const request = await create(staged.id) + if (!request) return setSubmitting(false) + appToast.success("Request created.") + await openRequest(request.id) + } + + function createAndSign() { + setAttempted(true) + if (problems.length > 0 || !staged) return null + return create(staged.id).then((request) => (request ? [{ requestId: request.id, title: request.title }] : [])) + } + + function signingStopped(error: string | null) { + const request = created.current + if (!request) return + if (error) appToast.error(`Request created, but the signature couldn't start. ${error}`) + else appToast.success("Request created. Sign it here when you're ready.") + void openRequest(request.id) + } + function onDrop(event: DragEvent) { event.preventDefault() setDragging(false) @@ -348,11 +392,21 @@ export function NewEsignRequestPage({ - Create request - + signsFirst ? ( + created.current && void openRequest(created.current.id)} + onStopped={signingStopped} + > + {onlyMe ? "Sign with CIE" : "Create and sign"} + + ) : ( + + Create request + + ) } /> @@ -610,7 +664,10 @@ export function NewEsignRequestPage({ className="flex h-10 w-full items-center gap-2 rounded-(--pn-r-2) px-2 text-left text-[14px] hover:bg-(--pn-hover)" > - {person.name} + + {person.name} + {person.sub === me && (you)} + {person.email && ( {person.email} )} @@ -647,7 +704,9 @@ export function NewEsignRequestPage({ Sign in this order - Each signer is asked after the previous one signs. + {signsFirst && ordered + ? "You sign first, as soon as the request is created; each next signer is asked after the previous one signs." + : "Each signer is asked after the previous one signs."} diff --git a/src/features/esign/requests-page.tsx b/src/features/esign/requests-page.tsx index b0710a2..930fcb3 100644 --- a/src/features/esign/requests-page.tsx +++ b/src/features/esign/requests-page.tsx @@ -1,5 +1,5 @@ import { Link, useNavigate } from "@tanstack/react-router" -import { FileSignature, Files, Plus } from "lucide-react" +import { FileSignature, Files, PenLine, Plus } from "lucide-react" import { useDeferredValue, useMemo, useState } from "react" import { AvatarGroup, DataTable, type DataTableColumn, EmptyState } from "@/components/primitives" @@ -31,10 +31,16 @@ function inFilter(request: EsignRequest, filter: StatusFilter) { return request.status === filter } -function NewRequestButton({ variant = "default" }: { variant?: "default" | "outline" }) { +/** In To sign it starts with the viewer as the first signer, who may then add others. */ +function NewRequestButton({ variant = "default", self }: { variant?: "default" | "outline"; self: boolean }) { return ( - ) } @@ -115,7 +121,7 @@ export function EsignRequestsPage({ requests, scope }: { requests: EsignRequest[ right={ canManage || waiting > 1 ? ( <> - {canManage && 1 ? "outline" : "default"} />} + {canManage && 1 ? "outline" : "default"} self={inbox} />} {waiting > 1 && (