1import { normalizeHostedServer } from "@t3tools/shared/hostedServerUrl";
2import { formatBytes } from "../../lib/formatBytes";
3import { ArchiveStatus, SelfHostedConnectionStatus } from "@t3tools/contracts";
4import * as Schema from "effect/Schema";
5import { readDesktopPrimaryBearerToken } from "../../environments/primary/desktopAuth";
6import { resolvePrimaryEnvironmentHttpUrl } from "../../environments/primary/target";
7import type { HostedAuthDevice } from "@t3tools/contracts";
8import {
9 beginHostedSignIn,
10 hostedDevices,
11 hostedAuthHeaders,
12 hostedAccountRequest,
13 forgetHostedWebSession,
14} from "../../connection/hostedAuth";
15import { useAtomValue } from "@effect/atom-react";
16import {
17 isAtomCommandInterrupted,
18 squashAtomCommandFailure,
19} from "@t3tools/client-runtime/state/runtime";
20import { CloudIcon, LinkIcon, ServerIcon } from "lucide-react";
21import { useEffect, useEffectEvent, useId, useRef, useState, type ReactNode } from "react";
22
23import { activateCloudAuthentication } from "../../cloud/activation";
24import { hasCloudPublicConfig } from "../../cloud/publicConfig";
25import { hubConnection } from "../../connection/hub";
26import { useAtomCommand } from "../../state/use-atom-command";
27import { Button } from "../ui/button";
28import { Input } from "../ui/input";
29import { Radio, RadioGroup } from "../ui/radio-group";
30import { SettingsRow, SettingsSection } from "./settingsLayout";
31
32export function RemoteConnectionFlow({
33 onDirect,
34 onConnected,
35 onCloudSelected,
36 cloudContent,
37}: {
38 readonly onDirect: () => void;
39 readonly onConnected?: () => void;
40 readonly onCloudSelected?: () => void;
41 readonly cloudContent?: ReactNode;
42}) {
43 const cloudAvailable = hasCloudPublicConfig();
44 const [method, setMethod] = useState("self");
45 const [step, setStep] = useState<"choose" | "self" | "cloud">("choose");
46 const headingId = useId();
47 const choicesRef = useRef<HTMLDivElement>(null);
48 const showChoices = () => {
49 setStep("choose");
50 requestAnimationFrame(() =>
51 choicesRef.current
52 ?.querySelector<HTMLElement>('[role="radio"][aria-checked="true"]')
53 ?.focus(),
54 );
55 };
56 if (step === "self")
57 return (
58 <div className="space-y-4">
59 <Button variant="ghost" size="sm" onClick={showChoices}>
60 Back
61 </Button>
62 <SelfHostedServerForm
63 onConnected={() => {
64 showChoices();
65 onConnected?.();
66 }}
67 />
68 </div>
69 );
70 if (step === "cloud")
71 return (
72 <div className="space-y-4">
73 <Button variant="ghost" size="sm" onClick={showChoices}>
74 Back
75 </Button>
76 {cloudContent}
77 </div>
78 );
79 return (
80 <div className="space-y-4">
81 <p id={headingId} className="text-sm text-muted-foreground">
82 Choose how to reach your computers.
83 </p>
84 <RadioGroup
85 ref={choicesRef}
86 value={method}
87 onValueChange={setMethod}
88 aria-labelledby={headingId}
89 >
90 {cloudAvailable ? (
91 <label className="flex min-h-20 cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-4 py-3">
92 <CloudIcon aria-hidden className="size-5 shrink-0 text-muted-foreground" />
93 <span className="min-w-0 flex-1">
94 <span className="block text-sm font-medium">T3 Connect</span>
95 <span className="mt-1 block text-xs text-muted-foreground">
96 Sign in with your T3 account
97 </span>
98 </span>
99 <Radio value="cloud" />
100 </label>
101 ) : null}
102 <label className="flex min-h-20 cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-4 py-3">
103 <ServerIcon aria-hidden className="size-5 shrink-0 text-muted-foreground" />
104 <span className="min-w-0 flex-1">
105 <span className="block text-sm font-medium">Self-hosted server</span>
106 <span className="mt-1 block text-xs text-muted-foreground">
107 Connect to a server you run
108 </span>
109 </span>
110 <Radio value="self" />
111 </label>
112 </RadioGroup>
113 <div className="flex flex-wrap items-center justify-between gap-3">
114 <Button variant="ghost" onClick={onDirect}>
115 <LinkIcon aria-hidden className="size-4" />
116 Other connections
117 </Button>
118 <Button
119 onClick={() => {
120 if (method === "self") setStep("self");
121 else {
122 activateCloudAuthentication(true);
123 setStep("cloud");
124 onCloudSelected?.();
125 }
126 }}
127 >
128 Connect
129 </Button>
130 </div>
131 </div>
132 );
133}
134
135function SelfHostedServerForm({ onConnected }: { readonly onConnected: () => void }) {
136 const state = useAtomValue(hubConnection.stateValueAtom);
137 const connect = useAtomCommand(hubConnection.connectAuthenticated, { reportFailure: false });
138 const addressId = useId();
139 const errorId = useId();
140 const connected = useEffectEvent(onConnected);
141 const [address, setAddress] = useState(state.connection?.httpBaseUrl ?? "");
142 const [connecting, setConnecting] = useState(false);
143 const [error, setError] = useState<string | null>(null);
144
145 useEffect(() => {
146 const bridge = window.desktopBridge;
147 if (!connecting || !bridge?.getHostedAuthState) return;
148 let stopped = false;
149 let checking = false;
150 const check = async () => {
151 if (checking) return;
152 checking = true;
153 try {
154 const next = await bridge.getHostedAuthState!();
155 if (stopped) return;
156 if (next.phase === "error") {
157 setConnecting(false);
158 setError(next.error ?? "Sign-in did not finish. Sign in again.");
159 } else if (
160 next.phase === "connected" &&
161 next.serverUrl === normalizeHostedServer(address.trim())
162 ) {
163 const result = await connect({ httpBaseUrl: next.serverUrl });
164 if (stopped) return;
165 setConnecting(false);
166 if (result._tag === "Success") connected();
167 else if (!isAtomCommandInterrupted(result)) {
168 const cause = squashAtomCommandFailure(result);
169 setError(cause instanceof Error ? cause.message : "Couldn't connect. Sign in again.");
170 }
171 }
172 } catch {
173 if (!stopped) {
174 setConnecting(false);
175 setError("Couldn't finish sign-in. Try again.");
176 }
177 } finally {
178 checking = false;
179 }
180 };
181 void check();
182 const timer = window.setInterval(() => void check(), 1000);
183 return () => {
184 stopped = true;
185 window.clearInterval(timer);
186 void bridge.cancelHostedAuth?.();
187 };
188 }, [connecting, address, connect]);
189
190 return (
191 <form
192 className="space-y-4"
193 onSubmit={(event) => {
194 event.preventDefault();
195 if (connecting) return;
196 setError(null);
197 try {
198 const server = normalizeHostedServer(address.trim());
199 setConnecting(true);
200 void beginHostedSignIn(server).catch(() => {
201 setConnecting(false);
202 setError("Couldn't start sign-in. Check the server address and try again.");
203 });
204 } catch {
205 setError("Enter an HTTPS server address.");
206 }
207 }}
208 >
209 <div className="space-y-1.5">
210 <label htmlFor={addressId} className="text-sm font-medium">
211 Server address
212 </label>
213 <Input
214 id={addressId}
215 autoFocus
216 inputMode="url"
217 value={address}
218 onChange={(event) => setAddress(event.target.value)}
219 placeholder="https://server.example.net"
220 autoComplete="url"
221 autoCapitalize="none"
222 spellCheck={false}
223 required
224 disabled={connecting}
225 aria-describedby={error ? errorId : undefined}
226 />
227 </div>
228 {connecting ? (
229 <p role="status" className="text-sm text-muted-foreground">
230 Finish signing in in your browser
231 </p>
232 ) : null}
233 {error ? (
234 <p id={errorId} role="alert" className="text-sm text-destructive">
235 {error}
236 </p>
237 ) : null}
238 <div className="flex justify-end gap-2">
239 {connecting && window.desktopBridge ? (
240 <Button
241 variant="outline"
242 type="button"
243 onClick={() => {
244 setConnecting(false);
245 void window.desktopBridge?.cancelHostedAuth?.();
246 }}
247 >
248 Cancel
249 </Button>
250 ) : null}
251 <Button type="submit" disabled={connecting || !address.trim()}>
252 {connecting ? "Signing in…" : "Sign in"}
253 </Button>
254 </div>
255 </form>
256 );
257}
258
259export function HostedServerAutoConnect() {
260 const state = useAtomValue(hubConnection.stateValueAtom);
261 const connect = useAtomCommand(hubConnection.connectAuthenticated, { reportFailure: false });
262 useEffect(() => {
263 if (state.connection !== null) return;
264 let stopped = false;
265 void (async () => {
266 let server: string | null = null;
267 if (window.desktopBridge?.getHostedAuthState) {
268 const current = await window.desktopBridge.getHostedAuthState();
269 if (current.phase === "connected") server = current.serverUrl;
270 } else {
271 const config = await fetch("/api/auth/oidc/config", {
272 credentials: "same-origin",
273 redirect: "error",
274 });
275 if (stopped || !config.ok || !(await config.json()).enabled) return;
276 const response = await fetch("/api/auth/account", {
277 credentials: "same-origin",
278 redirect: "error",
279 });
280 if (response.ok) server = window.location.origin;
281 }
282 if (!stopped && server !== null) await connect({ httpBaseUrl: server });
283 })().catch(() => undefined);
284 return () => {
285 stopped = true;
286 };
287 }, [state.connection, connect]);
288 useEffect(() => {
289 const connection = state.connection;
290 if (window.desktopBridge || connection?.authorization !== "hosted") return;
291 const renew = () => {
292 void hostedAuthHeaders(connection.httpBaseUrl).catch(() => undefined);
293 };
294 renew();
295 const timer = window.setInterval(renew, 30_000);
296 window.addEventListener("focus", renew);
297 return () => {
298 window.clearInterval(timer);
299 window.removeEventListener("focus", renew);
300 };
301 }, [state.connection]);
302 return null;
303}
304
305const decodeSyncStatus = Schema.decodeUnknownSync(SelfHostedConnectionStatus);
306const decodeArchiveStatus = Schema.decodeUnknownSync(Schema.toCodecJson(ArchiveStatus));
307
308const requestComputerSync = async (path: string, body?: unknown) => {
309 const token = await readDesktopPrimaryBearerToken();
310 if (!token) throw new Error("This computer is unavailable.");
311 const response = await fetch(resolvePrimaryEnvironmentHttpUrl(path), {
312 method: body === undefined ? "GET" : "POST",
313 headers: {
314 authorization: `Bearer ${token}`,
315 ...(body === undefined ? {} : { "content-type": "application/json" }),
316 },
317 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
318 redirect: "error",
319 });
320 if (!response.ok) throw new Error("Couldn't load sync status. Try again.");
321 return response;
322};
323
324function ComputerSyncStatus() {
325 const [status, setStatus] = useState<{ configured: boolean; connected: boolean } | null>(null);
326 const [archive, setArchive] = useState<ArchiveStatus | null>(null);
327 const [error, setError] = useState<string | null>(null);
328 const [retrying, setRetrying] = useState(false);
329 useEffect(() => {
330 let stopped = false;
331 let running = false;
332 const refresh = async () => {
333 if (running) return;
334 running = true;
335 try {
336 const [sync, archiveResponse] = await Promise.all([
337 requestComputerSync("/api/self-hosted/connection").then((response) => response.json()),
338 requestComputerSync("/api/archive/manage", { type: "archive.status" }).then((response) =>
339 response.json(),
340 ),
341 ]);
342 if (!stopped) {
343 setStatus(decodeSyncStatus(sync));
344 setArchive(decodeArchiveStatus(archiveResponse));
345 setError(null);
346 }
347 } catch {
348 if (!stopped) setError("Couldn't load sync status. Reconnecting…");
349 } finally {
350 running = false;
351 }
352 };
353 void refresh();
354 const timer = window.setInterval(() => void refresh(), 15_000);
355 return () => {
356 stopped = true;
357 window.clearInterval(timer);
358 };
359 }, []);
360 return (
361 <>
362 <SettingsRow
363 title="This computer"
364 description={
365 error ??
366 (status === null
367 ? "Checking sync…"
368 : !status.configured
369 ? "Connecting to your server…"
370 : !status.connected
371 ? "Reconnecting…"
372 : archive === null
373 ? "Connected"
374 : archive.pending > 0
375 ? `${archive.pending} chats waiting to sync`
376 : `${archive.offloaded} chats archived`)
377 }
378 control={
379 archive && (archive.pending > 0 || archive.failures.length > 0) ? (
380 <Button
381 variant="outline"
382 size="sm"
383 disabled={retrying}
384 onClick={() => {
385 setRetrying(true);
386 void requestComputerSync("/api/archive/manage", { type: "archive.retry" })
387 .then(async (response) => {
388 setArchive(decodeArchiveStatus(await response.json()));
389 setError(null);
390 })
391 .catch(() => setError("Couldn't retry sync. Try again."))
392 .finally(() => setRetrying(false));
393 }}
394 >
395 {retrying ? "Retrying…" : "Retry sync"}
396 </Button>
397 ) : undefined
398 }
399 />
400 {archive ? (
401 <p className="text-xs text-muted-foreground">
402 {formatBytes(archive.storage.allocatedDatabaseBytes + archive.storage.attachmentsBytes)}{" "}
403 local archive storage. {formatBytes(archive.storage.nativeProviderLogBytes)} provider logs
404 retained.
405 {archive.lastReclamation
406 ? ` Last cleanup freed ${formatBytes(Math.max(0, archive.lastReclamation.beforeAllocatedBytes - archive.lastReclamation.afterAllocatedBytes))}.`
407 : ""}
408 {archive.queue.length > 0
409 ? ` Next retry ${new Date(Math.min(...archive.queue.map((item) => item.nextAttemptAt))).toLocaleTimeString()}.`
410 : ""}
411 </p>
412 ) : null}
413 </>
414 );
415}
416
417export function SelfHostedServerSettings() {
418 const state = useAtomValue(hubConnection.stateValueAtom);
419 const refresh = useAtomCommand(hubConnection.refresh, { reportFailure: false });
420 const disconnect = useAtomCommand(hubConnection.disconnect, { reportFailure: false });
421 const [signingIn, setSigningIn] = useState(false);
422 const [deviceList, setDeviceList] = useState<{
423 serverUrl: string;
424 items: readonly HostedAuthDevice[];
425 } | null>(null);
426 const [busy, setBusy] = useState(false);
427 const [error, setError] = useState<string | null>(null);
428 const serverUrl = state.connection?.httpBaseUrl;
429 const hosted = state.connection?.authorization === "hosted";
430 useEffect(() => {
431 if (!hosted || !serverUrl) return;
432 let stopped = false;
433 void hostedDevices(serverUrl)
434 .then((items) => {
435 if (!stopped) setDeviceList({ serverUrl, items });
436 })
437 .catch(() => {
438 if (!stopped) setError("Couldn't load connected devices. Sign in again to reconnect.");
439 });
440 return () => {
441 stopped = true;
442 };
443 }, [serverUrl, hosted]);
444 const devices = deviceList !== null && deviceList.serverUrl === serverUrl ? deviceList.items : [];
445 if (state.connection === null) return null;
446 const run = async (command: typeof refresh) => {
447 if (busy) return;
448 setBusy(true);
449 setError(null);
450 const result = await command();
451 setBusy(false);
452 if (result._tag !== "Success" && !isAtomCommandInterrupted(result)) {
453 const cause = squashAtomCommandFailure(result);
454 setError(
455 cause instanceof Error ? cause.message : "Couldn't update this connection. Try again.",
456 );
457 }
458 };
459 return (
460 <SettingsSection title="Self-hosted server">
461 <SettingsRow
462 title={state.connection.label}
463 description={state.connection.httpBaseUrl}
464 control={
465 <div className="flex flex-wrap gap-2">
466 {hosted && (state.error || error) ? (
467 <Button
468 variant="outline"
469 size="sm"
470 disabled={busy}
471 onClick={() => setSigningIn(true)}
472 >
473 Sign in
474 </Button>
475 ) : null}
476 <Button
477 variant="outline"
478 size="sm"
479 disabled={busy || state.refreshing}
480 onClick={() => void run(refresh)}
481 >
482 {state.refreshing ? "Refreshing…" : "Refresh"}
483 </Button>
484 <Button
485 variant="outline"
486 size="sm"
487 disabled={busy}
488 onClick={() => {
489 if (!hosted || !serverUrl) {
490 void run(disconnect);
491 return;
492 }
493 setBusy(true);
494 void hostedAccountRequest(serverUrl, "/api/auth/logout", "POST")
495 .then(async () => {
496 await window.desktopBridge?.disconnectHostedAuth?.();
497 forgetHostedWebSession();
498 await run(disconnect);
499 })
500 .catch(() => setError("Couldn't sign out. Try again."))
501 .finally(() => setBusy(false));
502 }}
503 >
504 Disconnect
505 </Button>
506 </div>
507 }
508 />
509 {signingIn ? (
510 <SelfHostedServerForm
511 onConnected={() => {
512 setSigningIn(false);
513 setError(null);
514 if (serverUrl)
515 void hostedDevices(serverUrl)
516 .then((items) => setDeviceList({ serverUrl, items }))
517 .catch(() => undefined);
518 }}
519 />
520 ) : null}
521 {hosted && window.desktopBridge ? <ComputerSyncStatus /> : null}
522 {hosted
523 ? devices
524 .filter((device) => device.revokedAt === null)
525 .map(({ deviceId, current, client: presentation }) => (
526 <SettingsRow
527 key={deviceId}
528 title={presentation.label ?? "Connected device"}
529 description={current ? "This device" : "Signed in"}
530 control={
531 <Button
532 variant="outline"
533 size="sm"
534 disabled={busy}
535 onClick={() => {
536 if (!serverUrl) return;
537 setBusy(true);
538 void hostedAccountRequest(
539 serverUrl,
540 `/api/auth/devices/${encodeURIComponent(deviceId)}/revoke`,
541 "POST",
542 )
543 .then(async () => {
544 if (current) {
545 await window.desktopBridge?.disconnectHostedAuth?.();
546 forgetHostedWebSession();
547 await disconnect();
548 } else
549 setDeviceList({ serverUrl, items: await hostedDevices(serverUrl) });
550 })
551 .catch(() => setError("Couldn't revoke this device. Try again."))
552 .finally(() => setBusy(false));
553 }}
554 >
555 Revoke
556 </Button>
557 }
558 />
559 ))
560 : null}
561 {error || state.error ? (
562 <p role="alert" className="px-4 py-3 text-sm text-destructive">
563 {error ?? state.error}
564 </p>
565 ) : null}
566 </SettingsSection>
567 );
568}