1import { useAtomValue } from "@effect/atom-react";
2import { useAuth } from "@clerk/react";
3import { findErrorTraceId } from "@t3tools/client-runtime/errors";
4import { AuthRelayReadScope, AuthRelayWriteScope, type EnvironmentId } from "@t3tools/contracts";
5import { ManagedRelay } from "@t3tools/client-runtime/relay";
6import {
7 isAtomCommandInterrupted,
8 settlePromise,
9 squashAtomCommandFailure,
10} from "@t3tools/client-runtime/state/runtime";
11import * as Schema from "effect/Schema";
12import { useState } from "react";
13
14import { toastManager } from "../components/ui/toast";
15import { stackedThreadToast } from "../components/ui/toastHelpers";
16import { relayFailureReason } from "./relayFailureReason";
17import { relayEnvironmentDiscovery } from "../state/relay";
18import { readEnvironmentScope } from "../state/session";
19import { useAtomCommand } from "../state/use-atom-command";
20import { isCloudEnvironmentLinkError } from "./linkEnvironment";
21import { refreshManagedTunnelUsage } from "./managedRelayState";
22import {
23 linkEnvironment as linkEnvironmentAtom,
24 unlinkEnvironment as unlinkEnvironmentAtom,
25 updateEnvironmentPreferences as updateEnvironmentPreferencesAtom,
26} from "./linkEnvironmentAtoms";
27import { readCachedCloudLinkState, useCloudLinkState } from "./cloudLinkState";
28import { resolveRelayClerkTokenOptions } from "./publicConfig";
29import { isManagedTunnelActive } from "./t3ConnectState";
30
31export interface CloudLinkDesiredState {
32 readonly managedTunnel: boolean;
33 readonly publish: boolean;
34 readonly holdWebhooksWhileOffline?: boolean;
35}
36
37const isManagedRelayRequestFailedError = Schema.is(ManagedRelay.ManagedRelayRequestFailedError);
38const isManagedRelayClientError = Schema.is(ManagedRelay.ManagedRelayClientError);
39
40/** The account's tunnel cap when the relay refused a new tunnel for being at it. */
41function tunnelLimitFromLinkFailure(cause: unknown): number | null {
42 if (!isCloudEnvironmentLinkError(cause)) return null;
43 const relayCause = cause.cause;
44 if (!isManagedRelayRequestFailedError(relayCause)) return null;
45 return relayCause.relayError?._tag === "RelayEnvironmentLinkLimitExceededError"
46 ? relayCause.relayError.maxTunnels
47 : null;
48}
49
50export function useCloudLinkController(environmentId: EnvironmentId | null) {
51 const { getToken, isSignedIn } = useAuth();
52 const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, {
53 reportFailure: false,
54 });
55 const linkEnvironment = useAtomCommand(linkEnvironmentAtom, {
56 reportFailure: false,
57 });
58 const unlinkEnvironment = useAtomCommand(unlinkEnvironmentAtom, {
59 reportFailure: false,
60 });
61 const updateEnvironmentPreferences = useAtomCommand(updateEnvironmentPreferencesAtom, {
62 reportFailure: false,
63 });
64 const cloudLinkState = useCloudLinkState(environmentId);
65 const relayEnvironments = useAtomValue(relayEnvironmentDiscovery.stateValueAtom).environments;
66 const relayProviderKind = environmentId
67 ? relayEnvironments.get(environmentId)?.environment.endpoint.providerKind
68 : undefined;
69 const [operationError, setOperationError] = useState<string | null>(null);
70 const [tunnelLimit, setTunnelLimit] = useState<number | null>(null);
71
72 const reportUpdateFailure = (cause: unknown, reason?: string) => {
73 const message = cause instanceof Error ? cause.message : "Could not update T3 Connect access.";
74 const traceId = findErrorTraceId(cause);
75 console.error("[t3-connect] Could not update T3 Connect", { message, traceId, cause });
76 setOperationError(traceId ? `${message} Trace ID: ${traceId}` : message);
77 toastManager.add(
78 stackedThreadToast({
79 type: "error",
80 title: "Could not update T3 Connect",
81 description: reason ?? relayFailureReason(cause) ?? message,
82 ...(traceId
83 ? {
84 actionProps: {
85 children: "Copy trace ID",
86 onClick: () => void navigator.clipboard?.writeText(traceId),
87 },
88 actionVariant: "outline",
89 }
90 : {}),
91 data: { hideCopyButton: true },
92 }),
93 );
94 };
95
96 const linkData = cloudLinkState.data;
97 const linkRevoked = linkData?.linkRevoked ?? false;
98 const managedTunnelActive = linkData ? isManagedTunnelActive(linkData, relayProviderKind) : false;
99 const managedTunnelOutOfSync =
100 linkData !== null && !managedTunnelActive && isManagedTunnelActive(linkData, undefined);
101 const publishAgentActivity = cloudLinkState.data?.publishAgentActivity ?? false;
102 const holdWebhooksWhileOffline = cloudLinkState.data?.holdWebhooksWhileOffline ?? false;
103 const linked = !linkRevoked && (cloudLinkState.data?.linked ?? false);
104
105 const reconcileCloudState = async (desired: CloudLinkDesiredState): Promise<boolean> => {
106 setOperationError(null);
107 setTunnelLimit(null);
108 const target = cloudLinkState.target;
109 if (!target) {
110 reportUpdateFailure(new Error("Choose an environment first."));
111 return false;
112 }
113 const environmentId = target.environmentId;
114 const canManageLink = () => {
115 if (
116 !readEnvironmentScope(environmentId, AuthRelayReadScope) ||
117 !readEnvironmentScope(environmentId, AuthRelayWriteScope)
118 ) {
119 reportUpdateFailure(
120 new Error("This connection needs permission to view and manage T3 Connect settings."),
121 );
122 return false;
123 }
124 return true;
125 };
126 const readLinkState = () => {
127 const state = readCachedCloudLinkState(target);
128 if (state === null) {
129 reportUpdateFailure(new Error("Wait until the current T3 Connect settings can be read."));
130 }
131 return state;
132 };
133 if (!canManageLink()) return false;
134 const wantsLink = desired.managedTunnel || desired.publish;
135 if (wantsLink && readLinkState() === null) return false;
136 const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions()));
137 if (!canManageLink()) return false;
138
139 // A failed mutation may have partially applied, so refresh server state on every exit.
140 if (!wantsLink) {
141 // Unlink needs no relay token; failed token reads must still allow it.
142 const unlinkResult = await unlinkEnvironment({
143 target,
144 clerkToken: tokenResult._tag === "Success" ? (tokenResult.value ?? null) : null,
145 });
146 refreshManagedTunnelUsage();
147 if (unlinkResult._tag === "Failure") {
148 if (!isAtomCommandInterrupted(unlinkResult)) {
149 const cause = squashAtomCommandFailure(unlinkResult);
150 // Only the relay delete fails with a relay error, and it runs after the local unlink.
151 const relayKeptLink =
152 isCloudEnvironmentLinkError(cause) && isManagedRelayClientError(cause.cause);
153 reportUpdateFailure(
154 cause,
155 relayKeptLink
156 ? "Disconnected here, but T3 Connect still lists it. Remove it in Account."
157 : undefined,
158 );
159 }
160 cloudLinkState.refresh();
161 await refreshRelayEnvironments();
162 return false;
163 }
164 } else {
165 const currentLinkState = readLinkState();
166 if (currentLinkState === null) return false;
167 if (tokenResult._tag === "Failure") {
168 reportUpdateFailure(squashAtomCommandFailure(tokenResult));
169 return false;
170 }
171 const clerkToken = tokenResult.value;
172 if (!clerkToken) {
173 reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this."));
174 return false;
175 }
176 const currentManagedTunnel = isManagedTunnelActive(currentLinkState, relayProviderKind);
177 if (
178 !currentLinkState.linked ||
179 currentLinkState.linkRevoked === true ||
180 currentManagedTunnel !== desired.managedTunnel
181 ) {
182 const linkResult = await linkEnvironment({
183 target,
184 clerkToken,
185 mode: desired.managedTunnel ? "managed" : "publish_only",
186 });
187 // Any link attempt can change the account's tunnel count.
188 refreshManagedTunnelUsage();
189 if (linkResult._tag === "Failure") {
190 if (!isAtomCommandInterrupted(linkResult)) {
191 const cause = squashAtomCommandFailure(linkResult);
192 // The caller shows an upgrade prompt instead of an error.
193 const limit = tunnelLimitFromLinkFailure(cause);
194 if (limit === null) reportUpdateFailure(cause);
195 else setTunnelLimit(limit);
196 }
197 cloudLinkState.refresh();
198 return false;
199 }
200 }
201 if (!canManageLink() || readLinkState() === null) {
202 cloudLinkState.refresh();
203 return false;
204 }
205 const prefResult = await updateEnvironmentPreferences({
206 target,
207 publishAgentActivity: desired.publish,
208 ...(desired.holdWebhooksWhileOffline === undefined
209 ? {}
210 : { holdWebhooksWhileOffline: desired.holdWebhooksWhileOffline }),
211 });
212 if (prefResult._tag === "Failure") {
213 if (!isAtomCommandInterrupted(prefResult)) {
214 reportUpdateFailure(squashAtomCommandFailure(prefResult));
215 }
216 cloudLinkState.refresh();
217 return false;
218 }
219 }
220
221 cloudLinkState.refresh();
222 const refreshResult = await refreshRelayEnvironments();
223 if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
224 reportUpdateFailure(squashAtomCommandFailure(refreshResult));
225 return false;
226 }
227 return true;
228 };
229
230 return {
231 isSignedIn,
232 linkState: cloudLinkState,
233 linked,
234 linkRevoked,
235 managedTunnelActive,
236 managedTunnelOutOfSync,
237 publishAgentActivity,
238 holdWebhooksWhileOffline,
239 operationError,
240 tunnelLimit,
241 reconcileCloudState,
242 };
243}