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

T3ConnectUserProfilePage.tsx

318 lines11.9 KB

1import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
2import { findErrorTraceId } from "@t3tools/client-runtime/errors";
3import {
4 isAtomCommandInterrupted,
5 squashAtomCommandFailure,
6} from "@t3tools/client-runtime/state/runtime";
7import type { EnvironmentId } from "@t3tools/contracts";
8import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
9import { ServerIcon } from "lucide-react";
10import { useRef, useState } from "react";
11​
12import {
13 deregisterManagedRelayEnvironmentCommand,
14 useManagedRelayEnvironments,
15 useManagedTunnelUsage,
16} from "../../cloud/managedRelayState";
17import { relayFailureReason } from "../../cloud/relayFailureReason";
18import { relayEnvironmentDiscovery } from "../../state/relay";
19import { useAtomCommand } from "../../state/use-atom-command";
20import { T3CHAT_SUBSCRIPTION_URL, TunnelUsageMeter, tunnelUpgradeCopy } from "../cloud/TunnelUsage";
21import { Button, InlineButton } from "../ui/button";
22import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
23import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
24import { toastManager } from "../ui/toast";
25import { stackedThreadToast } from "../ui/toastHelpers";
26import {
27 ClerkUserProfilePage,
28 ClerkUserProfileRefreshButton,
29 ClerkUserProfileRow,
30} from "./ClerkUserProfilePage";
31import { useT3ChatLink } from "./useT3ChatLink";
32​
33const linkedAtFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" });
34​
35function linkedAtLabel(value: string): string {
36 const linkedAt = new Date(value);
37 return Number.isNaN(linkedAt.getTime())
38 ? "Link date unavailable"
39 : `Linked ${linkedAtFormatter.format(linkedAt)}`;
41​
42function endpointLabel(environment: RelayClientEnvironmentRecord): string {
43 return environment.endpoint.providerKind === "cloudflare_tunnel"
44 ? "Managed tunnel"
45 : "Activity publishing only";
47​
48function T3ConnectEnvironmentRow(props: {
49 readonly environment: RelayClientEnvironmentRecord;
50 readonly confirmationOpen: boolean;
51 readonly mutationPending: boolean;
52 readonly onConfirmationChange: (open: boolean) => void;
53 readonly onDeregister: (environment: RelayClientEnvironmentRecord) => void;
54}) {
55 const { environment } = props;
56 return (
57 <ClerkUserProfileRow icon={<ServerIcon className="size-4" />}>
58 <Collapsible open={props.confirmationOpen} onOpenChange={props.onConfirmationChange}>
59 <div className="flex items-start gap-4">
60 <div className="min-w-0 flex-1">
61 <h3 className="truncate text-label text-strong-foreground">{environment.label}</h3>
62 <p className="mt-1 text-meta text-muted-foreground">
63 <Tooltip>
64 <TooltipTrigger render={<span tabIndex={0} />}>
65 {endpointLabel(environment)}
66 </TooltipTrigger>
67 <TooltipPopup>{linkedAtLabel(environment.linkedAt)}</TooltipPopup>
68 </Tooltip>
69 </p>
70 </div>
71 <CollapsibleTrigger
72 render={
73 <Button size="sm" variant="destructive-outline" disabled={props.mutationPending}>
74 Deregister
75 </Button>
76 }
77 />
78 </div>
79​
80 <CollapsiblePanel>
81 <div className="pt-3">
82 <div
83 className="rounded-card surface-panel px-5 py-4"
84 role="group"
85 aria-label={`Confirm deregistration of ${environment.label}`}
86 >
87 <h4 className="text-label text-strong-foreground">Deregister server</h4>
88 <p className="mt-1 text-meta text-muted-foreground">
89 “{environment.label}” will be removed from this account.
90 </p>
91 <p className="mt-4 max-w-xl text-meta text-muted-foreground">
92 T3 Connect access will be revoked, any managed tunnel will be removed, and a host
93 space will become available. Local connections on your devices are not changed.
94 </p>
95 <div className="mt-4 flex justify-end gap-2">
96 <Button
97 size="sm"
98 variant="ghost"
99 disabled={props.mutationPending}
100 onClick={() => props.onConfirmationChange(false)}
101 >
102 Cancel
103 </Button>
104 <Button
105 size="sm"
106 variant="destructive"
107 disabled={props.mutationPending}
108 onClick={() => props.onDeregister(environment)}
109 >
110 {props.mutationPending ? "Deregistering…" : "Deregister"}
111 </Button>
112 </div>
113 </div>
114 </div>
115 </CollapsiblePanel>
116 </Collapsible>
117 </ClerkUserProfileRow>
118 );
120​
121function T3ConnectTunnelUsage() {
122 const usage = useManagedTunnelUsage();
123 const t3Chat = useT3ChatLink();
124 if (usage.used === null || usage.limit === null) return null;
125 return (
126 <div className="border-t py-4">
127 <TunnelUsageMeter used={usage.used} limit={usage.limit} />
128 {usage.upgrade ? (
129 <p className="mt-2 text-meta leading-4.5 text-muted-foreground">
130 {usage.upgrade === "link" && t3Chat.linkUrl ? (
131 <>
132 Confirm on{" "}
133 <InlineButton
134 render={<a href={t3Chat.linkUrl} target="_blank" rel="noopener noreferrer" />}
135 >
136 t3.chat
137 </InlineButton>
138 , then select Refresh.
139 </>
140 ) : (
141 <>
142 {tunnelUpgradeCopy(usage.upgrade)}{" "}
143 {usage.upgrade === "link" ? (
144 <InlineButton disabled={t3Chat.pending !== null} onClick={() => void t3Chat.link()}>
145 {t3Chat.pending === "link" ? "Opening…" : "Link T3 Chat"}
146 </InlineButton>
147 ) : (
148 <InlineButton
149 render={
150 <a href={T3CHAT_SUBSCRIPTION_URL} target="_blank" rel="noopener noreferrer" />
151 }
152 >
153 Upgrade on T3 Chat
154 </InlineButton>
155 )}
156 </>
157 )}
158 </p>
159 ) : null}
160 </div>
161 );
163​
164export function T3ConnectUserProfilePage() {
165 const environmentsState = useManagedRelayEnvironments();
166 const deregisterEnvironment = useAtomCommand(deregisterManagedRelayEnvironmentCommand, {
167 reportFailure: false,
168 });
169 const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refreshAfterMutation, {
170 reportFailure: false,
171 });
172 const [deregisteringEnvironmentId, setDeregisteringEnvironmentId] =
173 useState<EnvironmentId | null>(null);
174 const [confirmingEnvironmentId, setConfirmingEnvironmentId] = useState<EnvironmentId | null>(
175 null,
176 );
177 const mutationPendingRef = useRef(false);
178 const [removedEnvironments, setRemovedEnvironments] = useState<{
179 readonly accountId: string | null;
180 readonly linkedAtById: ReadonlyMap<EnvironmentId, string>;
181 }>({ accountId: null, linkedAtById: new Map() });
182​
183 const handleDeregister = async (environment: RelayClientEnvironmentRecord) => {
184 const accountId = environmentsState.accountId;
185 if (!accountId || mutationPendingRef.current) return;
186​
187 mutationPendingRef.current = true;
188 setDeregisteringEnvironmentId(environment.environmentId);
189 const result = await deregisterEnvironment({
190 accountId,
191 environmentId: environment.environmentId,
192 });
193 mutationPendingRef.current = false;
194 setDeregisteringEnvironmentId(null);
195 setConfirmingEnvironmentId(null);
196​
197 if (result._tag === "Success") {
198 setRemovedEnvironments((current) => {
199 const linkedAtById = new Map(current.accountId === accountId ? current.linkedAtById : []);
200 linkedAtById.set(environment.environmentId, environment.linkedAt);
201 return { accountId, linkedAtById };
202 });
203 environmentsState.refresh();
204 void refreshRelayEnvironments();
205 toastManager.add({
206 type: "success",
207 title: "Server deregistered",
208 description: "T3 Connect access was revoked and a host space is now available.",
209 });
210 return;
211 }
212 if (isAtomCommandInterrupted(result)) return;
213​
214 // The relay may have removed it before failing.
215 environmentsState.refresh();
216 const cause = squashAtomCommandFailure(result);
217 const message = cause instanceof Error ? cause.message : "Could not deregister the server.";
218 const traceId = findErrorTraceId(cause);
219 console.error("[t3-connect] Could not deregister environment", {
220 environmentId: environment.environmentId,
221 message,
222 traceId,
223 cause,
224 });
225 toastManager.add(
226 stackedThreadToast({
227 type: "error",
228 title: "Could not deregister server",
229 description: relayFailureReason(cause) ?? message,
230 ...(traceId
231 ? {
232 actionProps: {
233 children: "Copy trace ID",
234 onClick: () => void navigator.clipboard?.writeText(traceId),
235 },
236 actionVariant: "secondary",
237 }
238 : {}),
239 data: { hideCopyButton: true },
240 }),
241 );
242 };
243​
244 const removedEnvironmentLinkedAt =
245 removedEnvironments.accountId === environmentsState.accountId
246 ? removedEnvironments.linkedAtById
247 : new Map<EnvironmentId, string>();
248 const environments = (environmentsState.data ?? []).filter(
249 (environment) =>
250 removedEnvironmentLinkedAt.get(environment.environmentId) !== environment.linkedAt,
251 );
252 const usage = useManagedTunnelUsage();
253 const refreshAll = usage.refresh;
254 const isInitialLoad =
255 !environmentsState.accountId || (environmentsState.data === null && !environmentsState.error);
256​
257 return (
258 <ClerkUserProfilePage
259 title="T3 Connect"
260 description="Environments registered to your account. Connections on this device are managed in Settings."
261 action={
262 <ClerkUserProfileRefreshButton
263 disabled={deregisteringEnvironmentId !== null}
264 isPending={environmentsState.isPending}
265 onClick={refreshAll}
266 />
267 }
268 >
269 <div>
270 <T3ConnectTunnelUsage />
271 {environmentsState.error ? (
272 <div className="mb-4 border-t border-destructive/35 py-3" role="alert">
273 <p className="text-label text-destructive-foreground">
274 Could not load T3 Connect environments
275 </p>
276 <p className="mt-1 text-meta text-muted-foreground">{environmentsState.error}</p>
277 </div>
278 ) : null}
279​
280 {isInitialLoad ? (
281 <p className="border-t py-4 text-meta text-muted-foreground" role="status">
282 Loading environments…
283 </p>
284 ) : environments.length > 0 ? (
285 <ul className="border-t">
286 {environments.map((environment) => (
287 <T3ConnectEnvironmentRow
288 key={environment.environmentId}
289 environment={environment}
290 confirmationOpen={confirmingEnvironmentId === environment.environmentId}
291 mutationPending={deregisteringEnvironmentId !== null}
292 onConfirmationChange={(open) =>
293 setConfirmingEnvironmentId(open ? environment.environmentId : null)
294 }
295 onDeregister={(selected) => void handleDeregister(selected)}
296 />
297 ))}
298 </ul>
299 ) : environmentsState.error ? null : (
300 <div className="border-t">
301 <Empty size="compact">
302 <EmptyMedia variant="icon">
303 <ServerIcon />
304 </EmptyMedia>
305 <EmptyHeader>
306 <EmptyTitle>No T3 Connect environments</EmptyTitle>
307 <EmptyDescription>
308 Link an environment from its local Settings to make it available through T3
309 Connect.
310 </EmptyDescription>
311 </EmptyHeader>
312 </Empty>
313 </div>
314 )}
315 </div>
316 </ClerkUserProfilePage>
317 );