Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/esign/TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
105 changes: 82 additions & 23 deletions src/features/esign/new-request-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -140,7 +149,10 @@ export function NewEsignRequestPage({
}
}, [bytes])

const [signers, setSigners] = useState<SignerDraft[]>([])
const [signers, setSigners] = useState<SignerDraft[]>(() => {
const self = withMe ? people.find((p) => p.sub === me) : undefined
return self ? [draftOf(self, 0, [])] : []
})
const [placing, setPlacing] = useState<Placing | null>(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. */
Expand Down Expand Up @@ -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("")
}

Expand Down Expand Up @@ -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)))

Expand All @@ -310,32 +320,66 @@ 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:
saveToDrive && folder ? { driveId: folder.driveId, folderId: folder.id, fileName: fileName.trim() } : null,
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)
Expand All @@ -348,11 +392,21 @@ export function NewEsignRequestPage({
<PageBar
width="record"
back={{ label: "requests", link: { to: "/dashboard/esign/requests" } }}
context="New request"
context={onlyMe ? "Sign a document" : "New request"}
right={
<LoadingButton className="h-10" pending={submitting} onClick={submit}>
Create request
</LoadingButton>
signsFirst ? (
<SignWithCie
prepare={createAndSign}
onSigned={() => created.current && void openRequest(created.current.id)}
onStopped={signingStopped}
>
{onlyMe ? "Sign with CIE" : "Create and sign"}
</SignWithCie>
) : (
<LoadingButton className="h-10" pending={submitting} onClick={submit}>
Create request
</LoadingButton>
)
}
/>
<PageContent width="record">
Expand Down Expand Up @@ -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)"
>
<UserPlus aria-hidden className="size-4 shrink-0 text-(--pn-fg-muted)" />
<span className="min-w-0 flex-1 truncate">{person.name}</span>
<span className="min-w-0 flex-1 truncate">
{person.name}
{person.sub === me && <span className="text-(--pn-fg-muted)"> (you)</span>}
</span>
{person.email && (
<span className="truncate font-mono text-[12px] text-(--pn-fg-muted)">{person.email}</span>
)}
Expand Down Expand Up @@ -647,7 +704,9 @@ export function NewEsignRequestPage({
<span>
Sign in this order
<span className="block text-[12px] text-(--pn-fg-muted)">
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."}
</span>
</span>
</label>
Expand Down
18 changes: 12 additions & 6 deletions src/features/esign/requests-page.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 (
<Button variant={variant} className="h-10" nativeButton={false} render={<Link to="/dashboard/esign/new" />}>
<Plus aria-hidden /> New request
<Button
variant={variant}
className="h-10"
nativeButton={false}
render={<Link to="/dashboard/esign/new" search={self ? { self: true } : {}} />}
>
{self ? <PenLine aria-hidden /> : <Plus aria-hidden />} {self ? "Sign a document" : "New request"}
</Button>
)
}
Expand Down Expand Up @@ -115,7 +121,7 @@ export function EsignRequestsPage({ requests, scope }: { requests: EsignRequest[
right={
canManage || waiting > 1 ? (
<>
{canManage && <NewRequestButton variant={waiting > 1 ? "outline" : "default"} />}
{canManage && <NewRequestButton variant={waiting > 1 ? "outline" : "default"} self={inbox} />}
{waiting > 1 && (
<Button className="h-10" nativeButton={false} render={<Link to="/dashboard/esign/sign" />}>
<Files aria-hidden /> Sign {waiting} together
Expand Down Expand Up @@ -178,7 +184,7 @@ export function EsignRequestsPage({ requests, scope }: { requests: EsignRequest[
? "When someone asks for your signature, the document shows up here."
: "Start a request from a PDF in OneDrive or on your computer, choose who signs and where."
}
action={canManage && !inbox ? <NewRequestButton variant="outline" /> : undefined}
action={canManage ? <NewRequestButton variant="outline" self={inbox} /> : undefined}
/>
) : (
<EmptyState
Expand Down
42 changes: 33 additions & 9 deletions src/features/esign/sign-with-cie.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,28 @@ function openPopup(): Window | null {
*/
export function SignWithCie({
items,
prepare,
disabled,
children,
onSigned,
}: {
items: SignItem[]
onStopped,
}: (
| { items: SignItem[]; prepare?: never }
| {
items?: never
/**
* Decides in the click what to sign, for documents that don't exist yet: null leaves the popup closed (the caller
* shows why), else the popup opens and the promise's documents are signed (none: it closes again).
*/
prepare: () => Promise<SignItem[]> | null
}
) & {
disabled?: boolean
children?: ReactNode
/** After the signatures are saved and the page data reloaded. */
onSigned?: () => void
/** After the dialog closes before every document is signed, or the signature couldn't start (with why). */
onStopped?: (error: string | null) => void
}) {
const router = useRouter()
const startFn = useServerFn(startEsignSignature)
Expand All @@ -114,7 +127,6 @@ export function SignWithCie({
const [error, setError] = useState<string | null>(null)
const popupRef = useRef<Window | null>(null)
const finishing = useRef(false)
const batch = items.length > 1

const reload = useCallback(async () => {
try {
Expand All @@ -139,6 +151,7 @@ export function SignWithCie({
)

const jobs = phase && "jobs" in phase ? phase.jobs : null
const batch = (jobs ?? items ?? []).length > 1

useEffect(() => {
if (!jobs) return
Expand Down Expand Up @@ -188,6 +201,8 @@ export function SignWithCie({
}, [jobs, statusFn, finish])

async function start() {
const prepared = prepare ? prepare() : Promise.resolve(items ?? [])
if (!prepared) return
setError(null)
setStates(new Map())
finishing.current = false
Expand All @@ -198,15 +213,21 @@ export function SignWithCie({
popup = openPopup()
popupRef.current = popup
setPhase({ kind: "starting" })
const toSign = await prepared
if (toSign.length === 0) {
popup?.close()
setPhase(null)
return
}
const started = await startFn({
data: { items: items.map((item) => ({ requestId: item.requestId, places: item.places })) },
data: { items: toSign.map((item) => ({ requestId: item.requestId, places: item.places })) },
})
const url = agentSignUrl(
started.map((s) => s.ticket),
window.location.origin
)
if (popup && !popup.closed) popup.location.href = url
const titles = new Map(items.map((item) => [item.requestId, item.title]))
const titles = new Map(toSign.map((item) => [item.requestId, item.title]))
setPhase({
kind: "waiting",
jobs: started.map((s) => ({ jobId: s.jobId, requestId: s.requestId, title: titles.get(s.requestId) ?? "" })),
Expand All @@ -218,7 +239,9 @@ export function SignWithCie({
console.error(cause)
popup?.close()
setPhase(null)
setError(cause instanceof Error ? cause.message : "Couldn't start the signature.")
const message = cause instanceof Error ? cause.message : "Couldn't start the signature."
setError(message)
onStopped?.(message)
}
}

Expand All @@ -227,7 +250,7 @@ export function SignWithCie({
const current = phase
setPhase(null)
popupRef.current?.close()
if (!current || !("jobs" in current)) return
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) {
try {
Expand All @@ -237,6 +260,7 @@ export function SignWithCie({
}
}
if (open.length < current.jobs.length) await reload()
onStopped?.(null)
}

function reopen(url: string) {
Expand All @@ -256,11 +280,11 @@ export function SignWithCie({
<LoadingButton
icon={PenLine}
pending={phase?.kind === "starting"}
disabled={disabled || items.length === 0}
disabled={disabled || items?.length === 0}
onClick={start}
className="h-10"
>
{children ?? (batch ? `Sign ${items.length} documents` : "Sign with CIE")}
{children ?? (items && items.length > 1 ? `Sign ${items.length} documents` : "Sign with CIE")}
</LoadingButton>
{error && <InlineAlert className="mt-2">{error}</InlineAlert>}
<Dialog open={phase !== null && phase.kind !== "starting"} onOpenChange={(open) => !open && void close()}>
Expand Down
Loading
Loading