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
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";5import * as Exit from "effect/Exit";
6import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";6import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
7import { sourcesAtom, sourcesOptimisticAtom, toolsAtom } from "@executor-js/react/api/atoms";7import {
8 connectionsAtom,
9 sourcesAtom,
10 sourcesOptimisticAtom,
11 toolsAtom,
12} from "@executor-js/react/api/atoms";
8import { useScope, useScopeInfo } from "@executor-js/react/api/scope-context";13import { useScope, useScopeInfo } from "@executor-js/react/api/scope-context";
9import { Button } from "@executor-js/react/components/button";14import { Button } from "@executor-js/react/components/button";
10import { SourceFavicon, sourcePresetIconUrl } from "@executor-js/react/components/source-favicon";15import { sourcePresetIconUrl } from "@executor-js/react/components/source-favicon";
16import { SourceIconWithAccount } from "@executor-js/react/components/source-icon-with-account";
11import { CommandPalette } from "@executor-js/react/components/command-palette";17import { CommandPalette } from "@executor-js/react/components/command-palette";
12import { useClientPlugins, useSourcePlugins } from "@executor-js/sdk/client";18import { useClientPlugins, useSourcePlugins } from "@executor-js/sdk/client";
13import { ServerConnectionMenu } from "./server-connection-menu";19import { ServerConnectionMenu } from "./server-connection-menu";
@@ -269,6 +275,8 @@ function PluginNav(props: { pathname: string; onNavigate?: () => void }) { · 255 unmodified lines
269function SourceList(props: { pathname: string; onNavigate?: () => void }) {275function SourceList(props: { pathname: string; onNavigate?: () => void }) {
270 const scopeId = useScope();276 const scopeId = useScope();
271 const sources = useAtomValue(sourcesOptimisticAtom(scopeId));277 const sources = useAtomValue(sourcesOptimisticAtom(scopeId));
278 const connectionsResult = useAtomValue(connectionsAtom(scopeId));
279 const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : [];
272 const sourcePlugins = useSourcePlugins();280 const sourcePlugins = useSourcePlugins();
273 281
274 return AsyncResult.match(sources, {282 return AsyncResult.match(sources, {
@@ -287,6 +295,9 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) { · 12 unmodified lines
287 const detailPath = `/sources/${s.id}`;295 const detailPath = `/sources/${s.id}`;
288 const active =296 const active =
289 props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`);297 props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`);
298 const connection = connections.find((candidate) =>
299 s.connectionIds?.includes(candidate.id),
300 );
290 return (301 return (
291 <Link302 <Link
292 key={s.id}303 key={s.id}
@@ -300,10 +311,12 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) { · 7 unmodified lines
300 : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",311 : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
301 ].join(" ")}312 ].join(" ")}
302 >313 >
303 <SourceFavicon314 <SourceIconWithAccount
304 icon={sourcePresetIconUrl(s, sourcePlugins)}315 icon={sourcePresetIconUrl(s, sourcePlugins)}
305 sourceId={s.id}316 sourceId={s.id}
306 url={s.url}317 url={s.url}
318 connection={connection}
319 size="sm"
307 />320 />
308 <span className="flex-1 truncate">{s.name}</span>321 <span className="flex-1 truncate">{s.name}</span>
309 <span className="rounded bg-secondary/50 px-1 py-px text-xs font-medium text-muted-foreground">322 <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
88 reactivityKeys: [ReactivityKey.connections],88 reactivityKeys: [ReactivityKey.connections],
89 });89 });
90 90
91export 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
91export const secretUsagesAtom = (scopeId: ScopeId, secretId: SecretId) =>98export const secretUsagesAtom = (scopeId: ScopeId, secretId: SecretId) =>
92 ExecutorApiClient.query("secrets", "usages", {99 ExecutorApiClient.query("secrets", "usages", {
93 params: { scopeId, secretId },100 params: { scopeId, secretId },
@@ -130,6 +137,8 @@ export const removeSecret = ExecutorApiClient.mutation("secrets", "remove"); · 36 unmodified lines
130 137
131export const removeConnection = ExecutorApiClient.mutation("connections", "remove");138export const removeConnection = ExecutorApiClient.mutation("connections", "remove");
132 139
140export const updateConnectionIdentity = ExecutorApiClient.mutation("connections", "updateIdentity");
141
133export const removeSource = ExecutorApiClient.mutation("sources", "remove");142export const removeSource = ExecutorApiClient.mutation("sources", "remove");
134 143
135export const refreshSource = ExecutorApiClient.mutation("sources", "refresh");144export const refreshSource = ExecutorApiClient.mutation("sources", "refresh");
packages/react/src/components/source-account-badge.tsx+40−0
@@ -0,0 +1,40 @@
1import { useAtomValue } from "@effect/atom-react";
2import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3import { ConnectionId, ScopeId } from "@executor-js/sdk/shared";
4
5import { connectionIdentityAtom } from "../api/atoms";
6
7export 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 @@
1import { SourceAccountBadge } from "./source-account-badge";
2import { SourceFavicon } from "./source-favicon";
3
4export 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 @@
1import { Suspense } from "react";1import { Suspense, useEffect, useState } from "react";
2import { Link } from "@tanstack/react-router";
2import { useAtomValue, useAtomSet } from "@effect/atom-react";3import { useAtomValue, useAtomSet } from "@effect/atom-react";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import * as Exit from "effect/Exit";5import * as Exit from "effect/Exit";
@@ -6,11 +7,15 @@ import * as Option from "effect/Option"; · 1 unmodified line
6import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
7import { ConnectionId, ConnectionInUseError, ScopeId } from "@executor-js/sdk/shared";8import { ConnectionId, ConnectionInUseError, ScopeId } from "@executor-js/sdk/shared";
8import { toast } from "sonner";9import { toast } from "sonner";
10import { ChevronDownIcon } from "lucide-react";
9 11
10import {12import {
13 connectionIdentityAtom,
11 connectionUsagesAtom,14 connectionUsagesAtom,
12 connectionsOptimisticAtom,15 connectionsOptimisticAtom,
13 removeConnectionOptimistic,16 removeConnectionOptimistic,
17 sourcesOptimisticAtom,
18 updateConnectionIdentity,
14} from "../api/atoms";19} from "../api/atoms";
15import { connectionWriteKeys } from "../api/reactivity-keys";20import { connectionWriteKeys } from "../api/reactivity-keys";
16import { useScope, useScopeStack } from "../hooks/use-scope";21import { useScope, useScopeStack } from "../hooks/use-scope";
@@ -32,6 +37,19 @@ import { · 15 unmodified lines
32 DropdownMenuItem,37 DropdownMenuItem,
33 DropdownMenuTrigger,38 DropdownMenuTrigger,
34} from "../components/dropdown-menu";39} from "../components/dropdown-menu";
40import {
41 Dialog,
42 DialogContent,
43 DialogDescription,
44 DialogFooter,
45 DialogHeader,
46 DialogTitle,
47} from "../components/dialog";
48import { Input } from "../components/input";
49import { FieldLabel } from "../components/field";
50import { SourceIconWithAccount } from "../components/source-icon-with-account";
51import { sourcePresetIconUrl } from "../components/source-favicon";
52import { useSourcePlugins } from "@executor-js/sdk/client";
35 53
36// ---------------------------------------------------------------------------54// ---------------------------------------------------------------------------
37// Provider display55// Provider display
@@ -47,6 +65,22 @@ const displayProvider = (provider: string): string => providerDisplayNames[provi · 9 unmodified lines
47 65
48const isConnectionInUseError = Schema.is(ConnectionInUseError);66const isConnectionInUseError = Schema.is(ConnectionInUseError);
49 67
68type 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
50const connectionScopeLabel = (84const connectionScopeLabel = (
51 scopeId: string,85 scopeId: string,
52 stack: readonly { readonly id: string; readonly name: string }[],86 stack: readonly { readonly id: string; readonly name: string }[],
@@ -57,29 +91,125 @@ const connectionScopeLabel = ( · 4 unmodified lines
57 return "Scoped";91 return "Scoped";
58};92};
59 93
94const splitScopes = (oauthScope: string | null): readonly string[] =>
95 oauthScope?.split(/\s+/).filter((scope) => scope.length > 0) ?? [];
96
97const 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
104type LinkedSource = {
105 readonly id: string;
106 readonly name: string;
107 readonly kind: string;
108 readonly url?: string;
109 readonly connectionIds?: readonly string[];
110};
111
60// ---------------------------------------------------------------------------112// ---------------------------------------------------------------------------
61// Used-by footer — same shape as the secrets page. Returns null when a113// Used-by footer — same shape as the secrets page. Returns null when a
62// connection isn't referenced anywhere so newly-created connections114// connection isn't referenced anywhere so newly-created connections
63// don't get a stray "Used by 0" line before any source binds to them.115// don't get a stray "Used by 0" line before any source binds to them.
64// ---------------------------------------------------------------------------116// ---------------------------------------------------------------------------
65 117
66function ConnectionUsageFooter(props: { scopeId: ScopeId; connectionId: ConnectionId }) {118function ConnectionDetails(props: {
67 const usages = useAtomValue(connectionUsagesAtom(props.scopeId, props.connectionId));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 };
68 return AsyncResult.match(usages, {137 return AsyncResult.match(usages, {
69 onInitial: () => null,138 onInitial: () => null,
70 onFailure: () => null,139 onFailure: () => null,
71 onSuccess: ({ value }) => {140 onSuccess: ({ value }) => {
72 if (value.length === 0) return null;141 const linkedSources = value
73 const labels = value142 .map((usage) => {
74 .map((u) => u.ownerName ?? u.ownerId)143 const source = sources.find((candidate) => candidate.id === usage.ownerId);
75 .filter((s, i, a) => a.indexOf(s) === i);144 return {
76 const visible = labels.slice(0, 3);145 id: usage.ownerId,
77 const hidden = labels.length - visible.length;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 }
78 return (164 return (
79 <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground">165 <div className="w-full space-y-4">
80 Used by {visible.join(", ")}166 <div className="space-y-2">
81 {hidden > 0 ? ` +${hidden} more` : ""}167 <div className="text-[11px] font-medium text-muted-foreground">Linked sources</div>
82 </CardStackEntryDescription>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>
83 );213 );
84 },214 },
85 });215 });
@@ -91,65 +221,187 @@ function ConnectionUsageFooter(props: { scopeId: ScopeId; connectionId: Connecti · 5 unmodified lines
91 221
92function ConnectionRow(props: {222function ConnectionRow(props: {
93 scopeId: ScopeId;223 scopeId: ScopeId;
94 connection: {224 connection: ConnectionListItem;
95 id: string;
96 scopeId: string;
97 provider: string;
98 identityLabel: string | null;
99 };
100 scopeStack: readonly { readonly id: string; readonly name: string }[];225 scopeStack: readonly { readonly id: string; readonly name: string }[];
101 onRemove: () => void;226 onRemove: () => void;
102}) {227}) {
103 const { connection } = props;228 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;
104 const scopeLabel = connectionScopeLabel(connection.scopeId, props.scopeStack);247 const scopeLabel = connectionScopeLabel(connection.scopeId, props.scopeStack);
105 const displayLabel =248 const displayLabel =
106 connection.identityLabel && connection.identityLabel.length > 0249 identity?.email ??
250 identity?.name ??
251 (connection.identityLabel && connection.identityLabel.length > 0
107 ? connection.identityLabel252 ? 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 };
109 291
110 return (292 return (
111 <CardStackEntry>293 <>
112 <CardStackEntryContent>294 <CardStackEntry className="flex-wrap items-start">
113 <CardStackEntryTitle className="flex items-center gap-2">295 <CardStackEntryContent>
114 <span className="truncate">{displayLabel}</span>296 <CardStackEntryTitle className="flex min-w-0 items-center gap-2">
115 </CardStackEntryTitle>297 {identity?.picture ? (
116 <CardStackEntryDescription className="text-xs text-muted-foreground">298 <img
117 {displayProvider(connection.provider)}299 src={identity.picture}
118 </CardStackEntryDescription>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>
119 <Suspense fallback={null}>356 <Suspense fallback={null}>
120 <ConnectionUsageFooter357 <ConnectionDetails scopeId={props.scopeId} connection={connection} open={expanded} />
121 scopeId={props.scopeId}
122 connectionId={ConnectionId.make(connection.id)}
123 />
124 </Suspense>358 </Suspense>
125 </CardStackEntryContent>359 </CardStackEntry>
126 <CardStackEntryActions>360 <Dialog open={editingIdentity} onOpenChange={setEditingIdentity}>
127 <Badge variant="outline">{scopeLabel}</Badge>361 <DialogContent>
128 <DropdownMenu>362 <DialogHeader>
129 <DropdownMenuTrigger asChild>363 <DialogTitle>Account info</DialogTitle>
130 <Button364 <DialogDescription>
131 variant="ghost"365 Override the account details shown for this connection.
132 size="icon"366 </DialogDescription>
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"367 </DialogHeader>
134 >368 <div className="grid gap-3">
135 <svg viewBox="0 0 16 16" className="size-3">369 <div className="space-y-1.5">
136 <circle cx="8" cy="3" r="1.2" fill="currentColor" />370 <FieldLabel>Display name</FieldLabel>
137 <circle cx="8" cy="8" r="1.2" fill="currentColor" />371 <Input
138 <circle cx="8" cy="13" r="1.2" fill="currentColor" />372 value={displayName}
139 </svg>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"}
140 </Button>400 </Button>
141 </DropdownMenuTrigger>401 </DialogFooter>
142 <DropdownMenuContent align="end" className="w-40">402 </DialogContent>
143 <DropdownMenuItem403 </Dialog>
144 className="text-destructive focus:text-destructive text-sm"404 </>
145 onClick={props.onRemove}
146 >
147 Remove
148 </DropdownMenuItem>
149 </DropdownMenuContent>
150 </DropdownMenu>
151 </CardStackEntryActions>
152 </CardStackEntry>
153 );405 );
154}406}
155 407
@@ -222,29 +474,15 @@ export function ConnectionsPage() { · 66 unmodified lines
222 </CardStackEntryContent>474 </CardStackEntryContent>
223 </CardStackEntry>475 </CardStackEntry>
224 ) : (476 ) : (
225 value.map(477 value.map((c: ConnectionListItem) => (
226 (c: {478 <ConnectionRow
227 readonly id: string;479 key={c.id}
228 readonly scopeId: string;480 scopeId={scopeId}
229 readonly provider: string;481 connection={c}
230 readonly identityLabel: string | null;482 scopeStack={scopeStack}
231 }) => (483 onRemove={() => handleRemove({ id: c.id, scopeId: ScopeId.make(c.scopeId) })}
232 <ConnectionRow484 />
233 key={c.id}485 ))
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 )
248 )}486 )}
249 </CardStackContent>487 </CardStackContent>
250 </CardStack>488 </CardStack>
packages/react/src/pages/sources.tsx+17−10
@@ -6,7 +6,7 @@ import * as Exit from "effect/Exit"; · 5 unmodified lines
6import { PlusIcon } from "lucide-react";6import { PlusIcon } from "lucide-react";
7import type { SourceDetectionResult } from "@executor-js/sdk/shared";7import type { SourceDetectionResult } from "@executor-js/sdk/shared";
8import { useSourcePlugins, type SourcePlugin, type SourcePreset } from "@executor-js/sdk/client";8import { useSourcePlugins, type SourcePlugin, type SourcePreset } from "@executor-js/sdk/client";
9import { detectSource, sourcesOptimisticAtom } from "../api/atoms";9import { connectionsAtom, detectSource, sourcesOptimisticAtom } from "../api/atoms";
10import { useScope } from "../hooks/use-scope";10import { useScope } from "../hooks/use-scope";
11import { McpInstallCard } from "../components/mcp-install-card";11import { McpInstallCard } from "../components/mcp-install-card";
12import { Button } from "../components/button";12import { Button } from "../components/button";
@@ -29,7 +29,8 @@ import { · 16 unmodified lines
29 CardStackEntryMedia,29 CardStackEntryMedia,
30 CardStackEntryTitle,30 CardStackEntryTitle,
31} from "../components/card-stack";31} from "../components/card-stack";
32import { SourceFavicon, sourcePresetIconUrl } from "../components/source-favicon";32import { sourcePresetIconUrl } from "../components/source-favicon";
33import { SourceIconWithAccount } from "../components/source-icon-with-account";
33import { Skeleton } from "../components/skeleton";34import { Skeleton } from "../components/skeleton";
34 35
35const KIND_TO_PLUGIN_KEY: Record<string, string> = {36const KIND_TO_PLUGIN_KEY: Record<string, string> = {
@@ -94,6 +95,7 @@ export function SourcesPage() { · 58 unmodified lines
94 readonly kind: string;95 readonly kind: string;
95 readonly url?: string;96 readonly url?: string;
96 readonly runtime?: boolean;97 readonly runtime?: boolean;
98 readonly connectionIds?: readonly string[];
97 }>99 }>
98 ).filter((source) => !source.runtime);100 ).filter((source) => !source.runtime);
99 101
@@ -391,9 +393,13 @@ function SourceGrid(props: { · 291 unmodified lines
391 kind: string;393 kind: string;
392 url?: string;394 url?: string;
393 runtime?: boolean;395 runtime?: boolean;
396 connectionIds?: readonly string[];
394 }[];397 }[];
395}) {398}) {
396 const sourcePlugins = useSourcePlugins();399 const sourcePlugins = useSourcePlugins();
400 const scopeId = useScope();
401 const connectionsResult = useAtomValue(connectionsAtom(scopeId));
402 const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : [];
397 const pluginByKind = useMemo(() => {403 const pluginByKind = useMemo(() => {
398 const out = new Map<string, SourcePlugin>();404 const out = new Map<string, SourcePlugin>();
399 for (const p of sourcePlugins) out.set(p.key, p);405 for (const p of sourcePlugins) out.set(p.key, p);
@@ -407,17 +413,18 @@ function SourceGrid(props: { · 7 unmodified lines
407 const pluginKey = KIND_TO_PLUGIN_KEY[s.kind] ?? s.kind;413 const pluginKey = KIND_TO_PLUGIN_KEY[s.kind] ?? s.kind;
408 const plugin = pluginByKind.get(pluginKey);414 const plugin = pluginByKind.get(pluginKey);
409 const SummaryComponent = plugin?.summary;415 const SummaryComponent = plugin?.summary;
416 const connection = connections.find((candidate) =>
417 s.connectionIds?.includes(candidate.id),
418 );
410 return (419 return (
411 <CardStackEntry key={s.id} asChild searchText={`${s.name} ${s.id} ${s.kind}`}>420 <CardStackEntry key={s.id} asChild searchText={`${s.name} ${s.id} ${s.kind}`}>
412 <Link to="/sources/$namespace" params={{ namespace: s.id }}>421 <Link to="/sources/$namespace" params={{ namespace: s.id }}>
413 <CardStackEntryMedia>422 <SourceIconWithAccount
414 <SourceFavicon423 icon={sourcePresetIconUrl(s, sourcePlugins)}
415 icon={sourcePresetIconUrl(s, sourcePlugins)}424 sourceId={s.id}
416 sourceId={s.id}425 url={s.url}
417 url={s.url}426 connection={connection}
418 size={32}427 />
419 />
420 </CardStackEntryMedia>
421 <CardStackEntryContent>428 <CardStackEntryContent>
422 <CardStackEntryTitle>{s.name}</CardStackEntryTitle>429 <CardStackEntryTitle>{s.name}</CardStackEntryTitle>
423 <CardStackEntryDescription>{s.id}</CardStackEntryDescription>430 <CardStackEntryDescription>{s.id}</CardStackEntryDescription>