@vvedantb/native-ui 0.10.0 → 0.11.1

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
@@ -65,8 +65,9 @@ The root entry exports everything. It still imports `BottomSheetContainer` and `
65
65
  | `@vvedantb/native-ui/tab-bar` | `FloatingTabBar`, `FloatingTabBarProps`, `FLOATING_INSET`, `FLOATING_TAB_BAR_HEIGHT`, `FLOATING_TAB_BAR_HEIGHT_LARGE`, `tabBarReserve` | `expo-blur`, `expo-haptics` (loaders) |
66
66
  | `@vvedantb/native-ui/brand` | `AppMark`, `AppWordmark`, `GoogleMark` (from 0.3.15), `APP_ICONS`, `IconVibot` and the other `Icon*`, the SVG helpers (`APP_MARKS`, `appMarkSvg`, `appTileSvg` and the rest), `FAMILY_APPS`, `familyApp`, `V_PRO_APP_BENEFITS` | none |
67
67
  | `@vvedantb/native-ui/button` | `Button`, `ButtonProps`, `ButtonShape` (0.6.0), `ButtonSize`, `ButtonVariant` | none |
68
- | `@vvedantb/native-ui/primitives` | `Button`, `Input`, `SearchInput`, `Textarea`, `Label`, `Select`, `Switch`, `LabeledSwitchRow`, `Checkbox`, `Card` and its parts, `Badge`, `StatusDot`, `Avatar`, `Separator`, `Progress`, `Spinner`, `Skeleton`, `EmptyState`, `ListRow`, `Chip`, `HeaderIconButton`, `ThemeToggle` (from 0.3.16), `Tabs`, `PillTabs`, `Dialog`, `AlertDialog`, `ConfirmDialog`, `Sheet`, `toast`, `Toaster`. From 0.5.0: `Text` and `TextClassContext` (only here, not on the root), `ColorSwatches`, `StatGrid`, `SectionLabel`, `SheetRow`, `PageIndicator`, `IconButton`, `CaptureButton`, and the `ListRowProps` and `CardProps` types. From 0.6.0: the `ButtonShape` type. Unreleased: `DropdownMenu` and its parts, `BulkActionBar` | `expo-blur`, `expo-haptics` (loaders) |
68
+ | `@vvedantb/native-ui/primitives` | `Button`, `Input`, `SearchInput`, `PasswordInput` (0.11.0), `Textarea`, `Label`, `Select`, `Switch`, `LabeledSwitchRow`, `Checkbox`, `Card` and its parts, `Badge`, `StatusDot`, `Avatar`, `Separator`, `Progress`, `Spinner`, `Skeleton`, `EmptyState`, `ListRow`, `Chip`, `HeaderIconButton`, `ThemeToggle` (from 0.3.16), `Tabs`, `PillTabs`, `Dialog`, `AlertDialog`, `ConfirmDialog`, `Sheet`, `toast`, `Toaster`. From 0.5.0: `Text` and `TextClassContext` (only here, not on the root), `ColorSwatches`, `StatGrid`, `SectionLabel`, `SheetRow`, `PageIndicator`, `IconButton`, `CaptureButton`, and the `ListRowProps` and `CardProps` types. From 0.6.0: the `ButtonShape` type. Unreleased: `DropdownMenu` and its parts, `BulkActionBar` | `expo-blur`, `expo-haptics` (loaders) |
69
69
  | `@vvedantb/native-ui/search-input` | `SearchInput`, `SearchInputProps` | none |
70
+ | `@vvedantb/native-ui/password-input` | `PasswordInput`, `PasswordInputProps` (from 0.11.0) | none |
70
71
  | `@vvedantb/native-ui/page-title` | `PageTitle`, `PageHeader`, `PageTitleProps`, `PageHeaderProps` (from 0.3.13), `StackPageHeader`, `StackPageHeaderProps` (from 0.5.0; it brings the Tabler package for its chevron) | none |
71
72
  | `@vvedantb/native-ui/toast` | `toast`, `Toaster`, `ToastOptions`, `ToastVariant` | none (from 0.3.12) |
72
73
  | `@vvedantb/native-ui/theme` | `NativeUiProvider`, `useNativeUi`, `useLargerText`, `useAccentColor`, `DEFAULT_ACCENT`, `tokens`, `palette`, `withAlpha`, `configureTheme` (with the `ThemeConfig`, `ThemeColors` and `ThemePalette` types), `fontClass`, `cn`, `Colors` and the colour constants | none |
@@ -80,7 +81,7 @@ The root entry exports everything. It still imports `BottomSheetContainer` and `
80
81
  | `@vvedantb/native-ui/date-time` | `DateTimeField`, `DateTimeFieldProps` (0.5.0) | `@react-native-community/datetimepicker`, `dayjs` (import) |
81
82
  | `@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
83
  | `@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`, `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
+ | `@vvedantb/native-ui/auth` | From 0.7.0: `AuthProviders`, `SignInScreen`, `SignUpScreen`, `GoogleSignInButton`, `useGoogleSignIn`, `AuthScreen`, `OrDivider`, `FieldError`, `authErrorMessage`, `SecondFactorScreen`, `useSecondFactor`, `submitTotpSecondFactor`, `submitSecondFactor`, `prepareSecondFactorIfNeeded`, `prepareFirstFactorIfNeeded`, `submitFirstFactor`, `pickFirstFactorStrategy`, `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
85
  | `@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
86
  | `@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
87
 
@@ -212,8 +213,9 @@ export default function SignUp() {
212
213
  - `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
214
  - 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
215
  - 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.
216
+ - `needs_first_factor`: when the account has an `email_code` or `phone_code` first factor (with its `emailAddressId` or `phoneNumberId`), the screen sends the code (`prepareFirstFactor`), shows `SecondFactorScreen`, and checks it with `attemptFirstFactor`. The result then runs the usual path: session, new password, or second factor. Email is preferred; a link switches to phone, and back. Back returns to the email and password form.
215
217
  - `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`.
218
+ - `onIncomplete(signIn)`: optional. Called only for statuses the built-in UI cannot finish: `needs_identifier`, `needs_first_factor` without an `email_code` or `phone_code` 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`, and runs `needs_first_factor` when an email or phone code exists. 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
219
  - 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.
218
220
  - No forgot-password step. An app that needs one keeps its own screen.
219
221
 
@@ -309,7 +311,7 @@ Limits: a call after the first render does not redraw mounted components. Only t
309
311
 
310
312
  | Area | Exports |
311
313
  | --- | --- |
312
- | Actions and fields | `Button` (`default`/`primary`, `secondary`, `outline`, `destructive`, `ghost`, `link`, `tinted`; `sm`/`small`, `default`/`medium`, `lg`/`large`, `icon`; `shape` `pill`/`rounded` from 0.6.0), `IconButton` (0.5.0), `CaptureButton` (0.5.0), `Input`, `SearchInput` (0.3.9), `Textarea`, `Label`, `Select`, `Switch`, `Checkbox`, `LabeledSwitchRow`, `ColorSwatches` (0.5.0), `DateTimeField` (date-only, empty and clearable from 0.5.0) |
314
+ | Actions and fields | `Button` (`default`/`primary`, `secondary`, `outline`, `destructive`, `ghost`, `link`, `tinted`; `sm`/`small`, `default`/`medium`, `lg`/`large`, `icon`; `shape` `pill`/`rounded` from 0.6.0), `IconButton` (0.5.0), `CaptureButton` (0.5.0), `Input`, `SearchInput` (0.3.9), `PasswordInput` (0.11.0), `Textarea`, `Label`, `Select`, `Switch`, `Checkbox`, `LabeledSwitchRow`, `ColorSwatches` (0.5.0), `DateTimeField` (date-only, empty and clearable from 0.5.0) |
313
315
  | Display | `Card` (+ `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`; `onPress` from 0.5.0), `Badge`, `StatusDot` (`info` from 0.5.0), `Avatar` (+ `AvatarImage`, `AvatarFallback`, `initials()`), `Separator`, `Progress`, `Spinner`, `CenteredSpinner`, `Skeleton`, `EmptyState` (+ `EmptyState.Root` and parts), `ListRow` (`leading`, `titleEnd`, `control`, `onLongPress` from 0.5.0), `StatGrid` (0.5.0), `SectionLabel` (0.5.0), `Chip`, `Text` (0.5.0, `/primitives` only) |
314
316
  | Navigation | `Tabs` (+ `TabsList`, `TabsTrigger`, `TabsContent`), `PillTabs`, `PageIndicator` (0.5.0), `FloatingTabBar`, `FloatingFAB`, `HeaderIconButton`, `ThemeToggle` (0.3.16), `StackPageHeader` (0.5.0) |
315
317
  | Overlays | `Dialog`, `AlertDialog`, `ConfirmDialog`, `Sheet` (RN `Modal`, each with the web part names), `SheetRow` (0.5.0), `BottomSheetContainer` (`@gorhom/bottom-sheet`), `DropdownMenu` (Unreleased; see below) |
@@ -360,6 +362,12 @@ const [query, setQuery] = useState("");
360
362
  <SearchInput value={query} onChange={setQuery} label="Search apps" />
361
363
  ```
362
364
 
365
+ From 0.11.0 `PasswordInput` is `Input` with a show/hide eye button at the end (`IconEye` and `IconEyeOff`). The password is hidden at first. The button is labelled "Show password" or "Hide password" to match, has the test ID `${testID}-toggle` (`password-input-toggle` without a `testID`), is painted at 24 and reaches 44 with its hit slop. It flips `secureTextEntry` on the same mounted field, so focus and the cursor stay. The component owns `secureTextEntry`, so that prop is not accepted. Other `Input` props and `ref` pass through; the button is hidden when `editable={false}`. The `SignInScreen` password, new-password and confirm-password fields use it. Inside a `ScrollView`, set `keyboardShouldPersistTaps="handled"`.
366
+
367
+ ```tsx
368
+ <PasswordInput value={password} onChangeText={setPassword} placeholder="Password" autoComplete="password" textContentType="password" />
369
+ ```
370
+
363
371
  From 0.3.13 `PageTitle` is the web page title, the type on the web `Breadcrumb` (`text-xl font-semibold leading-tight tracking-tight`): Instrument Sans 600 at 20 / 25 with -0.5 letter spacing (-0.025em), in the foreground colour. It sets the semibold face class and `fontWeight: "600"` together, so it is not drawn Regular on Android or Expo web. It is a header for VoiceOver and TalkBack, one line (pass `numberOfLines={0}` to wrap), and scales with the system font size. `PageHeader` is a tab-root title row: `PageTitle` at the start and an optional `action` at the end, which keeps its width while a long title is cut off. It has the `ScreenContainer` side padding (`px-4`) and a row at least 56 high, so tabs with and without an action put their titles at the same height. It pads the top by the safe-area inset; pass `safeTop={false}` under a native header or inside a view that already pads it. Give a `Button` in `action` `fullWidth={false}`.
364
372
 
365
373
  ```tsx
@@ -554,7 +562,7 @@ The bar is the glass `FloatingSurface` at radius 16 with `px-2.5 py-2`. It holds
554
562
 
555
563
  ## Phones and accessibility
556
564
 
557
- The components keep the web sizes on screen and reach a 44 pt touch target with `hitSlop` (`touchSlop` in `src/controlStyles.ts`): `Checkbox` (16), `Switch` (36 × 20), tab triggers (30 high), the `Select` trigger (40 high), the `SearchInput` clear button (24), the `UpgradeToProCard` pill (32 high, slop 8) and `Button` (the slop brings each size to at least 48). From 0.5.0 so do `ColorSwatches` swatches and `IconButton` (32 and 40); the `DateTimeField` clear button and the pressable `PageIndicator` dots are 44 squares. Controls set their role and state for VoiceOver and TalkBack: `Button` (`disabled`, `busy` while loading), `Checkbox` (`checked`, `mixed`), `Switch` (`checked`), `Tabs` and `PillTabs` (`tablist`, `tab`, `selected`) and the `Select` trigger (`combobox`, with an `accessibilityLabel` prop). Give a `Button` with no text child an `accessibilityLabel`.
565
+ The components keep the web sizes on screen and reach a 44 pt touch target with `hitSlop` (`touchSlop` in `src/controlStyles.ts`): `Checkbox` (16), `Switch` (36 × 20), tab triggers (30 high), the `Select` trigger (40 high), the `SearchInput` clear button (24), the `PasswordInput` show/hide button (24), the `UpgradeToProCard` pill (32 high, slop 8) and `Button` (the slop brings each size to at least 48). From 0.5.0 so do `ColorSwatches` swatches and `IconButton` (32 and 40); the `DateTimeField` clear button and the pressable `PageIndicator` dots are 44 squares. Controls set their role and state for VoiceOver and TalkBack: `Button` (`disabled`, `busy` while loading), `Checkbox` (`checked`, `mixed`), `Switch` (`checked`), `Tabs` and `PillTabs` (`tablist`, `tab`, `selected`) and the `Select` trigger (`combobox`, with an `accessibilityLabel` prop). Give a `Button` with no text child an `accessibilityLabel`.
558
566
 
559
567
  Text scales with the system font size. Labelled controls have minimum heights, so a large Dynamic Type size grows them instead of clipping the label (the `SearchInput` lock grows with the font scale); only the `FloatingTabBar` labels are capped (1.5×), to fit the fixed bar. With Reduce Motion on, dialogs, sheets, toasts, the `Select` popover, `PricingModal`, `Progress`, `Skeleton` and the `FloatingTabBar` pill do not animate. Dialogs, sheets, toasts, `FloatingTabBar` and `FloatingFAB` stay inside the safe-area insets, and dialog and sheet fields stay above the keyboard on iOS and Android.
560
568
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/native-ui",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
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",
@@ -37,6 +37,10 @@
37
37
  "types": "./src/entries/search-input.ts",
38
38
  "default": "./src/entries/search-input.ts"
39
39
  },
40
+ "./password-input": {
41
+ "types": "./src/entries/password-input.ts",
42
+ "default": "./src/entries/password-input.ts"
43
+ },
40
44
  "./page-title": {
41
45
  "types": "./src/entries/page-title.ts",
42
46
  "default": "./src/entries/page-title.ts"
@@ -0,0 +1,101 @@
1
+ import * as React from "react";
2
+ import { Pressable, View } from "react-native";
3
+ import { IconEye, IconEyeOff } from "@tabler/icons-react-native";
4
+ import { Input } from "./Input";
5
+ import { cn } from "./cn";
6
+ import { fieldStyle, fieldTextStyle, touchSlop, typeStyle } from "./controlStyles";
7
+ import { tokens } from "./tokens";
8
+ import { useLargerText } from "./theme";
9
+ import { useScheme } from "./useScheme";
10
+
11
+ type PasswordInputProps = Omit<React.ComponentPropsWithoutRef<typeof Input>, "secureTextEntry"> & {
12
+ /** Classes for the `View` around the field and the toggle. */
13
+ wrapperClassName?: string;
14
+ };
15
+
16
+ /** Toggle metrics: painted 24 (32 with larger text), 44 with its hit slop. */
17
+ function passwordToggleSize(largerText: boolean) {
18
+ return largerText
19
+ ? ({ button: 32, icon: 20, right: 12, paddingRight: 56 } as const)
20
+ : ({ button: 24, icon: 18, right: 8, paddingRight: 44 } as const);
21
+ }
22
+
23
+ /**
24
+ * `Input` for a password, with a show/hide eye button at the end. The text is
25
+ * hidden at first. The toggle flips `secureTextEntry` on the same mounted
26
+ * field, so focus and the cursor stay where they were. The `secureTextEntry`
27
+ * prop is not accepted: the toggle owns it. Every other `Input` prop, `ref`
28
+ * and `testID` pass through. The toggle is labelled "Show password" while the
29
+ * text is hidden and "Hide password" while it shows, has the test ID
30
+ * `${testID}-toggle` (`password-input-toggle` without a `testID`), is painted
31
+ * at 24 and reaches 44 with its hit slop (`touchSlop`). It is hidden when the
32
+ * field is not editable. Inside a `ScrollView`, set
33
+ * `keyboardShouldPersistTaps="handled"` so the first tap toggles while the
34
+ * keyboard is up.
35
+ */
36
+ const PasswordInput = React.forwardRef<
37
+ React.ElementRef<typeof Input>,
38
+ PasswordInputProps
39
+ >(({ wrapperClassName, className, style, testID, ...props }, ref) => {
40
+ const largerText = useLargerText();
41
+ const scheme = useScheme();
42
+ const [visible, setVisible] = React.useState(false);
43
+ const metrics = passwordToggleSize(largerText);
44
+ const muted = tokens.color.muted[scheme];
45
+ const Icon = visible ? IconEyeOff : IconEye;
46
+ return (
47
+ <View className={cn("relative w-full", wrapperClassName)}>
48
+ <Input
49
+ {...props}
50
+ ref={ref}
51
+ testID={testID}
52
+ // The same size and font classes as `Input`, so the field has the same
53
+ // height and type. `Input` adds them too; `cn` merges the repeat.
54
+ // The type comes from these classes only (no style `fontSize`), so it
55
+ // cannot diverge from NativeWind's `text-base` / `text-xl`.
56
+ className={cn(
57
+ "min-h-10 border px-3.5 py-2 text-base font-instrument-sans",
58
+ largerText && "min-h-16 px-4 py-4 text-xl font-instrument-sans-medium",
59
+ className,
60
+ )}
61
+ // After the spread, so a caller cannot fight the toggle.
62
+ secureTextEntry={!visible}
63
+ // The `Input` styles. `fontSize` and `lineHeight` are not set: the field
64
+ // inherits `Input`'s type through the same className. Then room for the
65
+ // toggle, so text does not run under it.
66
+ style={[
67
+ fieldStyle(scheme),
68
+ fieldTextStyle(scheme),
69
+ largerText ? typeStyle("medium") : null,
70
+ { paddingRight: metrics.paddingRight },
71
+ style,
72
+ ]}
73
+ />
74
+ {props.editable !== false ? (
75
+ <View
76
+ pointerEvents="box-none"
77
+ className="absolute bottom-0 top-0 justify-center"
78
+ style={{ right: metrics.right }}
79
+ >
80
+ <Pressable
81
+ testID={testID ? `${testID}-toggle` : "password-input-toggle"}
82
+ accessibilityRole="button"
83
+ accessibilityLabel={visible ? "Hide password" : "Show password"}
84
+ hitSlop={touchSlop(metrics.button)}
85
+ onPress={() => setVisible((current) => !current)}
86
+ className="items-center justify-center rounded-md active:opacity-70"
87
+ style={{ width: metrics.button, height: metrics.button }}
88
+ >
89
+ {/* `strokeWidth`, not `stroke`: here `stroke` is the colour. */}
90
+ <Icon size={metrics.icon} color={muted} strokeWidth={1.75} />
91
+ </Pressable>
92
+ </View>
93
+ ) : null}
94
+ </View>
95
+ );
96
+ });
97
+
98
+ PasswordInput.displayName = "PasswordInput";
99
+
100
+ export { PasswordInput };
101
+ export type { PasswordInputProps };
@@ -5,15 +5,28 @@ import { useRouter } from "expo-router";
5
5
  import { Button } from "../Button";
6
6
  import { textStyle } from "../controlStyles";
7
7
  import { Input } from "../Input";
8
+ import { PasswordInput } from "../PasswordInput";
8
9
  import { toast } from "../toastStore";
9
10
  import { useScheme } from "../useScheme";
10
11
  import { AuthScreen, FieldError, OrDivider } from "./AuthScreen";
11
- import { authErrorMessage } from "./authErrors";
12
+ import {
13
+ authErrorMessage,
14
+ DEFAULT_STAGE_TIMEOUT_MS,
15
+ isAuthTimeoutError,
16
+ logAuthFailure,
17
+ withAuthTimeout,
18
+ } from "./authErrors";
12
19
  import { SecondFactorScreen } from "./SecondFactorScreen";
13
20
  import {
21
+ pickFirstFactorStrategy,
14
22
  pickSecondFactorStrategy,
23
+ prepareFirstFactorIfNeeded,
24
+ submitFirstFactor,
25
+ supportedFirstFactorStrategies,
15
26
  supportedSecondFactorStrategies,
16
27
  useSecondFactor,
28
+ type FirstFactor,
29
+ type FirstFactorStrategy,
17
30
  type SecondFactor,
18
31
  type SecondFactorStrategy,
19
32
  } from "./totp";
@@ -121,15 +134,23 @@ export type SignInScreenProps = AuthNavigationOptions & {
121
134
  footer?: ReactNode;
122
135
  /**
123
136
  * Called with the sign-in when the built-in UI cannot finish it:
124
- * `needs_identifier`, `needs_first_factor`, any unknown status, and
137
+ * `needs_identifier`, any unknown status, `needs_first_factor` when no
138
+ * `email_code` or `phone_code` first factor exists (with its id), and
125
139
  * `needs_second_factor` or `needs_client_trust` when no supported strategy
126
140
  * (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
141
+ * runs `needs_first_factor` with an email or phone code, and always runs
142
+ * `needs_second_factor`, `needs_client_trust` and `needs_new_password`
143
+ * itself. When called, the web-fallback message is not
129
144
  * shown. Without it, the screen shows the web fallback and logs
130
145
  * `console.warn`. Ignored in `sign-up`.
131
146
  */
132
147
  onIncomplete?: (signIn: IncompleteSignIn) => void;
148
+ /**
149
+ * Time for each Clerk call (create, prepare, attempt, reset password,
150
+ * set active), in milliseconds. A call that takes longer fails with code
151
+ * `auth_timeout`. Default 20000.
152
+ */
153
+ stageTimeoutMs?: number;
133
154
  };
134
155
 
135
156
  /**
@@ -158,6 +179,7 @@ export function SignInScreen({
158
179
  header,
159
180
  footer,
160
181
  onIncomplete,
182
+ stageTimeoutMs = DEFAULT_STAGE_TIMEOUT_MS,
161
183
  }: SignInScreenProps) {
162
184
  const signInState = useSignIn();
163
185
  const signUpState = useSignUp();
@@ -168,12 +190,17 @@ export function SignInScreen({
168
190
  const [code, setCode] = useState("");
169
191
  const [pendingVerification, setPendingVerification] = useState(false);
170
192
  const [pendingSecondFactor, setPendingSecondFactor] = useState(false);
193
+ const [pendingFirstFactor, setPendingFirstFactor] = useState(false);
194
+ const [firstFactors, setFirstFactors] = useState<FirstFactor[]>([]);
195
+ const [firstStrategy, setFirstStrategy] = useState<FirstFactorStrategy>("email_code");
196
+ const [firstLoading, setFirstLoading] = useState(false);
197
+ const [firstError, setFirstError] = useState("");
171
198
  const [factors, setFactors] = useState<SecondFactor[]>([]);
172
199
  const [clientTrust, setClientTrust] = useState(false);
173
200
  const [pendingNewPassword, setPendingNewPassword] = useState(false);
174
201
  const [newPassword, setNewPassword] = useState("");
175
202
  const [confirmPassword, setConfirmPassword] = useState("");
176
- const secondFactor = useSecondFactor();
203
+ const secondFactor = useSecondFactor({ stageTimeoutMs });
177
204
  const [error, setError] = useState("");
178
205
  const [submitting, setSubmitting] = useState(false);
179
206
  const signUp = mode === "sign-up";
@@ -224,6 +251,78 @@ export function SignInScreen({
224
251
  await chooseStrategy(strategy, list);
225
252
  };
226
253
 
254
+ const chooseFirstStrategy = async (strategy: FirstFactorStrategy, list: FirstFactor[]) => {
255
+ const { signIn } = signInState;
256
+ if (!signIn) return;
257
+ setFirstError("");
258
+ setFirstStrategy(strategy);
259
+ setPendingFirstFactor(true);
260
+ setFirstLoading(true);
261
+ const sent = await prepareFirstFactorIfNeeded({
262
+ signIn,
263
+ strategy,
264
+ factor: list.find((factor) => factor.strategy === strategy),
265
+ timeoutMs: stageTimeoutMs,
266
+ });
267
+ if (!sent.ok) setFirstError(sent.error);
268
+ setFirstLoading(false);
269
+ };
270
+
271
+ const startFirstFactor = async (attempt: IncompleteSignIn) => {
272
+ const list = attempt.supportedFirstFactors ?? [];
273
+ const strategy = pickFirstFactorStrategy(list);
274
+ if (!strategy) return unhandled(attempt);
275
+ setFirstFactors(list);
276
+ await chooseFirstStrategy(strategy, list);
277
+ };
278
+
279
+ // Routes a sign-in attempt. `needs_first_factor` is built in only after `signIn.create`.
280
+ const routeAttempt = async (attempt: IncompleteSignIn, afterCreate: boolean) => {
281
+ const { setActive } = signInState;
282
+ // Clerk's `SignInStatus` omits `needs_client_trust`: widen before comparing.
283
+ const status: string | null = attempt.status;
284
+ if (status === "complete" && attempt.createdSessionId && setActive) {
285
+ await withAuthTimeout(setActive({ session: attempt.createdSessionId }), "set-active", stageTimeoutMs);
286
+ done();
287
+ } else if (status === "needs_new_password") {
288
+ setPendingFirstFactor(false);
289
+ setPendingNewPassword(true);
290
+ } else if (status === "needs_second_factor" || status === "needs_client_trust") {
291
+ setPendingFirstFactor(false);
292
+ await startSecondFactor(attempt, status === "needs_client_trust");
293
+ } else if (status === "needs_first_factor" && afterCreate) {
294
+ await startFirstFactor(attempt);
295
+ } else {
296
+ unhandled(attempt);
297
+ }
298
+ };
299
+
300
+ const onFirstFactor = async (firstCode: string) => {
301
+ const { isLoaded, signIn } = signInState;
302
+ if (!isLoaded || !signIn || firstLoading) return;
303
+ setFirstLoading(true);
304
+ setFirstError("");
305
+ const result = await submitFirstFactor({
306
+ signIn,
307
+ code: firstCode,
308
+ strategy: firstStrategy,
309
+ timeoutMs: stageTimeoutMs,
310
+ });
311
+ if (!result.ok) {
312
+ setFirstError(result.error);
313
+ setFirstLoading(false);
314
+ return;
315
+ }
316
+ try {
317
+ await routeAttempt(result.attempt, false);
318
+ } catch (caught) {
319
+ if (isAuthTimeoutError(caught)) logAuthFailure("set-active", caught);
320
+ setFirstError(authErrorMessage(caught, AUTH_MESSAGES.verifyFailed));
321
+ } finally {
322
+ setFirstLoading(false);
323
+ }
324
+ };
325
+
227
326
  const onNewPassword = async () => {
228
327
  const { isLoaded, signIn, setActive } = signInState;
229
328
  if (!isLoaded || !signIn || !setActive || submitting) return;
@@ -237,9 +336,13 @@ export function SignInScreen({
237
336
  setSubmitting(true);
238
337
  setError("");
239
338
  try {
240
- const result = await signIn.resetPassword({ password: newPassword });
339
+ const result = await withAuthTimeout(
340
+ signIn.resetPassword({ password: newPassword }),
341
+ "reset-password",
342
+ stageTimeoutMs,
343
+ );
241
344
  if (result.status === "complete" && result.createdSessionId) {
242
- await setActive({ session: result.createdSessionId });
345
+ await withAuthTimeout(setActive({ session: result.createdSessionId }), "set-active", stageTimeoutMs);
243
346
  done();
244
347
  } else if (onIncomplete) {
245
348
  onIncomplete(result);
@@ -248,6 +351,7 @@ export function SignInScreen({
248
351
  fail(AUTH_MESSAGES.resetPasswordFailed, AUTH_MESSAGES.needsAnotherStep);
249
352
  }
250
353
  } catch (caught) {
354
+ if (isAuthTimeoutError(caught)) logAuthFailure("reset-password", caught);
251
355
  fail(AUTH_MESSAGES.resetPasswordFailed, authErrorMessage(caught, AUTH_MESSAGES.resetPasswordFailed));
252
356
  } finally {
253
357
  setSubmitting(false);
@@ -261,19 +365,18 @@ export function SignInScreen({
261
365
  setSubmitting(true);
262
366
  setError("");
263
367
  try {
264
- const attempt = await signIn.create({ identifier: email, password });
265
- if (attempt.status === "complete" && attempt.createdSessionId) {
266
- await setActive({ session: attempt.createdSessionId });
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");
272
- } else {
273
- unhandled(attempt);
274
- }
368
+ const attempt = await withAuthTimeout(
369
+ signIn.create({ identifier: email, password }),
370
+ "create",
371
+ stageTimeoutMs,
372
+ );
373
+ await routeAttempt(attempt, true);
275
374
  } catch (caught) {
276
- fail(AUTH_MESSAGES.signInFailed, authErrorMessage(caught, AUTH_MESSAGES.invalidCredentials));
375
+ logAuthFailure("create", caught);
376
+ fail(
377
+ AUTH_MESSAGES.signInFailed,
378
+ authErrorMessage(caught, AUTH_MESSAGES.invalidCredentials, { credentials: true }),
379
+ );
277
380
  } finally {
278
381
  setSubmitting(false);
279
382
  }
@@ -287,6 +390,8 @@ export function SignInScreen({
287
390
  const onSecondFactorBack = () => {
288
391
  // The pending attempt stays; the next `signIn.create` replaces it.
289
392
  setPendingSecondFactor(false);
393
+ setPendingFirstFactor(false);
394
+ setFirstError("");
290
395
  setPendingNewPassword(false);
291
396
  setNewPassword("");
292
397
  setConfirmPassword("");
@@ -368,26 +473,24 @@ export function SignInScreen({
368
473
  subtitle={AUTH_MESSAGES.newPasswordDescription}
369
474
  header={header}
370
475
  >
371
- <Input
476
+ <PasswordInput
372
477
  testID={id("new-password")}
373
478
  accessibilityLabel={AUTH_MESSAGES.newPasswordLabel}
374
479
  autoCapitalize="none"
375
480
  autoComplete="new-password"
376
481
  textContentType="newPassword"
377
- secureTextEntry
378
482
  autoFocus
379
483
  returnKeyType="next"
380
484
  value={newPassword}
381
485
  placeholder={AUTH_MESSAGES.newPasswordLabel}
382
486
  onChangeText={setNewPassword}
383
487
  />
384
- <Input
488
+ <PasswordInput
385
489
  testID={id("confirm-password")}
386
490
  accessibilityLabel={AUTH_MESSAGES.confirmPasswordLabel}
387
491
  autoCapitalize="none"
388
492
  autoComplete="new-password"
389
493
  textContentType="newPassword"
390
- secureTextEntry
391
494
  returnKeyType="go"
392
495
  value={confirmPassword}
393
496
  placeholder={AUTH_MESSAGES.confirmPasswordLabel}
@@ -405,6 +508,40 @@ export function SignInScreen({
405
508
  );
406
509
  }
407
510
 
511
+ if (pendingFirstFactor && !pendingSecondFactor && !pendingNewPassword) {
512
+ const others = supportedFirstFactorStrategies(firstFactors).filter((strategy) => strategy !== firstStrategy);
513
+ const switchTo = others[0] ?? null;
514
+ const copy = secondFactorCopy(firstStrategy, false);
515
+ return (
516
+ <SecondFactorScreen
517
+ key={`first-${firstStrategy}`}
518
+ title={copy.title}
519
+ description={copy.description}
520
+ footer={
521
+ switchTo ? (
522
+ <Button
523
+ testID={id("first-factor-switch")}
524
+ variant="link"
525
+ size="sm"
526
+ fullWidth={false}
527
+ disabled={firstLoading}
528
+ onPress={() => void chooseFirstStrategy(switchTo, firstFactors)}
529
+ >
530
+ {METHOD_LABEL[switchTo]}
531
+ </Button>
532
+ ) : undefined
533
+ }
534
+ onSubmit={onFirstFactor}
535
+ error={firstError}
536
+ loading={firstLoading}
537
+ onBack={onSecondFactorBack}
538
+ backLabel="Use a different account"
539
+ header={header}
540
+ testIDPrefix={id("first-factor")}
541
+ />
542
+ );
543
+ }
544
+
408
545
  if (pendingSecondFactor) {
409
546
  const active = secondFactor.strategy;
410
547
  const available = supportedSecondFactorStrategies(factors);
@@ -528,13 +665,12 @@ export function SignInScreen({
528
665
  placeholder="Email"
529
666
  onChangeText={setEmail}
530
667
  />
531
- <Input
668
+ <PasswordInput
532
669
  testID={id("password")}
533
670
  accessibilityLabel="Password"
534
671
  autoCapitalize="none"
535
672
  autoComplete={signUp ? "new-password" : "password"}
536
673
  textContentType={signUp ? "newPassword" : "password"}
537
- secureTextEntry
538
674
  returnKeyType="go"
539
675
  value={password}
540
676
  placeholder="Password"
@@ -1,19 +1,145 @@
1
1
  import { isClerkAPIResponseError } from "@clerk/clerk-expo";
2
2
 
3
3
  /** Words that must not reach the user: the auth provider and stack names. */
4
- const PRODUCT_NAMES = /\b(clerk|convex)\b/i;
4
+ const PRODUCT_NAMES = /\b(clerk|convex)\b/gi;
5
+
6
+ /** The only Clerk codes that mean "Invalid email or password". */
7
+ export const CREDENTIAL_ERROR_CODES = ["form_password_incorrect", "form_identifier_not_found"] as const;
8
+
9
+ type ClerkErrorItem = {
10
+ code?: unknown;
11
+ message?: unknown;
12
+ longMessage?: unknown;
13
+ meta?: { paramName?: unknown } | null;
14
+ };
15
+
16
+ type ParsedAuthError = {
17
+ errors: ClerkErrorItem[];
18
+ status?: number;
19
+ clerkTraceId?: string;
20
+ errorName?: string;
21
+ };
22
+
23
+ const asString = (value: unknown): string | undefined =>
24
+ typeof value === "string" && value.length > 0 ? value : undefined;
25
+
26
+ /** Reads a Clerk API error, or a plain `{ errors: [...] }` object. No `zod`. */
27
+ function parseAuthError(error: unknown): ParsedAuthError | null {
28
+ if (typeof error !== "object" || error === null) return null;
29
+ const record = error as Record<string, unknown>;
30
+ if (!isClerkAPIResponseError(error) && !Array.isArray(record.errors)) return null;
31
+ const errors = Array.isArray(record.errors)
32
+ ? (record.errors as unknown[]).filter((item): item is ClerkErrorItem => typeof item === "object" && item !== null)
33
+ : [];
34
+ return {
35
+ errors,
36
+ status: typeof record.status === "number" ? record.status : undefined,
37
+ clerkTraceId: asString(record.clerkTraceId),
38
+ errorName: asString(record.name),
39
+ };
40
+ }
41
+
42
+ function firstError(parsed: ParsedAuthError | null) {
43
+ const first = parsed?.errors[0];
44
+ return {
45
+ code: asString(first?.code),
46
+ message: asString(first?.message),
47
+ longMessage: asString(first?.longMessage),
48
+ paramName: asString(first?.meta?.paramName),
49
+ };
50
+ }
51
+
52
+ /** The code of a timed-out auth stage. */
53
+ export const AUTH_TIMEOUT_CODE = "auth_timeout";
54
+
55
+ /** Default time for one Clerk call, in milliseconds. */
56
+ export const DEFAULT_STAGE_TIMEOUT_MS = 20000;
5
57
 
6
58
  /**
7
- * A message to show for an auth error. An API error gives its first
8
- * `longMessage` or `message` (such as "Password is incorrect."); anything
9
- * else, or a message that names the provider, gives `fallback`. Never shows
10
- * a raw exception message.
59
+ * The error `withAuthTimeout` rejects with. It is Clerk-shaped (`errors[0].code`
60
+ * is `auth_timeout`), so `authErrorMessage` and `logAuthFailure` read it.
11
61
  */
12
- export function authErrorMessage(error: unknown, fallback: string): string {
13
- if (isClerkAPIResponseError(error)) {
14
- const first = error.errors[0];
15
- const message = first?.longMessage ?? first?.message;
16
- if (message && !PRODUCT_NAMES.test(message)) return message;
62
+ export class AuthTimeoutError extends Error {
63
+ readonly code = AUTH_TIMEOUT_CODE;
64
+ readonly stage: string;
65
+ readonly errors: { code: string; message: string }[];
66
+ constructor(stage: string, ms: number) {
67
+ const message = `The ${stage} step timed out after ${Math.round(ms / 1000)} seconds. Try again.`;
68
+ super(message);
69
+ this.name = "AuthTimeoutError";
70
+ this.stage = stage;
71
+ this.errors = [{ code: AUTH_TIMEOUT_CODE, message }];
17
72
  }
18
- return fallback;
73
+ }
74
+
75
+ export function isAuthTimeoutError(error: unknown): error is AuthTimeoutError {
76
+ return error instanceof AuthTimeoutError;
77
+ }
78
+
79
+ /**
80
+ * Rejects with `AuthTimeoutError` (code `auth_timeout`, message names `stage`)
81
+ * when `promise` has not settled after `ms`. The timer is cleared on settle.
82
+ */
83
+ export function withAuthTimeout<T>(promise: Promise<T>, stage: string, ms: number = DEFAULT_STAGE_TIMEOUT_MS): Promise<T> {
84
+ return new Promise<T>((resolve, reject) => {
85
+ const timer = setTimeout(() => reject(new AuthTimeoutError(stage, ms)), ms);
86
+ promise.then(
87
+ (value) => {
88
+ clearTimeout(timer);
89
+ resolve(value);
90
+ },
91
+ (error) => {
92
+ clearTimeout(timer);
93
+ reject(error);
94
+ },
95
+ );
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Logs a failed auth call as one structured `console.error`. It holds the
101
+ * Clerk code, message, longMessage and meta.paramName, plus stage, errorName,
102
+ * status and clerkTraceId when present. For `AuthTimeoutError` the logged stage is the error's own stage. Only the error object is read, so a
103
+ * password or one-time code is never logged.
104
+ */
105
+ export function logAuthFailure(stage: string, error: unknown): void {
106
+ if (isAuthTimeoutError(error)) stage = error.stage;
107
+ const parsed = parseAuthError(error);
108
+ const { code, message, longMessage, paramName } = firstError(parsed);
109
+ const errorName = parsed?.errorName ?? (error instanceof Error ? error.name : undefined);
110
+ console.error("[sign-in] failure", {
111
+ stage,
112
+ ...(errorName ? { errorName } : {}),
113
+ ...(parsed?.status !== undefined ? { status: parsed.status } : {}),
114
+ ...(parsed?.clerkTraceId ? { clerkTraceId: parsed.clerkTraceId } : {}),
115
+ code,
116
+ message,
117
+ longMessage,
118
+ meta: { paramName },
119
+ });
120
+ }
121
+
122
+ export type AuthErrorOptions = {
123
+ /**
124
+ * Sign-in with email and password. `form_password_incorrect` and
125
+ * `form_identifier_not_found` (only these) return `fallback`.
126
+ */
127
+ credentials?: boolean;
128
+ };
129
+
130
+ /**
131
+ * A message to show for an auth error. For a Clerk error it gives the first
132
+ * `longMessage` or `message`, with `clerk` and `convex` written as "the
133
+ * sign-in service", then `(code: <code>)`. With no text it gives `fallback`,
134
+ * still followed by the code when there is one. With `credentials: true`, the
135
+ * two credential codes give `fallback` alone. Anything that is not a Clerk
136
+ * error gives `fallback`. Never shows a raw exception message.
137
+ */
138
+ export function authErrorMessage(error: unknown, fallback: string, options: AuthErrorOptions = {}): string {
139
+ const parsed = parseAuthError(error);
140
+ if (!parsed) return fallback;
141
+ const { code, message, longMessage } = firstError(parsed);
142
+ if (options.credentials && code && (CREDENTIAL_ERROR_CODES as readonly string[]).includes(code)) return fallback;
143
+ const text = (longMessage ?? message)?.replace(PRODUCT_NAMES, "the sign-in service") ?? fallback;
144
+ return code ? `${text} (code: ${code})` : text;
19
145
  }
package/src/auth/index.ts CHANGED
@@ -6,7 +6,17 @@ export { AuthProviders } from "./AuthProviders";
6
6
  export type { AuthProvidersProps } from "./AuthProviders";
7
7
  export { AuthScreen, OrDivider, FieldError } from "./AuthScreen";
8
8
  export type { AuthScreenProps } from "./AuthScreen";
9
- export { authErrorMessage } from "./authErrors";
9
+ export {
10
+ authErrorMessage,
11
+ logAuthFailure,
12
+ withAuthTimeout,
13
+ AuthTimeoutError,
14
+ isAuthTimeoutError,
15
+ AUTH_TIMEOUT_CODE,
16
+ DEFAULT_STAGE_TIMEOUT_MS,
17
+ CREDENTIAL_ERROR_CODES,
18
+ } from "./authErrors";
19
+ export type { AuthErrorOptions } from "./authErrors";
10
20
  export {
11
21
  GoogleSignInButton,
12
22
  useGoogleSignIn,
@@ -26,6 +36,11 @@ export {
26
36
  submitTotpSecondFactor,
27
37
  submitSecondFactor,
28
38
  prepareSecondFactorIfNeeded,
39
+ prepareFirstFactorIfNeeded,
40
+ submitFirstFactor,
41
+ pickFirstFactorStrategy,
42
+ supportedFirstFactorStrategies,
43
+ FIRST_FACTOR_PREFERENCE,
29
44
  pickSecondFactorStrategy,
30
45
  supportedSecondFactorStrategies,
31
46
  SECOND_FACTOR_PREFERENCE,
@@ -33,6 +48,10 @@ export {
33
48
  TOTP_MESSAGES,
34
49
  } from "./totp";
35
50
  export type {
51
+ FirstFactor,
52
+ FirstFactorResult,
53
+ FirstFactorSignIn,
54
+ FirstFactorStrategy,
36
55
  SecondFactor,
37
56
  SecondFactorSignIn,
38
57
  SecondFactorStrategy,
package/src/auth/totp.ts CHANGED
@@ -1,6 +1,17 @@
1
1
  import { useState } from "react";
2
2
  import { useSignIn } from "@clerk/clerk-expo";
3
- import { authErrorMessage } from "./authErrors";
3
+ import {
4
+ authErrorMessage,
5
+ DEFAULT_STAGE_TIMEOUT_MS,
6
+ isAuthTimeoutError,
7
+ logAuthFailure,
8
+ withAuthTimeout,
9
+ } from "./authErrors";
10
+
11
+ /** Logs a timed-out stage. Other failures are not logged here. */
12
+ const logTimeout = (stage: string, error: unknown) => {
13
+ if (isAuthTimeoutError(error)) logAuthFailure(stage, error);
14
+ };
4
15
 
5
16
  /** The messages the second-factor step can show. Generic: none names a provider. */
6
17
  export const TOTP_MESSAGES = {
@@ -76,25 +87,37 @@ export async function prepareSecondFactorIfNeeded(args: {
76
87
  signIn: Pick<SecondFactorSignIn, "supportedSecondFactors" | "prepareSecondFactor">;
77
88
  strategy: SecondFactorStrategy;
78
89
  factor?: SecondFactor;
90
+ /** Time for the Clerk call. Default 20000 ms. */
91
+ timeoutMs?: number;
79
92
  }): Promise<TotpResult> {
80
93
  const { signIn, strategy } = args;
94
+ const ms = args.timeoutMs ?? DEFAULT_STAGE_TIMEOUT_MS;
81
95
  if (strategy !== "phone_code" && strategy !== "email_code") return { ok: true };
82
96
  if (!signIn.prepareSecondFactor) return { ok: false, error: TOTP_MESSAGES.sendFailed };
83
97
  const factor = args.factor ?? signIn.supportedSecondFactors?.find((item) => item.strategy === strategy);
84
98
  try {
85
99
  if (strategy === "phone_code") {
86
- await signIn.prepareSecondFactor({
87
- strategy,
88
- ...(factor?.phoneNumberId ? { phoneNumberId: factor.phoneNumberId } : {}),
89
- });
100
+ await withAuthTimeout(
101
+ signIn.prepareSecondFactor({
102
+ strategy,
103
+ ...(factor?.phoneNumberId ? { phoneNumberId: factor.phoneNumberId } : {}),
104
+ }),
105
+ "prepare-second-factor",
106
+ ms,
107
+ );
90
108
  } else {
91
- await signIn.prepareSecondFactor({
92
- strategy,
93
- ...(factor?.emailAddressId ? { emailAddressId: factor.emailAddressId } : {}),
94
- });
109
+ await withAuthTimeout(
110
+ signIn.prepareSecondFactor({
111
+ strategy,
112
+ ...(factor?.emailAddressId ? { emailAddressId: factor.emailAddressId } : {}),
113
+ }),
114
+ "prepare-second-factor",
115
+ ms,
116
+ );
95
117
  }
96
118
  return { ok: true };
97
119
  } catch (caught) {
120
+ logTimeout("prepare-second-factor", caught);
98
121
  return { ok: false, error: authErrorMessage(caught, TOTP_MESSAGES.sendFailed) };
99
122
  }
100
123
  }
@@ -111,32 +134,166 @@ export async function submitSecondFactor(args: {
111
134
  setActive: TotpSetActive;
112
135
  code: string;
113
136
  strategy: SecondFactorStrategy;
137
+ /** Time for each Clerk call. Default 20000 ms. */
138
+ timeoutMs?: number;
114
139
  }): Promise<TotpResult> {
140
+ const ms = args.timeoutMs ?? DEFAULT_STAGE_TIMEOUT_MS;
115
141
  try {
116
- const attempt = await args.signIn.attemptSecondFactor({ strategy: args.strategy, code: args.code });
142
+ const attempt = await withAuthTimeout(
143
+ args.signIn.attemptSecondFactor({ strategy: args.strategy, code: args.code }),
144
+ "attempt-second-factor",
145
+ ms,
146
+ );
117
147
  if (attempt.status === "complete" && attempt.createdSessionId) {
118
- await args.setActive({ session: attempt.createdSessionId });
148
+ await withAuthTimeout(args.setActive({ session: attempt.createdSessionId }), "set-active", ms);
119
149
  return { ok: true };
120
150
  }
121
151
  return { ok: false, error: TOTP_MESSAGES.needsAnotherStep };
122
152
  } catch (caught) {
153
+ logTimeout("attempt-second-factor", caught);
123
154
  const fallback = args.strategy === "totp" ? TOTP_MESSAGES.invalidCode : TOTP_MESSAGES.invalidCodeGeneric;
124
155
  return { ok: false, error: authErrorMessage(caught, fallback) };
125
156
  }
126
157
  }
127
158
 
159
+ /** The first-factor code strategies the built-in step handles. */
160
+ export type FirstFactorStrategy = "email_code" | "phone_code";
161
+
162
+ /** Default order: `email_code`, then `phone_code`. */
163
+ export const FIRST_FACTOR_PREFERENCE: readonly FirstFactorStrategy[] = ["email_code", "phone_code"];
164
+
165
+ /** One entry of `signIn.supportedFirstFactors`. */
166
+ export type FirstFactor = {
167
+ strategy: string;
168
+ emailAddressId?: string;
169
+ phoneNumberId?: string;
170
+ safeIdentifier?: string;
171
+ };
172
+
173
+ /** The part of the sign-in resource that a first-factor code needs. Tests pass a mock. */
174
+ export type FirstFactorSignIn = {
175
+ supportedFirstFactors?: FirstFactor[] | null;
176
+ prepareFirstFactor?(params: {
177
+ strategy: FirstFactorStrategy;
178
+ phoneNumberId?: string;
179
+ emailAddressId?: string;
180
+ }): Promise<unknown>;
181
+ attemptFirstFactor(params: { strategy: FirstFactorStrategy; code: string }): Promise<AttemptResult>;
182
+ };
183
+
184
+ /** A first-factor result: the attempt, for the caller to route, or an error. */
185
+ export type FirstFactorResult = { ok: true; attempt: AttemptResult } | { ok: false; error: string };
186
+
187
+ /**
188
+ * The first-factor strategies in `factors` that the built-in step handles, in
189
+ * preference order. A factor counts only when it has the id that
190
+ * `prepareFirstFactor` needs (`emailAddressId` or `phoneNumberId`).
191
+ */
192
+ export function supportedFirstFactorStrategies(
193
+ factors: readonly FirstFactor[] | null | undefined,
194
+ ): FirstFactorStrategy[] {
195
+ const list = factors ?? [];
196
+ return FIRST_FACTOR_PREFERENCE.filter((strategy) =>
197
+ list.some(
198
+ (factor) =>
199
+ factor.strategy === strategy &&
200
+ Boolean(strategy === "email_code" ? factor.emailAddressId : factor.phoneNumberId),
201
+ ),
202
+ );
203
+ }
204
+
205
+ /** The default first-factor strategy: `email_code`, then `phone_code`. `null` when none is supported. */
206
+ export function pickFirstFactorStrategy(
207
+ factors: readonly FirstFactor[] | null | undefined,
208
+ ): FirstFactorStrategy | null {
209
+ return supportedFirstFactorStrategies(factors)[0] ?? null;
210
+ }
211
+
212
+ /**
213
+ * Sends the first-factor code with the `emailAddressId` or `phoneNumberId` of
214
+ * the matching factor. Without that id it returns an error and makes no call.
215
+ * Never throws.
216
+ */
217
+ export async function prepareFirstFactorIfNeeded(args: {
218
+ signIn: Pick<FirstFactorSignIn, "supportedFirstFactors" | "prepareFirstFactor">;
219
+ strategy: FirstFactorStrategy;
220
+ factor?: FirstFactor;
221
+ /** Time for the Clerk call. Default 20000 ms. */
222
+ timeoutMs?: number;
223
+ }): Promise<TotpResult> {
224
+ const { signIn, strategy } = args;
225
+ const ms = args.timeoutMs ?? DEFAULT_STAGE_TIMEOUT_MS;
226
+ if (!signIn.prepareFirstFactor) return { ok: false, error: TOTP_MESSAGES.sendFailed };
227
+ const factor = args.factor ?? signIn.supportedFirstFactors?.find((item) => item.strategy === strategy);
228
+ try {
229
+ if (strategy === "phone_code") {
230
+ if (!factor?.phoneNumberId) return { ok: false, error: TOTP_MESSAGES.sendFailed };
231
+ await withAuthTimeout(
232
+ signIn.prepareFirstFactor({ strategy, phoneNumberId: factor.phoneNumberId }),
233
+ "prepare-first-factor",
234
+ ms,
235
+ );
236
+ } else {
237
+ if (!factor?.emailAddressId) return { ok: false, error: TOTP_MESSAGES.sendFailed };
238
+ await withAuthTimeout(
239
+ signIn.prepareFirstFactor({ strategy, emailAddressId: factor.emailAddressId }),
240
+ "prepare-first-factor",
241
+ ms,
242
+ );
243
+ }
244
+ return { ok: true };
245
+ } catch (caught) {
246
+ logTimeout("prepare-first-factor", caught);
247
+ return { ok: false, error: authErrorMessage(caught, TOTP_MESSAGES.sendFailed) };
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Checks a first-factor code. It does not start the session: the attempt may
253
+ * need a second factor or a new password, so the caller routes it. Never
254
+ * throws: a failure is `{ ok: false, error }`.
255
+ */
256
+ export async function submitFirstFactor(args: {
257
+ signIn: Pick<FirstFactorSignIn, "attemptFirstFactor">;
258
+ code: string;
259
+ strategy: FirstFactorStrategy;
260
+ /** Time for the Clerk call. Default 20000 ms. */
261
+ timeoutMs?: number;
262
+ }): Promise<FirstFactorResult> {
263
+ try {
264
+ const attempt = await withAuthTimeout(
265
+ args.signIn.attemptFirstFactor({ strategy: args.strategy, code: args.code }),
266
+ "attempt-first-factor",
267
+ args.timeoutMs ?? DEFAULT_STAGE_TIMEOUT_MS,
268
+ );
269
+ return { ok: true, attempt };
270
+ } catch (caught) {
271
+ logTimeout("attempt-first-factor", caught);
272
+ return { ok: false, error: authErrorMessage(caught, TOTP_MESSAGES.invalidCodeGeneric) };
273
+ }
274
+ }
275
+
128
276
  /** Checks a TOTP code and starts the session. TOTP needs no `prepareSecondFactor`. Same as `submitSecondFactor` with `strategy: "totp"`. */
129
277
  export function submitTotpSecondFactor(args: {
130
278
  signIn: TotpSignIn;
131
279
  setActive: TotpSetActive;
132
280
  code: string;
281
+ timeoutMs?: number;
133
282
  }): Promise<TotpResult> {
134
- return submitSecondFactor({ signIn: args.signIn, setActive: args.setActive, code: args.code, strategy: "totp" });
283
+ return submitSecondFactor({
284
+ signIn: args.signIn,
285
+ setActive: args.setActive,
286
+ code: args.code,
287
+ strategy: "totp",
288
+ timeoutMs: args.timeoutMs,
289
+ });
135
290
  }
136
291
 
137
292
  export type UseSecondFactorOptions = {
138
293
  /** The strategy `submit` uses until `setStrategy` changes it. Default `"totp"`. */
139
294
  strategy?: SecondFactorStrategy;
295
+ /** Time for each Clerk call, in milliseconds. Default 20000. */
296
+ stageTimeoutMs?: number;
140
297
  };
141
298
 
142
299
  export type UseSecondFactorResult = {
@@ -167,6 +324,7 @@ export function useSecondFactor(options: UseSecondFactorOptions = {}): UseSecond
167
324
  const strategy = chosen ?? options.strategy ?? "totp";
168
325
  const [loading, setLoading] = useState(false);
169
326
  const [error, setError] = useState("");
327
+ const timeoutMs = options.stageTimeoutMs ?? DEFAULT_STAGE_TIMEOUT_MS;
170
328
 
171
329
  const submit = async (code: string): Promise<TotpResult> => {
172
330
  if (loading) return { ok: false, error };
@@ -176,7 +334,7 @@ export function useSecondFactor(options: UseSecondFactorOptions = {}): UseSecond
176
334
  }
177
335
  setLoading(true);
178
336
  setError("");
179
- const result = await submitSecondFactor({ signIn, setActive, code, strategy });
337
+ const result = await submitSecondFactor({ signIn, setActive, code, strategy, timeoutMs });
180
338
  if (!result.ok) setError(result.error);
181
339
  setLoading(false);
182
340
  return result;
@@ -192,7 +350,7 @@ export function useSecondFactor(options: UseSecondFactorOptions = {}): UseSecond
192
350
  }
193
351
  setLoading(true);
194
352
  setError("");
195
- const result = await prepareSecondFactorIfNeeded({ signIn, strategy: prepareStrategy, factor });
353
+ const result = await prepareSecondFactorIfNeeded({ signIn, strategy: prepareStrategy, factor, timeoutMs });
196
354
  if (!result.ok) setError(result.error);
197
355
  setLoading(false);
198
356
  return result;
@@ -0,0 +1,4 @@
1
+ // `@vvedantb/native-ui/password-input`: `PasswordInput` on its own. Reaches no
2
+ // optional peer, not even a loader.
3
+ export { PasswordInput } from "../PasswordInput";
4
+ export type { PasswordInputProps } from "../PasswordInput";
@@ -9,6 +9,8 @@ export { Input } from "../Input";
9
9
  export type { InputProps } from "../Input";
10
10
  export { SearchInput } from "../SearchInput";
11
11
  export type { SearchInputProps } from "../SearchInput";
12
+ export { PasswordInput } from "../PasswordInput";
13
+ export type { PasswordInputProps } from "../PasswordInput";
12
14
  export { Textarea } from "../Textarea";
13
15
  export type { TextareaProps } from "../Textarea";
14
16
  export { Label } from "../Label";
package/src/index.ts CHANGED
@@ -45,6 +45,8 @@ export { Input } from "./Input";
45
45
  export type { InputProps } from "./Input";
46
46
  export { SearchInput } from "./SearchInput";
47
47
  export type { SearchInputProps } from "./SearchInput";
48
+ export { PasswordInput } from "./PasswordInput";
49
+ export type { PasswordInputProps } from "./PasswordInput";
48
50
  export { Textarea } from "./Textarea";
49
51
  export type { TextareaProps } from "./Textarea";
50
52
  export { Label } from "./Label";