mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
feat(ui): Action approval card v2 — stale variant + bindings + signed payload + mobile (PAP-10787)
New ui/src/components/actions/ActionCard.tsx implementing the issue-thread approval card per the PAP-10771 v2 spec (surfaces 11/12/99): - Real Card: avatar + "Coder requested approval to call" header + outcome pill; body with tool name + risk/capability pills; bindings table (application + manifest version, connection, catalog sha256, signed payload sha256); JSON input mono block; "Why I'm asking" referencing the policy by number; footrow with expiry icon + Edit & re-sign / Deny / Approve. - Stale variant (PAP-10400 hardening): amber border, catalog-hash-mismatch banner, previous catalog hash struck through next to the current one, and Approve rendered disabled until re-issuance. - Mobile (390×844) via ActionCardMobile: identical content, full-width buttons stacked Approve / Deny / Edit & re-sign, 70px bindings label column. - New exported BindingsTable (two-column mono key/value) lives inside the card and is reusable in the audit row drilldown. Showcased in the design guide and a Storybook story; unit tests cover the PAP-10400 security states (signed payload + expiry always shown, stale Approve disabled, struck-through prior hash) and the mobile button order. Typecheck and the new component tests pass. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
378bcafb6b
commit
d28508fbe3
4 files changed
+644
No files matched your search
@@ -0,0 +1,141 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { ReactElement } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ActionCard, ActionCardMobile, BindingsTable, shortSha } from "./ActionCard";
|
||||
|
||||
// EnforcementBanner pulls in a react-query hook; the stale variant only needs
|
||||
// its presentational copy, so stub it to keep the test free of a QueryClient.
|
||||
vi.mock("@/components/EnforcementBanner", () => ({
|
||||
EnforcementBanner: ({ title, body }: { title?: string; body?: string }) => (
|
||||
<div data-testid="stale-banner">
|
||||
<span>{title}</span>
|
||||
<span>{body}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) act(() => root?.unmount());
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
function render(element: ReactElement) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => root?.render(element));
|
||||
return container;
|
||||
}
|
||||
|
||||
const baseBinding = {
|
||||
application: "Slack",
|
||||
manifestVersion: "2.4.1",
|
||||
connection: "https://slack.com/api",
|
||||
catalogSha256: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
|
||||
payloadSha256: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
|
||||
};
|
||||
|
||||
const baseProps = {
|
||||
toolName: "slack.post_message",
|
||||
risk: "medium" as const,
|
||||
isWrite: true,
|
||||
binding: baseBinding,
|
||||
input: { channel: "#launch", text: "hi" },
|
||||
reason: "Write-capable tool.",
|
||||
policyNumber: 7,
|
||||
expiresInLabel: "expires in 23h 51m",
|
||||
};
|
||||
|
||||
function approveButton(c: HTMLElement): HTMLButtonElement {
|
||||
const btn = Array.from(c.querySelectorAll("button")).find((b) => b.textContent?.trim() === "Approve");
|
||||
if (!btn) throw new Error("Approve button not found");
|
||||
return btn as HTMLButtonElement;
|
||||
}
|
||||
|
||||
describe("ActionCard", () => {
|
||||
it("surfaces the signed payload sha256 and expiry (PAP-10400)", () => {
|
||||
const c = render(<ActionCard {...baseProps} />);
|
||||
expect(c.textContent).toContain(shortSha(baseBinding.payloadSha256));
|
||||
expect(c.textContent).toContain("signed");
|
||||
expect(c.textContent).toContain("expires in 23h 51m");
|
||||
});
|
||||
|
||||
it("references the policy number in the explanation", () => {
|
||||
const c = render(<ActionCard {...baseProps} />);
|
||||
expect(c.textContent).toContain("Policy #7");
|
||||
});
|
||||
|
||||
it("enables Approve on the pending variant and fires the handler", () => {
|
||||
const onApprove = vi.fn();
|
||||
const c = render(<ActionCard {...baseProps} onApprove={onApprove} />);
|
||||
const btn = approveButton(c);
|
||||
expect(btn.disabled).toBe(false);
|
||||
act(() => btn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(onApprove).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("disables Approve and shows the catalog mismatch on the stale variant", () => {
|
||||
const c = render(
|
||||
<ActionCard
|
||||
{...baseProps}
|
||||
variant="stale"
|
||||
binding={{
|
||||
...baseBinding,
|
||||
catalogSha256: "sha256:7d793037a0760186574b0282f2f435e7deadbeefcafef00dba5eba5eba5eba5e",
|
||||
previousCatalogSha256: baseBinding.catalogSha256,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(approveButton(c).disabled).toBe(true);
|
||||
expect(c.querySelector('[data-testid="stale-banner"]')).not.toBeNull();
|
||||
// Previous (now-invalid) hash is struck through next to the current one.
|
||||
const struck = c.querySelector(".line-through");
|
||||
expect(struck?.textContent).toContain(shortSha(baseBinding.catalogSha256));
|
||||
});
|
||||
|
||||
it("stacks buttons Approve / Deny / Edit & re-sign on mobile with a 70px label column", () => {
|
||||
const c = render(<ActionCardMobile {...baseProps} />);
|
||||
const labels = c.querySelectorAll("dt");
|
||||
expect(labels.length).toBeGreaterThan(0);
|
||||
expect((labels[0] as HTMLElement).style.width).toBe("70px");
|
||||
|
||||
const buttonText = Array.from(c.querySelectorAll("button")).map((b) => b.textContent?.trim());
|
||||
const order = buttonText.filter((t) => t === "Approve" || t === "Deny" || t?.startsWith("Edit"));
|
||||
expect(order[0]).toBe("Approve");
|
||||
expect(order[1]).toBe("Deny");
|
||||
expect(order[2]).toContain("Edit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BindingsTable", () => {
|
||||
it("renders mono rows with the default 132px label column", () => {
|
||||
const c = render(
|
||||
<BindingsTable rows={[{ label: "Catalog", value: "sha256:abc", mono: true }]} />,
|
||||
);
|
||||
const dt = c.querySelector("dt") as HTMLElement;
|
||||
expect(dt.style.width).toBe("132px");
|
||||
expect(c.querySelector("dd")?.className).toContain("font-mono");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortSha", () => {
|
||||
it("truncates a long sha to the review form", () => {
|
||||
expect(shortSha("sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")).toBe(
|
||||
"sha256:9f86d08188…f00a08",
|
||||
);
|
||||
});
|
||||
it("leaves a short sha intact", () => {
|
||||
expect(shortSha("sha256:abcd")).toBe("sha256:abcd");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Clock, Pencil, ShieldCheck } from "lucide-react";
|
||||
import type { ToolRiskLevel } from "@paperclipai/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { EnforcementBanner } from "@/components/EnforcementBanner";
|
||||
import { CapabilityBadges, DecisionBadge, RiskBadge } from "@/pages/tools/shared";
|
||||
|
||||
/**
|
||||
* Action approval card (PAP-10787 / PAP-10778, surfaces 11/12/99).
|
||||
*
|
||||
* The card an agent's run posts into the issue thread when a governed tool
|
||||
* call needs human approval. Two hard requirements from the PAP-10400 security
|
||||
* hardening must never regress:
|
||||
*
|
||||
* 1. The **signed payload sha256 + expiry** are always surfaced, so a reviewer
|
||||
* approves exactly the bytes that were signed and can see when the request
|
||||
* lapses.
|
||||
* 2. The server-driven **stale** variant disables Approve and shows the
|
||||
* catalog-hash mismatch (previous hash struck through next to current), so
|
||||
* re-issuance is visibly required — an approval can never be granted
|
||||
* against a catalog the orchestrator no longer trusts.
|
||||
*/
|
||||
|
||||
export type ActionCardVariant = "pending" | "stale";
|
||||
|
||||
/** One key/value row in the {@link BindingsTable}. */
|
||||
export interface BindingRow {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
/** Render the value in the mono catalog/hash treatment. */
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-column key/value block with mono values. Lives inside {@link ActionCard}
|
||||
* and is reused in the audit row drilldown, so it takes raw rows rather than a
|
||||
* baked-in binding shape. `labelWidth` narrows to 70px on mobile (surface 99).
|
||||
*/
|
||||
export function BindingsTable({
|
||||
rows,
|
||||
labelWidth = 132,
|
||||
className,
|
||||
}: {
|
||||
rows: BindingRow[];
|
||||
labelWidth?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<dl className={cn("divide-y divide-border rounded-md border border-border text-sm", className)}>
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="flex gap-3 px-3 py-2">
|
||||
<dt
|
||||
className="shrink-0 pt-0.5 text-xs font-medium uppercase tracking-normal text-muted-foreground"
|
||||
style={{ width: labelWidth }}
|
||||
>
|
||||
{row.label}
|
||||
</dt>
|
||||
<dd className={cn("min-w-0 flex-1 break-all text-foreground", row.mono && "font-mono text-xs")}>
|
||||
{row.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
/** Truncate a sha to the standard `sha256:abcd…1234` review form. */
|
||||
export function shortSha(sha: string): string {
|
||||
const hex = sha.replace(/^sha256:/, "");
|
||||
if (hex.length <= 16) return `sha256:${hex}`;
|
||||
return `sha256:${hex.slice(0, 10)}…${hex.slice(-6)}`;
|
||||
}
|
||||
|
||||
export interface ActionCardBinding {
|
||||
/** Application the tool belongs to. */
|
||||
application: string;
|
||||
/** Manifest version the catalog was discovered at. */
|
||||
manifestVersion: string;
|
||||
/** Connection label (mono URL / command). */
|
||||
connection: string;
|
||||
/** Current catalog sha256 the gateway will enforce against. */
|
||||
catalogSha256: string;
|
||||
/** sha256 of the signed argument payload — never elided. */
|
||||
payloadSha256: string;
|
||||
/**
|
||||
* Previous catalog sha256, only present on the stale variant. Rendered struck
|
||||
* through next to {@link catalogSha256} so the mismatch is obvious.
|
||||
*/
|
||||
previousCatalogSha256?: string;
|
||||
}
|
||||
|
||||
export interface ActionCardProps {
|
||||
/** Requesting agent — defaults to "Coder" to match the spec copy. */
|
||||
agentName?: string;
|
||||
agentAvatarUrl?: string | null;
|
||||
/** Tool the agent is asking to call, e.g. `slack.post_message`. */
|
||||
toolName: string;
|
||||
risk: ToolRiskLevel;
|
||||
isReadOnly?: boolean;
|
||||
isWrite?: boolean;
|
||||
isDestructive?: boolean;
|
||||
binding: ActionCardBinding;
|
||||
/** Raw tool input, rendered as pretty JSON in a mono block. */
|
||||
input: unknown;
|
||||
/** Free-form "why I'm asking" explanation. */
|
||||
reason: ReactNode;
|
||||
/** Policy number the explanation references, e.g. `7` → "Policy #7". */
|
||||
policyNumber?: number | string;
|
||||
/** Footrow expiry copy, e.g. "expires in 23h 51m". */
|
||||
expiresInLabel?: string;
|
||||
variant?: ActionCardVariant;
|
||||
/** Mobile (390×844) layout: stacked full-width buttons + 70px label column. */
|
||||
mobile?: boolean;
|
||||
onApprove?: () => void;
|
||||
onDeny?: () => void;
|
||||
onEditResign?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function initials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.map((part) => part[0])
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function bindingRows(binding: ActionCardBinding, isStale: boolean): BindingRow[] {
|
||||
const catalogValue = isStale && binding.previousCatalogSha256 ? (
|
||||
<span className="inline-flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-muted-foreground line-through decoration-amber-500" title="Previous catalog hash">
|
||||
{shortSha(binding.previousCatalogSha256)}
|
||||
</span>
|
||||
<span className="text-amber-600 dark:text-amber-400" title="Current catalog hash">
|
||||
{shortSha(binding.catalogSha256)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
shortSha(binding.catalogSha256)
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
label: "Application",
|
||||
value: (
|
||||
<span>
|
||||
{binding.application}
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">manifest v{binding.manifestVersion}</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ label: "Connection", value: binding.connection, mono: true },
|
||||
{ label: "Catalog", value: catalogValue, mono: !isStale },
|
||||
{
|
||||
label: "Payload",
|
||||
value: (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ShieldCheck className="h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
<span>{shortSha(binding.payloadSha256)}</span>
|
||||
<span className="font-sans text-[11px] uppercase tracking-normal text-muted-foreground">signed</span>
|
||||
</span>
|
||||
),
|
||||
mono: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function ActionCard({
|
||||
agentName = "Coder",
|
||||
agentAvatarUrl,
|
||||
toolName,
|
||||
risk,
|
||||
isReadOnly,
|
||||
isWrite,
|
||||
isDestructive,
|
||||
binding,
|
||||
input,
|
||||
reason,
|
||||
policyNumber,
|
||||
expiresInLabel,
|
||||
variant = "pending",
|
||||
mobile = false,
|
||||
onApprove,
|
||||
onDeny,
|
||||
onEditResign,
|
||||
className,
|
||||
}: ActionCardProps) {
|
||||
const isStale = variant === "stale";
|
||||
const json = typeof input === "string" ? input : JSON.stringify(input, null, 2);
|
||||
|
||||
// Surface 99: buttons stack full-width in the order Approve / Deny /
|
||||
// Edit & re-sign; desktop keeps them inline as Edit & re-sign / Deny / Approve.
|
||||
const approveButton = (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onApprove}
|
||||
disabled={isStale}
|
||||
className={mobile ? "w-full" : undefined}
|
||||
title={isStale ? "Re-issue the request before approving — the catalog hash changed." : undefined}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
);
|
||||
const denyButton = (
|
||||
<Button size="sm" variant="outline" onClick={onDeny} className={mobile ? "w-full" : undefined}>
|
||||
Deny
|
||||
</Button>
|
||||
);
|
||||
const editButton = (
|
||||
<Button size="sm" variant="outline" onClick={onEditResign} className={mobile ? "w-full" : undefined}>
|
||||
<Pencil className="mr-1 h-3.5 w-3.5" />
|
||||
Edit & re-sign
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"gap-0 py-0",
|
||||
isStale && "border-amber-500/50 dark:border-amber-500/40",
|
||||
className,
|
||||
)}
|
||||
data-variant={variant}
|
||||
>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
{/* Header: avatar + request line + outcome pill */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar size="sm" className="shrink-0">
|
||||
{agentAvatarUrl ? <AvatarImage src={agentAvatarUrl} alt={agentName} /> : null}
|
||||
<AvatarFallback>{initials(agentName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-foreground">
|
||||
<span className="font-medium">{agentName}</span> requested approval to call
|
||||
</p>
|
||||
<p className="mt-0.5 font-mono text-xs text-muted-foreground break-all">{toolName}</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<DecisionBadge decision="require_approval" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body: tool name + risk / capability pills */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-sm text-foreground">{toolName}</span>
|
||||
<RiskBadge risk={risk} />
|
||||
<CapabilityBadges isReadOnly={isReadOnly} isWrite={isWrite} isDestructive={isDestructive} />
|
||||
</div>
|
||||
|
||||
{/* Stale banner (PAP-10400 hardening) */}
|
||||
{isStale ? (
|
||||
<EnforcementBanner
|
||||
tone="warning"
|
||||
title="Catalog changed since this request was signed."
|
||||
body="The application's tool catalog hash no longer matches the one this approval was issued against. Approval is disabled — the agent must edit & re-sign to request again."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Bindings table */}
|
||||
<BindingsTable rows={bindingRows(binding, isStale)} labelWidth={mobile ? 70 : 132} />
|
||||
|
||||
{/* JSON input */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-normal text-muted-foreground">Input</p>
|
||||
<pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 font-mono text-xs leading-relaxed text-foreground">
|
||||
{json}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Why I'm asking */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-normal text-muted-foreground">Why I'm asking</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{reason}
|
||||
{policyNumber != null ? (
|
||||
<>
|
||||
{" "}
|
||||
<span className="font-medium text-foreground">Policy #{policyNumber}</span> requires approval here.
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{/* Footrow: expiry + actions */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 border-t border-border p-4",
|
||||
mobile ? "flex-col" : "flex-wrap items-center justify-between",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Clock className="h-3.5 w-3.5 shrink-0" />
|
||||
{expiresInLabel ?? "no expiry set"}
|
||||
</span>
|
||||
{mobile ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{approveButton}
|
||||
{denyButton}
|
||||
{editButton}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
{editButton}
|
||||
{denyButton}
|
||||
{approveButton}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mobile (390×844) presentation of {@link ActionCard}. */
|
||||
export function ActionCardMobile(props: Omit<ActionCardProps, "mobile">) {
|
||||
return <ActionCard {...props} mobile />;
|
||||
}
|
||||
@@ -115,6 +115,7 @@ import {
|
||||
import { StatusBadge, AgentStatusBadge, AgentStatusCapsule } from "@/components/StatusBadge";
|
||||
import { StatusIcon } from "@/components/StatusIcon";
|
||||
import { EnforcementBanner } from "@/components/EnforcementBanner";
|
||||
import { ActionCard, ActionCardMobile, BindingsTable } from "@/components/actions/ActionCard";
|
||||
import { PriorityIcon } from "@/components/PriorityIcon";
|
||||
import { EntityRow } from "@/components/EntityRow";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
@@ -1661,6 +1662,91 @@ export function DesignGuide() {
|
||||
</p>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="Action approval card — pending / stale (surfaces 11/12)">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ActionCard
|
||||
toolName="slack.post_message"
|
||||
risk="medium"
|
||||
isWrite
|
||||
binding={{
|
||||
application: "Slack",
|
||||
manifestVersion: "2.4.1",
|
||||
connection: "https://slack.com/api · acme-workspace",
|
||||
catalogSha256: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
|
||||
payloadSha256: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
|
||||
}}
|
||||
input={{ channel: "#launch", text: "Deploy v2 is live 🎉", unfurl_links: false }}
|
||||
reason="This tool can write to your workspace, so a human signs off before the agent posts."
|
||||
policyNumber={7}
|
||||
expiresInLabel="expires in 23h 51m"
|
||||
/>
|
||||
<ActionCard
|
||||
variant="stale"
|
||||
toolName="slack.post_message"
|
||||
risk="medium"
|
||||
isWrite
|
||||
binding={{
|
||||
application: "Slack",
|
||||
manifestVersion: "2.4.1",
|
||||
connection: "https://slack.com/api · acme-workspace",
|
||||
catalogSha256: "sha256:7d793037a0760186574b0282f2f435e7a4b1b2b0b822cd15d6c15b0f00a0e3f1",
|
||||
previousCatalogSha256: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
|
||||
payloadSha256: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
|
||||
}}
|
||||
input={{ channel: "#launch", text: "Deploy v2 is live 🎉", unfurl_links: false }}
|
||||
reason="This tool can write to your workspace, so a human signs off before the agent posts."
|
||||
policyNumber={7}
|
||||
expiresInLabel="expires in 18h 02m"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Signed payload sha256 + expiry surface on every variant (PAP-10400). The{" "}
|
||||
<code>stale</code> variant tints the border amber, banners the catalog-hash mismatch, strikes through
|
||||
the previous hash next to the current one, and renders <code>Approve</code> disabled until the request
|
||||
is re-issued.
|
||||
</p>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="Action approval card — mobile (390×844, surface 99)">
|
||||
<div className="w-[390px] max-w-full rounded-xl border border-border bg-background p-3">
|
||||
<ActionCardMobile
|
||||
toolName="slack.post_message"
|
||||
risk="medium"
|
||||
isWrite
|
||||
binding={{
|
||||
application: "Slack",
|
||||
manifestVersion: "2.4.1",
|
||||
connection: "https://slack.com/api · acme-workspace",
|
||||
catalogSha256: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
|
||||
payloadSha256: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
|
||||
}}
|
||||
input={{ channel: "#launch", text: "Deploy v2 is live 🎉" }}
|
||||
reason="This tool can write to your workspace, so a human signs off before the agent posts."
|
||||
policyNumber={7}
|
||||
expiresInLabel="expires in 23h 51m"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Identical content; the three buttons stack full-width in the order Approve / Deny / Edit & re-sign,
|
||||
and the bindings table uses a 70px label column.
|
||||
</p>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="BindingsTable (reused in the audit row drilldown)">
|
||||
<BindingsTable
|
||||
rows={[
|
||||
{ label: "Application", value: "Slack · manifest v2.4.1" },
|
||||
{ label: "Connection", value: "https://slack.com/api · acme-workspace", mono: true },
|
||||
{ label: "Catalog", value: "sha256:9f86d081…f00a08", mono: true },
|
||||
{ label: "Payload", value: "sha256:2c26b46b…66e7ae", mono: true },
|
||||
]}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Two-column key/value block with mono values. Lives inside <code>ActionCard</code> and is reused
|
||||
standalone in the audit row drilldown.
|
||||
</p>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="Tool-access status keys (StatusBadge)">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{[
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ActionCard, ActionCardMobile, BindingsTable } from "@/components/actions/ActionCard";
|
||||
|
||||
const binding = {
|
||||
application: "Slack",
|
||||
manifestVersion: "2.4.1",
|
||||
connection: "https://slack.com/api · acme-workspace",
|
||||
catalogSha256: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
|
||||
payloadSha256: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
|
||||
};
|
||||
|
||||
const baseProps = {
|
||||
toolName: "slack.post_message",
|
||||
risk: "medium" as const,
|
||||
isWrite: true,
|
||||
binding,
|
||||
input: { channel: "#launch", text: "Deploy v2 is live 🎉", unfurl_links: false },
|
||||
reason: "This tool can write to your workspace, so a human signs off before the agent posts.",
|
||||
policyNumber: 7,
|
||||
expiresInLabel: "expires in 23h 51m",
|
||||
};
|
||||
|
||||
const meta: Meta<typeof ActionCard> = {
|
||||
title: "Tools & Access/ActionCard",
|
||||
component: ActionCard,
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ActionCard>;
|
||||
|
||||
export const Pending: Story = {
|
||||
render: () => (
|
||||
<div className="max-w-xl p-6">
|
||||
<ActionCard {...baseProps} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Stale: Story = {
|
||||
render: () => (
|
||||
<div className="max-w-xl p-6">
|
||||
<ActionCard
|
||||
{...baseProps}
|
||||
variant="stale"
|
||||
binding={{
|
||||
...binding,
|
||||
catalogSha256: "sha256:7d793037a0760186574b0282f2f435e7a4b1b2b0b822cd15d6c15b0f00a0e3f1",
|
||||
previousCatalogSha256: binding.catalogSha256,
|
||||
}}
|
||||
expiresInLabel="expires in 18h 02m"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const DesktopSideBySide: Story = {
|
||||
render: () => (
|
||||
<div className="grid max-w-5xl gap-4 p-6 lg:grid-cols-2">
|
||||
<ActionCard {...baseProps} />
|
||||
<ActionCard
|
||||
{...baseProps}
|
||||
variant="stale"
|
||||
binding={{
|
||||
...binding,
|
||||
catalogSha256: "sha256:7d793037a0760186574b0282f2f435e7a4b1b2b0b822cd15d6c15b0f00a0e3f1",
|
||||
previousCatalogSha256: binding.catalogSha256,
|
||||
}}
|
||||
expiresInLabel="expires in 18h 02m"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
render: () => (
|
||||
<div className="w-[390px] bg-background p-3">
|
||||
<ActionCardMobile {...baseProps} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Bindings: Story = {
|
||||
render: () => (
|
||||
<div className="max-w-md p-6">
|
||||
<BindingsTable
|
||||
rows={[
|
||||
{ label: "Application", value: "Slack · manifest v2.4.1" },
|
||||
{ label: "Connection", value: "https://slack.com/api · acme-workspace", mono: true },
|
||||
{ label: "Catalog", value: "sha256:9f86d08188…f00a08", mono: true },
|
||||
{ label: "Payload", value: "sha256:2c26b46b68…66e7ae", mono: true },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
Reference in new issue
Block a user