| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useAuth } from "@clerk/react"; |
| 3 | import { findErrorTraceId } from "@t3tools/client-runtime/errors"; |
| 4 | import { |
| 5 | EnvironmentId, |
| 6 | AuthRelayReadScope, |
| 7 | AuthRelayWriteScope, |
| 8 | type EnvironmentCloudLinkStateResult, |
| 9 | } from "@t3tools/contracts"; |
| 10 | import type { RelayManagedEndpointProviderKind } from "@t3tools/contracts/relay"; |
| 11 | import { |
| 12 | isAtomCommandInterrupted, |
| 13 | settlePromise, |
| 14 | squashAtomCommandFailure, |
| 15 | } from "@t3tools/client-runtime/state/runtime"; |
| 16 | import { useState } from "react"; |
| 17 | |
| 18 | import { toastManager } from "../components/ui/toast"; |
| 19 | import { relayEnvironmentDiscovery } from "../state/relay"; |
| 20 | import { readEnvironmentScope } from "../state/session"; |
| 21 | import { useAtomCommand } from "../state/use-atom-command"; |
| 22 | import { |
| 23 | linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, |
| 24 | unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, |
| 25 | updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom, |
| 26 | } from "./linkEnvironmentAtoms"; |
| 27 | import { readCachedPrimaryCloudLinkState, usePrimaryCloudLinkState } from "./primaryCloudLinkState"; |
| 28 | import { resolveRelayClerkTokenOptions } from "./publicConfig"; |
| 29 | |
| 30 | export interface CloudLinkDesiredState { |
| 31 | readonly managedTunnel: boolean; |
| 32 | readonly publish: boolean; |
| 33 | /** Omit to leave the webhook-hold setting as it is. */ |
| 34 | readonly holdWebhooksWhileOffline?: boolean; |
| 35 | } |
| 36 | |
| 37 | /** |
| 38 | * Whether the link has a working managed tunnel. The local tunnel config can |
| 39 | * outlive a relay link that drifted to publish-only, and clients then get |
| 40 | * `endpoint_provider_not_managed`. So when relay discovery has this |
| 41 | * environment's record, it must also say managed. Older environment servers |
| 42 | * predate managedTunnelActive; for them a link always implies a tunnel. A link |
| 43 | * the relay rejected (usually after a Deregister) gives no access at all. |
| 44 | */ |
| 45 | function isManagedTunnelActive( |
| 46 | linkState: EnvironmentCloudLinkStateResult, |
| 47 | relayProviderKind: RelayManagedEndpointProviderKind | undefined, |
| 48 | ) { |
| 49 | const localManagedTunnel = linkState.managedTunnelActive ?? linkState.linked; |
| 50 | return ( |
| 51 | linkState.linkRevoked !== true && |
| 52 | localManagedTunnel && |
| 53 | (relayProviderKind === undefined || relayProviderKind === "cloudflare_tunnel") |
| 54 | ); |
| 55 | } |
| 56 | |
| 57 | /** |
| 58 | * Drives the primary environment's T3 Connect link. T3 Connect (managed |
| 59 | * tunnel) and agent-activity publishing are independent capabilities backed by |
| 60 | * a single relay link, so consumers express the full desired state and |
| 61 | * `reconcileCloudState` applies it: unlink when neither is wanted, otherwise |
| 62 | * (re)link with the mode the managed-tunnel bit implies and set the publish |
| 63 | * preference. Re-linking only happens when the managed-tunnel mode actually |
| 64 | * changes, so flipping publish alone is cheap. |
| 65 | */ |
| 66 | export function useCloudLinkController() { |
| 67 | const { getToken, isSignedIn } = useAuth(); |
| 68 | const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { |
| 69 | reportFailure: false, |
| 70 | }); |
| 71 | const linkPrimaryEnvironment = useAtomCommand(linkPrimaryEnvironmentAtom, { |
| 72 | reportFailure: false, |
| 73 | }); |
| 74 | const unlinkPrimaryEnvironment = useAtomCommand(unlinkPrimaryEnvironmentAtom, { |
| 75 | reportFailure: false, |
| 76 | }); |
| 77 | const updatePrimaryEnvironmentPreferences = useAtomCommand( |
| 78 | updatePrimaryEnvironmentPreferencesAtom, |
| 79 | { reportFailure: false }, |
| 80 | ); |
| 81 | const primaryCloudLinkState = usePrimaryCloudLinkState(); |
| 82 | const relayEnvironments = useAtomValue(relayEnvironmentDiscovery.stateValueAtom).environments; |
| 83 | const primaryEnvironmentId = primaryCloudLinkState.target?.environmentId; |
| 84 | const relayProviderKind = primaryEnvironmentId |
| 85 | ? relayEnvironments.get(primaryEnvironmentId)?.environment.endpoint.providerKind |
| 86 | : undefined; |
| 87 | const [operationError, setOperationError] = useState<string | null>(null); |
| 88 | |
| 89 | const reportUpdateFailure = (cause: unknown) => { |
| 90 | const message = cause instanceof Error ? cause.message : "Could not update T3 Connect access."; |
| 91 | const traceId = findErrorTraceId(cause); |
| 92 | console.error("[t3-connect] Could not update T3 Connect", { message, traceId, cause }); |
| 93 | setOperationError(traceId ? `${message} Trace ID: ${traceId}` : message); |
| 94 | toastManager.add({ |
| 95 | type: "error", |
| 96 | title: "Could not update T3 Connect", |
| 97 | description: message, |
| 98 | data: traceId |
| 99 | ? { |
| 100 | secondaryActionProps: { |
| 101 | children: "Copy trace ID", |
| 102 | onClick: () => void navigator.clipboard?.writeText(traceId), |
| 103 | }, |
| 104 | } |
| 105 | : undefined, |
| 106 | }); |
| 107 | }; |
| 108 | |
| 109 | const linkData = primaryCloudLinkState.data; |
| 110 | // A revoked link reads as off; turning it on links again. |
| 111 | const linkRevoked = linkData?.linkRevoked ?? false; |
| 112 | const managedTunnelActive = linkData ? isManagedTunnelActive(linkData, relayProviderKind) : false; |
| 113 | // Local state says managed but the relay holds a publish-only link. Turning |
| 114 | // T3 Connect on relinks as managed, which repairs it. |
| 115 | const managedTunnelOutOfSync = |
| 116 | linkData !== null && !managedTunnelActive && isManagedTunnelActive(linkData, undefined); |
| 117 | const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false; |
| 118 | const holdWebhooksWhileOffline = primaryCloudLinkState.data?.holdWebhooksWhileOffline ?? false; |
| 119 | const linked = !linkRevoked && (primaryCloudLinkState.data?.linked ?? false); |
| 120 | |
| 121 | const reconcileCloudState = async (desired: CloudLinkDesiredState): Promise<boolean> => { |
| 122 | setOperationError(null); |
| 123 | const target = primaryCloudLinkState.target; |
| 124 | if (!target) { |
| 125 | reportUpdateFailure(new Error("Local environment is not ready yet.")); |
| 126 | return false; |
| 127 | } |
| 128 | const environmentId = EnvironmentId.make(target.environmentId); |
| 129 | const canManageLink = () => { |
| 130 | if ( |
| 131 | !readEnvironmentScope(environmentId, AuthRelayReadScope) || |
| 132 | !readEnvironmentScope(environmentId, AuthRelayWriteScope) |
| 133 | ) { |
| 134 | reportUpdateFailure( |
| 135 | new Error("This connection needs permission to view and manage T3 Connect settings."), |
| 136 | ); |
| 137 | return false; |
| 138 | } |
| 139 | return true; |
| 140 | }; |
| 141 | const readLinkState = () => { |
| 142 | const state = readCachedPrimaryCloudLinkState(target); |
| 143 | if (state === null) { |
| 144 | reportUpdateFailure(new Error("Wait until the current T3 Connect settings can be read.")); |
| 145 | } |
| 146 | return state; |
| 147 | }; |
| 148 | if (!canManageLink()) return false; |
| 149 | const wantsLink = desired.managedTunnel || desired.publish; |
| 150 | if (wantsLink && readLinkState() === null) return false; |
| 151 | const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); |
| 152 | if (!canManageLink()) return false; |
| 153 | |
| 154 | // A failure after this point may follow a partially applied mutation (e.g. |
| 155 | // the link succeeded but the preference update did not), so every exit — |
| 156 | // success or failure — refreshes the rendered state to whatever the server |
| 157 | // actually holds now. |
| 158 | if (!wantsLink) { |
| 159 | // Unlink works without a relay token — a failed token read must not |
| 160 | // leave the user unable to turn T3 Connect off. |
| 161 | const unlinkResult = await unlinkPrimaryEnvironment({ |
| 162 | target, |
| 163 | clerkToken: tokenResult._tag === "Success" ? (tokenResult.value ?? null) : null, |
| 164 | }); |
| 165 | if (unlinkResult._tag === "Failure") { |
| 166 | if (!isAtomCommandInterrupted(unlinkResult)) { |
| 167 | reportUpdateFailure(squashAtomCommandFailure(unlinkResult)); |
| 168 | } |
| 169 | primaryCloudLinkState.refresh(); |
| 170 | return false; |
| 171 | } |
| 172 | } else { |
| 173 | const currentLinkState = readLinkState(); |
| 174 | if (currentLinkState === null) return false; |
| 175 | if (tokenResult._tag === "Failure") { |
| 176 | reportUpdateFailure(squashAtomCommandFailure(tokenResult)); |
| 177 | return false; |
| 178 | } |
| 179 | const clerkToken = tokenResult.value; |
| 180 | if (!clerkToken) { |
| 181 | reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this.")); |
| 182 | return false; |
| 183 | } |
| 184 | const currentManagedTunnel = isManagedTunnelActive(currentLinkState, relayProviderKind); |
| 185 | if ( |
| 186 | !currentLinkState.linked || |
| 187 | currentLinkState.linkRevoked === true || |
| 188 | currentManagedTunnel !== desired.managedTunnel |
| 189 | ) { |
| 190 | const linkResult = await linkPrimaryEnvironment({ |
| 191 | target, |
| 192 | clerkToken, |
| 193 | mode: desired.managedTunnel ? "managed" : "publish_only", |
| 194 | }); |
| 195 | if (linkResult._tag === "Failure") { |
| 196 | if (!isAtomCommandInterrupted(linkResult)) { |
| 197 | reportUpdateFailure(squashAtomCommandFailure(linkResult)); |
| 198 | } |
| 199 | primaryCloudLinkState.refresh(); |
| 200 | return false; |
| 201 | } |
| 202 | } |
| 203 | if (!canManageLink() || readLinkState() === null) { |
| 204 | primaryCloudLinkState.refresh(); |
| 205 | return false; |
| 206 | } |
| 207 | const prefResult = await updatePrimaryEnvironmentPreferences({ |
| 208 | target, |
| 209 | publishAgentActivity: desired.publish, |
| 210 | ...(desired.holdWebhooksWhileOffline === undefined |
| 211 | ? {} |
| 212 | : { holdWebhooksWhileOffline: desired.holdWebhooksWhileOffline }), |
| 213 | }); |
| 214 | if (prefResult._tag === "Failure") { |
| 215 | if (!isAtomCommandInterrupted(prefResult)) { |
| 216 | reportUpdateFailure(squashAtomCommandFailure(prefResult)); |
| 217 | } |
| 218 | primaryCloudLinkState.refresh(); |
| 219 | return false; |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | primaryCloudLinkState.refresh(); |
| 224 | const refreshResult = await refreshRelayEnvironments(); |
| 225 | if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) { |
| 226 | reportUpdateFailure(squashAtomCommandFailure(refreshResult)); |
| 227 | return false; |
| 228 | } |
| 229 | return true; |
| 230 | }; |
| 231 | |
| 232 | return { |
| 233 | isSignedIn, |
| 234 | linkState: primaryCloudLinkState, |
| 235 | linked, |
| 236 | linkRevoked, |
| 237 | managedTunnelActive, |
| 238 | managedTunnelOutOfSync, |
| 239 | publishAgentActivity, |
| 240 | holdWebhooksWhileOffline, |
| 241 | operationError, |
| 242 | reconcileCloudState, |
| 243 | }; |
| 244 | } |