From 81615edb5bd69a89db67c86343c179ef3d21e86c Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sat, 17 Oct 2026 11:42:14 +0200 Subject: [PATCH 1/2] feat(esign): pick among several OneDrive drives The drive browser gets a drive picker when the backend offers several libraries, and every drive call (listing, import, the request's output) names its drive, since item IDs are only unique within one. A document imported from a drive defaults its output to the same drive and folder. Needs the backend release with ESIGN_DRIVES (backend#74). --- docs/esign/TRACKER.md | 6 +- docs/esign/contracts.md | 8 ++- src/features/esign/drive-browser.tsx | 82 ++++++++++++++++++------ src/features/esign/new-request-page.tsx | 41 +++++++----- src/features/esign/request-page.tsx | 7 +- src/features/esign/requests.functions.ts | 14 ++-- src/lib/api/types.ts | 1 + 7 files changed, 112 insertions(+), 47 deletions(-) diff --git a/docs/esign/TRACKER.md b/docs/esign/TRACKER.md index 35830fc2..09059702 100644 --- a/docs/esign/TRACKER.md +++ b/docs/esign/TRACKER.md @@ -122,10 +122,12 @@ Legend: ☐ todo · ◐ in progress · ☑ done ## Manual steps for the maintainer (cannot be automated here) -- ☐ Create IdP permissions `esign:sign` and `esign:requests:manage` and grant them. +- ☑ Create IdP permissions `esign:sign` and `esign:requests:manage` and grant them. They only reach the backend once + auth#61 adds `esign:` to the backend projection. - ☐ Azure app registration: Graph application permission `Files.ReadWrite.All` (or `Sites.Selected`) + admin consent; `Mail.Send` already used. -- ☐ Configure backend env for the OneDrive drive and default folders (see backend README once written). +- ☐ Set `ESIGN_DRIVES` on the backend: one `{id, label, driveId, rootId?}` per OneDrive/SharePoint library the + dashboard may use (backend README). Several drives are supported (backend#74). - ☐ Test with a real CIE + reader on macOS/Windows/Linux. This Mac already has Software CIE and a BIT4ID miniLector AIR NFC v3 reader: run the local end-to-end below without `--soft`, place the CIE on the reader and sign (expect a macOS "access data from other apps" prompt the first time, and Rosetta for the official module). diff --git a/docs/esign/contracts.md b/docs/esign/contracts.md index be0103f8..beee393e 100644 --- a/docs/esign/contracts.md +++ b/docs/esign/contracts.md @@ -72,10 +72,12 @@ browse OneDrive). Requests are visible to their signers and creator. - `esign.profile.sync({name, email})` → upserts the caller's signer profile (BFF passes ID-token claims). - `esign.signers.list()` → signer profiles (manage). -- `esign.drive.list({folderId?})` → `{folder, breadcrumbs, items: [{id, name, kind: folder|pdf, size, modifiedAt}]}`. -- `esign.documents.stage(FormData{file} | {driveItemId})` → `{documentId, fileName, pages: [{width,height}], signatures, sha256}`. +- `esign.config()` → `{drives: [{id, label}], canManage, maxPdfBytes}`; `drives` is empty without OneDrive. +- `esign.drive.list({driveId, folderId?})` → `{folder, path, items: [{id, name, kind: folder|pdf, size, modifiedAt}]}`. + Item IDs are only unique within their drive, so every drive call names one. +- `esign.documents.upload(FormData{file})` / `esign.documents.fromDrive({driveId, itemId})` → `{documentId, fileName, pages: [{width,height}], signatures, sha256}`. - `esign.documents.content({documentId})` → `{base64}` (a staged or request document version). -- `esign.requests.create({title, documentId, signers: [{sub, role, placement}], ordered, output: {folderId, fileName}, email?: {to[], message?}})`. +- `esign.requests.create({title, documentId, signers: [{sub, role, placement}], ordered, output: {driveId, folderId, fileName}, email?: {to[], message?}})`. - `esign.requests.list({scope: "inbox"|"mine"|"all", status?})`, `esign.requests.get({id})`. - `esign.requests.cancel({id})`, `esign.requests.decline({id, reason})`, `esign.requests.retryDelivery({id})`. - `esign.jobs.create({requestId, placement?})` → `{jobId, ticket, expiresAt, agentUrl}`; `esign.jobs.status({jobId})`; diff --git a/src/features/esign/drive-browser.tsx b/src/features/esign/drive-browser.tsx index 074dd3ff..c06ac8cc 100644 --- a/src/features/esign/drive-browser.tsx +++ b/src/features/esign/drive-browser.tsx @@ -3,20 +3,30 @@ import { ArrowUp, FileText, Folder, LoaderCircle } from "lucide-react" import { useEffect, useState } from "react" import { IconButton, InlineAlert } from "@/components/primitives" +import { floatingMotion, raisedSurface } from "@/components/primitives/motion" import { Button } from "@/components/ui/button" -import type { EsignDriveItem, EsignDriveListing } from "@/lib/api/types" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import type { EsignDrive, EsignDriveItem, EsignDriveListing } from "@/lib/api/types" import { formatDate } from "@/lib/format" import { cn } from "@/lib/utils" import { listEsignDrive } from "./requests.functions" +/** A folder in one of the shared drives: item IDs are only unique within their drive. */ +export type DriveFolder = { driveId: string; id: string; path: string } + type DriveBrowserProps = { /** "file" picks a PDF, "folder" picks the open folder. */ mode: "file" | "folder" + /** What the list holds, e.g. "Files"; the drive's name is added. */ label: string + /** The configured drives, at least one; a picker appears when there are several. */ + drives: EsignDrive[] + /** The drive opened first, the first one by default. */ + initialDriveId?: string /** A PDF, with the folder it is in. */ - onPickFile?: (item: EsignDriveItem, folder: { id: string; path: string }) => void - onPickFolder?: (folder: { id: string; path: string }) => void + onPickFile?: (item: EsignDriveItem, folder: DriveFolder) => void + onPickFolder?: (folder: DriveFolder) => void /** The item being imported, shown with a spinner. */ busyId?: string | null className?: string @@ -28,19 +38,31 @@ function sizeLabel(bytes: number | null) { return `${(bytes / 1024 / 1024).toFixed(1)} MB` } -/** Walks the association's shared drive (below its configured root) to pick a PDF or a folder. */ -export function DriveBrowser({ mode, label, onPickFile, onPickFolder, busyId, className }: DriveBrowserProps) { +/** Walks the association's shared drives (each below its configured root) to pick a PDF or a folder. */ +export function DriveBrowser({ + mode, + label, + drives, + initialDriveId, + onPickFile, + onPickFolder, + busyId, + className, +}: DriveBrowserProps) { const listFn = useServerFn(listEsignDrive) + const [driveId, setDriveId] = useState(drives.some((d) => d.id === initialDriveId) ? initialDriveId : drives[0]?.id) const [folderId, setFolderId] = useState(undefined) + const drive = drives.find((d) => d.id === driveId) const [listing, setListing] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) useEffect(() => { + if (!driveId) return let cancelled = false setLoading(true) setError(null) - listFn({ data: { folderId } }) + listFn({ data: { driveId, folderId } }) .then((result) => { if (!cancelled) setListing(result) }) @@ -54,15 +76,42 @@ export function DriveBrowser({ mode, label, onPickFile, onPickFolder, busyId, cl return () => { cancelled = true } - }, [folderId, listFn]) + }, [driveId, folderId, listFn]) const items = listing?.items.filter((item) => mode === "file" || item.kind === "folder") ?? [] + const here = listing && driveId ? { driveId, id: listing.folder.id, path: listing.path } : null return (
+ {drives.length > 1 && ( + + )} {error}
) : ( -
    +
      {listing && items.length === 0 && (
    • {mode === "file" ? "No PDFs or folders here" : "No folders here"} @@ -90,11 +142,7 @@ export function DriveBrowser({ mode, label, onPickFile, onPickFolder, busyId, cl
    )} - {mode === "folder" && listing && ( + {mode === "folder" && here && (
    -
    diff --git a/src/features/esign/new-request-page.tsx b/src/features/esign/new-request-page.tsx index 213f2c1b..8043ba0a 100644 --- a/src/features/esign/new-request-page.tsx +++ b/src/features/esign/new-request-page.tsx @@ -21,13 +21,13 @@ import type { EsignConfig, EsignPdfInfo, EsignProfile } from "@/lib/api/types" import { pluralize } from "@/lib/format" import { cn } from "@/lib/utils" -import { DriveBrowser } from "./drive-browser" +import { DriveBrowser, type DriveFolder } from "./drive-browser" import { getEsignDocumentContent } from "./esign.functions" import { extractTextAnchors, type PageSize, PdfViewer, sizeOnPage, type TextAnchor, usePdfBytes } from "./pdf-viewer" import { createEsignRequest, importEsignDriveDocument, uploadEsignDocument } from "./requests.functions" import { type BoxPlacement, SignatureBox } from "./signature-box" -type Staged = { id: string; fileName: string; pdf: EsignPdfInfo; folder: { id: string; path: string } | null } +type Staged = { id: string; fileName: string; pdf: EsignPdfInfo; folder: DriveFolder | null } /** `manual`: the user moved the box, so a role no longer places it. */ type SignerDraft = { sub: string; name: string; role: string; placement: BoxPlacement; manual: boolean } @@ -87,7 +87,7 @@ export function NewEsignRequestPage({ const createFn = useServerFn(createEsignRequest) const ids = { title: useId(), file: useId(), emails: useId(), message: useId(), search: useId(), roles: useId() } - const [source, setSource] = useState<"drive" | "upload">(config.drive ? "drive" : "upload") + const [source, setSource] = useState<"drive" | "upload">(config.drives.length > 0 ? "drive" : "upload") const [staged, setStaged] = useState(null) const [base64, setBase64] = useState(null) const [busyItem, setBusyItem] = useState(null) @@ -115,8 +115,8 @@ export function NewEsignRequestPage({ const [search, setSearch] = useState("") const [title, setTitle] = useState("") - const [saveToDrive, setSaveToDrive] = useState(Boolean(config.drive)) - const [folder, setFolder] = useState<{ id: string; path: string } | null>(null) + const [saveToDrive, setSaveToDrive] = useState(config.drives.length > 0) + const [folder, setFolder] = useState(null) const [choosingFolder, setChoosingFolder] = useState(false) const [fileName, setFileName] = useState("") const [emails, setEmails] = useState("") @@ -126,6 +126,9 @@ export function NewEsignRequestPage({ const [attempted, setAttempted] = useState(false) const pages = staged?.pdf.pages ?? [] + // One drive goes by its own name; several are browsed together under "OneDrive". + const drivesName = config.drives.length === 1 ? config.drives[0].label : "OneDrive" + const driveLabel = (id: string) => config.drives.find((d) => d.id === id)?.label ?? "OneDrive" function adopt(next: Staged, content: string) { setStaged(next) @@ -157,11 +160,11 @@ export function NewEsignRequestPage({ } } - async function importFromDrive(itemId: string, from: { id: string; path: string }) { + async function importFromDrive(itemId: string, from: DriveFolder) { setDocError(null) setBusyItem(itemId) try { - const result = await importFn({ data: { itemId } }) + const result = await importFn({ data: { driveId: from.driveId, itemId } }) const content = await contentFn({ data: { documentId: result.document.id } }) adopt( { id: result.document.id, fileName: result.document.fileName, pdf: result.pdf, folder: from }, @@ -251,7 +254,8 @@ export function NewEsignRequestPage({ documentId: staged.id, ordered, signers: signers.map((s) => ({ sub: s.sub, role: s.role.trim() || null, placement: s.placement })), - output: saveToDrive && folder ? { folderId: folder.id, fileName: fileName.trim() } : null, + output: + saveToDrive && folder ? { driveId: folder.driveId, folderId: folder.id, fileName: fileName.trim() } : null, email: recipients.length ? { to: recipients, message: message.trim() || null } : null, }, }) @@ -341,14 +345,14 @@ export function NewEsignRequestPage({ >

    Choose the document

    - {config.drive && ( + {config.drives.length > 0 && (