@vvedantb/native-ui 0.9.0 → 0.10.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.
package/README.md CHANGED
@@ -80,7 +80,7 @@ The root entry exports everything. It still imports `BottomSheetContainer` and `
80
80
  | `@vvedantb/native-ui/date-time` | `DateTimeField`, `DateTimeFieldProps` (0.5.0) | `@react-native-community/datetimepicker`, `dayjs` (import) |
81
81
  | `@vvedantb/native-ui/ai` | The AI chat parts (from 0.4.0): `Conversation`, `Message`, `Reasoning`, `AgentTrace`, `ChainOfThought`, `ActivityStep`, `PromptInput`, `Suggestions`, `Sources`, `FileChip`, `Attachments`, `CodeBlock`, `ResponseText`, `Shimmer`, `Collapsible`, `useStreamingDisclosure` and their parts. See [AI chat](#ai-chat-040). Not on the root entry. | none |
82
82
  | `@vvedantb/native-ui/ai-gallery` | `AiChatGallery`, `AiChatSamples` (from 0.4.0) | none |
83
- | `@vvedantb/native-ui/auth` | From 0.7.0: `AuthProviders`, `SignInScreen`, `SignUpScreen`, `GoogleSignInButton`, `useGoogleSignIn`, `AuthScreen`, `OrDivider`, `FieldError`, `authErrorMessage` and their props types. See [Auth](#auth-070). Not on the root entry. | `@clerk/clerk-expo`, `convex`, `expo-auth-session`, `expo-web-browser`, `expo-router` (import) |
83
+ | `@vvedantb/native-ui/auth` | From 0.7.0: `AuthProviders`, `SignInScreen`, `SignUpScreen`, `GoogleSignInButton`, `useGoogleSignIn`, `AuthScreen`, `OrDivider`, `FieldError`, `authErrorMessage`, `SecondFactorScreen`, `useSecondFactor`, `submitTotpSecondFactor`, `submitSecondFactor`, `prepareSecondFactorIfNeeded`, `TOTP_MESSAGES` and their props types. See [Auth](#auth-070). Not on the root entry. | `@clerk/clerk-expo`, `convex`, `expo-auth-session`, `expo-web-browser`, `expo-router` (import) |
84
84
  | `@vvedantb/native-ui/expo-web-root` | From 0.7.0: `ExpoWebRoot`, `ExpoWebRootProps`, `ExpoWebRootViewport`, `EXPO_WEB_VIEWPORT`, `expoWebBackgroundCss`. Not on the root entry. | `expo-router` (import of `expo-router/html`) |
85
85
  | `@vvedantb/native-ui/tailwind-preset` | From 0.7.0, CommonJS for `tailwind.config.js`: `nativeUiPreset`, `nativeUiContent`, `cssVar`, `hslVar`, `rgbAlpha`, `cssVariables`, `schemeColors` and a default preset. See [Expo / NativeWind](#expo--nativewind). | `nativewind` (`require`, when installed) |
86
86
 
@@ -211,10 +211,52 @@ export default function SignUp() {
211
211
  - `title`, `subtitle`, `showGoogle`, `googleLabel`, `errorDisplay` (`"toast"`, default, needs a `<Toaster />`; or `"inline"`), `testIDPrefix` (default the mode: `sign-in-email`, `sign-in-password`, `sign-in-submit`, `sign-in-google` and the rest).
212
212
  - `header` and `footer` slots for app chrome, such as a brand header or credit. The screen itself is neutral: background fill, title, subtitle, fields.
213
213
  - Messages are generic ("Sign in failed", "Invalid email or password", "Could not continue with Google") and never name the auth provider. `authErrorMessage(error, fallback)` shows an API error's own text only when it does not name it.
214
+ - Two-step verification: when sign-in returns `needs_second_factor` or `needs_client_trust`, the screen shows `SecondFactorScreen` with the account's default method: authenticator app (`totp`), then `email_code`, `phone_code`, `backup_code`. Email and text codes are sent first. A "Use a backup code" link (and a link back) switches method. A valid code runs the same success path. Back returns to the email and password form.
215
+ - `needs_new_password` shows a new-password step (`signIn.resetPassword`), then starts the session.
216
+ - `onIncomplete(signIn)`: optional. Called only for statuses the built-in UI cannot finish: `needs_identifier`, `needs_first_factor`, unknown statuses, and `needs_second_factor` or `needs_client_trust` when no supported strategy (totp, backup code, phone code, email code) exists. The built-in screen always runs `needs_second_factor`, `needs_client_trust` and `needs_new_password`. When `onIncomplete` is called, the screen shows no message. Without it, those statuses show "This account needs another step to sign in. Finish it on the web." and log the status and strategy ids with `console.warn`. Ignored in `sign-up`.
217
+ - Keyboard: `AuthScreen` (and so every screen here) pads for the soft keyboard on iOS and Android, including edge-to-edge Android, and scrolls the focused field and the button above it.
214
218
  - No forgot-password step. An app that needs one keeps its own screen.
215
219
 
216
220
  `GoogleSignInButton` (`scheme`, `redirectPath`, `afterAuthHref`, `replaceOptions`, `onSuccess`, `onError`, `label` and the `Button` props), `AuthScreen`, `OrDivider` and `FieldError` build custom screens. The module calls `WebBrowser.maybeCompleteAuthSession()` once when it loads, so the app no longer needs its own call.
217
221
 
222
+ ### Second factor (0.10.0)
223
+
224
+ `SecondFactorScreen` is a one-time code step with no Clerk import. The caller owns the check.
225
+
226
+ | Prop | Default | Use |
227
+ | --- | --- | --- |
228
+ | `title` | required | Screen title, through `AuthScreen` |
229
+ | `description` | none | Text under the title |
230
+ | `codeLength` | `6` | Digits in the code |
231
+ | `onSubmit(code)` | required | Called with the full code |
232
+ | `error` | none | Inline error under the field |
233
+ | `loading` | `false` | Verify spinner; blocks a second submit |
234
+ | `onBack` | none | Shows a ghost Back button |
235
+ | `backLabel` | `"Back"` | Back button text |
236
+ | `header` | none | Above the title |
237
+ | `testIDPrefix` | `"second-factor"` | `-screen`, `-code`, `-error`, `-verify`, `-back` |
238
+ | `footer` | none | Below the buttons, such as a "Use a backup code" switch |
239
+ | `digitsOnly` | `true` | `false` takes any characters (backup codes) |
240
+ | `placeholder` | `"Code"` | Field placeholder when `digitsOnly` is `false` |
241
+
242
+ For Clerk, `useSecondFactor({ strategy? })` (default `"totp"`) returns `{ submit(code), prepare(strategy?, factor?), strategy, setStrategy, loading, error, clearError }`. `submit` calls `signIn.attemptSecondFactor({ strategy, code })`, then `setActive({ session: createdSessionId })`. `submit` never throws: it resolves `{ ok: true }` or `{ ok: false, error }`.
243
+
244
+ ```tsx
245
+ const { submit, loading, error } = useSecondFactor();
246
+
247
+ <SecondFactorScreen
248
+ title="Two-step verification"
249
+ description="Enter the code from your authenticator app."
250
+ onSubmit={async (code) => {
251
+ if ((await submit(code)).ok) router.replace("/");
252
+ }}
253
+ error={error}
254
+ loading={loading}
255
+ />
256
+ ```
257
+
258
+ `submitTotpSecondFactor({ signIn, setActive, code })` is the TOTP call without the hook; `submitSecondFactor({ signIn, setActive, code, strategy })` takes any of `totp`, `backup_code`, `phone_code`, `email_code`. `prepareSecondFactorIfNeeded({ signIn, strategy, factor? })` sends the `email_code` or `phone_code` first (no call for the others). `pickSecondFactorStrategy(factors)` gives the default. `TOTP_MESSAGES` holds `invalidCode`, `invalidCodeGeneric`, `sendFailed`, `needsAnotherStep` and `notReady`. An API error shows its own text unless it names the provider.
259
+
218
260
  ## Theme
219
261
 
220
262
  Wrap the tree (inside your colour-scheme provider):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/native-ui",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "license": "MIT",
5
5
  "description": "Shared React Native / Expo UI primitives. Floating surfaces, forms, overlays, toasts, settings chrome, tab bar, AI chat parts, the family app marks, a NativeWind preset, the Expo web root and Clerk + Convex auth screens.",
6
6
  "type": "module",
@@ -1,5 +1,5 @@
1
- import type { ReactNode } from "react";
2
- import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text, View } from "react-native";
1
+ import { useEffect, useState, type ReactNode } from "react";
2
+ import { Keyboard, KeyboardAvoidingView, ScrollView, StyleSheet, Text, View } from "react-native";
3
3
  import { cn } from "../cn";
4
4
  import { textStyle, typeStyle } from "../controlStyles";
5
5
  import { palette } from "../palette";
@@ -18,23 +18,51 @@ export type AuthScreenProps = {
18
18
  className?: string;
19
19
  };
20
20
 
21
+ /** Space under the form while the keyboard is open, on top of its height. */
22
+ const KEYBOARD_EXTRA = 24;
23
+
24
+ /** The height of the open soft keyboard, or 0. Edge-to-edge Android ignores `adjustResize`, so the screen pads itself. */
25
+ function useKeyboardHeight(): number {
26
+ const [height, setHeight] = useState(0);
27
+ useEffect(() => {
28
+ const show = (event: { endCoordinates: { height: number } }) => setHeight(event.endCoordinates.height);
29
+ const hide = () => setHeight(0);
30
+ const subs = [
31
+ Keyboard.addListener("keyboardWillShow", show),
32
+ Keyboard.addListener("keyboardDidShow", show),
33
+ Keyboard.addListener("keyboardWillHide", hide),
34
+ Keyboard.addListener("keyboardDidHide", hide),
35
+ ];
36
+ return () => subs.forEach((sub) => sub.remove());
37
+ }, []);
38
+ return height;
39
+ }
40
+
21
41
  /**
22
42
  * The sign-in and sign-up frame: a keyboard-avoiding scroll view on the
23
43
  * background colour, with a title, subtitle and the form in a column of 16.
44
+ * The form is centred until the keyboard opens. Then it aligns to the top and
45
+ * the scroll content gains the keyboard height as bottom space, so the focused
46
+ * field and the submit button scroll above the keyboard.
24
47
  */
25
48
  export function AuthScreen({ title, subtitle, children, header, footer, testID, className }: AuthScreenProps) {
26
49
  const scheme = useScheme();
50
+ const keyboardHeight = useKeyboardHeight();
51
+ const keyboardOpen = keyboardHeight > 0;
27
52
  return (
28
53
  <KeyboardAvoidingView
29
- behavior={Platform.OS === "ios" ? "padding" : "height"}
54
+ // Padding on both platforms, as OverlayModal and PromptInput: Android `height` does nothing edge-to-edge.
55
+ behavior="padding"
30
56
  className="flex-1"
31
57
  style={{ backgroundColor: tokens.color.background[scheme] }}
32
58
  >
33
59
  <ScrollView
34
60
  testID={testID}
35
61
  className="flex-1"
36
- contentContainerClassName={cn("grow justify-center px-6 py-12", className)}
62
+ contentContainerClassName={cn("grow px-6 py-12", keyboardOpen ? "justify-start" : "justify-center", className)}
63
+ contentContainerStyle={keyboardOpen ? { paddingBottom: keyboardHeight + KEYBOARD_EXTRA } : undefined}
37
64
  keyboardShouldPersistTaps="handled"
65
+ keyboardDismissMode="on-drag"
38
66
  >
39
67
  <View className="w-full max-w-sm self-center gap-6">
40
68
  {header}
@@ -0,0 +1,87 @@
1
+ // Clerk-free: a one-time code step. The caller owns the verification call and
2
+ // its errors (`useSecondFactor` does both for Clerk).
3
+ import { useState, type ReactNode } from "react";
4
+ import { Button } from "../Button";
5
+ import { Input } from "../Input";
6
+ import { AuthScreen, FieldError } from "./AuthScreen";
7
+
8
+ export type SecondFactorScreenProps = {
9
+ title: string;
10
+ description?: string;
11
+ /** Digits in the code. Default 6. */
12
+ codeLength?: number;
13
+ /** Called with the full code. A rejected promise is the caller's to handle. */
14
+ onSubmit: (code: string) => Promise<void> | void;
15
+ /** Inline error under the field, such as "Incorrect code". */
16
+ error?: string;
17
+ /** Shows the Verify spinner and blocks a second submit. */
18
+ loading?: boolean;
19
+ /** Shows a ghost back button when set. */
20
+ onBack?: () => void;
21
+ /** Default "Back". */
22
+ backLabel?: string;
23
+ /** Above the title. App brand chrome goes here. */
24
+ header?: ReactNode;
25
+ /** Prefix for every `testID`. Default "second-factor". */
26
+ testIDPrefix?: string;
27
+ /** Below the buttons, such as a "Use a backup code" switch. Passed to `AuthScreen`. */
28
+ footer?: ReactNode;
29
+ /** Default `true`: digits only, number pad. `false` takes any characters (backup codes). */
30
+ digitsOnly?: boolean;
31
+ /** Placeholder when `digitsOnly` is `false`. Default "Code". */
32
+ placeholder?: string;
33
+ };
34
+
35
+ /** A one-time code step: digits only, a Verify button, an inline error and a back link. */
36
+ export function SecondFactorScreen({
37
+ title,
38
+ description,
39
+ codeLength = 6,
40
+ onSubmit,
41
+ error,
42
+ loading = false,
43
+ onBack,
44
+ backLabel = "Back",
45
+ header,
46
+ testIDPrefix = "second-factor",
47
+ footer,
48
+ digitsOnly = true,
49
+ placeholder,
50
+ }: SecondFactorScreenProps) {
51
+ const [code, setCode] = useState("");
52
+ const ready = code.length === codeLength && !loading;
53
+
54
+ const submit = () => {
55
+ if (ready) void onSubmit(code);
56
+ };
57
+
58
+ return (
59
+ <AuthScreen testID={`${testIDPrefix}-screen`} title={title} subtitle={description} header={header} footer={footer}>
60
+ <Input
61
+ testID={`${testIDPrefix}-code`}
62
+ accessibilityLabel="Verification code"
63
+ value={code}
64
+ placeholder={digitsOnly ? "0".repeat(codeLength) : (placeholder ?? "Code")}
65
+ keyboardType={digitsOnly ? "number-pad" : "default"}
66
+ autoCapitalize="none"
67
+ autoCorrect={false}
68
+ autoComplete="one-time-code"
69
+ textContentType="oneTimeCode"
70
+ maxLength={codeLength}
71
+ autoFocus
72
+ returnKeyType="go"
73
+ onChangeText={(text) => setCode(digitsOnly ? text.replace(/\D/g, "") : text.trim())}
74
+ onSubmitEditing={submit}
75
+ />
76
+ <FieldError testID={`${testIDPrefix}-error`} message={error} />
77
+ <Button testID={`${testIDPrefix}-verify`} loading={loading} disabled={!ready} onPress={submit}>
78
+ Verify
79
+ </Button>
80
+ {onBack ? (
81
+ <Button testID={`${testIDPrefix}-back`} variant="ghost" onPress={onBack}>
82
+ {backLabel}
83
+ </Button>
84
+ ) : null}
85
+ </AuthScreen>
86
+ );
87
+ }
@@ -9,6 +9,14 @@ import { toast } from "../toastStore";
9
9
  import { useScheme } from "../useScheme";
10
10
  import { AuthScreen, FieldError, OrDivider } from "./AuthScreen";
11
11
  import { authErrorMessage } from "./authErrors";
12
+ import { SecondFactorScreen } from "./SecondFactorScreen";
13
+ import {
14
+ pickSecondFactorStrategy,
15
+ supportedSecondFactorStrategies,
16
+ useSecondFactor,
17
+ type SecondFactor,
18
+ type SecondFactorStrategy,
19
+ } from "./totp";
12
20
  import { GoogleSignInButton, type AuthNavigationOptions } from "./GoogleSignInButton";
13
21
 
14
22
  export type SignInMode = "sign-in" | "sign-up";
@@ -22,8 +30,66 @@ export const AUTH_MESSAGES = {
22
30
  verifyFailed: "Invalid code",
23
31
  missingCode: "Enter the code from the email",
24
32
  needsAnotherStep: "This account needs another step to sign in. Finish it on the web.",
33
+ secondFactorTitle: "Two-step verification",
34
+ secondFactorDescription: "Enter the code from your authenticator app.",
35
+ emailCodeTitle: "Check your email",
36
+ emailCodeDescription: "Enter the code we sent to your email.",
37
+ phoneCodeTitle: "Check your phone",
38
+ phoneCodeDescription: "Enter the code we sent by text message.",
39
+ backupCodeTitle: "Enter a backup code",
40
+ backupCodeDescription: "Use one of the backup codes you saved when you set up two-step verification.",
41
+ clientTrustTitle: "Verify it's you",
42
+ clientTrustEmailDescription: "This device is new. Enter the code we sent to your email.",
43
+ clientTrustPhoneDescription: "This device is new. Enter the code we sent by text message.",
44
+ useBackupCode: "Use a backup code",
45
+ useAuthenticator: "Use authenticator app",
46
+ useEmailCode: "Email me a code",
47
+ usePhoneCode: "Text me a code",
48
+ newPasswordTitle: "Set a new password",
49
+ newPasswordDescription: "Your password must change before you can sign in.",
50
+ newPasswordLabel: "New password",
51
+ confirmPasswordLabel: "Confirm new password",
52
+ passwordMismatch: "The passwords do not match",
53
+ passwordTooShort: "Use a longer password",
54
+ resetPasswordFailed: "Could not set the new password",
55
+ setPassword: "Set password",
25
56
  } as const;
26
57
 
58
+ /**
59
+ * A sign-in that is not complete, as `signIn.create` returns it. Passed to
60
+ * `onIncomplete` so the app can run the step itself.
61
+ */
62
+ export type IncompleteSignIn = {
63
+ status: string | null;
64
+ createdSessionId: string | null;
65
+ supportedFirstFactors?: Array<{ strategy: string; [key: string]: unknown }> | null;
66
+ supportedSecondFactors?: SecondFactor[] | null;
67
+ };
68
+
69
+ const METHOD_LABEL: Record<SecondFactorStrategy, string> = {
70
+ totp: AUTH_MESSAGES.useAuthenticator,
71
+ email_code: AUTH_MESSAGES.useEmailCode,
72
+ phone_code: AUTH_MESSAGES.usePhoneCode,
73
+ backup_code: AUTH_MESSAGES.useBackupCode,
74
+ };
75
+
76
+ function secondFactorCopy(strategy: SecondFactorStrategy, clientTrust: boolean) {
77
+ if (strategy === "email_code") {
78
+ return clientTrust
79
+ ? { title: AUTH_MESSAGES.clientTrustTitle, description: AUTH_MESSAGES.clientTrustEmailDescription }
80
+ : { title: AUTH_MESSAGES.emailCodeTitle, description: AUTH_MESSAGES.emailCodeDescription };
81
+ }
82
+ if (strategy === "phone_code") {
83
+ return clientTrust
84
+ ? { title: AUTH_MESSAGES.clientTrustTitle, description: AUTH_MESSAGES.clientTrustPhoneDescription }
85
+ : { title: AUTH_MESSAGES.phoneCodeTitle, description: AUTH_MESSAGES.phoneCodeDescription };
86
+ }
87
+ if (strategy === "backup_code") {
88
+ return { title: AUTH_MESSAGES.backupCodeTitle, description: AUTH_MESSAGES.backupCodeDescription };
89
+ }
90
+ return { title: AUTH_MESSAGES.secondFactorTitle, description: AUTH_MESSAGES.secondFactorDescription };
91
+ }
92
+
27
93
  export type SignInScreenProps = AuthNavigationOptions & {
28
94
  /** Default `"sign-in"`. `"sign-up"` creates the account and checks an emailed code. */
29
95
  mode?: SignInMode;
@@ -53,6 +119,17 @@ export type SignInScreenProps = AuthNavigationOptions & {
53
119
  header?: ReactNode;
54
120
  /** Below the footer link row, such as an app credit. */
55
121
  footer?: ReactNode;
122
+ /**
123
+ * Called with the sign-in when the built-in UI cannot finish it:
124
+ * `needs_identifier`, `needs_first_factor`, any unknown status, and
125
+ * `needs_second_factor` or `needs_client_trust` when no supported strategy
126
+ * (totp, backup code, phone code, email code) exists. The built-in screen
127
+ * always runs `needs_second_factor`, `needs_client_trust` and
128
+ * `needs_new_password` itself. When called, the web-fallback message is not
129
+ * shown. Without it, the screen shows the web fallback and logs
130
+ * `console.warn`. Ignored in `sign-up`.
131
+ */
132
+ onIncomplete?: (signIn: IncompleteSignIn) => void;
56
133
  };
57
134
 
58
135
  /**
@@ -80,6 +157,7 @@ export function SignInScreen({
80
157
  testIDPrefix,
81
158
  header,
82
159
  footer,
160
+ onIncomplete,
83
161
  }: SignInScreenProps) {
84
162
  const signInState = useSignIn();
85
163
  const signUpState = useSignUp();
@@ -89,6 +167,13 @@ export function SignInScreen({
89
167
  const [password, setPassword] = useState("");
90
168
  const [code, setCode] = useState("");
91
169
  const [pendingVerification, setPendingVerification] = useState(false);
170
+ const [pendingSecondFactor, setPendingSecondFactor] = useState(false);
171
+ const [factors, setFactors] = useState<SecondFactor[]>([]);
172
+ const [clientTrust, setClientTrust] = useState(false);
173
+ const [pendingNewPassword, setPendingNewPassword] = useState(false);
174
+ const [newPassword, setNewPassword] = useState("");
175
+ const [confirmPassword, setConfirmPassword] = useState("");
176
+ const secondFactor = useSecondFactor();
92
177
  const [error, setError] = useState("");
93
178
  const [submitting, setSubmitting] = useState(false);
94
179
  const signUp = mode === "sign-up";
@@ -104,6 +189,71 @@ export function SignInScreen({
104
189
  else router.replace(afterAuthHref, replaceOptions);
105
190
  };
106
191
 
192
+ const warnIncomplete = (attempt: IncompleteSignIn) => {
193
+ console.warn(
194
+ `[native-ui] Sign-in not complete: status=${attempt.status}, ` +
195
+ `secondFactors=${JSON.stringify((attempt.supportedSecondFactors ?? []).map((f) => f.strategy))}, ` +
196
+ `firstFactors=${JSON.stringify((attempt.supportedFirstFactors ?? []).map((f) => f.strategy))}. ` +
197
+ "Pass onIncomplete to handle it in the app.",
198
+ );
199
+ };
200
+
201
+ // A status the built-in UI cannot finish: the app's `onIncomplete`, or the web fallback.
202
+ const unhandled = (attempt: IncompleteSignIn) => {
203
+ if (onIncomplete) return onIncomplete(attempt);
204
+ warnIncomplete(attempt);
205
+ fail(AUTH_MESSAGES.signInFailed, AUTH_MESSAGES.needsAnotherStep);
206
+ };
207
+
208
+ const chooseStrategy = async (strategy: SecondFactorStrategy, list: SecondFactor[]) => {
209
+ secondFactor.clearError();
210
+ secondFactor.setStrategy(strategy);
211
+ setPendingSecondFactor(true);
212
+ await secondFactor.prepare(
213
+ strategy,
214
+ list.find((factor) => factor.strategy === strategy),
215
+ );
216
+ };
217
+
218
+ const startSecondFactor = async (attempt: IncompleteSignIn, trust: boolean) => {
219
+ const list = attempt.supportedSecondFactors ?? [];
220
+ const strategy = pickSecondFactorStrategy(list);
221
+ if (!strategy) return unhandled(attempt);
222
+ setFactors(list);
223
+ setClientTrust(trust);
224
+ await chooseStrategy(strategy, list);
225
+ };
226
+
227
+ const onNewPassword = async () => {
228
+ const { isLoaded, signIn, setActive } = signInState;
229
+ if (!isLoaded || !signIn || !setActive || submitting) return;
230
+ if (!newPassword) return fail(AUTH_MESSAGES.resetPasswordFailed, AUTH_MESSAGES.missingFields);
231
+ if (minPasswordLength && newPassword.length < minPasswordLength) {
232
+ return fail(AUTH_MESSAGES.resetPasswordFailed, `Use at least ${minPasswordLength} characters for the password`);
233
+ }
234
+ if (newPassword !== confirmPassword) {
235
+ return fail(AUTH_MESSAGES.resetPasswordFailed, AUTH_MESSAGES.passwordMismatch);
236
+ }
237
+ setSubmitting(true);
238
+ setError("");
239
+ try {
240
+ const result = await signIn.resetPassword({ password: newPassword });
241
+ if (result.status === "complete" && result.createdSessionId) {
242
+ await setActive({ session: result.createdSessionId });
243
+ done();
244
+ } else if (onIncomplete) {
245
+ onIncomplete(result);
246
+ } else {
247
+ warnIncomplete(result);
248
+ fail(AUTH_MESSAGES.resetPasswordFailed, AUTH_MESSAGES.needsAnotherStep);
249
+ }
250
+ } catch (caught) {
251
+ fail(AUTH_MESSAGES.resetPasswordFailed, authErrorMessage(caught, AUTH_MESSAGES.resetPasswordFailed));
252
+ } finally {
253
+ setSubmitting(false);
254
+ }
255
+ };
256
+
107
257
  const onSignIn = async () => {
108
258
  const { isLoaded, signIn, setActive } = signInState;
109
259
  if (!isLoaded || !signIn || !setActive || submitting) return;
@@ -115,8 +265,12 @@ export function SignInScreen({
115
265
  if (attempt.status === "complete" && attempt.createdSessionId) {
116
266
  await setActive({ session: attempt.createdSessionId });
117
267
  done();
268
+ } else if (attempt.status === "needs_new_password") {
269
+ setPendingNewPassword(true);
270
+ } else if (attempt.status === "needs_second_factor" || attempt.status === "needs_client_trust") {
271
+ await startSecondFactor(attempt, attempt.status === "needs_client_trust");
118
272
  } else {
119
- fail(AUTH_MESSAGES.signInFailed, AUTH_MESSAGES.needsAnotherStep);
273
+ unhandled(attempt);
120
274
  }
121
275
  } catch (caught) {
122
276
  fail(AUTH_MESSAGES.signInFailed, authErrorMessage(caught, AUTH_MESSAGES.invalidCredentials));
@@ -125,6 +279,22 @@ export function SignInScreen({
125
279
  }
126
280
  };
127
281
 
282
+ const onSecondFactor = async (secondFactorCode: string) => {
283
+ const result = await secondFactor.submit(secondFactorCode);
284
+ if (result.ok) done();
285
+ };
286
+
287
+ const onSecondFactorBack = () => {
288
+ // The pending attempt stays; the next `signIn.create` replaces it.
289
+ setPendingSecondFactor(false);
290
+ setPendingNewPassword(false);
291
+ setNewPassword("");
292
+ setConfirmPassword("");
293
+ setPassword("");
294
+ setError("");
295
+ secondFactor.clearError();
296
+ };
297
+
128
298
  const onSignUp = async () => {
129
299
  const { isLoaded, signUp: attempt } = signUpState;
130
300
  if (!isLoaded || !attempt || submitting) return;
@@ -190,6 +360,97 @@ export function SignInScreen({
190
360
  );
191
361
  const hasFooter = Boolean((linkHref && !pendingVerification) || footer);
192
362
 
363
+ if (pendingNewPassword) {
364
+ return (
365
+ <AuthScreen
366
+ testID={id("new-password-screen")}
367
+ title={AUTH_MESSAGES.newPasswordTitle}
368
+ subtitle={AUTH_MESSAGES.newPasswordDescription}
369
+ header={header}
370
+ >
371
+ <Input
372
+ testID={id("new-password")}
373
+ accessibilityLabel={AUTH_MESSAGES.newPasswordLabel}
374
+ autoCapitalize="none"
375
+ autoComplete="new-password"
376
+ textContentType="newPassword"
377
+ secureTextEntry
378
+ autoFocus
379
+ returnKeyType="next"
380
+ value={newPassword}
381
+ placeholder={AUTH_MESSAGES.newPasswordLabel}
382
+ onChangeText={setNewPassword}
383
+ />
384
+ <Input
385
+ testID={id("confirm-password")}
386
+ accessibilityLabel={AUTH_MESSAGES.confirmPasswordLabel}
387
+ autoCapitalize="none"
388
+ autoComplete="new-password"
389
+ textContentType="newPassword"
390
+ secureTextEntry
391
+ returnKeyType="go"
392
+ value={confirmPassword}
393
+ placeholder={AUTH_MESSAGES.confirmPasswordLabel}
394
+ onChangeText={setConfirmPassword}
395
+ onSubmitEditing={() => void onNewPassword()}
396
+ />
397
+ <FieldError testID={id("error")} message={error} />
398
+ <Button testID={id("new-password-submit")} loading={submitting} onPress={() => void onNewPassword()}>
399
+ {AUTH_MESSAGES.setPassword}
400
+ </Button>
401
+ <Button testID={id("new-password-back")} variant="ghost" onPress={onSecondFactorBack}>
402
+ Use a different account
403
+ </Button>
404
+ </AuthScreen>
405
+ );
406
+ }
407
+
408
+ if (pendingSecondFactor) {
409
+ const active = secondFactor.strategy;
410
+ const available = supportedSecondFactorStrategies(factors);
411
+ const others = available.filter((strategy) => strategy !== active);
412
+ // From a backup code, go back to the default non-backup method. Otherwise offer a backup code.
413
+ const switchTo =
414
+ active === "backup_code"
415
+ ? pickSecondFactorStrategy(factors.filter((factor) => factor.strategy !== "backup_code"))
416
+ : others.includes("backup_code")
417
+ ? "backup_code"
418
+ : null;
419
+ const copy = secondFactorCopy(active, clientTrust);
420
+ const backup = active === "backup_code";
421
+ return (
422
+ <SecondFactorScreen
423
+ key={active}
424
+ title={copy.title}
425
+ description={copy.description}
426
+ codeLength={backup ? 8 : 6}
427
+ digitsOnly={!backup}
428
+ placeholder="Backup code"
429
+ footer={
430
+ switchTo ? (
431
+ <Button
432
+ testID={id("second-factor-switch")}
433
+ variant="link"
434
+ size="sm"
435
+ fullWidth={false}
436
+ disabled={secondFactor.loading}
437
+ onPress={() => void chooseStrategy(switchTo, factors)}
438
+ >
439
+ {METHOD_LABEL[switchTo]}
440
+ </Button>
441
+ ) : undefined
442
+ }
443
+ onSubmit={onSecondFactor}
444
+ error={secondFactor.error}
445
+ loading={secondFactor.loading}
446
+ onBack={onSecondFactorBack}
447
+ backLabel="Use a different account"
448
+ header={header}
449
+ testIDPrefix={id("second-factor")}
450
+ />
451
+ );
452
+ }
453
+
193
454
  if (pendingVerification) {
194
455
  return (
195
456
  <AuthScreen
package/src/auth/index.ts CHANGED
@@ -19,4 +19,26 @@ export type {
19
19
  GoogleSignInButtonProps,
20
20
  } from "./GoogleSignInButton";
21
21
  export { SignInScreen, SignUpScreen, AUTH_MESSAGES } from "./SignInScreen";
22
- export type { SignInMode, SignInScreenProps, SignUpScreenProps } from "./SignInScreen";
22
+ export type { IncompleteSignIn, SignInMode, SignInScreenProps, SignUpScreenProps } from "./SignInScreen";
23
+ export { SecondFactorScreen } from "./SecondFactorScreen";
24
+ export type { SecondFactorScreenProps } from "./SecondFactorScreen";
25
+ export {
26
+ submitTotpSecondFactor,
27
+ submitSecondFactor,
28
+ prepareSecondFactorIfNeeded,
29
+ pickSecondFactorStrategy,
30
+ supportedSecondFactorStrategies,
31
+ SECOND_FACTOR_PREFERENCE,
32
+ useSecondFactor,
33
+ TOTP_MESSAGES,
34
+ } from "./totp";
35
+ export type {
36
+ SecondFactor,
37
+ SecondFactorSignIn,
38
+ SecondFactorStrategy,
39
+ TotpResult,
40
+ TotpSignIn,
41
+ TotpSetActive,
42
+ UseSecondFactorOptions,
43
+ UseSecondFactorResult,
44
+ } from "./totp";
@@ -0,0 +1,202 @@
1
+ import { useState } from "react";
2
+ import { useSignIn } from "@clerk/clerk-expo";
3
+ import { authErrorMessage } from "./authErrors";
4
+
5
+ /** The messages the second-factor step can show. Generic: none names a provider. */
6
+ export const TOTP_MESSAGES = {
7
+ invalidCode: "Incorrect code. Check your authenticator app and try again.",
8
+ invalidCodeGeneric: "Incorrect code. Check it and try again.",
9
+ sendFailed: "Could not send the code. Try again.",
10
+ needsAnotherStep: "This account needs another step to sign in. Finish it on the web.",
11
+ notReady: "Sign in is not ready yet. Try again in a moment.",
12
+ } as const;
13
+
14
+ /** The second-factor strategies the built-in step handles. */
15
+ export type SecondFactorStrategy = "totp" | "backup_code" | "phone_code" | "email_code";
16
+
17
+ /** Default order: the first one the account supports wins. */
18
+ export const SECOND_FACTOR_PREFERENCE: readonly SecondFactorStrategy[] = [
19
+ "totp",
20
+ "email_code",
21
+ "phone_code",
22
+ "backup_code",
23
+ ];
24
+
25
+ /** One entry of `signIn.supportedSecondFactors`. */
26
+ export type SecondFactor = {
27
+ strategy: string;
28
+ emailAddressId?: string;
29
+ phoneNumberId?: string;
30
+ safeIdentifier?: string;
31
+ };
32
+
33
+ type AttemptResult = { status: string | null; createdSessionId: string | null };
34
+
35
+ /** The part of the sign-in resource that TOTP needs. Tests pass a mock. */
36
+ export type TotpSignIn = {
37
+ attemptSecondFactor(params: { strategy: "totp"; code: string }): Promise<AttemptResult>;
38
+ };
39
+
40
+ /** The part of the sign-in resource that any second factor needs. Tests pass a mock. */
41
+ export type SecondFactorSignIn = {
42
+ supportedSecondFactors?: SecondFactor[] | null;
43
+ prepareSecondFactor?(params: {
44
+ strategy: "phone_code" | "email_code";
45
+ phoneNumberId?: string;
46
+ emailAddressId?: string;
47
+ }): Promise<unknown>;
48
+ attemptSecondFactor(params: { strategy: SecondFactorStrategy; code: string }): Promise<AttemptResult>;
49
+ };
50
+
51
+ export type TotpSetActive = (params: { session: string }) => Promise<void>;
52
+
53
+ export type TotpResult = { ok: true } | { ok: false; error: string };
54
+
55
+ /** The strategies in `factors` that the built-in step handles, in the order given. */
56
+ export function supportedSecondFactorStrategies(
57
+ factors: readonly SecondFactor[] | null | undefined,
58
+ ): SecondFactorStrategy[] {
59
+ const found = new Set((factors ?? []).map((factor) => factor.strategy));
60
+ return SECOND_FACTOR_PREFERENCE.filter((strategy) => found.has(strategy));
61
+ }
62
+
63
+ /** The default strategy: `totp`, then `email_code`, `phone_code`, `backup_code`. `null` when none is supported. */
64
+ export function pickSecondFactorStrategy(
65
+ factors: readonly SecondFactor[] | null | undefined,
66
+ ): SecondFactorStrategy | null {
67
+ return supportedSecondFactorStrategies(factors)[0] ?? null;
68
+ }
69
+
70
+ /**
71
+ * Sends the code for `phone_code` and `email_code`, with the `phoneNumberId` or
72
+ * `emailAddressId` of the matching factor. `totp` and `backup_code` need no
73
+ * prepare: it returns `{ ok: true }` without a call. Never throws.
74
+ */
75
+ export async function prepareSecondFactorIfNeeded(args: {
76
+ signIn: Pick<SecondFactorSignIn, "supportedSecondFactors" | "prepareSecondFactor">;
77
+ strategy: SecondFactorStrategy;
78
+ factor?: SecondFactor;
79
+ }): Promise<TotpResult> {
80
+ const { signIn, strategy } = args;
81
+ if (strategy !== "phone_code" && strategy !== "email_code") return { ok: true };
82
+ if (!signIn.prepareSecondFactor) return { ok: false, error: TOTP_MESSAGES.sendFailed };
83
+ const factor = args.factor ?? signIn.supportedSecondFactors?.find((item) => item.strategy === strategy);
84
+ try {
85
+ if (strategy === "phone_code") {
86
+ await signIn.prepareSecondFactor({
87
+ strategy,
88
+ ...(factor?.phoneNumberId ? { phoneNumberId: factor.phoneNumberId } : {}),
89
+ });
90
+ } else {
91
+ await signIn.prepareSecondFactor({
92
+ strategy,
93
+ ...(factor?.emailAddressId ? { emailAddressId: factor.emailAddressId } : {}),
94
+ });
95
+ }
96
+ return { ok: true };
97
+ } catch (caught) {
98
+ return { ok: false, error: authErrorMessage(caught, TOTP_MESSAGES.sendFailed) };
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Checks a second-factor code and starts the session. Call
104
+ * `prepareSecondFactorIfNeeded` first for `phone_code` and `email_code`.
105
+ * Never throws: a failure is `{ ok: false, error }`. An API error shows its
106
+ * own text (such as "Incorrect code") unless it names the provider; anything
107
+ * else gives `TOTP_MESSAGES.invalidCode` (`totp`) or `invalidCodeGeneric`.
108
+ */
109
+ export async function submitSecondFactor(args: {
110
+ signIn: Pick<SecondFactorSignIn, "attemptSecondFactor">;
111
+ setActive: TotpSetActive;
112
+ code: string;
113
+ strategy: SecondFactorStrategy;
114
+ }): Promise<TotpResult> {
115
+ try {
116
+ const attempt = await args.signIn.attemptSecondFactor({ strategy: args.strategy, code: args.code });
117
+ if (attempt.status === "complete" && attempt.createdSessionId) {
118
+ await args.setActive({ session: attempt.createdSessionId });
119
+ return { ok: true };
120
+ }
121
+ return { ok: false, error: TOTP_MESSAGES.needsAnotherStep };
122
+ } catch (caught) {
123
+ const fallback = args.strategy === "totp" ? TOTP_MESSAGES.invalidCode : TOTP_MESSAGES.invalidCodeGeneric;
124
+ return { ok: false, error: authErrorMessage(caught, fallback) };
125
+ }
126
+ }
127
+
128
+ /** Checks a TOTP code and starts the session. TOTP needs no `prepareSecondFactor`. Same as `submitSecondFactor` with `strategy: "totp"`. */
129
+ export function submitTotpSecondFactor(args: {
130
+ signIn: TotpSignIn;
131
+ setActive: TotpSetActive;
132
+ code: string;
133
+ }): Promise<TotpResult> {
134
+ return submitSecondFactor({ signIn: args.signIn, setActive: args.setActive, code: args.code, strategy: "totp" });
135
+ }
136
+
137
+ export type UseSecondFactorOptions = {
138
+ /** The strategy `submit` uses until `setStrategy` changes it. Default `"totp"`. */
139
+ strategy?: SecondFactorStrategy;
140
+ };
141
+
142
+ export type UseSecondFactorResult = {
143
+ /** Checks the code and starts the session. Never throws. Sets `error` on failure. */
144
+ submit: (code: string) => Promise<TotpResult>;
145
+ /** Sends the code for `phone_code` and `email_code` (no call for the others). Never throws. Sets `error` on failure. */
146
+ prepare: (strategy?: SecondFactorStrategy, factor?: SecondFactor) => Promise<TotpResult>;
147
+ /** The strategy `submit` uses. */
148
+ strategy: SecondFactorStrategy;
149
+ setStrategy: (strategy: SecondFactorStrategy) => void;
150
+ /** True while `submit` or `prepare` runs. */
151
+ loading: boolean;
152
+ /** The last failure message, or `""`. */
153
+ error: string;
154
+ clearError: () => void;
155
+ };
156
+
157
+ /**
158
+ * Completes a sign-in that returned `needs_second_factor`. Default strategy
159
+ * `"totp"` (an authenticator-app code); pass `{ strategy }` or call
160
+ * `setStrategy` for the others. Pair with `SecondFactorScreen`:
161
+ * `onSubmit={submit}`, `error={error}`, `loading={loading}`. After
162
+ * `{ ok: true }` the session is active: navigate in the caller.
163
+ */
164
+ export function useSecondFactor(options: UseSecondFactorOptions = {}): UseSecondFactorResult {
165
+ const { isLoaded, signIn, setActive } = useSignIn();
166
+ const [chosen, setChosen] = useState<SecondFactorStrategy | null>(null);
167
+ const strategy = chosen ?? options.strategy ?? "totp";
168
+ const [loading, setLoading] = useState(false);
169
+ const [error, setError] = useState("");
170
+
171
+ const submit = async (code: string): Promise<TotpResult> => {
172
+ if (loading) return { ok: false, error };
173
+ if (!isLoaded || !signIn || !setActive) {
174
+ setError(TOTP_MESSAGES.notReady);
175
+ return { ok: false, error: TOTP_MESSAGES.notReady };
176
+ }
177
+ setLoading(true);
178
+ setError("");
179
+ const result = await submitSecondFactor({ signIn, setActive, code, strategy });
180
+ if (!result.ok) setError(result.error);
181
+ setLoading(false);
182
+ return result;
183
+ };
184
+
185
+ const prepare = async (
186
+ prepareStrategy: SecondFactorStrategy = strategy,
187
+ factor?: SecondFactor,
188
+ ): Promise<TotpResult> => {
189
+ if (!isLoaded || !signIn) {
190
+ setError(TOTP_MESSAGES.notReady);
191
+ return { ok: false, error: TOTP_MESSAGES.notReady };
192
+ }
193
+ setLoading(true);
194
+ setError("");
195
+ const result = await prepareSecondFactorIfNeeded({ signIn, strategy: prepareStrategy, factor });
196
+ if (!result.ok) setError(result.error);
197
+ setLoading(false);
198
+ return result;
199
+ };
200
+
201
+ return { submit, prepare, strategy, setStrategy: setChosen, loading, error, clearError: () => setError("") };
202
+ }