1import { ClerkProvider, useAuth } from "@clerk/expo";
2import { tokenCache } from "@clerk/expo/token-cache";
3import { useAtomValue } from "@effect/atom-react";
4import { ManagedRelay, setManagedRelaySession } from "@t3tools/client-runtime/relay";
5import {
6 reportAtomCommandResult,
7 settleAsyncResult,
8 settlePromise,
9 squashAtomCommandFailure,
10} from "@t3tools/client-runtime/state/runtime";
11import * as Effect from "effect/Effect";
12import { type ReactNode, useEffect, useRef } from "react";
13import { AsyncResult } from "effect/reactivity";
14
15import { runtime } from "../../lib/runtime";
16import { appAtomRegistry } from "../../state/atom-registry";
17import { useAtomCommand } from "../../state/use-atom-command";
18import {
19 getComposerCloudAccountId,
20 restoreCloudComposerDrafts,
21} from "../../state/use-composer-drafts";
22import {
23 releaseAgentAwarenessRelayTokenProvider,
24 setAgentAwarenessRelayTokenProvider,
25 unregisterAgentAwarenessDeviceForCurrentUser,
26} from "../agent-awareness/remoteRegistration";
27import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding";
28import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
29import { removeCloudEnvironments } from "./cloud-drafts";
30import { environmentCatalog } from "../../connection/catalog";
31import { mobilePreferencesAtom } from "../../state/preferences";
32import { CloudAuthAvailableContext } from "./cloudAvailability";
33import { shouldEnableCloudAuth } from "./connectionService";
34
35function resetManagedRelayTokenCache() {
36 return settleAsyncResult(() =>
37 runtime.runPromiseExit(
38 ManagedRelay.ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)),
39 ),
40 );
41}
42
43export function deactivateCloudRelayAccount(): void {
44 setAgentAwarenessRelayTokenProvider(null);
45 setManagedRelaySession(appAtomRegistry, null);
46}
47
48export function activateCloudRelayAccount(
49 accountId: string,
50 tokenProvider: () => Promise<string | null>,
51): void {
52 setAgentAwarenessRelayTokenProvider(tokenProvider, accountId);
53 setManagedRelaySession(appAtomRegistry, {
54 accountId,
55 readClerkToken: tokenProvider,
56 });
57}
58
59function CloudAuthBridge(props: { readonly children: ReactNode }) {
60 const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });
61 const removeRelayEnvironments = useAtomCommand(removeCloudEnvironments, {
62 reportFailure: false,
63 reportDefect: false,
64 });
65 const previousTokenProviderRef = useRef<{
66 readonly userId: string;
67 readonly provider: () => Promise<string | null>;
68 } | null>(null);
69 const observedAccountRef = useRef<string | null | undefined>(undefined);
70 const accountTransitionRef = useRef<Promise<void> | null>(null);
71
72 useEffect(() => {
73 let cancelled = false;
74 if (!isLoaded) {
75 return;
76 }
77
78 const previousObservedAccount = observedAccountRef.current;
79 const nextAccount = isSignedIn && userId ? userId : null;
80 observedAccountRef.current = nextAccount;
81
82 // Every sign-in or account switch that completes during this session (a
83 // cold start observes undefined → account and must not re-prompt) requests
84 // the T3 Connect onboarding sheet — account transitions clear the
85 // connected environments, so each new session starts with no devices to
86 // reach. The request itself is issued after the cleanup transition inside
87 // activateSession, so the sheet never lists the previous account's
88 // environments; sign-out drops any not-yet-presented request instead.
89 const isAccountTransition =
90 previousObservedAccount !== undefined && previousObservedAccount !== nextAccount;
91 if (isAccountTransition && nextAccount === null) {
92 clearConnectOnboardingRequest();
93 }
94
95 const cleanUpAccount = async (
96 previous: {
97 readonly userId: string;
98 readonly provider: () => Promise<string | null>;
99 } | null,
100 accountId: string | null,
101 ) => {
102 const removal = await removeRelayEnvironments(accountId);
103 if (removal._tag !== "Success") throw squashAtomCommandFailure(removal);
104 const cleanup = [
105 resetManagedRelayTokenCache(),
106 ...(previous
107 ? [
108 settleAsyncResult(() =>
109 runtime.runPromiseExit(
110 unregisterAgentAwarenessDeviceForCurrentUser(previous.provider),
111 ),
112 ),
113 ]
114 : []),
115 ];
116 const results = await Promise.all(cleanup);
117 for (const result of results) {
118 reportAtomCommandResult(result, { label: "cloud account cleanup" });
119 }
120 };
121 const queueAccountCleanup = (previous: typeof previousTokenProviderRef.current) => {
122 const previousTransition = accountTransitionRef.current ?? Promise.resolve();
123 accountTransitionRef.current = previousTransition
124 .catch(() => {})
125 .then(() => cleanUpAccount(previous, previousObservedAccount ?? null));
126 return accountTransitionRef.current;
127 };
128
129 if (!isSignedIn || !userId) {
130 const previous = previousTokenProviderRef.current;
131 previousTokenProviderRef.current = null;
132 deactivateCloudRelayAccount();
133 if (previousObservedAccount !== null) {
134 void settlePromise(() => queueAccountCleanup(previous)).then((result) => {
135 reportAtomCommandResult(result, { label: "cloud account cleanup" });
136 });
137 }
138 return;
139 }
140
141 const previous = previousTokenProviderRef.current;
142 const tokenProvider = () => getToken(resolveRelayClerkTokenOptions());
143 const activateSession = () => {
144 if (cancelled) {
145 return;
146 }
147 previousTokenProviderRef.current = { userId, provider: tokenProvider };
148 activateCloudRelayAccount(userId, tokenProvider);
149 if (isAccountTransition) {
150 requestConnectOnboarding(userId);
151 }
152 };
153 const activateAfterTransition = (transition: Promise<void>) => {
154 const activation = (async () => {
155 await transition;
156 if (cancelled) return;
157 const storedAccount = await getComposerCloudAccountId();
158 if (storedAccount !== null && storedAccount !== userId) {
159 await cleanUpAccount(null, storedAccount);
160 }
161 if (cancelled) return;
162 await restoreCloudComposerDrafts(userId);
163 activateSession();
164 })();
165 accountTransitionRef.current = activation;
166 void settlePromise(() => activation).then((result) => {
167 reportAtomCommandResult(result, { label: "cloud account activation" });
168 });
169 };
170 if (
171 previousObservedAccount !== undefined &&
172 previousObservedAccount !== null &&
173 previousObservedAccount !== userId
174 ) {
175 previousTokenProviderRef.current = null;
176 deactivateCloudRelayAccount();
177 activateAfterTransition(queueAccountCleanup(previous));
178 } else {
179 // A failed disk write can be retried. The persisted account check above
180 // still requires cleanup before activating a different account.
181 activateAfterTransition((accountTransitionRef.current ?? Promise.resolve()).catch(() => {}));
182 }
183
184 return () => {
185 cancelled = true;
186 };
187 }, [getToken, isLoaded, isSignedIn, removeRelayEnvironments, userId]);
188
189 useEffect(
190 () => () => {
191 previousTokenProviderRef.current = null;
192 // Unmounting is not a sign-out: the user is usually still signed in, so
193 // detach the provider without ending lock-screen activities or wiping the
194 // persisted registration (a remount reuses both).
195 releaseAgentAwarenessRelayTokenProvider();
196 setManagedRelaySession(appAtomRegistry, null);
197 },
198 [],
199 );
200
201 return props.children;
202}
203
204export function CloudAuthProvider(props: { readonly children: ReactNode }) {
205 const preferences = useAtomValue(mobilePreferencesAtom);
206 const catalog = useAtomValue(environmentCatalog.catalogValueAtom);
207 const config = resolveCloudPublicConfig();
208 const publishableKey = config.clerk.publishableKey;
209 const relayUrl = config.relay.url;
210 const enabled =
211 Boolean(publishableKey && relayUrl) &&
212 shouldEnableCloudAuth(
213 AsyncResult.isSuccess(preferences) ? preferences.value.connectionService : undefined,
214 catalog.entries.values(),
215 );
216
217 useEffect(() => {
218 if (!enabled) {
219 deactivateCloudRelayAccount();
220 }
221 }, [enabled]);
222
223 if (!catalog.isReady || AsyncResult.isInitial(preferences)) {
224 return null;
225 }
226
227 if (!enabled || !publishableKey) {
228 return props.children;
229 }
230
231 return (
232 <ClerkProvider telemetry={false} publishableKey={publishableKey} tokenCache={tokenCache}>
233 <CloudAuthAvailableContext.Provider value>
234 <CloudAuthBridge>{props.children}</CloudAuthBridge>
235 </CloudAuthAvailableContext.Provider>
236 </ClerkProvider>
237 );
238}