Skip to content
Source
Sign inSign up
Open History to see the last commit

ProviderAuthenticationSection.tsx

488 lines18.4 KB

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