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 && (