feat: merge agent detail view with enhanced agents list and review fixes

Merge feature/agent-detail-view (#444) and resolve conflicts, keeping the
PR's backend-driven detail page (API endpoint, audit scoping, tests) and
our enhanced agents list view (search, card headers, claim badges).

Also fixes three review issues:
- Add 403 guard for non-admin audit identity filter (prevents querying
  events for identities the caller does not own)
- Add idx_audit_events_identity index via migration v2
- Update test assertions for new 403 behavior and migration count

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manoj Bajaj
2026-06-17 06:06:12 -07:00
15 changed files with 319 additions and 251 deletions
+11
View File
@@ -20,14 +20,25 @@ 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
scope: Literal["global", "principal"] = "global" if effective_principal_id is None else "principal"
if identity is not None and effective_principal_id is not None:
claim = await request.app.state.store.identity_claims.resolve(identity)
if claim is None or claim.principal_id != effective_principal_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Identity does not belong to this principal",
)
try:
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
+7 -1
View File
@@ -33,7 +33,12 @@ class StoreMigration:
def build_migrations(backend: StoreBackend) -> list[StoreMigration]:
return [StoreMigration(version=1, statements=tuple(build_schema(backend)))]
return [
StoreMigration(version=1, statements=tuple(build_schema(backend))),
StoreMigration(
version=2, statements=("CREATE INDEX IF NOT EXISTS idx_audit_events_identity ON audit_events(identity)",)
),
]
class StoreDatabase:
@@ -292,6 +297,7 @@ def build_schema(backend: StoreBackend) -> list[str]:
")",
"CREATE INDEX IF NOT EXISTS idx_audit_events_timestamp ON audit_events(timestamp DESC, event_id DESC)",
"CREATE INDEX IF NOT EXISTS idx_audit_events_principal ON audit_events(principal_id)",
"CREATE INDEX IF NOT EXISTS idx_audit_events_identity ON audit_events(identity)",
]
@@ -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,
)
@@ -60,7 +60,7 @@ async def test_sqlite_migrations_are_idempotent(tmp_path: Path) -> None:
finally:
await second.close()
assert row == {"count": 1}
assert row == {"count": len(build_migrations("sqlite"))}
def test_postgres_url_uses_postgres_backend(tmp_path: Path) -> None:
+36 -7
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:
@@ -266,13 +266,42 @@ def test_non_admin_audit_query_cannot_widen_scope_with_principal_or_identity(
),
)
assert response.status_code == status.HTTP_403_FORBIDDEN
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
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"])
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:
+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")
@@ -6,52 +6,56 @@ 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 { fetchDashboard } from "@/lib/authsome-api";
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 handle = searchParams.get("agent") ?? "";
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 }));
const { data } = useSWR("authsome-dashboard", fetchDashboard);
if (!handle) {
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>
);
}
if (!data) return null;
const agent = data.agents.find((a) => a.handle === handle);
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="w-full max-w-md border-border/50 shadow-none">
<CardHeader>
<CardTitle>Agent not found</CardTitle>
<CardDescription>The agent &ldquo;{handle}&rdquo; was not found in this account.</CardDescription>
</CardHeader>
<CardContent>
<Link className={buttonVariants({ variant: "outline" })} href="/agents">
Back to agents
</Link>
<Card className="shadow-none border-border/50">
<CardContent className="p-0">
<PageLoadingState columns={4} />
</CardContent>
</Card>
);
}
return <AgentDetailBody agent={agent} data={data} />;
return <AgentDetailBody agent={detail.data} events={audit.data.events} />;
}
export default function AgentDetailPage() {
+1 -2
View File
@@ -103,10 +103,9 @@ function buildBreadcrumbs(
}
if (first === "agents") {
const agentHandle = searchParams.get("agent") ?? "";
return [
{ label: parent, href: parentHref },
{ label: agentHandle || "Detail" },
{ label: searchParams.get("agent") || "Detail" },
];
}
+113 -199
View File
@@ -1,222 +1,136 @@
"use client";
import { KeyRound, Link2, Mail, Shield, UserRound } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ShieldCheck, UserRound } from "lucide-react";
import type { ReactNode } from "react";
import {
INTERACTIVE_ROW_CLASS,
ProviderLogo,
StatusBadge,
connectionDetailHref,
} from "@/components/dashboard/dashboard-primitives";
import { agentDetailHref } from "@/components/dashboard/overview-views";
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 { H3 } from "@/components/ui/typography";
import { AgentRow, DashboardData } from "@/lib/authsome-api";
function DetailField({ children, label }: { children: React.ReactNode; label: string }) {
return (
<div className="grid gap-1">
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">{label}</div>
<div>{children}</div>
</div>
);
}
function ClaimStatusBadge({ status }: { status: string }) {
if (status === "accepted") {
return (
<Badge className="border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-400" variant="outline">
Accepted
</Badge>
);
}
if (status === "pending") {
return (
<Badge className="border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-400" variant="outline">
Pending
</Badge>
);
}
if (status === "rejected") {
return (
<Badge className="border-destructive/60 bg-destructive/10 text-destructive" variant="outline">
Rejected
</Badge>
);
}
return <Badge variant="outline">{status}</Badge>;
}
import { AgentDetail, AuditRow } from "@/lib/authsome-api";
import { cn } from "@/lib/utils";
export function AgentDetailBody({
agent,
data,
events,
}: {
agent: AgentRow;
data: DashboardData;
agent: AgentDetail;
events: AuditRow[];
}) {
const router = useRouter();
const providerMap = new Map(data.providers.map((p) => [p.name, p]));
const agentConnections = data.connections.filter(() => agent.isActive);
return (
<div className="grid gap-5">
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border/50 pb-4">
<div className="flex items-center gap-3 min-w-0">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-muted">
<UserRound className="size-5 text-muted-foreground" />
</span>
<div className="min-w-0">
<div className="flex items-center gap-2">
<Link className="text-sm text-muted-foreground hover:text-foreground hover:underline" href="/agents">
Agents
</Link>
</div>
<H3 className="mt-0.5 leading-tight">{agent.handle}</H3>
</div>
</div>
<div className="flex items-center gap-2">
<ClaimStatusBadge status={agent.claimStatus} />
{agent.isActive ? (
<Badge className="border-primary/30 bg-primary/10 text-primary" variant="outline">
Active
</Badge>
) : null}
</div>
</div>
<SectionHeader
description="Cryptographic signing identity claimed to this account."
title={agent.handle}
/>
<div className="grid gap-4 lg:grid-cols-2">
<Card className="border-border/50 shadow-none">
<CardHeader className="pb-0">
<CardTitle className="flex items-center gap-2">
<Shield className="size-4 text-muted-foreground" />
Identity
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 pt-4">
<DetailField label="Handle">
<span className="text-sm font-medium">{agent.handle}</span>
</DetailField>
<DetailField label="Claim Status">
<ClaimStatusBadge status={agent.claimStatus} />
</DetailField>
<DetailField label="Session">
<span className="text-sm">{agent.isActive ? "Active (current session)" : "Inactive"}</span>
</DetailField>
</CardContent>
</Card>
<Card className="border-border/50 shadow-none">
<CardHeader className="pb-0">
<CardTitle className="flex items-center gap-2">
<Mail className="size-4 text-muted-foreground" />
Owner
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 pt-4">
<DetailField label="Account">
<span className="text-sm">{data.account.email || "-"}</span>
</DetailField>
<DetailField label="Role">
{data.account.roleLabel ? (
<Badge
className={data.account.roleLabel === "Admin" ? "border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-400" : ""}
variant="outline"
>
{data.account.roleLabel}
</Badge>
) : (
<span className="text-sm">-</span>
)}
</DetailField>
<DetailField label="Principal ID">
<code className="text-xs font-mono text-muted-foreground">{data.account.principalId || "-"}</code>
</DetailField>
</CardContent>
</Card>
</div>
{agent.isActive ? (
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.8fr)]">
<Card className="shadow-none border-border/50">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Link2 className="size-4 text-muted-foreground" />
Connections
</CardTitle>
<CardDescription>Connections available to this agent in the current vault.</CardDescription>
</div>
{agentConnections.length ? (
<Badge variant="outline">{agentConnections.length}</Badge>
) : null}
</div>
<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="p-0">
{agentConnections.length ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Connection</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{agentConnections.map((row) => {
const href = connectionDetailHref(row.providerName, row.connectionName);
const provider = providerMap.get(row.providerName);
return (
<TableRow
className={INTERACTIVE_ROW_CLASS}
key={`${row.providerName}:${row.connectionName}`}
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 min-w-0 items-center gap-2.5">
{provider ? (
<ProviderLogo className="size-7 shrink-0" initial={provider.logoInitial} logo={provider.logo} />
) : null}
<span className="truncate font-medium">{row.connectionName}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground">{row.providerDisplayName}</TableCell>
<TableCell className="text-muted-foreground">{row.authTypeLabel}</TableCell>
<TableCell>
<StatusBadge status={row.status} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
) : (
<div className="p-4">
<PageEmptyState
actionLabel="Browse providers"
description="Connect a provider to create a connection for this agent."
href="/providers"
title="No connections yet"
/>
</div>
)}
<CardContent className="grid gap-3">
<DetailRow label="Handle" value={agent.handle} />
<DetailRow label="DID" value={agent.did} />
<DetailRow label="Created" value={formatDate(agent.created_at)} />
<DetailRow label="Claimed" value={formatDate(agent.claimed_at)} />
</CardContent>
</Card>
) : null}
<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="Principal" value={agent.principal_email || agent.principal_id || "-"} />
<DetailRow 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({
label,
value,
}: {
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>
<div className="min-w-0 break-all text-sm font-medium">{value}</div>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const normalized = status.toLowerCase();
const colorClass =
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"
: "";
return (
<Badge className={cn(colorClass)} 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,13 +2,14 @@
import { UserRound } from "lucide-react";
import Link from "next/link";
import { useRef, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useMemo, useRef, useState } from "react";
import useSWR from "swr";
import {
INTERACTIVE_ROW_CLASS,
SearchInput,
agentDetailHref,
} from "@/components/dashboard/dashboard-primitives";
import { PageEmptyState, PageErrorState, PageLoadingState } from "@/components/dashboard/page-state";
import { ProviderSummary } from "@/components/dashboard/provider-views";
@@ -17,8 +18,8 @@ import { Badge } from "@/components/ui/badge";
import { Button, 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 { H4, Muted } from "@/components/ui/typography";
import { AgentRow, DashboardData, PrincipalRow, fetchAuditEvents, fetchPrincipals } from "@/lib/authsome-api";
import { H4 } from "@/components/ui/typography";
import { DashboardData, PrincipalRow, fetchAuditEvents, fetchPrincipals } from "@/lib/authsome-api";
export function DashboardView({ data }: { data: DashboardData }) {
const recentEvents = data.audit.events.slice(0, 5);
@@ -116,10 +117,6 @@ export function DashboardView({ data }: { data: DashboardData }) {
);
}
export function agentDetailHref(handle: string): string {
return `/agents/detail?${new URLSearchParams({ agent: handle }).toString()}`;
}
function AgentClaimBadge({ status }: { status: string }) {
if (status === "accepted") {
return (
+19
View File
@@ -56,6 +56,7 @@ export type AuditRow = {
export type AuditEventsQuery = {
cursor?: string | null;
identity?: string | null;
limit?: number;
};
@@ -96,6 +97,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;
@@ -577,6 +591,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();
}
@@ -677,6 +692,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>,
Generated
+1 -1
View File
@@ -162,7 +162,7 @@ wheels = [
[[package]]
name = "authsome"
version = "0.6.4"
version = "0.7.1"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },