diff --git a/docs/design.md b/docs/design.md index ea05f18..8a7f7ed 100644 --- a/docs/design.md +++ b/docs/design.md @@ -447,5 +447,9 @@ Rules that the templates above don't imply. 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). + 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. - **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 0464a32..4282a81 100644 --- a/docs/esign/TRACKER.md +++ b/docs/esign/TRACKER.md @@ -123,6 +123,9 @@ Legend: ☐ todo · ◐ in progress · ☑ done mark, covered by it); the person appears once in the dashboard (backend#75, admin PR, sign-agent#6). - ☑ Sign several documents with one PIN: "Sign N together" in To sign opens a document strip to check each, then one 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). +- ☐ 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/docs/esign/contracts.md b/docs/esign/contracts.md index e60e510..f4cfb83 100644 --- a/docs/esign/contracts.md +++ b/docs/esign/contracts.md @@ -82,6 +82,12 @@ browse OneDrive). Requests are visible to their signers and creator. update); each signer has 1–20 places, at least one a signature, and appears once in the request. - `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.drive.access()` (`esign:drives:manage`) → `{drives: [{id, label, request: {roles, permissions}, sign: {roles, +permissions}, updatedBySub, updatedAt}], catalog: {roles: [{key, name}], permissions: [{key, name}]}}`; + `esign.drive.setAccess({driveId, request, sign})`. A rule names roles and permissions, any of which grants it; an + empty rule keeps the default (request: `esign:requests:manage`; sign: everyone who signs). `request` covers browsing + the drive, using it in a request and seeing its requests; `sign` covers being chosen as signer and signing. Requests + record `drives` (source and output); `esign.config().drives` and `esign.signers.list()[].drives` are filtered. - `esign.jobs.create({items: [{requestId, places?}]})` (1–10, all or none) → `[{requestId, jobId, ticket, expiresAt}]`, opened together in one agent window; `places` moves the caller's boxes (one position per place) before signing. `esign.jobs.statuses({jobIds})`, `esign.jobs.cancel({jobIds})`. diff --git a/package.json b/package.json index 83f346a..6997db0 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "dependencies": { "@base-ui/react": "^1.6.0", "@dnd-kit/react": "^0.4.0", - "@polinetwork/backend": "^1.9.0", + "@polinetwork/backend": "^1.10.0", "@t3-oss/env-core": "^0.13.10", "@tanstack/charts": "1.1.0", "@tanstack/react-charts": "1.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5f345b..a97a2b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,8 +26,8 @@ importers: specifier: ^0.4.0 version: 0.4.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@polinetwork/backend': - specifier: ^1.9.0 - version: 1.9.0 + specifier: ^1.10.0 + version: 1.10.0 '@t3-oss/env-core': specifier: ^0.13.10 version: 0.13.11(typescript@6.0.3)(zod@4.3.5) @@ -1034,8 +1034,8 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} - '@polinetwork/backend@1.9.0': - resolution: {integrity: sha512-lh8cLYOpEiPN3oqtmq0EWaxwKUjvaDqsBG0gaOg/2KrkICoF0jDj00LCIn66hf02Boe9jq0G7skyfCiUo2A+6w==} + '@polinetwork/backend@1.10.0': + resolution: {integrity: sha512-V2nCsISh/hmRtTNgjXKlmtACtIKiWSEiVkhwbdMul+WeQ1MnG9VDoSga9F4NJIRBLQkf23HrIhB41/FdcE7hNA==} '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -5952,7 +5952,7 @@ snapshots: '@pkgr/core@0.3.6': {} - '@polinetwork/backend@1.9.0': {} + '@polinetwork/backend@1.10.0': {} '@polka/url@1.0.0-next.29': {} diff --git a/src/components/shell/nav.ts b/src/components/shell/nav.ts index fe112ab..747d07a 100644 --- a/src/components/shell/nav.ts +++ b/src/components/shell/nav.ts @@ -9,6 +9,7 @@ import { FolderKanban, FolderTree, Globe, + HardDrive, Inbox, LayoutDashboard, type LucideIcon, @@ -86,6 +87,7 @@ export type PageMatch = | { kind: "esign-request"; id: string } | { kind: "esign-new" } | { kind: "esign-sign" } + | { kind: "esign-drives" } | { kind: "esign-setup" } | { kind: "reports-open" } | { kind: "reports-closed" } @@ -261,6 +263,13 @@ const esign: Service = { permission: "esign:sign", searchPlaceholder: "Search by title or signer…", }, + { + id: "drives", + title: "Drives", + icon: HardDrive, + path: "/dashboard/esign/drives", + permission: "esign:drives:manage", + }, { id: "setup", title: "Setup", icon: MonitorDown, path: "/dashboard/esign/setup", permission: "esign:sign" }, ], } @@ -357,6 +366,7 @@ export function matchPath(pathname: string): PageMatch { if (second === "requests") return { kind: "esign-requests" } if (second === "new") return { kind: "esign-new" } if (second === "sign") return { kind: "esign-sign" } + if (second === "drives") return { kind: "esign-drives" } if (second === "setup") return { kind: "esign-setup" } } if (second === "requests" && third !== undefined && /^[0-9a-f-]{36}$/i.test(third)) { @@ -403,6 +413,7 @@ export function serviceFor(match: PageMatch): Service { case "esign-request": case "esign-new": case "esign-sign": + case "esign-drives": case "esign-setup": return esign case "reports-open": @@ -452,6 +463,8 @@ export function sectionFor(match: PageMatch): Section | null { case "esign-request": case "esign-new": return section(esign, "requests") + case "esign-drives": + return section(esign, "drives") case "esign-setup": return section(esign, "setup") case "reports-open": diff --git a/src/features/esign/drives-page.tsx b/src/features/esign/drives-page.tsx new file mode 100644 index 0000000..7e09636 --- /dev/null +++ b/src/features/esign/drives-page.tsx @@ -0,0 +1,326 @@ +import { useRouter } from "@tanstack/react-router" +import { useServerFn } from "@tanstack/react-start" +import { KeyRound, Plus, Search, TriangleAlert, UsersRound, X } from "lucide-react" +import { useId, useMemo, useState } from "react" + +import { fieldControl, LoadingButton, SectionCard } from "@/components/primitives" +import { appToast, PageBar, PageContent, useCan } from "@/components/shell" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { formatDateTime } from "@/lib/format" +import { cn } from "@/lib/utils" + +import { setEsignDriveAccess } from "./drives.functions" + +type Rule = { roles: string[]; permissions: string[] } +type Entry = { key: string; name: string } +type Catalog = { roles: Entry[]; permissions: Entry[] } +type DriveAccess = { + id: string + label: string + request: Rule + sign: Rule + updatedAt: Date | string | null +} + +const same = (a: Rule, b: Rule) => a.roles.join() === b.roles.join() && a.permissions.join() === b.permissions.join() + +/** Signatures › Drives: which roles and permissions may use each OneDrive/SharePoint drive. */ +export function EsignDrivesPage({ drives, catalog }: { drives: DriveAccess[]; catalog: Catalog }) { + return ( + <> + + +
+ {drives.length === 0 && ( + +

No drives are configured on the backend.

+
+ )} + {drives.map((drive) => ( + + ))} +
+
+ + ) +} + +function DriveCard({ drive, catalog }: { drive: DriveAccess; catalog: Catalog }) { + const router = useRouter() + const canEdit = useCan("esign:drives:manage") + const setFn = useServerFn(setEsignDriveAccess) + const [request, setRequest] = useState(drive.request) + const [sign, setSign] = useState(drive.sign) + const [saving, setSaving] = useState(false) + const dirty = !same(request, drive.request) || !same(sign, drive.sign) + + async function save() { + setSaving(true) + try { + await setFn({ data: { driveId: drive.id, request, sign } }) + } catch (error) { + console.error(error) + appToast.error(error instanceof Error ? error.message : "Couldn't save the access.") + setSaving(false) + return + } + try { + await router.invalidate({ sync: true }) + } catch (error) { + console.error(error) + } + setSaving(false) + appToast.success(`Access to ${drive.label} saved.`) + } + + return ( + + {dirty && ( + + )} + + Save + + + ) : undefined + } + > +
+ + + {drive.updatedAt && ( +

Changed on {formatDateTime(new Date(drive.updatedAt))}

+ )} +
+
+ ) +} + +function RuleEditor({ + title, + help, + fallback, + rule, + catalog, + disabled, + onChange, +}: { + title: string + help: string + /** Who qualifies while the rule is empty. */ + fallback: string + rule: Rule + catalog: Catalog + disabled: boolean + onChange: (rule: Rule) => void +}) { + const searchId = useId() + const [query, setQuery] = useState("") + const [open, setOpen] = useState(false) + const roleNames = useMemo(() => new Map(catalog.roles.map((r) => [r.key, r.name])), [catalog]) + const permissionNames = useMemo(() => new Map(catalog.permissions.map((p) => [p.key, p.name])), [catalog]) + + const needle = query.trim().toLocaleLowerCase() + const matches = (entry: Entry) => needle === "" || `${entry.name} ${entry.key}`.toLocaleLowerCase().includes(needle) + const roles = catalog.roles.filter((r) => !rule.roles.includes(r.key) && matches(r)) + const permissions = catalog.permissions.filter((p) => !rule.permissions.includes(p.key) && matches(p)) + const empty = rule.roles.length === 0 && rule.permissions.length === 0 + + function add(kind: keyof Rule, key: string) { + onChange({ ...rule, [kind]: [...rule[kind], key].sort() }) + setQuery("") + } + + function remove(kind: keyof Rule, key: string) { + onChange({ ...rule, [kind]: rule[kind].filter((k) => k !== key) }) + } + + return ( +
+
+

{title}

+

{help}

+
+
    + {empty &&
  • {fallback}
  • } + {rule.roles.map((key) => ( + remove("roles", key)} + /> + ))} + {rule.permissions.map((key) => ( + remove("permissions", key)} + /> + ))} +
+ {!empty && ( +

+ Anyone with at least one of these roles or permissions qualifies. +

+ )} + {!disabled && ( +
+ + setOpen(true)} + onClick={() => setOpen(true)} + onBlur={() => setOpen(false)} + onChange={(event) => { + setQuery(event.target.value) + setOpen(true) + }} + /> + {open && ( +
+ add("roles", key)} /> + add("permissions", key)} + /> + {roles.length === 0 && permissions.length === 0 && ( +

+ {catalog.roles.length + catalog.permissions.length === 0 + ? "The identity provider hasn't shared its roles and permissions yet." + : "Nothing matches."} +

+ )} +
+ )} +
+ )} +
+ ) +} + +function OptionGroup({ + label, + icon: Icon, + entries, + onPick, +}: { + label: string + icon: typeof KeyRound + entries: Entry[] + onPick: (key: string) => void +}) { + if (entries.length === 0) return null + return ( +
+

{label}

+ {entries.map((entry) => ( + + ))} +
+ ) +} + +function AccessChip({ + icon: Icon, + label, + detail, + unknown, + disabled, + onRemove, +}: { + icon: typeof KeyRound + label: string + detail: string + /** Not in the identity provider's catalog: renamed or deleted there, so nobody holds it. */ + unknown: boolean + disabled: boolean + onRemove: () => void +}) { + return ( +
  • + {unknown ? ( + + ) : ( + + )} + {label} + {!disabled && ( + + )} +
  • + ) +} diff --git a/src/features/esign/drives.functions.ts b/src/features/esign/drives.functions.ts new file mode 100644 index 0000000..4b33a06 --- /dev/null +++ b/src/features/esign/drives.functions.ts @@ -0,0 +1,17 @@ +import { createServerFn } from "@tanstack/react-start" +import { z } from "zod" + +import { esignDrivesManageMiddleware } from "@/server/auth.middleware" + +const key = z.string().regex(/^[A-Za-z0-9:._-]{1,100}$/) +const rule = z.object({ roles: z.array(key).max(50), permissions: z.array(key).max(50) }) + +/** Every drive's access rules, with the roles and permissions a rule can name. */ +export const getEsignDriveAccess = createServerFn() + .middleware([esignDrivesManageMiddleware]) + .handler(({ context }) => context.backend.esign.drive.access.query()) + +export const setEsignDriveAccess = createServerFn({ method: "POST" }) + .middleware([esignDrivesManageMiddleware]) + .validator(z.object({ driveId: z.string().min(1), request: rule, sign: rule })) + .handler(({ data, context }) => context.backend.esign.drive.setAccess.mutate(data)) diff --git a/src/features/esign/new-request-page.tsx b/src/features/esign/new-request-page.tsx index fec5bc6..749697f 100644 --- a/src/features/esign/new-request-page.tsx +++ b/src/features/esign/new-request-page.tsx @@ -12,6 +12,7 @@ import { Search, Stamp, Trash2, + TriangleAlert, UserPlus, } from "lucide-react" import { type DragEvent, useEffect, useId, useMemo, useState } from "react" @@ -276,12 +277,23 @@ export function NewEsignRequestPage({ setPlacing(null) } + // The drives the request uses decide who may sign it: where the PDF came from, and where it is saved. + const usedDrives = [ + ...new Set([staged?.folder?.driveId, saveToDrive ? folder?.driveId : undefined].filter((d) => d !== undefined)), + ] + const blockedDrive = (sub: string) => { + const person = people.find((p) => p.sub === sub) + return usedDrives.find((d) => !person?.drives.includes(d)) + } const available = useMemo(() => { const needle = search.trim().toLocaleLowerCase() return people .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]) + }, [people, signers, search, usedDrives.join()]) + const outsiders = signers.filter((s) => blockedDrive(s.sub) !== undefined) + const eligible = people.filter((p) => usedDrives.every((d) => p.drives.includes(d))) const recipients = emails .split(/[\s,;]+/) @@ -295,6 +307,7 @@ export function NewEsignRequestPage({ saveToDrive && !folder && "Choose the OneDrive folder.", saveToDrive && fileName.trim() === "" && "Name the signed file.", badEmail && `“${badEmail}” isn't an email address.`, + ...outsiders.map((s) => `${s.name} can't sign documents of ${driveLabel(blockedDrive(s.sub) ?? "")}.`), ].filter((p): p is string => Boolean(p)) async function submit() { @@ -537,6 +550,12 @@ export function NewEsignRequestPage({ ) }} /> + {blockedDrive(s.sub) !== undefined && ( +

    + + Can't sign documents of {driveLabel(blockedDrive(s.sub) ?? "")} +

    + )} - {people.length <= signers.length - ? "Everyone who can sign is added. People appear here after they open Signatures once." - : "Nobody matches."} + {search.trim() !== "" + ? "Nobody matches." + : eligible.length === 0 + ? `Nobody can sign documents of ${usedDrives.map(driveLabel).join(" and ")}. Who can is set in Signatures › Drives.` + : usedDrives.length > 0 + ? "Everyone allowed to sign these documents is added." + : "Everyone who can sign is added. People appear here after they open Signatures once."} )} + {available.length > 0 && eligible.length < people.length && ( +

    + Only people allowed to sign documents of {usedDrives.map(driveLabel).join(" and ")} are listed. +

    + )} {signers.length > 1 && (