mirror of
https://github.com/agentrhq/authsome.git
synced 2026-09-19 01:34:19 +08:00
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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 “{handle}” 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() {
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user