From 256753f3200701f2382319db78c9d5f4e4cbd162 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Wed, 17 Jun 2026 15:49:37 +0530 Subject: [PATCH 1/3] feat: add agent detail view --- src/authsome/server/routes/audit.py | 2 + src/authsome/server/routes/identities.py | 45 +++++ src/authsome/server/schemas.py | 13 ++ src/authsome/server/store/repositories.py | 6 + tests/server/test_audit_events.py | 38 ++++- tests/server/test_pop_auth.py | 21 +++ .../(authenticated)/agents/detail/page.tsx | 67 ++++++++ ui/src/app/(authenticated)/layout.tsx | 7 + .../dashboard/agent-detail-view.tsx | 157 ++++++++++++++++++ .../dashboard/dashboard-primitives.tsx | 4 + .../components/dashboard/overview-views.tsx | 52 ++++-- ui/src/lib/authsome-api.ts | 19 +++ 12 files changed, 414 insertions(+), 17 deletions(-) create mode 100644 ui/src/app/(authenticated)/agents/detail/page.tsx create mode 100644 ui/src/components/dashboard/agent-detail-view.tsx diff --git a/src/authsome/server/routes/audit.py b/src/authsome/server/routes/audit.py index 19d770e8..750f41a2 100644 --- a/src/authsome/server/routes/audit.py +++ b/src/authsome/server/routes/audit.py @@ -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: diff --git a/src/authsome/server/routes/identities.py b/src/authsome/server/routes/identities.py index 5e167721..2ae19231 100644 --- a/src/authsome/server/routes/identities.py +++ b/src/authsome/server/routes/identities.py @@ -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) diff --git a/src/authsome/server/schemas.py b/src/authsome/server/schemas.py index d550a037..e1c13755 100644 --- a/src/authsome/server/schemas.py +++ b/src/authsome/server/schemas.py @@ -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 diff --git a/src/authsome/server/store/repositories.py b/src/authsome/server/store/repositories.py index 6d06355d..cf58b206 100644 --- a/src/authsome/server/store/repositories.py +++ b/src/authsome/server/store/repositories.py @@ -139,6 +139,7 @@ async def query_events( *, 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 @@ async def query_events( 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 @@ async def query_events( *, 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, ) diff --git a/tests/server/test_audit_events.py b/tests/server/test_audit_events.py index 595d0f8c..ef537fcd 100644 --- a/tests/server/test_audit_events.py +++ b/tests/server/test_audit_events.py @@ -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)) diff --git a/tests/server/test_pop_auth.py b/tests/server/test_pop_auth.py index 5c11bf28..3d021da3 100644 --- a/tests/server/test_pop_auth.py +++ b/tests/server/test_pop_auth.py @@ -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") diff --git a/ui/src/app/(authenticated)/agents/detail/page.tsx b/ui/src/app/(authenticated)/agents/detail/page.tsx new file mode 100644 index 00000000..eb332fd2 --- /dev/null +++ b/ui/src/app/(authenticated)/agents/detail/page.tsx @@ -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 ( + + + Agent not found + Open an agent from the agents list to view its details. + + + + Back to agents + + + + ); +} + +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 ; + } + + if (detail.error instanceof ApiError && detail.error.status === 404) { + return ; + } + + if (detail.error || audit.error) { + return ; + } + + if (!detail.data || !audit.data) { + return ( + + + + + + ); + } + + return ; +} + +export default function AgentDetailPage() { + return ( + + + + ); +} diff --git a/ui/src/app/(authenticated)/layout.tsx b/ui/src/app/(authenticated)/layout.tsx index 78c2fd2c..1ec40524 100644 --- a/ui/src/app/(authenticated)/layout.tsx +++ b/ui/src/app/(authenticated)/layout.tsx @@ -102,6 +102,13 @@ function buildBreadcrumbs( ]; } + if (first === "agents") { + return [ + { label: parent, href: parentHref }, + { label: searchParams.get("agent") || "Detail" }, + ]; + } + return [{ label: parent }]; } diff --git a/ui/src/components/dashboard/agent-detail-view.tsx b/ui/src/components/dashboard/agent-detail-view.tsx new file mode 100644 index 00000000..d24da386 --- /dev/null +++ b/ui/src/components/dashboard/agent-detail-view.tsx @@ -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 ( +
+
+ + + + Agents + +
+ +
+ + + + + Identity + + Local Ed25519 agent metadata and claim state. + + + + + + + + + + + + + + Owner + + Principal ownership for this signing identity. + + + } /> + + + + + +
+ + + + Recent Activity + Recent audit events recorded for this agent. + + + {events.length ? ( + + + + Time + Event + Target + Status + + + + {events.map((event) => ( + + + {event.time} + + {event.event} + {event.target} + {event.status && event.status !== "-" ? : null} + + ))} + +
+ ) : ( +
+ +
+ )} +
+
+
+ ); +} + +function DetailRow({ + code = false, + label, + value, +}: { + code?: boolean; + label: string; + value: ReactNode; +}) { + return ( +
+
{label}
+ {code ? ( + + {value} + + ) : ( +
{value}
+ )} +
+ ); +} + +function StatusBadge({ status }: { status: string }) { + const normalized = status.toLowerCase(); + return ( + + {status} + + ); +} + +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"; +} diff --git a/ui/src/components/dashboard/dashboard-primitives.tsx b/ui/src/components/dashboard/dashboard-primitives.tsx index 1fd60dfc..0dc7b91f 100644 --- a/ui/src/components/dashboard/dashboard-primitives.tsx +++ b/ui/src/components/dashboard/dashboard-primitives.tsx @@ -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, diff --git a/ui/src/components/dashboard/overview-views.tsx b/ui/src/components/dashboard/overview-views.tsx index 899848b7..604282e8 100644 --- a/ui/src/components/dashboard/overview-views.tsx +++ b/ui/src/components/dashboard/overview-views.tsx @@ -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 ? (
{data.agents.map((agent) => ( -
@@ -58,7 +61,7 @@ export function DashboardView({ data }: { data: DashboardData }) { {agent.handle}
{agent.isActive ? Active : null} -
+ ))}
) : ( @@ -98,6 +101,8 @@ export function DashboardView({ data }: { data: DashboardData }) { } export function AgentsView({ data }: { data: DashboardData }) { + const router = useRouter(); + return (
@@ -108,21 +113,38 @@ export function AgentsView({ data }: { data: DashboardData }) { Agent + Status - {data.agents.map((agent) => ( - - -
- - - - {agent.handle} -
-
-
- ))} + {data.agents.map((agent) => { + const href = agentDetailHref(agent.handle); + return ( + router.push(href)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + router.push(href); + } + }} + role="link" + tabIndex={0} + > + +
+ + + + {agent.handle} +
+
+ {agent.isActive ? Active : null} +
+ ); + })}
) : } diff --git a/ui/src/lib/authsome-api.ts b/ui/src/lib/authsome-api.ts index 0ee32a18..ce70c331 100644 --- a/ui/src/lib/authsome-api.ts +++ b/ui/src/lib/authsome-api.ts @@ -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(`/api/providers/${encodeURIComponent(provider)}/detail`); } +export async function fetchAgentDetail(agent: string): Promise { + return requestJson(`/api/identities/${encodeURIComponent(agent)}/detail`); +} + export async function updateProviderConfiguration( provider: string, payload: Record, From 43f9b4ebde17717f243ebe972e567c1c4db96595 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Wed, 17 Jun 2026 15:53:29 +0530 Subject: [PATCH 2/3] fix: simplify agent detail metadata layout --- .../dashboard/agent-detail-view.tsx | 33 +++++-------------- 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/ui/src/components/dashboard/agent-detail-view.tsx b/ui/src/components/dashboard/agent-detail-view.tsx index d24da386..66fb0653 100644 --- a/ui/src/components/dashboard/agent-detail-view.tsx +++ b/ui/src/components/dashboard/agent-detail-view.tsx @@ -1,13 +1,11 @@ "use client"; -import { ArrowLeft, ShieldCheck, UserRound } from "lucide-react"; -import Link from "next/link"; +import { ShieldCheck, UserRound } from "lucide-react"; 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"; @@ -22,16 +20,10 @@ export function AgentDetailBody({ }) { return (
-
- - - - Agents - -
+
@@ -44,7 +36,7 @@ export function AgentDetailBody({ - + @@ -60,9 +52,8 @@ export function AgentDetailBody({ } /> - - +
@@ -108,24 +99,16 @@ export function AgentDetailBody({ } function DetailRow({ - code = false, label, value, }: { - code?: boolean; label: string; value: ReactNode; }) { return (
{label}
- {code ? ( - - {value} - - ) : ( -
{value}
- )} +
{value}
); } From 8d4dfd8c53fa4c9bd76a45008a22992d25bd5ee3 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Wed, 17 Jun 2026 15:56:44 +0530 Subject: [PATCH 3/3] chore: bump authsome version to 0.7.1 in uv.lock --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 95ffdc0b..9fd9bc9e 100644 --- a/uv.lock +++ b/uv.lock @@ -162,7 +162,7 @@ wheels = [ [[package]] name = "authsome" -version = "0.6.4" +version = "0.7.1" source = { editable = "." } dependencies = [ { name = "aiosqlite" },