From bd3465deec46a470fd7874ba8c3c9dda1da3f070 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 12:10:42 +0000 Subject: [PATCH] Fix borderless outline link-buttons and keep Audit inside Administration buttonVariants() returned a raw cva string, so links styled as outline buttons kept border-transparent alongside the variant border colour and rendered as plain text. Merge the classes with cn. Render the organization audit trail at /admin/audit so the Administration tabs stay visible, send administrators from /audit there, drop the redundant Back to tasks on Find tasks, and make Disable on a team look like a button. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Lu73uozkZGX8gP8LWNbBqt --- CHANGELOG.md | 5 ++ docs/guides/use-the-console.md | 4 +- src/app/admin/audit/page.tsx | 7 +++ src/app/admin/teams/page.tsx | 2 +- src/app/audit/page.tsx | 75 +++-------------------- src/app/discover/page.tsx | 1 - src/components/admin/admin-nav.test.tsx | 4 +- src/components/admin/admin-nav.tsx | 2 +- src/components/app-shell.tsx | 1 - src/components/audit/audit-view.tsx | 79 +++++++++++++++++++++++++ src/components/ui/button.test.ts | 20 +++++++ src/components/ui/button.tsx | 13 +++- 12 files changed, 134 insertions(+), 79 deletions(-) create mode 100644 src/app/admin/audit/page.tsx create mode 100644 src/components/audit/audit-view.tsx create mode 100644 src/components/ui/button.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8175d7c..d128a4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/docs/guides/use-the-console.md b/docs/guides/use-the-console.md index 74a5695..9337cc2 100644 --- a/docs/guides/use-the-console.md +++ b/docs/guides/use-the-console.md @@ -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. @@ -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 | diff --git a/src/app/admin/audit/page.tsx b/src/app/admin/audit/page.tsx new file mode 100644 index 0000000..719132c --- /dev/null +++ b/src/app/admin/audit/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { AuditView } from "@/components/audit/audit-view"; + +export default function AdminAuditPage() { + return ; +} diff --git a/src/app/admin/teams/page.tsx b/src/app/admin/teams/page.tsx index 3f2007e..7368088 100644 --- a/src/app/admin/teams/page.tsx +++ b/src/app/admin/teams/page.tsx @@ -30,7 +30,7 @@ export default function TeamsPage() { const candidates = view.memberships.filter((member) => !members.some((link) => link.membershipId === member.id)); return ( { if (window.confirm(`Disable team “${team.name}”? Its grants stop applying.`)) void change({ action: "disableTeam", teamId: team.id }); }}>Disable}> + action={}>
    {members.length === 0 &&
  • No members yet.
  • } {members.map((link) => { diff --git a/src/app/audit/page.tsx b/src/app/audit/page.tsx index 75b4532..c7d6653 100644 --- a/src/app/audit/page.tsx +++ b/src/app/audit/page.tsx @@ -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("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 Loading audit trail…; - if (!probe.data) { - return ( - Back to tasks} - > - {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."} - - ); - } - return ( - - {taskId && Back to task} - {admin && Export CSV} - - } - > -
    - 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")} - /> -
    -
    setSince(event.target.value)} />
    -
    setUntil(event.target.value)} />
    -
    -
    - - - -
    - ); +export default function AuditRoute() { + return Loading audit trail…}>; } -export default function AuditRoute() { - return Loading audit trail…}>; +function AuditRouteBody() { + return ; } diff --git a/src/app/discover/page.tsx b/src/app/discover/page.tsx index b97f2a5..f8ff875 100644 --- a/src/app/discover/page.tsx +++ b/src/app/discover/page.tsx @@ -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={Back to tasks} > {loading ? Loading agents… : reachable.length === 0 ? ( Go to agents}> diff --git a/src/components/admin/admin-nav.test.tsx b/src/components/admin/admin-nav.test.tsx index 7111261..5480bff 100644 --- a/src/components/admin/admin-nav.test.tsx +++ b/src/components/admin/admin-nav.test.tsx @@ -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); }); }); diff --git a/src/components/admin/admin-nav.tsx b/src/components/admin/admin-nav.tsx index 73df86b..9b9c716 100644 --- a/src/components/admin/admin-nav.tsx +++ b/src/components/admin/admin-nav.tsx @@ -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. */ diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 8d24830..5d85bee 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -63,7 +63,6 @@ const PRIMARY_TABS = new Set(["/", "/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}/`))); } diff --git a/src/components/audit/audit-view.tsx b/src/components/audit/audit-view.tsx new file mode 100644 index 0000000..add8cfb --- /dev/null +++ b/src/components/audit/audit-view.tsx @@ -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("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 Loading audit trail…; + if (!probe.data) { + return ( + Back to tasks} + > + {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."} + + ); + } + return ( + + {taskId && Back to task} + {admin && Export CSV} + + } + > +
    + 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")} + /> +
    +
    setSince(event.target.value)} />
    +
    setUntil(event.target.value)} />
    +
    +
    + + + +
    + ); +} + diff --git a/src/components/ui/button.test.ts b/src/components/ui/button.test.ts new file mode 100644 index 0000000..fce3ba6 --- /dev/null +++ b/src/components/ui/button.test.ts @@ -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"); + }); +}); diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 13d42ca..52a47d5 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -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: { @@ -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 +const buttonVariants = (props?: Parameters[0]) => cn(buttonStyles(props)) + function Button({ className, variant = "default", size = "default", ...props -}: ButtonPrimitive.Props & VariantProps) { +}: ButtonPrimitive.Props & ButtonStyleProps) { return ( )