| 1 | import { |
| 2 | isAtomCommandInterrupted, |
| 3 | squashAtomCommandFailure, |
| 4 | type AtomCommandResult, |
| 5 | } from "@t3tools/client-runtime/state/runtime"; |
| 6 | import type { ProviderAuthResponse, ServerProvider } from "@t3tools/contracts"; |
| 7 | import { useRef, useState } from "react"; |
| 8 | import { Alert, Linking, Pressable, ScrollView, TextInput, View } from "react-native"; |
| 9 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 10 | |
| 11 | import { AppText as Text } from "../../components/AppText"; |
| 12 | import { ScreenScrollView } from "../../components/ScreenScrollView"; |
| 13 | import { useEnvironmentQuery } from "../../state/query"; |
| 14 | import { serverEnvironment } from "../../state/server"; |
| 15 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 16 | import { SettingsActionRow } from "./components/SettingsActionRow"; |
| 17 | import { |
| 18 | AndroidSettingsEnvironmentFilter, |
| 19 | SettingsEnvironmentFilterHeader, |
| 20 | } from "./components/SettingsEnvironmentFilterHeader"; |
| 21 | import { SettingsScreen } from "./components/SettingsScreen"; |
| 22 | import { SettingsSection } from "./components/SettingsSection"; |
| 23 | import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter"; |
| 24 | |
| 25 | export 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 | |
| 74 | function 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 | |
| 357 | function 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 | } |