| 1 | import { RemoteConnectionFlow, SelfHostedServerSettings } from "./SelfHostedServer"; |
| 2 | import { CloudAuthBoundary } from "../../cloud/CloudAuthBoundary"; |
| 3 | import { activateCloudAuthentication } from "../../cloud/activation"; |
| 4 | import { SessionPermissions } from "./SessionPermissions"; |
| 5 | import { AUTH_SCOPE_OPTIONS as PAIRING_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions"; |
| 6 | import { |
| 7 | ChevronRightIcon, |
| 8 | ChevronsLeftRightEllipsisIcon, |
| 9 | EllipsisIcon, |
| 10 | PlusIcon, |
| 11 | QrCodeIcon, |
| 12 | RouteIcon, |
| 13 | TerminalIcon, |
| 14 | } from "lucide-react"; |
| 15 | import { useAtomValue } from "@effect/atom-react"; |
| 16 | import { Atom } from "effect/reactivity"; |
| 17 | import { |
| 18 | type KeyboardEvent, |
| 19 | type ReactNode, |
| 20 | memo, |
| 21 | useCallback, |
| 22 | useEffect, |
| 23 | useId, |
| 24 | useMemo, |
| 25 | useRef, |
| 26 | useState, |
| 27 | } from "react"; |
| 28 | import { |
| 29 | AuthAccessReadScope, |
| 30 | AuthAccessWriteScope, |
| 31 | AuthEnvironmentMaintainScope, |
| 32 | AuthDiagnosticsReadScope, |
| 33 | AuthOrchestrationReadScope, |
| 34 | AuthRelayReadScope, |
| 35 | AuthRelayWriteScope, |
| 36 | AuthFilesystemReadScope, |
| 37 | AuthStandardClientScopes, |
| 38 | AuthTerminalReadScope, |
| 39 | type AuthClientSession, |
| 40 | type AuthEnvironmentScope, |
| 41 | type AuthGrantScope, |
| 42 | type AuthPairingLink, |
| 43 | type AuthPairingCredentialResult, |
| 44 | type AdvertisedEndpoint, |
| 45 | type DesktopDiscoveredSshHost, |
| 46 | type DesktopSshEnvironmentTarget, |
| 47 | type DesktopServerExposureState, |
| 48 | type DesktopWslState, |
| 49 | type EnvironmentId, |
| 50 | resolveEnvironmentMachineKind, |
| 51 | } from "@t3tools/contracts"; |
| 52 | import { |
| 53 | RelayConnectionRegistration, |
| 54 | RelayConnectionTarget, |
| 55 | connectionRoutes, |
| 56 | connectionStatusText, |
| 57 | environmentMcpUrl, |
| 58 | } from "@t3tools/client-runtime/connection"; |
| 59 | import { |
| 60 | isAtomCommandInterrupted, |
| 61 | squashAtomCommandFailure, |
| 62 | } from "@t3tools/client-runtime/state/runtime"; |
| 63 | import * as DateTime from "effect/DateTime"; |
| 64 | import * as Option from "effect/Option"; |
| 65 | |
| 66 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 67 | import { cn } from "../../lib/utils"; |
| 68 | import { isLocalEnvironmentDisabled } from "../../localEnvironment"; |
| 69 | import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; |
| 70 | import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; |
| 71 | import { |
| 72 | applyWslEnableSelection, |
| 73 | canRevokeOtherClients, |
| 74 | isQrShareableEndpoint, |
| 75 | isWslSettingsRowVisible, |
| 76 | selectQrEndpointOption, |
| 77 | togglePairingScopeSelection, |
| 78 | } from "./ConnectionsSettings.logic"; |
| 79 | import { |
| 80 | SettingsPageContainer, |
| 81 | SettingsRow, |
| 82 | SettingsSection, |
| 83 | useRelativeTimeTick, |
| 84 | } from "./settingsLayout"; |
| 85 | import { LocalEnvironmentSetting } from "./LocalEnvironmentSetting"; |
| 86 | import { searchableSetting } from "./settingsSearch"; |
| 87 | import { EnvironmentIconMenu } from "./EnvironmentIconPicker"; |
| 88 | import { EnvironmentRoutesList } from "./EnvironmentRoutesList"; |
| 89 | import { usePreparedConnection } from "~/state/session"; |
| 90 | import { |
| 91 | EnvironmentRow, |
| 92 | environmentTransportLabel, |
| 93 | formatDesktopSshTarget, |
| 94 | } from "./EnvironmentRow"; |
| 95 | import { FoldedSettingsSection } from "./FoldedSettingsSection"; |
| 96 | import { LoadBalancingSettings } from "./LoadBalancingSettings"; |
| 97 | import { GitHubRoutingSettings } from "./GitHubRoutingSettings"; |
| 98 | import { Input } from "../ui/input"; |
| 99 | import { CommandShortcut } from "../ui/command"; |
| 100 | import { |
| 101 | Autocomplete, |
| 102 | AutocompleteEmpty, |
| 103 | AutocompleteInput, |
| 104 | AutocompleteItem, |
| 105 | AutocompleteList, |
| 106 | AutocompletePopup, |
| 107 | } from "../ui/autocomplete"; |
| 108 | import { Checkbox } from "../ui/checkbox"; |
| 109 | import { |
| 110 | Dialog, |
| 111 | DialogClose, |
| 112 | DialogFooter, |
| 113 | DialogDescription, |
| 114 | DialogHeader, |
| 115 | DialogPanel, |
| 116 | DialogPopup, |
| 117 | DialogTitle, |
| 118 | DialogTrigger, |
| 119 | } from "../ui/dialog"; |
| 120 | import { ScrollArea } from "../ui/scroll-area"; |
| 121 | import { |
| 122 | AlertDialog, |
| 123 | AlertDialogClose, |
| 124 | AlertDialogDescription, |
| 125 | AlertDialogFooter, |
| 126 | AlertDialogHeader, |
| 127 | AlertDialogPopup, |
| 128 | AlertDialogTitle, |
| 129 | } from "../ui/alert-dialog"; |
| 130 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 131 | import { QRCodeSvg } from "../ui/qr-code"; |
| 132 | import { Spinner } from "../ui/spinner"; |
| 133 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 134 | import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; |
| 135 | import { Switch } from "../ui/switch"; |
| 136 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 137 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 138 | import { Alert, AlertDescription } from "../ui/alert"; |
| 139 | import { Button } from "../ui/button"; |
| 140 | import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; |
| 141 | import { AnimatedHeight } from "../AnimatedHeight"; |
| 142 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; |
| 143 | import { Textarea } from "../ui/textarea"; |
| 144 | import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; |
| 145 | import { readHostedPairingRequest } from "../../hostedPairing"; |
| 146 | import { |
| 147 | createServerPairingCredential, |
| 148 | revokeOtherServerClientSessions, |
| 149 | revokeServerClientSession, |
| 150 | revokeServerPairingLink, |
| 151 | isLoopbackHostname, |
| 152 | usePrimarySessionState, |
| 153 | type ServerClientSessionRecord, |
| 154 | type ServerPairingLinkRecord, |
| 155 | } from "~/environments/primary"; |
| 156 | import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; |
| 157 | import { useUiStateStore } from "~/uiStateStore"; |
| 158 | import { |
| 159 | resolveServerConfigVersionMismatch, |
| 160 | resolveServerSelfUpdateCapability, |
| 161 | supportsDesktopAppUpdate, |
| 162 | supportsServerUpdateThreadContinuation, |
| 163 | } from "~/versionSkew"; |
| 164 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| 165 | import { RemoveT3ConnectEnvironmentDialog } from "../clerk/RemoveT3ConnectEnvironmentDialog"; |
| 166 | import { useCloudLinkController } from "~/cloud/useCloudLinkController"; |
| 167 | import { authEnvironment } from "~/state/auth"; |
| 168 | import { environmentCatalog } from "~/connection/catalog"; |
| 169 | import { |
| 170 | connectPairing as connectPairingAtom, |
| 171 | connectSshEnvironment as connectSshEnvironmentAtom, |
| 172 | } from "~/connection/onboarding"; |
| 173 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; |
| 174 | import { useEnvironmentQuery } from "~/state/query"; |
| 175 | import { |
| 176 | desktopNetworkAccessStateAtom, |
| 177 | refreshDesktopNetworkAccessState, |
| 178 | } from "~/state/desktopNetworkAccess"; |
| 179 | import { desktopSshHostsStateAtom, filterDiscoveredSshHosts } from "~/state/desktopSshHosts"; |
| 180 | import { desktopWslStateAtom, refreshDesktopWslState } from "~/state/desktopWslState"; |
| 181 | import { |
| 182 | type EnvironmentPresentation, |
| 183 | useEnvironments, |
| 184 | usePrimaryEnvironment, |
| 185 | useRelayEnvironmentDiscovery, |
| 186 | usePrimaryEnvironmentId, |
| 187 | } from "~/state/environments"; |
| 188 | import { APP_VERSION } from "~/branding"; |
| 189 | import { requestConfirmDialog } from "~/confirmDialog"; |
| 190 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 191 | import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server"; |
| 192 | import { ConnectionStatusDot } from "../ConnectionStatusDot"; |
| 193 | import { |
| 194 | OutdatedServerUpdateAction, |
| 195 | ServerUpdateAction, |
| 196 | ServerUpdateProgress, |
| 197 | ServerUpdatesAction, |
| 198 | type ServerUpdateTarget, |
| 199 | } from "../ServerUpdateAction"; |
| 200 | import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; |
| 201 | import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; |
| 202 | import { |
| 203 | resolveShortcutCommand, |
| 204 | shortcutLabelForCommand, |
| 205 | threadJumpCommandForIndex, |
| 206 | threadJumpIndexFromCommand, |
| 207 | } from "../../keybindings"; |
| 208 | |
| 209 | const DEFAULT_TAILSCALE_SERVE_PORT = 443; |
| 210 | const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray<AdvertisedEndpoint> = []; |
| 211 | const EMPTY_DISCOVERED_SSH_HOSTS: ReadonlyArray<DesktopDiscoveredSshHost> = []; |
| 212 | |
| 213 | // Sentinels for the consolidated WSL backend picker. The colon is |
| 214 | // rejected by DISTRO_NAME_PATTERN (validated on the desktop side) so |
| 215 | // neither can collide with a real distro name. |
| 216 | const BACKEND_VALUE_DEFAULT_WSL = "backend:default-wsl"; |
| 217 | const BACKEND_VALUE_WSL_OFF = "backend:wsl-off"; |
| 218 | |
| 219 | const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { |
| 220 | dateStyle: "medium", |
| 221 | timeStyle: "short", |
| 222 | }); |
| 223 | |
| 224 | function formatAccessTimestamp(value: string): string { |
| 225 | const parsed = new Date(value); |
| 226 | if (Number.isNaN(parsed.getTime())) { |
| 227 | return value; |
| 228 | } |
| 229 | return accessTimestampFormatter.format(parsed); |
| 230 | } |
| 231 | |
| 232 | function AccessScopeSummary({ |
| 233 | scopes, |
| 234 | label, |
| 235 | }: { |
| 236 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 237 | readonly label: string; |
| 238 | }) { |
| 239 | const scopeCountLabel = `${scopes.length} ${scopes.length === 1 ? "scope" : "scopes"}`; |
| 240 | |
| 241 | return ( |
| 242 | <Popover> |
| 243 | <PopoverTrigger |
| 244 | openOnHover |
| 245 | delay={250} |
| 246 | closeDelay={100} |
| 247 | render={ |
| 248 | <button |
| 249 | type="button" |
| 250 | aria-label={`${label}: show ${scopeCountLabel}`} |
| 251 | className="cursor-help underline decoration-border underline-offset-2 outline-hidden hover:text-foreground focus-visible:text-foreground" |
| 252 | /> |
| 253 | } |
| 254 | > |
| 255 | {scopeCountLabel} |
| 256 | </PopoverTrigger> |
| 257 | <PopoverPopup |
| 258 | side="top" |
| 259 | align="start" |
| 260 | tooltipStyle |
| 261 | className="w-max max-w-80 whitespace-normal" |
| 262 | > |
| 263 | <p className="mb-1 font-medium">Granted scopes</p> |
| 264 | <div className="flex flex-col gap-0.5"> |
| 265 | {scopes.map((scope) => ( |
| 266 | <code key={scope} className="font-mono text-foreground/85"> |
| 267 | {scope} |
| 268 | </code> |
| 269 | ))} |
| 270 | </div> |
| 271 | </PopoverPopup> |
| 272 | </Popover> |
| 273 | ); |
| 274 | } |
| 275 | |
| 276 | function parseManualDesktopSshTarget(input: { |
| 277 | readonly host: string; |
| 278 | readonly username: string; |
| 279 | readonly port: string; |
| 280 | }): DesktopSshEnvironmentTarget { |
| 281 | const rawHost = input.host.trim(); |
| 282 | if (rawHost.length === 0) { |
| 283 | throw new Error("SSH host or alias is required."); |
| 284 | } |
| 285 | |
| 286 | let hostname = rawHost; |
| 287 | let username = input.username.trim() || null; |
| 288 | let port: number | null = null; |
| 289 | |
| 290 | const atIndex = hostname.lastIndexOf("@"); |
| 291 | if (atIndex > 0) { |
| 292 | const inlineUsername = hostname.slice(0, atIndex).trim(); |
| 293 | hostname = hostname.slice(atIndex + 1).trim(); |
| 294 | if (!username && inlineUsername.length > 0) { |
| 295 | username = inlineUsername; |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | const bracketedHostMatch = /^\[([^\]]+)\](?::(\d+))?$/u.exec(hostname); |
| 300 | if (bracketedHostMatch) { |
| 301 | hostname = bracketedHostMatch[1]!.trim(); |
| 302 | if (bracketedHostMatch[2]) { |
| 303 | port = Number.parseInt(bracketedHostMatch[2], 10); |
| 304 | } |
| 305 | } else { |
| 306 | const colonSegments = hostname.split(":"); |
| 307 | if (colonSegments.length === 2 && /^\d+$/u.test(colonSegments[1] ?? "")) { |
| 308 | hostname = colonSegments[0]!.trim(); |
| 309 | port = Number.parseInt(colonSegments[1]!, 10); |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | const rawPort = input.port.trim(); |
| 314 | if (rawPort.length > 0) { |
| 315 | port = Number.parseInt(rawPort, 10); |
| 316 | } |
| 317 | |
| 318 | if (hostname.length === 0) { |
| 319 | throw new Error("SSH host or alias is required."); |
| 320 | } |
| 321 | |
| 322 | if (port !== null && (!Number.isInteger(port) || port <= 0 || port > 65_535)) { |
| 323 | throw new Error("SSH port must be between 1 and 65535."); |
| 324 | } |
| 325 | |
| 326 | return { |
| 327 | alias: hostname, |
| 328 | hostname, |
| 329 | username, |
| 330 | port, |
| 331 | }; |
| 332 | } |
| 333 | |
| 334 | function parsePairingUrlFields( |
| 335 | input: string, |
| 336 | ): { readonly host: string; readonly pairingCode: string } | null { |
| 337 | const trimmed = input.trim(); |
| 338 | if (!trimmed) return null; |
| 339 | |
| 340 | try { |
| 341 | const urlLikeInput = |
| 342 | /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//u.test(trimmed) || trimmed.startsWith("//") |
| 343 | ? trimmed |
| 344 | : `https://${trimmed}`; |
| 345 | const url = new URL(urlLikeInput, window.location.origin); |
| 346 | const hostedPairingRequest = readHostedPairingRequest(url); |
| 347 | if (hostedPairingRequest) { |
| 348 | return { |
| 349 | host: hostedPairingRequest.host, |
| 350 | pairingCode: hostedPairingRequest.token, |
| 351 | }; |
| 352 | } |
| 353 | |
| 354 | const pairingCode = getPairingTokenFromUrl(url); |
| 355 | if (!pairingCode) return null; |
| 356 | return { |
| 357 | host: url.origin, |
| 358 | pairingCode, |
| 359 | }; |
| 360 | } catch { |
| 361 | return null; |
| 362 | } |
| 363 | } |
| 364 | |
| 365 | function parseRemotePairingFields(input: { readonly host: string; readonly pairingCode: string }): { |
| 366 | readonly host: string; |
| 367 | readonly pairingCode: string; |
| 368 | } { |
| 369 | const parsedPairingUrl = parsePairingUrlFields(input.host); |
| 370 | if (parsedPairingUrl) return parsedPairingUrl; |
| 371 | |
| 372 | const host = input.host.trim(); |
| 373 | const pairingCode = input.pairingCode.trim(); |
| 374 | if (!host) { |
| 375 | throw new Error("Enter a backend host."); |
| 376 | } |
| 377 | if (!pairingCode) { |
| 378 | throw new Error("Enter a pairing code."); |
| 379 | } |
| 380 | return { host, pairingCode }; |
| 381 | } |
| 382 | |
| 383 | function formatDesktopSshConnectionError(error: unknown): string { |
| 384 | const fallback = "Failed to connect SSH host."; |
| 385 | const rawMessage = error instanceof Error ? error.message : fallback; |
| 386 | const withoutIpcPrefix = rawMessage.replace( |
| 387 | /^Error invoking remote method 'desktop:ensure-ssh-environment':\s*/u, |
| 388 | "", |
| 389 | ); |
| 390 | const withoutTaggedErrorPrefix = withoutIpcPrefix.replace(/^Ssh[A-Za-z]+Error:\s*/u, ""); |
| 391 | return withoutTaggedErrorPrefix.trim() || fallback; |
| 392 | } |
| 393 | |
| 394 | const ENDPOINT_ROW_CLASSNAME = "first:rounded-t-xl last:rounded-b-xl px-3 py-2.5 sm:px-4"; |
| 395 | |
| 396 | type AccessSectionPresentation = "current" | "endpoint-rail"; |
| 397 | |
| 398 | function accessRowClassName(_presentation: AccessSectionPresentation) { |
| 399 | return ITEM_ROW_CLASSNAME; |
| 400 | } |
| 401 | |
| 402 | function endpointRowClassName(presentation: AccessSectionPresentation, isAvailable: boolean) { |
| 403 | if (presentation === "endpoint-rail") { |
| 404 | return cn( |
| 405 | "relative first:rounded-t-xl last:rounded-b-xl px-3 py-3 sm:px-4", |
| 406 | !isAvailable && "bg-muted/15", |
| 407 | ); |
| 408 | } |
| 409 | |
| 410 | return cn(ENDPOINT_ROW_CLASSNAME, !isAvailable && "bg-muted/24"); |
| 411 | } |
| 412 | |
| 413 | function sortDesktopPairingLinks(links: ReadonlyArray<ServerPairingLinkRecord>) { |
| 414 | return [...links].toSorted( |
| 415 | (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(), |
| 416 | ); |
| 417 | } |
| 418 | |
| 419 | /** Closed-header summary for the Authorized clients fold. */ |
| 420 | function summarizeAuthorizedClients( |
| 421 | sessions: ReadonlyArray<ServerClientSessionRecord>, |
| 422 | links: ReadonlyArray<ServerPairingLinkRecord>, |
| 423 | ): string { |
| 424 | const parts = [ |
| 425 | `${sessions.length} ${sessions.length === 1 ? "client" : "clients"}`, |
| 426 | links.length > 0 |
| 427 | ? `${links.length} ${links.length === 1 ? "pairing link" : "pairing links"}` |
| 428 | : null, |
| 429 | ]; |
| 430 | return parts.filter((part): part is string => part !== null).join(" · "); |
| 431 | } |
| 432 | |
| 433 | function sortDesktopClientSessions(sessions: ReadonlyArray<ServerClientSessionRecord>) { |
| 434 | return [...sessions].toSorted((left, right) => { |
| 435 | if (left.current !== right.current) { |
| 436 | return left.current ? -1 : 1; |
| 437 | } |
| 438 | if (left.connected !== right.connected) { |
| 439 | return left.connected ? -1 : 1; |
| 440 | } |
| 441 | return new Date(right.issuedAt).getTime() - new Date(left.issuedAt).getTime(); |
| 442 | }); |
| 443 | } |
| 444 | |
| 445 | function toDesktopPairingLinkRecord(pairingLink: AuthPairingLink): ServerPairingLinkRecord { |
| 446 | return { |
| 447 | ...pairingLink, |
| 448 | scopes: pairingLink.permissions ?? pairingLink.scopes, |
| 449 | createdAt: DateTime.formatIso(pairingLink.createdAt), |
| 450 | expiresAt: DateTime.formatIso(pairingLink.expiresAt), |
| 451 | }; |
| 452 | } |
| 453 | |
| 454 | function toDesktopClientSessionRecord(clientSession: AuthClientSession): ServerClientSessionRecord { |
| 455 | return { |
| 456 | ...clientSession, |
| 457 | scopes: clientSession.permissions ?? clientSession.scopes, |
| 458 | issuedAt: DateTime.formatIso(clientSession.issuedAt), |
| 459 | expiresAt: DateTime.formatIso(clientSession.expiresAt), |
| 460 | lastConnectedAt: |
| 461 | clientSession.lastConnectedAt === null |
| 462 | ? null |
| 463 | : DateTime.formatIso(clientSession.lastConnectedAt), |
| 464 | }; |
| 465 | } |
| 466 | |
| 467 | function selectPairingEndpoint( |
| 468 | endpoints: ReadonlyArray<AdvertisedEndpoint>, |
| 469 | defaultEndpointKey?: string | null, |
| 470 | ): AdvertisedEndpoint | null { |
| 471 | const availableEndpoints = endpoints.filter((endpoint) => endpoint.status !== "unavailable"); |
| 472 | if (defaultEndpointKey) { |
| 473 | const selectedEndpoint = availableEndpoints.find( |
| 474 | (endpoint) => endpointDefaultPreferenceKey(endpoint) === defaultEndpointKey, |
| 475 | ); |
| 476 | if (selectedEndpoint) { |
| 477 | return selectedEndpoint; |
| 478 | } |
| 479 | } |
| 480 | return ( |
| 481 | availableEndpoints.find((endpoint) => endpoint.isDefault) ?? |
| 482 | availableEndpoints.find((endpoint) => endpoint.reachability !== "loopback") ?? |
| 483 | availableEndpoints.find((endpoint) => endpoint.compatibility.hostedHttpsApp === "compatible") ?? |
| 484 | null |
| 485 | ); |
| 486 | } |
| 487 | |
| 488 | function isTailscaleHttpsEndpoint(endpoint: AdvertisedEndpoint): boolean { |
| 489 | return endpoint.id.startsWith("tailscale-magicdns:"); |
| 490 | } |
| 491 | |
| 492 | function endpointDefaultPreferenceKey(endpoint: AdvertisedEndpoint): string { |
| 493 | if (endpoint.id.startsWith("desktop-loopback:")) { |
| 494 | return "desktop-core:loopback:http"; |
| 495 | } |
| 496 | if (endpoint.id.startsWith("desktop-lan:")) { |
| 497 | return "desktop-core:lan:http"; |
| 498 | } |
| 499 | if (endpoint.id.startsWith("tailscale-ip:")) { |
| 500 | return "tailscale:ip:http"; |
| 501 | } |
| 502 | if (isTailscaleHttpsEndpoint(endpoint)) { |
| 503 | return "tailscale:magicdns:https"; |
| 504 | } |
| 505 | |
| 506 | let scheme = "unknown"; |
| 507 | try { |
| 508 | scheme = new URL(endpoint.httpBaseUrl).protocol.replace(/:$/u, ""); |
| 509 | } catch { |
| 510 | // Keep the stored preference stable even if a custom endpoint is malformed. |
| 511 | } |
| 512 | |
| 513 | return `${endpoint.provider.id}:${endpoint.reachability}:${scheme}:${endpoint.label}`; |
| 514 | } |
| 515 | |
| 516 | function resolveAdvertisedEndpointPairingUrl( |
| 517 | endpoint: AdvertisedEndpoint, |
| 518 | credential: string, |
| 519 | ): string { |
| 520 | if (endpoint.compatibility.hostedHttpsApp === "compatible") { |
| 521 | return ( |
| 522 | resolveHostedPairingUrl(endpoint.httpBaseUrl, credential) ?? |
| 523 | resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential) |
| 524 | ); |
| 525 | } |
| 526 | return resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential); |
| 527 | } |
| 528 | |
| 529 | function resolveCurrentOriginPairingUrl(credential: string): string { |
| 530 | const url = new URL("/pair", window.location.href); |
| 531 | return setPairingTokenOnUrl(url, credential).toString(); |
| 532 | } |
| 533 | |
| 534 | function isHostedAppPairingUrl(value: string): boolean { |
| 535 | try { |
| 536 | const url = new URL(value); |
| 537 | return url.pathname === "/pair" && url.searchParams.has("host"); |
| 538 | } catch { |
| 539 | return false; |
| 540 | } |
| 541 | } |
| 542 | |
| 543 | function endpointShareHint(endpoint: AdvertisedEndpoint, url: string): string { |
| 544 | if (isHostedAppPairingUrl(url)) { |
| 545 | return "Opens the hosted app, no install needed"; |
| 546 | } |
| 547 | switch (endpoint.reachability) { |
| 548 | case "lan": |
| 549 | return "Devices on the same network"; |
| 550 | case "private-network": |
| 551 | return "Devices on your private network"; |
| 552 | case "public": |
| 553 | return "Reachable from anywhere"; |
| 554 | case "loopback": |
| 555 | return "Clients on this machine"; |
| 556 | } |
| 557 | } |
| 558 | |
| 559 | type PairingLinkListRowProps = { |
| 560 | pairingLink: ServerPairingLinkRecord; |
| 561 | credential: string | undefined; |
| 562 | endpointUrl: string | null | undefined; |
| 563 | endpoints: ReadonlyArray<AdvertisedEndpoint>; |
| 564 | defaultEndpointKey: string | null; |
| 565 | presentation?: AccessSectionPresentation; |
| 566 | revokingPairingLinkId: string | null; |
| 567 | onRevoke: (id: string) => void; |
| 568 | canRevoke: boolean; |
| 569 | }; |
| 570 | |
| 571 | const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 572 | pairingLink, |
| 573 | credential, |
| 574 | endpointUrl, |
| 575 | endpoints, |
| 576 | defaultEndpointKey, |
| 577 | presentation = "current", |
| 578 | revokingPairingLinkId, |
| 579 | onRevoke, |
| 580 | canRevoke, |
| 581 | }: PairingLinkListRowProps) { |
| 582 | const nowMs = useRelativeTimeTick(1_000); |
| 583 | const expiresAtMs = useMemo( |
| 584 | () => new Date(pairingLink.expiresAt).getTime(), |
| 585 | [pairingLink.expiresAt], |
| 586 | ); |
| 587 | const [isRevealDialogOpen, setIsRevealDialogOpen] = useState(false); |
| 588 | const [isQrPanelOpen, setIsQrPanelOpen] = useState(false); |
| 589 | // Ephemeral per-row choice of which endpoint the QR encodes (AdvertisedEndpoint.id); |
| 590 | // null falls back to the saved default endpoint. |
| 591 | const [qrEndpointId, setQrEndpointId] = useState<string | null>(null); |
| 592 | const qrPanelId = useId(); |
| 593 | |
| 594 | const currentOriginPairingUrl = useMemo( |
| 595 | () => (credential ? resolveCurrentOriginPairingUrl(credential) : null), |
| 596 | [credential], |
| 597 | ); |
| 598 | const hostedPairingUrl = useMemo( |
| 599 | () => |
| 600 | credential && endpointUrl != null && endpointUrl !== "" |
| 601 | ? resolveHostedPairingUrl(endpointUrl, credential) |
| 602 | : null, |
| 603 | [endpointUrl, credential], |
| 604 | ); |
| 605 | const endpointPairingUrl = useMemo(() => { |
| 606 | const endpoint = selectPairingEndpoint(endpoints, defaultEndpointKey); |
| 607 | return endpoint && credential |
| 608 | ? resolveAdvertisedEndpointPairingUrl(endpoint, credential) |
| 609 | : null; |
| 610 | }, [defaultEndpointKey, endpoints, credential]); |
| 611 | const endpointCopyOptions = useMemo(() => { |
| 612 | const options: Array<{ |
| 613 | readonly id: string; |
| 614 | readonly preferenceKey: string; |
| 615 | readonly label: string; |
| 616 | readonly url: string; |
| 617 | readonly detail: string; |
| 618 | readonly qrShareable: boolean; |
| 619 | }> = []; |
| 620 | if (!credential) return options; |
| 621 | for (const endpoint of endpoints) { |
| 622 | if (endpoint.status === "unavailable") { |
| 623 | continue; |
| 624 | } |
| 625 | const url = resolveAdvertisedEndpointPairingUrl(endpoint, credential); |
| 626 | options.push({ |
| 627 | id: endpoint.id, |
| 628 | preferenceKey: endpointDefaultPreferenceKey(endpoint), |
| 629 | label: endpoint.label, |
| 630 | url, |
| 631 | detail: endpointShareHint(endpoint, url), |
| 632 | qrShareable: isQrShareableEndpoint(endpoint), |
| 633 | }); |
| 634 | } |
| 635 | return options; |
| 636 | }, [endpoints, credential]); |
| 637 | const shareablePairingUrl = |
| 638 | endpointPairingUrl ?? |
| 639 | (credential && endpointUrl != null && endpointUrl !== "" |
| 640 | ? (hostedPairingUrl ?? resolveDesktopPairingUrl(endpointUrl, credential)) |
| 641 | : isLoopbackHostname(window.location.hostname) |
| 642 | ? null |
| 643 | : currentOriginPairingUrl); |
| 644 | // Value of the copy attempt that last failed. The clipboard-failure reveal |
| 645 | // dialog must show exactly what failed to copy, not the row's default URL. |
| 646 | const [failedCopyValue, setFailedCopyValue] = useState<string | null>(null); |
| 647 | const revealValue = failedCopyValue ?? shareablePairingUrl ?? credential ?? ""; |
| 648 | const isRevealValueUrl = revealValue !== credential; |
| 649 | const isRevealValueHostedAppPairingUrl = isRevealValueUrl && isHostedAppPairingUrl(revealValue); |
| 650 | // Never render a QR for a loopback URL, even in the manual-copy fallback. |
| 651 | const isRevealValueQrShareable = |
| 652 | endpointCopyOptions.find((option) => option.url === revealValue)?.qrShareable ?? true; |
| 653 | const canCopyToClipboard = |
| 654 | typeof window !== "undefined" && |
| 655 | window.isSecureContext && |
| 656 | navigator.clipboard?.writeText != null; |
| 657 | |
| 658 | const { copyToClipboard } = useCopyToClipboard<{ |
| 659 | value: string; |
| 660 | kind: "code" | "hosted-link" | "link"; |
| 661 | }>({ |
| 662 | onCopy: ({ kind }) => { |
| 663 | toastManager.add({ |
| 664 | type: "success", |
| 665 | title: |
| 666 | kind === "hosted-link" |
| 667 | ? "Hosted app link copied" |
| 668 | : kind === "link" |
| 669 | ? "Pairing URL copied" |
| 670 | : "Pairing code copied", |
| 671 | description: |
| 672 | kind === "hosted-link" |
| 673 | ? "Open it in the browser on the device you want to connect." |
| 674 | : kind === "link" |
| 675 | ? "Open it in the client you want to pair to this environment." |
| 676 | : "Paste it into another client to finish pairing.", |
| 677 | }); |
| 678 | }, |
| 679 | onError: (error, { value, kind }) => { |
| 680 | // Captured per attempt so concurrent copies cannot make the dialog |
| 681 | // reveal a different value than the one that failed. |
| 682 | setFailedCopyValue(value); |
| 683 | setIsRevealDialogOpen(true); |
| 684 | toastManager.add( |
| 685 | stackedThreadToast({ |
| 686 | type: "error", |
| 687 | title: canCopyToClipboard |
| 688 | ? kind === "hosted-link" |
| 689 | ? "Could not copy hosted app link" |
| 690 | : kind === "link" |
| 691 | ? "Could not copy pairing URL" |
| 692 | : "Could not copy pairing code" |
| 693 | : "Clipboard copy unavailable", |
| 694 | description: canCopyToClipboard ? error.message : "Showing the full value instead.", |
| 695 | }), |
| 696 | ); |
| 697 | }, |
| 698 | }); |
| 699 | |
| 700 | const copyPairingValue = useCallback( |
| 701 | (value: string, kind: "code" | "hosted-link" | "link") => { |
| 702 | copyToClipboard(value, { value, kind }); |
| 703 | }, |
| 704 | [copyToClipboard], |
| 705 | ); |
| 706 | |
| 707 | const copyKindForUrl = useCallback( |
| 708 | (url: string): "hosted-link" | "link" => (isHostedAppPairingUrl(url) ? "hosted-link" : "link"), |
| 709 | [], |
| 710 | ); |
| 711 | |
| 712 | const handleCopyCode = useCallback(() => { |
| 713 | if (credential) copyPairingValue(credential, "code"); |
| 714 | }, [copyPairingValue, credential]); |
| 715 | |
| 716 | const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); |
| 717 | |
| 718 | const primaryLabel = pairingLink.label ?? "Pairing link"; |
| 719 | const selectedQrOption = selectQrEndpointOption( |
| 720 | endpointCopyOptions, |
| 721 | qrEndpointId, |
| 722 | defaultEndpointKey, |
| 723 | ); |
| 724 | const qrPairingUrl = selectedQrOption?.url ?? shareablePairingUrl; |
| 725 | // With no endpoint list the fallback is never loopback: selectPairingEndpoint |
| 726 | // skips loopback and the current-origin fallback is guarded by |
| 727 | // isLoopbackHostname, so only an explicit loopback selection hides the QR. |
| 728 | const canRenderQrForSelection = selectedQrOption?.qrShareable ?? true; |
| 729 | if (expiresAtMs <= nowMs) { |
| 730 | return null; |
| 731 | } |
| 732 | |
| 733 | return ( |
| 734 | <div className={accessRowClassName(presentation)}> |
| 735 | <div className={ITEM_ROW_INNER_CLASSNAME}> |
| 736 | <div className="min-w-0 flex-1 space-y-1"> |
| 737 | <div className="flex min-h-5 items-center gap-1.5"> |
| 738 | <ConnectionStatusDot |
| 739 | tooltipText={`Link created at ${formatAccessTimestamp(pairingLink.createdAt)}`} |
| 740 | dotClassName="bg-warning" |
| 741 | /> |
| 742 | <h3 className="text-sm font-medium text-foreground">{primaryLabel}</h3> |
| 743 | </div> |
| 744 | <p className="text-xs text-muted-foreground"> |
| 745 | <Tooltip> |
| 746 | <TooltipTrigger render={<span />}> |
| 747 | {formatExpiresInLabel(pairingLink.expiresAt, nowMs)} |
| 748 | </TooltipTrigger> |
| 749 | <TooltipPopup side="top">{expiresAbsolute}</TooltipPopup> |
| 750 | </Tooltip> |
| 751 | <span aria-hidden> · </span> |
| 752 | <AccessScopeSummary scopes={pairingLink.scopes} label="Pairing link scopes" /> |
| 753 | </p> |
| 754 | {!credential ? ( |
| 755 | <p className="text-2xs text-muted-foreground/70"> |
| 756 | Create a new link to share from this client. |
| 757 | </p> |
| 758 | ) : shareablePairingUrl === null ? ( |
| 759 | <p className="text-2xs text-muted-foreground/70"> |
| 760 | Copy the token and pair from another client using this backend&apos;s reachable host. |
| 761 | </p> |
| 762 | ) : null} |
| 763 | </div> |
| 764 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> |
| 765 | {shareablePairingUrl && canCopyToClipboard ? ( |
| 766 | <Button |
| 767 | size="xs" |
| 768 | variant="outline" |
| 769 | aria-expanded={isQrPanelOpen} |
| 770 | aria-controls={qrPanelId} |
| 771 | onClick={() => setIsQrPanelOpen((open) => !open)} |
| 772 | > |
| 773 | <QrCodeIcon aria-hidden /> |
| 774 | Share |
| 775 | </Button> |
| 776 | ) : null} |
| 777 | <Dialog |
| 778 | open={credential !== undefined && isRevealDialogOpen} |
| 779 | onOpenChange={(open) => { |
| 780 | setIsRevealDialogOpen(open); |
| 781 | if (!open) setFailedCopyValue(null); |
| 782 | }} |
| 783 | > |
| 784 | {!credential ? null : canCopyToClipboard ? ( |
| 785 | shareablePairingUrl ? null : ( |
| 786 | <Button size="xs" variant="outline" onClick={handleCopyCode}> |
| 787 | Copy code |
| 788 | </Button> |
| 789 | ) |
| 790 | ) : ( |
| 791 | <DialogTrigger render={<Button size="xs" variant="outline" />}> |
| 792 | {shareablePairingUrl ? "Show link" : "Show code"} |
| 793 | </DialogTrigger> |
| 794 | )} |
| 795 | <DialogPopup className="max-w-md"> |
| 796 | <DialogHeader> |
| 797 | <DialogTitle> |
| 798 | {isRevealValueUrl |
| 799 | ? isRevealValueHostedAppPairingUrl |
| 800 | ? "Hosted app pairing link" |
| 801 | : "Pairing link" |
| 802 | : "Pairing code"} |
| 803 | </DialogTitle> |
| 804 | <DialogDescription> |
| 805 | {isRevealValueUrl |
| 806 | ? isRevealValueHostedAppPairingUrl |
| 807 | ? "Clipboard copy is unavailable here. Open or manually copy this hosted app link on the device you want to connect." |
| 808 | : "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect." |
| 809 | : "Clipboard copy is unavailable here. Manually copy this code into another client."} |
| 810 | </DialogDescription> |
| 811 | </DialogHeader> |
| 812 | <DialogPanel> |
| 813 | <Textarea |
| 814 | readOnly |
| 815 | value={revealValue} |
| 816 | rows={isRevealValueUrl ? 4 : 3} |
| 817 | onFocus={(event) => event.currentTarget.select()} |
| 818 | onClick={(event) => event.currentTarget.select()} |
| 819 | /> |
| 820 | {isRevealValueUrl && isRevealValueQrShareable ? ( |
| 821 | <div className="flex justify-center rounded-xl border border-border/60 bg-muted/30 p-4"> |
| 822 | <QRCodeSvg |
| 823 | value={revealValue} |
| 824 | size={132} |
| 825 | level="M" |
| 826 | marginSize={2} |
| 827 | title="Pairing link — scan to open on another device" |
| 828 | /> |
| 829 | </div> |
| 830 | ) : null} |
| 831 | </DialogPanel> |
| 832 | <DialogFooter variant="bare"> |
| 833 | <Button variant="outline" onClick={() => setIsRevealDialogOpen(false)}> |
| 834 | Done |
| 835 | </Button> |
| 836 | {canCopyToClipboard ? ( |
| 837 | <Button variant="outline" onClick={handleCopyCode}> |
| 838 | Copy code |
| 839 | </Button> |
| 840 | ) : null} |
| 841 | </DialogFooter> |
| 842 | </DialogPopup> |
| 843 | </Dialog> |
| 844 | <Button |
| 845 | size="xs" |
| 846 | variant="destructive-outline" |
| 847 | disabled={!canRevoke || revokingPairingLinkId === pairingLink.id} |
| 848 | onClick={() => void onRevoke(pairingLink.id)} |
| 849 | > |
| 850 | {revokingPairingLinkId === pairingLink.id ? "Revoking…" : "Revoke"} |
| 851 | </Button> |
| 852 | </div> |
| 853 | </div> |
| 854 | {isQrPanelOpen && qrPairingUrl !== null ? ( |
| 855 | <div |
| 856 | id={qrPanelId} |
| 857 | className="mt-3 flex flex-col gap-4 border-t border-border/50 pt-3 sm:flex-row sm:items-start sm:justify-between" |
| 858 | > |
| 859 | <div className="min-w-0 flex-1 space-y-3"> |
| 860 | {endpointCopyOptions.length > 1 ? ( |
| 861 | <div |
| 862 | className="space-y-1.5" |
| 863 | role="radiogroup" |
| 864 | aria-label="Endpoint the pairing QR code and URL use" |
| 865 | > |
| 866 | <p className="text-2xs text-muted-foreground/70">Reach this machine via</p> |
| 867 | {endpointCopyOptions.map((option) => { |
| 868 | const isSelected = option.id === selectedQrOption?.id; |
| 869 | return ( |
| 870 | <button |
| 871 | key={option.id} |
| 872 | type="button" |
| 873 | role="radio" |
| 874 | aria-checked={isSelected} |
| 875 | className={cn( |
| 876 | "flex w-full items-baseline gap-2 rounded-lg border px-2.5 py-1.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", |
| 877 | isSelected |
| 878 | ? "border-foreground/60 bg-muted/30" |
| 879 | : "border-border/50 hover:bg-muted/20", |
| 880 | )} |
| 881 | onClick={() => setQrEndpointId(option.id)} |
| 882 | > |
| 883 | <span |
| 884 | className={cn( |
| 885 | "text-xs font-medium", |
| 886 | isSelected ? "text-foreground" : "text-muted-foreground", |
| 887 | )} |
| 888 | > |
| 889 | {option.label} |
| 890 | </span> |
| 891 | <span className="min-w-0 truncate text-2xs text-muted-foreground/70"> |
| 892 | {option.detail} |
| 893 | </span> |
| 894 | </button> |
| 895 | ); |
| 896 | })} |
| 897 | </div> |
| 898 | ) : null} |
| 899 | <div className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-2.5 py-1.5"> |
| 900 | <Tooltip> |
| 901 | <TooltipTrigger |
| 902 | render={ |
| 903 | <code className="min-w-0 flex-1 truncate font-mono text-2xs text-muted-foreground"> |
| 904 | {qrPairingUrl} |
| 905 | </code> |
| 906 | } |
| 907 | /> |
| 908 | <TooltipPopup side="top">{qrPairingUrl}</TooltipPopup> |
| 909 | </Tooltip> |
| 910 | <Button |
| 911 | size="xs" |
| 912 | variant="ghost" |
| 913 | className="shrink-0" |
| 914 | onClick={() => copyPairingValue(qrPairingUrl, copyKindForUrl(qrPairingUrl))} |
| 915 | > |
| 916 | Copy link |
| 917 | </Button> |
| 918 | </div> |
| 919 | <Button size="xs" variant="ghost" onClick={handleCopyCode}> |
| 920 | Copy code only |
| 921 | </Button> |
| 922 | </div> |
| 923 | {canRenderQrForSelection ? ( |
| 924 | <div className="w-fit shrink-0 self-center rounded-xl bg-white p-3 sm:self-start"> |
| 925 | <QRCodeSvg |
| 926 | value={qrPairingUrl} |
| 927 | size={168} |
| 928 | level="M" |
| 929 | marginSize={1} |
| 930 | title="Pairing link — scan to open on another device" |
| 931 | /> |
| 932 | </div> |
| 933 | ) : ( |
| 934 | <div className="flex size-[192px] shrink-0 items-center justify-center self-center rounded-xl border border-border/50 p-4 sm:self-start"> |
| 935 | <p className="text-center text-2xs text-muted-foreground/70"> |
| 936 | No QR for this endpoint. Another device scanning a loopback link would dial itself; |
| 937 | copy the URL for use on this machine instead. |
| 938 | </p> |
| 939 | </div> |
| 940 | )} |
| 941 | </div> |
| 942 | ) : null} |
| 943 | </div> |
| 944 | ); |
| 945 | }); |
| 946 | |
| 947 | type ConnectedClientListRowProps = { |
| 948 | clientSession: ServerClientSessionRecord; |
| 949 | presentation?: AccessSectionPresentation; |
| 950 | revokingClientSessionId: string | null; |
| 951 | onRevokeSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void; |
| 952 | canRevoke: boolean; |
| 953 | }; |
| 954 | |
| 955 | const ConnectedClientListRow = memo(function ConnectedClientListRow({ |
| 956 | clientSession, |
| 957 | presentation = "current", |
| 958 | revokingClientSessionId, |
| 959 | onRevokeSession, |
| 960 | canRevoke, |
| 961 | }: ConnectedClientListRowProps) { |
| 962 | const nowMs = useRelativeTimeTick(1_000); |
| 963 | const isLive = clientSession.current || clientSession.connected; |
| 964 | const lastConnectedAt = clientSession.lastConnectedAt; |
| 965 | const statusTooltip = isLive |
| 966 | ? lastConnectedAt |
| 967 | ? `Connected for ${formatElapsedDurationLabel(lastConnectedAt, nowMs)}` |
| 968 | : "Connected" |
| 969 | : lastConnectedAt |
| 970 | ? `Last connected at ${formatAccessTimestamp(lastConnectedAt)}` |
| 971 | : "Not connected yet."; |
| 972 | const deviceInfoBits = [ |
| 973 | clientSession.client.deviceType !== "unknown" |
| 974 | ? clientSession.client.deviceType[0]?.toUpperCase() + clientSession.client.deviceType.slice(1) |
| 975 | : null, |
| 976 | clientSession.client.os ?? null, |
| 977 | clientSession.client.browser ?? null, |
| 978 | clientSession.client.ipAddress ?? null, |
| 979 | ].filter((value): value is string => value !== null); |
| 980 | const primaryLabel = |
| 981 | clientSession.client.label ?? |
| 982 | ([clientSession.client.os, clientSession.client.browser].filter(Boolean).join(" · ") || |
| 983 | clientSession.subject); |
| 984 | |
| 985 | return ( |
| 986 | <div className={accessRowClassName(presentation)}> |
| 987 | <div className={ITEM_ROW_INNER_CLASSNAME}> |
| 988 | <div className="min-w-0 flex-1 space-y-1"> |
| 989 | <div className="flex min-h-5 items-center gap-1.5"> |
| 990 | <ConnectionStatusDot |
| 991 | tooltipText={statusTooltip} |
| 992 | dotClassName={isLive ? "bg-success" : "bg-muted-foreground/30"} |
| 993 | pingClassName={isLive ? "bg-success/60 duration-2000" : null} |
| 994 | /> |
| 995 | <h3 className="text-sm font-medium text-foreground">{primaryLabel}</h3> |
| 996 | {clientSession.current ? ( |
| 997 | <span className="text-3xs text-muted-foreground/80 rounded-md border border-border/50 bg-muted/50 px-1 py-0.5"> |
| 998 | This device |
| 999 | </span> |
| 1000 | ) : null} |
| 1001 | </div> |
| 1002 | <p className="text-xs text-muted-foreground"> |
| 1003 | {deviceInfoBits.length > 0 ? ( |
| 1004 | <> |
| 1005 | {deviceInfoBits.join(" · ")} |
| 1006 | <span aria-hidden> · </span> |
| 1007 | </> |
| 1008 | ) : null} |
| 1009 | <AccessScopeSummary scopes={clientSession.scopes} label="Client scopes" /> |
| 1010 | </p> |
| 1011 | </div> |
| 1012 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> |
| 1013 | {!clientSession.current ? ( |
| 1014 | <Button |
| 1015 | size="xs" |
| 1016 | variant="destructive-outline" |
| 1017 | disabled={!canRevoke || revokingClientSessionId === clientSession.sessionId} |
| 1018 | onClick={() => void onRevokeSession(clientSession.sessionId)} |
| 1019 | > |
| 1020 | {revokingClientSessionId === clientSession.sessionId ? "Revoking…" : "Revoke"} |
| 1021 | </Button> |
| 1022 | ) : null} |
| 1023 | </div> |
| 1024 | </div> |
| 1025 | </div> |
| 1026 | ); |
| 1027 | }); |
| 1028 | |
| 1029 | type AuthorizedClientsHeaderActionProps = { |
| 1030 | onPairingLinkCreated: (result: AuthPairingCredentialResult) => void; |
| 1031 | clientSessions: ReadonlyArray<ServerClientSessionRecord> | null; |
| 1032 | isRevokingOtherClients: boolean; |
| 1033 | onRevokeOtherClients: () => void; |
| 1034 | delegatableScopes: ReadonlyArray<AuthEnvironmentScope>; |
| 1035 | }; |
| 1036 | |
| 1037 | const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderAction({ |
| 1038 | onPairingLinkCreated, |
| 1039 | clientSessions, |
| 1040 | isRevokingOtherClients, |
| 1041 | onRevokeOtherClients, |
| 1042 | delegatableScopes, |
| 1043 | }: AuthorizedClientsHeaderActionProps) { |
| 1044 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 1045 | const [dialogOpen, setDialogOpen] = useState(false); |
| 1046 | const [pairingLabel, setPairingLabel] = useState(""); |
| 1047 | const [pairingScopes, setPairingScopes] = useState<ReadonlyArray<AuthGrantScope>>([ |
| 1048 | ...AuthStandardClientScopes, |
| 1049 | ]); |
| 1050 | const selectedScopes = pairingScopes.filter((scope) => delegatableScopes.includes(scope)); |
| 1051 | const [isCreatingPairingLink, setIsCreatingPairingLink] = useState(false); |
| 1052 | |
| 1053 | const handleCreatePairingLink = useCallback(async () => { |
| 1054 | if ( |
| 1055 | primaryEnvironmentId === null || |
| 1056 | selectedScopes.length === 0 || |
| 1057 | !readEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope) || |
| 1058 | !selectedScopes.every((scope) => readEnvironmentScope(primaryEnvironmentId, scope)) |
| 1059 | ) |
| 1060 | return; |
| 1061 | setIsCreatingPairingLink(true); |
| 1062 | try { |
| 1063 | const created = await createServerPairingCredential({ |
| 1064 | label: pairingLabel, |
| 1065 | scopes: selectedScopes, |
| 1066 | }); |
| 1067 | onPairingLinkCreated(created); |
| 1068 | setPairingLabel(""); |
| 1069 | setPairingScopes( |
| 1070 | AuthStandardClientScopes.filter((scope) => delegatableScopes.includes(scope)), |
| 1071 | ); |
| 1072 | setDialogOpen(false); |
| 1073 | } catch (error) { |
| 1074 | const message = error instanceof Error ? error.message : "Failed to create pairing URL."; |
| 1075 | toastManager.add( |
| 1076 | stackedThreadToast({ |
| 1077 | type: "error", |
| 1078 | title: "Could not create pairing URL", |
| 1079 | description: message, |
| 1080 | }), |
| 1081 | ); |
| 1082 | } finally { |
| 1083 | setIsCreatingPairingLink(false); |
| 1084 | } |
| 1085 | }, [delegatableScopes, onPairingLinkCreated, pairingLabel, primaryEnvironmentId, selectedScopes]); |
| 1086 | |
| 1087 | const togglePairingScope = useCallback((scope: AuthGrantScope, checked: boolean) => { |
| 1088 | setPairingScopes((current) => togglePairingScopeSelection(current, scope, checked)); |
| 1089 | }, []); |
| 1090 | |
| 1091 | return ( |
| 1092 | <div className="flex items-center gap-2"> |
| 1093 | <Button |
| 1094 | size="xs" |
| 1095 | variant="destructive-outline" |
| 1096 | disabled={isRevokingOtherClients || !canRevokeOtherClients(clientSessions)} |
| 1097 | onClick={() => void onRevokeOtherClients()} |
| 1098 | > |
| 1099 | {isRevokingOtherClients ? "Revoking…" : "Revoke others"} |
| 1100 | </Button> |
| 1101 | <Dialog |
| 1102 | open={dialogOpen} |
| 1103 | onOpenChange={(open) => { |
| 1104 | setDialogOpen(open); |
| 1105 | if (!open) { |
| 1106 | setPairingLabel(""); |
| 1107 | setPairingScopes( |
| 1108 | AuthStandardClientScopes.filter((scope) => delegatableScopes.includes(scope)), |
| 1109 | ); |
| 1110 | } |
| 1111 | }} |
| 1112 | > |
| 1113 | <DialogTrigger |
| 1114 | render={ |
| 1115 | <Button size="xs" variant="default"> |
| 1116 | <PlusIcon className="size-3" /> |
| 1117 | Create link |
| 1118 | </Button> |
| 1119 | } |
| 1120 | /> |
| 1121 | <DialogPopup className="max-w-md"> |
| 1122 | <DialogHeader> |
| 1123 | <DialogTitle>Create pairing link</DialogTitle> |
| 1124 | <DialogDescription> |
| 1125 | Generate a one-time link that another device can use to pair with this backend as an |
| 1126 | authorized client. |
| 1127 | </DialogDescription> |
| 1128 | </DialogHeader> |
| 1129 | <DialogPanel> |
| 1130 | <label className="block"> |
| 1131 | <span className="mb-1.5 block text-xs font-medium text-foreground"> |
| 1132 | Client label (optional) |
| 1133 | </span> |
| 1134 | <Input |
| 1135 | value={pairingLabel} |
| 1136 | onChange={(event) => setPairingLabel(event.target.value)} |
| 1137 | placeholder="e.g. Living room iPad" |
| 1138 | disabled={isCreatingPairingLink} |
| 1139 | autoFocus |
| 1140 | /> |
| 1141 | </label> |
| 1142 | <section className="space-y-3"> |
| 1143 | <div className="flex items-center justify-between gap-3"> |
| 1144 | <div> |
| 1145 | <h3 className="text-xs font-medium text-foreground">Permissions</h3> |
| 1146 | <p className="text-xs text-muted-foreground"> |
| 1147 | Limit what the paired client can do. |
| 1148 | </p> |
| 1149 | </div> |
| 1150 | <div className="flex gap-1"> |
| 1151 | <Button |
| 1152 | size="xs" |
| 1153 | variant="outline" |
| 1154 | disabled={isCreatingPairingLink} |
| 1155 | onClick={() => |
| 1156 | setPairingScopes( |
| 1157 | [ |
| 1158 | AuthOrchestrationReadScope, |
| 1159 | AuthFilesystemReadScope, |
| 1160 | AuthDiagnosticsReadScope, |
| 1161 | AuthTerminalReadScope, |
| 1162 | ].filter((scope) => delegatableScopes.includes(scope)), |
| 1163 | ) |
| 1164 | } |
| 1165 | > |
| 1166 | Read only |
| 1167 | </Button> |
| 1168 | <Button |
| 1169 | size="xs" |
| 1170 | variant="outline" |
| 1171 | disabled={isCreatingPairingLink} |
| 1172 | onClick={() => |
| 1173 | setPairingScopes( |
| 1174 | AuthStandardClientScopes.filter((scope) => |
| 1175 | delegatableScopes.includes(scope), |
| 1176 | ), |
| 1177 | ) |
| 1178 | } |
| 1179 | > |
| 1180 | Standard |
| 1181 | </Button> |
| 1182 | </div> |
| 1183 | </div> |
| 1184 | <div className="divide-y divide-border/60 rounded-lg border border-input bg-muted/25"> |
| 1185 | {PAIRING_SCOPE_OPTIONS.filter(({ scope }) => delegatableScopes.includes(scope)).map( |
| 1186 | ({ scope, title, description }) => ( |
| 1187 | <label |
| 1188 | key={scope} |
| 1189 | className="flex cursor-pointer items-start gap-3 px-3 py-2.5 transition-colors hover:bg-muted/40" |
| 1190 | > |
| 1191 | <Checkbox |
| 1192 | className="mt-0.5" |
| 1193 | checked={pairingScopes.includes(scope)} |
| 1194 | disabled={isCreatingPairingLink} |
| 1195 | onCheckedChange={(checked) => togglePairingScope(scope, checked === true)} |
| 1196 | /> |
| 1197 | <span className="min-w-0"> |
| 1198 | <span className="block text-xs font-medium text-foreground">{title}</span> |
| 1199 | <span className="block text-xs leading-snug text-muted-foreground"> |
| 1200 | {description} |
| 1201 | </span> |
| 1202 | </span> |
| 1203 | </label> |
| 1204 | ), |
| 1205 | )} |
| 1206 | </div> |
| 1207 | {selectedScopes.length === 0 ? ( |
| 1208 | <p className="text-xs text-destructive">Select at least one permission.</p> |
| 1209 | ) : selectedScopes.includes(AuthAccessWriteScope) ? ( |
| 1210 | <p className="text-xs text-warning"> |
| 1211 | This client can create or revoke access for other devices. |
| 1212 | </p> |
| 1213 | ) : null} |
| 1214 | </section> |
| 1215 | </DialogPanel> |
| 1216 | <DialogFooter variant="bare"> |
| 1217 | <Button |
| 1218 | variant="outline" |
| 1219 | disabled={isCreatingPairingLink} |
| 1220 | onClick={() => setDialogOpen(false)} |
| 1221 | > |
| 1222 | Cancel |
| 1223 | </Button> |
| 1224 | <Button |
| 1225 | disabled={isCreatingPairingLink || selectedScopes.length === 0} |
| 1226 | onClick={() => void handleCreatePairingLink()} |
| 1227 | > |
| 1228 | {isCreatingPairingLink ? "Creating…" : "Create link"} |
| 1229 | </Button> |
| 1230 | </DialogFooter> |
| 1231 | </DialogPopup> |
| 1232 | </Dialog> |
| 1233 | </div> |
| 1234 | ); |
| 1235 | }); |
| 1236 | |
| 1237 | type PairingClientsListProps = { |
| 1238 | endpointUrl: string | null | undefined; |
| 1239 | endpoints: ReadonlyArray<AdvertisedEndpoint>; |
| 1240 | defaultEndpointKey: string | null; |
| 1241 | presentation?: AccessSectionPresentation; |
| 1242 | isLoading: boolean; |
| 1243 | pairingLinks: ReadonlyArray<ServerPairingLinkRecord>; |
| 1244 | createdPairingCredentials: ReadonlyMap<string, string>; |
| 1245 | clientSessions: ReadonlyArray<ServerClientSessionRecord>; |
| 1246 | revokingPairingLinkId: string | null; |
| 1247 | revokingClientSessionId: string | null; |
| 1248 | onRevokePairingLink: (id: string) => void; |
| 1249 | onRevokeClientSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void; |
| 1250 | canRevoke: boolean; |
| 1251 | }; |
| 1252 | |
| 1253 | const PairingClientsList = memo(function PairingClientsList({ |
| 1254 | endpointUrl, |
| 1255 | endpoints, |
| 1256 | defaultEndpointKey, |
| 1257 | presentation = "current", |
| 1258 | isLoading, |
| 1259 | pairingLinks, |
| 1260 | createdPairingCredentials, |
| 1261 | clientSessions, |
| 1262 | revokingPairingLinkId, |
| 1263 | revokingClientSessionId, |
| 1264 | onRevokePairingLink, |
| 1265 | onRevokeClientSession, |
| 1266 | canRevoke, |
| 1267 | }: PairingClientsListProps) { |
| 1268 | return ( |
| 1269 | <> |
| 1270 | {pairingLinks.map((pairingLink) => ( |
| 1271 | <PairingLinkListRow |
| 1272 | key={pairingLink.id} |
| 1273 | pairingLink={pairingLink} |
| 1274 | credential={createdPairingCredentials.get(pairingLink.id)} |
| 1275 | endpointUrl={endpointUrl} |
| 1276 | endpoints={endpoints} |
| 1277 | defaultEndpointKey={defaultEndpointKey} |
| 1278 | presentation={presentation} |
| 1279 | revokingPairingLinkId={revokingPairingLinkId} |
| 1280 | onRevoke={onRevokePairingLink} |
| 1281 | canRevoke={canRevoke} |
| 1282 | /> |
| 1283 | ))} |
| 1284 | |
| 1285 | {clientSessions.map((clientSession) => ( |
| 1286 | <ConnectedClientListRow |
| 1287 | key={clientSession.sessionId} |
| 1288 | clientSession={clientSession} |
| 1289 | presentation={presentation} |
| 1290 | revokingClientSessionId={revokingClientSessionId} |
| 1291 | onRevokeSession={onRevokeClientSession} |
| 1292 | canRevoke={canRevoke} |
| 1293 | /> |
| 1294 | ))} |
| 1295 | |
| 1296 | {pairingLinks.length === 0 && clientSessions.length === 0 && !isLoading ? ( |
| 1297 | <div className={accessRowClassName(presentation)}> |
| 1298 | <p className="text-xs text-muted-foreground/60">No pairing links or client sessions.</p> |
| 1299 | </div> |
| 1300 | ) : null} |
| 1301 | </> |
| 1302 | ); |
| 1303 | }); |
| 1304 | |
| 1305 | type AdvertisedEndpointListRowProps = { |
| 1306 | endpoint: AdvertisedEndpoint; |
| 1307 | isDefault: boolean; |
| 1308 | presentation?: AccessSectionPresentation; |
| 1309 | onSetDefault: (endpoint: AdvertisedEndpoint) => void; |
| 1310 | onSetupTailscaleServe: (endpoint: AdvertisedEndpoint) => void; |
| 1311 | onDisableTailscaleServe: (endpoint: AdvertisedEndpoint) => void; |
| 1312 | isUpdatingTailscaleServe: boolean; |
| 1313 | }; |
| 1314 | |
| 1315 | const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({ |
| 1316 | endpoint, |
| 1317 | isDefault, |
| 1318 | presentation = "current", |
| 1319 | onSetDefault, |
| 1320 | onSetupTailscaleServe, |
| 1321 | onDisableTailscaleServe, |
| 1322 | isUpdatingTailscaleServe, |
| 1323 | }: AdvertisedEndpointListRowProps) { |
| 1324 | const isAvailable = endpoint.status === "available"; |
| 1325 | const needsTailscaleSetup = isTailscaleHttpsEndpoint(endpoint) && endpoint.status !== "available"; |
| 1326 | const canDisableTailscaleServe = |
| 1327 | isTailscaleHttpsEndpoint(endpoint) && endpoint.status === "available"; |
| 1328 | const shouldShowEndpointUrl = !needsTailscaleSetup; |
| 1329 | const isEndpointRail = presentation === "endpoint-rail"; |
| 1330 | return ( |
| 1331 | <div className={endpointRowClassName(presentation, isAvailable)}> |
| 1332 | {isEndpointRail && isDefault ? ( |
| 1333 | <span className="absolute inset-y-2 left-0 w-1 rounded-r-full bg-primary" aria-hidden /> |
| 1334 | ) : null} |
| 1335 | <div className="flex min-h-6 min-w-0 flex-col gap-2 sm:-my-0.5 sm:flex-row sm:items-center"> |
| 1336 | <div className="flex min-w-0 items-baseline gap-3"> |
| 1337 | <h3 className="shrink-0 text-sm leading-5 font-medium text-foreground"> |
| 1338 | {endpoint.label} |
| 1339 | </h3> |
| 1340 | {shouldShowEndpointUrl ? ( |
| 1341 | <Tooltip> |
| 1342 | <TooltipTrigger |
| 1343 | render={ |
| 1344 | <p className="min-w-0 truncate text-xs leading-5 text-muted-foreground"> |
| 1345 | {endpoint.httpBaseUrl} |
| 1346 | </p> |
| 1347 | } |
| 1348 | /> |
| 1349 | <TooltipPopup side="top">{endpoint.httpBaseUrl}</TooltipPopup> |
| 1350 | </Tooltip> |
| 1351 | ) : null} |
| 1352 | {!isAvailable ? ( |
| 1353 | <span className="shrink-0 rounded-md border border-border/70 px-1 py-0.5 text-3xs text-muted-foreground"> |
| 1354 | Setup required |
| 1355 | </span> |
| 1356 | ) : null} |
| 1357 | </div> |
| 1358 | <div className="ml-auto flex min-h-6 shrink-0 items-center justify-end gap-2"> |
| 1359 | {isDefault ? ( |
| 1360 | <span className="rounded-md border border-primary/30 bg-primary/10 px-1 py-0.5 text-3xs text-primary"> |
| 1361 | Default |
| 1362 | </span> |
| 1363 | ) : null} |
| 1364 | {needsTailscaleSetup ? ( |
| 1365 | <Button |
| 1366 | size="xs" |
| 1367 | variant="outline" |
| 1368 | onClick={() => onSetupTailscaleServe(endpoint)} |
| 1369 | disabled={isUpdatingTailscaleServe} |
| 1370 | > |
| 1371 | {isUpdatingTailscaleServe ? "Restarting…" : "Setup"} |
| 1372 | </Button> |
| 1373 | ) : null} |
| 1374 | {canDisableTailscaleServe ? ( |
| 1375 | <Button |
| 1376 | size="xs" |
| 1377 | variant="destructive-outline" |
| 1378 | onClick={() => onDisableTailscaleServe(endpoint)} |
| 1379 | disabled={isUpdatingTailscaleServe} |
| 1380 | > |
| 1381 | {isUpdatingTailscaleServe ? "Restarting…" : "Disable"} |
| 1382 | </Button> |
| 1383 | ) : null} |
| 1384 | {!needsTailscaleSetup && !isDefault ? ( |
| 1385 | <Button size="xs" variant="outline" onClick={() => onSetDefault(endpoint)}> |
| 1386 | Set as default |
| 1387 | </Button> |
| 1388 | ) : null} |
| 1389 | </div> |
| 1390 | </div> |
| 1391 | </div> |
| 1392 | ); |
| 1393 | }); |
| 1394 | |
| 1395 | function NetworkAccessDescription({ |
| 1396 | endpoint, |
| 1397 | hiddenEndpointCount, |
| 1398 | expanded, |
| 1399 | onToggleExpanded, |
| 1400 | fallback, |
| 1401 | }: { |
| 1402 | endpoint: AdvertisedEndpoint | null; |
| 1403 | hiddenEndpointCount: number; |
| 1404 | expanded: boolean; |
| 1405 | onToggleExpanded: () => void; |
| 1406 | fallback: ReactNode; |
| 1407 | }) { |
| 1408 | if (!endpoint) { |
| 1409 | return fallback; |
| 1410 | } |
| 1411 | |
| 1412 | const summary = ( |
| 1413 | <> |
| 1414 | <span className="min-w-0 truncate">{endpoint.httpBaseUrl}</span> |
| 1415 | {hiddenEndpointCount > 0 ? ( |
| 1416 | <span className="shrink-0 text-xs font-medium"> |
| 1417 | {expanded ? "Hide" : `+${hiddenEndpointCount}`} |
| 1418 | </span> |
| 1419 | ) : null} |
| 1420 | </> |
| 1421 | ); |
| 1422 | |
| 1423 | return ( |
| 1424 | <span className="inline-flex min-w-0 max-w-full items-baseline gap-1"> |
| 1425 | <span className="shrink-0">Reachable at</span> |
| 1426 | {hiddenEndpointCount > 0 ? ( |
| 1427 | <button |
| 1428 | type="button" |
| 1429 | className="inline-flex min-w-0 max-w-full items-baseline gap-2 border-b border-dotted border-muted-foreground/60 text-left text-muted-foreground underline-offset-4 hover:border-foreground hover:text-foreground" |
| 1430 | onClick={onToggleExpanded} |
| 1431 | aria-expanded={expanded} |
| 1432 | > |
| 1433 | {summary} |
| 1434 | </button> |
| 1435 | ) : ( |
| 1436 | <span className="inline-flex min-w-0 max-w-full items-baseline gap-2">{summary}</span> |
| 1437 | )} |
| 1438 | </span> |
| 1439 | ); |
| 1440 | } |
| 1441 | |
| 1442 | type SavedBackendListRowProps = { |
| 1443 | environment: EnvironmentPresentation; |
| 1444 | removingEnvironmentId: EnvironmentId | null; |
| 1445 | onSetEnabled: (environmentId: EnvironmentId, enabled: boolean) => void; |
| 1446 | onRemove: (environment: EnvironmentPresentation) => void; |
| 1447 | onAddRoute: (environment: EnvironmentPresentation) => void; |
| 1448 | }; |
| 1449 | |
| 1450 | /** |
| 1451 | * Status word for a row subtitle: "Reconnecting: <reason>" instead of the |
| 1452 | * long-form sentence, since the row has one line and the full text is one |
| 1453 | * hover away. |
| 1454 | */ |
| 1455 | function savedBackendStatus(environment: EnvironmentPresentation): { |
| 1456 | readonly text: string; |
| 1457 | readonly tone: "muted" | "error"; |
| 1458 | } { |
| 1459 | if (!environment.entry.enabled && environment.connection.phase !== "unsupported") |
| 1460 | return { text: "Off", tone: "muted" }; |
| 1461 | const { connection } = environment; |
| 1462 | switch (connection.phase) { |
| 1463 | case "connected": |
| 1464 | return { text: "Connected", tone: "muted" }; |
| 1465 | case "connecting": |
| 1466 | return { text: "Connecting", tone: "muted" }; |
| 1467 | case "reconnecting": |
| 1468 | return { |
| 1469 | text: connection.error ? `Reconnecting: ${connection.error}` : "Reconnecting", |
| 1470 | tone: "error", |
| 1471 | }; |
| 1472 | // Not a failure: the machine is fine, this build just cannot talk to it. |
| 1473 | case "unsupported": |
| 1474 | return { text: "Client not supported", tone: "muted" }; |
| 1475 | case "error": |
| 1476 | return { |
| 1477 | text: connection.error ? `Connection failed: ${connection.error}` : "Connection failed", |
| 1478 | tone: "error", |
| 1479 | }; |
| 1480 | case "offline": |
| 1481 | return { text: "Offline", tone: "muted" }; |
| 1482 | case "available": |
| 1483 | return { text: "Not connected", tone: "muted" }; |
| 1484 | } |
| 1485 | } |
| 1486 | |
| 1487 | /** |
| 1488 | * One added machine in the Environments list. The switch is the main action; |
| 1489 | * the update icon appears only when that machine can take an update; the |
| 1490 | * row menu holds the icon override, trace ID, and removal. |
| 1491 | */ |
| 1492 | function SavedBackendListRow({ |
| 1493 | environment, |
| 1494 | removingEnvironmentId, |
| 1495 | onSetEnabled, |
| 1496 | onRemove, |
| 1497 | onAddRoute, |
| 1498 | }: SavedBackendListRowProps) { |
| 1499 | const [routesOpen, setRoutesOpen] = useState(false); |
| 1500 | const [permissionsOpen, setPermissionsOpen] = useState(false); |
| 1501 | const environmentId = environment.environmentId; |
| 1502 | const unsupported = environment.connection.phase === "unsupported"; |
| 1503 | const enabled = environment.entry.enabled && !unsupported; |
| 1504 | const isConnected = environment.connection.phase === "connected"; |
| 1505 | const isRemoving = removingEnvironmentId === environmentId; |
| 1506 | const errorTraceId = environment.connection.traceId; |
| 1507 | const { copyToClipboard: copyTraceIdToClipboard } = useCopyToClipboard<{ traceId: string }>({ |
| 1508 | target: "trace ID", |
| 1509 | onCopy: ({ traceId }) => { |
| 1510 | toastManager.add({ |
| 1511 | type: "success", |
| 1512 | title: "Trace ID copied", |
| 1513 | description: traceId, |
| 1514 | }); |
| 1515 | }, |
| 1516 | onError: (error) => { |
| 1517 | toastManager.add( |
| 1518 | stackedThreadToast({ |
| 1519 | type: "error", |
| 1520 | title: "Could not copy trace ID", |
| 1521 | description: error.message, |
| 1522 | }), |
| 1523 | ); |
| 1524 | }, |
| 1525 | }); |
| 1526 | const copyTraceId = useCallback( |
| 1527 | (traceId: string) => { |
| 1528 | copyTraceIdToClipboard(traceId, { traceId }); |
| 1529 | }, |
| 1530 | [copyTraceIdToClipboard], |
| 1531 | ); |
| 1532 | const { copyToClipboard: copyMcpUrl } = useCopyToClipboard<{ url: string }>({ |
| 1533 | target: "MCP URL", |
| 1534 | onCopy: ({ url }) => { |
| 1535 | toastManager.add({ |
| 1536 | type: "success", |
| 1537 | title: "MCP URL copied", |
| 1538 | description: `Add it to an agent, e.g. claude mcp add --transport http t3 ${url}`, |
| 1539 | }); |
| 1540 | }, |
| 1541 | onError: (error) => { |
| 1542 | toastManager.add( |
| 1543 | stackedThreadToast({ |
| 1544 | type: "error", |
| 1545 | title: "Could not copy MCP URL", |
| 1546 | description: error.message, |
| 1547 | }), |
| 1548 | ); |
| 1549 | }, |
| 1550 | }); |
| 1551 | const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig); |
| 1552 | const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId)); |
| 1553 | const resumingServerUpdate = |
| 1554 | serverUpdateState.status === "running" && serverUpdateState.stage === "resuming"; |
| 1555 | const status = savedBackendStatus(environment); |
| 1556 | const serverVersion = environment.serverConfig?.environment.serverVersion ?? null; |
| 1557 | // A saved T3 Connect machine this device has never reached (unsupported, |
| 1558 | // or not yet connected) still has a descriptor from relay discovery, so |
| 1559 | // it can wear its detected glyph instead of the generic server. Discovery |
| 1560 | // empties its map on every refresh, so hold the last descriptor seen or |
| 1561 | // the glyph would blink back to the generic one each time. |
| 1562 | const relayDiscovery = useRelayEnvironmentDiscovery(); |
| 1563 | const discoveredDescriptor = Option.getOrNull( |
| 1564 | relayDiscovery.environments.get(environmentId)?.status ?? Option.none(), |
| 1565 | )?.descriptor; |
| 1566 | const [lastDescriptor, setLastDescriptor] = useState(discoveredDescriptor); |
| 1567 | if (discoveredDescriptor !== undefined && discoveredDescriptor !== lastDescriptor) { |
| 1568 | setLastDescriptor(discoveredDescriptor); |
| 1569 | } |
| 1570 | // Held for the same reason as the descriptor, so Copy MCP URL survives a refresh. |
| 1571 | const discoveredRelayHttpBaseUrl = |
| 1572 | relayDiscovery.environments.get(environmentId)?.environment.endpoint.httpBaseUrl; |
| 1573 | const [lastRelayHttpBaseUrl, setLastRelayHttpBaseUrl] = useState(discoveredRelayHttpBaseUrl); |
| 1574 | if ( |
| 1575 | discoveredRelayHttpBaseUrl !== undefined && |
| 1576 | discoveredRelayHttpBaseUrl !== lastRelayHttpBaseUrl |
| 1577 | ) { |
| 1578 | setLastRelayHttpBaseUrl(discoveredRelayHttpBaseUrl); |
| 1579 | } |
| 1580 | const prepared = usePreparedConnection(environmentId); |
| 1581 | const connectedTarget = isConnected && prepared._tag === "Some" ? prepared.value.target : null; |
| 1582 | const mcpUrl = environmentMcpUrl({ |
| 1583 | entry: environment.entry, |
| 1584 | relayHttpBaseUrl: discoveredRelayHttpBaseUrl ?? lastRelayHttpBaseUrl, |
| 1585 | connectedTarget, |
| 1586 | }); |
| 1587 | const machineKind = resolveEnvironmentMachineKind( |
| 1588 | environment.serverConfig ?? |
| 1589 | (lastDescriptor === undefined ? null : { environment: lastDescriptor }), |
| 1590 | ); |
| 1591 | const routeCount = connectionRoutes(environment.entry).length; |
| 1592 | const subtitleText = [ |
| 1593 | environmentTransportLabel(environment, connectedTarget), |
| 1594 | resumingServerUpdate ? "Restarting" : status.text, |
| 1595 | enabled && versionMismatch ? serverVersion : null, |
| 1596 | ] |
| 1597 | .filter((value): value is string => value !== null) |
| 1598 | .join(" · "); |
| 1599 | |
| 1600 | // Only a connected, enabled machine can take a remote update; a switched-off |
| 1601 | // one keeps the version note so the icon is not a surprise later. |
| 1602 | const showUpdateAction = |
| 1603 | enabled && |
| 1604 | isConnected && |
| 1605 | versionMismatch !== null && |
| 1606 | (serverUpdateState.status === "idle" || serverUpdateState.status === "failed"); |
| 1607 | |
| 1608 | const statusTooltip = `${ |
| 1609 | unsupported |
| 1610 | ? (environment.connection.error ?? connectionStatusText(environment.connection)) |
| 1611 | : enabled |
| 1612 | ? connectionStatusText(environment.connection) |
| 1613 | : "Switched off" |
| 1614 | }${ |
| 1615 | versionMismatch |
| 1616 | ? `\nUpdate available: ${versionMismatch.serverVersion} → ${versionMismatch.clientVersion}` |
| 1617 | : "" |
| 1618 | }`; |
| 1619 | |
| 1620 | return ( |
| 1621 | <EnvironmentRow |
| 1622 | kind={machineKind} |
| 1623 | label={environment.label} |
| 1624 | dimmed={!enabled} |
| 1625 | subtitle={ |
| 1626 | <span className="flex min-w-0 items-center gap-1"> |
| 1627 | <Tooltip> |
| 1628 | {/* The status can change while the tooltip is open, and base-ui only |
| 1629 | re-measures the popup when the trigger's payload changes. */} |
| 1630 | <TooltipTrigger |
| 1631 | payload={statusTooltip} |
| 1632 | render={ |
| 1633 | <span |
| 1634 | className={cn( |
| 1635 | "min-w-0 truncate", |
| 1636 | enabled && |
| 1637 | status.tone === "error" && |
| 1638 | !resumingServerUpdate && |
| 1639 | "text-destructive", |
| 1640 | )} |
| 1641 | /> |
| 1642 | } |
| 1643 | > |
| 1644 | {subtitleText} |
| 1645 | </TooltipTrigger> |
| 1646 | <TooltipPopup side="top" className="whitespace-pre-wrap"> |
| 1647 | {statusTooltip} |
| 1648 | </TooltipPopup> |
| 1649 | </Tooltip> |
| 1650 | <span aria-hidden className="shrink-0"> |
| 1651 | · |
| 1652 | </span> |
| 1653 | <button |
| 1654 | type="button" |
| 1655 | aria-expanded={routesOpen} |
| 1656 | onClick={() => setRoutesOpen((open) => !open)} |
| 1657 | className="inline-flex shrink-0 items-center gap-0.5 rounded-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring" |
| 1658 | > |
| 1659 | {routeCount === 1 ? "Routes" : `${routeCount} routes`} |
| 1660 | <ChevronRightIcon |
| 1661 | aria-hidden |
| 1662 | className={cn( |
| 1663 | "size-3 shrink-0 transition-transform duration-150 motion-reduce:transition-none", |
| 1664 | routesOpen && "rotate-90", |
| 1665 | )} |
| 1666 | /> |
| 1667 | </button> |
| 1668 | <span aria-hidden className="shrink-0"> |
| 1669 | · |
| 1670 | </span> |
| 1671 | <button |
| 1672 | type="button" |
| 1673 | aria-expanded={permissionsOpen} |
| 1674 | aria-controls={`remote-permissions-${environmentId}`} |
| 1675 | onClick={() => setPermissionsOpen((open) => !open)} |
| 1676 | className="inline-flex shrink-0 items-center gap-0.5 rounded-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring" |
| 1677 | > |
| 1678 | Permissions |
| 1679 | <ChevronRightIcon |
| 1680 | aria-hidden |
| 1681 | className={cn( |
| 1682 | "size-3 shrink-0 transition-transform duration-150 motion-reduce:transition-none", |
| 1683 | permissionsOpen && "rotate-90", |
| 1684 | )} |
| 1685 | /> |
| 1686 | </button> |
| 1687 | </span> |
| 1688 | } |
| 1689 | below={ |
| 1690 | serverUpdateState.status !== "idle" ? ( |
| 1691 | <div className="mt-1 max-w-md"> |
| 1692 | <ServerUpdateProgress state={serverUpdateState} /> |
| 1693 | </div> |
| 1694 | ) : null |
| 1695 | } |
| 1696 | detail={ |
| 1697 | <> |
| 1698 | {routesOpen && ( |
| 1699 | <EnvironmentRoutesList |
| 1700 | environment={environment} |
| 1701 | onAddRoute={() => onAddRoute(environment)} |
| 1702 | /> |
| 1703 | )} |
| 1704 | {permissionsOpen && ( |
| 1705 | <div |
| 1706 | id={`remote-permissions-${environmentId}`} |
| 1707 | className="mt-2 border-t border-border/50" |
| 1708 | > |
| 1709 | <SessionPermissions |
| 1710 | environmentId={environmentId} |
| 1711 | connected={isConnected} |
| 1712 | routeContext |
| 1713 | /> |
| 1714 | </div> |
| 1715 | )} |
| 1716 | </> |
| 1717 | } |
| 1718 | > |
| 1719 | {unsupported && |
| 1720 | environment.entry.serverUpdateRequired === true && |
| 1721 | serverUpdateState.status !== "running" ? ( |
| 1722 | <OutdatedServerUpdateAction |
| 1723 | environmentId={environmentId} |
| 1724 | serverLabel={`${environment.label} server`} |
| 1725 | fromVersion={lastDescriptor?.serverVersion} |
| 1726 | targetVersion={APP_VERSION} |
| 1727 | label={serverUpdateState.status === "failed" ? "Retry update" : "Update"} |
| 1728 | /> |
| 1729 | ) : null} |
| 1730 | {showUpdateAction ? ( |
| 1731 | <ServerUpdateAction |
| 1732 | environmentId={environmentId} |
| 1733 | serverLabel={`${environment.label} server`} |
| 1734 | selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)} |
| 1735 | installation={environment.serverConfig?.environment.capabilities.serverInstallation} |
| 1736 | desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)} |
| 1737 | threadContinuation={supportsServerUpdateThreadContinuation(environment.serverConfig)} |
| 1738 | targetVersion={versionMismatch.clientVersion} |
| 1739 | label={serverUpdateState.status === "failed" ? "Retry update" : "Update"} |
| 1740 | appearance="icon" |
| 1741 | /> |
| 1742 | ) : null} |
| 1743 | <Tooltip> |
| 1744 | <TooltipTrigger |
| 1745 | render={ |
| 1746 | <Switch |
| 1747 | size="sm" |
| 1748 | checked={enabled} |
| 1749 | disabled={isRemoving || unsupported} |
| 1750 | aria-label={`${enabled ? "Switch off" : "Switch on"} ${environment.label}`} |
| 1751 | onCheckedChange={(checked) => onSetEnabled(environmentId, checked)} |
| 1752 | /> |
| 1753 | } |
| 1754 | /> |
| 1755 | <TooltipPopup side="top"> |
| 1756 | {unsupported ? "Client not supported" : enabled ? "Switch off" : "Switch on"} |
| 1757 | </TooltipPopup> |
| 1758 | </Tooltip> |
| 1759 | <Menu> |
| 1760 | <MenuTrigger |
| 1761 | render={ |
| 1762 | <Button |
| 1763 | type="button" |
| 1764 | variant="ghost-muted" |
| 1765 | size="icon-xs" |
| 1766 | disabled={isRemoving} |
| 1767 | aria-label={`More actions for ${environment.label}`} |
| 1768 | /> |
| 1769 | } |
| 1770 | > |
| 1771 | <EllipsisIcon className="size-3.5" /> |
| 1772 | </MenuTrigger> |
| 1773 | <MenuPopup align="end"> |
| 1774 | <EnvironmentIconMenu |
| 1775 | environmentId={environmentId} |
| 1776 | serverConfig={environment.serverConfig} |
| 1777 | /> |
| 1778 | <MenuItem onClick={() => setRoutesOpen((open) => !open)}> |
| 1779 | <RouteIcon /> |
| 1780 | {routesOpen ? "Hide routes" : "Routes"} |
| 1781 | </MenuItem> |
| 1782 | {mcpUrl ? ( |
| 1783 | <MenuItem onClick={() => copyMcpUrl(mcpUrl, { url: mcpUrl })}>Copy MCP URL</MenuItem> |
| 1784 | ) : null} |
| 1785 | {errorTraceId ? ( |
| 1786 | <MenuItem onClick={() => copyTraceId(errorTraceId)}>Copy trace ID</MenuItem> |
| 1787 | ) : null} |
| 1788 | <MenuSeparator /> |
| 1789 | <MenuItem variant="destructive" onClick={() => onRemove(environment)}> |
| 1790 | {isRemoving ? "Removing…" : "Remove from this device…"} |
| 1791 | </MenuItem> |
| 1792 | </MenuPopup> |
| 1793 | </Menu> |
| 1794 | </EnvironmentRow> |
| 1795 | ); |
| 1796 | } |
| 1797 | |
| 1798 | function CloudLinkSwitch({ |
| 1799 | checked, |
| 1800 | disabled, |
| 1801 | disabledReason, |
| 1802 | onCheckedChange, |
| 1803 | ariaLabel = "Enable T3 Connect", |
| 1804 | }: { |
| 1805 | readonly checked: boolean; |
| 1806 | readonly disabled: boolean; |
| 1807 | readonly disabledReason: string | null; |
| 1808 | readonly onCheckedChange?: (enabled: boolean) => void; |
| 1809 | readonly ariaLabel?: string; |
| 1810 | }) { |
| 1811 | const control = ( |
| 1812 | <Switch |
| 1813 | aria-label={ariaLabel} |
| 1814 | checked={checked} |
| 1815 | disabled={disabled} |
| 1816 | {...(onCheckedChange ? { onCheckedChange } : {})} |
| 1817 | /> |
| 1818 | ); |
| 1819 | return disabledReason ? ( |
| 1820 | <Tooltip> |
| 1821 | <TooltipTrigger render={<span className="inline-flex">{control}</span>} /> |
| 1822 | <TooltipPopup side="top">{disabledReason}</TooltipPopup> |
| 1823 | </Tooltip> |
| 1824 | ) : ( |
| 1825 | control |
| 1826 | ); |
| 1827 | } |
| 1828 | |
| 1829 | function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) { |
| 1830 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 1831 | const { |
| 1832 | isSignedIn, |
| 1833 | linkState: primaryCloudLinkState, |
| 1834 | linkRevoked, |
| 1835 | managedTunnelActive, |
| 1836 | managedTunnelOutOfSync, |
| 1837 | publishAgentActivity, |
| 1838 | holdWebhooksWhileOffline, |
| 1839 | operationError, |
| 1840 | reconcileCloudState, |
| 1841 | } = useCloudLinkController(); |
| 1842 | const [isUpdating, setIsUpdating] = useState(false); |
| 1843 | const [isUpdatingPreference, setIsUpdatingPreference] = useState(false); |
| 1844 | |
| 1845 | const disabledReason = !isSignedIn |
| 1846 | ? "Sign in to T3 Connect to manage this environment." |
| 1847 | : !canManageRelay |
| 1848 | ? "Your session does not have permission to manage T3 Connect access." |
| 1849 | : null; |
| 1850 | const isBusy = isUpdating || isUpdatingPreference; |
| 1851 | |
| 1852 | const updateManagedTunnel = async (enabled: boolean) => { |
| 1853 | if ( |
| 1854 | primaryEnvironmentId === null || |
| 1855 | !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope) |
| 1856 | ) |
| 1857 | return; |
| 1858 | setIsUpdating(true); |
| 1859 | const ok = await reconcileCloudState({ managedTunnel: enabled, publish: publishAgentActivity }); |
| 1860 | if (ok) { |
| 1861 | // Turning the tunnel off while publishing stays on downgrades the link |
| 1862 | // rather than removing it — say so instead of claiming an unlink. |
| 1863 | toastManager.add({ |
| 1864 | type: "success", |
| 1865 | title: enabled |
| 1866 | ? "T3 Connect linked" |
| 1867 | : publishAgentActivity |
| 1868 | ? "T3 Connect tunnel disabled" |
| 1869 | : "T3 Connect unlinked", |
| 1870 | description: enabled |
| 1871 | ? "This environment is available through T3 Connect." |
| 1872 | : publishAgentActivity |
| 1873 | ? "The managed tunnel was removed. Agent activity publishing stays on." |
| 1874 | : "This environment is no longer available through T3 Connect.", |
| 1875 | }); |
| 1876 | } |
| 1877 | setIsUpdating(false); |
| 1878 | }; |
| 1879 | |
| 1880 | const updatePublishAgentActivity = async (enabled: boolean) => { |
| 1881 | if ( |
| 1882 | primaryEnvironmentId === null || |
| 1883 | !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope) |
| 1884 | ) |
| 1885 | return; |
| 1886 | setIsUpdatingPreference(true); |
| 1887 | const ok = await reconcileCloudState({ managedTunnel: managedTunnelActive, publish: enabled }); |
| 1888 | if (ok) { |
| 1889 | toastManager.add({ |
| 1890 | type: "success", |
| 1891 | title: enabled ? "Agent activity enabled" : "Agent activity disabled", |
| 1892 | description: enabled |
| 1893 | ? "This environment publishes agent activity to your mobile clients." |
| 1894 | : "This environment will stop publishing agent activity.", |
| 1895 | }); |
| 1896 | } |
| 1897 | setIsUpdatingPreference(false); |
| 1898 | }; |
| 1899 | |
| 1900 | const updateHoldWebhooks = async (enabled: boolean) => { |
| 1901 | setIsUpdatingPreference(true); |
| 1902 | const ok = await reconcileCloudState({ |
| 1903 | managedTunnel: managedTunnelActive, |
| 1904 | publish: publishAgentActivity, |
| 1905 | holdWebhooksWhileOffline: enabled, |
| 1906 | }); |
| 1907 | if (ok) { |
| 1908 | toastManager.add({ |
| 1909 | type: "success", |
| 1910 | title: enabled ? "Webhooks held while offline" : "Webhooks no longer held", |
| 1911 | description: enabled |
| 1912 | ? "T3 Connect keeps webhook requests for up to 24 hours while this environment is offline." |
| 1913 | : "Requests to an offline environment now fail. Anything already held is still delivered.", |
| 1914 | }); |
| 1915 | } |
| 1916 | setIsUpdatingPreference(false); |
| 1917 | }; |
| 1918 | |
| 1919 | return ( |
| 1920 | <> |
| 1921 | {window.desktopBridge ? ( |
| 1922 | <SettingsRow |
| 1923 | title={searchableSetting("t3-connect").title} |
| 1924 | description={ |
| 1925 | linkRevoked |
| 1926 | ? "This environment was removed from T3 Connect. Turn it on to link it again." |
| 1927 | : managedTunnelActive |
| 1928 | ? "This environment is available to your other devices through T3 Connect." |
| 1929 | : managedTunnelOutOfSync |
| 1930 | ? "T3 Connect is out of sync. This environment is linked only to publish agent activity, so other devices cannot connect. Turn T3 Connect on to repair it." |
| 1931 | : "Make this environment available to your other devices through T3 Connect." |
| 1932 | } |
| 1933 | status={operationError ?? primaryCloudLinkState.error} |
| 1934 | control={ |
| 1935 | <CloudLinkSwitch |
| 1936 | checked={managedTunnelActive} |
| 1937 | disabled={ |
| 1938 | !canManageRelay || |
| 1939 | !isSignedIn || |
| 1940 | primaryCloudLinkState.data === null || |
| 1941 | primaryCloudLinkState.isPending || |
| 1942 | isBusy |
| 1943 | } |
| 1944 | disabledReason={disabledReason} |
| 1945 | onCheckedChange={(enabled) => void updateManagedTunnel(enabled)} |
| 1946 | /> |
| 1947 | } |
| 1948 | /> |
| 1949 | ) : linkRevoked ? ( |
| 1950 | <SettingsRow |
| 1951 | title={searchableSetting("t3-connect").title} |
| 1952 | description="This environment was removed from T3 Connect. Run `t3 connect link` on the host to link it again." |
| 1953 | /> |
| 1954 | ) : null} |
| 1955 | <SettingsRow |
| 1956 | title={searchableSetting("publish-agent-activity").title} |
| 1957 | description="Send activity to mobile notifications and Live Activities without T3 Connect." |
| 1958 | control={ |
| 1959 | <CloudLinkSwitch |
| 1960 | ariaLabel="Publish agent activity to mobile clients" |
| 1961 | checked={publishAgentActivity} |
| 1962 | disabled={ |
| 1963 | !canManageRelay || |
| 1964 | !isSignedIn || |
| 1965 | primaryCloudLinkState.data === null || |
| 1966 | primaryCloudLinkState.isPending || |
| 1967 | isBusy |
| 1968 | } |
| 1969 | disabledReason={disabledReason} |
| 1970 | onCheckedChange={(enabled) => void updatePublishAgentActivity(enabled)} |
| 1971 | /> |
| 1972 | } |
| 1973 | /> |
| 1974 | {managedTunnelActive ? ( |
| 1975 | <SettingsRow |
| 1976 | title={searchableSetting("hold-webhooks-while-offline").title} |
| 1977 | description="Keep webhook requests for up to 24 hours while this environment is offline, then deliver them. Off: T3 Connect only forwards requests and stores nothing." |
| 1978 | control={ |
| 1979 | <CloudLinkSwitch |
| 1980 | ariaLabel="Hold webhook requests while this environment is offline" |
| 1981 | checked={holdWebhooksWhileOffline} |
| 1982 | disabled={!canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isBusy} |
| 1983 | disabledReason={disabledReason} |
| 1984 | onCheckedChange={(enabled) => void updateHoldWebhooks(enabled)} |
| 1985 | /> |
| 1986 | } |
| 1987 | /> |
| 1988 | ) : null} |
| 1989 | </> |
| 1990 | ); |
| 1991 | } |
| 1992 | |
| 1993 | function CloudLinkRow({ |
| 1994 | canReadRelay, |
| 1995 | canManageRelay, |
| 1996 | }: { |
| 1997 | readonly canReadRelay: boolean; |
| 1998 | readonly canManageRelay: boolean; |
| 1999 | }) { |
| 2000 | if (!hasCloudPublicConfig()) return null; |
| 2001 | if (!canReadRelay) { |
| 2002 | return ( |
| 2003 | <SettingsRow |
| 2004 | title="T3 Connect" |
| 2005 | description="To edit these settings, pair this connection with both View relay and Manage relay permissions." |
| 2006 | /> |
| 2007 | ); |
| 2008 | } |
| 2009 | return ( |
| 2010 | <CloudAuthBoundary |
| 2011 | fallback={ |
| 2012 | <SettingsRow title="T3 Connect" description="Reach your computers through your T3 account."> |
| 2013 | <Button variant="outline" size="sm" onClick={() => activateCloudAuthentication(true)}> |
| 2014 | Sign in |
| 2015 | </Button> |
| 2016 | </SettingsRow> |
| 2017 | } |
| 2018 | > |
| 2019 | <ConfiguredCloudLinkRow canManageRelay={canManageRelay} /> |
| 2020 | </CloudAuthBoundary> |
| 2021 | ); |
| 2022 | } |
| 2023 | |
| 2024 | function EmptyRemoteEnvironments() { |
| 2025 | return ( |
| 2026 | <Empty className="min-h-52"> |
| 2027 | <EmptyMedia variant="icon"> |
| 2028 | <ChevronsLeftRightEllipsisIcon /> |
| 2029 | </EmptyMedia> |
| 2030 | <EmptyHeader> |
| 2031 | <EmptyTitle>No saved remote environments</EmptyTitle> |
| 2032 | <EmptyDescription>Add an environment to connect your computers.</EmptyDescription> |
| 2033 | </EmptyHeader> |
| 2034 | </Empty> |
| 2035 | ); |
| 2036 | } |
| 2037 | |
| 2038 | function CloudRemoteEnvironmentRows({ |
| 2039 | primaryEnvironmentId, |
| 2040 | savedEnvironments, |
| 2041 | }: { |
| 2042 | readonly primaryEnvironmentId: EnvironmentId | null; |
| 2043 | readonly savedEnvironments: ReadonlyArray<EnvironmentPresentation>; |
| 2044 | }) { |
| 2045 | return hasCloudPublicConfig() ? ( |
| 2046 | <CloudEnvironmentConnectRows |
| 2047 | primaryEnvironmentId={primaryEnvironmentId} |
| 2048 | savedEnvironments={savedEnvironments} |
| 2049 | empty={<EmptyRemoteEnvironments />} |
| 2050 | /> |
| 2051 | ) : savedEnvironments.length === 0 ? ( |
| 2052 | <EmptyRemoteEnvironments /> |
| 2053 | ) : null; |
| 2054 | } |
| 2055 | |
| 2056 | export function ConnectionsSettings() { |
| 2057 | const desktopBridge = window.desktopBridge; |
| 2058 | const [directPairing, setDirectPairing] = useState(false); |
| 2059 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 2060 | const { environments } = useEnvironments(); |
| 2061 | const primaryEnvironment = usePrimaryEnvironment(); |
| 2062 | const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false }); |
| 2063 | const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, { |
| 2064 | reportFailure: false, |
| 2065 | }); |
| 2066 | const removeEnvironment = useAtomCommand(environmentCatalog.remove, { reportFailure: false }); |
| 2067 | const registerEnvironment = useAtomCommand(environmentCatalog.register, { |
| 2068 | reportFailure: false, |
| 2069 | }); |
| 2070 | const relayDiscoveryState = useRelayEnvironmentDiscovery(); |
| 2071 | const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, { |
| 2072 | reportFailure: false, |
| 2073 | }); |
| 2074 | const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; |
| 2075 | const primarySessionState = usePrimarySessionState(); |
| 2076 | const currentSessionScopes = primarySessionState.data?.authenticated |
| 2077 | ? (primarySessionState.data.permissions ?? primarySessionState.data.scopes ?? null) |
| 2078 | : null; |
| 2079 | const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null); |
| 2080 | // Catalog order is the order the machines were added; rows never jump when |
| 2081 | // one is switched off. |
| 2082 | const savedEnvironments = useMemo( |
| 2083 | () => |
| 2084 | environments.filter( |
| 2085 | (environment) => environment.entry.target._tag !== "PrimaryConnectionTarget", |
| 2086 | ), |
| 2087 | [environments], |
| 2088 | ); |
| 2089 | // The WSL backend is managed from the WSL row under this machine, so it has |
| 2090 | // no row of its own in the list. |
| 2091 | const listedEnvironments = useMemo( |
| 2092 | () => |
| 2093 | savedEnvironments.filter( |
| 2094 | (environment) => !isDesktopLocalConnectionTarget(environment.entry.target), |
| 2095 | ), |
| 2096 | [savedEnvironments], |
| 2097 | ); |
| 2098 | // Machines "Update all" can reach: switched on, connected, behind the client |
| 2099 | // version, remotely updatable, and not already mid-update. The button only |
| 2100 | // renders when this list is non-empty. |
| 2101 | const savedServerUpdateStatesAtom = useMemo( |
| 2102 | () => |
| 2103 | Atom.make((get) => |
| 2104 | savedEnvironments.map((environment) => ({ |
| 2105 | environment, |
| 2106 | updateStatus: get(serverEnvironment.updateStateAtom(environment.environmentId)).status, |
| 2107 | })), |
| 2108 | ), |
| 2109 | [savedEnvironments], |
| 2110 | ); |
| 2111 | const savedServerUpdateStates = useAtomValue(savedServerUpdateStatesAtom); |
| 2112 | const savedServerUpdateTargets = useMemo( |
| 2113 | () => |
| 2114 | savedServerUpdateStates.flatMap(({ environment, updateStatus }): ServerUpdateTarget[] => { |
| 2115 | const mismatch = resolveServerConfigVersionMismatch(environment.serverConfig); |
| 2116 | const selfUpdate = resolveServerSelfUpdateCapability(environment.serverConfig); |
| 2117 | const desktopAppUpdate = supportsDesktopAppUpdate(environment.serverConfig); |
| 2118 | if ( |
| 2119 | !mismatch || |
| 2120 | updateStatus === "running" || |
| 2121 | !environment.entry.enabled || |
| 2122 | environment.connection.phase !== "connected" || |
| 2123 | isDesktopLocalConnectionTarget(environment.entry.target) || |
| 2124 | // Manual-update machines only offer a copy command on their row. |
| 2125 | selfUpdate === null || |
| 2126 | (selfUpdate === "desktop-managed" && !desktopAppUpdate) |
| 2127 | ) { |
| 2128 | return []; |
| 2129 | } |
| 2130 | return [ |
| 2131 | { |
| 2132 | environmentId: environment.environmentId, |
| 2133 | serverLabel: environment.label, |
| 2134 | selfUpdate, |
| 2135 | installation: environment.serverConfig?.environment.capabilities.serverInstallation, |
| 2136 | desktopAppUpdate, |
| 2137 | threadContinuation: supportsServerUpdateThreadContinuation(environment.serverConfig), |
| 2138 | continueThreadsAfterServerUpdate: |
| 2139 | environment.serverConfig?.settings.continueThreadsAfterServerUpdate ?? false, |
| 2140 | targetVersion: mismatch.clientVersion, |
| 2141 | }, |
| 2142 | ]; |
| 2143 | }), |
| 2144 | [savedServerUpdateStates], |
| 2145 | ); |
| 2146 | // Switched-off machines never receive threads, so they stay out of the |
| 2147 | // load balancing and GitHub sharing lists. The WSL backend has no row in |
| 2148 | // the Environments list but does take threads, so it stays in here. This |
| 2149 | // machine leads the list. |
| 2150 | const loadBalancingEnvironments = useMemo( |
| 2151 | () => [ |
| 2152 | ...(primaryEnvironment ? [primaryEnvironment] : []), |
| 2153 | ...savedEnvironments.filter((environment) => environment.entry.enabled), |
| 2154 | ], |
| 2155 | [primaryEnvironment, savedEnvironments], |
| 2156 | ); |
| 2157 | const savedDesktopSshEnvironmentKeys = useMemo(() => { |
| 2158 | const keys = new Set<string>(); |
| 2159 | for (const environment of savedEnvironments) { |
| 2160 | const profile = environment.entry.profile; |
| 2161 | if ( |
| 2162 | environment.entry.target._tag !== "SshConnectionTarget" || |
| 2163 | Option.isNone(profile) || |
| 2164 | profile.value._tag !== "SshConnectionProfile" |
| 2165 | ) { |
| 2166 | continue; |
| 2167 | } |
| 2168 | const target = profile.value.target; |
| 2169 | keys.add(target.alias); |
| 2170 | keys.add(formatDesktopSshTarget(target)); |
| 2171 | } |
| 2172 | return keys; |
| 2173 | }, [savedEnvironments]); |
| 2174 | const [desktopServerExposureMutationError, setDesktopServerExposureMutationError] = useState< |
| 2175 | string | null |
| 2176 | >(null); |
| 2177 | const [desktopAccessManagementMutationError, setDesktopAccessManagementMutationError] = useState< |
| 2178 | string | null |
| 2179 | >(null); |
| 2180 | // Only this client's creation response can supply a shareable credential. |
| 2181 | const [createdPairingCredentials, setCreatedPairingCredentials] = useState< |
| 2182 | ReadonlyMap<string, string> |
| 2183 | >(() => new Map()); |
| 2184 | const handlePairingLinkCreated = useCallback((created: AuthPairingCredentialResult) => { |
| 2185 | setCreatedPairingCredentials((current) => new Map(current).set(created.id, created.credential)); |
| 2186 | }, []); |
| 2187 | const [revokingDesktopPairingLinkId, setRevokingDesktopPairingLinkId] = useState<string | null>( |
| 2188 | null, |
| 2189 | ); |
| 2190 | const [revokingDesktopClientSessionId, setRevokingDesktopClientSessionId] = useState< |
| 2191 | string | null |
| 2192 | >(null); |
| 2193 | const [isRevokingOtherDesktopClients, setIsRevokingOtherDesktopClients] = useState(false); |
| 2194 | const [addBackendDialogOpen, setAddBackendDialogOpen] = useState(false); |
| 2195 | // Set when the dialog adds a route to a saved machine instead of a new one. |
| 2196 | const [routeTarget, setRouteTarget] = useState<EnvironmentPresentation | null>(null); |
| 2197 | const [savedBackendMode, setSavedBackendMode] = useState<"remote" | "ssh">("remote"); |
| 2198 | const [savedBackendHost, setSavedBackendHost] = useState(""); |
| 2199 | const [savedBackendPairingCode, setSavedBackendPairingCode] = useState(""); |
| 2200 | const [savedBackendSshHost, setSavedBackendSshHost] = useState(""); |
| 2201 | const [savedBackendSshUsername, setSavedBackendSshUsername] = useState(""); |
| 2202 | const [savedBackendSshPort, setSavedBackendSshPort] = useState(""); |
| 2203 | const [sshHostSuggestionsOpen, setSshHostSuggestionsOpen] = useState(false); |
| 2204 | // Tracks the arrow-key/hover highlight so Enter selects it instead of submitting the typed text. |
| 2205 | const highlightedSshHostRef = useRef<DesktopDiscoveredSshHost | undefined>(undefined); |
| 2206 | const [savedBackendError, setSavedBackendError] = useState<string | null>(null); |
| 2207 | const [isAddingSavedBackend, setIsAddingSavedBackend] = useState(false); |
| 2208 | const [removingSavedEnvironmentId, setRemovingSavedEnvironmentId] = |
| 2209 | useState<EnvironmentId | null>(null); |
| 2210 | const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false); |
| 2211 | const [isDesktopServerExposureDialogOpen, setIsDesktopServerExposureDialogOpen] = useState(false); |
| 2212 | const [isUpdatingTailscaleServe, setIsUpdatingTailscaleServe] = useState(false); |
| 2213 | const [isUpdatingWslBackend, setIsUpdatingWslBackend] = useState(false); |
| 2214 | const [desktopWslMutationError, setDesktopWslMutationError] = useState<string | null>(null); |
| 2215 | // Pending WSL setting change waiting on user confirmation. Set when |
| 2216 | // the user tries a destructive change (disable, switch distro, |
| 2217 | // toggle wsl-only) while the WSL backend has saved-env state on this |
| 2218 | // machine. Confirming applies the change; cancelling drops it |
| 2219 | // without touching the persisted setting. Null when nothing is |
| 2220 | // pending. |
| 2221 | type PendingWslChange = |
| 2222 | // wasWslOnly is true when the user picked Off while wsl-only mode |
| 2223 | // was active. In that case "disable" also clears wsl-only and |
| 2224 | // relaunches onto the Windows backend, because leaving wsl-only on |
| 2225 | // with wslBackendEnabled off is a meaningless state (wsl-only is |
| 2226 | // only honoured when the WSL backend is enabled). |
| 2227 | | { readonly kind: "disable"; readonly wasWslOnly: boolean } |
| 2228 | | { readonly kind: "distro"; readonly nextDistro: string | null } |
| 2229 | // Asked at enable time so the user picks the mode upfront instead |
| 2230 | // of being dropped into "both backends" and having to discover the |
| 2231 | // wsl-only switch separately. Resolved through enable-mode action |
| 2232 | // buttons on the dialog rather than a single Confirm. |
| 2233 | | { readonly kind: "enable"; readonly nextDistro: string | null } |
| 2234 | | { readonly kind: "wsl-only"; readonly nextValue: boolean }; |
| 2235 | const [pendingWslChange, setPendingWslChange] = useState<PendingWslChange | null>(null); |
| 2236 | const isWslConfirmDialogOpen = pendingWslChange !== null; |
| 2237 | const [pendingTailscaleServeEndpoint, setPendingTailscaleServeEndpoint] = |
| 2238 | useState<AdvertisedEndpoint | null>(null); |
| 2239 | const [disableTailscaleServeDialogOpen, setDisableTailscaleServeDialogOpen] = useState(false); |
| 2240 | const [tailscaleServePortInput, setTailscaleServePortInput] = useState( |
| 2241 | String(DEFAULT_TAILSCALE_SERVE_PORT), |
| 2242 | ); |
| 2243 | const [pendingDesktopServerExposureMode, setPendingDesktopServerExposureMode] = useState< |
| 2244 | DesktopServerExposureState["mode"] | null |
| 2245 | >(null); |
| 2246 | const primaryServerConfig = primaryEnvironment?.serverConfig ?? null; |
| 2247 | const primaryVersionMismatch = resolveServerConfigVersionMismatch(primaryServerConfig); |
| 2248 | const primaryServerUpdateState = useAtomValue( |
| 2249 | serverEnvironment.updateStateAtom(primaryEnvironmentId), |
| 2250 | ); |
| 2251 | const [isAdvertisedEndpointListExpanded, setIsAdvertisedEndpointListExpanded] = useState(false); |
| 2252 | const defaultAdvertisedEndpointKey = useUiStateStore( |
| 2253 | (state) => state.defaultAdvertisedEndpointKey, |
| 2254 | ); |
| 2255 | const setDefaultAdvertisedEndpointKey = useUiStateStore( |
| 2256 | (state) => state.setDefaultAdvertisedEndpointKey, |
| 2257 | ); |
| 2258 | const canReadAccess = useEnvironmentScope(primaryEnvironmentId, AuthAccessReadScope); |
| 2259 | const canWriteAccess = useEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope); |
| 2260 | const canReadRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope); |
| 2261 | const canMaintain = useEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope); |
| 2262 | const canManageRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope); |
| 2263 | const canManageLocalBackend = !isLocalEnvironmentDisabled() && canMaintain; |
| 2264 | const authAccessChanges = useEnvironmentQuery( |
| 2265 | canReadAccess && primaryEnvironmentId !== null |
| 2266 | ? authEnvironment.accessChanges({ |
| 2267 | environmentId: primaryEnvironmentId, |
| 2268 | input: null, |
| 2269 | }) |
| 2270 | : null, |
| 2271 | ); |
| 2272 | const desktopNetworkAccess = useEnvironmentQuery( |
| 2273 | canManageLocalBackend && desktopBridge ? desktopNetworkAccessStateAtom : null, |
| 2274 | ); |
| 2275 | const isSshDiscoveryActive = |
| 2276 | desktopBridge !== undefined && addBackendDialogOpen && savedBackendMode === "ssh"; |
| 2277 | const desktopSshHosts = useEnvironmentQuery( |
| 2278 | isSshDiscoveryActive ? desktopSshHostsStateAtom : null, |
| 2279 | ); |
| 2280 | // The discovery atom is kept alive across dialog opens, so re-read SSH config |
| 2281 | // each time the SSH tab is shown; stale hosts stay visible while it refreshes. |
| 2282 | const refreshDesktopSshHosts = desktopSshHosts.refresh; |
| 2283 | useEffect(() => { |
| 2284 | if (isSshDiscoveryActive) refreshDesktopSshHosts(); |
| 2285 | }, [isSshDiscoveryActive, refreshDesktopSshHosts]); |
| 2286 | const desktopWsl = useEnvironmentQuery( |
| 2287 | canManageLocalBackend && desktopBridge ? desktopWslStateAtom : null, |
| 2288 | ); |
| 2289 | const desktopWslState = desktopWsl.data; |
| 2290 | const desktopWslError = desktopWslMutationError ?? desktopWsl.error; |
| 2291 | const isLoadingWslState = desktopWsl.isPending && desktopWsl.data === null; |
| 2292 | const discoveredSshHosts = desktopSshHosts.data ?? EMPTY_DISCOVERED_SSH_HOSTS; |
| 2293 | const unsavedDiscoveredSshHosts = useMemo( |
| 2294 | () => |
| 2295 | discoveredSshHosts.filter((target) => { |
| 2296 | const address = formatDesktopSshTarget(target); |
| 2297 | return ( |
| 2298 | !savedDesktopSshEnvironmentKeys.has(target.alias) && |
| 2299 | !savedDesktopSshEnvironmentKeys.has(address) |
| 2300 | ); |
| 2301 | }), |
| 2302 | [discoveredSshHosts, savedDesktopSshEnvironmentKeys], |
| 2303 | ); |
| 2304 | const filteredDiscoveredSshHosts = useMemo( |
| 2305 | () => filterDiscoveredSshHosts(unsavedDiscoveredSshHosts, savedBackendSshHost), |
| 2306 | [savedBackendSshHost, unsavedDiscoveredSshHosts], |
| 2307 | ); |
| 2308 | const isLoadingDiscoveredSshHosts = desktopSshHosts.isPending && desktopSshHosts.data === null; |
| 2309 | const discoveredSshHostsError = desktopSshHosts.error; |
| 2310 | const hasSshHostSuggestionContent = |
| 2311 | desktopBridge !== undefined && |
| 2312 | (isLoadingDiscoveredSshHosts || unsavedDiscoveredSshHosts.length > 0); |
| 2313 | const desktopServerExposureState = desktopNetworkAccess.data?.serverExposureState ?? null; |
| 2314 | const desktopAdvertisedEndpoints = |
| 2315 | desktopNetworkAccess.data?.advertisedEndpoints ?? EMPTY_ADVERTISED_ENDPOINTS; |
| 2316 | const desktopServerExposureError = |
| 2317 | desktopServerExposureMutationError ?? desktopNetworkAccess.error; |
| 2318 | const desktopAccessManagementError = |
| 2319 | desktopAccessManagementMutationError ?? authAccessChanges.error; |
| 2320 | const isLoadingDesktopAccessManagement = |
| 2321 | authAccessChanges.isPending && authAccessChanges.data === null; |
| 2322 | const desktopPairingLinks = useMemo(() => { |
| 2323 | const event = authAccessChanges.data; |
| 2324 | if (event?.type !== "snapshot") return []; |
| 2325 | return sortDesktopPairingLinks( |
| 2326 | event.payload.pairingLinks.map((pairingLink: AuthPairingLink) => |
| 2327 | toDesktopPairingLinkRecord(pairingLink), |
| 2328 | ), |
| 2329 | ); |
| 2330 | }, [authAccessChanges.data]); |
| 2331 | const desktopClientSessions = useMemo(() => { |
| 2332 | const event = authAccessChanges.data; |
| 2333 | if (event?.type !== "snapshot") return []; |
| 2334 | return sortDesktopClientSessions( |
| 2335 | event.payload.clientSessions.map((clientSession: AuthClientSession) => |
| 2336 | toDesktopClientSessionRecord(clientSession), |
| 2337 | ), |
| 2338 | ); |
| 2339 | }, [authAccessChanges.data]); |
| 2340 | const isLocalBackendNetworkAccessible = desktopBridge |
| 2341 | ? desktopServerExposureState?.mode === "network-accessible" |
| 2342 | : currentAuthPolicy === "remote-reachable"; |
| 2343 | const trimmedTailscaleServePortInput = tailscaleServePortInput.trim(); |
| 2344 | const parsedTailscaleServePort = Number(trimmedTailscaleServePortInput); |
| 2345 | const isTailscaleServePortValid = |
| 2346 | /^\d+$/u.test(trimmedTailscaleServePortInput) && |
| 2347 | Number.isInteger(parsedTailscaleServePort) && |
| 2348 | parsedTailscaleServePort >= 1 && |
| 2349 | parsedTailscaleServePort <= 65_535; |
| 2350 | |
| 2351 | const pendingTailscaleServeBaseUrl = useMemo(() => { |
| 2352 | if (!pendingTailscaleServeEndpoint) return null; |
| 2353 | if (!isTailscaleServePortValid) return pendingTailscaleServeEndpoint.httpBaseUrl; |
| 2354 | if (parsedTailscaleServePort === DEFAULT_TAILSCALE_SERVE_PORT) { |
| 2355 | return pendingTailscaleServeEndpoint.httpBaseUrl; |
| 2356 | } |
| 2357 | try { |
| 2358 | const url = new URL(pendingTailscaleServeEndpoint.httpBaseUrl); |
| 2359 | url.port = String(parsedTailscaleServePort); |
| 2360 | return url.toString().replace(/\/$/u, ""); |
| 2361 | } catch { |
| 2362 | return pendingTailscaleServeEndpoint.httpBaseUrl; |
| 2363 | } |
| 2364 | }, [isTailscaleServePortValid, parsedTailscaleServePort, pendingTailscaleServeEndpoint]); |
| 2365 | |
| 2366 | const handleDesktopServerExposureChange = useCallback( |
| 2367 | async (checked: boolean) => { |
| 2368 | if ( |
| 2369 | !desktopBridge || |
| 2370 | primaryEnvironmentId === null || |
| 2371 | !readEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope) |
| 2372 | ) |
| 2373 | return; |
| 2374 | setIsUpdatingDesktopServerExposure(true); |
| 2375 | setDesktopServerExposureMutationError(null); |
| 2376 | try { |
| 2377 | await desktopBridge.setServerExposureMode(checked ? "network-accessible" : "local-only"); |
| 2378 | refreshDesktopNetworkAccessState(); |
| 2379 | setIsDesktopServerExposureDialogOpen(false); |
| 2380 | setIsUpdatingDesktopServerExposure(false); |
| 2381 | } catch (error) { |
| 2382 | const message = |
| 2383 | error instanceof Error ? error.message : "Failed to update network exposure."; |
| 2384 | setIsDesktopServerExposureDialogOpen(false); |
| 2385 | setDesktopServerExposureMutationError(message); |
| 2386 | toastManager.add( |
| 2387 | stackedThreadToast({ |
| 2388 | type: "error", |
| 2389 | title: "Could not update network access", |
| 2390 | description: message, |
| 2391 | }), |
| 2392 | ); |
| 2393 | setIsUpdatingDesktopServerExposure(false); |
| 2394 | } |
| 2395 | }, |
| 2396 | [desktopBridge, primaryEnvironmentId], |
| 2397 | ); |
| 2398 | |
| 2399 | const handleConfirmDesktopServerExposureChange = useCallback(() => { |
| 2400 | if (pendingDesktopServerExposureMode === null) return; |
| 2401 | const checked = pendingDesktopServerExposureMode === "network-accessible"; |
| 2402 | void handleDesktopServerExposureChange(checked); |
| 2403 | }, [handleDesktopServerExposureChange, pendingDesktopServerExposureMode]); |
| 2404 | |
| 2405 | const handleConfirmTailscaleServeSetup = useCallback(async () => { |
| 2406 | if ( |
| 2407 | !desktopBridge || |
| 2408 | primaryEnvironmentId === null || |
| 2409 | !readEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope) |
| 2410 | ) |
| 2411 | return; |
| 2412 | if (!isTailscaleServePortValid) return; |
| 2413 | setIsUpdatingTailscaleServe(true); |
| 2414 | setDesktopServerExposureMutationError(null); |
| 2415 | try { |
| 2416 | await desktopBridge.setTailscaleServeEnabled({ |
| 2417 | enabled: true, |
| 2418 | port: parsedTailscaleServePort, |
| 2419 | }); |
| 2420 | refreshDesktopNetworkAccessState(); |
| 2421 | setPendingTailscaleServeEndpoint(null); |
| 2422 | } catch (error) { |
| 2423 | const message = |
| 2424 | error instanceof Error ? error.message : "Failed to configure Tailscale HTTPS."; |
| 2425 | setDesktopServerExposureMutationError(message); |
| 2426 | toastManager.add( |
| 2427 | stackedThreadToast({ |
| 2428 | type: "error", |
| 2429 | title: "Could not set up Tailscale HTTPS", |
| 2430 | description: message, |
| 2431 | }), |
| 2432 | ); |
| 2433 | } finally { |
| 2434 | setIsUpdatingTailscaleServe(false); |
| 2435 | } |
| 2436 | }, [desktopBridge, isTailscaleServePortValid, parsedTailscaleServePort, primaryEnvironmentId]); |
| 2437 | |
| 2438 | const handleStartTailscaleServeSetup = useCallback( |
| 2439 | (endpoint: AdvertisedEndpoint) => { |
| 2440 | setTailscaleServePortInput( |
| 2441 | String(desktopServerExposureState?.tailscaleServePort ?? DEFAULT_TAILSCALE_SERVE_PORT), |
| 2442 | ); |
| 2443 | setPendingTailscaleServeEndpoint(endpoint); |
| 2444 | }, |
| 2445 | [desktopServerExposureState?.tailscaleServePort], |
| 2446 | ); |
| 2447 | |
| 2448 | const handleConfirmTailscaleServeDisable = useCallback(async () => { |
| 2449 | if ( |
| 2450 | !desktopBridge || |
| 2451 | primaryEnvironmentId === null || |
| 2452 | !readEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope) |
| 2453 | ) |
| 2454 | return; |
| 2455 | setIsUpdatingTailscaleServe(true); |
| 2456 | setDesktopServerExposureMutationError(null); |
| 2457 | try { |
| 2458 | await desktopBridge.setTailscaleServeEnabled({ |
| 2459 | enabled: false, |
| 2460 | port: desktopServerExposureState?.tailscaleServePort ?? DEFAULT_TAILSCALE_SERVE_PORT, |
| 2461 | }); |
| 2462 | refreshDesktopNetworkAccessState(); |
| 2463 | setDisableTailscaleServeDialogOpen(false); |
| 2464 | } catch (error) { |
| 2465 | const message = error instanceof Error ? error.message : "Failed to disable Tailscale HTTPS."; |
| 2466 | setDesktopServerExposureMutationError(message); |
| 2467 | toastManager.add( |
| 2468 | stackedThreadToast({ |
| 2469 | type: "error", |
| 2470 | title: "Could not disable Tailscale HTTPS", |
| 2471 | description: message, |
| 2472 | }), |
| 2473 | ); |
| 2474 | } finally { |
| 2475 | setIsUpdatingTailscaleServe(false); |
| 2476 | } |
| 2477 | }, [desktopBridge, desktopServerExposureState, primaryEnvironmentId]); |
| 2478 | |
| 2479 | const handleStartTailscaleServeDisable = useCallback((_endpoint: AdvertisedEndpoint) => { |
| 2480 | setDisableTailscaleServeDialogOpen(true); |
| 2481 | }, []); |
| 2482 | |
| 2483 | const handleRevokeDesktopPairingLink = useCallback( |
| 2484 | async (id: string) => { |
| 2485 | if ( |
| 2486 | primaryEnvironmentId === null || |
| 2487 | !readEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope) |
| 2488 | ) |
| 2489 | return; |
| 2490 | setRevokingDesktopPairingLinkId(id); |
| 2491 | setDesktopAccessManagementMutationError(null); |
| 2492 | try { |
| 2493 | await revokeServerPairingLink(id); |
| 2494 | } catch (error) { |
| 2495 | const message = error instanceof Error ? error.message : "Failed to revoke pairing link."; |
| 2496 | setDesktopAccessManagementMutationError(message); |
| 2497 | toastManager.add( |
| 2498 | stackedThreadToast({ |
| 2499 | type: "error", |
| 2500 | title: "Could not revoke pairing link", |
| 2501 | description: message, |
| 2502 | }), |
| 2503 | ); |
| 2504 | } finally { |
| 2505 | setRevokingDesktopPairingLinkId(null); |
| 2506 | } |
| 2507 | }, |
| 2508 | [primaryEnvironmentId], |
| 2509 | ); |
| 2510 | |
| 2511 | const handleRevokeDesktopClientSession = useCallback( |
| 2512 | async (sessionId: ServerClientSessionRecord["sessionId"]) => { |
| 2513 | if ( |
| 2514 | primaryEnvironmentId === null || |
| 2515 | !readEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope) |
| 2516 | ) |
| 2517 | return; |
| 2518 | setRevokingDesktopClientSessionId(sessionId); |
| 2519 | setDesktopAccessManagementMutationError(null); |
| 2520 | try { |
| 2521 | await revokeServerClientSession(sessionId); |
| 2522 | } catch (error) { |
| 2523 | const message = error instanceof Error ? error.message : "Failed to revoke client access."; |
| 2524 | setDesktopAccessManagementMutationError(message); |
| 2525 | toastManager.add( |
| 2526 | stackedThreadToast({ |
| 2527 | type: "error", |
| 2528 | title: "Could not revoke client access", |
| 2529 | description: message, |
| 2530 | }), |
| 2531 | ); |
| 2532 | } finally { |
| 2533 | setRevokingDesktopClientSessionId(null); |
| 2534 | } |
| 2535 | }, |
| 2536 | [primaryEnvironmentId], |
| 2537 | ); |
| 2538 | |
| 2539 | const handleRevokeOtherDesktopClients = useCallback(async () => { |
| 2540 | if ( |
| 2541 | primaryEnvironmentId === null || |
| 2542 | !readEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope) |
| 2543 | ) |
| 2544 | return; |
| 2545 | setIsRevokingOtherDesktopClients(true); |
| 2546 | setDesktopAccessManagementMutationError(null); |
| 2547 | try { |
| 2548 | const revokedCount = await revokeOtherServerClientSessions(); |
| 2549 | toastManager.add({ |
| 2550 | type: "success", |
| 2551 | title: revokedCount === 1 ? "Revoked 1 other client" : `Revoked ${revokedCount} clients`, |
| 2552 | description: "Other paired clients will need a new pairing link before reconnecting.", |
| 2553 | }); |
| 2554 | } catch (error) { |
| 2555 | const message = error instanceof Error ? error.message : "Failed to revoke other clients."; |
| 2556 | setDesktopAccessManagementMutationError(message); |
| 2557 | toastManager.add( |
| 2558 | stackedThreadToast({ |
| 2559 | type: "error", |
| 2560 | title: "Could not revoke other clients", |
| 2561 | description: message, |
| 2562 | }), |
| 2563 | ); |
| 2564 | } finally { |
| 2565 | setIsRevokingOtherDesktopClients(false); |
| 2566 | } |
| 2567 | }, [primaryEnvironmentId]); |
| 2568 | |
| 2569 | // Shared by manual SSH submission and discovered-host selection. |
| 2570 | const connectSavedBackendSshTarget = useCallback( |
| 2571 | async (target: DesktopSshEnvironmentTarget) => { |
| 2572 | setIsAddingSavedBackend(true); |
| 2573 | setSavedBackendError(null); |
| 2574 | const result = await connectSshEnvironment({ |
| 2575 | target, |
| 2576 | label: "", |
| 2577 | ...(routeTarget ? { expectedEnvironmentId: routeTarget.environmentId } : {}), |
| 2578 | }); |
| 2579 | if (result._tag === "Failure") { |
| 2580 | if (!isAtomCommandInterrupted(result)) { |
| 2581 | setSavedBackendError(formatDesktopSshConnectionError(squashAtomCommandFailure(result))); |
| 2582 | } |
| 2583 | setIsAddingSavedBackend(false); |
| 2584 | return; |
| 2585 | } |
| 2586 | |
| 2587 | setSavedBackendHost(""); |
| 2588 | setSavedBackendPairingCode(""); |
| 2589 | setSavedBackendSshHost(""); |
| 2590 | setSavedBackendSshUsername(""); |
| 2591 | setSavedBackendSshPort(""); |
| 2592 | setAddBackendDialogOpen(false); |
| 2593 | toastManager.add({ |
| 2594 | type: "success", |
| 2595 | title: routeTarget ? "Route added" : "Environment connected", |
| 2596 | description: routeTarget |
| 2597 | ? `${routeTarget.label} can now be reached over SSH ${target.alias}.` |
| 2598 | : `${target.alias} is ready over an SSH-managed tunnel.`, |
| 2599 | }); |
| 2600 | setIsAddingSavedBackend(false); |
| 2601 | }, |
| 2602 | [connectSshEnvironment, routeTarget], |
| 2603 | ); |
| 2604 | |
| 2605 | const handleAddSavedBackend = useCallback(async () => { |
| 2606 | if (savedBackendMode === "ssh") { |
| 2607 | let target: DesktopSshEnvironmentTarget; |
| 2608 | try { |
| 2609 | target = parseManualDesktopSshTarget({ |
| 2610 | host: savedBackendSshHost, |
| 2611 | username: savedBackendSshUsername, |
| 2612 | port: savedBackendSshPort, |
| 2613 | }); |
| 2614 | } catch (error) { |
| 2615 | setSavedBackendError(formatDesktopSshConnectionError(error)); |
| 2616 | return; |
| 2617 | } |
| 2618 | |
| 2619 | await connectSavedBackendSshTarget(target); |
| 2620 | return; |
| 2621 | } |
| 2622 | |
| 2623 | setIsAddingSavedBackend(true); |
| 2624 | setSavedBackendError(null); |
| 2625 | let remotePairingInput: ReturnType<typeof parseRemotePairingFields>; |
| 2626 | try { |
| 2627 | remotePairingInput = parseRemotePairingFields({ |
| 2628 | host: savedBackendHost, |
| 2629 | pairingCode: savedBackendPairingCode, |
| 2630 | }); |
| 2631 | } catch (error) { |
| 2632 | const message = error instanceof Error ? error.message : "Failed to add backend."; |
| 2633 | setSavedBackendError(message); |
| 2634 | toastManager.add( |
| 2635 | stackedThreadToast({ |
| 2636 | type: "error", |
| 2637 | title: "Could not add backend", |
| 2638 | description: message, |
| 2639 | }), |
| 2640 | ); |
| 2641 | setIsAddingSavedBackend(false); |
| 2642 | return; |
| 2643 | } |
| 2644 | |
| 2645 | const result = await connectPairing({ |
| 2646 | ...remotePairingInput, |
| 2647 | ...(routeTarget ? { expectedEnvironmentId: routeTarget.environmentId } : {}), |
| 2648 | }); |
| 2649 | if (result._tag === "Failure") { |
| 2650 | if (!isAtomCommandInterrupted(result)) { |
| 2651 | const error = squashAtomCommandFailure(result); |
| 2652 | const message = error instanceof Error ? error.message : "Failed to add backend."; |
| 2653 | setSavedBackendError(message); |
| 2654 | toastManager.add( |
| 2655 | stackedThreadToast({ |
| 2656 | type: "error", |
| 2657 | title: "Could not add backend", |
| 2658 | description: message, |
| 2659 | }), |
| 2660 | ); |
| 2661 | } |
| 2662 | setIsAddingSavedBackend(false); |
| 2663 | return; |
| 2664 | } |
| 2665 | |
| 2666 | setSavedBackendHost(""); |
| 2667 | setSavedBackendPairingCode(""); |
| 2668 | setSavedBackendSshHost(""); |
| 2669 | setSavedBackendSshUsername(""); |
| 2670 | setSavedBackendSshPort(""); |
| 2671 | setAddBackendDialogOpen(false); |
| 2672 | toastManager.add( |
| 2673 | routeTarget |
| 2674 | ? { |
| 2675 | type: "success", |
| 2676 | title: "Route added", |
| 2677 | description: `${routeTarget.label} now has another way to connect.`, |
| 2678 | } |
| 2679 | : { |
| 2680 | type: "success", |
| 2681 | title: "Backend added", |
| 2682 | description: "The environment is saved and will reconnect on app startup.", |
| 2683 | }, |
| 2684 | ); |
| 2685 | setIsAddingSavedBackend(false); |
| 2686 | }, [ |
| 2687 | routeTarget, |
| 2688 | connectPairing, |
| 2689 | connectSavedBackendSshTarget, |
| 2690 | savedBackendHost, |
| 2691 | savedBackendMode, |
| 2692 | savedBackendPairingCode, |
| 2693 | savedBackendSshHost, |
| 2694 | savedBackendSshPort, |
| 2695 | savedBackendSshUsername, |
| 2696 | ]); |
| 2697 | |
| 2698 | const handleSavedBackendSshFieldKeyDown = useCallback( |
| 2699 | (event: KeyboardEvent<HTMLInputElement>) => { |
| 2700 | if (event.nativeEvent.isComposing || event.keyCode === 229) return; |
| 2701 | if (event.key === "Enter" && savedBackendSshHost.trim().length > 0) { |
| 2702 | event.preventDefault(); |
| 2703 | void handleAddSavedBackend(); |
| 2704 | } |
| 2705 | }, |
| 2706 | [handleAddSavedBackend, savedBackendSshHost], |
| 2707 | ); |
| 2708 | |
| 2709 | // Resolves a picked alias before connecting it through the manual SSH flow. |
| 2710 | const handleSelectSshHostSuggestion = useCallback( |
| 2711 | async (target: DesktopDiscoveredSshHost) => { |
| 2712 | if (isAddingSavedBackend || !desktopBridge) return; |
| 2713 | |
| 2714 | setIsAddingSavedBackend(true); |
| 2715 | setSavedBackendError(null); |
| 2716 | setSavedBackendSshHost(target.alias); |
| 2717 | let resolved: DesktopSshEnvironmentTarget; |
| 2718 | try { |
| 2719 | resolved = await desktopBridge.resolveSshHost(target.alias); |
| 2720 | } catch (error) { |
| 2721 | setSavedBackendError(formatDesktopSshConnectionError(error)); |
| 2722 | setIsAddingSavedBackend(false); |
| 2723 | return; |
| 2724 | } |
| 2725 | setSavedBackendSshUsername(resolved.username ?? ""); |
| 2726 | setSavedBackendSshPort(resolved.port === null ? "" : String(resolved.port)); |
| 2727 | await connectSavedBackendSshTarget(resolved); |
| 2728 | }, |
| 2729 | [connectSavedBackendSshTarget, desktopBridge, isAddingSavedBackend], |
| 2730 | ); |
| 2731 | |
| 2732 | const handleSavedBackendSshHostKeyDown = useCallback( |
| 2733 | (event: KeyboardEvent<HTMLInputElement>) => { |
| 2734 | if (event.nativeEvent.isComposing || event.keyCode === 229) return; |
| 2735 | |
| 2736 | // The popup only renders when there is content, so an "open" flag alone is not enough. |
| 2737 | const isSshHostPopupVisible = sshHostSuggestionsOpen && hasSshHostSuggestionContent; |
| 2738 | if (isSshHostPopupVisible) { |
| 2739 | const command = resolveShortcutCommand(event, keybindings, { |
| 2740 | platform: navigator.platform, |
| 2741 | context: { modelPickerOpen: false }, |
| 2742 | }); |
| 2743 | const index = threadJumpIndexFromCommand(command ?? ""); |
| 2744 | const target = index === null ? undefined : filteredDiscoveredSshHosts[index]; |
| 2745 | if (target) { |
| 2746 | event.preventDefault(); |
| 2747 | event.stopPropagation(); |
| 2748 | setSshHostSuggestionsOpen(false); |
| 2749 | void handleSelectSshHostSuggestion(target); |
| 2750 | return; |
| 2751 | } |
| 2752 | |
| 2753 | if (event.key === "Escape") { |
| 2754 | event.preventDefault(); |
| 2755 | event.stopPropagation(); |
| 2756 | return; |
| 2757 | } |
| 2758 | } |
| 2759 | |
| 2760 | // A highlighted row means Enter belongs to the autocomplete, which selects it. |
| 2761 | const hasHighlightedSshHost = |
| 2762 | isSshHostPopupVisible && highlightedSshHostRef.current !== undefined; |
| 2763 | if ( |
| 2764 | !event.defaultPrevented && |
| 2765 | !hasHighlightedSshHost && |
| 2766 | event.key === "Enter" && |
| 2767 | savedBackendSshHost.trim().length > 0 |
| 2768 | ) { |
| 2769 | event.preventDefault(); |
| 2770 | void handleAddSavedBackend(); |
| 2771 | } |
| 2772 | }, |
| 2773 | [ |
| 2774 | filteredDiscoveredSshHosts, |
| 2775 | handleAddSavedBackend, |
| 2776 | handleSelectSshHostSuggestion, |
| 2777 | hasSshHostSuggestionContent, |
| 2778 | keybindings, |
| 2779 | savedBackendSshHost, |
| 2780 | sshHostSuggestionsOpen, |
| 2781 | ], |
| 2782 | ); |
| 2783 | |
| 2784 | const handleSetSavedBackendEnabled = useCallback( |
| 2785 | async (environmentId: EnvironmentId, enabled: boolean) => { |
| 2786 | setSavedBackendError(null); |
| 2787 | const result = await setEnvironmentEnabled({ environmentId, enabled }); |
| 2788 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 2789 | const error = squashAtomCommandFailure(result); |
| 2790 | const message = |
| 2791 | error instanceof Error |
| 2792 | ? error.message |
| 2793 | : `Failed to switch the backend ${enabled ? "on" : "off"}.`; |
| 2794 | setSavedBackendError(message); |
| 2795 | toastManager.add( |
| 2796 | stackedThreadToast({ |
| 2797 | type: "error", |
| 2798 | title: `Could not switch backend ${enabled ? "on" : "off"}`, |
| 2799 | description: message, |
| 2800 | }), |
| 2801 | ); |
| 2802 | } |
| 2803 | }, |
| 2804 | [setEnvironmentEnabled], |
| 2805 | ); |
| 2806 | |
| 2807 | const removeSavedBackend = useCallback( |
| 2808 | async (environment: EnvironmentPresentation) => { |
| 2809 | const environmentId = environment.environmentId; |
| 2810 | setRemovingSavedEnvironmentId(environmentId); |
| 2811 | setSavedBackendError(null); |
| 2812 | const result = await removeEnvironment(environmentId); |
| 2813 | setRemovingSavedEnvironmentId(null); |
| 2814 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 2815 | const error = squashAtomCommandFailure(result); |
| 2816 | const message = error instanceof Error ? error.message : "Failed to remove backend."; |
| 2817 | setSavedBackendError(message); |
| 2818 | toastManager.add( |
| 2819 | stackedThreadToast({ |
| 2820 | type: "error", |
| 2821 | title: "Could not remove backend", |
| 2822 | description: message, |
| 2823 | }), |
| 2824 | ); |
| 2825 | } |
| 2826 | }, |
| 2827 | [removeEnvironment], |
| 2828 | ); |
| 2829 | |
| 2830 | // Removing forgets the pairing, credentials, and cached threads on this |
| 2831 | // device. Switching off is the reversible path, so removal always confirms. |
| 2832 | // T3 Connect environments get their own dialog: removing one here leaves its |
| 2833 | // account registration, so it points to where that can be deregistered. |
| 2834 | const [pendingT3ConnectRemoval, setPendingT3ConnectRemoval] = |
| 2835 | useState<EnvironmentPresentation | null>(null); |
| 2836 | const handleRemoveSavedBackend = useCallback( |
| 2837 | async (environment: EnvironmentPresentation) => { |
| 2838 | if (environment.relayManaged && hasCloudPublicConfig()) { |
| 2839 | setPendingT3ConnectRemoval(environment); |
| 2840 | return; |
| 2841 | } |
| 2842 | // Fail closed: no mounted confirm host means no removal. |
| 2843 | const confirmed = await requestConfirmDialog( |
| 2844 | `Remove ${environment.label} from this device?\nThis forgets its pairing, credentials, and cached threads here. Switch it off instead to keep it saved.`, |
| 2845 | { variant: "destructive" }, |
| 2846 | ); |
| 2847 | if (confirmed === true) await removeSavedBackend(environment); |
| 2848 | }, |
| 2849 | [removeSavedBackend], |
| 2850 | ); |
| 2851 | |
| 2852 | const visibleDesktopPairingLinks = desktopPairingLinks; |
| 2853 | const tailscaleHttpsEndpoint = useMemo( |
| 2854 | () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null, |
| 2855 | [desktopAdvertisedEndpoints], |
| 2856 | ); |
| 2857 | const visibleDesktopNetworkAdvertisedEndpoints = useMemo( |
| 2858 | () => |
| 2859 | isLocalBackendNetworkAccessible |
| 2860 | ? desktopAdvertisedEndpoints.filter((endpoint) => !isTailscaleHttpsEndpoint(endpoint)) |
| 2861 | : [], |
| 2862 | [desktopAdvertisedEndpoints, isLocalBackendNetworkAccessible], |
| 2863 | ); |
| 2864 | const visibleDesktopAdvertisedEndpoints = useMemo( |
| 2865 | () => |
| 2866 | tailscaleHttpsEndpoint |
| 2867 | ? [...visibleDesktopNetworkAdvertisedEndpoints, tailscaleHttpsEndpoint] |
| 2868 | : visibleDesktopNetworkAdvertisedEndpoints, |
| 2869 | [tailscaleHttpsEndpoint, visibleDesktopNetworkAdvertisedEndpoints], |
| 2870 | ); |
| 2871 | const defaultDesktopNetworkAdvertisedEndpoint = useMemo( |
| 2872 | () => |
| 2873 | selectPairingEndpoint(visibleDesktopNetworkAdvertisedEndpoints, defaultAdvertisedEndpointKey), |
| 2874 | [defaultAdvertisedEndpointKey, visibleDesktopNetworkAdvertisedEndpoints], |
| 2875 | ); |
| 2876 | const defaultDesktopAdvertisedEndpoint = useMemo( |
| 2877 | () => |
| 2878 | defaultDesktopNetworkAdvertisedEndpoint ?? |
| 2879 | selectPairingEndpoint( |
| 2880 | tailscaleHttpsEndpoint ? [tailscaleHttpsEndpoint] : [], |
| 2881 | defaultAdvertisedEndpointKey, |
| 2882 | ), |
| 2883 | [defaultAdvertisedEndpointKey, defaultDesktopNetworkAdvertisedEndpoint, tailscaleHttpsEndpoint], |
| 2884 | ); |
| 2885 | const defaultDesktopAdvertisedEndpointKey = defaultDesktopAdvertisedEndpoint |
| 2886 | ? endpointDefaultPreferenceKey(defaultDesktopAdvertisedEndpoint) |
| 2887 | : null; |
| 2888 | const handleSetDefaultAdvertisedEndpoint = useCallback( |
| 2889 | (endpoint: AdvertisedEndpoint) => { |
| 2890 | setDefaultAdvertisedEndpointKey(endpointDefaultPreferenceKey(endpoint)); |
| 2891 | }, |
| 2892 | [setDefaultAdvertisedEndpointKey], |
| 2893 | ); |
| 2894 | const handleSavedBackendHostChange = useCallback((value: string) => { |
| 2895 | const parsedPairingUrl = parsePairingUrlFields(value); |
| 2896 | if (parsedPairingUrl) { |
| 2897 | setSavedBackendHost(parsedPairingUrl.host); |
| 2898 | setSavedBackendPairingCode(parsedPairingUrl.pairingCode); |
| 2899 | return; |
| 2900 | } |
| 2901 | setSavedBackendHost(value); |
| 2902 | }, []); |
| 2903 | |
| 2904 | const renderConnectionModeCard = (input: { |
| 2905 | readonly mode: "remote" | "ssh"; |
| 2906 | readonly title: string; |
| 2907 | readonly description: string; |
| 2908 | readonly icon?: ReactNode; |
| 2909 | }) => { |
| 2910 | const selected = savedBackendMode === input.mode; |
| 2911 | return ( |
| 2912 | <button |
| 2913 | type="button" |
| 2914 | aria-pressed={selected} |
| 2915 | className={cn( |
| 2916 | "group flex min-h-24 items-start gap-3 rounded-lg border p-4 text-left", |
| 2917 | selected ? "border-primary/50 bg-primary/5" : "border-border/60 hover:bg-muted/40", |
| 2918 | )} |
| 2919 | disabled={isAddingSavedBackend} |
| 2920 | onClick={() => { |
| 2921 | setSavedBackendMode(input.mode); |
| 2922 | }} |
| 2923 | > |
| 2924 | {input.icon ? ( |
| 2925 | <span |
| 2926 | className={cn( |
| 2927 | "mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border", |
| 2928 | selected |
| 2929 | ? "border-primary/30 bg-primary/10 text-primary" |
| 2930 | : "border-border/70 bg-background text-muted-foreground group-hover:text-foreground", |
| 2931 | )} |
| 2932 | > |
| 2933 | {input.icon} |
| 2934 | </span> |
| 2935 | ) : null} |
| 2936 | <span className="min-w-0"> |
| 2937 | <span className="block text-sm font-medium text-foreground">{input.title}</span> |
| 2938 | <span className="mt-1 block text-xs leading-relaxed text-muted-foreground"> |
| 2939 | {input.description} |
| 2940 | </span> |
| 2941 | </span> |
| 2942 | </button> |
| 2943 | ); |
| 2944 | }; |
| 2945 | |
| 2946 | const renderRemoteFields = () => ( |
| 2947 | <div className="space-y-3"> |
| 2948 | <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_10rem]"> |
| 2949 | <label className="block"> |
| 2950 | <span className="mb-1.5 block text-xs font-medium text-foreground">Host</span> |
| 2951 | <Input |
| 2952 | value={savedBackendHost} |
| 2953 | onChange={(event) => handleSavedBackendHostChange(event.target.value)} |
| 2954 | placeholder="backend.example.com" |
| 2955 | disabled={isAddingSavedBackend} |
| 2956 | spellCheck={false} |
| 2957 | /> |
| 2958 | </label> |
| 2959 | <label className="block"> |
| 2960 | <span className="mb-1.5 block text-xs font-medium text-foreground">Pairing code</span> |
| 2961 | <Input |
| 2962 | value={savedBackendPairingCode} |
| 2963 | onChange={(event) => setSavedBackendPairingCode(event.target.value)} |
| 2964 | placeholder="PAIRCODE" |
| 2965 | disabled={isAddingSavedBackend} |
| 2966 | spellCheck={false} |
| 2967 | /> |
| 2968 | </label> |
| 2969 | </div> |
| 2970 | <div> |
| 2971 | <span className="mt-1 block text-2xs text-muted-foreground"> |
| 2972 | Paste a full pairing URL here to fill both fields automatically. |
| 2973 | </span> |
| 2974 | </div> |
| 2975 | </div> |
| 2976 | ); |
| 2977 | // T3 Connect is offered as a route when this account can reach the machine |
| 2978 | // through it and it is not one of the machine's routes yet. |
| 2979 | const relayRouteOffer = |
| 2980 | routeTarget !== null && |
| 2981 | !routeTarget.relayManaged && |
| 2982 | relayDiscoveryState.environments.has(routeTarget.environmentId) |
| 2983 | ? relayDiscoveryState.environments.get(routeTarget.environmentId)!.environment |
| 2984 | : null; |
| 2985 | const addRelayRoute = async () => { |
| 2986 | if (relayRouteOffer === null || routeTarget === null) return; |
| 2987 | setIsAddingSavedBackend(true); |
| 2988 | const result = await registerEnvironment( |
| 2989 | new RelayConnectionRegistration({ |
| 2990 | target: new RelayConnectionTarget({ |
| 2991 | environmentId: relayRouteOffer.environmentId, |
| 2992 | label: routeTarget.label, |
| 2993 | }), |
| 2994 | }), |
| 2995 | ); |
| 2996 | setIsAddingSavedBackend(false); |
| 2997 | if (result._tag === "Failure") { |
| 2998 | if (!isAtomCommandInterrupted(result)) { |
| 2999 | const error = squashAtomCommandFailure(result); |
| 3000 | setSavedBackendError(error instanceof Error ? error.message : "Could not add the route."); |
| 3001 | } |
| 3002 | return; |
| 3003 | } |
| 3004 | setAddBackendDialogOpen(false); |
| 3005 | toastManager.add({ |
| 3006 | type: "success", |
| 3007 | title: "Route added", |
| 3008 | description: `${routeTarget.label} falls back to T3 Connect when its other routes are unreachable.`, |
| 3009 | }); |
| 3010 | }; |
| 3011 | const renderRemoteModeBody = () => ( |
| 3012 | <div className="space-y-4"> |
| 3013 | {relayRouteOffer !== null ? ( |
| 3014 | <div className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2"> |
| 3015 | <p className="text-xs text-muted-foreground"> |
| 3016 | This machine is on your T3 Connect account. Use it as a fallback route. |
| 3017 | </p> |
| 3018 | <Button |
| 3019 | size="xs" |
| 3020 | variant="outline" |
| 3021 | disabled={isAddingSavedBackend} |
| 3022 | onClick={() => void addRelayRoute()} |
| 3023 | > |
| 3024 | Add T3 Connect |
| 3025 | </Button> |
| 3026 | </div> |
| 3027 | ) : null} |
| 3028 | {renderRemoteFields()} |
| 3029 | {savedBackendError ? <p className="text-xs text-destructive">{savedBackendError}</p> : null} |
| 3030 | <Button |
| 3031 | variant="outline" |
| 3032 | className="w-full" |
| 3033 | disabled={isAddingSavedBackend} |
| 3034 | onClick={() => void handleAddSavedBackend()} |
| 3035 | > |
| 3036 | <PlusIcon className="size-3.5" /> |
| 3037 | {isAddingSavedBackend ? "Adding…" : routeTarget ? "Add route" : "Add environment"} |
| 3038 | </Button> |
| 3039 | </div> |
| 3040 | ); |
| 3041 | const renderSshFields = () => ( |
| 3042 | <div className="space-y-4"> |
| 3043 | <div className="space-y-3"> |
| 3044 | <div className="block"> |
| 3045 | <label |
| 3046 | htmlFor="saved-backend-ssh-host" |
| 3047 | className="mb-1.5 block text-xs font-medium text-foreground" |
| 3048 | > |
| 3049 | SSH host or alias |
| 3050 | </label> |
| 3051 | <Autocomplete |
| 3052 | items={filteredDiscoveredSshHosts} |
| 3053 | itemToStringValue={(target) => target.alias} |
| 3054 | mode="none" |
| 3055 | openOnInputClick |
| 3056 | open={sshHostSuggestionsOpen} |
| 3057 | onOpenChange={setSshHostSuggestionsOpen} |
| 3058 | onItemHighlighted={(target) => { |
| 3059 | highlightedSshHostRef.current = target; |
| 3060 | }} |
| 3061 | value={savedBackendSshHost} |
| 3062 | onValueChange={(value, eventDetails) => { |
| 3063 | setSavedBackendSshHost(value); |
| 3064 | if (eventDetails.reason !== "item-press") return; |
| 3065 | |
| 3066 | const target = filteredDiscoveredSshHosts.find((host) => host.alias === value); |
| 3067 | if (target) void handleSelectSshHostSuggestion(target); |
| 3068 | }} |
| 3069 | > |
| 3070 | <AutocompleteInput |
| 3071 | id="saved-backend-ssh-host" |
| 3072 | onKeyDown={handleSavedBackendSshHostKeyDown} |
| 3073 | placeholder="Search hosts or type devbox" |
| 3074 | disabled={isAddingSavedBackend} |
| 3075 | spellCheck={false} |
| 3076 | /> |
| 3077 | {hasSshHostSuggestionContent ? ( |
| 3078 | <AutocompletePopup> |
| 3079 | {isLoadingDiscoveredSshHosts ? ( |
| 3080 | <div className="px-3 py-2 text-xs text-muted-foreground">Loading hosts…</div> |
| 3081 | ) : filteredDiscoveredSshHosts.length > 0 ? ( |
| 3082 | <AutocompleteList className="max-h-72"> |
| 3083 | {filteredDiscoveredSshHosts.map((target, index) => { |
| 3084 | const address = formatDesktopSshTarget(target); |
| 3085 | const shortcutCommand = index < 9 ? threadJumpCommandForIndex(index) : null; |
| 3086 | const shortcutLabel = shortcutCommand |
| 3087 | ? shortcutLabelForCommand(keybindings, shortcutCommand, navigator.platform) |
| 3088 | : null; |
| 3089 | return ( |
| 3090 | <AutocompleteItem |
| 3091 | key={`${target.alias}:${target.hostname}:${target.port ?? ""}`} |
| 3092 | value={target} |
| 3093 | className="h-8 min-h-8 whitespace-nowrap" |
| 3094 | > |
| 3095 | <span className="min-w-0 truncate text-sm font-medium"> |
| 3096 | {target.alias} |
| 3097 | </span> |
| 3098 | {address !== target.alias ? ( |
| 3099 | <span className="min-w-0 flex-1 truncate text-xs text-muted-foreground"> |
| 3100 | {address} |
| 3101 | </span> |
| 3102 | ) : ( |
| 3103 | <span className="flex-1" /> |
| 3104 | )} |
| 3105 | {shortcutLabel ? ( |
| 3106 | <CommandShortcut className="shrink-0">{shortcutLabel}</CommandShortcut> |
| 3107 | ) : null} |
| 3108 | </AutocompleteItem> |
| 3109 | ); |
| 3110 | })} |
| 3111 | </AutocompleteList> |
| 3112 | ) : ( |
| 3113 | <AutocompleteEmpty className="break-all"> |
| 3114 | No hosts match "{savedBackendSshHost.trim()}". |
| 3115 | </AutocompleteEmpty> |
| 3116 | )} |
| 3117 | </AutocompletePopup> |
| 3118 | ) : null} |
| 3119 | </Autocomplete> |
| 3120 | </div> |
| 3121 | <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_7rem]"> |
| 3122 | <label className="block"> |
| 3123 | <span className="mb-1.5 block text-xs font-medium text-foreground">Username</span> |
| 3124 | <Input |
| 3125 | value={savedBackendSshUsername} |
| 3126 | onChange={(event) => setSavedBackendSshUsername(event.target.value)} |
| 3127 | onKeyDown={handleSavedBackendSshFieldKeyDown} |
| 3128 | placeholder="root" |
| 3129 | disabled={isAddingSavedBackend} |
| 3130 | spellCheck={false} |
| 3131 | /> |
| 3132 | </label> |
| 3133 | <label className="block"> |
| 3134 | <span className="mb-1.5 block text-xs font-medium text-foreground">Port</span> |
| 3135 | <Input |
| 3136 | value={savedBackendSshPort} |
| 3137 | onChange={(event) => setSavedBackendSshPort(event.target.value)} |
| 3138 | onKeyDown={handleSavedBackendSshFieldKeyDown} |
| 3139 | placeholder="22" |
| 3140 | inputMode="numeric" |
| 3141 | disabled={isAddingSavedBackend} |
| 3142 | spellCheck={false} |
| 3143 | /> |
| 3144 | </label> |
| 3145 | </div> |
| 3146 | {savedBackendError || discoveredSshHostsError ? ( |
| 3147 | <Alert variant="error"> |
| 3148 | <AlertDescription>{savedBackendError ?? discoveredSshHostsError}</AlertDescription> |
| 3149 | </Alert> |
| 3150 | ) : null} |
| 3151 | <Button |
| 3152 | variant="outline" |
| 3153 | className="w-full" |
| 3154 | disabled={isAddingSavedBackend} |
| 3155 | onClick={() => void handleAddSavedBackend()} |
| 3156 | > |
| 3157 | <PlusIcon className="size-3.5" /> |
| 3158 | {isAddingSavedBackend ? "Adding…" : routeTarget ? "Add route" : "Add environment"} |
| 3159 | </Button> |
| 3160 | </div> |
| 3161 | </div> |
| 3162 | ); |
| 3163 | const renderNetworkAccessToggle = () => ( |
| 3164 | <Switch |
| 3165 | checked={desktopServerExposureState?.mode === "network-accessible"} |
| 3166 | disabled={!desktopServerExposureState || isUpdatingDesktopServerExposure} |
| 3167 | onCheckedChange={(checked) => { |
| 3168 | setPendingDesktopServerExposureMode(checked ? "network-accessible" : "local-only"); |
| 3169 | setIsDesktopServerExposureDialogOpen(true); |
| 3170 | }} |
| 3171 | aria-label="Enable network access" |
| 3172 | /> |
| 3173 | ); |
| 3174 | const renderEndpointRows = (presentation: AccessSectionPresentation) => |
| 3175 | isAdvertisedEndpointListExpanded |
| 3176 | ? visibleDesktopNetworkAdvertisedEndpoints.map((endpoint) => { |
| 3177 | const endpointKey = endpointDefaultPreferenceKey(endpoint); |
| 3178 | return ( |
| 3179 | <AdvertisedEndpointListRow |
| 3180 | key={endpoint.id} |
| 3181 | endpoint={endpoint} |
| 3182 | isDefault={endpointKey === defaultDesktopAdvertisedEndpointKey} |
| 3183 | presentation={presentation} |
| 3184 | onSetDefault={handleSetDefaultAdvertisedEndpoint} |
| 3185 | onSetupTailscaleServe={handleStartTailscaleServeSetup} |
| 3186 | onDisableTailscaleServe={handleStartTailscaleServeDisable} |
| 3187 | isUpdatingTailscaleServe={isUpdatingTailscaleServe} |
| 3188 | /> |
| 3189 | ); |
| 3190 | }) |
| 3191 | : null; |
| 3192 | // Apply a setting change immediately. The orchestrator reconciles the |
| 3193 | // pool in the background and the primary backend is untouched, so we |
| 3194 | // don't gate this behind a confirmation dialog. After the desktop |
| 3195 | // side persists the change and nudges its orchestrator, we trigger |
| 3196 | // the renderer's reconciler so the WSL backend's saved-env-shaped |
| 3197 | // entry catches up (registers/unregisters) without a reload. |
| 3198 | const applyWslSettingChange = useCallback( |
| 3199 | async (apply: () => Promise<DesktopWslState>) => { |
| 3200 | if ( |
| 3201 | !desktopBridge || |
| 3202 | primaryEnvironmentId === null || |
| 3203 | !readEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope) |
| 3204 | ) |
| 3205 | return; |
| 3206 | setIsUpdatingWslBackend(true); |
| 3207 | setDesktopWslMutationError(null); |
| 3208 | try { |
| 3209 | await apply(); |
| 3210 | refreshDesktopWslState(); |
| 3211 | // The connection platform source polls the desktop bootstrap list and |
| 3212 | // reconciles the environment catalog automatically, so toggling the WSL |
| 3213 | // backend on/off or switching distros is picked up here without an |
| 3214 | // explicit renderer reconcile. |
| 3215 | } catch (error) { |
| 3216 | const message = error instanceof Error ? error.message : "Failed to update WSL backend."; |
| 3217 | setDesktopWslMutationError(message); |
| 3218 | toastManager.add( |
| 3219 | stackedThreadToast({ |
| 3220 | type: "error", |
| 3221 | title: "Could not change WSL backend", |
| 3222 | description: message, |
| 3223 | }), |
| 3224 | ); |
| 3225 | refreshDesktopWslState(); |
| 3226 | } finally { |
| 3227 | setIsUpdatingWslBackend(false); |
| 3228 | } |
| 3229 | }, |
| 3230 | [desktopBridge, primaryEnvironmentId], |
| 3231 | ); |
| 3232 | |
| 3233 | // Reload the keep-alive WSL state atom. Clearing the mutation error before |
| 3234 | // refresh lets the atom-owned load error become the visible retry state. |
| 3235 | const loadWslState = useCallback(() => { |
| 3236 | setDesktopWslMutationError(null); |
| 3237 | refreshDesktopWslState(); |
| 3238 | }, []); |
| 3239 | |
| 3240 | // True when a desktop-local WSL backend is currently registered as an |
| 3241 | // environment on this machine. We use this as a proxy for "the user has work |
| 3242 | // that lives on the WSL side": if WSL has connected in a way that registered |
| 3243 | // the env, disabling or switching distros could disrupt open threads/projects. |
| 3244 | // If WSL never connected (fresh install, toggled on then immediately off, |
| 3245 | // etc.) there's no local environment, so we skip the confirmation dialog. |
| 3246 | const hasWslRegistrationToLose = useMemo(() => { |
| 3247 | return environments.some((environment) => |
| 3248 | isDesktopLocalConnectionTarget(environment.entry.target), |
| 3249 | ); |
| 3250 | }, [environments]); |
| 3251 | |
| 3252 | // Single picker for "WSL backend off" vs "running on distro X". The |
| 3253 | // dropdown maps "Off" to disable and any distro entry to enable + |
| 3254 | // run on that distro. Splitting these into a separate switch and |
| 3255 | // dropdown was confusing — they're the same decision. |
| 3256 | const handleSelectWslMode = useCallback( |
| 3257 | (value: string) => { |
| 3258 | if (!desktopBridge || !desktopWslState) return; |
| 3259 | const defaultDistroName = |
| 3260 | desktopWslState.distros.find((distro) => distro.isDefault)?.name ?? null; |
| 3261 | if (value === BACKEND_VALUE_WSL_OFF) { |
| 3262 | // Match the recovery row's visibility (`enabled || wslOnly`): when WSL |
| 3263 | // went unavailable while wsl-only was persisted, `enabled` can be false |
| 3264 | // while `wslOnly` is true, and the "Switch to Windows" button must |
| 3265 | // still clear that state instead of silently no-op'ing. |
| 3266 | if (!desktopWslState.enabled && !desktopWslState.wslOnly) return; |
| 3267 | const wasWslOnly = desktopWslState.wslOnly; |
| 3268 | // Confirm when there's WSL state to lose, OR when wsl-only is |
| 3269 | // on (turning the only running backend off needs to switch |
| 3270 | // back to Windows and restart — always consequential). |
| 3271 | if (hasWslRegistrationToLose || wasWslOnly) { |
| 3272 | setPendingWslChange({ kind: "disable", wasWslOnly }); |
| 3273 | return; |
| 3274 | } |
| 3275 | void applyWslSettingChange(() => desktopBridge.setWslBackendEnabled(false)); |
| 3276 | return; |
| 3277 | } |
| 3278 | const nextDistro = value === BACKEND_VALUE_DEFAULT_WSL ? null : value; |
| 3279 | const resolvedNext = nextDistro ?? defaultDistroName; |
| 3280 | if (!desktopWslState.enabled) { |
| 3281 | // Was off, user picked a distro: ask whether to run both |
| 3282 | // backends or only WSL. We always ask here so the user picks |
| 3283 | // the mode upfront instead of having to discover the wsl-only |
| 3284 | // switch afterwards. |
| 3285 | setPendingWslChange({ kind: "enable", nextDistro }); |
| 3286 | return; |
| 3287 | } |
| 3288 | // Already enabled — treat as a distro switch. Skip the change if |
| 3289 | // the user re-picked the row that's already selected. |
| 3290 | const resolvedCurrent = desktopWslState.distro ?? defaultDistroName; |
| 3291 | if (resolvedCurrent === resolvedNext) return; |
| 3292 | // Confirm when there's WSL registration to lose, OR in wsl-only mode: |
| 3293 | // there the primary IS the WSL backend, so a distro change relaunches |
| 3294 | // the app (the IPC handler does this) rather than swapping a secondary, |
| 3295 | // and the user should see that coming. |
| 3296 | if (hasWslRegistrationToLose || desktopWslState.wslOnly) { |
| 3297 | setPendingWslChange({ kind: "distro", nextDistro }); |
| 3298 | return; |
| 3299 | } |
| 3300 | void applyWslSettingChange(() => desktopBridge.setWslDistro(nextDistro)); |
| 3301 | }, |
| 3302 | [applyWslSettingChange, desktopBridge, desktopWslState, hasWslRegistrationToLose], |
| 3303 | ); |
| 3304 | |
| 3305 | // Dispatched from the enable modal's two action buttons. |
| 3306 | const handleConfirmEnableWsl = useCallback( |
| 3307 | (mode: "both" | "wsl-only") => { |
| 3308 | if (!desktopBridge || !pendingWslChange || pendingWslChange.kind !== "enable") return; |
| 3309 | const nextDistro = pendingWslChange.nextDistro; |
| 3310 | setPendingWslChange(null); |
| 3311 | const persistedDistro = desktopWslState?.distro ?? null; |
| 3312 | void applyWslSettingChange(() => |
| 3313 | applyWslEnableSelection({ |
| 3314 | bridge: desktopBridge, |
| 3315 | mode, |
| 3316 | nextDistro, |
| 3317 | persistedDistro, |
| 3318 | }), |
| 3319 | ); |
| 3320 | }, |
| 3321 | [applyWslSettingChange, desktopBridge, desktopWslState, pendingWslChange], |
| 3322 | ); |
| 3323 | |
| 3324 | const handleToggleWslOnly = useCallback( |
| 3325 | (enabled: boolean) => { |
| 3326 | if (!desktopBridge || !desktopWslState || desktopWslState.wslOnly === enabled) return; |
| 3327 | // wsl-only changes which backend the pool uses as "primary", |
| 3328 | // which is decided once at app launch. The desktop side persists |
| 3329 | // the setting immediately but doesn't tear down or restart |
| 3330 | // anything itself; the renderer warns the user to expect a |
| 3331 | // restart and (in a follow-up) can trigger it automatically. |
| 3332 | // Always prompt — even enabling is consequential here. |
| 3333 | setPendingWslChange({ kind: "wsl-only", nextValue: enabled }); |
| 3334 | }, |
| 3335 | [desktopBridge, desktopWslState], |
| 3336 | ); |
| 3337 | |
| 3338 | const handleConfirmWslChange = useCallback(() => { |
| 3339 | if (!desktopBridge || !pendingWslChange) return; |
| 3340 | const change = pendingWslChange; |
| 3341 | // The enable kind resolves through handleConfirmEnableWsl, not |
| 3342 | // this single Confirm path. |
| 3343 | if (change.kind === "enable") return; |
| 3344 | setPendingWslChange(null); |
| 3345 | if (change.kind === "disable") { |
| 3346 | void applyWslSettingChange(async () => { |
| 3347 | const next = await desktopBridge.setWslBackendEnabled(false); |
| 3348 | if (change.wasWslOnly) { |
| 3349 | // Clearing wsl-only relaunches onto the Windows backend. |
| 3350 | return await desktopBridge.setWslOnly(false); |
| 3351 | } |
| 3352 | return next; |
| 3353 | }); |
| 3354 | return; |
| 3355 | } |
| 3356 | if (change.kind === "distro") { |
| 3357 | void applyWslSettingChange(() => desktopBridge.setWslDistro(change.nextDistro)); |
| 3358 | return; |
| 3359 | } |
| 3360 | void applyWslSettingChange(() => desktopBridge.setWslOnly(change.nextValue)); |
| 3361 | }, [applyWslSettingChange, desktopBridge, pendingWslChange]); |
| 3362 | |
| 3363 | const renderWslRow = () => { |
| 3364 | if (!desktopWslState) { |
| 3365 | // A load failed: keep a recovery row (with retry) visible instead of |
| 3366 | // silently hiding the section. The error persists across an in-flight |
| 3367 | // retry so the row doesn't flicker away, and the button reflects the |
| 3368 | // loading state. With no error we simply haven't loaded yet (or WSL |
| 3369 | // management isn't available), so render nothing. |
| 3370 | if ( |
| 3371 | isWslSettingsRowVisible({ state: null, error: desktopWslError }) && |
| 3372 | canManageLocalBackend |
| 3373 | ) { |
| 3374 | return ( |
| 3375 | <SettingsRow |
| 3376 | {...searchableSetting("wsl-backend")} |
| 3377 | description="Couldn't load the WSL backend state." |
| 3378 | status={<span className="block text-destructive">{desktopWslError}</span>} |
| 3379 | control={ |
| 3380 | <Button |
| 3381 | size="sm" |
| 3382 | variant="outline" |
| 3383 | onClick={loadWslState} |
| 3384 | disabled={isLoadingWslState} |
| 3385 | > |
| 3386 | {isLoadingWslState ? "Retrying…" : "Retry"} |
| 3387 | </Button> |
| 3388 | } |
| 3389 | /> |
| 3390 | ); |
| 3391 | } |
| 3392 | return null; |
| 3393 | } |
| 3394 | // WSL went unavailable while the user still has the WSL backend persisted |
| 3395 | // (it may have been uninstalled or its distro removed). The desktop side |
| 3396 | // falls back to the Windows backend, but the normal distro picker needs a |
| 3397 | // live distro list it no longer has. Without a control here the user would |
| 3398 | // be stranded on a WSL preference they can't clear, so render a recovery |
| 3399 | // row that switches back to Windows. When WSL is unavailable AND unused, |
| 3400 | // there's nothing to recover — keep the section hidden as before. |
| 3401 | if (!isWslSettingsRowVisible({ state: desktopWslState, error: desktopWslError })) { |
| 3402 | return null; |
| 3403 | } |
| 3404 | if (!desktopWslState.available) { |
| 3405 | return ( |
| 3406 | <SettingsRow |
| 3407 | {...searchableSetting("wsl-backend")} |
| 3408 | description="WSL is unavailable, so Windows is running instead. Turn WSL off to clear this preference." |
| 3409 | status={ |
| 3410 | desktopWslError ? ( |
| 3411 | <span className="block text-destructive">{desktopWslError}</span> |
| 3412 | ) : null |
| 3413 | } |
| 3414 | control={ |
| 3415 | <Button |
| 3416 | size="sm" |
| 3417 | variant="outline" |
| 3418 | disabled={isUpdatingWslBackend} |
| 3419 | onClick={() => handleSelectWslMode(BACKEND_VALUE_WSL_OFF)} |
| 3420 | > |
| 3421 | Switch to Windows |
| 3422 | </Button> |
| 3423 | } |
| 3424 | /> |
| 3425 | ); |
| 3426 | } |
| 3427 | // Distro is null when the user wants the WSL default. Map it to the |
| 3428 | // real default's name so the Select highlights a real option; fall |
| 3429 | // back to the sentinel only when no distros are listed yet (the |
| 3430 | // dropdown then renders a single placeholder that matches). |
| 3431 | const defaultDistroName = |
| 3432 | desktopWslState.distros.find((distro) => distro.isDefault)?.name ?? null; |
| 3433 | const selectValue = !desktopWslState.enabled |
| 3434 | ? BACKEND_VALUE_WSL_OFF |
| 3435 | : (desktopWslState.distro ?? defaultDistroName ?? BACKEND_VALUE_DEFAULT_WSL); |
| 3436 | const selectLabel = |
| 3437 | selectValue === BACKEND_VALUE_WSL_OFF |
| 3438 | ? "Off" |
| 3439 | : selectValue === BACKEND_VALUE_DEFAULT_WSL |
| 3440 | ? "Default distro" |
| 3441 | : selectValue; |
| 3442 | return ( |
| 3443 | <> |
| 3444 | <SettingsRow |
| 3445 | {...searchableSetting("wsl-backend")} |
| 3446 | description="Run the selected WSL distro alongside Windows. Projects remain on their current filesystem." |
| 3447 | status={ |
| 3448 | desktopWslError ? ( |
| 3449 | <span className="block text-destructive">{desktopWslError}</span> |
| 3450 | ) : desktopWslState.preflightError ? ( |
| 3451 | <span className="block text-destructive"> |
| 3452 | WSL backend couldn't start: {desktopWslState.preflightError} |
| 3453 | </span> |
| 3454 | ) : null |
| 3455 | } |
| 3456 | control={ |
| 3457 | <Select |
| 3458 | value={selectValue} |
| 3459 | onValueChange={(value) => { |
| 3460 | if (typeof value !== "string") return; |
| 3461 | handleSelectWslMode(value); |
| 3462 | }} |
| 3463 | > |
| 3464 | <SelectTrigger |
| 3465 | size="sm" |
| 3466 | className="w-full sm:w-56" |
| 3467 | aria-label="WSL backend" |
| 3468 | disabled={isUpdatingWslBackend} |
| 3469 | > |
| 3470 | <SelectValue>{selectLabel}</SelectValue> |
| 3471 | </SelectTrigger> |
| 3472 | <SelectPopup align="end" alignItemWithTrigger={false}> |
| 3473 | <SelectItem hideIndicator value={BACKEND_VALUE_WSL_OFF}> |
| 3474 | Off |
| 3475 | </SelectItem> |
| 3476 | {desktopWslState.distros.length === 0 ? ( |
| 3477 | <SelectItem hideIndicator value={BACKEND_VALUE_DEFAULT_WSL}> |
| 3478 | Default distro |
| 3479 | </SelectItem> |
| 3480 | ) : ( |
| 3481 | desktopWslState.distros.map((distro) => ( |
| 3482 | <SelectItem hideIndicator key={distro.name} value={distro.name}> |
| 3483 | {distro.name} |
| 3484 | {distro.isDefault ? " (default)" : ""} |
| 3485 | </SelectItem> |
| 3486 | )) |
| 3487 | )} |
| 3488 | </SelectPopup> |
| 3489 | </Select> |
| 3490 | } |
| 3491 | /> |
| 3492 | {desktopWslState.enabled ? ( |
| 3493 | <SettingsRow |
| 3494 | title="WSL only" |
| 3495 | description="Run only the WSL backend. The Knife restarts when this changes." |
| 3496 | className="bg-muted/20 pl-7 sm:pl-8" |
| 3497 | control={ |
| 3498 | <Switch |
| 3499 | checked={desktopWslState.wslOnly} |
| 3500 | disabled={isUpdatingWslBackend} |
| 3501 | onCheckedChange={(checked) => handleToggleWslOnly(checked)} |
| 3502 | aria-label="Run WSL only" |
| 3503 | /> |
| 3504 | } |
| 3505 | /> |
| 3506 | ) : null} |
| 3507 | </> |
| 3508 | ); |
| 3509 | }; |
| 3510 | |
| 3511 | const renderTailscaleRow = () => ( |
| 3512 | <SettingsRow |
| 3513 | title={searchableSetting("tailscale-https").title} |
| 3514 | description={ |
| 3515 | tailscaleHttpsEndpoint |
| 3516 | ? tailscaleHttpsEndpoint.status === "available" |
| 3517 | ? tailscaleHttpsEndpoint.httpBaseUrl |
| 3518 | : "Use Tailscale Serve to expose this backend through a MagicDNS HTTPS URL." |
| 3519 | : "Start Tailscale to set up HTTPS access through MagicDNS." |
| 3520 | } |
| 3521 | control={ |
| 3522 | tailscaleHttpsEndpoint ? ( |
| 3523 | <Switch |
| 3524 | checked={tailscaleHttpsEndpoint.status === "available"} |
| 3525 | disabled={isUpdatingTailscaleServe} |
| 3526 | onCheckedChange={(checked) => { |
| 3527 | if (checked) { |
| 3528 | handleStartTailscaleServeSetup(tailscaleHttpsEndpoint); |
| 3529 | return; |
| 3530 | } |
| 3531 | handleStartTailscaleServeDisable(tailscaleHttpsEndpoint); |
| 3532 | }} |
| 3533 | aria-label="Enable Tailscale HTTPS" |
| 3534 | /> |
| 3535 | ) : null |
| 3536 | } |
| 3537 | /> |
| 3538 | ); |
| 3539 | const renderAuthorizedClients = (presentation: AccessSectionPresentation) => ( |
| 3540 | <> |
| 3541 | {desktopAccessManagementError ? ( |
| 3542 | <div className={accessRowClassName(presentation)}> |
| 3543 | <p className="text-xs text-destructive">{desktopAccessManagementError}</p> |
| 3544 | </div> |
| 3545 | ) : null} |
| 3546 | <PairingClientsList |
| 3547 | endpointUrl={desktopServerExposureState?.endpointUrl} |
| 3548 | endpoints={visibleDesktopAdvertisedEndpoints} |
| 3549 | defaultEndpointKey={defaultDesktopAdvertisedEndpointKey} |
| 3550 | presentation={presentation} |
| 3551 | isLoading={isLoadingDesktopAccessManagement} |
| 3552 | pairingLinks={visibleDesktopPairingLinks} |
| 3553 | createdPairingCredentials={createdPairingCredentials} |
| 3554 | clientSessions={desktopClientSessions} |
| 3555 | revokingPairingLinkId={revokingDesktopPairingLinkId} |
| 3556 | revokingClientSessionId={revokingDesktopClientSessionId} |
| 3557 | onRevokePairingLink={handleRevokeDesktopPairingLink} |
| 3558 | onRevokeClientSession={handleRevokeDesktopClientSession} |
| 3559 | canRevoke={canWriteAccess} |
| 3560 | /> |
| 3561 | </> |
| 3562 | ); |
| 3563 | const renderNetworkAccessRow = () => ( |
| 3564 | <SettingsRow |
| 3565 | title={searchableSetting("network-access").title} |
| 3566 | description={ |
| 3567 | isLocalBackendNetworkAccessible ? ( |
| 3568 | <NetworkAccessDescription |
| 3569 | endpoint={defaultDesktopNetworkAdvertisedEndpoint} |
| 3570 | hiddenEndpointCount={Math.max(visibleDesktopNetworkAdvertisedEndpoints.length - 1, 0)} |
| 3571 | expanded={isAdvertisedEndpointListExpanded} |
| 3572 | onToggleExpanded={() => setIsAdvertisedEndpointListExpanded((expanded) => !expanded)} |
| 3573 | fallback={ |
| 3574 | desktopServerExposureState?.endpointUrl |
| 3575 | ? `Reachable at ${desktopServerExposureState.endpointUrl}` |
| 3576 | : desktopServerExposureState?.advertisedHost |
| 3577 | ? `Exposed on all interfaces. Pairing links use ${desktopServerExposureState.advertisedHost}.` |
| 3578 | : "Exposed on all interfaces." |
| 3579 | } |
| 3580 | /> |
| 3581 | ) : desktopServerExposureState ? ( |
| 3582 | "Limited to this machine." |
| 3583 | ) : ( |
| 3584 | "Loading…" |
| 3585 | ) |
| 3586 | } |
| 3587 | status={ |
| 3588 | desktopServerExposureError ? ( |
| 3589 | <span className="block text-destructive">{desktopServerExposureError}</span> |
| 3590 | ) : null |
| 3591 | } |
| 3592 | control={renderNetworkAccessToggle()} |
| 3593 | /> |
| 3594 | ); |
| 3595 | const renderDisabledNetworkAccessRow = () => ( |
| 3596 | <SettingsRow |
| 3597 | title={searchableSetting("network-access").title} |
| 3598 | description={ |
| 3599 | desktopBridge |
| 3600 | ? "This connection cannot view or change network exposure." |
| 3601 | : currentAuthPolicy === "remote-reachable" |
| 3602 | ? "Remote access is already configured. Change network exposure where the server starts." |
| 3603 | : currentAuthPolicy === "loopback-browser" |
| 3604 | ? "Only this machine can connect. Restart with a non-loopback host for remote pairing." |
| 3605 | : "Network exposure information is unavailable." |
| 3606 | } |
| 3607 | control={ |
| 3608 | !desktopBridge && |
| 3609 | (currentAuthPolicy === "remote-reachable" || currentAuthPolicy === "loopback-browser") ? ( |
| 3610 | <Tooltip> |
| 3611 | <TooltipTrigger |
| 3612 | render={ |
| 3613 | <span className="inline-flex"> |
| 3614 | <Switch |
| 3615 | checked={isLocalBackendNetworkAccessible} |
| 3616 | disabled |
| 3617 | aria-label="Enable network access" |
| 3618 | /> |
| 3619 | </span> |
| 3620 | } |
| 3621 | /> |
| 3622 | <TooltipPopup side="top"> |
| 3623 | Network exposure changes restart the backend and must be controlled where the server |
| 3624 | process is launched. |
| 3625 | </TooltipPopup> |
| 3626 | </Tooltip> |
| 3627 | ) : undefined |
| 3628 | } |
| 3629 | /> |
| 3630 | ); |
| 3631 | |
| 3632 | const primarySettings = ( |
| 3633 | <> |
| 3634 | {primaryEnvironmentId !== null ? ( |
| 3635 | <> |
| 3636 | <SettingsSection |
| 3637 | {...searchableSetting("connections-environment")} |
| 3638 | title={ |
| 3639 | primaryEnvironment?.label ?? (desktopBridge ? "This machine" : "Primary environment") |
| 3640 | } |
| 3641 | icon={ |
| 3642 | <EnvironmentMachineIcon |
| 3643 | aria-hidden |
| 3644 | kind={ |
| 3645 | primaryServerConfig |
| 3646 | ? resolveEnvironmentMachineKind(primaryServerConfig) |
| 3647 | : "desktop" |
| 3648 | } |
| 3649 | className="size-4" |
| 3650 | /> |
| 3651 | } |
| 3652 | headerAction={ |
| 3653 | primaryEnvironmentId !== null ? ( |
| 3654 | <Menu> |
| 3655 | <MenuTrigger |
| 3656 | render={ |
| 3657 | <Button |
| 3658 | type="button" |
| 3659 | variant="ghost-muted" |
| 3660 | size="icon-xs" |
| 3661 | aria-label="More actions for this machine" |
| 3662 | /> |
| 3663 | } |
| 3664 | > |
| 3665 | <EllipsisIcon className="size-3.5" /> |
| 3666 | </MenuTrigger> |
| 3667 | <MenuPopup align="end"> |
| 3668 | <EnvironmentIconMenu |
| 3669 | environmentId={primaryEnvironmentId} |
| 3670 | serverConfig={primaryServerConfig} |
| 3671 | /> |
| 3672 | </MenuPopup> |
| 3673 | </Menu> |
| 3674 | ) : null |
| 3675 | } |
| 3676 | > |
| 3677 | <LocalEnvironmentSetting /> |
| 3678 | {canManageLocalBackend ? ( |
| 3679 | <SettingsRow |
| 3680 | title="Version" |
| 3681 | description={ |
| 3682 | primaryServerUpdateState.status !== "idle" ? ( |
| 3683 | <ServerUpdateProgress state={primaryServerUpdateState} /> |
| 3684 | ) : ( |
| 3685 | [ |
| 3686 | primaryServerConfig?.environment.serverVersion ?? null, |
| 3687 | primaryEnvironment?.displayUrl ?? null, |
| 3688 | ] |
| 3689 | .filter((value): value is string => value !== null) |
| 3690 | .join(" · ") || "Loading…" |
| 3691 | ) |
| 3692 | } |
| 3693 | control={ |
| 3694 | primaryVersionMismatch && |
| 3695 | primaryEnvironmentId !== null && |
| 3696 | primaryServerUpdateState.status !== "running" ? ( |
| 3697 | <ServerUpdateAction |
| 3698 | size="sm" |
| 3699 | environmentId={primaryEnvironmentId} |
| 3700 | serverLabel={ |
| 3701 | primaryEnvironment ? `${primaryEnvironment.label} server` : "server" |
| 3702 | } |
| 3703 | selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)} |
| 3704 | installation={ |
| 3705 | primaryServerConfig?.environment.capabilities.serverInstallation |
| 3706 | } |
| 3707 | desktopAppUpdate={supportsDesktopAppUpdate(primaryServerConfig)} |
| 3708 | threadContinuation={supportsServerUpdateThreadContinuation( |
| 3709 | primaryServerConfig, |
| 3710 | )} |
| 3711 | targetVersion={primaryVersionMismatch.clientVersion} |
| 3712 | label={ |
| 3713 | primaryServerUpdateState.status === "failed" |
| 3714 | ? "Retry update" |
| 3715 | : `Update to ${primaryVersionMismatch.clientVersion}` |
| 3716 | } |
| 3717 | /> |
| 3718 | ) : primaryServerUpdateState.status === "idle" && primaryServerConfig ? ( |
| 3719 | <span className="text-xs text-muted-foreground">Up to date</span> |
| 3720 | ) : undefined |
| 3721 | } |
| 3722 | /> |
| 3723 | ) : null} |
| 3724 | {canManageLocalBackend && desktopBridge ? ( |
| 3725 | <> |
| 3726 | {renderNetworkAccessRow()} |
| 3727 | {renderEndpointRows("endpoint-rail")} |
| 3728 | {renderTailscaleRow()} |
| 3729 | {renderWslRow()} |
| 3730 | {canReadRelay || canManageRelay ? ( |
| 3731 | <CloudLinkRow canReadRelay={canReadRelay} canManageRelay={canManageRelay} /> |
| 3732 | ) : null} |
| 3733 | </> |
| 3734 | ) : canManageLocalBackend ? ( |
| 3735 | <> |
| 3736 | {renderDisabledNetworkAccessRow()} |
| 3737 | {canReadRelay || canManageRelay ? ( |
| 3738 | <CloudLinkRow canReadRelay={canReadRelay} canManageRelay={canManageRelay} /> |
| 3739 | ) : null} |
| 3740 | </> |
| 3741 | ) : null} |
| 3742 | {primaryEnvironment ? ( |
| 3743 | <details className="group px-3 sm:px-4"> |
| 3744 | <summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 rounded-md text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring [&::-webkit-details-marker]:hidden"> |
| 3745 | Permissions |
| 3746 | <ChevronRightIcon |
| 3747 | aria-hidden |
| 3748 | className="size-3 text-muted-foreground group-open:rotate-90" |
| 3749 | /> |
| 3750 | </summary> |
| 3751 | <SessionPermissions |
| 3752 | environmentId={primaryEnvironment.environmentId} |
| 3753 | connected={primaryEnvironment.connection.phase === "connected"} |
| 3754 | /> |
| 3755 | </details> |
| 3756 | ) : null} |
| 3757 | </SettingsSection> |
| 3758 | |
| 3759 | {canReadAccess || canWriteAccess ? ( |
| 3760 | <FoldedSettingsSection |
| 3761 | id="authorized-clients" |
| 3762 | title="Authorized clients" |
| 3763 | summary={summarizeAuthorizedClients( |
| 3764 | desktopClientSessions, |
| 3765 | visibleDesktopPairingLinks, |
| 3766 | )} |
| 3767 | control={ |
| 3768 | canWriteAccess ? ( |
| 3769 | <AuthorizedClientsHeaderAction |
| 3770 | delegatableScopes={currentSessionScopes ?? []} |
| 3771 | onPairingLinkCreated={handlePairingLinkCreated} |
| 3772 | clientSessions={ |
| 3773 | canReadAccess && |
| 3774 | authAccessChanges.error === null && |
| 3775 | authAccessChanges.data?.type === "snapshot" |
| 3776 | ? desktopClientSessions |
| 3777 | : null |
| 3778 | } |
| 3779 | isRevokingOtherClients={isRevokingOtherDesktopClients} |
| 3780 | onRevokeOtherClients={handleRevokeOtherDesktopClients} |
| 3781 | /> |
| 3782 | ) : undefined |
| 3783 | } |
| 3784 | > |
| 3785 | <ScrollArea |
| 3786 | scrollFade |
| 3787 | chainVerticalScroll |
| 3788 | className="max-h-[22.5rem]" |
| 3789 | data-testid="authorized-clients-scroll-area" |
| 3790 | > |
| 3791 | {canReadAccess ? ( |
| 3792 | renderAuthorizedClients("current") |
| 3793 | ) : ( |
| 3794 | <p className="px-4 py-3 text-xs text-muted-foreground"> |
| 3795 | This connection can create access links but cannot view authorized clients. |
| 3796 | </p> |
| 3797 | )} |
| 3798 | </ScrollArea> |
| 3799 | </FoldedSettingsSection> |
| 3800 | ) : null} |
| 3801 | <AlertDialog |
| 3802 | open={isDesktopServerExposureDialogOpen && canManageLocalBackend} |
| 3803 | onOpenChange={(open) => { |
| 3804 | if (isUpdatingDesktopServerExposure) return; |
| 3805 | setIsDesktopServerExposureDialogOpen(open); |
| 3806 | }} |
| 3807 | onOpenChangeComplete={(open) => { |
| 3808 | if (!open) setPendingDesktopServerExposureMode(null); |
| 3809 | }} |
| 3810 | > |
| 3811 | <AlertDialogPopup> |
| 3812 | <AlertDialogHeader> |
| 3813 | <AlertDialogTitle> |
| 3814 | {pendingDesktopServerExposureMode === "network-accessible" |
| 3815 | ? "Enable network access?" |
| 3816 | : "Disable network access?"} |
| 3817 | </AlertDialogTitle> |
| 3818 | <AlertDialogDescription> |
| 3819 | {pendingDesktopServerExposureMode === "network-accessible" |
| 3820 | ? "Let your other devices connect to The Knife over the network. Pair devices to give them access. The Knife will restart." |
| 3821 | : "Devices connected over your local network will disconnect. Existing tunnels, such as T3 Connect or Tailscale HTTPS, keep working. The Knife will restart."} |
| 3822 | </AlertDialogDescription> |
| 3823 | </AlertDialogHeader> |
| 3824 | <AlertDialogFooter> |
| 3825 | <AlertDialogClose |
| 3826 | disabled={isUpdatingDesktopServerExposure} |
| 3827 | render={<Button variant="outline" disabled={isUpdatingDesktopServerExposure} />} |
| 3828 | > |
| 3829 | <span className="[text-box:trim-both_cap_alphabetic]">Cancel</span> |
| 3830 | </AlertDialogClose> |
| 3831 | <Button |
| 3832 | variant="default" |
| 3833 | onClick={handleConfirmDesktopServerExposureChange} |
| 3834 | disabled={ |
| 3835 | !canManageLocalBackend || |
| 3836 | pendingDesktopServerExposureMode === null || |
| 3837 | isUpdatingDesktopServerExposure |
| 3838 | } |
| 3839 | > |
| 3840 | {isUpdatingDesktopServerExposure && <Spinner size="sm" />} |
| 3841 | <span className="[text-box:trim-both_cap_alphabetic]"> |
| 3842 | {isUpdatingDesktopServerExposure |
| 3843 | ? "Restarting…" |
| 3844 | : pendingDesktopServerExposureMode === "network-accessible" |
| 3845 | ? "Restart and enable" |
| 3846 | : "Restart and disable"} |
| 3847 | </span> |
| 3848 | </Button> |
| 3849 | </AlertDialogFooter> |
| 3850 | </AlertDialogPopup> |
| 3851 | </AlertDialog> |
| 3852 | <AlertDialog |
| 3853 | open={isWslConfirmDialogOpen && canManageLocalBackend} |
| 3854 | onOpenChange={(open) => { |
| 3855 | if (isUpdatingWslBackend) return; |
| 3856 | if (!open) setPendingWslChange(null); |
| 3857 | }} |
| 3858 | > |
| 3859 | <AlertDialogPopup> |
| 3860 | <AlertDialogHeader> |
| 3861 | <AlertDialogTitle> |
| 3862 | {pendingWslChange?.kind === "disable" |
| 3863 | ? pendingWslChange.wasWslOnly |
| 3864 | ? "Turn off WSL and switch back to Windows?" |
| 3865 | : "Disable WSL backend?" |
| 3866 | : pendingWslChange?.kind === "distro" |
| 3867 | ? "Switch WSL distro?" |
| 3868 | : pendingWslChange?.kind === "enable" |
| 3869 | ? "Start the WSL backend" |
| 3870 | : pendingWslChange?.nextValue |
| 3871 | ? "Run only the WSL backend?" |
| 3872 | : "Re-enable the Windows backend?"} |
| 3873 | </AlertDialogTitle> |
| 3874 | <AlertDialogDescription> |
| 3875 | {pendingWslChange?.kind === "disable" |
| 3876 | ? pendingWslChange.wasWslOnly |
| 3877 | ? "The Knife will restart on the Windows backend. Threads and projects opened against WSL stay safe inside the distro and become available again when you re-enable WSL." |
| 3878 | : "The WSL backend will stop. Threads and projects opened against WSL stay safe inside the distro, but they'll be unavailable in The Knife until you re-enable WSL." |
| 3879 | : pendingWslChange?.kind === "distro" |
| 3880 | ? "The Knife will restart the WSL backend on the new distro. Sessions still running on the current distro will be interrupted." |
| 3881 | : pendingWslChange?.kind === "enable" |
| 3882 | ? "Run the WSL backend alongside the Windows one, or stop the Windows backend and use only WSL? You can change this later from Settings." |
| 3883 | : pendingWslChange?.nextValue |
| 3884 | ? "The Knife will restart and start only the WSL backend. Your Windows-side projects won't be accessible until you turn this off again." |
| 3885 | : "The Knife will restart and bring the Windows backend back up alongside WSL."} |
| 3886 | </AlertDialogDescription> |
| 3887 | </AlertDialogHeader> |
| 3888 | <AlertDialogFooter> |
| 3889 | <AlertDialogClose |
| 3890 | disabled={isUpdatingWslBackend} |
| 3891 | render={<Button variant="outline" disabled={isUpdatingWslBackend} />} |
| 3892 | > |
| 3893 | Cancel |
| 3894 | </AlertDialogClose> |
| 3895 | {pendingWslChange?.kind === "enable" ? ( |
| 3896 | <> |
| 3897 | <Button |
| 3898 | variant="outline" |
| 3899 | onClick={() => handleConfirmEnableWsl("wsl-only")} |
| 3900 | disabled={isUpdatingWslBackend || !canManageLocalBackend} |
| 3901 | > |
| 3902 | {isUpdatingWslBackend ? ( |
| 3903 | <> |
| 3904 | <Spinner size="sm" /> |
| 3905 | Applying… |
| 3906 | </> |
| 3907 | ) : ( |
| 3908 | "Use only WSL" |
| 3909 | )} |
| 3910 | </Button> |
| 3911 | <Button |
| 3912 | variant="default" |
| 3913 | onClick={() => handleConfirmEnableWsl("both")} |
| 3914 | disabled={isUpdatingWslBackend || !canManageLocalBackend} |
| 3915 | > |
| 3916 | {isUpdatingWslBackend ? ( |
| 3917 | <> |
| 3918 | <Spinner size="sm" /> |
| 3919 | Applying… |
| 3920 | </> |
| 3921 | ) : ( |
| 3922 | "Run both backends" |
| 3923 | )} |
| 3924 | </Button> |
| 3925 | </> |
| 3926 | ) : ( |
| 3927 | <Button |
| 3928 | variant={ |
| 3929 | pendingWslChange?.kind === "disable" || |
| 3930 | (pendingWslChange?.kind === "wsl-only" && pendingWslChange.nextValue) |
| 3931 | ? "destructive" |
| 3932 | : "default" |
| 3933 | } |
| 3934 | onClick={handleConfirmWslChange} |
| 3935 | disabled={isUpdatingWslBackend || !canManageLocalBackend} |
| 3936 | > |
| 3937 | {isUpdatingWslBackend ? ( |
| 3938 | <> |
| 3939 | <Spinner size="sm" /> |
| 3940 | Applying… |
| 3941 | </> |
| 3942 | ) : pendingWslChange?.kind === "disable" ? ( |
| 3943 | pendingWslChange.wasWslOnly ? ( |
| 3944 | "Switch to Windows" |
| 3945 | ) : ( |
| 3946 | "Disable WSL" |
| 3947 | ) |
| 3948 | ) : pendingWslChange?.kind === "distro" ? ( |
| 3949 | "Switch distro" |
| 3950 | ) : pendingWslChange?.nextValue ? ( |
| 3951 | "Restart and enable" |
| 3952 | ) : ( |
| 3953 | "Restart and disable" |
| 3954 | )} |
| 3955 | </Button> |
| 3956 | )} |
| 3957 | </AlertDialogFooter> |
| 3958 | </AlertDialogPopup> |
| 3959 | </AlertDialog> |
| 3960 | <AlertDialog |
| 3961 | open={disableTailscaleServeDialogOpen && canManageLocalBackend} |
| 3962 | onOpenChange={(open) => { |
| 3963 | if (isUpdatingTailscaleServe) return; |
| 3964 | setDisableTailscaleServeDialogOpen(open); |
| 3965 | }} |
| 3966 | > |
| 3967 | <AlertDialogPopup> |
| 3968 | <AlertDialogHeader> |
| 3969 | <AlertDialogTitle>Disable Tailscale HTTPS?</AlertDialogTitle> |
| 3970 | <AlertDialogDescription> |
| 3971 | The Knife will restart the local backend without Tailscale Serve. |
| 3972 | </AlertDialogDescription> |
| 3973 | </AlertDialogHeader> |
| 3974 | <AlertDialogFooter> |
| 3975 | <AlertDialogClose |
| 3976 | disabled={isUpdatingTailscaleServe} |
| 3977 | render={<Button variant="outline" disabled={isUpdatingTailscaleServe} />} |
| 3978 | > |
| 3979 | Cancel |
| 3980 | </AlertDialogClose> |
| 3981 | <Button |
| 3982 | variant="destructive" |
| 3983 | onClick={() => void handleConfirmTailscaleServeDisable()} |
| 3984 | disabled={isUpdatingTailscaleServe || !canManageLocalBackend} |
| 3985 | > |
| 3986 | {isUpdatingTailscaleServe ? ( |
| 3987 | <> |
| 3988 | <Spinner size="sm" /> |
| 3989 | Restarting… |
| 3990 | </> |
| 3991 | ) : ( |
| 3992 | "Restart and disable" |
| 3993 | )} |
| 3994 | </Button> |
| 3995 | </AlertDialogFooter> |
| 3996 | </AlertDialogPopup> |
| 3997 | </AlertDialog> |
| 3998 | <Dialog |
| 3999 | open={pendingTailscaleServeEndpoint !== null} |
| 4000 | onOpenChange={(open) => { |
| 4001 | if (isUpdatingTailscaleServe) return; |
| 4002 | if (!open) setPendingTailscaleServeEndpoint(null); |
| 4003 | }} |
| 4004 | > |
| 4005 | <DialogPopup className="max-w-md"> |
| 4006 | <DialogHeader> |
| 4007 | <DialogTitle>Set up Tailscale HTTPS?</DialogTitle> |
| 4008 | <DialogDescription> |
| 4009 | The Knife will restart the local backend with Tailscale Serve enabled and ask |
| 4010 | Tailscale to proxy HTTPS traffic to this backend. |
| 4011 | </DialogDescription> |
| 4012 | </DialogHeader> |
| 4013 | <DialogPanel> |
| 4014 | <label className="block"> |
| 4015 | <span className="text-sm font-medium text-foreground">HTTPS port</span> |
| 4016 | <Input |
| 4017 | className="mt-2" |
| 4018 | type="number" |
| 4019 | inputMode="numeric" |
| 4020 | min={1} |
| 4021 | max={65_535} |
| 4022 | step={1} |
| 4023 | value={tailscaleServePortInput} |
| 4024 | onChange={(event) => setTailscaleServePortInput(event.target.value)} |
| 4025 | disabled={isUpdatingTailscaleServe} |
| 4026 | /> |
| 4027 | </label> |
| 4028 | {!isTailscaleServePortValid ? ( |
| 4029 | <p className="mt-2 text-xs text-destructive">Enter a port from 1 to 65535.</p> |
| 4030 | ) : null} |
| 4031 | <div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2"> |
| 4032 | <p className="text-xs font-medium text-muted-foreground">HTTPS endpoint</p> |
| 4033 | <Tooltip> |
| 4034 | <TooltipTrigger |
| 4035 | render={ |
| 4036 | <p className="mt-1 truncate text-sm text-foreground"> |
| 4037 | {pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"} |
| 4038 | </p> |
| 4039 | } |
| 4040 | /> |
| 4041 | {pendingTailscaleServeBaseUrl ? ( |
| 4042 | <TooltipPopup side="top">{pendingTailscaleServeBaseUrl}</TooltipPopup> |
| 4043 | ) : null} |
| 4044 | </Tooltip> |
| 4045 | </div> |
| 4046 | </DialogPanel> |
| 4047 | <DialogFooter> |
| 4048 | <DialogClose |
| 4049 | disabled={isUpdatingTailscaleServe} |
| 4050 | render={<Button variant="outline" disabled={isUpdatingTailscaleServe} />} |
| 4051 | > |
| 4052 | Cancel |
| 4053 | </DialogClose> |
| 4054 | <Button |
| 4055 | onClick={() => void handleConfirmTailscaleServeSetup()} |
| 4056 | disabled={isUpdatingTailscaleServe || !isTailscaleServePortValid} |
| 4057 | > |
| 4058 | {isUpdatingTailscaleServe ? ( |
| 4059 | <> |
| 4060 | <Spinner size="sm" /> |
| 4061 | Restarting… |
| 4062 | </> |
| 4063 | ) : ( |
| 4064 | "Enable" |
| 4065 | )} |
| 4066 | </Button> |
| 4067 | </DialogFooter> |
| 4068 | </DialogPopup> |
| 4069 | </Dialog> |
| 4070 | </> |
| 4071 | ) : ( |
| 4072 | <SettingsSection {...searchableSetting("connections-environment")}> |
| 4073 | <SettingsRow |
| 4074 | title="No environment selected" |
| 4075 | description="Connect an environment to view its settings and access." |
| 4076 | /> |
| 4077 | {/* A turned-off local environment leaves no primary environment, so its switch lives here too. */} |
| 4078 | <LocalEnvironmentSetting /> |
| 4079 | {canReadRelay || canManageRelay ? ( |
| 4080 | <CloudLinkRow canReadRelay={canReadRelay} canManageRelay={canManageRelay} /> |
| 4081 | ) : null} |
| 4082 | </SettingsSection> |
| 4083 | )} |
| 4084 | </> |
| 4085 | ); |
| 4086 | |
| 4087 | return ( |
| 4088 | <SettingsPageContainer> |
| 4089 | {primarySettings} |
| 4090 | <SelfHostedServerSettings /> |
| 4091 | <SettingsSection |
| 4092 | {...searchableSetting("remote-environments")} |
| 4093 | title="Environments" |
| 4094 | headerAction={ |
| 4095 | <div className="flex items-center gap-1"> |
| 4096 | {savedServerUpdateTargets.length > 0 ? ( |
| 4097 | <ServerUpdatesAction targets={savedServerUpdateTargets} variant="ghost-muted" /> |
| 4098 | ) : null} |
| 4099 | <Dialog |
| 4100 | open={addBackendDialogOpen} |
| 4101 | onOpenChange={(open) => { |
| 4102 | setAddBackendDialogOpen(open); |
| 4103 | if (open) { |
| 4104 | setRouteTarget(null); |
| 4105 | setDirectPairing(false); |
| 4106 | } else { |
| 4107 | setSavedBackendError(null); |
| 4108 | } |
| 4109 | }} |
| 4110 | > |
| 4111 | <Tooltip> |
| 4112 | <TooltipTrigger |
| 4113 | render={ |
| 4114 | <DialogTrigger |
| 4115 | render={ |
| 4116 | <Button size="xs" variant="ghost-muted" aria-label="Add environment"> |
| 4117 | <PlusIcon className="size-3" /> |
| 4118 | <span>Add environment</span> |
| 4119 | </Button> |
| 4120 | } |
| 4121 | /> |
| 4122 | } |
| 4123 | /> |
| 4124 | <TooltipPopup side="top">Add environment</TooltipPopup> |
| 4125 | </Tooltip> |
| 4126 | <DialogPopup className="max-h-[80dvh] sm:max-w-3xl"> |
| 4127 | <DialogHeader> |
| 4128 | <DialogTitle> |
| 4129 | {routeTarget ? `Add a route to ${routeTarget.label}` : "Connect"} |
| 4130 | </DialogTitle> |
| 4131 | <DialogDescription> |
| 4132 | {routeTarget |
| 4133 | ? "Pair this machine again over another address, such as its Tailscale name. It joins the existing routes instead of adding a second machine." |
| 4134 | : "Connect to your computers through an account or your own server."} |
| 4135 | </DialogDescription> |
| 4136 | </DialogHeader> |
| 4137 | <DialogPanel> |
| 4138 | {!routeTarget && !directPairing ? ( |
| 4139 | <RemoteConnectionFlow |
| 4140 | onDirect={() => setDirectPairing(true)} |
| 4141 | onConnected={() => setAddBackendDialogOpen(false)} |
| 4142 | onCloudSelected={() => setAddBackendDialogOpen(false)} |
| 4143 | /> |
| 4144 | ) : ( |
| 4145 | <div className="space-y-4"> |
| 4146 | {!routeTarget ? ( |
| 4147 | <Button variant="ghost" size="sm" onClick={() => setDirectPairing(false)}> |
| 4148 | Back |
| 4149 | </Button> |
| 4150 | ) : null} |
| 4151 | <div className="grid gap-3 sm:grid-cols-2"> |
| 4152 | {renderConnectionModeCard({ |
| 4153 | mode: "remote", |
| 4154 | title: "Remote link", |
| 4155 | description: "Enter a backend host and pairing code.", |
| 4156 | icon: <ChevronsLeftRightEllipsisIcon aria-hidden className="size-4" />, |
| 4157 | })} |
| 4158 | {desktopBridge |
| 4159 | ? renderConnectionModeCard({ |
| 4160 | mode: "ssh", |
| 4161 | title: "SSH", |
| 4162 | description: |
| 4163 | "Use local SSH config, agent, and tunnels for the backend.", |
| 4164 | icon: <TerminalIcon aria-hidden className="size-4" />, |
| 4165 | }) |
| 4166 | : null} |
| 4167 | </div> |
| 4168 | <AnimatedHeight> |
| 4169 | {savedBackendMode === "ssh" ? renderSshFields() : renderRemoteModeBody()} |
| 4170 | </AnimatedHeight> |
| 4171 | </div> |
| 4172 | )} |
| 4173 | </DialogPanel> |
| 4174 | </DialogPopup> |
| 4175 | </Dialog> |
| 4176 | </div> |
| 4177 | } |
| 4178 | > |
| 4179 | {listedEnvironments.map((environment) => ( |
| 4180 | <SavedBackendListRow |
| 4181 | key={environment.environmentId} |
| 4182 | environment={environment} |
| 4183 | removingEnvironmentId={removingSavedEnvironmentId} |
| 4184 | onSetEnabled={handleSetSavedBackendEnabled} |
| 4185 | onRemove={handleRemoveSavedBackend} |
| 4186 | onAddRoute={(target) => { |
| 4187 | setRouteTarget(target); |
| 4188 | setSavedBackendError(null); |
| 4189 | setAddBackendDialogOpen(true); |
| 4190 | }} |
| 4191 | /> |
| 4192 | ))} |
| 4193 | <CloudRemoteEnvironmentRows |
| 4194 | primaryEnvironmentId={primaryEnvironmentId} |
| 4195 | savedEnvironments={savedEnvironments} |
| 4196 | /> |
| 4197 | </SettingsSection> |
| 4198 | {hasCloudPublicConfig() ? ( |
| 4199 | <CloudAuthBoundary> |
| 4200 | <RemoveT3ConnectEnvironmentDialog |
| 4201 | environmentLabel={pendingT3ConnectRemoval?.label ?? null} |
| 4202 | onCancel={() => setPendingT3ConnectRemoval(null)} |
| 4203 | onConfirm={() => { |
| 4204 | if (!pendingT3ConnectRemoval) return; |
| 4205 | setPendingT3ConnectRemoval(null); |
| 4206 | void removeSavedBackend(pendingT3ConnectRemoval); |
| 4207 | }} |
| 4208 | /> |
| 4209 | </CloudAuthBoundary> |
| 4210 | ) : null} |
| 4211 | <LoadBalancingSettings environments={loadBalancingEnvironments} /> |
| 4212 | <GitHubRoutingSettings environments={loadBalancingEnvironments} /> |
| 4213 | </SettingsPageContainer> |
| 4214 | ); |
| 4215 | } |