mirror of
https://github.com/agentrhq/authsome.git
synced 2026-09-19 01:34:19 +08:00
feat: add agent detail view
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user