feat: add agent detail view

This commit is contained in:
Ankit Ranjan
2026-06-17 15:49:37 +05:30
parent fd571d8921
commit 256753f320
12 changed files with 414 additions and 17 deletions
+2
View File
@@ -20,6 +20,7 @@ async def list_audit_events(
request: Request,
limit: int = 50,
cursor: str | None = None,
identity: str | None = None,
auth: CredentialService = Depends(get_daemon_or_browser_auth_service),
) -> dict[str, Any]:
effective_principal_id = None if auth.principal_role == PrincipalRole.ADMIN else auth.principal_id
@@ -28,6 +29,7 @@ async def list_audit_events(
page = await request.app.state.audit_log.query_events(
limit=limit,
principal_id=effective_principal_id,
identity=identity,
cursor=cursor,
)
except ValueError as exc:
+45
View File
@@ -3,9 +3,11 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from authsome.identity.principal import ClaimStatus, PrincipalRole
from authsome.server.analytics import capture_event
from authsome.server.credential_service import CredentialService
from authsome.server.routes._deps import get_daemon_or_browser_auth_service
from authsome.server.schemas import AgentDetailResponse
from authsome.server.store.repositories import IdentityRegistrationError
router = APIRouter(prefix="/identities", tags=["identities"])
@@ -63,6 +65,49 @@ async def resolve_identity_by_did(did: str, request: Request) -> dict[str, str]:
return {"identity": registration.handle, "did": registration.did}
@router.get("/{handle}/detail")
async def get_identity_detail(
handle: str,
request: Request,
auth: CredentialService = Depends(get_daemon_or_browser_auth_service),
) -> AgentDetailResponse:
registration = await request.app.state.store.identity_registry.resolve(handle)
if registration is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Identity not found")
claim = await request.app.state.store.identity_claims.resolve(handle)
if auth.principal_role != PrincipalRole.ADMIN and (claim is None or claim.principal_id != auth.principal_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Identity not found")
principal_email = None
if claim is not None:
principal = await request.app.state.store.principals.get(claim.principal_id)
principal_email = principal.email if principal else None
if claim is None:
registration_status = "claim_required"
elif claim.claim_status == ClaimStatus.ACCEPTED:
registration_status = "claimed"
else:
registration_status = claim.claim_status.value
active_identity = (
auth.identity or getattr(request.state, "identity", None) or getattr(request.state, "ui_identity", None)
)
return AgentDetailResponse(
handle=registration.handle,
did=registration.did,
registration_status=registration_status,
claim_status=claim.claim_status.value if claim else None,
principal_id=claim.principal_id if claim else None,
principal_email=principal_email,
is_active=registration.handle == active_identity,
created_at=registration.created_at,
updated_at=registration.updated_at,
claimed_at=claim.created_at if claim else None,
)
@router.get("/{handle}")
async def get_identity_status(handle: str, request: Request) -> dict[str, str]:
registration_status = await request.app.state.identity_bootstrap.get_identity_status(handle=handle)
+13
View File
@@ -227,3 +227,16 @@ class ConnectionDetailResponse(BaseModel):
can_set_default: bool = False
can_set_global: bool = False
is_global: bool = False
class AgentDetailResponse(BaseModel):
handle: str
did: str
registration_status: str
claim_status: str | None = None
principal_id: str | None = None
principal_email: str | None = None
is_active: bool = False
created_at: datetime | None = None
updated_at: datetime | None = None
claimed_at: datetime | None = None
@@ -139,6 +139,7 @@ class AuditEventRegistry:
*,
limit: int = 50,
principal_id: str | None = None,
identity: str | None = None,
cursor: str | None = None,
) -> AuditEventPage:
bounded_limit = min(max(limit, 1), 500)
@@ -148,6 +149,9 @@ class AuditEventRegistry:
if principal_id is not None:
conditions.append("principal_id = ?")
params.append(principal_id)
if identity is not None:
conditions.append("identity = ?")
params.append(identity)
if cursor:
cursor_timestamp, cursor_event_id = _decode_audit_cursor(cursor)
@@ -334,12 +338,14 @@ class ServerAuditLog:
*,
limit: int = 50,
principal_id: str | None = None,
identity: str | None = None,
cursor: str | None = None,
) -> AuditEventPage:
await self.async_force_flush()
return await self._registry.query_events(
limit=limit,
principal_id=principal_id,
identity=identity,
cursor=cursor,
)
+36 -2
View File
@@ -90,7 +90,7 @@ def test_audit_events_endpoint_only_documents_pagination_params(monkeypatch, tmp
assert response.status_code == status.HTTP_200_OK
params = response.json()["paths"]["/api/audit/events"]["get"]["parameters"]
assert {param["name"] for param in params} == {"limit", "cursor"}
assert {param["name"] for param in params} == {"limit", "cursor", "identity"}
def test_external_audit_post_is_enriched_from_pop_identity(monkeypatch, tmp_path: Path) -> None:
@@ -270,11 +270,45 @@ def test_non_admin_audit_query_cannot_widen_scope_with_principal_or_identity(
body = response.json()
assert body["scope"] == "principal"
event_ids = {entry["event_id"] for entry in body["entries"]}
assert "audit_011" in event_ids
assert "audit_010" not in event_ids
assert all(entry["principal_id"] == user_whoami["principal_id"] for entry in body["entries"])
def test_audit_events_can_filter_current_principal_by_identity(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
with create_server_test_client() as client:
_claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com")
_claim_identity(client, tmp_path, "calmly-simply-boldly-0043", email="user@example.com")
whoami = client.get(
"/api/whoami",
headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="steady-wisely-boldly-0042"),
).json()
_emit_audit_event(
"audit_020",
"connection.login",
principal_id=whoami["principal_id"],
identity="steady-wisely-boldly-0042",
provider="github",
)
_emit_audit_event(
"audit_021",
"connection.logout",
principal_id=whoami["principal_id"],
identity="calmly-simply-boldly-0043",
provider="linear",
)
path = "/api/audit/events?identity=calmly-simply-boldly-0043&limit=10"
response = client.get(
path,
headers=_auth_header(tmp_path, "GET", path, handle="steady-wisely-boldly-0042"),
)
assert response.status_code == status.HTTP_200_OK
manual_entries = [entry for entry in response.json()["entries"] if entry["event_id"].startswith("audit_02")]
assert [entry["event_id"] for entry in manual_entries] == ["audit_021"]
def test_admin_audit_events_support_cursor_pagination(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
+21
View File
@@ -146,6 +146,27 @@ def test_resolve_identity_by_did_returns_handle(monkeypatch, tmp_path: Path) ->
assert response.json()["did"] == identity.did
def test_identity_detail_returns_owner_status_and_active_flag(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
identity = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042")
with create_server_test_client() as client:
register_and_claim_identity(client, tmp_path, identity.handle)
response = client.get(
f"/api/identities/{identity.handle}/detail",
headers=_auth_header(tmp_path, "GET", f"/api/identities/{identity.handle}/detail"),
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["handle"] == identity.handle
assert body["did"] == identity.did
assert body["claim_status"] == "accepted"
assert body["principal_id"].startswith("principal_")
assert body["is_active"] is True
assert body["created_at"]
def test_resolve_identity_by_did_returns_404_for_unknown_did(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
identity = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042")
@@ -0,0 +1,67 @@
"use client";
import { Suspense } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import useSWR from "swr";
import { AgentDetailBody } from "@/components/dashboard/agent-detail-view";
import { PageErrorState, PageLoadingState } from "@/components/dashboard/page-state";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ApiError, fetchAgentDetail, fetchAuditEvents } from "@/lib/authsome-api";
function AgentNotFoundCard() {
return (
<Card className="w-full max-w-md border-border/50 shadow-none">
<CardHeader>
<CardTitle>Agent not found</CardTitle>
<CardDescription>Open an agent from the agents list to view its details.</CardDescription>
</CardHeader>
<CardContent>
<Link className={buttonVariants({ variant: "outline" })} href="/agents">
Back to agents
</Link>
</CardContent>
</Card>
);
}
function AgentDetailContent() {
const searchParams = useSearchParams();
const agent = searchParams.get("agent") ?? "";
const detail = useSWR(agent ? ["authsome-agent-detail", agent] : null, () => fetchAgentDetail(agent));
const audit = useSWR(agent ? ["authsome-agent-audit", agent] : null, () => fetchAuditEvents({ identity: agent, limit: 25 }));
if (!agent) {
return <AgentNotFoundCard />;
}
if (detail.error instanceof ApiError && detail.error.status === 404) {
return <AgentNotFoundCard />;
}
if (detail.error || audit.error) {
return <PageErrorState title="Failed to load agent details" />;
}
if (!detail.data || !audit.data) {
return (
<Card className="shadow-none border-border/50">
<CardContent className="p-0">
<PageLoadingState columns={4} />
</CardContent>
</Card>
);
}
return <AgentDetailBody agent={detail.data} events={audit.data.events} />;
}
export default function AgentDetailPage() {
return (
<Suspense fallback={null}>
<AgentDetailContent />
</Suspense>
);
}
+7
View File
@@ -102,6 +102,13 @@ function buildBreadcrumbs(
];
}
if (first === "agents") {
return [
{ label: parent, href: parentHref },
{ label: searchParams.get("agent") || "Detail" },
];
}
return [{ label: parent }];
}
@@ -0,0 +1,157 @@
"use client";
import { ArrowLeft, ShieldCheck, UserRound } from "lucide-react";
import Link from "next/link";
import type { ReactNode } from "react";
import { PageEmptyState } from "@/components/dashboard/page-state";
import { SectionHeader } from "@/components/dashboard/section-header";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { AgentDetail, AuditRow } from "@/lib/authsome-api";
import { cn } from "@/lib/utils";
export function AgentDetailBody({
agent,
events,
}: {
agent: AgentDetail;
events: AuditRow[];
}) {
return (
<div className="grid gap-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<SectionHeader
description="Cryptographic signing identity claimed to this account."
title={agent.handle}
/>
<Link className={buttonVariants({ size: "sm", variant: "outline" })} href="/agents">
<ArrowLeft />
Agents
</Link>
</div>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.8fr)]">
<Card className="shadow-none border-border/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<UserRound className="size-4 text-muted-foreground" />
Identity
</CardTitle>
<CardDescription>Local Ed25519 agent metadata and claim state.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
<DetailRow label="Handle" value={agent.handle} />
<DetailRow code label="DID" value={agent.did} />
<DetailRow label="Created" value={formatDate(agent.created_at)} />
<DetailRow label="Claimed" value={formatDate(agent.claimed_at)} />
</CardContent>
</Card>
<Card className="shadow-none border-border/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheck className="size-4 text-muted-foreground" />
Owner
</CardTitle>
<CardDescription>Principal ownership for this signing identity.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
<DetailRow label="Claim Status" value={<StatusBadge status={agent.claim_status ?? agent.registration_status} />} />
<DetailRow label="Active Agent" value={agent.is_active ? "Yes" : "No"} />
<DetailRow label="Principal" value={agent.principal_email || agent.principal_id || "-"} />
<DetailRow code label="Principal ID" value={agent.principal_id || "-"} />
</CardContent>
</Card>
</div>
<Card className="shadow-none border-border/50">
<CardHeader>
<CardTitle className="text-base">Recent Activity</CardTitle>
<CardDescription>Recent audit events recorded for this agent.</CardDescription>
</CardHeader>
<CardContent className="p-0">
{events.length ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Event</TableHead>
<TableHead>Target</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{events.map((event) => (
<TableRow key={event.eventId}>
<TableCell className="whitespace-nowrap font-mono text-xs text-muted-foreground">
{event.time}
</TableCell>
<TableCell className="font-medium">{event.event}</TableCell>
<TableCell className="text-muted-foreground">{event.target}</TableCell>
<TableCell>{event.status && event.status !== "-" ? <StatusBadge status={event.status} /> : null}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="p-4">
<PageEmptyState title="No audit events found" />
</div>
)}
</CardContent>
</Card>
</div>
);
}
function DetailRow({
code = false,
label,
value,
}: {
code?: boolean;
label: string;
value: ReactNode;
}) {
return (
<div className="grid gap-1 sm:grid-cols-[120px_minmax(0,1fr)] sm:items-start">
<div className="text-xs font-medium uppercase text-muted-foreground">{label}</div>
{code ? (
<code className="-ml-2 min-w-0 break-all rounded bg-muted py-1 pl-2 pr-2 font-mono text-sm font-medium leading-5">
{value}
</code>
) : (
<div className="min-w-0 text-sm font-medium">{value}</div>
)}
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const normalized = status.toLowerCase();
return (
<Badge
className={cn(
normalized === "accepted" || normalized === "claimed" || normalized === "success"
? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-400"
: "",
normalized === "rejected" || normalized === "failure" || normalized === "error"
? "border-destructive/60 bg-destructive/10 text-destructive"
: "",
)}
variant="outline"
>
{status}
</Badge>
);
}
function formatDate(value: string | null): string {
if (!value) return "-";
const parsed = new Date(value);
if (Number.isNaN(parsed.valueOf())) return value;
return parsed.toISOString().replace("T", " ").slice(0, 16) + " UTC";
}
@@ -94,6 +94,10 @@ export function providerDetailHref(provider: string): string {
return `/providers/detail?${new URLSearchParams({ provider }).toString()}`;
}
export function agentDetailHref(agent: string): string {
return `/agents/detail?${new URLSearchParams({ agent }).toString()}`;
}
export function SearchInput({
onChange,
placeholder,
+37 -15
View File
@@ -2,9 +2,11 @@
import { UserRound } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useRef, useState } from "react";
import useSWR from "swr";
import { INTERACTIVE_ROW_CLASS, agentDetailHref } from "@/components/dashboard/dashboard-primitives";
import { PageEmptyState, PageErrorState, PageLoadingState } from "@/components/dashboard/page-state";
import { ProviderSummary } from "@/components/dashboard/provider-views";
import { SectionHeader } from "@/components/dashboard/section-header";
@@ -12,7 +14,7 @@ import { Badge } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { H4, Muted } from "@/components/ui/typography";
import { H4 } from "@/components/ui/typography";
import { DashboardData, PrincipalRow, fetchAuditEvents, fetchPrincipals } from "@/lib/authsome-api";
export function DashboardView({ data }: { data: DashboardData }) {
@@ -49,8 +51,9 @@ export function DashboardView({ data }: { data: DashboardData }) {
{data.agents.length ? (
<div className="grid gap-1.5">
{data.agents.map((agent) => (
<div
<Link
className="flex items-center justify-between rounded-lg border bg-muted/30 px-3 py-2.5"
href={agentDetailHref(agent.handle)}
key={agent.handle}
>
<div className="flex items-center gap-2.5">
@@ -58,7 +61,7 @@ export function DashboardView({ data }: { data: DashboardData }) {
<span className="text-sm font-medium">{agent.handle}</span>
</div>
{agent.isActive ? <Badge variant="outline">Active</Badge> : null}
</div>
</Link>
))}
</div>
) : (
@@ -98,6 +101,8 @@ export function DashboardView({ data }: { data: DashboardData }) {
}
export function AgentsView({ data }: { data: DashboardData }) {
const router = useRouter();
return (
<div className="grid gap-5">
<SectionHeader description="Local Ed25519 key pairs (agents) claimed to this account." title="Agents" />
@@ -108,21 +113,38 @@ export function AgentsView({ data }: { data: DashboardData }) {
<TableHeader>
<TableRow>
<TableHead>Agent</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.agents.map((agent) => (
<TableRow key={agent.handle}>
<TableCell>
<div className="flex items-center gap-2.5">
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted">
<UserRound className="size-3.5 text-muted-foreground" />
</span>
<span className="font-medium">{agent.handle}</span>
</div>
</TableCell>
</TableRow>
))}
{data.agents.map((agent) => {
const href = agentDetailHref(agent.handle);
return (
<TableRow
className={INTERACTIVE_ROW_CLASS}
key={agent.handle}
onClick={() => router.push(href)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
router.push(href);
}
}}
role="link"
tabIndex={0}
>
<TableCell>
<div className="flex items-center gap-2.5">
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted">
<UserRound className="size-3.5 text-muted-foreground" />
</span>
<span className="font-medium">{agent.handle}</span>
</div>
</TableCell>
<TableCell>{agent.isActive ? <Badge variant="outline">Active</Badge> : null}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
) : <PageEmptyState title="No agents found" />}
+19
View File
@@ -54,6 +54,7 @@ export type AuditRow = {
export type AuditEventsQuery = {
cursor?: string | null;
identity?: string | null;
limit?: number;
};
@@ -94,6 +95,19 @@ export type DashboardData = {
};
};
export type AgentDetail = {
handle: string;
did: string;
registration_status: string;
claim_status: string | null;
principal_id: string | null;
principal_email: string | null;
is_active: boolean;
created_at: string | null;
updated_at: string | null;
claimed_at: string | null;
};
type WhoamiResponse = {
version: string;
identity?: string;
@@ -574,6 +588,7 @@ function auditQueryString(query: AuditEventsQuery = {}): string {
const params = new URLSearchParams();
params.set("limit", String(query.limit ?? 50));
if (query.cursor) params.set("cursor", query.cursor);
if (query.identity) params.set("identity", query.identity);
return params.toString();
}
@@ -674,6 +689,10 @@ export async function fetchProviderDetail(provider: string): Promise<ProviderDet
return requestJson<ProviderDetail>(`/api/providers/${encodeURIComponent(provider)}/detail`);
}
export async function fetchAgentDetail(agent: string): Promise<AgentDetail> {
return requestJson<AgentDetail>(`/api/identities/${encodeURIComponent(agent)}/detail`);
}
export async function updateProviderConfiguration(
provider: string,
payload: Record<string, string | undefined>,