diff --git a/src/authsome/server/routes/connections.py b/src/authsome/server/routes/connections.py index b2a6a813..6b950a30 100644 --- a/src/authsome/server/routes/connections.py +++ b/src/authsome/server/routes/connections.py @@ -7,6 +7,7 @@ from authsome.server.analytics import capture_event from authsome.server.credential_service import CredentialService from authsome.server.routes._deps import ( + build_auth_service, get_daemon_or_browser_auth_service, get_protected_auth_service, get_vault_registry, @@ -32,6 +33,13 @@ async def _connection_detail( ) -> ConnectionDetailResponse: definition = await auth.get_provider(provider) record = await auth.get_connection(provider, connection) + global_pointer = await auth.global_connections.get(provider) + is_global = ( + global_pointer is not None + and global_pointer.owner_principal_id == record.principal_id + and global_pointer.owner_vault_id == record.vault_id + and global_pointer.connection_name == record.connection_name + ) return ConnectionDetailResponse( provider=record.provider, provider_display_name=definition.display_name, @@ -56,15 +64,41 @@ async def _connection_detail( ), can_set_default=can_set_default, can_set_global=can_set_global, + is_global=is_global, ) +async def _provider_connection_counts(request: Request, auth: CredentialService) -> dict[str, int]: + if auth.principal_role != PrincipalRole.ADMIN: + return {} + + counts: dict[str, int] = {} + for principal in await request.app.state.store.principals.list_all(): + resolved = await request.app.state.ownership_resolver.resolve_for_principal(principal_id=principal.principal_id) + if resolved is None: + continue + principal_auth = build_auth_service( + request, + identity=None, + principal_id=principal.principal_id, + principal_role=principal.role, + vault_id=resolved.vault_id, + ) + for group in await principal_auth.list_connections(): + counts[group["name"]] = counts.get(group["name"], 0) + len(group["connections"]) + return counts + + @router.get("/connections") -async def list_connections(auth: CredentialService = Depends(get_daemon_or_browser_auth_service)): +async def list_connections( + request: Request, + auth: CredentialService = Depends(get_daemon_or_browser_auth_service), +): by_source = await auth.list_providers_by_source() return { "connections": await auth.list_connections(), "global_connections": [row.model_dump(mode="json") for row in await auth.list_global_connection_summaries()], + "provider_connection_counts": await _provider_connection_counts(request, auth), "by_source": { source: [provider.model_dump(mode="json") for provider in providers] for source, providers in by_source.items() diff --git a/src/authsome/server/schemas.py b/src/authsome/server/schemas.py index c515994b..d550a037 100644 --- a/src/authsome/server/schemas.py +++ b/src/authsome/server/schemas.py @@ -226,3 +226,4 @@ class ConnectionDetailResponse(BaseModel): secrets: ConnectionSecretsResponse = Field(default_factory=ConnectionSecretsResponse) can_set_default: bool = False can_set_global: bool = False + is_global: bool = False diff --git a/tests/server/test_connection_details.py b/tests/server/test_connection_details.py index 370b7782..1fc76d5e 100644 --- a/tests/server/test_connection_details.py +++ b/tests/server/test_connection_details.py @@ -129,6 +129,36 @@ def test_admin_can_make_own_connection_global_from_detail(monkeypatch, tmp_path: assert response.json()["can_set_global"] is True +def test_connection_detail_marks_current_global_connection(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + with create_server_test_client() as client: + admin = _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") + admin_ctx = asyncio.run(client.app.state.ownership_resolver.resolve(identity=admin.handle)) + _put_connection(client, admin_ctx.principal_id, admin_ctx.vault_id, "secondary") + _put_connection(client, admin_ctx.principal_id, admin_ctx.vault_id, "default") + assert ( + client.post( + "/api/connections/github/default/global", + headers=_auth_header(tmp_path, "POST", "/api/connections/github/default/global", handle=admin.handle), + ).status_code + == status.HTTP_200_OK + ) + + global_response = client.get( + "/api/connections/github/default/detail", + headers=_auth_header(tmp_path, "GET", "/api/connections/github/default/detail", handle=admin.handle), + ) + secondary_response = client.get( + "/api/connections/github/secondary/detail", + headers=_auth_header(tmp_path, "GET", "/api/connections/github/secondary/detail", handle=admin.handle), + ) + + assert global_response.status_code == status.HTTP_200_OK + assert global_response.json()["is_global"] is True + assert secondary_response.status_code == status.HTTP_200_OK + assert secondary_response.json()["is_global"] is False + + def test_admin_logout_targets_other_principal_connection(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) with create_server_test_client() as client: diff --git a/tests/server/test_global_connections.py b/tests/server/test_global_connections.py index ee980ec6..7e1f22f9 100644 --- a/tests/server/test_global_connections.py +++ b/tests/server/test_global_connections.py @@ -114,6 +114,32 @@ def test_admin_can_make_connection_global_and_user_sees_redacted_summary(monkeyp assert raw_response.status_code == status.HTTP_404_NOT_FOUND +def test_admin_connections_include_provider_counts_across_principals(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + with create_server_test_client() as client: + admin = _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") + _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com") + _claim_identity(client, tmp_path, "calmly-simply-boldly-0043", email="second@example.com") + user_ctx = asyncio.run(client.app.state.ownership_resolver.resolve(identity="steady-wisely-boldly-0042")) + second_ctx = asyncio.run(client.app.state.ownership_resolver.resolve(identity="calmly-simply-boldly-0043")) + _put_connection(client, user_ctx.principal_id, user_ctx.vault_id, "default") + _put_connection(client, second_ctx.principal_id, second_ctx.vault_id, "team") + + admin_response = client.get( + "/api/connections", + headers=_auth_header(tmp_path, "GET", "/api/connections", handle=admin.handle), + ) + user_response = client.get( + "/api/connections", + headers=_auth_header(tmp_path, "GET", "/api/connections", handle="steady-wisely-boldly-0042"), + ) + + assert admin_response.status_code == status.HTTP_200_OK + assert admin_response.json()["provider_connection_counts"] == {"github": 2} + assert user_response.status_code == status.HTTP_200_OK + assert user_response.json()["provider_connection_counts"] == {} + + def test_global_connection_resolves_for_explicit_default_and_proxy_routes(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) with create_server_test_client() as client: diff --git a/ui/src/app/(authenticated)/layout.tsx b/ui/src/app/(authenticated)/layout.tsx index 58828a91..5e754f51 100644 --- a/ui/src/app/(authenticated)/layout.tsx +++ b/ui/src/app/(authenticated)/layout.tsx @@ -37,7 +37,6 @@ function pathToView(pathname: string): View { connections: "connections", agents: "agents", principal: "principals", - vault: "vault", audit: "audit", settings: "settings", }; @@ -57,7 +56,6 @@ function buildBreadcrumbs( connections: "Connections", agents: "Agents", principal: "Principals", - vault: "Vault", audit: "Audit Log", settings: "Settings", }; diff --git a/ui/src/app/(authenticated)/vault/page.tsx b/ui/src/app/(authenticated)/vault/page.tsx deleted file mode 100644 index 5d3c9b7f..00000000 --- a/ui/src/app/(authenticated)/vault/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -"use client"; - -import useSWR from "swr"; - -import { VaultView } from "@/components/authsome-dashboard"; -import { fetchDashboard } from "@/lib/authsome-api"; - -export default function VaultPage() { - const { data } = useSWR("authsome-dashboard", fetchDashboard); - if (!data) return null; - return ; -} diff --git a/ui/src/app/globals.css b/ui/src/app/globals.css index 07f068ca..5807bce3 100644 --- a/ui/src/app/globals.css +++ b/ui/src/app/globals.css @@ -148,4 +148,12 @@ textarea { letter-spacing: 0; } + button:not(:disabled), + [role="button"]:not([aria-disabled="true"]) { + cursor: pointer; + } + button:disabled, + [role="button"][aria-disabled="true"] { + cursor: not-allowed; + } } diff --git a/ui/src/app/not-found.tsx b/ui/src/app/not-found.tsx new file mode 100644 index 00000000..a0b06c8c --- /dev/null +++ b/ui/src/app/not-found.tsx @@ -0,0 +1,27 @@ +import Link from "next/link"; +import { ArrowLeft, CircleAlert } from "lucide-react"; + +import { buttonVariants } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function NotFound() { + return ( +
+ + +
+ +
+ Page not found + The dashboard route is missing or no longer available. +
+ + + + Back to dashboard + + +
+
+ ); +} diff --git a/ui/src/components/authsome-dashboard.tsx b/ui/src/components/authsome-dashboard.tsx index ec0fce08..e84ee9cf 100644 --- a/ui/src/components/authsome-dashboard.tsx +++ b/ui/src/components/authsome-dashboard.tsx @@ -1,6 +1,7 @@ "use client"; import { + ArrowLeft, AppWindow, BookOpen, Check, @@ -8,7 +9,8 @@ import { CircleAlert, Clipboard, ClipboardList, - Database, + Eye, + EyeOff, GitBranch, Globe2, KeyRound, @@ -88,7 +90,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; -export type View = "dashboard" | "providers" | "connections" | "agents" | "principals" | "vault" | "audit" | "settings"; +export type View = "dashboard" | "providers" | "connections" | "agents" | "principals" | "audit" | "settings"; type NavItem = { id: View; @@ -104,7 +106,6 @@ const NAV_ITEMS: NavItem[] = [ { id: "connections", href: "/connections", label: "Connections", icon: }, { id: "agents", href: "/agents", label: "Agents", icon: }, { id: "principals", href: "/principal", label: "Principals", icon: , adminOnly: true }, - { id: "vault", href: "/vault", label: "Vault", icon: }, { id: "audit", href: "/audit", label: "Audit Log", icon: , adminOnly: true }, { id: "settings", href: "/settings", label: "Settings", icon: }, ]; @@ -112,6 +113,10 @@ const NAV_ITEMS: NavItem[] = [ const NEXT_URL = "/"; const ADVANCED_SESSION_FIELD_NAMES = new Set(["host_url", "base_url", "api_url", "scopes"]); const LOGO_DEV_TOKEN = "pk_BhJg_kBbQPqNGuuWcNs9Cg"; +const INTERACTIVE_CARD_CLASS = + "cursor-pointer border-border/50 shadow-none transition-all hover:border-primary/60 hover:bg-primary/[0.03] hover:shadow-sm"; +const INTERACTIVE_ROW_CLASS = + "cursor-pointer transition-colors hover:bg-primary/[0.03] focus-visible:bg-primary/[0.03] focus-visible:outline-none"; export function isUnauthorized(error: unknown): boolean { return error instanceof ApiError && error.status === 401; @@ -715,13 +720,13 @@ export function LoadingScreen() { export function ErrorState({ onRetry }: { onRetry: () => void }) { return (
- + - - - Dashboard Unavailable + + + Dashboard Unavailable - The daemon did not return dashboard data. + The local daemon is not reachable. Start it again, then retry. - + ) : ( +
+ + + +
+ )} +
@@ -1702,16 +1740,12 @@ function ProviderConfigurationForm({ data, onRefresh }: { data: ProviderDetail;
) : null} {data.configuration_fields.map((field) => ( - + setValues((current) => ({ ...current, [field.name]: value }))} + value={values[field.name] || ""} + /> ))} {message ?
{message}
: null} + ) : null} +
+ {field.pattern_hint ? {field.pattern_hint} : null} + + ); +} + +function redactedValue(value: string): string { + return value.length <= 8 ? "********" : `${value.slice(0, 4)}...${value.slice(-4)}`; +} + function SecretValue({ label, value }: { label: string; value: string | null }) { const [copied, setCopied] = useState(false); + const [revealed, setRevealed] = useState(false); if (!value) return null; async function copy() { @@ -1882,8 +1961,24 @@ function SecretValue({ label, value }: { label: string; value: string | null })
{label}
- {value} - +
@@ -1902,6 +1997,10 @@ export function ConnectionDetailBody({ }) { return (
+ + + Back to connections +

{data.provider_display_name}

@@ -1952,10 +2051,11 @@ function ConnectionActions({ onRefresh: () => void; principal?: string; }) { + const router = useRouter(); const [open, setOpen] = useState(false); const [working, setWorking] = useState(false); const [globalWorking, setGlobalWorking] = useState(false); - const [globalMessage, setGlobalMessage] = useState(""); + const [globalMessage, setGlobalMessage] = useState<{ text: string; tone: "error" | "success" } | null>(null); async function logout() { setWorking(true); @@ -1963,20 +2063,29 @@ function ConnectionActions({ await logoutConnection(data.provider, data.connection_name, principal); setOpen(false); onRefresh(); + router.replace("/connections"); } finally { setWorking(false); } } - async function makeGlobal() { + async function toggleGlobal() { setGlobalWorking(true); - setGlobalMessage(""); + setGlobalMessage(null); try { - await setGlobalConnection(data.provider, data.connection_name); - setGlobalMessage("Global connection updated."); + if (data.is_global) { + await unsetGlobalConnection(data.provider); + setGlobalMessage({ text: "Global connection removed.", tone: "success" }); + } else { + await setGlobalConnection(data.provider, data.connection_name); + setGlobalMessage({ text: "Global connection updated.", tone: "success" }); + } onRefresh(); } catch (error) { - setGlobalMessage(error instanceof Error ? error.message : "Global connection could not be updated."); + setGlobalMessage({ + text: error instanceof Error ? error.message : "Global connection could not be updated.", + tone: "error", + }); } finally { setGlobalWorking(false); } @@ -1984,31 +2093,41 @@ function ConnectionActions({ return ( <> -
- {data.can_set_default ? ( -
- +
+ ) : null} + {data.can_set_global ? ( + - - ) : null} - {data.can_set_global ? ( - - ) : null} - {globalMessage ?
{globalMessage}
: null} - - View provider - - +
+
+ {globalMessage?.text} +
@@ -2050,7 +2169,6 @@ function ActiveView({ connectionFilter, data, onRefresh, view }: { } if (view === "agents") return ; if (view === "principals") return ; - if (view === "vault") return ; if (view === "audit" && data.account.isAdmin) return ; if (view === "settings") return ; return ; diff --git a/ui/src/components/ui/button.tsx b/ui/src/components/ui/button.tsx index b0336017..e6f60e42 100644 --- a/ui/src/components/ui/button.tsx +++ b/ui/src/components/ui/button.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/button inline-flex shrink-0 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { diff --git a/ui/src/components/ui/sidebar.tsx b/ui/src/components/ui/sidebar.tsx index f95cbccf..69a41d30 100644 --- a/ui/src/components/ui/sidebar.tsx +++ b/ui/src/components/ui/sidebar.tsx @@ -475,7 +475,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { } const sidebarMenuButtonVariants = cva( - "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate", + "peer/menu-button group/menu-button flex w-full cursor-pointer items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate", { variants: { variant: { diff --git a/ui/src/lib/authsome-api.ts b/ui/src/lib/authsome-api.ts index ab130baa..6684256d 100644 --- a/ui/src/lib/authsome-api.ts +++ b/ui/src/lib/authsome-api.ts @@ -203,6 +203,7 @@ export type ConnectionDetail = { }; can_set_default: boolean; can_set_global: boolean; + is_global: boolean; }; type ConnectionsResponse = { @@ -211,6 +212,7 @@ type ConnectionsResponse = { connections: ConnectionSummary[]; }>; global_connections: GlobalConnectionSummary[]; + provider_connection_counts: Record; by_source: Record; }; @@ -351,8 +353,11 @@ function providerView( source: string, connections: ConnectionSummary[], globalConnections: GlobalConnectionSummary[], + providerConnectionCount?: number, ): ProviderView { const displayName = provider.display_name || provider.name; + const localConnectionCount = connections.length + globalConnections.length; + const connectionCount = providerConnectionCount ?? localConnectionCount; return { name: provider.name, displayName, @@ -363,9 +368,9 @@ function providerView( source, logo: provider.logo || null, logoInitial: (displayName[0] || "?").toUpperCase(), - status: providerStatus(connections, globalConnections), + status: providerConnectionCount && providerConnectionCount > 0 ? "connected" : providerStatus(connections, globalConnections), scopeCount: connections[0]?.scopes?.length || 0, - connectionCount: connections.length + globalConnections.length, + connectionCount, globalConnectionCount: globalConnections.length, requiresNamedLogin: connections.some((connection) => connection.connection_name === "default"), }; @@ -373,6 +378,7 @@ function providerView( function buildProviders(data: ConnectionsResponse): ProviderView[] { const connectionMap = new Map(data.connections.map((group) => [group.name, group.connections])); + const providerConnectionCounts = data.provider_connection_counts || {}; const globalConnectionMap = new Map(); for (const connection of data.global_connections || []) { const entries = globalConnectionMap.get(connection.provider) || []; @@ -381,7 +387,13 @@ function buildProviders(data: ConnectionsResponse): ProviderView[] { } return Object.entries(data.by_source).flatMap(([source, providers]) => providers.map((provider) => - providerView(provider, source, connectionMap.get(provider.name) || [], globalConnectionMap.get(provider.name) || []), + providerView( + provider, + source, + connectionMap.get(provider.name) || [], + globalConnectionMap.get(provider.name) || [], + providerConnectionCounts[provider.name], + ), ), ); }