Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ the project uses [semantic versioning](https://semver.org/) once it reaches 1.0;

## [Unreleased]

### Fixed

- **Link-styled outline buttons had no border.** `buttonVariants()` returned a raw class string, so an outline button built from a link kept both `border-transparent` and the variant's border colour, and the transparent one won. "Back to tasks", **Export CSV**, **Add agent** and similar looked like plain text. They now render as buttons.
- **Audit trail no longer drops you out of Administration.** It opens inside Administration at `/admin/audit` with the tabs still visible; `/audit` sends administrators there. **Find tasks** no longer shows a redundant "Back to tasks" button, and **Disable** on a team looks like a button.

### Changed

- **Console navigation is reorganised by job.** The sidebar groups daily work (**Home**, **Inbox**, **Tasks**, **Chat**), **Agents** (**Agents**, **Find tasks**, **Orchestration**) and **You** (**Notifications**, **Settings**, **Administration** for administrators).
Expand Down
4 changes: 2 additions & 2 deletions docs/guides/use-the-console.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ The arrows are only a layout. They show references the agent or you set, not an

## Audit

**Audit** is the organization-wide audit trail. Only administrators see it in the sidebar. A non-admin who opens `/audit` sees "Administrators only".
**Audit trail** is the organization-wide audit trail. It is the last tab of **Administration** (`/admin/audit`), so the Administration tabs stay in view. An administrator who opens `/audit` is sent there; a non-admin who opens it sees "Administrators only". `/audit?taskId=…` is a single task's trail, reached from **View the full trail** on a task page.

The trail is read-only and newest first, 25 entries at a time with **Show older entries**. Each entry is one sentence ("You approved …", "The agent requested approval: …"), a time, and a link to the task. **Details** expands the exact facts: revision and digest, proposed or sent content, rationale, risk, expiry and delivery result. Note text is never shown, only that a note was added.

Expand Down Expand Up @@ -353,7 +353,7 @@ Administrators also see a link to **Administration** at the bottom of the page.
| "Conversation not found" / "This conversation is unavailable in the current organization." | The link is from another organization, or you lost read access | Open the chat from **Recent conversations**, or ask for a grant |
| "Task not found" | The task does not exist or you cannot read it. The server answers 404 rather than 403. | Ask an administrator for a grant |
| Banner ending "Retrying server sync…" | The browser could not load tasks from the server | Check the server is running. The console retries every 5 seconds. |
| "Administrators only" on **Audit** | You are not an administrator | Open a task and use **View the full trail** |
| "Administrators only" on **Audit trail** | You are not an administrator | Open a task and use **View the full trail** |
| **Administration** missing from the sidebar, or "Administrators only" | You are not an administrator | Ask an administrator |
| "Too many requests. Try again shortly." | Per-person rate limit | Wait a minute |

Expand Down
7 changes: 7 additions & 0 deletions src/app/admin/audit/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"use client";

import { AuditView } from "@/components/audit/audit-view";

export default function AdminAuditPage() {
return <AuditView embedded />;
}
2 changes: 1 addition & 1 deletion src/app/admin/teams/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export default function TeamsPage() {
const candidates = view.memberships.filter((member) => !members.some((link) => link.membershipId === member.id));
return (
<Panel key={team.id} title={team.name} description={`${members.length} ${members.length === 1 ? "person" : "people"}`}
action={<Button variant="ghost" size="sm" disabled={busy} onClick={() => { if (window.confirm(`Disable team “${team.name}”? Its grants stop applying.`)) void change({ action: "disableTeam", teamId: team.id }); }}>Disable</Button>}>
action={<Button variant="outline" size="sm" disabled={busy} onClick={() => { if (window.confirm(`Disable team “${team.name}”? Its grants stop applying.`)) void change({ action: "disableTeam", teamId: team.id }); }}>Disable</Button>}>
<ul className="mb-3 divide-y" aria-label={`Members of ${team.name}`}>
{members.length === 0 && <li className="text-sm text-muted-foreground">No members yet.</li>}
{members.map((link) => {
Expand Down
75 changes: 7 additions & 68 deletions src/app/audit/page.tsx
Original file line number Diff line number Diff line change
@@ -1,75 +1,14 @@
"use client";

import { Suspense, useState } from "react";
import Link from "next/link";
import { Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { Download, ScrollText } from "lucide-react";
import { AuditTrail } from "@/components/audit/audit-trail";
import { EmptyState } from "@/components/a2a/primitives";
import { Page, PageMessage } from "@/components/console/page";
import { FilterChips } from "@/components/patterns/filter-chips";
import { Panel } from "@/components/patterns/layout";
import { buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { auditParams, GROUP_LABEL } from "@/lib/audit";
import { useServerResource } from "@/lib/use-server-resource";
import type { AuditGroup } from "@/shared/audit-types";
import { AuditView } from "@/components/audit/audit-view";
import { PageMessage } from "@/components/console/page";

const GROUPS = Object.keys(GROUP_LABEL) as AuditGroup[];

function AuditPage() {
const taskId = useSearchParams().get("taskId") ?? undefined;
const [group, setGroup] = useState<AuditGroup>("all");
const [since, setSince] = useState("");
const [until, setUntil] = useState("");
// The same endpoint answers who may see what: a 403 means this member may only open a single task's trail.
const probe = useServerResource<{ viewer: { role: string } }>(`/api/audit?${auditParams({ taskId, limit: 1 })}`);
const admin = probe.data?.viewer.role === "admin";

if (probe.loading) return <PageMessage>Loading audit trail…</PageMessage>;
if (!probe.data) {
return (
<EmptyState
icon={ScrollText}
title={taskId ? "Task not found" : "Administrators only"}
action={<Link href="/tasks" className={buttonVariants({ variant: "outline" })}>Back to tasks</Link>}
>
{taskId ? "This task is not available to you." : "The organization-wide audit trail is limited to administrators. Open a task to see its own trail."}
</EmptyState>
);
}
return (
<Page
size="default"
title={taskId ? "Task audit trail" : "Audit trail"}
description="A permanent, read-only record of who did what and when. Entries cannot be edited or removed. Internal note text is never shown here."
actions={
<>
{taskId && <Link href={`/tasks/${taskId}`} className={buttonVariants({ variant: "outline" })}>Back to task</Link>}
{admin && <a href={`/api/audit/export?${auditParams({ taskId, group, since, until, limit: 200 })}`} download="audit-trail.csv" className={buttonVariants({ variant: "outline" })}><Download /> Export CSV</a>}
</>
}
>
<div className="mb-4 space-y-3">
<FilterChips
label="Filter the audit trail"
options={GROUPS.filter((name) => admin || name === "all" || name === "approvals" || name === "ownership").map((name) => ({ value: name, label: GROUP_LABEL[name] }))}
value={[group]}
onValueChange={(next) => setGroup((next[0] as AuditGroup | undefined) ?? "all")}
/>
<div className="grid max-w-md grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5"><Label htmlFor="audit-since">From</Label><Input id="audit-since" type="datetime-local" value={since} onChange={(event) => setSince(event.target.value)} /></div>
<div className="space-y-1.5"><Label htmlFor="audit-until">To</Label><Input id="audit-until" type="datetime-local" value={until} onChange={(event) => setUntil(event.target.value)} /></div>
</div>
</div>
<Panel bodyClassName="p-4">
<AuditTrail key={`${taskId}|${group}|${since}|${until}`} taskId={taskId} group={group} since={since || undefined} until={until || undefined} />
</Panel>
</Page>
);
export default function AuditRoute() {
return <Suspense fallback={<PageMessage>Loading audit trail…</PageMessage>}><AuditRouteBody /></Suspense>;
}

export default function AuditRoute() {
return <Suspense fallback={<PageMessage>Loading audit trail…</PageMessage>}><AuditPage /></Suspense>;
function AuditRouteBody() {
return <AuditView taskId={useSearchParams().get("taskId") ?? undefined} />;
}
1 change: 0 additions & 1 deletion src/app/discover/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ function Find() {
size="wide"
title="Find tasks"
description="Read the task list straight from an agent to find work another system started. Nothing is stored until you track a task, and then it joins your Tasks and Inbox."
actions={<Link href={"/tasks" as Route} className={buttonVariants({ variant: "outline" })}>Back to tasks</Link>}
>
{loading ? <PageMessage>Loading agents…</PageMessage> : reachable.length === 0 ? (
<EmptyState icon={SearchCheck} title="No reachable agents" action={<Link href={"/agents" as Route} className={buttonVariants()}>Go to agents</Link>}>
Expand Down
4 changes: 2 additions & 2 deletions src/components/admin/admin-nav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
import { ADMIN_LINKS } from "./admin-nav";

describe("Admin sub-navigation", () => {
it("has one tab per job, starting at the overview, and ends at the audit trail", () => {
expect(ADMIN_LINKS.map((link) => link.href)).toEqual(["/admin", "/admin/people", "/admin/teams", "/admin/access", "/admin/policies", "/audit"]);
it("has one tab per job, starting at the overview, and ends at the audit trail inside Administration", () => {
expect(ADMIN_LINKS.map((link) => link.href)).toEqual(["/admin", "/admin/people", "/admin/teams", "/admin/access", "/admin/policies", "/admin/audit"]);
expect(ADMIN_LINKS[0].exact).toBe(true);
});
});
2 changes: 1 addition & 1 deletion src/components/admin/admin-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const ADMIN_LINKS: AdminLink[] = [
{ href: "/admin/teams" as Route, label: "Teams", icon: UsersRound },
{ href: "/admin/access" as Route, label: "Agent access", icon: KeyRound },
{ href: "/admin/policies" as Route, label: "Policies & alerts", icon: Bell },
{ href: "/audit" as Route, label: "Audit trail", icon: ScrollText },
{ href: "/admin/audit" as Route, label: "Audit trail", icon: ScrollText },
];

/** The sub-navigation of the Admin area: one tab per job, scrolling sideways on a phone. */
Expand Down
1 change: 0 additions & 1 deletion src/components/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ const PRIMARY_TABS = new Set<string>(["/", "/chat", "/inbox", "/tasks"]);
function useActive(nav: NavItem[]) {
const pathname = usePathname() ?? "/";
// The standalone approvals queue now lives in the inbox, so its review pages keep the Inbox item active.
if (pathname === "/audit" || pathname.startsWith("/audit/")) return nav.find((item) => item.href === "/admin") ?? undefined;
const path = pathname === "/approvals" || pathname.startsWith("/approvals/") ? pathname.replace("/approvals", "/inbox") : pathname;
return nav.find((item) => (item.href === "/" ? path === "/" : path === item.href || path.startsWith(`${item.href}/`)));
}
Expand Down
79 changes: 79 additions & 0 deletions src/components/audit/audit-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"use client";

import { useEffect, useState } from "react";
import Link from "next/link";
import type { Route } from "next";
import { useRouter } from "next/navigation";
import { Download, ScrollText } from "lucide-react";
import { AuditTrail } from "@/components/audit/audit-trail";
import { EmptyState } from "@/components/a2a/primitives";
import { Page, PageMessage } from "@/components/console/page";
import { FilterChips } from "@/components/patterns/filter-chips";
import { Panel } from "@/components/patterns/layout";
import { buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { auditParams, GROUP_LABEL } from "@/lib/audit";
import { useServerResource } from "@/lib/use-server-resource";
import type { AuditGroup } from "@/shared/audit-types";

const GROUPS = Object.keys(GROUP_LABEL) as AuditGroup[];

/**
* The audit trail. With a `taskId` it is that task's trail, readable by anyone who can read the task. Without one it is the
* organization-wide trail, which lives under Administration; an administrator who lands on `/audit` is sent there so the
* Administration navigation stays around the page.
*/
export function AuditView({ taskId, embedded = false }: { taskId?: string; embedded?: boolean }) {
const router = useRouter();
const [group, setGroup] = useState<AuditGroup>("all");
const [since, setSince] = useState("");
const [until, setUntil] = useState("");
// The same endpoint answers who may see what: a 403 means this member may only open a single task's trail.
const probe = useServerResource<{ viewer: { role: string } }>(`/api/audit?${auditParams({ taskId, limit: 1 })}`);
const admin = probe.data?.viewer.role === "admin";
useEffect(() => { if (!embedded && !taskId && admin) router.replace("/admin/audit" as Route); }, [embedded, taskId, admin, router]);

if (probe.loading) return <PageMessage>Loading audit trail…</PageMessage>;
if (!probe.data) {
return (
<EmptyState
icon={ScrollText}
title={taskId ? "Task not found" : "Administrators only"}
action={<Link href="/tasks" className={buttonVariants({ variant: "outline" })}>Back to tasks</Link>}
>
{taskId ? "This task is not available to you." : "The organization-wide audit trail is limited to administrators. Open a task to see its own trail."}
</EmptyState>
);
}
return (
<Page
size="default"
title={taskId ? "Task audit trail" : "Audit trail"}
description="A permanent, read-only record of who did what and when. Entries cannot be edited or removed. Internal note text is never shown here."
actions={
<>
{taskId && <Link href={`/tasks/${taskId}`} className={buttonVariants({ variant: "outline" })}>Back to task</Link>}
{admin && <a href={`/api/audit/export?${auditParams({ taskId, group, since, until, limit: 200 })}`} download="audit-trail.csv" className={buttonVariants({ variant: "outline" })}><Download /> Export CSV</a>}
</>
}
>
<div className="mb-4 space-y-3">
<FilterChips
label="Filter the audit trail"
options={GROUPS.filter((name) => admin || name === "all" || name === "approvals" || name === "ownership").map((name) => ({ value: name, label: GROUP_LABEL[name] }))}
value={[group]}
onValueChange={(next) => setGroup((next[0] as AuditGroup | undefined) ?? "all")}
/>
<div className="grid max-w-md grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5"><Label htmlFor="audit-since">From</Label><Input id="audit-since" type="datetime-local" value={since} onChange={(event) => setSince(event.target.value)} /></div>
<div className="space-y-1.5"><Label htmlFor="audit-until">To</Label><Input id="audit-until" type="datetime-local" value={until} onChange={(event) => setUntil(event.target.value)} /></div>
</div>
</div>
<Panel bodyClassName="p-4">
<AuditTrail key={`${taskId}|${group}|${since}|${until}`} taskId={taskId} group={group} since={since || undefined} until={until || undefined} />
</Panel>
</Page>
);
}

20 changes: 20 additions & 0 deletions src/components/ui/button.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { buttonVariants } from "./button";

describe("buttonVariants", () => {
// Links styled as buttons use this string directly, with no component to merge classes for them.
it("lets a variant's border colour replace the base transparent border", () => {
const classes = buttonVariants({ variant: "outline" }).split(" ");
expect(classes).toContain("border-border-strong");
expect(classes).not.toContain("border-transparent");
});

it("keeps the transparent border for variants that have no border of their own", () => {
expect(buttonVariants({ variant: "default" }).split(" ")).toContain("border-transparent");
});

it("lets a caller's class win over the variant's", () => {
expect(buttonVariants({ variant: "outline", className: "mt-3 h-10" }).split(" ")).toEqual(expect.arrayContaining(["mt-3", "h-10"]));
expect(buttonVariants({ size: "sm", className: "h-10" }).split(" ")).not.toContain("h-7");
});
});
13 changes: 10 additions & 3 deletions src/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

const buttonVariants = cva(
const buttonStyles = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
Expand Down Expand Up @@ -39,16 +39,23 @@ const buttonVariants = cva(
}
)

/**
* The class string for a button. Merged with `cn` so a variant's own border colour replaces the base `border-transparent`; a raw
* `cva` string keeps both and the transparent one wins, which turns outline buttons built from links into plain text.
*/
type ButtonStyleProps = VariantProps<typeof buttonStyles>
const buttonVariants = (props?: Parameters<typeof buttonStyles>[0]) => cn(buttonStyles(props))

function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
}: ButtonPrimitive.Props & ButtonStyleProps) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
className={buttonVariants({ variant, size, className })}
{...props}
/>
)
Expand Down
Loading