| 1 | import { |
| 2 | isAtomCommandInterrupted, |
| 3 | squashAtomCommandFailure, |
| 4 | type AtomCommandResult, |
| 5 | } from "@t3tools/client-runtime/state/runtime"; |
| 6 | import type { |
| 7 | EnvironmentId, |
| 8 | ProviderAuthRespondInput, |
| 9 | ProviderAuthResponse, |
| 10 | ProviderInstanceId, |
| 11 | ServerProvider, |
| 12 | } from "@t3tools/contracts"; |
| 13 | import { lazy, Suspense, useRef, useState } from "react"; |
| 14 | import { CopyIcon } from "lucide-react"; |
| 15 | |
| 16 | import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; |
| 17 | import { ensureLocalApi } from "../../localApi"; |
| 18 | import { useEnvironmentQuery } from "../../state/query"; |
| 19 | import { serverEnvironment } from "../../state/server"; |
| 20 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 21 | import { Button } from "../ui/button"; |
| 22 | import { Input } from "../ui/input"; |
| 23 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 24 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 25 | import { SettingsRow } from "./settingsLayout"; |
| 26 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; |
| 27 | |
| 28 | const ProviderAuthTerminal = lazy(() => import("./ProviderAuthTerminal")); |
| 29 | |
| 30 | /** All actions target the provider's environment, even when the browser is on another device. */ |
| 31 | export function ProviderAuthenticationSection({ |
| 32 | environmentId, |
| 33 | environmentLabel, |
| 34 | instanceId, |
| 35 | provider, |
| 36 | readOnly, |
| 37 | }: { |
| 38 | readonly environmentId: EnvironmentId; |
| 39 | readonly environmentLabel: string; |
| 40 | readonly instanceId: ProviderInstanceId; |
| 41 | readonly provider: ServerProvider; |
| 42 | readonly readOnly: boolean; |
| 43 | }) { |
| 44 | const target = { environmentId, input: { instanceId } }; |
| 45 | const query = useEnvironmentQuery(serverEnvironment.providerAuthState(target)); |
| 46 | const commands = { reportFailure: false, reportDefect: false }; |
| 47 | const start = useAtomCommand(serverEnvironment.startProviderAuth, commands); |
| 48 | const respond = useAtomCommand(serverEnvironment.respondProviderAuth, commands); |
| 49 | const complete = useAtomCommand(serverEnvironment.completeProviderAuth, commands); |
| 50 | const cancel = useAtomCommand(serverEnvironment.cancelProviderAuth, commands); |
| 51 | const logout = useAtomCommand(serverEnvironment.logoutProviderAuth, commands); |
| 52 | const [methodId, setMethodId] = useState(""); |
| 53 | const [draft, setDraft] = useState({ id: "", values: {} as Record<string, string> }); |
| 54 | const [error, setError] = useState<string | null>(null); |
| 55 | const [pending, setPending] = useState(false); |
| 56 | const pendingRef = useRef(false); |
| 57 | const terminalQueue = useRef<ProviderAuthRespondInput[]>([]); |
| 58 | const terminalSending = useRef(false); |
| 59 | const auth = query.data; |
| 60 | const interaction = auth?.interaction; |
| 61 | const active = |
| 62 | auth?.phase === "starting" || auth?.phase === "waiting" || auth?.phase === "verifying"; |
| 63 | const signedIn = |
| 64 | provider.auth.status === "authenticated" || |
| 65 | (provider.auth.status === "unknown" && auth?.phase === "succeeded"); |
| 66 | const isDiscovering = |
| 67 | provider.driver === "acpRegistry" && |
| 68 | !active && |
| 69 | !signedIn && |
| 70 | !query.error && |
| 71 | auth?.methods === undefined; |
| 72 | const needsExternalSetup = |
| 73 | !active && |
| 74 | !signedIn && |
| 75 | (provider.setup?.canAuthenticate === false || |
| 76 | (provider.driver === "acpRegistry" && auth?.methods?.length === 0)); |
| 77 | const accountDescription = active |
| 78 | ? auth?.phase === "starting" |
| 79 | ? "Starting sign-in…" |
| 80 | : auth?.phase === "verifying" |
| 81 | ? "Checking your account…" |
| 82 | : interaction?.type === "terminal" |
| 83 | ? "Complete sign-in in the terminal below." |
| 84 | : interaction?.type === "credentials" |
| 85 | ? "Enter your credentials below." |
| 86 | : "Finish signing in in your browser." |
| 87 | : signedIn |
| 88 | ? "Signed in." |
| 89 | : isDiscovering |
| 90 | ? "Discovering sign-in methods…" |
| 91 | : needsExternalSetup |
| 92 | ? "No in-app sign-in advertised. Follow the provider's docs to finish setup." |
| 93 | : `Sign in on ${environmentLabel}.`; |
| 94 | const statusMessage = auth?.phase === "failed" ? auth.message : null; |
| 95 | const disabled = readOnly || pending || query.error !== null || isDiscovering; |
| 96 | const draftId = `${auth?.flowId ?? ""}:${interaction?.id ?? ""}`; |
| 97 | const values = draft.id === draftId ? draft.values : {}; |
| 98 | const url = |
| 99 | interaction?.type === "browser" || interaction?.type === "deviceCode" |
| 100 | ? interaction.url |
| 101 | : auth?.authorizationUrl; |
| 102 | |
| 103 | async function run(command: () => Promise<AtomCommandResult<unknown, unknown>>) { |
| 104 | if (pendingRef.current) return false; |
| 105 | pendingRef.current = true; |
| 106 | setPending(true); |
| 107 | setError(null); |
| 108 | let succeeded = false; |
| 109 | try { |
| 110 | const result = await command(); |
| 111 | if (result._tag === "Success") succeeded = true; |
| 112 | else if (!isAtomCommandInterrupted(result)) { |
| 113 | const failure = squashAtomCommandFailure(result); |
| 114 | setError( |
| 115 | failure instanceof Error ? failure.message : "Provider sign-in failed. Try again.", |
| 116 | ); |
| 117 | } |
| 118 | } catch { |
| 119 | setError("Provider sign-in failed. Try again."); |
| 120 | } |
| 121 | pendingRef.current = false; |
| 122 | setPending(false); |
| 123 | return succeeded; |
| 124 | } |
| 125 | |
| 126 | async function send(response: ProviderAuthResponse) { |
| 127 | if (!auth?.flowId || !interaction) return false; |
| 128 | return run(() => |
| 129 | respond({ |
| 130 | environmentId, |
| 131 | input: { instanceId, flowId: auth.flowId!, interactionId: interaction.id, response }, |
| 132 | }), |
| 133 | ); |
| 134 | } |
| 135 | |
| 136 | async function openBrowser() { |
| 137 | if (!url || readOnly) return; |
| 138 | const requiresConsent = interaction?.type === "browser" && interaction.requiresConsent; |
| 139 | // Browsers block tabs opened after an await, so the web build reserves one |
| 140 | // while the environment records consent. |
| 141 | const pending = requiresConsent && !window.desktopBridge ? window.open("", "_blank") : null; |
| 142 | if (pending) pending.opener = null; |
| 143 | try { |
| 144 | // Consent is checked on the environment before opening a provider URL locally. |
| 145 | if (requiresConsent && !(await send({ type: "browser", action: "accept" }))) { |
| 146 | pending?.close(); |
| 147 | return; |
| 148 | } |
| 149 | if (pending) pending.location.href = url; |
| 150 | else await ensureLocalApi().shell.openExternal(url); |
| 151 | setError(null); |
| 152 | } catch { |
| 153 | pending?.close(); |
| 154 | setError("Could not open the sign-in page. Copy the link and open it in your browser."); |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | function updateDraft(name: string, value: string) { |
| 159 | setDraft({ id: draftId, values: { ...values, [name]: value } }); |
| 160 | } |
| 161 | |
| 162 | return ( |
| 163 | <SettingsRow |
| 164 | title="Account" |
| 165 | description={ |
| 166 | signedIn && !active && provider.auth.email?.trim() ? ( |
| 167 | <span> |
| 168 | Signed in as{" "} |
| 169 | <RedactedSensitiveText |
| 170 | key={provider.auth.email} |
| 171 | value={provider.auth.email} |
| 172 | ariaLabel="Toggle account email visibility" |
| 173 | revealTooltip="Click to reveal email" |
| 174 | hideTooltip="Click to hide email" |
| 175 | className="max-w-full truncate" |
| 176 | /> |
| 177 | </span> |
| 178 | ) : ( |
| 179 | <span role="status">{accountDescription}</span> |
| 180 | ) |
| 181 | } |
| 182 | status={ |
| 183 | statusMessage ? ( |
| 184 | <p role="status" className="[overflow-wrap:anywhere]"> |
| 185 | {statusMessage} |
| 186 | </p> |
| 187 | ) : undefined |
| 188 | } |
| 189 | control={ |
| 190 | <div className="flex min-w-0 flex-wrap items-center gap-1.5"> |
| 191 | {!active && (auth?.methods?.length ?? 0) > 1 ? ( |
| 192 | <Select |
| 193 | value={auth?.methods?.some((method) => method.id === methodId) ? methodId : ""} |
| 194 | disabled={disabled} |
| 195 | onValueChange={(value) => setMethodId(value ?? "")} |
| 196 | > |
| 197 | <SelectTrigger size="sm" aria-label="Sign-in method" className="w-44"> |
| 198 | <SelectValue> |
| 199 | {auth?.methods?.find((method) => method.id === methodId)?.name ?? |
| 200 | "Provider default"} |
| 201 | </SelectValue> |
| 202 | </SelectTrigger> |
| 203 | <SelectPopup> |
| 204 | <SelectItem value="">Provider default</SelectItem> |
| 205 | {auth?.methods?.map((method) => ( |
| 206 | <SelectItem key={method.id} value={method.id}> |
| 207 | {method.name} |
| 208 | </SelectItem> |
| 209 | ))} |
| 210 | </SelectPopup> |
| 211 | </Select> |
| 212 | ) : null} |
| 213 | {url ? ( |
| 214 | <> |
| 215 | <Button |
| 216 | size="sm" |
| 217 | variant="outline" |
| 218 | disabled={disabled} |
| 219 | onClick={() => void openBrowser()} |
| 220 | > |
| 221 | Open browser |
| 222 | </Button> |
| 223 | <Tooltip> |
| 224 | <TooltipTrigger |
| 225 | render={ |
| 226 | <Button |
| 227 | aria-label="Copy sign-in link" |
| 228 | size="icon-sm" |
| 229 | variant="ghost-muted" |
| 230 | disabled={disabled} |
| 231 | onClick={() => { |
| 232 | // Copy inside the click so the clipboard keeps the user |
| 233 | // activation; the link only works once consent is recorded. |
| 234 | const copied = writeTextToClipboard(url, "Provider sign-in link"); |
| 235 | void (async () => { |
| 236 | await copied; |
| 237 | if (interaction?.type === "browser" && interaction.requiresConsent) |
| 238 | await send({ type: "browser", action: "accept" }); |
| 239 | })().catch(() => setError("Could not copy the sign-in link.")); |
| 240 | }} |
| 241 | > |
| 242 | <CopyIcon /> |
| 243 | </Button> |
| 244 | } |
| 245 | /> |
| 246 | <TooltipPopup>Copy sign-in link</TooltipPopup> |
| 247 | </Tooltip> |
| 248 | </> |
| 249 | ) : null} |
| 250 | <> |
| 251 | {needsExternalSetup && provider.setup?.documentationUrl ? ( |
| 252 | <Button |
| 253 | size="sm" |
| 254 | variant="outline" |
| 255 | render={ |
| 256 | <a href={provider.setup.documentationUrl} target="_blank" rel="noreferrer" /> |
| 257 | } |
| 258 | > |
| 259 | Open docs |
| 260 | </Button> |
| 261 | ) : active && auth?.flowId ? ( |
| 262 | <Button |
| 263 | size="sm" |
| 264 | variant="ghost-muted" |
| 265 | aria-label="Cancel sign-in" |
| 266 | disabled={disabled} |
| 267 | onClick={() => |
| 268 | void run(() => |
| 269 | cancel({ environmentId, input: { instanceId, flowId: auth.flowId! } }), |
| 270 | ) |
| 271 | } |
| 272 | > |
| 273 | Cancel |
| 274 | </Button> |
| 275 | ) : !active && !needsExternalSetup && provider.setup?.canAuthenticate !== false ? ( |
| 276 | <Button |
| 277 | size="sm" |
| 278 | variant="outline" |
| 279 | disabled={disabled || !provider.enabled || !provider.installed || !auth} |
| 280 | onClick={() => |
| 281 | void run(() => |
| 282 | start({ |
| 283 | environmentId, |
| 284 | input: { |
| 285 | instanceId, |
| 286 | ...(methodId && auth?.methods?.some((method) => method.id === methodId) |
| 287 | ? { methodId } |
| 288 | : {}), |
| 289 | }, |
| 290 | }), |
| 291 | ) |
| 292 | } |
| 293 | > |
| 294 | {signedIn |
| 295 | ? "Change account" |
| 296 | : auth?.phase === "failed" || auth?.phase === "cancelled" |
| 297 | ? "Retry sign-in" |
| 298 | : "Sign in"} |
| 299 | </Button> |
| 300 | ) : null} |
| 301 | {!active && signedIn && (provider.auth.canLogout ?? provider.setup?.canAuthenticate) ? ( |
| 302 | <Button |
| 303 | size="sm" |
| 304 | variant="ghost" |
| 305 | disabled={disabled || !auth} |
| 306 | onClick={() => { |
| 307 | void ensureLocalApi() |
| 308 | .dialogs.confirm( |
| 309 | `Sign out of ${provider.displayName ?? provider.driver} on ${environmentLabel}? This stops running threads that share this sign-in. Thread history is kept.`, |
| 310 | ) |
| 311 | .then((confirmed) => { |
| 312 | if (confirmed) void run(() => logout(target)); |
| 313 | }); |
| 314 | }} |
| 315 | > |
| 316 | Sign out |
| 317 | </Button> |
| 318 | ) : null} |
| 319 | </> |
| 320 | </div> |
| 321 | } |
| 322 | > |
| 323 | {interaction?.type === "terminal" || |
| 324 | interaction?.type === "credentials" || |
| 325 | interaction?.type === "deviceCode" || |
| 326 | (url && (interaction?.type === "browser" ? interaction.acceptsCallback : !interaction)) || |
| 327 | error || |
| 328 | query.error ? ( |
| 329 | <> |
| 330 | {interaction?.type === "deviceCode" ? ( |
| 331 | <p className="py-2 text-sm text-muted-foreground"> |
| 332 | Enter code{" "} |
| 333 | <code className="select-all font-mono text-foreground">{interaction.userCode}</code>{" "} |
| 334 | in your browser. |
| 335 | </p> |
| 336 | ) : null} |
| 337 | {interaction?.type === "terminal" ? ( |
| 338 | <div className="py-2"> |
| 339 | <Suspense |
| 340 | fallback={ |
| 341 | <p className="text-xs text-muted-foreground">Loading sign-in terminal…</p> |
| 342 | } |
| 343 | > |
| 344 | <ProviderAuthTerminal |
| 345 | key={`${auth?.flowId}:${interaction.id}`} |
| 346 | output={interaction.output} |
| 347 | outputOffset={interaction.outputOffset} |
| 348 | onResponse={(response) => { |
| 349 | if (readOnly || !auth?.flowId) return; |
| 350 | for ( |
| 351 | let offset = 0; |
| 352 | offset < Math.max(1, response.data.length); |
| 353 | offset += 4_096 |
| 354 | ) { |
| 355 | terminalQueue.current.push({ |
| 356 | instanceId, |
| 357 | flowId: auth.flowId, |
| 358 | interactionId: interaction.id, |
| 359 | response: { |
| 360 | ...response, |
| 361 | data: response.data.slice(offset, offset + 4_096), |
| 362 | }, |
| 363 | }); |
| 364 | } |
| 365 | if (terminalSending.current) return; |
| 366 | terminalSending.current = true; |
| 367 | void (async () => { |
| 368 | while (terminalQueue.current.length > 0) { |
| 369 | const input = terminalQueue.current.shift()!; |
| 370 | const result = await respond({ environmentId, input }); |
| 371 | if (result._tag !== "Success") { |
| 372 | terminalQueue.current = []; |
| 373 | if (!isAtomCommandInterrupted(result)) |
| 374 | setError("The provider sign-in terminal is no longer available."); |
| 375 | break; |
| 376 | } |
| 377 | } |
| 378 | })() |
| 379 | .catch(() => { |
| 380 | terminalQueue.current = []; |
| 381 | setError("Could not send input to the provider sign-in terminal."); |
| 382 | }) |
| 383 | .finally(() => { |
| 384 | terminalSending.current = false; |
| 385 | }); |
| 386 | }} |
| 387 | /> |
| 388 | </Suspense> |
| 389 | </div> |
| 390 | ) : null} |
| 391 | {interaction?.type === "credentials" ? ( |
| 392 | <form |
| 393 | className="grid gap-2 py-2 text-sm leading-normal" |
| 394 | onSubmit={(event) => { |
| 395 | event.preventDefault(); |
| 396 | void send({ type: "credentials", values }).then((sent) => { |
| 397 | if (sent) setDraft({ id: "", values: {} }); |
| 398 | }); |
| 399 | }} |
| 400 | > |
| 401 | {interaction.fields.map((field) => ( |
| 402 | <label key={field.name} className="grid gap-1"> |
| 403 | {field.label} |
| 404 | <Input |
| 405 | size="sm" |
| 406 | type={field.secret ? "password" : "text"} |
| 407 | autoComplete="off" |
| 408 | value={values[field.name] ?? ""} |
| 409 | disabled={disabled} |
| 410 | maxLength={16_384} |
| 411 | onChange={(event) => updateDraft(field.name, event.target.value)} |
| 412 | /> |
| 413 | </label> |
| 414 | ))} |
| 415 | <Button |
| 416 | type="submit" |
| 417 | size="sm" |
| 418 | variant="outline" |
| 419 | className="w-fit" |
| 420 | disabled={disabled} |
| 421 | > |
| 422 | Connect |
| 423 | </Button> |
| 424 | </form> |
| 425 | ) : null} |
| 426 | {url && (interaction?.type === "browser" ? interaction.acceptsCallback : !interaction) ? ( |
| 427 | <form |
| 428 | className="grid gap-2 py-2 text-sm leading-normal" |
| 429 | onSubmit={(event) => { |
| 430 | event.preventDefault(); |
| 431 | if (!auth?.flowId || !values.callback?.trim()) return; |
| 432 | void run(() => |
| 433 | complete({ |
| 434 | environmentId, |
| 435 | input: { instanceId, flowId: auth.flowId!, callbackUrl: values.callback! }, |
| 436 | }), |
| 437 | ).then((sent) => { |
| 438 | if (sent) setDraft({ id: "", values: {} }); |
| 439 | }); |
| 440 | }} |
| 441 | > |
| 442 | <label className="grid gap-1"> |
| 443 | If the final localhost page does not load, paste its full URL here. |
| 444 | <Input |
| 445 | size="sm" |
| 446 | id={`provider-callback-${instanceId}`} |
| 447 | type="url" |
| 448 | autoComplete="off" |
| 449 | value={values.callback ?? ""} |
| 450 | disabled={disabled} |
| 451 | maxLength={16_384} |
| 452 | onChange={(event) => updateDraft("callback", event.target.value)} |
| 453 | /> |
| 454 | </label> |
| 455 | <Button |
| 456 | type="submit" |
| 457 | size="sm" |
| 458 | variant="outline" |
| 459 | className="w-fit" |
| 460 | disabled={disabled || !values.callback?.trim()} |
| 461 | > |
| 462 | Continue |
| 463 | </Button> |
| 464 | </form> |
| 465 | ) : null} |
| 466 | {error || query.error ? ( |
| 467 | <p role="alert" className="py-2 text-xs text-destructive"> |
| 468 | {error ?? query.error} |
| 469 | </p> |
| 470 | ) : null} |
| 471 | </> |
| 472 | ) : null} |
| 473 | </SettingsRow> |
| 474 | ); |
| 475 | } |