@pithy-sh/ui-react-native 0.0.0-stage → 0.2.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 +223 -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 +674 -0
  45. package/templates/src/routes/pithy/subscription.tsx +240 -0
  46. package/templates/src/session.tsx +349 -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,239 @@
1
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
2
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
3
+ import {
4
+ acceptInvitation,
5
+ chooseOrganization,
6
+ type InvitationOffer,
7
+ readInvitation,
8
+ } from "@pithy-sh/organization/src/client/api";
9
+ import { organizationRefusal } from "@pithy-sh/organization/src/client/lifecycle";
10
+ import { useAction } from "@pithy-sh/ui-core/src/action";
11
+ import { useRead } from "@pithy-sh/ui-core/src/read";
12
+ import { type ScreenProps, screenPath } from "@pithy-sh/ui-core/src/routes";
13
+ import { type ReactNode, useState } from "react";
14
+ import { Pressable, Text, View } from "react-native";
15
+ import { queryString } from "../../platform";
16
+ import { Redirect, routeTable } from "../../router";
17
+ import { getSession, RESUME_PARAM } from "../../session";
18
+ import { type Theme, useTheme } from "../../theme";
19
+ import { transport } from "../../transport";
20
+
21
+ /**
22
+ * Where an invitation link lands. Change this if `organization({ invitationAcceptPath })` does: the link in every
23
+ * invitation email is that path with the token appended as one segment. On a phone the link reaches this screen
24
+ * when the app answers it as a universal link or an app link.
25
+ */
26
+ export const path = "/invitations/:token";
27
+
28
+ // Pithy's screen. Yours to override: put your own file at this path under src/routes/app/ and it wins.
29
+ //
30
+ // **Anybody holding the link sees the offer; only the invited address can take it.** Reading an offer is public, so
31
+ // a stranger sees what they were invited to before being asked to sign in, and comes back here once they have.
32
+ // Accepting is checked by the server against the signed-in address, so a forwarded link does nothing for whoever it
33
+ // was forwarded to.
34
+ //
35
+ // **One refusal, one sentence.** An unknown link, a spent one, a withdrawn one, an expired one and one sent to a
36
+ // different address all come back as the same code, on purpose: telling them apart tells whoever holds the link the
37
+ // same thing. The screen says all of it at once rather than guessing which.
38
+ //
39
+ // **Joining does not change the account in force.** The server writes the membership and stops. This screen then
40
+ // makes the account you joined the one you act in, and leaves remounting every screen, for the chooser's reason:
41
+ // nothing the previous account's data put on a screen may survive the switch.
42
+ const EN = {
43
+ "organization/invitation.title": "Join {organization}",
44
+ "organization/invitation.invited_by": "{inviter} invited you as {role}.",
45
+ "organization/invitation.invited": "You're invited as {role}.",
46
+ "organization/invitation.expires": "The invitation expires on {date}.",
47
+ "organization/invitation.as": "You'll join as {email}.",
48
+ "organization/invitation.accept": "Join",
49
+ "organization/invitation.sign_in": "Sign in to accept",
50
+ "organization/invitation.invalid.title": "This invitation can't be used.",
51
+ "organization/invitation.invalid.body":
52
+ "It has expired, been used or withdrawn, or was sent to a different address. Ask whoever invited you for a new one.",
53
+ "organization/invitation.failed": "We couldn't load this invitation.",
54
+ "organization/invitation.join_failed": "We couldn't finish joining.",
55
+ "organization/invitation.retry": "Try again",
56
+ } satisfies MessageCatalog;
57
+
58
+ /** Where to go once the account you joined is in force. The chooser's destination, for the chooser's reason. */
59
+ const DESTINATION = "/";
60
+
61
+ /** What arriving with a link comes to: the offer, a link that can no longer be used, or a read that failed. */
62
+ type Arrival =
63
+ | { kind: "offer"; offer: InvitationOffer; email: string | null; signIn: string | null }
64
+ | { kind: "invalid" }
65
+ | { kind: "failed" };
66
+
67
+ /**
68
+ * What pressing "Join" comes to. `joined` on a failure is the account already joined, so trying again records the
69
+ * choice rather than spending an invitation that is already spent.
70
+ */
71
+ type Joining =
72
+ | { kind: "leave"; to: string; load: boolean }
73
+ | { kind: "invalid" }
74
+ | { kind: "failed"; joined: string | null };
75
+
76
+ const INVITATION_INVALID = "organization/invitation_invalid";
77
+
78
+ /** This screen's own address for `token`, which is where a sign-in comes back to. */
79
+ function ownPath(token: string): string {
80
+ return path.replace(":token", encodeURIComponent(token));
81
+ }
82
+
83
+ async function arrive(token: string): Promise<Arrival> {
84
+ const [offer, session] = await Promise.all([readInvitation(token, transport.organization), getSession()]);
85
+ if (!offer.ok) return offer.failure.code === INVITATION_INVALID ? { kind: "invalid" } : { kind: "failed" };
86
+ if (session !== null) return { kind: "offer", offer: offer.value, email: session.user.email, signIn: null };
87
+ const signIn = `${screenPath(await routeTable(), "sign-in")}${queryString({ [RESUME_PARAM]: ownPath(token) })}`;
88
+ return { kind: "offer", offer: offer.value, email: null, signIn };
89
+ }
90
+
91
+ /** Accept, unless that already happened, then make the account joined the one in force. */
92
+ async function join(token: string, joined: string | null): Promise<Joining> {
93
+ let organizationId = joined;
94
+ if (organizationId === null) {
95
+ const accepted = await acceptInvitation(token, transport.organization);
96
+ if (!accepted.ok) {
97
+ return accepted.failure.code === INVITATION_INVALID ? { kind: "invalid" } : { kind: "failed", joined: null };
98
+ }
99
+ organizationId = accepted.value.organizationId;
100
+ }
101
+ const chosen = await chooseOrganization(organizationId, transport.organization);
102
+ if (chosen.ok) return { kind: "leave", to: DESTINATION, load: true };
103
+ if (organizationRefusal(chosen.failure.code) === "suspended") {
104
+ return { kind: "leave", to: screenPath(await routeTable(), "organization-suspended"), load: false };
105
+ }
106
+ return { kind: "failed", joined: organizationId };
107
+ }
108
+
109
+ export default function Invitation({ params }: ScreenProps<typeof path>): ReactNode {
110
+ const t = useTranslator(EN);
111
+ const theme = useTheme();
112
+ const token = params.token;
113
+ const arrival = useRead(`invitation:${token}`, () => arrive(token));
114
+ const joining = useAction((_signal: AbortSignal, joined: string | null) => join(token, joined));
115
+ const [signingIn, setSigningIn] = useState(false);
116
+
117
+ const answer = arrival.loading ? undefined : arrival.value;
118
+ const result = joining.value;
119
+ const screen = {
120
+ flex: 1,
121
+ padding: theme.spacing.lg,
122
+ gap: theme.spacing.md,
123
+ backgroundColor: theme.colors.background,
124
+ };
125
+
126
+ if (result?.kind === "leave") return <Redirect to={result.to} load={result.load} />;
127
+ if (signingIn && answer?.kind === "offer" && answer.signIn !== null) return <Redirect to={answer.signIn} />;
128
+
129
+ if (answer?.kind === "invalid" || result?.kind === "invalid") {
130
+ return (
131
+ <View style={screen}>
132
+ <Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
133
+ {t.t("organization/invitation.invalid.title")}
134
+ </Text>
135
+ <Text style={[theme.type.body, { color: theme.colors.muted }]}>
136
+ {t.t("organization/invitation.invalid.body")}
137
+ </Text>
138
+ </View>
139
+ );
140
+ }
141
+
142
+ if (result?.kind === "failed") {
143
+ return (
144
+ <View style={screen}>
145
+ <Text style={[theme.type.body, { color: theme.colors.text }]}>
146
+ {t.t("organization/invitation.join_failed")}
147
+ </Text>
148
+ <Button
149
+ theme={theme}
150
+ disabled={joining.pending}
151
+ onPress={() => void joining.run(result.joined)}
152
+ label={t.t("organization/invitation.retry")}
153
+ />
154
+ </View>
155
+ );
156
+ }
157
+
158
+ if (answer?.kind === "failed" || (answer === undefined && arrival.failure !== null && !arrival.loading)) {
159
+ return (
160
+ <View style={screen}>
161
+ <Text style={[theme.type.body, { color: theme.colors.text }]}>{t.t("organization/invitation.failed")}</Text>
162
+ <Button theme={theme} onPress={arrival.refresh} label={t.t("organization/invitation.retry")} />
163
+ </View>
164
+ );
165
+ }
166
+
167
+ if (answer === undefined) return <View style={screen} />;
168
+ const { offer, email } = answer;
169
+ return (
170
+ <View style={screen}>
171
+ <Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
172
+ {t.t("organization/invitation.title", { organization: offer.organizationName })}
173
+ </Text>
174
+ <Text style={[theme.type.body, { color: theme.colors.muted }]}>
175
+ {offer.inviterName === null
176
+ ? t.t("organization/invitation.invited", { role: offer.role })
177
+ : t.t("organization/invitation.invited_by", { inviter: offer.inviterName, role: offer.role })}
178
+ </Text>
179
+ <Text style={[theme.type.body, { color: theme.colors.muted }]}>
180
+ {t.t("organization/invitation.expires", {
181
+ date: t.formatDate(new Date(offer.expiresAt), { dateStyle: "long" }),
182
+ })}
183
+ </Text>
184
+ {email === null ? (
185
+ <Button
186
+ theme={theme}
187
+ primary
188
+ onPress={() => setSigningIn(true)}
189
+ label={t.t("organization/invitation.sign_in")}
190
+ />
191
+ ) : (
192
+ <>
193
+ <Text style={[theme.type.body, { color: theme.colors.text }]}>
194
+ {t.t("organization/invitation.as", { email })}
195
+ </Text>
196
+ <Button
197
+ theme={theme}
198
+ primary
199
+ disabled={joining.pending}
200
+ onPress={() => void joining.run(null)}
201
+ label={t.t("organization/invitation.accept")}
202
+ />
203
+ </>
204
+ )}
205
+ </View>
206
+ );
207
+ }
208
+
209
+ /** One button: the accent for the action the screen wants taken, a bordered surface for a retry. */
210
+ function Button(props: {
211
+ theme: Theme;
212
+ label: string;
213
+ onPress: () => void;
214
+ disabled?: boolean;
215
+ primary?: boolean;
216
+ }): ReactNode {
217
+ const { theme } = props;
218
+ return (
219
+ <Pressable
220
+ role="button"
221
+ disabled={props.disabled}
222
+ onPress={props.onPress}
223
+ style={{
224
+ alignSelf: "flex-start",
225
+ borderWidth: 1,
226
+ borderColor: props.primary ? theme.colors.accent : theme.colors.border,
227
+ borderRadius: theme.radius,
228
+ backgroundColor: props.primary ? theme.colors.accent : theme.colors.surface,
229
+ paddingVertical: theme.spacing.sm,
230
+ paddingHorizontal: theme.spacing.md,
231
+ opacity: props.disabled ? 0.6 : 1,
232
+ }}
233
+ >
234
+ <Text style={[theme.type.button, { color: props.primary ? theme.colors.onAccent : theme.colors.text }]}>
235
+ {props.label}
236
+ </Text>
237
+ </Pressable>
238
+ );
239
+ }
@@ -0,0 +1,51 @@
1
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
2
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
3
+ import type { ReactNode } from "react";
4
+ import { Text, View } from "react-native";
5
+ import { Link } from "../../router";
6
+ import { useTheme } from "../../theme";
7
+
8
+ export const path = "/organization-suspended";
9
+
10
+ // Where somebody is sent when the organization they act for is suspended, and their own account is not.
11
+ export const role = "organization-suspended";
12
+
13
+ // Pithy's screen. Yours to override: put your own file at this path under src/routes/app/ and it wins.
14
+ //
15
+ // **Arrived at from a refusal, by role.** A member's request to act in a suspended organization is refused with
16
+ // `organization/organization_suspended`, and the chooser sends them here when every account they belong to is
17
+ // suspended, or when the account they picked was suspended after the list was drawn. Your own screens do the same
18
+ // with `organizationRefusal` from `@pithy-sh/organization/src/client/lifecycle`. Like the other two it names nobody:
19
+ // the organization's owners were told why, so they are who to ask.
20
+
21
+ /** This screen's English, baked in — the only catalog that survives being copied into your repository. */
22
+ const EN = {
23
+ "organization/suspended.title": "This organization is suspended.",
24
+ "organization/suspended.body": "Nobody can use it until it is reinstated. Your own account is not affected.",
25
+ "organization/suspended.contact": "Its owners were told why. Ask one of them.",
26
+ "organization/suspended.home": "Go home",
27
+ } satisfies MessageCatalog;
28
+
29
+ /**
30
+ * Where "home" is. The app's root, and deliberately not a `ScreenRole`: nothing guards its way there. An
31
+ * adopter whose home is elsewhere changes this line, which is the one edit a copied screen is for.
32
+ */
33
+ const HOME = "/";
34
+
35
+ export default function OrganizationSuspended(): ReactNode {
36
+ const t = useTranslator(EN);
37
+ const theme = useTheme();
38
+
39
+ return (
40
+ <View
41
+ style={{ flex: 1, padding: theme.spacing.lg, gap: theme.spacing.md, backgroundColor: theme.colors.background }}
42
+ >
43
+ <Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
44
+ {t.t("organization/suspended.title")}
45
+ </Text>
46
+ <Text style={[theme.type.body, { color: theme.colors.text }]}>{t.t("organization/suspended.body")}</Text>
47
+ <Text style={[theme.type.body, { color: theme.colors.muted }]}>{t.t("organization/suspended.contact")}</Text>
48
+ <Link to={HOME}>{t.t("organization/suspended.home")}</Link>
49
+ </View>
50
+ );
51
+ }
@@ -0,0 +1,223 @@
1
+ import { sendOtp, signInWithOtp } from "@pithy-sh/auth/src/client/api";
2
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
3
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
4
+ import { screenPath } from "@pithy-sh/ui-core/src/routes";
5
+ import { type ReactNode, useCallback, useRef, useState } from "react";
6
+ import { Pressable, Text, TextInput, View } from "react-native";
7
+ import { authConfig } from "../../pithy-config";
8
+ import { parseQuery, queryString } from "../../platform";
9
+ import { Redirect, routeTable, useGo, usePath, useSearch, useSearchParam } from "../../router";
10
+ import {
11
+ adoptSignIn,
12
+ afterSignIn,
13
+ heldOtpAddress,
14
+ holdOtpAddress,
15
+ refusedAccountScreen,
16
+ releaseOtpAddress,
17
+ resumeQuery,
18
+ } from "../../session";
19
+ import { type Theme, useTheme } from "../../theme";
20
+ import { transport } from "../../transport";
21
+ import { Turnstile, turnstilePending, turnstileRequest } from "../../turnstile";
22
+
23
+ export const path = "/otp";
24
+
25
+ // Pithy's screen. Yours to override: put your own file at this path under src/routes/app/ and it wins.
26
+ //
27
+ // **The sign-in screen sends people here on a phone with no universal link.** It offers one way in, and there that
28
+ // is a code: the Worker hands a magic link only to an https link, since any app can claim a custom scheme. With
29
+ // `universalLinkOrigin` set, the sign-in screen mails a link instead and nothing sends anyone here. To send a code
30
+ // from your own screen, hand it the address and go here: `holdOtpAddress(email); go("/otp")`.
31
+ //
32
+ // **The address is never in the URL.** A path or a query string is written to every request log between the
33
+ // browser and the Worker, so `holdOtpAddress` keeps it in memory and in this tab's session storage. A reload keeps
34
+ // it. A new tab, a bookmark or a link somebody else opens has none, and goes back to sign-in, since a code cannot be
35
+ // checked without the address it was sent to. An older link carrying `?email=` is read once, held, and dropped from
36
+ // the address bar.
37
+
38
+ /**
39
+ * This screen's English, baked in: the only catalog that survives being copied into your repository.
40
+ *
41
+ * The digit count is a real count, so it goes through `plural`: `otpLength` is configurable, and a locale with more
42
+ * than two plural forms cannot say "digits" correctly from one string.
43
+ */
44
+ const EN = {
45
+ "auth/otp.title": "Enter the code.",
46
+ "auth/otp.sent.one": "We sent {count} digit to {email}.",
47
+ "auth/otp.sent.other": "We sent {count} digits to {email}.",
48
+ "auth/otp.inbox": "your inbox",
49
+ "auth/otp.failed": "That code didn't work. Try again, or send a new one.",
50
+ "auth/otp.submit": "Sign in",
51
+ "auth/otp.resend": "Send a new code",
52
+ } satisfies MessageCatalog;
53
+
54
+ function Button(props: {
55
+ label: string;
56
+ onPress: () => void;
57
+ disabled: boolean;
58
+ primary?: boolean;
59
+ theme: Theme;
60
+ }): ReactNode {
61
+ const { theme } = props;
62
+ return (
63
+ <Pressable
64
+ role="button"
65
+ disabled={props.disabled}
66
+ onPress={props.onPress}
67
+ style={{
68
+ borderWidth: 1,
69
+ borderColor: props.primary ? theme.colors.accent : theme.colors.border,
70
+ backgroundColor: props.primary ? theme.colors.accent : "transparent",
71
+ borderRadius: theme.radius,
72
+ paddingVertical: theme.spacing.sm,
73
+ paddingHorizontal: theme.spacing.md,
74
+ alignItems: "center",
75
+ opacity: props.disabled ? 0.5 : 1,
76
+ }}
77
+ >
78
+ <Text style={[theme.type.button, { color: props.primary ? theme.colors.onAccent : theme.colors.text }]}>
79
+ {props.label}
80
+ </Text>
81
+ </Pressable>
82
+ );
83
+ }
84
+
85
+ export default function Otp(): ReactNode {
86
+ const t = useTranslator(EN);
87
+ const theme = useTheme();
88
+ const go = useGo();
89
+ const search = useSearch();
90
+ const path = usePath();
91
+ const carried = useSearchParam("email");
92
+ const [email] = useState(() => {
93
+ if (carried) holdOtpAddress(carried);
94
+ return carried || heldOtpAddress() || "";
95
+ });
96
+ const length = authConfig.otpLength;
97
+ const [digits, setDigits] = useState<string[]>(() => Array.from({ length }, () => ""));
98
+ const [captcha, setCaptcha] = useState<string | null>(null);
99
+ const [error, setError] = useState(false);
100
+ const [busy, setBusy] = useState(false);
101
+ const inputs = useRef<(TextInput | null)[]>([]);
102
+ const onToken = useCallback((value: string | null) => setCaptcha(value), []);
103
+
104
+ const code = digits.join("");
105
+
106
+ // One digit per box. A whole code arriving in one box, from the keyboard's one-time-code suggestion or a paste,
107
+ // is spread across the row from there.
108
+ function setDigit(index: number, value: string): void {
109
+ const typed = value.replace(/\D/g, "");
110
+ const spread = typed.length > 1 ? typed.slice(0, length - index) : typed.slice(-1);
111
+ setDigits((current) =>
112
+ current.map((existing, position) => {
113
+ if (spread === "") return position === index ? "" : existing;
114
+ const offset = position - index;
115
+ return offset >= 0 && offset < spread.length ? (spread[offset] ?? existing) : existing;
116
+ }),
117
+ );
118
+ const next = index + Math.max(spread.length, 1);
119
+ if (spread !== "" && next < length) inputs.current[next]?.focus();
120
+ }
121
+
122
+ // Both calls take the transport's options and name nothing about it: on a phone they go to the Worker's origin
123
+ // with the device's headers, on the web they carry the page's cookie.
124
+ async function verify(): Promise<void> {
125
+ setBusy(true);
126
+ setError(false);
127
+ const result = await signInWithOtp({ email, otp: code }, transport.auth);
128
+ const held = result.ok && (await adoptSignIn(result.value));
129
+ setBusy(false);
130
+ if (held) {
131
+ releaseOtpAddress();
132
+ // A sign-in a screen or an application's authorization sent somebody to resumes it, or goes home.
133
+ const next = await afterSignIn(search);
134
+ if (next !== null) go(next);
135
+ return;
136
+ }
137
+ // A right code for an account that may not act is refused with what happened to it, not as a wrong code, and
138
+ // goes on to the screen for that state, by role.
139
+ const refused = result.ok ? null : refusedAccountScreen(result.failure.code);
140
+ if (refused) return go(screenPath(await routeTable(), refused));
141
+ setError(true);
142
+ }
143
+
144
+ async function resend(): Promise<void> {
145
+ // The gate goes on the send route and not on the verify one, which is where `@pithy-sh/auth` stacks the
146
+ // humanity check: a code that was already mailed is not a surface worth challenging.
147
+ await sendOtp({ email, type: "sign-in" }, { ...transport.auth, gate: (body) => turnstileRequest(body, captcha) });
148
+ }
149
+
150
+ if (carried !== null) {
151
+ const { email: _dropped, ...rest } = parseQuery(search);
152
+ return <Redirect to={`${path}${queryString(rest)}`} replace />;
153
+ }
154
+ if (!email) {
155
+ const resume = resumeQuery(search);
156
+ return <Redirect screen="sign-in" replace {...(resume ? { search: resume.slice(1) } : {})} />;
157
+ }
158
+
159
+ return (
160
+ <View
161
+ style={{ flex: 1, padding: theme.spacing.lg, gap: theme.spacing.md, backgroundColor: theme.colors.background }}
162
+ >
163
+ <Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
164
+ {t.t("auth/otp.title")}
165
+ </Text>
166
+ <Text style={[theme.type.body, { color: theme.colors.muted }]}>
167
+ {t.plural("auth/otp.sent", length, { email: email || t.t("auth/otp.inbox") })}
168
+ </Text>
169
+
170
+ <View style={{ flexDirection: "row", gap: theme.spacing.sm }}>
171
+ {digits.map((digit, index) => (
172
+ <TextInput
173
+ // The inputs are a fixed-length positional row; the position is the identity.
174
+ // biome-ignore lint/suspicious/noArrayIndexKey: position is the identity here
175
+ key={index}
176
+ ref={(element) => {
177
+ inputs.current[index] = element;
178
+ }}
179
+ value={digit}
180
+ onChangeText={(value) => setDigit(index, value)}
181
+ keyboardType="number-pad"
182
+ inputMode="numeric"
183
+ textContentType="oneTimeCode"
184
+ autoComplete="one-time-code"
185
+ style={[
186
+ theme.type.title,
187
+ {
188
+ width: 44,
189
+ height: 56,
190
+ textAlign: "center",
191
+ borderWidth: 1,
192
+ borderColor: theme.colors.border,
193
+ borderRadius: theme.radius,
194
+ backgroundColor: theme.colors.surface,
195
+ color: theme.colors.text,
196
+ },
197
+ ]}
198
+ />
199
+ ))}
200
+ </View>
201
+
202
+ {error && <Text style={[theme.type.body, { color: theme.colors.danger }]}>{t.t("auth/otp.failed")}</Text>}
203
+
204
+ <View style={{ gap: theme.spacing.sm }}>
205
+ <Button
206
+ label={t.t("auth/otp.submit")}
207
+ primary
208
+ disabled={busy || code.length < length}
209
+ onPress={() => void verify()}
210
+ theme={theme}
211
+ />
212
+ {/* Resending goes back through the gated send route, so the widget belongs here too. */}
213
+ <Turnstile onToken={onToken} />
214
+ <Button
215
+ label={t.t("auth/otp.resend")}
216
+ disabled={busy || turnstilePending(captcha)}
217
+ onPress={() => void resend()}
218
+ theme={theme}
219
+ />
220
+ </View>
221
+ </View>
222
+ );
223
+ }