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

ProviderAuthenticationSection.tsx

488 lines18.3 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 { ensureLocalApi } from "../../localApi";
18import { useEnvironmentQuery } from "../../state/query";
19import { serverEnvironment } from "../../state/server";
20import { useAtomCommand } from "../../state/use-atom-command";
21import { Button } from "../ui/button";
22import { Input } from "../ui/input";
23import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
24import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
25import { SettingsRow } from "./settingsLayout";
26import { RedactedSensitiveText } from "./RedactedSensitiveText";
27​
28const ProviderAuthTerminal = lazy(() => import("./ProviderAuthTerminal"));
29​
30export function ProviderAuthenticationSection({
31 environmentId,
32 environmentLabel,
33 instanceId,
34 provider,
35 readOnly,
36}: {
37 readonly environmentId: EnvironmentId;
38 readonly environmentLabel: string;
39 readonly instanceId: ProviderInstanceId;
40 readonly provider: ServerProvider;
41 readonly readOnly: boolean;
42}) {
43 const target = { environmentId, input: { instanceId } };
44 const query = useEnvironmentQuery(serverEnvironment.providerAuthState(target));
45 const commands = { reportFailure: false, reportDefect: false };
46 const start = useAtomCommand(serverEnvironment.startProviderAuth, commands);
47 const respond = useAtomCommand(serverEnvironment.respondProviderAuth, commands);
48 const complete = useAtomCommand(serverEnvironment.completeProviderAuth, commands);
49 const cancel = useAtomCommand(serverEnvironment.cancelProviderAuth, commands);
50 const logout = useAtomCommand(serverEnvironment.logoutProviderAuth, commands);
51 const [methodId, setMethodId] = useState("");
52 const [draft, setDraft] = useState({ id: "", values: {} as Record<string, string> });
53 const [error, setError] = useState<string | null>(null);
54 const [pending, setPending] = useState(false);
55 const pendingRef = useRef(false);
56 const terminalQueue = useRef<ProviderAuthRespondInput[]>([]);
57 const terminalSending = useRef(false);
58 const auth = query.data;
59 const interaction = auth?.interaction;
60 const active =
61 auth?.phase === "starting" || auth?.phase === "waiting" || auth?.phase === "verifying";
62 const signedIn =
63 provider.auth.status === "authenticated" ||
64 (provider.auth.status === "unknown" && auth?.phase === "succeeded");
65 const isDiscovering =
66 provider.driver === "acpRegistry" &&
67 !active &&
68 !signedIn &&
69 !query.error &&
70 auth?.methods === undefined;
71 const needsExternalSetup =
72 !active &&
73 !signedIn &&
74 (provider.setup?.canAuthenticate === false ||
75 (provider.driver === "acpRegistry" && auth?.methods?.length === 0));
76 const usesCredentials =
77 provider.driver === "antigravity" &&
78 (provider.auth.type === "gemini-api-key" || provider.auth.type === "agent-platform");
79 const accountDescription = active
80 ? auth?.phase === "starting"
81 ? (auth.message ?? (usesCredentials ? "Connecting…" : "Starting sign-in…"))
82 : auth?.phase === "verifying"
83 ? usesCredentials
84 ? "Checking credentials and loading models…"
85 : "Checking your account…"
86 : interaction?.type === "terminal"
87 ? "Complete sign-in in the terminal below."
88 : interaction?.type === "credentials"
89 ? "Enter your credentials below."
90 : "Finish signing in in your browser."
91 : signedIn
92 ? "Signed in."
93 : isDiscovering
94 ? "Discovering sign-in methods…"
95 : needsExternalSetup
96 ? "No in-app sign-in advertised. Follow the provider's docs to finish setup."
97 : usesCredentials
98 ? `Connect on ${environmentLabel} to load models.`
99 : `Sign in on ${environmentLabel}.`;
100 const statusMessage = auth?.phase === "failed" ? auth.message : null;
101 const disabled = readOnly || pending || query.error !== null || isDiscovering;
102 const draftId = `${auth?.flowId ?? ""}:${interaction?.id ?? ""}`;
103 const values = draft.id === draftId ? draft.values : {};
104 const url =
105 interaction?.type === "browser" || interaction?.type === "deviceCode"
106 ? interaction.url
107 : auth?.authorizationUrl;
108​
109 async function run(command: () => Promise<AtomCommandResult<unknown, unknown>>) {
110 if (pendingRef.current) return false;
111 pendingRef.current = true;
112 setPending(true);
113 setError(null);
114 let succeeded = false;
115 try {
116 const result = await command();
117 if (result._tag === "Success") succeeded = true;
118 else if (!isAtomCommandInterrupted(result)) {
119 const failure = squashAtomCommandFailure(result);
120 setError(
121 failure instanceof Error ? failure.message : "Provider sign-in failed. Try again.",
122 );
123 }
124 } catch {
125 setError("Provider sign-in failed. Try again.");
126 }
127 pendingRef.current = false;
128 setPending(false);
129 return succeeded;
130 }
131​
132 async function send(response: ProviderAuthResponse) {
133 if (!auth?.flowId || !interaction) return false;
134 return run(() =>
135 respond({
136 environmentId,
137 input: { instanceId, flowId: auth.flowId!, interactionId: interaction.id, response },
138 }),
139 );
140 }
141​
142 async function openBrowser() {
143 if (!url || readOnly) return;
144 const requiresConsent = interaction?.type === "browser" && interaction.requiresConsent;
145 // Reserve tabs before awaiting consent; browsers block later opens.
146 const pending = requiresConsent && !window.desktopBridge ? window.open("", "_blank") : null;
147 if (pending) pending.opener = null;
148 try {
149 if (requiresConsent && !(await send({ type: "browser", action: "accept" }))) {
150 pending?.close();
151 return;
152 }
153 if (pending) pending.location.href = url;
154 else await ensureLocalApi().shell.openExternal(url);
155 setError(null);
156 } catch {
157 pending?.close();
158 setError("Could not open the sign-in page. Copy the link and open it in your browser.");
159 }
160 }
161​
162 function updateDraft(name: string, value: string) {
163 setDraft({ id: draftId, values: { ...values, [name]: value } });
164 }
165​
166 return (
167 <SettingsRow
168 title="Account"
169 description={
170 signedIn && !active && provider.auth.email?.trim() ? (
171 <span>
172 Signed in as{" "}
173 <RedactedSensitiveText
174 key={provider.auth.email}
175 value={provider.auth.email}
176 ariaLabel="Toggle account email visibility"
177 revealTooltip="Click to reveal email"
178 hideTooltip="Click to hide email"
179 className="max-w-full truncate"
180 />
181 </span>
182 ) : (
183 <span role="status">{accountDescription}</span>
184 )
185 }
186 status={
187 statusMessage ? (
188 <p role="status" className="[overflow-wrap:anywhere]">
189 {statusMessage}
190 </p>
191 ) : undefined
192 }
193 control={
194 <div className="flex min-w-0 flex-wrap items-center gap-1.5">
195 {!active && (auth?.methods?.length ?? 0) > 1 ? (
196 <Select
197 value={auth?.methods?.some((method) => method.id === methodId) ? methodId : ""}
198 disabled={disabled}
199 onValueChange={(value) => setMethodId(value ?? "")}
200 >
201 <SelectTrigger
202 variant="well"
203 size="compact"
204 aria-label="Sign-in method"
205 className="w-44"
206 >
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="secondary"
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="secondary"
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="secondary"
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="secondary"
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="secondary"
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 );