diff --git a/ui/src/app/(authenticated)/connections/detail/page.tsx b/ui/src/app/(authenticated)/connections/detail/page.tsx index 0c196cf..17306e8 100644 --- a/ui/src/app/(authenticated)/connections/detail/page.tsx +++ b/ui/src/app/(authenticated)/connections/detail/page.tsx @@ -20,7 +20,7 @@ function ConnectionDetailContent() { provider && connection ? ["authsome-connection-detail", provider, connection, principal] : null, () => fetchConnectionDetail(provider, connection, principal), ); - const { mutate: mutateDashboard } = useSWR("authsome-dashboard", fetchDashboard); + const { data: dashboard, mutate: mutateDashboard } = useSWR("authsome-dashboard", fetchDashboard); if (!provider || !connection) { return ( @@ -45,6 +45,7 @@ function ConnectionDetailContent() { data={data} onRefresh={() => { void mutate(); void mutateDashboard(); }} principal={principal} + providers={dashboard?.providers} /> ); } diff --git a/ui/src/app/(authenticated)/connections/page.tsx b/ui/src/app/(authenticated)/connections/page.tsx index 07989ac..e60b47c 100644 --- a/ui/src/app/(authenticated)/connections/page.tsx +++ b/ui/src/app/(authenticated)/connections/page.tsx @@ -14,6 +14,7 @@ export default function ConnectionsPage() { globalConnections={data.globalConnections} isAdmin={data.account.isAdmin} onRefresh={() => void mutate()} + providers={data.providers} /> ); } diff --git a/ui/src/components/dashboard/connection-detail-view.tsx b/ui/src/components/dashboard/connection-detail-view.tsx index 6a4c216..f5c390e 100644 --- a/ui/src/components/dashboard/connection-detail-view.tsx +++ b/ui/src/components/dashboard/connection-detail-view.tsx @@ -1,20 +1,22 @@ "use client"; -import { ArrowLeft, Check, Clipboard, Eye, EyeOff, Globe2, LogOut } from "lucide-react"; +import { Check, Clipboard, Clock, Eye, EyeOff, Globe2, KeyRound, Link2, LogOut, Server, Shield } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; import useSWR from "swr"; import { - KeyValue, + ProviderLogo, + StatusBadge, connectionDetailHref, providerDetailHref, } from "@/components/dashboard/dashboard-primitives"; import { currentBrowserPath, isUnauthorized } from "@/components/dashboard/dashboard-routing"; import { DashboardDetailShell, ErrorState, LoadingScreen } from "@/components/dashboard/dashboard-shell"; +import { Badge } from "@/components/ui/badge"; import { Button, buttonVariants } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog, DialogContent, @@ -23,8 +25,10 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { H3 } from "@/components/ui/typography"; import { ConnectionDetail, + DashboardData, fetchConnectionDetail, fetchDashboard, logoutConnection, @@ -87,7 +91,7 @@ export function AuthsomeConnectionDetail({ return ( - { void mutate(); void mutateDashboard(); }} principal={principal} /> + { void mutate(); void mutateDashboard(); }} principal={principal} providers={dashboard.providers} /> ); } @@ -96,58 +100,173 @@ export function ConnectionDetailBody({ data, onRefresh, principal, + providers, }: { data: ConnectionDetail; onRefresh: () => void; principal?: string; + providers?: DashboardData["providers"]; }) { + const provider = providers?.find((p) => p.name === data.provider); + const hasSecrets = data.secrets.access_token || data.secrets.refresh_token || data.secrets.api_key || Object.keys(data.secrets.credentials).length > 0; + const hasEndpoints = data.base_url || data.api_url; + return ( -
- - - Back to connections - -
-
-

{data.provider_display_name}

-

{data.connection_name}

+
+
+
+ {provider ? ( + + ) : null} +
+
+ + {data.provider_display_name} + + {data.is_global ? ( + + + Global + + ) : null} +
+

{data.connection_name}

+
-
- - - - - - - - - - - - - - - - - Secrets - - - - - - {Object.entries(data.secrets.credentials).map(([key, value]) => ( - - ))} - - +
+
+ + + + + Identity & Access + + + + + + + + {authTypeLabel(data.auth_type)} + + + {data.principal_id || "-"} + + + {data.identity || "-"} + + {data.scopes.length > 0 ? ( +
+ +
+ {data.scopes.map((scope) => ( + {scope} + ))} +
+
+
+ ) : null} +
+
+ + + + + + Token + + + + + {data.token_type || "-"} + + + {formatTimestamp(data.obtained_at)} + + + {formatTimestamp(data.expires_at)} + + + + + {hasEndpoints ? ( + + + + + Endpoints + + + + {data.base_url ? ( + + {data.base_url} + + ) : null} + {data.api_url ? ( + + {data.api_url} + + ) : null} + + + ) : null} +
+ + {hasSecrets ? ( + + + + + Secrets + + Encrypted at rest in the vault. + + + + + + {Object.entries(data.secrets.credentials).map(([key, value]) => ( + + ))} + + + ) : null}
); } +function DetailField({ children, label }: { children: ReactNode; label: string }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +function authTypeLabel(authType: string): string { + return authType === "oauth2" ? "OAuth 2.0" : authType === "api_key" ? "API Key" : authType || "-"; +} + +function formatTimestamp(value: string | null): string { + if (!value) return "-"; + const parsed = new Date(value); + if (Number.isNaN(parsed.valueOf())) return value; + return parsed.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + }); +} + function redactedValue(value: string): string { return value.length <= 8 ? "********" : `${value.slice(0, 4)}...${value.slice(-4)}`; } @@ -164,10 +283,10 @@ function SecretValue({ label, value }: { label: string; value: string | null }) } return ( -
-
{label}
-
- {revealed ? value : redactedValue(value)} +
+
{label}
+
+ {revealed ? value : redactedValue(value)}
@@ -243,7 +361,7 @@ function ConnectionActions({ return ( <> -
+
{data.can_set_default ? (
) : null} {data.can_set_global ? ( - @@ -269,21 +388,25 @@ function ConnectionActions({ Logout
-
- {globalMessage?.text} -
+ {globalMessage ? ( +
+ {globalMessage.text} +
+ ) : null}
Logout connection - This removes the stored credentials for this connection. + + This removes the stored credentials for “{data.connection_name}” on {data.provider_display_name}. + + <> + + + + + Remove global connection + + This will remove the global fallback for {providerDisplayName} ({connectionName}). + Agents without their own connection will no longer have access. + + + + + + + + + ); } diff --git a/ui/src/components/dashboard/provider-detail-view.tsx b/ui/src/components/dashboard/provider-detail-view.tsx index 81cbd0a..695a92f 100644 --- a/ui/src/components/dashboard/provider-detail-view.tsx +++ b/ui/src/components/dashboard/provider-detail-view.tsx @@ -1,24 +1,25 @@ "use client"; -import { ArrowLeft, Eye, EyeOff, LogIn, Save } from "lucide-react"; +import { Check, Clipboard, ExternalLink, Eye, EyeOff, Link2, LogIn, Save, Settings, UserRound } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useMemo, useState } from "react"; +import { type ReactNode, useEffect, useMemo, useState } from "react"; import useSWR from "swr"; import { - KeyValue, ProviderLogo, StatusBadge, connectionDetailHref, providerDetailHref, } from "@/components/dashboard/dashboard-primitives"; +import { PageEmptyState } from "@/components/dashboard/page-state"; import { currentBrowserPath, isUnauthorized } from "@/components/dashboard/dashboard-routing"; import { DashboardDetailShell, ErrorState, LoadingScreen } from "@/components/dashboard/dashboard-shell"; import { NamedConnectionDialog, NamedConnectionProvider, } from "@/components/dashboard/provider-views"; +import { Badge } from "@/components/ui/badge"; import { Button, buttonVariants } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { @@ -30,6 +31,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { H3 } from "@/components/ui/typography"; import { ProviderDetail, fetchDashboard, @@ -37,7 +39,6 @@ import { revokeProvider, updateProviderConfiguration, } from "@/lib/authsome-api"; -import { cn } from "@/lib/utils"; function detailProviderDisplayName(data: ProviderDetail): string { return data.provider.display_name || data.provider.name; @@ -108,25 +109,26 @@ export function ProviderDetailBody({ data, onRefresh }: { data: ProviderDetail; const [dialogProvider, setDialogProvider] = useState(null); const hasDefaultConnection = data.connections.some((connection) => connection.connection_name === "default"); const dialogData = { displayName, name: data.provider.name }; + const hasConnections = data.connections.length > 0 || data.principal_usage.some((g) => g.connections.length > 0); + const providerStatus = hasConnections ? "connected" : "available"; return ( -
- - - Back to providers - -
-
- +
+
+
+
-

{displayName}

-

+

+

{displayName}

+ +
+ {description || detailProviderApiUrl(data)} -

+
{hasDefaultConnection ? ( - @@ -134,7 +136,7 @@ export function ProviderDetailBody({ data, onRefresh }: { data: ProviderDetail;
- @@ -143,22 +145,48 @@ export function ProviderDetailBody({ data, onRefresh }: { data: ProviderDetail;
-
-
+
+
- - - - - {data.provider.docs_url ? : null} - {data.show_callback_helper && data.callback_url ? ( - + + + + Provider Details + + + + + {data.provider.name} + + + {detailAuthTypeLabel(data.provider.auth_type)} + + + {detailProviderApiUrl(data) || "-"} + + {data.provider.docs_url ? ( + + + {data.provider.docs_url.replace(/^https?:\/\//, "").replace(/\/$/, "")} + + + + ) : null} + {data.provider.auth_type !== "api_key" && data.show_callback_helper && data.callback_url ? ( +
+ +
) : null}
-
+
{showsConfiguration ? ( data.account.is_admin ? ( - Configuration + + + Configuration + Managed by the admin. @@ -181,6 +212,43 @@ export function ProviderDetailBody({ data, onRefresh }: { data: ProviderDetail; ); } +function DetailField({ children, label }: { children: ReactNode; label: string }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +function CopyableField({ label, value }: { label: string; value: string }) { + const [copied, setCopied] = useState(false); + + async function copy() { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1200); + } + + return ( +
+
{label}
+
+ {value} + +
+
+ ); +} + function ProviderConfigurationForm({ data, onRefresh }: { data: ProviderDetail; onRefresh: () => void }) { const initialValues = useMemo(() => { const values: Record = {}; @@ -191,54 +259,75 @@ function ProviderConfigurationForm({ data, onRefresh }: { data: ProviderDetail; }, [data.configuration_fields]); const [values, setValues] = useState(initialValues); const [saving, setSaving] = useState(false); - const [message, setMessage] = useState(""); + const [message, setMessage] = useState<{ text: string; tone: "error" | "success" | "muted" } | null>(null); async function save() { setSaving(true); - setMessage(""); + setMessage(null); try { const result = await updateProviderConfiguration(data.provider.name, values); - setMessage(result.changed ? "Configuration updated." : "No changes to save."); + setMessage({ + text: result.changed ? "Configuration updated." : "No changes to save.", + tone: result.changed ? "success" : "muted", + }); onRefresh(); } catch (error) { - setMessage(error instanceof Error ? error.message : "Configuration could not be saved."); + setMessage({ + text: error instanceof Error ? error.message : "Configuration could not be saved.", + tone: "error", + }); } finally { setSaving(false); } } return ( - - - Configuration - Provider-level inputs required before users can connect. - - - {data.configuration_warning ? ( -
- {data.configuration_warning} -
- ) : null} - {data.configuration_fields.map((field) => ( - setValues((current) => ({ ...current, [field.name]: value }))} - value={values[field.name] || ""} - /> - ))} - {message ?
{message}
: null} - - -
-
+ <> + + + + + Configuration + + Provider-level inputs required before users can connect. + + + {data.configuration_warning ? ( +
+ {data.configuration_warning} +
+ ) : null} + {data.configuration_fields.map((field) => ( + setValues((current) => ({ ...current, [field.name]: value }))} + value={values[field.name] || ""} + /> + ))} + {message ? ( +
+ {message.text} +
+ ) : null} + +
+
+ + ); } function RevokeProviderButton({ data, onRefresh }: { data: ProviderDetail; onRefresh: () => void }) { + const displayName = detailProviderDisplayName(data); + const connectionCount = data.connections.length; const [open, setOpen] = useState(false); const [working, setWorking] = useState(false); const [message, setMessage] = useState(""); @@ -259,14 +348,26 @@ function RevokeProviderButton({ data, onRefresh }: { data: ProviderDetail; onRef return ( <> - + + +
+
Revoke app
+
Remove all credentials for this provider.
+
+ +
+
- Revoke app - All connections for this provider will be revoked. + Revoke {displayName} + + {connectionCount + ? `This will revoke all ${connectionCount} connection${connectionCount === 1 ? "" : "s"} for ${displayName}. Stored credentials will be removed.` + : `This will revoke the app registration for ${displayName}.`} + {message ?
{message}
: null} @@ -287,20 +388,34 @@ function ProviderUsage({ data }: { data: ProviderDetail }) { const groups = data.account.is_admin ? data.principal_usage : [{ principal_id: data.account.principal_id || "current", email: null, connections: data.connections }]; + const totalConnections = groups.reduce((sum, group) => sum + group.connections.length, 0); return ( - Connections +
+ + + Connections + + {totalConnections > 0 ? ( + {totalConnections} + ) : null} +
- {groups.length ? ( - groups.map((group) => ( + {groups.length && totalConnections > 0 ? ( + groups.filter((g) => g.connections.length > 0).map((group) => (
-
{group.email || group.principal_id}
+ {data.account.is_admin ? ( +
+ + {group.email || group.principal_id} +
+ ) : null} {group.connections.map((connection) => ( - {connection.connection_name} +
+
{connection.connection_name}
+ {connection.account_label ? ( +
{connection.account_label}
+ ) : null} +
))}
)) ) : ( -
No connections found.
+ )}
@@ -335,9 +458,9 @@ function ConfigurationFieldInput({ const isSecret = field.secret; return ( -