diff --git a/CHANGELOG.md b/CHANGELOG.md
index 97b6ff8..6a2db12 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -105,6 +105,24 @@
runbooks for multi-tenant IdPs in `docs/operator/oidc-runbooks/` already
documented the field; the GUI now matches.
+- **Approval payload preview (Audit 2026-05-11 A-5).**
+ The MED-10 closure claim ("PARTIAL: raw JSON preview; diff library
+ deferred") was inaccurate — `ApprovalsPage.tsx` rendered no payload
+ at all, so approvers were clicking Approve / Reject without seeing
+ the change they were authorizing. That defeats the entire four-eyes
+ primitive: an approver who can't see what they're approving is
+ rubber-stamping. Each row now carries a Preview toggle that expands
+ an inline panel dispatching by kind: `profile_edit` shows a
+ field-level before/after diff (changed-only rows, red/green cells,
+ `(unset)` sentinel for added/removed fields); `cert_issuance` shows
+ a definition list of CN / SANs / profile / key algo / must-staple /
+ validity (catches the wildcard-against-corp-internal-profile attack
+ at review time); unknown kinds render a generic JSON preview for
+ forward-compat with future approval kinds. The base64-encoded JSON
+ payload is decoded via the new `decodePayload` helper; malformed
+ inputs render an explicit decode-error fallback — silent failure on
+ the payload preview is what produced this bug in the first place.
+
- **Pre-login cookie Path widened from `/auth/oidc/` to `/` (Audit MED-14
follow-on).** Required to satisfy the `__Host-` prefix's `Path=/` rule. The
cookie lifetime is unchanged (10 minutes) and only the callback handler
diff --git a/web/src/pages/auth/ApprovalsPage.test.tsx b/web/src/pages/auth/ApprovalsPage.test.tsx
index f8b2616..07bab79 100644
--- a/web/src/pages/auth/ApprovalsPage.test.tsx
+++ b/web/src/pages/auth/ApprovalsPage.test.tsx
@@ -141,4 +141,246 @@ describe('ApprovalsPage', () => {
renderWithProviders(
. Forward-compat for
+// future approval kinds added to migration 000033's enum.
+//
+// The payload arrives as a base64-encoded JSON string (Go json-encodes
+// []byte to base64 by default; see internal/domain/approval.go:41).
+// decodePayload() handles the decode + parse + null/error guard.
// =============================================================================
+// decodePayload base64-decodes the wire payload and JSON-parses the
+// result. Returns the parsed shape or null on any failure (empty
+// payload, malformed base64, malformed JSON). The component layer
+// renders the generic fallback in that case so the approver still
+// sees *something* — silent failure on the payload preview defeats
+// the entire fix.
+//
+// Exported for test reach.
+export function decodePayload(payload: string | undefined): unknown {
+ if (!payload) return null;
+ try {
+ // atob throws on invalid base64; the surrounding try/catch falls
+ // through to null which the caller renders as "Unable to decode
+ // payload" in the generic branch.
+ const decoded = atob(payload);
+ return JSON.parse(decoded);
+ } catch {
+ return null;
+ }
+}
+
+// =============================================================================
+// PayloadPreview — kind dispatch.
+// =============================================================================
+
+function PayloadPreview({ kind, payload }: { kind: string; payload: string | undefined }) {
+ const decoded = decodePayload(payload);
+
+ if (decoded === null && payload) {
+ return (
+
+ Unable to decode payload (base64 / JSON parse failed). Raw value:{' '}
+ {payload}
+
+ );
+ }
+
+ if (decoded === null) {
+ return (
+
+ No payload attached.
+
+ );
+ }
+
+ if (kind === 'profile_edit') {
+ return ;
+ }
+ if (kind === 'cert_issuance') {
+ return ;
+ }
+ // Forward-compat fallback for future kinds.
+ return (
+
+ {JSON.stringify(decoded, null, 2)}
+
+ );
+}
+
+// =============================================================================
+// ProfileEditDiff — field-level before/after table.
+// =============================================================================
+
+function ProfileEditDiff({ payload }: { payload: unknown }) {
+ const envelope = payload as { before?: Record; after?: Record };
+ const before = envelope?.before ?? {};
+ const after = envelope?.after ?? {};
+ const allKeys = Array.from(new Set([...Object.keys(before), ...Object.keys(after)])).sort();
+ const changedKeys = allKeys.filter(k => JSON.stringify(before[k]) !== JSON.stringify(after[k]));
+
+ if (changedKeys.length === 0) {
+ return (
+
+ No field changes detected.
+
+ );
+ }
+ return (
+
+
+
+ Field
+ Before
+ After
+
+
+
+ {changedKeys.map(k => (
+
+ {k}
+
+ {renderValue(before[k])}
+
+
+ {renderValue(after[k])}
+
+
+ ))}
+
+
+ );
+}
+
+// renderValue stringifies a payload value for the diff cells. Renders
+// undefined as a visually distinct "(unset)" sentinel so the approver
+// can tell a field was added (before=unset) vs flipped (before=value).
+function renderValue(v: unknown) {
+ if (v === undefined) {
+ return (unset);
+ }
+ return JSON.stringify(v);
+}
+
+// =============================================================================
+// IssuanceRequestPreview — definition list of the load-bearing fields.
+// =============================================================================
+
+function IssuanceRequestPreview({ payload }: { payload: unknown }) {
+ const p = payload as {
+ subject_common_name?: string;
+ common_name?: string;
+ sans?: string[];
+ profile_id?: string;
+ key_algorithm?: string;
+ must_staple?: boolean;
+ validity_days?: number;
+ requester_actor_id?: string;
+ };
+ // The certificate-service issuance request uses `subject_common_name`
+ // on some paths and `common_name` on others; surface either.
+ const cn = p.subject_common_name ?? p.common_name ?? '—';
+ return (
+
+ - Common Name
+ - {cn}
+ - SANs
+ - {(p.sans ?? []).join(', ') || '—'}
+ - Profile
+ - {p.profile_id ?? '—'}
+ - Key algorithm
+ - {p.key_algorithm ?? '—'}
+ - Must-staple
+ - {p.must_staple === undefined ? '—' : p.must_staple ? 'yes' : 'no'}
+ - Validity (days)
+ - {p.validity_days ?? '—'}
+ {p.requester_actor_id && (
+ <>
+ - Requester (payload-claimed)
+ - {p.requester_actor_id}
+ >
+ )}
+
+ );
+}
+
export default function ApprovalsPage() {
const me = useAuthMe();
const qc = useQueryClient();
@@ -47,6 +245,11 @@ export default function ApprovalsPage() {
const [actionError, setActionError] = useState(null);
const [busy, setBusy] = useState(null);
+ // Audit 2026-05-11 A-5 — per-row payload preview expansion.
+ // Single-string state (rather than a Set) because most operators
+ // only inspect one approval at a time; widening to multi-select
+ // is a trivial future change if the workflow demands it.
+ const [expandedID, setExpandedID] = useState(null);
const handleApprove = async (req: ApprovalRequest) => {
const note = window.prompt('Approval note (optional):') ?? '';
@@ -137,6 +340,7 @@ export default function ApprovalsPage() {
Profile
Requested by
Created
+ Payload
@@ -144,67 +348,93 @@ export default function ApprovalsPage() {
{items.map(req => {
const isMine = req.requested_by === myID;
const isPending = req.state === 'pending';
+ const isExpanded = expandedID === req.id;
return (
-
- {req.id}
-
-
- {req.kind}
-
-
- {req.profile_id}
-
- {req.requested_by}
- {isMine && (you)}
-
-
- {new Date(req.created_at).toLocaleString()}
-
-
- {isPending && !isMine && (
-
-
-
-
- )}
- {isPending && isMine && (
+
+
+ {req.id}
+
- self-approve blocked
+ {req.kind}
- )}
- {!isPending && (
- {req.state}
- )}
-
-
+
+ {req.profile_id}
+
+ {req.requested_by}
+ {isMine && (you)}
+
+
+ {new Date(req.created_at).toLocaleString()}
+
+
+ {/* Audit 2026-05-11 A-5 — payload preview toggle.
+ Always rendered (even when payload is empty)
+ so the approver can verify there ISN'T a
+ payload they might have missed. */}
+
+
+
+ {isPending && !isMine && (
+
+
+
+
+ )}
+ {isPending && isMine && (
+
+ self-approve blocked
+
+ )}
+ {!isPending && (
+ {req.state}
+ )}
+
+
+ {isExpanded && (
+
+
+
+
+
+ )}
+
);
})}