| @@ -1,4 +1,5 @@ |
| 1 | import { Suspense } from "react"; | 1 | import { Suspense, useEffect, useState } from "react"; |
| | 2 | import { Link } from "@tanstack/react-router"; |
| 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; | 3 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; |
| 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | import * as Exit from "effect/Exit"; | 5 | import * as Exit from "effect/Exit"; |
| @@ -6,11 +7,15 @@ import * as Option from "effect/Option"; · 1 unmodified line |
| 6 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| 7 | import { ConnectionId, ConnectionInUseError, ScopeId } from "@executor-js/sdk/shared"; | 8 | import { ConnectionId, ConnectionInUseError, ScopeId } from "@executor-js/sdk/shared"; |
| 8 | import { toast } from "sonner"; | 9 | import { toast } from "sonner"; |
| | 10 | import { ChevronDownIcon } from "lucide-react"; |
| 9 | | 11 | |
| 10 | import { | 12 | import { |
| | 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"; |
| 15 | import { connectionWriteKeys } from "../api/reactivity-keys"; | 20 | import { connectionWriteKeys } from "../api/reactivity-keys"; |
| 16 | import { useScope, useScopeStack } from "../hooks/use-scope"; | 21 | import { 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"; |
| | 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"; |
| 35 | | 53 | |
| 36 | // --------------------------------------------------------------------------- | 54 | // --------------------------------------------------------------------------- |
| 37 | // Provider display | 55 | // Provider display |
| @@ -47,6 +65,22 @@ const displayProvider = (provider: string): string => providerDisplayNames[provi · 9 unmodified lines |
| 47 | | 65 | |
| 48 | const isConnectionInUseError = Schema.is(ConnectionInUseError); | 66 | const isConnectionInUseError = Schema.is(ConnectionInUseError); |
| 49 | | 67 | |
| | 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 | |
| 50 | const connectionScopeLabel = ( | 84 | const 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 | |
| | 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 | |
| 60 | // --------------------------------------------------------------------------- | 112 | // --------------------------------------------------------------------------- |
| 61 | // Used-by footer — same shape as the secrets page. Returns null when a | 113 | // Used-by footer — same shape as the secrets page. Returns null when a |
| 62 | // connection isn't referenced anywhere so newly-created connections | 114 | // 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 | |
| 66 | function ConnectionUsageFooter(props: { scopeId: ScopeId; connectionId: ConnectionId }) { | 118 | function 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 = value | 142 | .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 | |
| 92 | function ConnectionRow(props: { | 222 | function 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 > 0 | 249 | identity?.email ?? |
| | 250 | identity?.name ?? |
| | 251 | (connection.identityLabel && connection.identityLabel.length > 0 |
| 107 | ? connection.identityLabel | 252 | ? 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 | <ConnectionUsageFooter | 357 | <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 | <Button | 364 | <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 | <DropdownMenuItem | 403 | </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 | <ConnectionRow | 484 | /> |
| 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> |