History

Show connection identities in the UI (#872)

* Show connection identities in the UI * Polish connection details layout

Rhys Sullivan committed 5 months ago

Commit 9374a1f1 parent b0376aa6 files · +430 −96Browse files

Unified · Split

6 files
  1. packages/app/src/web/shell.tsx +16 −3
  2. packages/react/src/api/atoms.tsx +9 −0
  3. packages/react/src/components/source-account-badge.tsx +40 −0
  4. packages/react/src/components/source-icon-with-account.tsx +27 −0
  5. packages/react/src/pages/connections.tsx +321 −83
  6. packages/react/src/pages/sources.tsx +17 −10
packages/app/src/web/shell.tsx+16−3
@@ -4,10 +4,16 @@ import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; · 3 unmodified lines
44 import * as Effect from "effect/Effect";
55 import * as Exit from "effect/Exit";
66 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
7−import { sourcesAtom, sourcesOptimisticAtom, toolsAtom } from "@executor-js/react/api/atoms";
7+import {
8+ connectionsAtom,
9+ sourcesAtom,
10+ sourcesOptimisticAtom,
11+ toolsAtom,
12+} from "@executor-js/react/api/atoms";
813 import { useScope, useScopeInfo } from "@executor-js/react/api/scope-context";
914 import { Button } from "@executor-js/react/components/button";
10−import { SourceFavicon, sourcePresetIconUrl } from "@executor-js/react/components/source-favicon";
15+import { sourcePresetIconUrl } from "@executor-js/react/components/source-favicon";
16+import { SourceIconWithAccount } from "@executor-js/react/components/source-icon-with-account";
1117 import { CommandPalette } from "@executor-js/react/components/command-palette";
1218 import { useClientPlugins, useSourcePlugins } from "@executor-js/sdk/client";
1319 import { ServerConnectionMenu } from "./server-connection-menu";
@@ -269,6 +275,8 @@ function PluginNav(props: { pathname: string; onNavigate?: () => void }) { · 255 unmodified lines
269275 function SourceList(props: { pathname: string; onNavigate?: () => void }) {
270276 const scopeId = useScope();
271277 const sources = useAtomValue(sourcesOptimisticAtom(scopeId));
278+ const connectionsResult = useAtomValue(connectionsAtom(scopeId));
279+ const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : [];
272280 const sourcePlugins = useSourcePlugins();
273281
274282 return AsyncResult.match(sources, {
@@ -287,6 +295,9 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) { · 12 unmodified lines
287295 const detailPath = `/sources/${s.id}`;
288296 const active =
289297 props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`);
298+ const connection = connections.find((candidate) =>
299+ s.connectionIds?.includes(candidate.id),
300+ );
290301 return (
291302 <Link
292303 key={s.id}
@@ -300,10 +311,12 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) { · 7 unmodified lines
300311 : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
301312 ].join(" ")}
302313 >
303− <SourceFavicon
314+ <SourceIconWithAccount
304315 icon={sourcePresetIconUrl(s, sourcePlugins)}
305316 sourceId={s.id}
306317 url={s.url}
318+ connection={connection}
319+ size="sm"
307320 />
308321 <span className="flex-1 truncate">{s.name}</span>
309322 <span className="rounded bg-secondary/50 px-1 py-px text-xs font-medium text-muted-foreground">
packages/react/src/api/atoms.tsx+9−0
@@ -88,6 +88,13 @@ export const connectionsAtom = (scopeId: ScopeId) => · 87 unmodified lines
8888 reactivityKeys: [ReactivityKey.connections],
8989 });
9090
91+export const connectionIdentityAtom = (scopeId: ScopeId, connectionId: ConnectionId) =>
92+ ExecutorApiClient.query("connections", "identity", {
93+ params: { scopeId, connectionId },
94+ timeToLive: "1 minute",
95+ reactivityKeys: [ReactivityKey.connections],
96+ });
97+
9198 export const secretUsagesAtom = (scopeId: ScopeId, secretId: SecretId) =>
9299 ExecutorApiClient.query("secrets", "usages", {
93100 params: { scopeId, secretId },
@@ -130,6 +137,8 @@ export const removeSecret = ExecutorApiClient.mutation("secrets", "remove"); · 36 unmodified lines
130137
131138 export const removeConnection = ExecutorApiClient.mutation("connections", "remove");
132139
140+export const updateConnectionIdentity = ExecutorApiClient.mutation("connections", "updateIdentity");
141+
133142 export const removeSource = ExecutorApiClient.mutation("sources", "remove");
134143
135144 export const refreshSource = ExecutorApiClient.mutation("sources", "refresh");
packages/react/src/components/source-account-badge.tsx+40−0
@@ -0,0 +1,40 @@
1+import { useAtomValue } from "@effect/atom-react";
2+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3+import { ConnectionId, ScopeId } from "@executor-js/sdk/shared";
4+
5+import { connectionIdentityAtom } from "../api/atoms";
6+
7+export function SourceAccountBadge(props: {
8+ readonly connection: {
9+ readonly id: string;
10+ readonly scopeId: string;
11+ readonly identityLabel: string | null;
12+ };
13+ readonly size?: "sm" | "md";
14+}) {
15+ const identityResult = useAtomValue(
16+ connectionIdentityAtom(
17+ ScopeId.make(props.connection.scopeId),
18+ ConnectionId.make(props.connection.id),
19+ ),
20+ );
21+ const identity =
22+ AsyncResult.isSuccess(identityResult) && identityResult.value.status === "available"
23+ ? identityResult.value
24+ : null;
25+ const label = identity?.email ?? identity?.name ?? props.connection.identityLabel ?? "Connected";
26+ const sizeClass = props.size === "sm" ? "size-3 text-[7px]" : "size-4 text-[9px]";
27+ 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`;
28+
29+ return identity?.picture ? (
30+ <span
31+ title={label}
32+ className={`${badgeClass} bg-cover bg-center`}
33+ style={{ backgroundImage: `url("${identity.picture.replaceAll('"', "%22")}")` }}
34+ />
35+ ) : (
36+ <span title={label} className={badgeClass}>
37+ {label.slice(0, 1).toUpperCase()}
38+ </span>
39+ );
40+}
packages/react/src/components/source-icon-with-account.tsx+27−0
@@ -0,0 +1,27 @@
1+import { SourceAccountBadge } from "./source-account-badge";
2+import { SourceFavicon } from "./source-favicon";
3+
4+export function SourceIconWithAccount(props: {
5+ readonly icon?: string | null;
6+ readonly sourceId: string;
7+ readonly url?: string;
8+ readonly connection?: {
9+ readonly id: string;
10+ readonly scopeId: string;
11+ readonly identityLabel: string | null;
12+ } | null;
13+ readonly size?: "sm" | "md";
14+}) {
15+ const iconSize = props.size === "sm" ? 16 : 32;
16+ return (
17+ <span className={props.size === "sm" ? "relative size-4 shrink-0" : "relative size-8 shrink-0"}>
18+ <SourceFavicon icon={props.icon} sourceId={props.sourceId} url={props.url} size={iconSize} />
19+ {props.connection ? (
20+ <SourceAccountBadge
21+ connection={props.connection}
22+ size={props.size === "sm" ? "sm" : "md"}
23+ />
24+ ) : null}
25+ </span>
26+ );
27+}
packages/react/src/pages/connections.tsx+321−83
@@ -1,4 +1,5 @@
1−import { Suspense } from "react";
1+import { Suspense, useEffect, useState } from "react";
2+import { Link } from "@tanstack/react-router";
23 import { useAtomValue, useAtomSet } from "@effect/atom-react";
34 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
45 import * as Exit from "effect/Exit";
@@ -6,11 +7,15 @@ import * as Option from "effect/Option"; · 1 unmodified line
67 import * as Schema from "effect/Schema";
78 import { ConnectionId, ConnectionInUseError, ScopeId } from "@executor-js/sdk/shared";
89 import { toast } from "sonner";
10+import { ChevronDownIcon } from "lucide-react";
911
1012 import {
13+ connectionIdentityAtom,
1114 connectionUsagesAtom,
1215 connectionsOptimisticAtom,
1316 removeConnectionOptimistic,
17+ sourcesOptimisticAtom,
18+ updateConnectionIdentity,
1419 } from "../api/atoms";
1520 import { connectionWriteKeys } from "../api/reactivity-keys";
1621 import { useScope, useScopeStack } from "../hooks/use-scope";
@@ -32,6 +37,19 @@ import { · 15 unmodified lines
3237 DropdownMenuItem,
3338 DropdownMenuTrigger,
3439 } from "../components/dropdown-menu";
40+import {
41+ Dialog,
42+ DialogContent,
43+ DialogDescription,
44+ DialogFooter,
45+ DialogHeader,
46+ DialogTitle,
47+} from "../components/dialog";
48+import { Input } from "../components/input";
49+import { FieldLabel } from "../components/field";
50+import { SourceIconWithAccount } from "../components/source-icon-with-account";
51+import { sourcePresetIconUrl } from "../components/source-favicon";
52+import { useSourcePlugins } from "@executor-js/sdk/client";
3553
3654 // ---------------------------------------------------------------------------
3755 // Provider display
@@ -47,6 +65,22 @@ const displayProvider = (provider: string): string => providerDisplayNames[provi · 9 unmodified lines
4765
4866 const isConnectionInUseError = Schema.is(ConnectionInUseError);
4967
68+type ConnectionListItem = {
69+ readonly id: string;
70+ readonly scopeId: string;
71+ readonly provider: string;
72+ readonly identityLabel: string | null;
73+ readonly expiresAt: number | null;
74+ readonly oauthScope: string | null;
75+ readonly identityOverride: {
76+ readonly displayName: string | null;
77+ readonly email: string | null;
78+ readonly avatarUrl: string | null;
79+ } | null;
80+ readonly createdAt: number;
81+ readonly updatedAt: number;
82+};
83+
5084 const connectionScopeLabel = (
5185 scopeId: string,
5286 stack: readonly { readonly id: string; readonly name: string }[],
@@ -57,29 +91,125 @@ const connectionScopeLabel = ( · 4 unmodified lines
5791 return "Scoped";
5892 };
5993
94+const splitScopes = (oauthScope: string | null): readonly string[] =>
95+ oauthScope?.split(/\s+/).filter((scope) => scope.length > 0) ?? [];
96+
97+const compactScope = (scope: string): string => {
98+ if (!URL.canParse(scope)) return scope;
99+ const url = new URL(scope);
100+ const last = url.pathname.split("/").filter(Boolean).at(-1);
101+ return last ?? scope;
102+};
103+
104+type LinkedSource = {
105+ readonly id: string;
106+ readonly name: string;
107+ readonly kind: string;
108+ readonly url?: string;
109+ readonly connectionIds?: readonly string[];
110+};
111+
60112 // ---------------------------------------------------------------------------
61113 // Used-by footer — same shape as the secrets page. Returns null when a
62114 // connection isn't referenced anywhere so newly-created connections
63115 // don't get a stray "Used by 0" line before any source binds to them.
64116 // ---------------------------------------------------------------------------
65117
66−function ConnectionUsageFooter(props: { scopeId: ScopeId; connectionId: ConnectionId }) {
67− const usages = useAtomValue(connectionUsagesAtom(props.scopeId, props.connectionId));
118+function ConnectionDetails(props: {
119+ scopeId: ScopeId;
120+ connection: ConnectionListItem;
121+ open: boolean;
122+}) {
123+ const sourcePlugins = useSourcePlugins();
124+ const usages = useAtomValue(
125+ connectionUsagesAtom(props.scopeId, ConnectionId.make(props.connection.id)),
126+ );
127+ const sourcesResult = useAtomValue(sourcesOptimisticAtom(props.scopeId));
128+ const sources = AsyncResult.isSuccess(sourcesResult)
129+ ? (sourcesResult.value as readonly LinkedSource[])
130+ : [];
131+ const allScopes = splitScopes(props.connection.oauthScope);
132+ const connectionShape = {
133+ id: props.connection.id,
134+ scopeId: props.connection.scopeId,
135+ identityLabel: props.connection.identityLabel,
136+ };
68137 return AsyncResult.match(usages, {
69138 onInitial: () => null,
70139 onFailure: () => null,
71140 onSuccess: ({ value }) => {
72− if (value.length === 0) return null;
73− const labels = value
74− .map((u) => u.ownerName ?? u.ownerId)
75− .filter((s, i, a) => a.indexOf(s) === i);
76− const visible = labels.slice(0, 3);
77− const hidden = labels.length - visible.length;
141+ const linkedSources = value
142+ .map((usage) => {
143+ const source = sources.find((candidate) => candidate.id === usage.ownerId);
144+ return {
145+ id: usage.ownerId,
146+ name: source?.name ?? usage.ownerName ?? usage.ownerId,
147+ kind: source?.kind ?? usage.pluginId,
148+ url: source?.url,
149+ connectionIds: source?.connectionIds,
150+ };
151+ })
152+ .filter((source, index, all) => all.findIndex((item) => item.id === source.id) === index);
153+ if (!props.open) {
154+ if (linkedSources.length === 0) return null;
155+ const visible = linkedSources.slice(0, 2).map((source) => source.name);
156+ const hidden = linkedSources.length - visible.length;
157+ return (
158+ <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground">
159+ Used by {visible.join(", ")}
160+ {hidden > 0 ? ` +${hidden} more` : ""}
161+ </CardStackEntryDescription>
162+ );
163+ }
78164 return (
79− <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground">
80− Used by {visible.join(", ")}
81− {hidden > 0 ? ` +${hidden} more` : ""}
82− </CardStackEntryDescription>
165+ <div className="w-full space-y-4">
166+ <div className="space-y-2">
167+ <div className="text-[11px] font-medium text-muted-foreground">Linked sources</div>
168+ {linkedSources.length === 0 ? (
169+ <div className="text-xs text-muted-foreground">No sources are using this yet.</div>
170+ ) : (
171+ <div className="flex flex-wrap gap-2">
172+ {linkedSources.map((source) => (
173+ <Link
174+ key={source.id}
175+ to="/sources/$namespace"
176+ params={{ namespace: source.id }}
177+ className="flex max-w-full items-center gap-2 rounded-md border border-border/60 bg-background/70 px-2 py-1.5 transition-colors hover:bg-accent/60 focus-visible:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
178+ >
179+ <SourceIconWithAccount
180+ icon={sourcePresetIconUrl(source, sourcePlugins)}
181+ sourceId={source.id}
182+ url={source.url}
183+ connection={connectionShape}
184+ size="sm"
185+ />
186+ <span className="truncate text-xs font-medium text-foreground">
187+ {source.name}
188+ </span>
189+ </Link>
190+ ))}
191+ </div>
192+ )}
193+ </div>
194+ {allScopes.length > 0 ? (
195+ <div className="space-y-2">
196+ <div className="text-[11px] font-medium text-muted-foreground">Granted scopes</div>
197+ <div className="max-h-24 overflow-y-auto pr-1">
198+ <div className="flex flex-wrap gap-1.5">
199+ {allScopes.map((scope) => (
200+ <span
201+ key={scope}
202+ title={scope}
203+ className="max-w-full truncate rounded border border-border/60 bg-background/70 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"
204+ >
205+ {compactScope(scope)}
206+ </span>
207+ ))}
208+ </div>
209+ </div>
210+ </div>
211+ ) : null}
212+ </div>
83213 );
84214 },
85215 });
@@ -91,65 +221,187 @@ function ConnectionUsageFooter(props: { scopeId: ScopeId; connectionId: Connecti · 5 unmodified lines
91221
92222 function ConnectionRow(props: {
93223 scopeId: ScopeId;
94− connection: {
95− id: string;
96− scopeId: string;
97− provider: string;
98− identityLabel: string | null;
99− };
224+ connection: ConnectionListItem;
100225 scopeStack: readonly { readonly id: string; readonly name: string }[];
101226 onRemove: () => void;
102227 }) {
103228 const { connection } = props;
229+ const doUpdateIdentity = useAtomSet(updateConnectionIdentity, { mode: "promiseExit" });
230+ const [editingIdentity, setEditingIdentity] = useState(false);
231+ const [expanded, setExpanded] = useState(false);
232+ const [savingIdentity, setSavingIdentity] = useState(false);
233+ const [displayName, setDisplayName] = useState(connection.identityOverride?.displayName ?? "");
234+ const [email, setEmail] = useState(connection.identityOverride?.email ?? "");
235+ const [avatarUrl, setAvatarUrl] = useState(connection.identityOverride?.avatarUrl ?? "");
236+ const identityResult = useAtomValue(
237+ connectionIdentityAtom(ScopeId.make(connection.scopeId), ConnectionId.make(connection.id)),
238+ );
239+ const identity =
240+ AsyncResult.isSuccess(identityResult) && identityResult.value.status === "available"
241+ ? identityResult.value
242+ : null;
243+ const identityStatus =
244+ AsyncResult.isSuccess(identityResult) && identityResult.value.status !== "available"
245+ ? identityResult.value
246+ : null;
104247 const scopeLabel = connectionScopeLabel(connection.scopeId, props.scopeStack);
105248 const displayLabel =
106− connection.identityLabel && connection.identityLabel.length > 0
249+ identity?.email ??
250+ identity?.name ??
251+ (connection.identityLabel && connection.identityLabel.length > 0
107252 ? connection.identityLabel
108− : connection.id;
253+ : connection.id);
254+ const details = [displayProvider(connection.provider), scopeLabel];
255+
256+ useEffect(() => {
257+ if (editingIdentity) return;
258+ setDisplayName(connection.identityOverride?.displayName ?? "");
259+ setEmail(connection.identityOverride?.email ?? "");
260+ setAvatarUrl(connection.identityOverride?.avatarUrl ?? "");
261+ }, [connection.identityOverride, editingIdentity]);
262+
263+ const handleSaveIdentity = async () => {
264+ setSavingIdentity(true);
265+ const cleanDisplayName = displayName.trim();
266+ const cleanEmail = email.trim();
267+ const cleanAvatarUrl = avatarUrl.trim();
268+ const identityOverride =
269+ cleanDisplayName || cleanEmail || cleanAvatarUrl
270+ ? {
271+ displayName: cleanDisplayName || null,
272+ email: cleanEmail || null,
273+ avatarUrl: cleanAvatarUrl || null,
274+ }
275+ : null;
276+ const exit = await doUpdateIdentity({
277+ params: {
278+ scopeId: ScopeId.make(connection.scopeId),
279+ connectionId: ConnectionId.make(connection.id),
280+ },
281+ payload: { identityOverride },
282+ reactivityKeys: connectionWriteKeys,
283+ });
284+ setSavingIdentity(false);
285+ if (Exit.isFailure(exit)) {
286+ toast.error("Failed to update account info");
287+ return;
288+ }
289+ setEditingIdentity(false);
290+ };
109291
110292 return (
111− <CardStackEntry>
112− <CardStackEntryContent>
113− <CardStackEntryTitle className="flex items-center gap-2">
114− <span className="truncate">{displayLabel}</span>
115− </CardStackEntryTitle>
116− <CardStackEntryDescription className="text-xs text-muted-foreground">
117− {displayProvider(connection.provider)}
118− </CardStackEntryDescription>
293+ <>
294+ <CardStackEntry className="flex-wrap items-start">
295+ <CardStackEntryContent>
296+ <CardStackEntryTitle className="flex min-w-0 items-center gap-2">
297+ {identity?.picture ? (
298+ <img
299+ src={identity.picture}
300+ alt=""
301+ referrerPolicy="no-referrer"
302+ className="size-5 shrink-0 rounded-full"
303+ />
304+ ) : null}
305+ <span className="truncate">{displayLabel}</span>
306+ </CardStackEntryTitle>
307+ <CardStackEntryDescription className="text-xs text-muted-foreground">
308+ {details.join(" · ")}
309+ </CardStackEntryDescription>
310+ {identityStatus?.status === "reauth_required" ? (
311+ <CardStackEntryDescription className="mt-1 text-xs text-amber-600 dark:text-amber-400">
312+ {identityStatus.message ?? "Connection needs re-authentication"}
313+ </CardStackEntryDescription>
314+ ) : null}
315+ </CardStackEntryContent>
316+ <CardStackEntryActions className="self-start pt-0.5">
317+ <Button
318+ variant="ghost"
319+ size="sm"
320+ className="h-7 gap-1 px-2 text-xs text-muted-foreground"
321+ onClick={() => setExpanded((value) => !value)}
322+ >
323+ Details
324+ <ChevronDownIcon
325+ className={`size-3 transition-transform ${expanded ? "rotate-180" : ""}`}
326+ />
327+ </Button>
328+ <Badge variant="outline">{scopeLabel}</Badge>
329+ <DropdownMenu>
330+ <DropdownMenuTrigger asChild>
331+ <Button
332+ variant="ghost"
333+ size="icon"
334+ className="size-7 opacity-0 transition-opacity group-hover/card-stack-entry:opacity-100 group-focus-within/card-stack-entry:opacity-100 data-[state=open]:opacity-100"
335+ >
336+ <svg viewBox="0 0 16 16" className="size-3">
337+ <circle cx="8" cy="3" r="1.2" fill="currentColor" />
338+ <circle cx="8" cy="8" r="1.2" fill="currentColor" />
339+ <circle cx="8" cy="13" r="1.2" fill="currentColor" />
340+ </svg>
341+ </Button>
342+ </DropdownMenuTrigger>
343+ <DropdownMenuContent align="end" className="w-40">
344+ <DropdownMenuItem className="text-sm" onClick={() => setEditingIdentity(true)}>
345+ Edit account info
346+ </DropdownMenuItem>
347+ <DropdownMenuItem
348+ className="text-destructive focus:text-destructive text-sm"
349+ onClick={props.onRemove}
350+ >
351+ Remove
352+ </DropdownMenuItem>
353+ </DropdownMenuContent>
354+ </DropdownMenu>
355+ </CardStackEntryActions>
119356 <Suspense fallback={null}>
120− <ConnectionUsageFooter
121− scopeId={props.scopeId}
122− connectionId={ConnectionId.make(connection.id)}
123− />
357+ <ConnectionDetails scopeId={props.scopeId} connection={connection} open={expanded} />
124358 </Suspense>
125− </CardStackEntryContent>
126− <CardStackEntryActions>
127− <Badge variant="outline">{scopeLabel}</Badge>
128− <DropdownMenu>
129− <DropdownMenuTrigger asChild>
130− <Button
131− variant="ghost"
132− size="icon"
133− className="size-7 opacity-0 transition-opacity group-hover/card-stack-entry:opacity-100 group-focus-within/card-stack-entry:opacity-100 data-[state=open]:opacity-100"
134− >
135− <svg viewBox="0 0 16 16" className="size-3">
136− <circle cx="8" cy="3" r="1.2" fill="currentColor" />
137− <circle cx="8" cy="8" r="1.2" fill="currentColor" />
138− <circle cx="8" cy="13" r="1.2" fill="currentColor" />
139− </svg>
359+ </CardStackEntry>
360+ <Dialog open={editingIdentity} onOpenChange={setEditingIdentity}>
361+ <DialogContent>
362+ <DialogHeader>
363+ <DialogTitle>Account info</DialogTitle>
364+ <DialogDescription>
365+ Override the account details shown for this connection.
366+ </DialogDescription>
367+ </DialogHeader>
368+ <div className="grid gap-3">
369+ <div className="space-y-1.5">
370+ <FieldLabel>Display name</FieldLabel>
371+ <Input
372+ value={displayName}
373+ onChange={(event) => setDisplayName((event.target as HTMLInputElement).value)}
374+ placeholder={identity?.name ?? connection.identityLabel ?? "Rhys Sullivan"}
375+ />
376+ </div>
377+ <div className="space-y-1.5">
378+ <FieldLabel>Email</FieldLabel>
379+ <Input
380+ value={email}
381+ onChange={(event) => setEmail((event.target as HTMLInputElement).value)}
382+ placeholder={identity?.email ?? "rhys@example.com"}
383+ />
384+ </div>
385+ <div className="space-y-1.5">
386+ <FieldLabel>Avatar URL</FieldLabel>
387+ <Input
388+ value={avatarUrl}
389+ onChange={(event) => setAvatarUrl((event.target as HTMLInputElement).value)}
390+ placeholder={identity?.picture ?? "https://example.com/avatar.png"}
391+ />
392+ </div>
393+ </div>
394+ <DialogFooter>
395+ <Button variant="outline" onClick={() => setEditingIdentity(false)}>
396+ Cancel
397+ </Button>
398+ <Button onClick={() => void handleSaveIdentity()} disabled={savingIdentity}>
399+ {savingIdentity ? "Saving..." : "Save"}
140400 </Button>
141− </DropdownMenuTrigger>
142− <DropdownMenuContent align="end" className="w-40">
143− <DropdownMenuItem
144− className="text-destructive focus:text-destructive text-sm"
145− onClick={props.onRemove}
146− >
147− Remove
148− </DropdownMenuItem>
149− </DropdownMenuContent>
150− </DropdownMenu>
151− </CardStackEntryActions>
152− </CardStackEntry>
401+ </DialogFooter>
402+ </DialogContent>
403+ </Dialog>
404+ </>
153405 );
154406 }
155407
@@ -222,29 +474,15 @@ export function ConnectionsPage() { · 66 unmodified lines
222474 </CardStackEntryContent>
223475 </CardStackEntry>
224476 ) : (
225− value.map(
226− (c: {
227− readonly id: string;
228− readonly scopeId: string;
229− readonly provider: string;
230− readonly identityLabel: string | null;
231− }) => (
232− <ConnectionRow
233− key={c.id}
234− scopeId={scopeId}
235− connection={{
236− id: c.id,
237− scopeId: c.scopeId,
238− provider: c.provider,
239− identityLabel: c.identityLabel,
240− }}
241− scopeStack={scopeStack}
242− onRemove={() =>
243− handleRemove({ id: c.id, scopeId: ScopeId.make(c.scopeId) })
244− }
245− />
246− ),
247− )
477+ value.map((c: ConnectionListItem) => (
478+ <ConnectionRow
479+ key={c.id}
480+ scopeId={scopeId}
481+ connection={c}
482+ scopeStack={scopeStack}
483+ onRemove={() => handleRemove({ id: c.id, scopeId: ScopeId.make(c.scopeId) })}
484+ />
485+ ))
248486 )}
249487 </CardStackContent>
250488 </CardStack>
packages/react/src/pages/sources.tsx+17−10
@@ -6,7 +6,7 @@ import * as Exit from "effect/Exit"; · 5 unmodified lines
66 import { PlusIcon } from "lucide-react";
77 import type { SourceDetectionResult } from "@executor-js/sdk/shared";
88 import { useSourcePlugins, type SourcePlugin, type SourcePreset } from "@executor-js/sdk/client";
9−import { detectSource, sourcesOptimisticAtom } from "../api/atoms";
9+import { connectionsAtom, detectSource, sourcesOptimisticAtom } from "../api/atoms";
1010 import { useScope } from "../hooks/use-scope";
1111 import { McpInstallCard } from "../components/mcp-install-card";
1212 import { Button } from "../components/button";
@@ -29,7 +29,8 @@ import { · 16 unmodified lines
2929 CardStackEntryMedia,
3030 CardStackEntryTitle,
3131 } from "../components/card-stack";
32−import { SourceFavicon, sourcePresetIconUrl } from "../components/source-favicon";
32+import { sourcePresetIconUrl } from "../components/source-favicon";
33+import { SourceIconWithAccount } from "../components/source-icon-with-account";
3334 import { Skeleton } from "../components/skeleton";
3435
3536 const KIND_TO_PLUGIN_KEY: Record<string, string> = {
@@ -94,6 +95,7 @@ export function SourcesPage() { · 58 unmodified lines
9495 readonly kind: string;
9596 readonly url?: string;
9697 readonly runtime?: boolean;
98+ readonly connectionIds?: readonly string[];
9799 }>
98100 ).filter((source) => !source.runtime);
99101
@@ -391,9 +393,13 @@ function SourceGrid(props: { · 291 unmodified lines
391393 kind: string;
392394 url?: string;
393395 runtime?: boolean;
396+ connectionIds?: readonly string[];
394397 }[];
395398 }) {
396399 const sourcePlugins = useSourcePlugins();
400+ const scopeId = useScope();
401+ const connectionsResult = useAtomValue(connectionsAtom(scopeId));
402+ const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : [];
397403 const pluginByKind = useMemo(() => {
398404 const out = new Map<string, SourcePlugin>();
399405 for (const p of sourcePlugins) out.set(p.key, p);
@@ -407,17 +413,18 @@ function SourceGrid(props: { · 7 unmodified lines
407413 const pluginKey = KIND_TO_PLUGIN_KEY[s.kind] ?? s.kind;
408414 const plugin = pluginByKind.get(pluginKey);
409415 const SummaryComponent = plugin?.summary;
416+ const connection = connections.find((candidate) =>
417+ s.connectionIds?.includes(candidate.id),
418+ );
410419 return (
411420 <CardStackEntry key={s.id} asChild searchText={`${s.name} ${s.id} ${s.kind}`}>
412421 <Link to="/sources/$namespace" params={{ namespace: s.id }}>
413− <CardStackEntryMedia>
414− <SourceFavicon
415− icon={sourcePresetIconUrl(s, sourcePlugins)}
416− sourceId={s.id}
417− url={s.url}
418− size={32}
419− />
420− </CardStackEntryMedia>
422+ <SourceIconWithAccount
423+ icon={sourcePresetIconUrl(s, sourcePlugins)}
424+ sourceId={s.id}
425+ url={s.url}
426+ connection={connection}
427+ />
421428 <CardStackEntryContent>
422429 <CardStackEntryTitle>{s.name}</CardStackEntryTitle>
423430 <CardStackEntryDescription>{s.id}</CardStackEntryDescription>