mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:21:37 +00:00
Merge Fix 05 (HIGH A-5): approval payload preview with profile-edit diff + cert-issuance preview
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,9 +348,10 @@ export default function ApprovalsPage() {
|
||||
{items.map(req => {
|
||||
const isMine = req.requested_by === myID;
|
||||
const isPending = req.state === 'pending';
|
||||
const isExpanded = expandedID === req.id;
|
||||
return (
|
||||
<Fragment key={req.id}>
|
||||
<tr
|
||||
key={req.id}
|
||||
className="border-t border-surface-border align-top"
|
||||
data-testid={`approvals-row-${req.id}`}
|
||||
>
|
||||
@@ -171,6 +376,20 @@ export default function ApprovalsPage() {
|
||||
<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">
|
||||
@@ -205,6 +424,17 @@ export default function ApprovalsPage() {
|
||||
)}
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user