feat(gui/approvals): payload preview with profile-edit diff + cert-issuance preview (A-5)

The MED-10 closure claim in `cowork/auth-bundles-audit-2026-05-10.md`
said "PARTIAL: raw JSON preview; diff library deferred", but the
2026-05-11 verifier hit `web/src/pages/auth/ApprovalsPage.tsx` and
found ZERO payload rendering — only a doc-comment mention. Approvers
in the GUI were clicking Approve / Reject without seeing the change
they were authorizing.

That defeats the entire two-person-approval primitive. An approver
who can't see what they're approving is rubber-stamping, and a
rubber-stamp workflow is operationally indistinguishable from
auto-approve except for one false promise of integrity. For
`kind=cert_issuance` the payload carries CN / SANs / profile / key
algorithm — the catch-the-wildcard-against-corp-internal-profile
data. For `kind=profile_edit` the payload carries a
`{ before, after }` envelope — the catch-the-must-staple-false-flip
data. Without the preview, both attacks land at the approval boundary
unchallenged.

Closure: each row in the approvals table now carries a `Preview`
toggle that expands an inline panel. Dispatch by `kind`:

  - profile_edit → ProfileEditDiff. Field-level before/after table
    with red/green cell shading; ONLY changed fields render rows
    (unchanged fields collapse to keep the diff focused on what
    needs review); `(unset)` sentinel rendered for added or removed
    fields so the approver can distinguish "this field was added"
    from "this field flipped value." For the flat-object profile
    shape Bundle 1 Phase 9 ships, a field diff carries more signal
    than a unified line diff would and avoids the external-dep cost.

  - cert_issuance → IssuanceRequestPreview. Definition list of CN /
    SANs / profile / key algorithm / must-staple / validity (the
    load-bearing fields an approver needs to gate the issuance
    decision). Accepts both `subject_common_name` and `common_name`
    keys because the certificate-service issuance request uses
    either on different paths.

  - any other kind → generic <pre> JSON dump. Forward-compat for
    future enum additions to migration 000033's CHECK constraint —
    a new approval kind ships rendering through this fallback until
    a kind-specific preview component is written.

The payload arrives over the wire as a base64-encoded JSON string
(Go's json.Marshal renders `[]byte` as base64 by default; see
internal/domain/approval.go:41 where `Payload []byte`). The new
exported `decodePayload(payload)` helper atob()s + JSON.parse()s,
returning null on any failure. Malformed base64 or malformed JSON
renders an explicit "Unable to decode payload" fallback with the
raw value visible to the approver — silent failure on the payload
preview is what produced the original bug in the first place, so
the fix can't have a silent-failure mode.

Component dispatch and base64 decode are also exposed for testing:

  decodePayload(undefined) → null
  decodePayload('') → null
  decodePayload(btoa(JSON.stringify(x))) → x
  decodePayload('!!!not-base64!!!') → null (atob throws)
  decodePayload(btoa('not a json document')) → null (JSON.parse throws)

Each interactive element carries a data-testid so future E2E
coverage can exercise the contract without brittle CSS selectors —
same pattern as Bundle 1's RolesPage.

Tests (13 total, all passing under vitest):

Page-level (8):
  A-5 Preview button toggles the payload panel
  A-5 ProfileEdit kind renders field diff with changed-only rows
  A-5 ProfileEdit before/after values are visible in the diff cells
  A-5 ProfileEdit with no changes renders empty-state
  A-5 CertIssuance renders definition list with SANs + profile + key algo
  A-5 Unknown kind falls back to generic JSON pre block
  A-5 Empty payload renders the "No payload attached" sentinel
  A-5 Malformed base64 payload renders the decode-error fallback

decodePayload pure-function suite (5):
  returns null for undefined input
  returns null for empty string
  round-trips base64-encoded JSON
  returns null on malformed base64
  returns null on valid base64 of non-JSON content

Verify gate green: tsc --noEmit clean; vitest passes all 17 tests
in ApprovalsPage.test.tsx (the 4 pre-existing tests still green —
the new preview row doesn't break the existing same-actor self-lock
+ approve-POST tests; new column header increments the colSpan but
the existing rows render unchanged).

Spec at cowork/auth-bundles-fixes-2026-05-11/05-high-approvals-payload-preview.md.
Audit doc: MED-10 row in `cowork/auth-bundles-audit-2026-05-10.md`
status table flipped from `PARTIAL (raw JSON preview; diff library
deferred)` to `CLOSED 2026-05-11 (A-5)`; the MED-10 section body
gains the A-5 follow-on closure annotation with the false-claim
verification and the three-mode rendering breakdown.
Operator-visible CHANGELOG.md entry under Security explains what
changed and why it matters — approvers can now see what they're
approving.
This commit is contained in:
shankar0123
2026-05-11 10:57:07 +00:00
parent 191384c1d2
commit f502da306f
3 changed files with 549 additions and 59 deletions
+242
View File
@@ -141,4 +141,246 @@ describe('ApprovalsPage', () => {
renderWithProviders(<ApprovalsPage />);
await waitFor(() => expect(screen.getByTestId('approvals-empty')).toBeTruthy());
});
// =============================================================================
// Audit 2026-05-11 A-5 — payload preview.
// =============================================================================
// b64 of '{"before":{"must_staple":false,"max_validity_days":397},"after":{"must_staple":true,"max_validity_days":90}}'
const profileEditPayload = btoa(JSON.stringify({
before: { must_staple: false, max_validity_days: 397, requires_approval: true },
after: { must_staple: true, max_validity_days: 90, requires_approval: true },
}));
const profileEditNoChangesPayload = btoa(JSON.stringify({
before: { must_staple: false },
after: { must_staple: false },
}));
const certIssuancePayload = btoa(JSON.stringify({
subject_common_name: 'api.acme.com',
sans: ['api.acme.com', 'www.acme.com'],
profile_id: 'p-corp-public',
key_algorithm: 'ECDSA-P256',
must_staple: true,
validity_days: 90,
}));
it('A-5 Preview button toggles the payload panel', async () => {
vi.mocked(client.listApprovals).mockResolvedValue({
data: [{
...samplePending[1],
payload: certIssuancePayload,
}],
total: 1,
page: 1,
per_page: 50,
} as never);
vi.mocked(client.authMe).mockResolvedValue(aliceMe);
renderWithProviders(<ApprovalsPage />);
await waitFor(() => screen.getByTestId('approvals-preview-toggle-ar-2'));
// Panel hidden by default.
expect(screen.queryByTestId('approvals-payload-preview-ar-2')).toBeNull();
fireEvent.click(screen.getByTestId('approvals-preview-toggle-ar-2'));
await waitFor(() => screen.getByTestId('approvals-payload-preview-ar-2'));
expect(screen.getByTestId('approval-cert-issuance-preview')).toBeTruthy();
// Toggle off.
fireEvent.click(screen.getByTestId('approvals-preview-toggle-ar-2'));
await waitFor(() => {
expect(screen.queryByTestId('approvals-payload-preview-ar-2')).toBeNull();
});
});
it('A-5 ProfileEdit kind renders field diff with changed-only rows', async () => {
vi.mocked(client.listApprovals).mockResolvedValue({
data: [{
...samplePending[0],
requested_by: 'bob', // not self — Preview button is enabled regardless
payload: profileEditPayload,
}],
total: 1,
page: 1,
per_page: 50,
} as never);
vi.mocked(client.authMe).mockResolvedValue(aliceMe);
renderWithProviders(<ApprovalsPage />);
await waitFor(() => screen.getByTestId('approvals-preview-toggle-ar-1'));
fireEvent.click(screen.getByTestId('approvals-preview-toggle-ar-1'));
await waitFor(() => screen.getByTestId('approval-profile-edit-diff'));
// Only changed fields render rows.
expect(screen.getByTestId('approval-profile-edit-row-must_staple')).toBeTruthy();
expect(screen.getByTestId('approval-profile-edit-row-max_validity_days')).toBeTruthy();
// Unchanged requires_approval should NOT render a row.
expect(screen.queryByTestId('approval-profile-edit-row-requires_approval')).toBeNull();
});
it('A-5 ProfileEdit before/after values are visible in the diff cells', async () => {
vi.mocked(client.listApprovals).mockResolvedValue({
data: [{
...samplePending[0],
requested_by: 'bob',
payload: profileEditPayload,
}],
total: 1,
page: 1,
per_page: 50,
} as never);
vi.mocked(client.authMe).mockResolvedValue(aliceMe);
renderWithProviders(<ApprovalsPage />);
await waitFor(() => screen.getByTestId('approvals-preview-toggle-ar-1'));
fireEvent.click(screen.getByTestId('approvals-preview-toggle-ar-1'));
await waitFor(() => screen.getByTestId('approval-profile-edit-diff'));
const row = screen.getByTestId('approval-profile-edit-row-must_staple');
expect(row.textContent).toContain('false');
expect(row.textContent).toContain('true');
});
it('A-5 ProfileEdit with no changes renders empty-state', async () => {
vi.mocked(client.listApprovals).mockResolvedValue({
data: [{
...samplePending[0],
requested_by: 'bob',
payload: profileEditNoChangesPayload,
}],
total: 1,
page: 1,
per_page: 50,
} as never);
vi.mocked(client.authMe).mockResolvedValue(aliceMe);
renderWithProviders(<ApprovalsPage />);
await waitFor(() => screen.getByTestId('approvals-preview-toggle-ar-1'));
fireEvent.click(screen.getByTestId('approvals-preview-toggle-ar-1'));
await waitFor(() => screen.getByTestId('approval-profile-edit-no-changes'));
expect(screen.queryByTestId('approval-profile-edit-diff')).toBeNull();
});
it('A-5 CertIssuance renders definition list with SANs + profile + key algo', async () => {
vi.mocked(client.listApprovals).mockResolvedValue({
data: [{
...samplePending[1],
payload: certIssuancePayload,
}],
total: 1,
page: 1,
per_page: 50,
} as never);
vi.mocked(client.authMe).mockResolvedValue(aliceMe);
renderWithProviders(<ApprovalsPage />);
await waitFor(() => screen.getByTestId('approvals-preview-toggle-ar-2'));
fireEvent.click(screen.getByTestId('approvals-preview-toggle-ar-2'));
const dl = await waitFor(() => screen.getByTestId('approval-cert-issuance-preview'));
expect(dl.textContent).toContain('api.acme.com');
expect(dl.textContent).toContain('www.acme.com');
expect(dl.textContent).toContain('p-corp-public');
expect(dl.textContent).toContain('ECDSA-P256');
});
it('A-5 Unknown kind falls back to generic JSON pre block', async () => {
const unknownKindPayload = btoa(JSON.stringify({ some_future_field: 42, nested: { ok: true } }));
vi.mocked(client.listApprovals).mockResolvedValue({
data: [{
id: 'ar-3',
kind: 'future_kind_v3' as never, // not in current enum
profile_id: 'prof-x',
requested_by: 'bob',
state: 'pending' as const,
payload: unknownKindPayload,
created_at: '2026-05-09T20:02:00Z',
updated_at: '2026-05-09T20:02:00Z',
}],
total: 1,
page: 1,
per_page: 50,
} as never);
vi.mocked(client.authMe).mockResolvedValue(aliceMe);
renderWithProviders(<ApprovalsPage />);
await waitFor(() => screen.getByTestId('approvals-preview-toggle-ar-3'));
fireEvent.click(screen.getByTestId('approvals-preview-toggle-ar-3'));
const pre = await waitFor(() => screen.getByTestId('approval-payload-generic-json'));
expect(pre.textContent).toContain('some_future_field');
expect(pre.textContent).toContain('42');
});
it('A-5 Empty payload renders the "No payload attached" sentinel', async () => {
vi.mocked(client.listApprovals).mockResolvedValue({
data: [{
...samplePending[1],
payload: undefined,
}],
total: 1,
page: 1,
per_page: 50,
} as never);
vi.mocked(client.authMe).mockResolvedValue(aliceMe);
renderWithProviders(<ApprovalsPage />);
await waitFor(() => screen.getByTestId('approvals-preview-toggle-ar-2'));
fireEvent.click(screen.getByTestId('approvals-preview-toggle-ar-2'));
await waitFor(() => screen.getByTestId('approval-payload-empty'));
});
it('A-5 Malformed base64 payload renders the decode-error fallback', async () => {
vi.mocked(client.listApprovals).mockResolvedValue({
data: [{
...samplePending[1],
payload: '!!!not-valid-base64!!!',
}],
total: 1,
page: 1,
per_page: 50,
} as never);
vi.mocked(client.authMe).mockResolvedValue(aliceMe);
renderWithProviders(<ApprovalsPage />);
await waitFor(() => screen.getByTestId('approvals-preview-toggle-ar-2'));
fireEvent.click(screen.getByTestId('approvals-preview-toggle-ar-2'));
await waitFor(() => screen.getByTestId('approval-payload-decode-error'));
});
});
// =============================================================================
// Pure-function tests on decodePayload (Audit 2026-05-11 A-5).
// =============================================================================
describe('decodePayload', () => {
it('returns null for undefined input', async () => {
const { decodePayload } = await import('./ApprovalsPage');
expect(decodePayload(undefined)).toBeNull();
});
it('returns null for empty string', async () => {
const { decodePayload } = await import('./ApprovalsPage');
expect(decodePayload('')).toBeNull();
});
it('round-trips base64-encoded JSON', async () => {
const { decodePayload } = await import('./ApprovalsPage');
const original = { foo: 'bar', n: 42, arr: [1, 2, 3] };
const encoded = btoa(JSON.stringify(original));
expect(decodePayload(encoded)).toEqual(original);
});
it('returns null on malformed base64', async () => {
const { decodePayload } = await import('./ApprovalsPage');
expect(decodePayload('!!!not-base64!!!')).toBeNull();
});
it('returns null on valid base64 of non-JSON content', async () => {
const { decodePayload } = await import('./ApprovalsPage');
expect(decodePayload(btoa('not a json document'))).toBeNull();
});
});
+289 -59
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { Fragment, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
listApprovals,
@@ -25,14 +25,212 @@ import ErrorState from '../../components/ErrorState';
// are both populated; metadata.common_name surfaces.
// - profile_edit — Bundle 1 Phase 9 closure; cert + job are empty,
// payload carries the pending profile diff (rendered as a
// collapsible JSON preview).
// field-level before/after diff via PayloadPreview).
//
// Same-actor self-approve is rejected server-side with HTTP 403; the
// page surfaces the error inline. Approve / reject actions are HIDDEN
// when the caller's actor_id equals requested_by, so the operator
// can't even click the wrong button.
//
// Audit 2026-05-11 A-5 — payload preview. The MED-10 closure claim
// said the GUI rendered a "raw JSON preview" but the verifier found
// zero payload rendering — approvers had to click Approve blind. This
// page now renders an inline expandable panel per row that dispatches
// to one of three components by `kind`:
//
// - kind=profile_edit → ProfileEditDiff: field-level before/after
// table. The whole point of the four-eyes principle is "the
// approver sees what's changing"; rendering this as a flat field
// diff is materially more useful than a unified line-diff for the
// small flat-object profile shape.
// - kind=cert_issuance → IssuanceRequestPreview: CN / SANs /
// profile / key algo / must-staple / validity. Catches the
// wildcard-against-corp-internal-profile attack at review time.
// - any other kind → generic JSON <pre>. 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 (
<div
className="text-xs text-red-700"
data-testid="approval-payload-decode-error"
>
Unable to decode payload (base64 / JSON parse failed). Raw value:{' '}
<code className="break-all">{payload}</code>
</div>
);
}
if (decoded === null) {
return (
<div
className="text-xs text-ink-muted italic"
data-testid="approval-payload-empty"
>
No payload attached.
</div>
);
}
if (kind === 'profile_edit') {
return <ProfileEditDiff payload={decoded} />;
}
if (kind === 'cert_issuance') {
return <IssuanceRequestPreview payload={decoded} />;
}
// Forward-compat fallback for future kinds.
return (
<pre
className="text-xs bg-surface-muted p-3 rounded overflow-x-auto"
data-testid="approval-payload-generic-json"
>
{JSON.stringify(decoded, null, 2)}
</pre>
);
}
// =============================================================================
// ProfileEditDiff — field-level before/after table.
// =============================================================================
function ProfileEditDiff({ payload }: { payload: unknown }) {
const envelope = payload as { before?: Record<string, unknown>; after?: Record<string, unknown> };
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 (
<div
className="text-xs text-ink-muted italic"
data-testid="approval-profile-edit-no-changes"
>
No field changes detected.
</div>
);
}
return (
<table
className="text-xs w-full border border-surface-border"
data-testid="approval-profile-edit-diff"
>
<thead className="bg-surface-muted">
<tr>
<th className="text-left px-2 py-1 font-medium">Field</th>
<th className="text-left px-2 py-1 font-medium">Before</th>
<th className="text-left px-2 py-1 font-medium">After</th>
</tr>
</thead>
<tbody>
{changedKeys.map(k => (
<tr
key={k}
className="border-t border-surface-border"
data-testid={`approval-profile-edit-row-${k}`}
>
<td className="px-2 py-1 font-mono"><code>{k}</code></td>
<td className="px-2 py-1 font-mono break-all bg-red-50">
{renderValue(before[k])}
</td>
<td className="px-2 py-1 font-mono break-all bg-green-50">
{renderValue(after[k])}
</td>
</tr>
))}
</tbody>
</table>
);
}
// 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 <span className="text-ink-muted italic">(unset)</span>;
}
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 (
<dl
className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs"
data-testid="approval-cert-issuance-preview"
>
<dt className="text-ink-muted">Common Name</dt>
<dd className="font-mono">{cn}</dd>
<dt className="text-ink-muted">SANs</dt>
<dd className="font-mono">{(p.sans ?? []).join(', ') || '—'}</dd>
<dt className="text-ink-muted">Profile</dt>
<dd className="font-mono">{p.profile_id ?? '—'}</dd>
<dt className="text-ink-muted">Key algorithm</dt>
<dd className="font-mono">{p.key_algorithm ?? '—'}</dd>
<dt className="text-ink-muted">Must-staple</dt>
<dd>{p.must_staple === undefined ? '—' : p.must_staple ? 'yes' : 'no'}</dd>
<dt className="text-ink-muted">Validity (days)</dt>
<dd>{p.validity_days ?? '—'}</dd>
{p.requester_actor_id && (
<>
<dt className="text-ink-muted">Requester (payload-claimed)</dt>
<dd className="font-mono">{p.requester_actor_id}</dd>
</>
)}
</dl>
);
}
export default function ApprovalsPage() {
const me = useAuthMe();
const qc = useQueryClient();
@@ -47,6 +245,11 @@ export default function ApprovalsPage() {
const [actionError, setActionError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(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<string | null>(null);
const handleApprove = async (req: ApprovalRequest) => {
const note = window.prompt('Approval note (optional):') ?? '';
@@ -137,6 +340,7 @@ export default function ApprovalsPage() {
<th className="text-left px-3 py-2">Profile</th>
<th className="text-left px-3 py-2">Requested by</th>
<th className="text-left px-3 py-2">Created</th>
<th className="px-3 py-2 w-24">Payload</th>
<th className="px-3 py-2 w-44"></th>
</tr>
</thead>
@@ -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 (
<tr
key={req.id}
className="border-t border-surface-border align-top"
data-testid={`approvals-row-${req.id}`}
>
<td className="px-3 py-2 font-mono text-xs">{req.id}</td>
<td className="px-3 py-2">
<span
className={
'inline-block px-2 py-0.5 rounded text-xs ' +
(req.kind === 'profile_edit'
? 'bg-amber-100 text-amber-800'
: 'bg-surface-muted')
}
>
{req.kind}
</span>
</td>
<td className="px-3 py-2 font-mono text-xs">{req.profile_id}</td>
<td className="px-3 py-2 text-xs">
{req.requested_by}
{isMine && <span className="ml-2 text-amber-700">(you)</span>}
</td>
<td className="px-3 py-2 text-xs text-ink-muted">
{new Date(req.created_at).toLocaleString()}
</td>
<td className="px-3 py-2 text-right">
{isPending && !isMine && (
<div className="flex gap-1 justify-end">
<button
className="btn btn-primary text-xs"
onClick={() => handleApprove(req)}
disabled={busy === req.id}
data-testid={`approvals-approve-${req.id}`}
>
Approve
</button>
<button
className="btn btn-ghost text-xs"
onClick={() => handleReject(req)}
disabled={busy === req.id}
data-testid={`approvals-reject-${req.id}`}
>
Reject
</button>
</div>
)}
{isPending && isMine && (
<Fragment key={req.id}>
<tr
className="border-t border-surface-border align-top"
data-testid={`approvals-row-${req.id}`}
>
<td className="px-3 py-2 font-mono text-xs">{req.id}</td>
<td className="px-3 py-2">
<span
className="text-xs text-ink-muted italic"
data-testid={`approvals-self-locked-${req.id}`}
className={
'inline-block px-2 py-0.5 rounded text-xs ' +
(req.kind === 'profile_edit'
? 'bg-amber-100 text-amber-800'
: 'bg-surface-muted')
}
>
self-approve blocked
{req.kind}
</span>
)}
{!isPending && (
<span className="text-xs text-ink-muted">{req.state}</span>
)}
</td>
</tr>
</td>
<td className="px-3 py-2 font-mono text-xs">{req.profile_id}</td>
<td className="px-3 py-2 text-xs">
{req.requested_by}
{isMine && <span className="ml-2 text-amber-700">(you)</span>}
</td>
<td className="px-3 py-2 text-xs text-ink-muted">
{new Date(req.created_at).toLocaleString()}
</td>
<td className="px-3 py-2">
{/* 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. */}
<button
className="btn btn-ghost text-xs"
onClick={() => setExpandedID(isExpanded ? null : req.id)}
data-testid={`approvals-preview-toggle-${req.id}`}
aria-expanded={isExpanded}
>
{isExpanded ? 'Hide' : 'Preview'}
</button>
</td>
<td className="px-3 py-2 text-right">
{isPending && !isMine && (
<div className="flex gap-1 justify-end">
<button
className="btn btn-primary text-xs"
onClick={() => handleApprove(req)}
disabled={busy === req.id}
data-testid={`approvals-approve-${req.id}`}
>
Approve
</button>
<button
className="btn btn-ghost text-xs"
onClick={() => handleReject(req)}
disabled={busy === req.id}
data-testid={`approvals-reject-${req.id}`}
>
Reject
</button>
</div>
)}
{isPending && isMine && (
<span
className="text-xs text-ink-muted italic"
data-testid={`approvals-self-locked-${req.id}`}
>
self-approve blocked
</span>
)}
{!isPending && (
<span className="text-xs text-ink-muted">{req.state}</span>
)}
</td>
</tr>
{isExpanded && (
<tr
className="border-t border-surface-border bg-surface-muted/40"
data-testid={`approvals-payload-preview-${req.id}`}
>
<td colSpan={7} className="px-3 py-3">
<PayloadPreview kind={req.kind} payload={req.payload} />
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>