diff --git a/packages/app/src/web/shell.tsx b/packages/app/src/web/shell.tsx index 8de519220..cdb65e62b 100644 --- a/packages/app/src/web/shell.tsx +++ b/packages/app/src/web/shell.tsx @@ -4,10 +4,16 @@ import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { sourcesAtom, sourcesOptimisticAtom, toolsAtom } from "@executor-js/react/api/atoms"; +import { + connectionsAtom, + sourcesAtom, + sourcesOptimisticAtom, + toolsAtom, +} from "@executor-js/react/api/atoms"; import { useScope, useScopeInfo } from "@executor-js/react/api/scope-context"; import { Button } from "@executor-js/react/components/button"; -import { SourceFavicon, sourcePresetIconUrl } from "@executor-js/react/components/source-favicon"; +import { sourcePresetIconUrl } from "@executor-js/react/components/source-favicon"; +import { SourceIconWithAccount } from "@executor-js/react/components/source-icon-with-account"; import { CommandPalette } from "@executor-js/react/components/command-palette"; import { useClientPlugins, useSourcePlugins } from "@executor-js/sdk/client"; import { ServerConnectionMenu } from "./server-connection-menu"; @@ -269,6 +275,8 @@ function PluginNav(props: { pathname: string; onNavigate?: () => void }) { function SourceList(props: { pathname: string; onNavigate?: () => void }) { const scopeId = useScope(); const sources = useAtomValue(sourcesOptimisticAtom(scopeId)); + const connectionsResult = useAtomValue(connectionsAtom(scopeId)); + const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []; const sourcePlugins = useSourcePlugins(); return AsyncResult.match(sources, { @@ -287,6 +295,9 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) { const detailPath = `/sources/${s.id}`; const active = props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`); + const connection = connections.find((candidate) => + s.connectionIds?.includes(candidate.id), + ); return ( void }) { : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground", ].join(" ")} > - {s.name} diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index 89a40fa2e..44386a483 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -88,6 +88,13 @@ export const connectionsAtom = (scopeId: ScopeId) => reactivityKeys: [ReactivityKey.connections], }); +export const connectionIdentityAtom = (scopeId: ScopeId, connectionId: ConnectionId) => + ExecutorApiClient.query("connections", "identity", { + params: { scopeId, connectionId }, + timeToLive: "1 minute", + reactivityKeys: [ReactivityKey.connections], + }); + export const secretUsagesAtom = (scopeId: ScopeId, secretId: SecretId) => ExecutorApiClient.query("secrets", "usages", { params: { scopeId, secretId }, @@ -130,6 +137,8 @@ export const removeSecret = ExecutorApiClient.mutation("secrets", "remove"); export const removeConnection = ExecutorApiClient.mutation("connections", "remove"); +export const updateConnectionIdentity = ExecutorApiClient.mutation("connections", "updateIdentity"); + export const removeSource = ExecutorApiClient.mutation("sources", "remove"); export const refreshSource = ExecutorApiClient.mutation("sources", "refresh"); diff --git a/packages/react/src/components/source-account-badge.tsx b/packages/react/src/components/source-account-badge.tsx new file mode 100644 index 000000000..d2d5737cf --- /dev/null +++ b/packages/react/src/components/source-account-badge.tsx @@ -0,0 +1,40 @@ +import { useAtomValue } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { ConnectionId, ScopeId } from "@executor-js/sdk/shared"; + +import { connectionIdentityAtom } from "../api/atoms"; + +export function SourceAccountBadge(props: { + readonly connection: { + readonly id: string; + readonly scopeId: string; + readonly identityLabel: string | null; + }; + readonly size?: "sm" | "md"; +}) { + const identityResult = useAtomValue( + connectionIdentityAtom( + ScopeId.make(props.connection.scopeId), + ConnectionId.make(props.connection.id), + ), + ); + const identity = + AsyncResult.isSuccess(identityResult) && identityResult.value.status === "available" + ? identityResult.value + : null; + const label = identity?.email ?? identity?.name ?? props.connection.identityLabel ?? "Connected"; + const sizeClass = props.size === "sm" ? "size-3 text-[7px]" : "size-4 text-[9px]"; + const badgeClass = `absolute -bottom-1 -right-1 z-10 flex ${sizeClass} items-center justify-center rounded-full border-2 border-card bg-background font-medium leading-none text-muted-foreground shadow-sm`; + + return identity?.picture ? ( + + ) : ( + + {label.slice(0, 1).toUpperCase()} + + ); +} diff --git a/packages/react/src/components/source-icon-with-account.tsx b/packages/react/src/components/source-icon-with-account.tsx new file mode 100644 index 000000000..36c6b54d2 --- /dev/null +++ b/packages/react/src/components/source-icon-with-account.tsx @@ -0,0 +1,27 @@ +import { SourceAccountBadge } from "./source-account-badge"; +import { SourceFavicon } from "./source-favicon"; + +export function SourceIconWithAccount(props: { + readonly icon?: string | null; + readonly sourceId: string; + readonly url?: string; + readonly connection?: { + readonly id: string; + readonly scopeId: string; + readonly identityLabel: string | null; + } | null; + readonly size?: "sm" | "md"; +}) { + const iconSize = props.size === "sm" ? 16 : 32; + return ( + + + {props.connection ? ( + + ) : null} + + ); +} diff --git a/packages/react/src/pages/connections.tsx b/packages/react/src/pages/connections.tsx index 786be1f5b..dd49775af 100644 --- a/packages/react/src/pages/connections.tsx +++ b/packages/react/src/pages/connections.tsx @@ -1,4 +1,5 @@ -import { Suspense } from "react"; +import { Suspense, useEffect, useState } from "react"; +import { Link } from "@tanstack/react-router"; import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import * as Exit from "effect/Exit"; @@ -6,11 +7,15 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { ConnectionId, ConnectionInUseError, ScopeId } from "@executor-js/sdk/shared"; import { toast } from "sonner"; +import { ChevronDownIcon } from "lucide-react"; import { + connectionIdentityAtom, connectionUsagesAtom, connectionsOptimisticAtom, removeConnectionOptimistic, + sourcesOptimisticAtom, + updateConnectionIdentity, } from "../api/atoms"; import { connectionWriteKeys } from "../api/reactivity-keys"; import { useScope, useScopeStack } from "../hooks/use-scope"; @@ -32,6 +37,19 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "../components/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../components/dialog"; +import { Input } from "../components/input"; +import { FieldLabel } from "../components/field"; +import { SourceIconWithAccount } from "../components/source-icon-with-account"; +import { sourcePresetIconUrl } from "../components/source-favicon"; +import { useSourcePlugins } from "@executor-js/sdk/client"; // --------------------------------------------------------------------------- // Provider display @@ -47,6 +65,22 @@ const displayProvider = (provider: string): string => providerDisplayNames[provi const isConnectionInUseError = Schema.is(ConnectionInUseError); +type ConnectionListItem = { + readonly id: string; + readonly scopeId: string; + readonly provider: string; + readonly identityLabel: string | null; + readonly expiresAt: number | null; + readonly oauthScope: string | null; + readonly identityOverride: { + readonly displayName: string | null; + readonly email: string | null; + readonly avatarUrl: string | null; + } | null; + readonly createdAt: number; + readonly updatedAt: number; +}; + const connectionScopeLabel = ( scopeId: string, stack: readonly { readonly id: string; readonly name: string }[], @@ -57,29 +91,125 @@ const connectionScopeLabel = ( return "Scoped"; }; +const splitScopes = (oauthScope: string | null): readonly string[] => + oauthScope?.split(/\s+/).filter((scope) => scope.length > 0) ?? []; + +const compactScope = (scope: string): string => { + if (!URL.canParse(scope)) return scope; + const url = new URL(scope); + const last = url.pathname.split("/").filter(Boolean).at(-1); + return last ?? scope; +}; + +type LinkedSource = { + readonly id: string; + readonly name: string; + readonly kind: string; + readonly url?: string; + readonly connectionIds?: readonly string[]; +}; + // --------------------------------------------------------------------------- // Used-by footer — same shape as the secrets page. Returns null when a // connection isn't referenced anywhere so newly-created connections // don't get a stray "Used by 0" line before any source binds to them. // --------------------------------------------------------------------------- -function ConnectionUsageFooter(props: { scopeId: ScopeId; connectionId: ConnectionId }) { - const usages = useAtomValue(connectionUsagesAtom(props.scopeId, props.connectionId)); +function ConnectionDetails(props: { + scopeId: ScopeId; + connection: ConnectionListItem; + open: boolean; +}) { + const sourcePlugins = useSourcePlugins(); + const usages = useAtomValue( + connectionUsagesAtom(props.scopeId, ConnectionId.make(props.connection.id)), + ); + const sourcesResult = useAtomValue(sourcesOptimisticAtom(props.scopeId)); + const sources = AsyncResult.isSuccess(sourcesResult) + ? (sourcesResult.value as readonly LinkedSource[]) + : []; + const allScopes = splitScopes(props.connection.oauthScope); + const connectionShape = { + id: props.connection.id, + scopeId: props.connection.scopeId, + identityLabel: props.connection.identityLabel, + }; return AsyncResult.match(usages, { onInitial: () => null, onFailure: () => null, onSuccess: ({ value }) => { - if (value.length === 0) return null; - const labels = value - .map((u) => u.ownerName ?? u.ownerId) - .filter((s, i, a) => a.indexOf(s) === i); - const visible = labels.slice(0, 3); - const hidden = labels.length - visible.length; + const linkedSources = value + .map((usage) => { + const source = sources.find((candidate) => candidate.id === usage.ownerId); + return { + id: usage.ownerId, + name: source?.name ?? usage.ownerName ?? usage.ownerId, + kind: source?.kind ?? usage.pluginId, + url: source?.url, + connectionIds: source?.connectionIds, + }; + }) + .filter((source, index, all) => all.findIndex((item) => item.id === source.id) === index); + if (!props.open) { + if (linkedSources.length === 0) return null; + const visible = linkedSources.slice(0, 2).map((source) => source.name); + const hidden = linkedSources.length - visible.length; + return ( + + Used by {visible.join(", ")} + {hidden > 0 ? ` +${hidden} more` : ""} + + ); + } return ( - - Used by {visible.join(", ")} - {hidden > 0 ? ` +${hidden} more` : ""} - +
+
+
Linked sources
+ {linkedSources.length === 0 ? ( +
No sources are using this yet.
+ ) : ( +
+ {linkedSources.map((source) => ( + + + + {source.name} + + + ))} +
+ )} +
+ {allScopes.length > 0 ? ( +
+
Granted scopes
+
+
+ {allScopes.map((scope) => ( + + {compactScope(scope)} + + ))} +
+
+
+ ) : null} +
); }, }); @@ -91,65 +221,187 @@ function ConnectionUsageFooter(props: { scopeId: ScopeId; connectionId: Connecti function ConnectionRow(props: { scopeId: ScopeId; - connection: { - id: string; - scopeId: string; - provider: string; - identityLabel: string | null; - }; + connection: ConnectionListItem; scopeStack: readonly { readonly id: string; readonly name: string }[]; onRemove: () => void; }) { const { connection } = props; + const doUpdateIdentity = useAtomSet(updateConnectionIdentity, { mode: "promiseExit" }); + const [editingIdentity, setEditingIdentity] = useState(false); + const [expanded, setExpanded] = useState(false); + const [savingIdentity, setSavingIdentity] = useState(false); + const [displayName, setDisplayName] = useState(connection.identityOverride?.displayName ?? ""); + const [email, setEmail] = useState(connection.identityOverride?.email ?? ""); + const [avatarUrl, setAvatarUrl] = useState(connection.identityOverride?.avatarUrl ?? ""); + const identityResult = useAtomValue( + connectionIdentityAtom(ScopeId.make(connection.scopeId), ConnectionId.make(connection.id)), + ); + const identity = + AsyncResult.isSuccess(identityResult) && identityResult.value.status === "available" + ? identityResult.value + : null; + const identityStatus = + AsyncResult.isSuccess(identityResult) && identityResult.value.status !== "available" + ? identityResult.value + : null; const scopeLabel = connectionScopeLabel(connection.scopeId, props.scopeStack); const displayLabel = - connection.identityLabel && connection.identityLabel.length > 0 + identity?.email ?? + identity?.name ?? + (connection.identityLabel && connection.identityLabel.length > 0 ? connection.identityLabel - : connection.id; + : connection.id); + const details = [displayProvider(connection.provider), scopeLabel]; + + useEffect(() => { + if (editingIdentity) return; + setDisplayName(connection.identityOverride?.displayName ?? ""); + setEmail(connection.identityOverride?.email ?? ""); + setAvatarUrl(connection.identityOverride?.avatarUrl ?? ""); + }, [connection.identityOverride, editingIdentity]); + + const handleSaveIdentity = async () => { + setSavingIdentity(true); + const cleanDisplayName = displayName.trim(); + const cleanEmail = email.trim(); + const cleanAvatarUrl = avatarUrl.trim(); + const identityOverride = + cleanDisplayName || cleanEmail || cleanAvatarUrl + ? { + displayName: cleanDisplayName || null, + email: cleanEmail || null, + avatarUrl: cleanAvatarUrl || null, + } + : null; + const exit = await doUpdateIdentity({ + params: { + scopeId: ScopeId.make(connection.scopeId), + connectionId: ConnectionId.make(connection.id), + }, + payload: { identityOverride }, + reactivityKeys: connectionWriteKeys, + }); + setSavingIdentity(false); + if (Exit.isFailure(exit)) { + toast.error("Failed to update account info"); + return; + } + setEditingIdentity(false); + }; return ( - - - - {displayLabel} - - - {displayProvider(connection.provider)} - + <> + + + + {identity?.picture ? ( + + ) : null} + {displayLabel} + + + {details.join(" · ")} + + {identityStatus?.status === "reauth_required" ? ( + + {identityStatus.message ?? "Connection needs re-authentication"} + + ) : null} + + + + {scopeLabel} + + + + + + setEditingIdentity(true)}> + Edit account info + + + Remove + + + + - + - - - {scopeLabel} - - - + - - - - Remove - - - - - + + + + ); } @@ -222,29 +474,15 @@ export function ConnectionsPage() { ) : ( - value.map( - (c: { - readonly id: string; - readonly scopeId: string; - readonly provider: string; - readonly identityLabel: string | null; - }) => ( - - handleRemove({ id: c.id, scopeId: ScopeId.make(c.scopeId) }) - } - /> - ), - ) + value.map((c: ConnectionListItem) => ( + handleRemove({ id: c.id, scopeId: ScopeId.make(c.scopeId) })} + /> + )) )} diff --git a/packages/react/src/pages/sources.tsx b/packages/react/src/pages/sources.tsx index dd1d2ec70..03044a2c7 100644 --- a/packages/react/src/pages/sources.tsx +++ b/packages/react/src/pages/sources.tsx @@ -6,7 +6,7 @@ import * as Exit from "effect/Exit"; import { PlusIcon } from "lucide-react"; import type { SourceDetectionResult } from "@executor-js/sdk/shared"; import { useSourcePlugins, type SourcePlugin, type SourcePreset } from "@executor-js/sdk/client"; -import { detectSource, sourcesOptimisticAtom } from "../api/atoms"; +import { connectionsAtom, detectSource, sourcesOptimisticAtom } from "../api/atoms"; import { useScope } from "../hooks/use-scope"; import { McpInstallCard } from "../components/mcp-install-card"; import { Button } from "../components/button"; @@ -29,7 +29,8 @@ import { CardStackEntryMedia, CardStackEntryTitle, } from "../components/card-stack"; -import { SourceFavicon, sourcePresetIconUrl } from "../components/source-favicon"; +import { sourcePresetIconUrl } from "../components/source-favicon"; +import { SourceIconWithAccount } from "../components/source-icon-with-account"; import { Skeleton } from "../components/skeleton"; const KIND_TO_PLUGIN_KEY: Record = { @@ -94,6 +95,7 @@ export function SourcesPage() { readonly kind: string; readonly url?: string; readonly runtime?: boolean; + readonly connectionIds?: readonly string[]; }> ).filter((source) => !source.runtime); @@ -391,9 +393,13 @@ function SourceGrid(props: { kind: string; url?: string; runtime?: boolean; + connectionIds?: readonly string[]; }[]; }) { const sourcePlugins = useSourcePlugins(); + const scopeId = useScope(); + const connectionsResult = useAtomValue(connectionsAtom(scopeId)); + const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []; const pluginByKind = useMemo(() => { const out = new Map(); for (const p of sourcePlugins) out.set(p.key, p); @@ -407,17 +413,18 @@ function SourceGrid(props: { const pluginKey = KIND_TO_PLUGIN_KEY[s.kind] ?? s.kind; const plugin = pluginByKind.get(pluginKey); const SummaryComponent = plugin?.summary; + const connection = connections.find((candidate) => + s.connectionIds?.includes(candidate.id), + ); return ( - - - + {s.name} {s.id}