@pithy-sh/ui-react-native 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/dist/generatedConfig.d.ts +195 -0
  3. package/dist/generatedConfig.d.ts.map +1 -0
  4. package/dist/generatedConfig.js +323 -0
  5. package/dist/templates.d.ts +48 -0
  6. package/dist/templates.d.ts.map +1 -0
  7. package/dist/templates.js +85 -0
  8. package/dist/version.d.ts +5 -0
  9. package/dist/version.d.ts.map +1 -0
  10. package/dist/version.js +10 -0
  11. package/package.json +83 -4
  12. package/src/generatedConfig.ts +419 -0
  13. package/src/templates.ts +92 -0
  14. package/src/version.ts +13 -0
  15. package/templates/app/[...pithy].tsx +7 -0
  16. package/templates/app/_layout.tsx +31 -0
  17. package/templates/app/index.tsx +4 -0
  18. package/templates/app.config.ts +56 -0
  19. package/templates/metro-proxy.cjs +289 -0
  20. package/templates/metro.config.js +6 -0
  21. package/templates/src/payments-store.native.ts +20 -0
  22. package/templates/src/payments-store.ts +16 -0
  23. package/templates/src/payments.tsx +769 -0
  24. package/templates/src/pithy-config.generated.ts +99 -0
  25. package/templates/src/pithy-config.ts +169 -0
  26. package/templates/src/pithy-locale.tsx +90 -0
  27. package/templates/src/platform.ts +111 -0
  28. package/templates/src/route-modules.ts +76 -0
  29. package/templates/src/router.tsx +387 -0
  30. package/templates/src/routes/app/home.bare.tsx +75 -0
  31. package/templates/src/routes/app/home.tsx +55 -0
  32. package/templates/src/routes/pithy/account-deleted.tsx +69 -0
  33. package/templates/src/routes/pithy/account-suspended.tsx +70 -0
  34. package/templates/src/routes/pithy/agent-consent.tsx +260 -0
  35. package/templates/src/routes/pithy/callback.tsx +74 -0
  36. package/templates/src/routes/pithy/choose-organization.tsx +230 -0
  37. package/templates/src/routes/pithy/connected-applications.tsx +153 -0
  38. package/templates/src/routes/pithy/consent.tsx +453 -0
  39. package/templates/src/routes/pithy/invitation.tsx +239 -0
  40. package/templates/src/routes/pithy/organization-suspended.tsx +51 -0
  41. package/templates/src/routes/pithy/otp.tsx +196 -0
  42. package/templates/src/routes/pithy/paywall.tsx +253 -0
  43. package/templates/src/routes/pithy/pricing.tsx +317 -0
  44. package/templates/src/routes/pithy/sign-in.tsx +673 -0
  45. package/templates/src/routes/pithy/subscription.tsx +240 -0
  46. package/templates/src/session.tsx +290 -0
  47. package/templates/src/theme.ts +100 -0
  48. package/templates/src/transport.ts +290 -0
  49. package/templates/src/turnstile.tsx +117 -0
  50. package/templates/src/useAppFocus.ts +20 -0
  51. package/templates/src/useStoreTransactions.ts +16 -0
  52. package/templates/src/useTurnstileWeb.ts +104 -0
  53. package/templates/tsconfig.json +10 -0
  54. package/README.md +0 -3
@@ -0,0 +1,673 @@
1
+ import { sendMagicLink, sendOtp, startSocialSignIn } from "@pithy-sh/auth/src/client/api";
2
+ import { handoffCode, nativeSignInStartURL } from "@pithy-sh/auth/src/client/handoff";
3
+ // The one code a refused provider sign-in comes back with. Imported rather than spelled: a literal here and a
4
+ // literal there is a pair that can drift, and the drift would show as this block silently never rendering again.
5
+ import { NEUTRAL_PROVIDER_REFUSAL } from "@pithy-sh/auth/src/http/providerRefusal";
6
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
7
+ import type { Translator } from "@pithy-sh/core/src/i18n/translator";
8
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
9
+ import { useRead } from "@pithy-sh/ui-core/src/read";
10
+ import { screenPath } from "@pithy-sh/ui-core/src/routes";
11
+ import {
12
+ GoogleSignin,
13
+ isErrorWithCode,
14
+ isSuccessResponse,
15
+ statusCodes,
16
+ } from "@react-native-google-signin/google-signin";
17
+ import * as AppleAuthentication from "expo-apple-authentication";
18
+ import { CryptoDigestAlgorithm, digestStringAsync, getRandomBytes } from "expo-crypto";
19
+ import * as WebBrowser from "expo-web-browser";
20
+ import { type ReactNode, useCallback, useMemo, useState } from "react";
21
+ import { Platform, Pressable, Text, TextInput, useWindowDimensions, View } from "react-native";
22
+ import { authConfig, googleClientIds, origin, universalLinkOrigin } from "../../pithy-config";
23
+ import { appLink, onDevice, parseLink, parseQuery, queryString } from "../../platform";
24
+ import { Redirect, routeTable, useGo, useSearch, useSearchParam } from "../../router";
25
+ import {
26
+ abandonHandoff,
27
+ afterSignIn,
28
+ beginHandoff,
29
+ completeHandoff,
30
+ type IdTokenUser,
31
+ leaveInPlace,
32
+ refusedAccountScreen,
33
+ resumeQuery,
34
+ returnLink,
35
+ type SignInOutcome,
36
+ signInWithIdToken,
37
+ } from "../../session";
38
+ import { type Theme, useTheme } from "../../theme";
39
+ import { transport } from "../../transport";
40
+ import { Turnstile, turnstilePending, turnstileRequest } from "../../turnstile";
41
+ // Every link that returns to the app is built from this, never from a literal of the same shape: renaming
42
+ // `callback.tsx`'s path is an edit that typechecks, builds, and would break the one round trip nobody signed in
43
+ // can test.
44
+ import { path as callbackPath } from "./callback";
45
+ import { path as otpPath } from "./otp";
46
+
47
+ export const path = "/sign-in";
48
+
49
+ // The screen the router's signed-out guard sends people to. Rename `path` above and the guard follows, because it
50
+ // reads this claim rather than holding a copy of the string.
51
+ export const role = "sign-in";
52
+
53
+ // Pithy's screen. Yours to override: put your own file at this path under src/routes/app/ and it wins.
54
+ // (Guarding your home screen is one line: see `export const session` in src/routes/app/home.tsx.)
55
+
56
+ /*
57
+ * ─────────────────────────────────────────────────────────────────────────────
58
+ * THE TWO SLOTS. This is the part of the screen that is yours, and it ships empty.
59
+ *
60
+ * On a wide screen, a tablet or a desktop browser, sign-in is two columns: a panel that says what this product
61
+ * is, and the form. The panel is your mark, your sentence, your claims, so it is a slot rather than copy you
62
+ * would have to find and delete. Fill it or leave it: with nothing here the screen is one column.
63
+ *
64
+ * const BRAND = (
65
+ * <>
66
+ * <Text>Your data stays yours.</Text>
67
+ * <Text>One sentence about what this is.</Text>
68
+ * </>
69
+ * );
70
+ * const MARK = <Image source={require("../../../assets/logo.png")} style={{ height: 28 }} />;
71
+ *
72
+ * BRAND is the panel. MARK is the small header above the form on a narrow screen, where the panel is not drawn,
73
+ * so exactly one of the two is on screen at any width.
74
+ * ─────────────────────────────────────────────────────────────────────────────
75
+ */
76
+
77
+ /** The panel beside the form. Yours. */
78
+ const BRAND: ReactNode = null;
79
+
80
+ /** The compact mark, for the widths the panel is not on. Yours. */
81
+ const MARK: ReactNode = null;
82
+
83
+ /** The width from which the panel is drawn beside the form. */
84
+ const TWO_COLUMNS = 768;
85
+
86
+ /**
87
+ * This screen's English, baked in.
88
+ *
89
+ * This file is written into your repository once and belongs to you afterwards, so the English lives here, and a
90
+ * project that composes no `i18n` capability renders exactly these sentences. With `i18n` composed it becomes the
91
+ * last layer instead: your own catalog first, then the kit's translation, then this.
92
+ */
93
+ const EN = {
94
+ "auth/sign_in.title": "Welcome.",
95
+ "auth/sign_in.provider.label": "Continue with {provider}",
96
+ "auth/sign_in.provider_unconfigured": "{provider} is not configured here. Use the link instead.",
97
+ "auth/sign_in.provider_silent": "{provider} didn't answer. Use the link instead.",
98
+ // The three-point refusal, and the order is the point: say what the provider told us, say that a secondary
99
+ // address is refused on purpose, then say the remedy. Not one word about whether the account exists: the
100
+ // Worker sends one code for "no account" and "not attached", and every sentence below is true of both.
101
+ "auth/sign_in.refused.title": "{provider} didn't sign you in.",
102
+ "auth/sign_in.refused.primary":
103
+ "{provider} gives us one address: the primary on your account. That one did not get you in here.",
104
+ "auth/sign_in.refused.secondary":
105
+ "A secondary address will not do it. Sign-in never looks past the primary, deliberately.",
106
+ "auth/sign_in.refused.remedy":
107
+ "Sign in with your email, then connect {provider} from your profile. After that, this works whatever your primary is.",
108
+ "auth/sign_in.divider": "or",
109
+ "auth/sign_in.email.label": "Email",
110
+ "auth/sign_in.submit": "Email me a link",
111
+ "auth/sign_in.signup.prompt": "No account yet?",
112
+ "auth/sign_in.signup.answer": "Signing in creates one.",
113
+ "auth/sign_in.signup.closed": "Existing accounts only.",
114
+ // Said instead of `signup.answer` when a provider on this screen may sign people in but not create an account.
115
+ "auth/sign_in.signup.email_only":
116
+ "Emailing you a link creates one. The buttons above sign in existing accounts only.",
117
+ "auth/sign_in.sent.title": "Check your inbox.",
118
+ "auth/sign_in.sent.body": "If that address can sign in, a link is on its way. The link expires shortly.",
119
+ // On a phone with no universal link the email path is a one-time code, so the copy that names the link names the
120
+ // code instead. Keyed under the code screen's domain: on the sign-in screen's own keys, a code is never offered.
121
+ "auth/otp.ask.submit": "Email me a code",
122
+ "auth/otp.ask.provider_unconfigured": "{provider} is not configured here. Use a code instead.",
123
+ "auth/otp.ask.provider_silent": "{provider} didn't answer. Use a code instead.",
124
+ "auth/otp.ask.signup_email_only":
125
+ "Emailing you a code creates one. The buttons above sign in existing accounts only.",
126
+ } satisfies MessageCatalog;
127
+
128
+ /** The auth capability's client-safe projection: the half of it this screen reads. */
129
+ export interface AuthProjection {
130
+ /** Which social providers are switched on in `pithy.config.ts`. Credentials never reach the app. */
131
+ readonly providers: Readonly<Record<string, boolean>>;
132
+ /** Which of those may **create** an account, from each provider's `allowSignUp`. Absent means none refuses. */
133
+ readonly providerSignUp?: Readonly<Record<string, boolean>>;
134
+ /** Whether signing in may provision a new account. Drives one sentence of copy. */
135
+ readonly signUpEnabled: boolean;
136
+ }
137
+
138
+ /** The humanity check, injected so this module never has to know whether one is composed. */
139
+ export interface HumanityCheck {
140
+ /** The widget itself. Rendered inside the form, above the submit. */
141
+ readonly widget: ReactNode;
142
+ /** True while the check still owes a token, so the gated submit stays disabled. */
143
+ readonly pending: boolean;
144
+ /** Puts the token where the middleware reads it: a body field, or a header. */
145
+ readonly attach: (body: Record<string, unknown>) => {
146
+ body: Record<string, unknown>;
147
+ headers: Record<string, string>;
148
+ };
149
+ }
150
+
151
+ /** No check at all: what the screen does when the turnstile capability is not composed. */
152
+ const NO_CHECK: HumanityCheck = { widget: null, pending: false, attach: (body) => ({ body, headers: {} }) };
153
+
154
+ export interface SignInScreenProps {
155
+ /** The translator this screen renders through. Absent, the screen reads the mounted provider, or {@link EN}. */
156
+ readonly t?: Translator;
157
+ /** The auth capability's projection. */
158
+ readonly auth: AuthProjection;
159
+ /** The humanity check. Absent means none is composed. */
160
+ readonly check?: HumanityCheck;
161
+ /** The panel beside the form. Absent means one column. */
162
+ readonly brand?: ReactNode;
163
+ /** The compact mark, shown where the panel is not. */
164
+ readonly mark?: ReactNode;
165
+ }
166
+
167
+ /*
168
+ * ─────────────────────────────────────────────────────────────────────────────
169
+ * THE PROVIDER BUTTONS CARRY NO MARK, except Apple's on iOS.
170
+ *
171
+ * Provider logos are trademarks, each under its owner's brand terms, and React Native draws no SVG without
172
+ * `react-native-svg`. A hand-drawn approximation of a logo on a credentials screen is what a phishing page looks
173
+ * like, so the buttons ship as words. If you add a mark, use the owner's published asset and check your use
174
+ * against their terms: Google's (https://developers.google.com/identity/branding-guidelines) require the
175
+ * four-color "G", never a single-color glyph.
176
+ *
177
+ * On iOS, Apple is Apple's own `AppleAuthenticationButton`, which draws the mark, the words and the shape its
178
+ * guidelines require.
179
+ * ─────────────────────────────────────────────────────────────────────────────
180
+ */
181
+
182
+ /** The providers the auth projection can name, in the order they render. */
183
+ const SOCIAL: readonly { id: string; label: string }[] = [
184
+ { id: "google", label: "Google" },
185
+ { id: "github", label: "GitHub" },
186
+ { id: "apple", label: "Apple" },
187
+ { id: "facebook", label: "Facebook" },
188
+ { id: "microsoft", label: "Microsoft" },
189
+ ];
190
+
191
+ /**
192
+ * Why a provider button did not sign you in. `unconfigured` and `silent` are learned on this screen; `refused`
193
+ * is the provider's answer, from the link the sign-in came back on or from the Worker's answer to a native token.
194
+ * **`refused` and not `no_account`**: the Worker deliberately does not say whether the account exists.
195
+ */
196
+ type Refusal = { provider: string; reason: "unconfigured" | "silent" | "refused" } | null;
197
+
198
+ function refusalText(t: Translator, refusal: NonNullable<Refusal>, byCode: boolean): string {
199
+ const key =
200
+ refusal.reason === "unconfigured"
201
+ ? byCode
202
+ ? "auth/otp.ask.provider_unconfigured"
203
+ : "auth/sign_in.provider_unconfigured"
204
+ : byCode
205
+ ? "auth/otp.ask.provider_silent"
206
+ : "auth/sign_in.provider_silent";
207
+ return t.t(key, { provider: refusal.provider });
208
+ }
209
+
210
+ /**
211
+ * Whether email signs in with a one-time code rather than a magic link: on a phone with no universal link. A magic
212
+ * link returns to the link it was asked for, and whoever asks for it is not who opens it. A custom scheme is one
213
+ * any app can claim, so the Worker hands a magic link off only to an https universal link (iOS) or app link
214
+ * (Android), which the system opens only in the app that owns the domain. Set `universalLinkOrigin` and the link is
215
+ * back.
216
+ */
217
+ function emailByCode(): boolean {
218
+ return onDevice() && universalLinkOrigin === null;
219
+ }
220
+
221
+ /**
222
+ * The refusal the Worker sent back on the link, read off it. The provider half is this screen's own: it wrote the
223
+ * id into `errorCallbackURL` before it left, since nothing on screen survives the round trip. The id is resolved
224
+ * through {@link SOCIAL} and never rendered, because anyone can hand somebody a `/sign-in?provider=…&error=…`
225
+ * link, and an id nobody offers renders nothing.
226
+ */
227
+ function linkRefusal(error: string | null, providerId: string | null): Refusal {
228
+ if (error !== NEUTRAL_PROVIDER_REFUSAL) return null;
229
+ const known = SOCIAL.find((candidate) => candidate.id === providerId);
230
+ return known ? { provider: known.label, reason: "refused" } : null;
231
+ }
232
+
233
+ /** How a provider signs in here: its own SDK's id token on a phone where it has one, the redirect otherwise. */
234
+ type Way = "apple" | "google" | "redirect";
235
+
236
+ function wayFor(id: string, appleNative: boolean): Way {
237
+ if (id === "apple" && appleNative) return "apple";
238
+ // Google's SDK asks for an id token whose audience is the web client id, the one the Worker checks.
239
+ if (id === "google" && onDevice() && googleClientIds.webClientId) return "google";
240
+ return "redirect";
241
+ }
242
+
243
+ /** How a provider attempt ended: signed in or refused, or `null` when the reader backed out and nothing is said. */
244
+ type Attempt = SignInOutcome | null;
245
+
246
+ /** A provider's own SDK answered with no token: the provider is switched on with no credential behind it. */
247
+ const UNCONFIGURED = "client/provider_unconfigured";
248
+
249
+ /** Anything that is not a sign-in and not a reason this screen knows: the provider did not answer. */
250
+ const SILENT = "client/provider_silent";
251
+
252
+ /** 32 random bytes as hex: a nonce Apple's identity token is bound to. */
253
+ function rawNonce(): string {
254
+ return Array.from(getRandomBytes(32), (byte) => byte.toString(16).padStart(2, "0")).join("");
255
+ }
256
+
257
+ /**
258
+ * The name and address Apple handed over, as the Worker takes them, or `undefined` when it handed over neither.
259
+ * Apple sends them on an account's first sign-in only, and never inside the identity token.
260
+ */
261
+ function appleUser(credential: AppleAuthentication.AppleAuthenticationCredential): IdTokenUser | undefined {
262
+ const { givenName, familyName } = credential.fullName ?? {};
263
+ const name = {
264
+ ...(givenName ? { firstName: givenName } : {}),
265
+ ...(familyName ? { lastName: familyName } : {}),
266
+ };
267
+ const user: IdTokenUser = {
268
+ ...(givenName || familyName ? { name } : {}),
269
+ ...(credential.email ? { email: credential.email } : {}),
270
+ };
271
+ return user.name || user.email ? user : undefined;
272
+ }
273
+
274
+ /**
275
+ * Sign in with Apple on iOS. Apple is handed the SHA-256 of a fresh nonce and puts it in the identity token; the
276
+ * Worker is sent the raw one and checks that it hashes to the token's. Apple's own sheet is the whole flow.
277
+ */
278
+ async function appleSignIn(): Promise<Attempt> {
279
+ const nonce = rawNonce();
280
+ let credential: AppleAuthentication.AppleAuthenticationCredential;
281
+ try {
282
+ credential = await AppleAuthentication.signInAsync({
283
+ requestedScopes: [
284
+ AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
285
+ AppleAuthentication.AppleAuthenticationScope.EMAIL,
286
+ ],
287
+ nonce: await digestStringAsync(CryptoDigestAlgorithm.SHA256, nonce),
288
+ });
289
+ } catch (error) {
290
+ if ((error as { code?: unknown } | null)?.code === "ERR_REQUEST_CANCELED") return null;
291
+ return { ok: false, code: SILENT };
292
+ }
293
+ if (!credential.identityToken) return { ok: false, code: SILENT };
294
+ const user = appleUser(credential);
295
+ return signInWithIdToken("apple", { token: credential.identityToken, nonce, ...(user ? { user } : {}) });
296
+ }
297
+
298
+ /** Sign in with Google's SDK on a phone, for an id token the Worker checks against the web client id. */
299
+ async function googleSignIn(): Promise<Attempt> {
300
+ try {
301
+ GoogleSignin.configure({
302
+ webClientId: googleClientIds.webClientId ?? undefined,
303
+ ...(googleClientIds.iosClientId ? { iosClientId: googleClientIds.iosClientId } : {}),
304
+ });
305
+ if (Platform.OS === "android") await GoogleSignin.hasPlayServices();
306
+ const response = await GoogleSignin.signIn();
307
+ if (!isSuccessResponse(response)) return null;
308
+ const token = response.data.idToken;
309
+ if (!token) return { ok: false, code: UNCONFIGURED };
310
+ return signInWithIdToken("google", { token });
311
+ } catch (error) {
312
+ if (
313
+ isErrorWithCode(error) &&
314
+ (error.code === statusCodes.SIGN_IN_CANCELLED || error.code === statusCodes.IN_PROGRESS)
315
+ ) {
316
+ return null;
317
+ }
318
+ return { ok: false, code: SILENT };
319
+ }
320
+ }
321
+
322
+ function Button(props: {
323
+ label: string;
324
+ accessibleName?: string;
325
+ onPress: () => void;
326
+ disabled: boolean;
327
+ primary?: boolean;
328
+ theme: Theme;
329
+ }): ReactNode {
330
+ const { theme } = props;
331
+ return (
332
+ <Pressable
333
+ role="button"
334
+ aria-label={props.accessibleName}
335
+ disabled={props.disabled}
336
+ onPress={props.onPress}
337
+ style={{
338
+ borderWidth: 1,
339
+ borderColor: props.primary ? theme.colors.accent : theme.colors.border,
340
+ backgroundColor: props.primary ? theme.colors.accent : theme.colors.surface,
341
+ borderRadius: theme.radius,
342
+ paddingVertical: theme.spacing.sm,
343
+ paddingHorizontal: theme.spacing.md,
344
+ minHeight: 44,
345
+ alignItems: "center",
346
+ justifyContent: "center",
347
+ opacity: props.disabled ? 0.5 : 1,
348
+ }}
349
+ >
350
+ <Text style={[theme.type.button, { color: props.primary ? theme.colors.onAccent : theme.colors.text }]}>
351
+ {props.label}
352
+ </Text>
353
+ </Pressable>
354
+ );
355
+ }
356
+
357
+ /** The frame. One shape in every state, so nothing on screen moves when the state changes. */
358
+ function Frame(props: { brand: ReactNode; mark: ReactNode; theme: Theme; children: ReactNode }): ReactNode {
359
+ const { theme } = props;
360
+ const wide = useWindowDimensions().width >= TWO_COLUMNS;
361
+ const panel = wide && props.brand;
362
+ return (
363
+ <View style={{ flex: 1, flexDirection: panel ? "row" : "column", backgroundColor: theme.colors.background }}>
364
+ {panel ? (
365
+ <View style={{ flex: 1, padding: theme.spacing.xl, backgroundColor: theme.colors.surface }}>{props.brand}</View>
366
+ ) : null}
367
+ <View style={{ flex: 1, padding: theme.spacing.lg, justifyContent: "center" }}>
368
+ <View style={{ width: "100%", maxWidth: 400, alignSelf: "center", gap: theme.spacing.md }}>
369
+ {!panel && props.mark ? <View>{props.mark}</View> : null}
370
+ {props.children}
371
+ </View>
372
+ </View>
373
+ </View>
374
+ );
375
+ }
376
+
377
+ /**
378
+ * The screen, with every seam as a prop.
379
+ *
380
+ * **One passwordless path: the magic link.** On a phone with no universal link it is a one-time code instead, on
381
+ * `routes/pithy/otp.tsx`, because the Worker refuses a magic link to a scheme any app can claim.
382
+ *
383
+ * **On a phone nothing comes back as a cookie.** The magic link and every redirect sign-in return to this app's
384
+ * own link with a one-time code, which `callback.tsx` or this screen trades for the session; Apple on iOS and
385
+ * Google with a web client id sign in through their own SDKs and post the id token. On the web, everything is
386
+ * the page's cookie, exactly as the React screens hold it.
387
+ *
388
+ * **Social is never gated by the humanity check.** The check disables the email submit and nothing else.
389
+ */
390
+ export function SignInScreen(props: SignInScreenProps): ReactNode {
391
+ const { auth } = props;
392
+ // Called unconditionally, and chosen from afterwards, so the hook count never changes between renders.
393
+ const baked = useTranslator(EN);
394
+ const t = props.t ?? baked;
395
+ const theme = useTheme();
396
+ const check = props.check ?? NO_CHECK;
397
+ const go = useGo();
398
+ const search = useSearch();
399
+
400
+ const [email, setEmail] = useState("");
401
+ const [sent, setSent] = useState(false);
402
+ const [busy, setBusy] = useState(false);
403
+ const [refusal, setRefusal] = useState<Refusal>(null);
404
+ const error = useSearchParam("error");
405
+ const returned = linkRefusal(error, useSearchParam("provider"));
406
+ // A sign-in refused for the account's standing, suspended or deleted, goes on to the screen for that state.
407
+ const refusedAccount = refusedAccountScreen(error);
408
+
409
+ const offered = SOCIAL.filter((provider) => auth.providers[provider.id]);
410
+ const providerRefuses = offered.some((provider) => auth.providerSignUp?.[provider.id] === false);
411
+ const asksApple = Platform.OS === "ios" && auth.providers.apple === true;
412
+ const appleNative = useRead("apple-available", () => AppleAuthentication.isAvailableAsync(), {
413
+ skip: !asksApple,
414
+ }).value;
415
+
416
+ // Where a sign-in returns: this app's link on a phone, carrying a verifier's challenge; this origin on the web.
417
+ const callbackTo = `${callbackPath}${resumeQuery(search)}`;
418
+
419
+ const byCode = emailByCode();
420
+
421
+ async function sendLink(): Promise<void> {
422
+ if (byCode) return sendCode();
423
+ setBusy(true);
424
+ const callbackURL = onDevice() ? await beginHandoff(callbackTo) : callbackTo;
425
+ await sendMagicLink({ email, callbackURL }, { ...transport.auth, gate: check.attach });
426
+ setBusy(false);
427
+ // Always the same answer, whether or not the address is registered: telling the two apart is an enumeration
428
+ // oracle.
429
+ setSent(true);
430
+ }
431
+
432
+ /** Mail a one-time code and go on to the screen that takes it, with the address and whatever it should resume. */
433
+ async function sendCode(): Promise<void> {
434
+ setBusy(true);
435
+ // Whatever the answer, the code screen: telling a registered address from another is an enumeration oracle.
436
+ await sendOtp({ email, type: "sign-in" }, { ...transport.auth, gate: check.attach });
437
+ setBusy(false);
438
+ const resume = resumeQuery(search);
439
+ go(`${otpPath}${queryString({ email })}${resume ? `&${resume.slice(1)}` : ""}`);
440
+ }
441
+
442
+ /** Where a sign-in goes when it took: the screen or the request it should resume, or home. */
443
+ async function signedIn(): Promise<void> {
444
+ const next = await afterSignIn(search);
445
+ if (next !== null) go(next);
446
+ }
447
+
448
+ async function settle(provider: { label: string }, attempt: Attempt): Promise<void> {
449
+ if (attempt === null) return;
450
+ if (attempt.ok) return signedIn();
451
+ const refused = refusedAccountScreen(attempt.code);
452
+ if (refused) return go(screenPath(await routeTable(), refused));
453
+ if (attempt.code === NEUTRAL_PROVIDER_REFUSAL) return setRefusal({ provider: provider.label, reason: "refused" });
454
+ setRefusal({ provider: provider.label, reason: attempt.code === UNCONFIGURED ? "unconfigured" : "silent" });
455
+ }
456
+
457
+ /**
458
+ * The redirect, on a phone. The sheet opens on the Worker's start route, which starts the sign-in there and sends
459
+ * the sheet on to the authorization page: the provider's state cookie is set in the sheet's own jar, and the
460
+ * sheet is what returns through the provider's callback. It closes on this app's own link: the callback with a
461
+ * one-time code, or this screen with the provider's refusal. `errorCallbackURL` carries the provider's id so a
462
+ * refusal can say who refused.
463
+ *
464
+ * With a universal link the sheet closes on one https path, so a refusal returns to the callback too, and the
465
+ * callback hands it on to this screen.
466
+ */
467
+ async function redirectOnDevice(provider: { id: string }): Promise<Attempt> {
468
+ const callbackURL = await beginHandoff(callbackTo);
469
+ const refusedTo = universalLinkOrigin === null ? appLink(path) : returnLink(callbackPath);
470
+ const start = nativeSignInStartURL({
471
+ origin,
472
+ basePath: transport.auth.basePath,
473
+ provider: provider.id,
474
+ callbackURL,
475
+ errorCallbackURL: `${refusedTo}${queryString({ provider: provider.id })}`,
476
+ });
477
+ if (start === null) {
478
+ await abandonHandoff();
479
+ return { ok: false, code: SILENT };
480
+ }
481
+ const closesOn = universalLinkOrigin === null ? appLink("") : returnLink(callbackPath);
482
+ const result = await WebBrowser.openAuthSessionAsync(start, closesOn);
483
+ if (result.type !== "success") {
484
+ await abandonHandoff();
485
+ return null;
486
+ }
487
+ const code = handoffCode(result.url);
488
+ if (code !== null) return completeHandoff(code);
489
+ await abandonHandoff();
490
+ const back = parseQuery(parseLink(result.url)?.search ?? "");
491
+ if (back.error) {
492
+ // Rendered from the link, the way the web screen renders the same refusal.
493
+ go(`${path}${queryString({ provider: provider.id, error: back.error })}`, { replace: true });
494
+ return null;
495
+ }
496
+ return { ok: false, code: SILENT };
497
+ }
498
+
499
+ /** The redirect, on the web: leave for the provider in this tab, and come back on the cookie. */
500
+ async function redirectOnWeb(provider: { id: string }): Promise<Attempt> {
501
+ const started = await startSocialSignIn(
502
+ { provider: provider.id, callbackURL: callbackTo, errorCallbackURL: `${path}?provider=${provider.id}` },
503
+ transport.auth,
504
+ );
505
+ if (started.kind === "authorize") {
506
+ await leaveInPlace(started.url);
507
+ return null;
508
+ }
509
+ return { ok: false, code: started.kind === "unconfigured" ? UNCONFIGURED : SILENT };
510
+ }
511
+
512
+ async function social(provider: { id: string; label: string }): Promise<void> {
513
+ setRefusal(null);
514
+ setBusy(true);
515
+ const way = wayFor(provider.id, appleNative === true);
516
+ let attempt: Attempt;
517
+ if (way === "apple") attempt = await appleSignIn();
518
+ else if (way === "google") attempt = await googleSignIn();
519
+ else attempt = onDevice() ? await redirectOnDevice(provider) : await redirectOnWeb(provider);
520
+ setBusy(false);
521
+ await settle(provider, attempt);
522
+ }
523
+
524
+ if (refusedAccount) return <Redirect screen={refusedAccount} />;
525
+
526
+ const title = (key: "auth/sign_in.title" | "auth/sign_in.sent.title") => (
527
+ <Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
528
+ {t.t(key)}
529
+ </Text>
530
+ );
531
+ const muted = [theme.type.body, { color: theme.colors.muted }];
532
+
533
+ if (sent) {
534
+ return (
535
+ <Frame brand={props.brand} mark={props.mark} theme={theme}>
536
+ {title("auth/sign_in.sent.title")}
537
+ <Text style={muted}>{t.t("auth/sign_in.sent.body")}</Text>
538
+ </Frame>
539
+ );
540
+ }
541
+
542
+ const shown = returned ?? refusal;
543
+
544
+ return (
545
+ <Frame brand={props.brand} mark={props.mark} theme={theme}>
546
+ {title("auth/sign_in.title")}
547
+
548
+ {/* The providers first, and the refusal directly under them: a failure belongs beside its control. */}
549
+ {offered.length > 0 && (
550
+ <>
551
+ <View style={{ gap: theme.spacing.sm }}>
552
+ {offered.map((provider) =>
553
+ wayFor(provider.id, appleNative === true) === "apple" ? (
554
+ <AppleAuthentication.AppleAuthenticationButton
555
+ key={provider.id}
556
+ buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
557
+ buttonStyle={
558
+ theme.scheme === "dark"
559
+ ? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
560
+ : AppleAuthentication.AppleAuthenticationButtonStyle.BLACK
561
+ }
562
+ cornerRadius={theme.radius}
563
+ style={{ height: 44 }}
564
+ onPress={() => {
565
+ if (!busy) void social(provider);
566
+ }}
567
+ />
568
+ ) : (
569
+ // The visible word is the provider; the accessible name says what the button does and contains
570
+ // that word, so voice control still matches it.
571
+ <Button
572
+ key={provider.id}
573
+ label={provider.label}
574
+ accessibleName={t.t("auth/sign_in.provider.label", { provider: provider.label })}
575
+ disabled={busy}
576
+ onPress={() => void social(provider)}
577
+ theme={theme}
578
+ />
579
+ ),
580
+ )}
581
+ </View>
582
+ {shown?.reason === "refused" ? (
583
+ <View style={{ gap: theme.spacing.xs }}>
584
+ <Text style={[theme.type.body, { color: theme.colors.danger }]}>
585
+ {t.t("auth/sign_in.refused.title", { provider: shown.provider })}
586
+ </Text>
587
+ <Text style={muted}>{t.t("auth/sign_in.refused.primary", { provider: shown.provider })}</Text>
588
+ <Text style={muted}>{t.t("auth/sign_in.refused.secondary")}</Text>
589
+ <Text style={muted}>{t.t("auth/sign_in.refused.remedy", { provider: shown.provider })}</Text>
590
+ </View>
591
+ ) : (
592
+ shown && (
593
+ <Text style={[theme.type.body, { color: theme.colors.danger }]}>{refusalText(t, shown, byCode)}</Text>
594
+ )
595
+ )}
596
+ <Text style={[theme.type.caption, { color: theme.colors.muted, textAlign: "center" }]}>
597
+ {t.t("auth/sign_in.divider")}
598
+ </Text>
599
+ </>
600
+ )}
601
+
602
+ <View style={{ gap: theme.spacing.sm }}>
603
+ <Text style={[theme.type.caption, { color: theme.colors.muted }]}>{t.t("auth/sign_in.email.label")}</Text>
604
+ <TextInput
605
+ aria-label={t.t("auth/sign_in.email.label")}
606
+ value={email}
607
+ onChangeText={setEmail}
608
+ inputMode="email"
609
+ keyboardType="email-address"
610
+ autoCapitalize="none"
611
+ autoCorrect={false}
612
+ autoComplete="email"
613
+ textContentType="emailAddress"
614
+ style={[
615
+ theme.type.body,
616
+ {
617
+ borderWidth: 1,
618
+ borderColor: theme.colors.border,
619
+ borderRadius: theme.radius,
620
+ backgroundColor: theme.colors.surface,
621
+ color: theme.colors.text,
622
+ paddingVertical: theme.spacing.sm,
623
+ paddingHorizontal: theme.spacing.md,
624
+ },
625
+ ]}
626
+ />
627
+ {check.widget}
628
+ <Button
629
+ label={t.t(byCode ? "auth/otp.ask.submit" : "auth/sign_in.submit")}
630
+ primary
631
+ disabled={busy || !email || check.pending}
632
+ onPress={() => void sendLink()}
633
+ theme={theme}
634
+ />
635
+ </View>
636
+
637
+ {/* Reassurance, not navigation: signing up is signing in, so there is no other screen to point at. */}
638
+ <Text style={muted}>
639
+ {auth.signUpEnabled ? (
640
+ <>
641
+ {t.t("auth/sign_in.signup.prompt")}{" "}
642
+ <Text style={{ fontWeight: "700", color: theme.colors.text }}>
643
+ {t.t(
644
+ providerRefuses
645
+ ? byCode
646
+ ? "auth/otp.ask.signup_email_only"
647
+ : "auth/sign_in.signup.email_only"
648
+ : "auth/sign_in.signup.answer",
649
+ )}
650
+ </Text>
651
+ </>
652
+ ) : (
653
+ t.t("auth/sign_in.signup.closed")
654
+ )}
655
+ </Text>
656
+ </Frame>
657
+ );
658
+ }
659
+
660
+ /** The wiring: the real projection, the real widget, and your two slots. */
661
+ export default function SignIn(): ReactNode {
662
+ const [token, setToken] = useState<string | null>(null);
663
+ const onToken = useCallback((value: string | null) => setToken(value), []);
664
+ const check = useMemo<HumanityCheck>(
665
+ () => ({
666
+ widget: <Turnstile onToken={onToken} />,
667
+ pending: turnstilePending(token),
668
+ attach: (body) => turnstileRequest(body, token),
669
+ }),
670
+ [onToken, token],
671
+ );
672
+ return <SignInScreen auth={authConfig} check={check} brand={BRAND} mark={MARK} />;
673
+ }