Two-Person Approve

Review screen for the two-person rule: it shows the exact plan, hides Approve from the requester, and flags edits made after review.

StopsHelps prevent a requester approving their own risky change

Installation

pnpm dlx shadcn@latest add https://bollard-ui.vercel.app/r/two-person-approve.json

The files land in your project. They're yours to edit.

Usage

two-person-approve-usage.tsx
import { TwoPersonApprove, type Person } from "@/components/ui/two-person-approve"

<TwoPersonApprove
  title="Purge old payments in production"
  plan={plan}
  requester={rahul}
  approvers={[rahul, priya, arjun]}
  currentUser={viewer}
  requestedAt={times.requestedAt}
  expiresAt={times.expiresAt}
  onApprove={({ fingerprint }) =>
    new Promise<void>((resolve) => setTimeout(() => {
      setLog(`Approved plan #${fingerprint}.`)
      resolve()
    }, 700))
  }
  onReject={({ reason }) => setLog(`Rejected: ${reason}`)}
/>

What it catches

  • The requester trying to approve their own request
  • Someone not on the approver list
  • A plan edited after review (fingerprint changed), with changed lines marked
  • Rejections without a reason
  • Expired requests

Props

TwoPersonApprove props
PropTypeDefault
title* (required)

What is being asked for, e.g. "Drop table payments_archive".

string—
plan* (required)

Exactly what will run once approved: a command, a diff, a SQL statement.

string—
requester* (required)

Who asked. They can never approve their own request.

Person—
approvers* (required)

Everyone allowed to approve. The requester is excluded even if listed.

Person[]—
currentUser* (required)

The person looking at the card.

Person—
expiresAt* (required)

When the request lapses. Nothing runs after this.

Date | number | string—
requestedAt* (required)

When it was requested; drives the expiry bar.

Date | number | string—
onApprove* (required)

Approve. Receives the note and the fingerprint of the exact plan approved. Return a promise to show progress.

(decision: { reason: string; fingerprint: string }) => void | Promise<void>—
onReject* (required)

Reject. A reason is required.

(decision: { reason: string }) => void | Promise<void>—
selfText

Templates: `{requester}`, `{approvers}`, `{time}` are filled in.

string"You requested this, so someone else has to approve it. A…
notApproverText

Shown when the current user isn't an approver.

string"You're not on the approver list. Ask {approvers}."
changedText

Shown when the plan changed since it was reviewed.

string"{requester} changed the plan after you opened it. Change…
reviewedLabel

Label of the reviewed-fingerprint chip.

string"I've reviewed the change"
expiredText

Shown once the request has expired.

string"Expired at {time}. Nothing ran. {requester} can request …
reasonMissingText

Shown when rejecting without a reason.

string"Add a reason so {requester} knows what to change."
approvedText

Shown after approving.

string"Approved. This exact plan will run now."
rejectedText

Shown after rejecting.

string"Rejected. Nothing will run."
approveLabel

Label of the Approve button.

string"Approve and run"
rejectLabel

Label of the Reject button.

string"Reject"
reasonLabel

Label of the reject-reason field.

string"Reason"
reasonHint

Hint after the reason label.

string"(required to reject)"
changedSrText

Screen-reader prefix for a line that changed since review.

string"Changed: "
cannotApproveSrText

Screen-reader note on the requester chip.

string"cannot approve,"
requesterText

Text on the requester chip.

string"requester"

* required. Also accepts the native props of <section> (className, aria-*, data-*…), passed through to it.

Accessibility

  • Section labelled by its title; plan <pre> is described by the changed-plan notice
  • Changed lines have a sr-only "Changed:" prefix
  • Outcome and expiry use role=status; changed-plan notice is a polite live region
  • Reason textarea has a <label>, aria-invalid when missing, and receives focus on reject

Only what the source does. Test with your own screen reader and content before shipping.

Limitations

This is a UX guard rail. Enforce on the server: Enforce it on the server: reject approvals from the requester or non-approvers, bind the approval to a server-side hash of the exact plan, and refuse expired requests.

  • UI only: who may approve and whether the plan changed must be checked by your server
  • The fingerprint is FNV-1a (8 hex digits), a change detector, not a cryptographic hash
  • Expiry uses the client clock unless you pass now

What it stops

Helps prevent a requester approving their own risky change

Also known as

maker checker · four eyes · approval flow · two person rule · approve request · change management · bank approval · access request

Dependencies

Pairs well with