1import {
2 isAtomCommandInterrupted,
3 squashAtomCommandFailure,
4 type AtomCommandResult,
5} from "@t3tools/client-runtime/state/runtime";
6import type { ProviderAuthResponse, ServerProvider } from "@t3tools/contracts";
7import { useRef, useState } from "react";
8import { Alert, Linking, Pressable, ScrollView, TextInput, View } from "react-native";
9import { useSafeAreaInsets } from "react-native-safe-area-context";
10
11import { AppText as Text } from "../../components/AppText";
12import { ScreenScrollView } from "../../components/ScreenScrollView";
13import { useEnvironmentQuery } from "../../state/query";
14import { serverEnvironment } from "../../state/server";
15import { useAtomCommand } from "../../state/use-atom-command";
16import { SettingsActionRow } from "./components/SettingsActionRow";
17import {
18 AndroidSettingsEnvironmentFilter,
19 SettingsEnvironmentFilterHeader,
20} from "./components/SettingsEnvironmentFilterHeader";
21import { SettingsScreen } from "./components/SettingsScreen";
22import { SettingsSection } from "./components/SettingsSection";
23import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter";
24
25export function SettingsProviderAccountsRouteScreen() {
26 const { selectedTargets } = useSettingsEnvironmentFilter();
27 const insets = useSafeAreaInsets();
28 return (
29 <>
30 <SettingsEnvironmentFilterHeader />
31 <SettingsScreen title="Provider accounts" trailing={<AndroidSettingsEnvironmentFilter />}>
32 <ScreenScrollView
33 className="flex-1"
34 contentInsetAdjustmentBehavior="automatic"
35 contentContainerClassName="gap-6 px-5 pt-4"
36 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
37 >
38 {selectedTargets.length === 0 ? (
39 <Text className="text-foreground-muted">Select a connected environment.</Text>
40 ) : (
41 selectedTargets.map((environment) => (
42 <SettingsSection key={environment.environmentId} title={environment.label}>
43 {environment.serverConfig.providers
44 .filter(
45 (provider) =>
46 provider.setup?.canAuthenticate ||
47 (provider.driver === "acpRegistry" && provider.installed),
48 )
49 .map((provider) => (
50 <ProviderAccount
51 key={provider.instanceId}
52 environment={environment}
53 provider={provider}
54 />
55 ))}
56 {!environment.serverConfig.providers.some(
57 (provider) =>
58 provider.setup?.canAuthenticate ||
59 (provider.driver === "acpRegistry" && provider.installed),
60 ) ? (
61 <Text className="p-4 text-foreground-muted">
62 Configure a provider with in-app sign-in in web or desktop Settings.
63 </Text>
64 ) : null}
65 </SettingsSection>
66 ))
67 )}
68 </ScreenScrollView>
69 </SettingsScreen>
70 </>
71 );
72}
73
74function ProviderAccount({
75 environment,
76 provider,
77}: {
78 readonly environment: SettingsTarget;
79 readonly provider: ServerProvider;
80}) {
81 const environmentId = environment.environmentId;
82 const instanceId = provider.instanceId;
83 const target = { environmentId, input: { instanceId } };
84 const auth = useEnvironmentQuery(serverEnvironment.providerAuthState(target));
85 const options = { reportFailure: false, reportDefect: false };
86 const start = useAtomCommand(serverEnvironment.startProviderAuth, options);
87 const respond = useAtomCommand(serverEnvironment.respondProviderAuth, options);
88 const complete = useAtomCommand(serverEnvironment.completeProviderAuth, options);
89 const cancel = useAtomCommand(serverEnvironment.cancelProviderAuth, options);
90 const logout = useAtomCommand(serverEnvironment.logoutProviderAuth, options);
91 const [pending, setPending] = useState(false);
92 const [choosingMethod, setChoosingMethod] = useState(false);
93 const pendingRef = useRef(false);
94 const [error, setError] = useState<string | null>(null);
95 const [draft, setDraft] = useState({ id: "", values: {} as Record<string, string> });
96 const state = auth.data;
97 const interaction = state?.interaction;
98 const draftId = `${state?.flowId ?? ""}:${interaction?.id ?? ""}`;
99 const values = draft.id === draftId ? draft.values : {};
100 const active =
101 state?.phase === "starting" || state?.phase === "waiting" || state?.phase === "verifying";
102 const signedIn =
103 provider.auth.status === "authenticated" ||
104 (provider.auth.status === "unknown" && state?.phase === "succeeded");
105 const isDiscovering =
106 provider.driver === "acpRegistry" &&
107 !active &&
108 !signedIn &&
109 !auth.error &&
110 state?.methods === undefined;
111 const needsExternalSetup =
112 !active &&
113 !signedIn &&
114 (provider.setup?.canAuthenticate === false ||
115 (provider.driver === "acpRegistry" && state?.methods?.length === 0));
116 const url =
117 interaction?.type === "browser" || interaction?.type === "deviceCode"
118 ? interaction.url
119 : state?.authorizationUrl;
120 const disabled = pending || auth.error !== null || isDiscovering;
121 async function run(command: () => Promise<AtomCommandResult<unknown, unknown>>) {
122 if (pendingRef.current) return false;
123 pendingRef.current = true;
124 setPending(true);
125 setError(null);
126 let succeeded = false;
127 try {
128 const result = await command();
129 if (result._tag === "Success") succeeded = true;
130 else if (!isAtomCommandInterrupted(result)) {
131 const failure = squashAtomCommandFailure(result);
132 setError(failure instanceof Error ? failure.message : "Could not update provider sign-in.");
133 }
134 } catch {
135 setError("Could not update provider sign-in.");
136 }
137 pendingRef.current = false;
138 setPending(false);
139 return succeeded;
140 }
141 function send(response: ProviderAuthResponse) {
142 if (!state?.flowId || !interaction) return Promise.resolve(false);
143 return run(() =>
144 respond({
145 environmentId,
146 input: { instanceId, flowId: state.flowId!, interactionId: interaction.id, response },
147 }),
148 );
149 }
150 function field(name: string, label: string, secret: boolean) {
151 return (
152 <TextInput
153 accessibilityLabel={label}
154 className="rounded-lg border border-border-subtle px-3 py-2 text-base text-foreground"
155 placeholderTextColorClassName="accent-foreground-muted"
156 placeholder={label}
157 secureTextEntry={secret}
158 autoCapitalize="none"
159 autoCorrect={false}
160 editable={!disabled}
161 maxLength={secret && interaction?.type === "terminal" ? 4_095 : 16_384}
162 value={values[name] ?? ""}
163 onChangeText={(value) => setDraft({ id: draftId, values: { ...values, [name]: value } })}
164 />
165 );
166 }
167 function chooseMethod() {
168 const methods = state?.methods ?? [];
169 if (methods.length <= 1) {
170 void run(() => start(target));
171 return;
172 }
173 setChoosingMethod(true);
174 }
175 return (
176 <View className="border-b border-border-subtle">
177 <View className="gap-2 p-4">
178 <Text className="text-lg font-semibold text-foreground">
179 {provider.displayName ?? provider.driver}
180 </Text>
181 <Text accessibilityLiveRegion="polite" className="text-sm text-foreground-muted">
182 {active || state?.phase === "failed" || state?.phase === "cancelled"
183 ? state.message
184 : signedIn
185 ? "Signed in."
186 : isDiscovering
187 ? "Discovering sign-in methods…"
188 : needsExternalSetup
189 ? "No in-app sign-in advertised. Follow the provider's docs to finish setup."
190 : "Connect this provider."}
191 </Text>
192 {signedIn && !active && provider.auth.email?.trim() ? (
193 <ProviderAccountEmail key={provider.auth.email} email={provider.auth.email} />
194 ) : null}
195 {interaction?.type === "deviceCode" ? (
196 <Text selectable className="text-foreground">
197 Enter code {interaction.userCode} on the sign-in page.
198 </Text>
199 ) : null}
200 {interaction?.type === "terminal" ? (
201 <>
202 <ScrollView className="max-h-64" nestedScrollEnabled>
203 <Text selectable className="font-mono text-sm text-foreground">
204 {interaction.output.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")}
205 </Text>
206 </ScrollView>
207 {field("input", "Terminal response", true)}
208 <SettingsActionRow
209 icon="arrow.up"
210 label="Send response"
211 disabled={disabled}
212 onPress={() => {
213 void send({ type: "terminal", data: `${values.input ?? ""}\r` }).then((sent) => {
214 if (sent) setDraft({ id: "", values: {} });
215 });
216 }}
217 />
218 </>
219 ) : null}
220 {interaction?.type === "credentials" ? (
221 <>
222 {interaction.fields.map((entry) => (
223 <View key={entry.name}>{field(entry.name, entry.label, entry.secret)}</View>
224 ))}
225 <SettingsActionRow
226 icon="person.crop.circle"
227 label="Connect"
228 disabled={disabled}
229 onPress={() => {
230 void send({ type: "credentials", values }).then((sent) => {
231 if (sent) setDraft({ id: "", values: {} });
232 });
233 }}
234 />
235 </>
236 ) : null}
237 {url && (interaction?.type === "browser" ? interaction.acceptsCallback : !interaction) ? (
238 <>
239 {field("callback", "Final localhost URL", false)}
240 <SettingsActionRow
241 icon="arrow.right"
242 label="Continue"
243 disabled={disabled || !values.callback?.trim()}
244 onPress={() => {
245 if (!state?.flowId) return;
246 void run(() =>
247 complete({
248 environmentId,
249 input: { instanceId, flowId: state.flowId!, callbackUrl: values.callback! },
250 }),
251 ).then((sent) => {
252 if (sent) setDraft({ id: "", values: {} });
253 });
254 }}
255 />
256 </>
257 ) : null}
258 {error || auth.error ? (
259 <Text accessibilityRole="alert" className="text-danger-foreground">
260 {error ?? auth.error}
261 </Text>
262 ) : null}
263 </View>
264 {choosingMethod && !active ? (
265 <View>
266 {state?.methods?.map((method) => (
267 <SettingsActionRow
268 key={method.id}
269 icon="person.crop.circle"
270 label={method.name}
271 disabled={disabled}
272 onPress={() => {
273 setChoosingMethod(false);
274 void run(() =>
275 start({ environmentId, input: { instanceId, methodId: method.id } }),
276 );
277 }}
278 />
279 ))}
280 <SettingsActionRow icon="xmark" label="Cancel" onPress={() => setChoosingMethod(false)} />
281 </View>
282 ) : null}
283 {url ? (
284 <SettingsActionRow
285 icon="globe"
286 label="Open sign-in page"
287 disabled={disabled}
288 onPress={() => {
289 void (async () => {
290 if (
291 interaction?.type === "browser" &&
292 interaction.requiresConsent &&
293 !(await send({ type: "browser", action: "accept" }))
294 )
295 return;
296 await Linking.openURL(url);
297 })().catch(() => setError("Could not open the sign-in page."));
298 }}
299 />
300 ) : null}
301 {needsExternalSetup && provider.setup?.documentationUrl ? (
302 <SettingsActionRow
303 icon="globe"
304 label="Open docs"
305 onPress={() => {
306 void Linking.openURL(provider.setup!.documentationUrl!).catch(() =>
307 setError("Could not open the provider docs."),
308 );
309 }}
310 />
311 ) : active && state?.flowId ? (
312 <SettingsActionRow
313 icon="xmark"
314 label="Cancel sign-in"
315 disabled={disabled}
316 onPress={() => {
317 void run(() => cancel({ environmentId, input: { instanceId, flowId: state.flowId! } }));
318 }}
319 />
320 ) : !active && !needsExternalSetup && provider.setup?.canAuthenticate !== false ? (
321 <SettingsActionRow
322 icon="person.crop.circle"
323 label={signedIn ? "Change account" : "Sign in"}
324 disabled={disabled || !provider.enabled || !provider.installed || state === null}
325 loading={pending}
326 onPress={chooseMethod}
327 />
328 ) : null}
329 {!active && signedIn && (provider.auth.canLogout ?? provider.setup?.canAuthenticate) ? (
330 <SettingsActionRow
331 icon="person.crop.circle"
332 label="Sign out"
333 tone="danger"
334 disabled={disabled || state === null}
335 onPress={() =>
336 Alert.alert(
337 "Sign out?",
338 `Running threads sharing this sign-in on ${environment.label} will stop. Thread history is kept.`,
339 [
340 { text: "Cancel", style: "cancel" },
341 {
342 text: "Sign out",
343 style: "destructive",
344 onPress: () => {
345 void run(() => logout(target));
346 },
347 },
348 ],
349 )
350 }
351 />
352 ) : null}
353 </View>
354 );
355}
356
357function ProviderAccountEmail({ email }: { readonly email: string }) {
358 const [revealed, setRevealed] = useState(false);
359 return (
360 <Pressable
361 accessibilityRole="button"
362 accessibilityLabel={revealed ? "Hide account email" : "Reveal account email"}
363 onPress={() => setRevealed((value) => !value)}
364 className="min-h-[44px] justify-center"
365 >
366 <Text className="text-sm text-foreground-muted">{revealed ? email : "••••••@••••••"}</Text>
367 </Pressable>
368 );
369}