@vvedantb/native-ui 0.9.0 → 0.11.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 +54 -4
- package/package.json +5 -1
- package/src/PasswordInput.tsx +101 -0
- package/src/auth/AuthScreen.tsx +32 -4
- package/src/auth/SecondFactorScreen.tsx +87 -0
- package/src/auth/SignInScreen.tsx +378 -10
- package/src/auth/authErrors.ts +90 -12
- package/src/auth/index.ts +34 -2
- package/src/auth/totp.ts +300 -0
- package/src/entries/password-input.ts +4 -0
- package/src/entries/primitives.ts +2 -0
- package/src/index.ts +2 -0
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` 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
|
|
|
@@ -211,10 +212,53 @@ export default function SignUp() {
|
|
|
211
212
|
- `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
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.
|
|
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.
|
|
217
|
+
- `needs_new_password` shows a new-password step (`signIn.resetPassword`), then starts the session.
|
|
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`.
|
|
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.
|
|
214
220
|
- No forgot-password step. An app that needs one keeps its own screen.
|
|
215
221
|
|
|
216
222
|
`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
223
|
|
|
224
|
+
### Second factor (0.10.0)
|
|
225
|
+
|
|
226
|
+
`SecondFactorScreen` is a one-time code step with no Clerk import. The caller owns the check.
|
|
227
|
+
|
|
228
|
+
| Prop | Default | Use |
|
|
229
|
+
| --- | --- | --- |
|
|
230
|
+
| `title` | required | Screen title, through `AuthScreen` |
|
|
231
|
+
| `description` | none | Text under the title |
|
|
232
|
+
| `codeLength` | `6` | Digits in the code |
|
|
233
|
+
| `onSubmit(code)` | required | Called with the full code |
|
|
234
|
+
| `error` | none | Inline error under the field |
|
|
235
|
+
| `loading` | `false` | Verify spinner; blocks a second submit |
|
|
236
|
+
| `onBack` | none | Shows a ghost Back button |
|
|
237
|
+
| `backLabel` | `"Back"` | Back button text |
|
|
238
|
+
| `header` | none | Above the title |
|
|
239
|
+
| `testIDPrefix` | `"second-factor"` | `-screen`, `-code`, `-error`, `-verify`, `-back` |
|
|
240
|
+
| `footer` | none | Below the buttons, such as a "Use a backup code" switch |
|
|
241
|
+
| `digitsOnly` | `true` | `false` takes any characters (backup codes) |
|
|
242
|
+
| `placeholder` | `"Code"` | Field placeholder when `digitsOnly` is `false` |
|
|
243
|
+
|
|
244
|
+
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 }`.
|
|
245
|
+
|
|
246
|
+
```tsx
|
|
247
|
+
const { submit, loading, error } = useSecondFactor();
|
|
248
|
+
|
|
249
|
+
<SecondFactorScreen
|
|
250
|
+
title="Two-step verification"
|
|
251
|
+
description="Enter the code from your authenticator app."
|
|
252
|
+
onSubmit={async (code) => {
|
|
253
|
+
if ((await submit(code)).ok) router.replace("/");
|
|
254
|
+
}}
|
|
255
|
+
error={error}
|
|
256
|
+
loading={loading}
|
|
257
|
+
/>
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
`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.
|
|
261
|
+
|
|
218
262
|
## Theme
|
|
219
263
|
|
|
220
264
|
Wrap the tree (inside your colour-scheme provider):
|
|
@@ -267,7 +311,7 @@ Limits: a call after the first render does not redraw mounted components. Only t
|
|
|
267
311
|
|
|
268
312
|
| Area | Exports |
|
|
269
313
|
| --- | --- |
|
|
270
|
-
| 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) |
|
|
271
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) |
|
|
272
316
|
| Navigation | `Tabs` (+ `TabsList`, `TabsTrigger`, `TabsContent`), `PillTabs`, `PageIndicator` (0.5.0), `FloatingTabBar`, `FloatingFAB`, `HeaderIconButton`, `ThemeToggle` (0.3.16), `StackPageHeader` (0.5.0) |
|
|
273
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) |
|
|
@@ -318,6 +362,12 @@ const [query, setQuery] = useState("");
|
|
|
318
362
|
<SearchInput value={query} onChange={setQuery} label="Search apps" />
|
|
319
363
|
```
|
|
320
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
|
+
|
|
321
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}`.
|
|
322
372
|
|
|
323
373
|
```tsx
|
|
@@ -512,7 +562,7 @@ The bar is the glass `FloatingSurface` at radius 16 with `px-2.5 py-2`. It holds
|
|
|
512
562
|
|
|
513
563
|
## Phones and accessibility
|
|
514
564
|
|
|
515
|
-
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`.
|
|
516
566
|
|
|
517
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.
|
|
518
568
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/native-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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",
|
|
@@ -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 };
|
package/src/auth/AuthScreen.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
|
@@ -5,10 +5,25 @@ 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 { authErrorMessage, logAuthFailure } from "./authErrors";
|
|
13
|
+
import { SecondFactorScreen } from "./SecondFactorScreen";
|
|
14
|
+
import {
|
|
15
|
+
pickFirstFactorStrategy,
|
|
16
|
+
pickSecondFactorStrategy,
|
|
17
|
+
prepareFirstFactorIfNeeded,
|
|
18
|
+
submitFirstFactor,
|
|
19
|
+
supportedFirstFactorStrategies,
|
|
20
|
+
supportedSecondFactorStrategies,
|
|
21
|
+
useSecondFactor,
|
|
22
|
+
type FirstFactor,
|
|
23
|
+
type FirstFactorStrategy,
|
|
24
|
+
type SecondFactor,
|
|
25
|
+
type SecondFactorStrategy,
|
|
26
|
+
} from "./totp";
|
|
12
27
|
import { GoogleSignInButton, type AuthNavigationOptions } from "./GoogleSignInButton";
|
|
13
28
|
|
|
14
29
|
export type SignInMode = "sign-in" | "sign-up";
|
|
@@ -22,8 +37,66 @@ export const AUTH_MESSAGES = {
|
|
|
22
37
|
verifyFailed: "Invalid code",
|
|
23
38
|
missingCode: "Enter the code from the email",
|
|
24
39
|
needsAnotherStep: "This account needs another step to sign in. Finish it on the web.",
|
|
40
|
+
secondFactorTitle: "Two-step verification",
|
|
41
|
+
secondFactorDescription: "Enter the code from your authenticator app.",
|
|
42
|
+
emailCodeTitle: "Check your email",
|
|
43
|
+
emailCodeDescription: "Enter the code we sent to your email.",
|
|
44
|
+
phoneCodeTitle: "Check your phone",
|
|
45
|
+
phoneCodeDescription: "Enter the code we sent by text message.",
|
|
46
|
+
backupCodeTitle: "Enter a backup code",
|
|
47
|
+
backupCodeDescription: "Use one of the backup codes you saved when you set up two-step verification.",
|
|
48
|
+
clientTrustTitle: "Verify it's you",
|
|
49
|
+
clientTrustEmailDescription: "This device is new. Enter the code we sent to your email.",
|
|
50
|
+
clientTrustPhoneDescription: "This device is new. Enter the code we sent by text message.",
|
|
51
|
+
useBackupCode: "Use a backup code",
|
|
52
|
+
useAuthenticator: "Use authenticator app",
|
|
53
|
+
useEmailCode: "Email me a code",
|
|
54
|
+
usePhoneCode: "Text me a code",
|
|
55
|
+
newPasswordTitle: "Set a new password",
|
|
56
|
+
newPasswordDescription: "Your password must change before you can sign in.",
|
|
57
|
+
newPasswordLabel: "New password",
|
|
58
|
+
confirmPasswordLabel: "Confirm new password",
|
|
59
|
+
passwordMismatch: "The passwords do not match",
|
|
60
|
+
passwordTooShort: "Use a longer password",
|
|
61
|
+
resetPasswordFailed: "Could not set the new password",
|
|
62
|
+
setPassword: "Set password",
|
|
25
63
|
} as const;
|
|
26
64
|
|
|
65
|
+
/**
|
|
66
|
+
* A sign-in that is not complete, as `signIn.create` returns it. Passed to
|
|
67
|
+
* `onIncomplete` so the app can run the step itself.
|
|
68
|
+
*/
|
|
69
|
+
export type IncompleteSignIn = {
|
|
70
|
+
status: string | null;
|
|
71
|
+
createdSessionId: string | null;
|
|
72
|
+
supportedFirstFactors?: Array<{ strategy: string; [key: string]: unknown }> | null;
|
|
73
|
+
supportedSecondFactors?: SecondFactor[] | null;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const METHOD_LABEL: Record<SecondFactorStrategy, string> = {
|
|
77
|
+
totp: AUTH_MESSAGES.useAuthenticator,
|
|
78
|
+
email_code: AUTH_MESSAGES.useEmailCode,
|
|
79
|
+
phone_code: AUTH_MESSAGES.usePhoneCode,
|
|
80
|
+
backup_code: AUTH_MESSAGES.useBackupCode,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
function secondFactorCopy(strategy: SecondFactorStrategy, clientTrust: boolean) {
|
|
84
|
+
if (strategy === "email_code") {
|
|
85
|
+
return clientTrust
|
|
86
|
+
? { title: AUTH_MESSAGES.clientTrustTitle, description: AUTH_MESSAGES.clientTrustEmailDescription }
|
|
87
|
+
: { title: AUTH_MESSAGES.emailCodeTitle, description: AUTH_MESSAGES.emailCodeDescription };
|
|
88
|
+
}
|
|
89
|
+
if (strategy === "phone_code") {
|
|
90
|
+
return clientTrust
|
|
91
|
+
? { title: AUTH_MESSAGES.clientTrustTitle, description: AUTH_MESSAGES.clientTrustPhoneDescription }
|
|
92
|
+
: { title: AUTH_MESSAGES.phoneCodeTitle, description: AUTH_MESSAGES.phoneCodeDescription };
|
|
93
|
+
}
|
|
94
|
+
if (strategy === "backup_code") {
|
|
95
|
+
return { title: AUTH_MESSAGES.backupCodeTitle, description: AUTH_MESSAGES.backupCodeDescription };
|
|
96
|
+
}
|
|
97
|
+
return { title: AUTH_MESSAGES.secondFactorTitle, description: AUTH_MESSAGES.secondFactorDescription };
|
|
98
|
+
}
|
|
99
|
+
|
|
27
100
|
export type SignInScreenProps = AuthNavigationOptions & {
|
|
28
101
|
/** Default `"sign-in"`. `"sign-up"` creates the account and checks an emailed code. */
|
|
29
102
|
mode?: SignInMode;
|
|
@@ -53,6 +126,19 @@ export type SignInScreenProps = AuthNavigationOptions & {
|
|
|
53
126
|
header?: ReactNode;
|
|
54
127
|
/** Below the footer link row, such as an app credit. */
|
|
55
128
|
footer?: ReactNode;
|
|
129
|
+
/**
|
|
130
|
+
* Called with the sign-in when the built-in UI cannot finish it:
|
|
131
|
+
* `needs_identifier`, any unknown status, `needs_first_factor` when no
|
|
132
|
+
* `email_code` or `phone_code` first factor exists (with its id), and
|
|
133
|
+
* `needs_second_factor` or `needs_client_trust` when no supported strategy
|
|
134
|
+
* (totp, backup code, phone code, email code) exists. The built-in screen
|
|
135
|
+
* runs `needs_first_factor` with an email or phone code, and always runs
|
|
136
|
+
* `needs_second_factor`, `needs_client_trust` and `needs_new_password`
|
|
137
|
+
* itself. When called, the web-fallback message is not
|
|
138
|
+
* shown. Without it, the screen shows the web fallback and logs
|
|
139
|
+
* `console.warn`. Ignored in `sign-up`.
|
|
140
|
+
*/
|
|
141
|
+
onIncomplete?: (signIn: IncompleteSignIn) => void;
|
|
56
142
|
};
|
|
57
143
|
|
|
58
144
|
/**
|
|
@@ -80,6 +166,7 @@ export function SignInScreen({
|
|
|
80
166
|
testIDPrefix,
|
|
81
167
|
header,
|
|
82
168
|
footer,
|
|
169
|
+
onIncomplete,
|
|
83
170
|
}: SignInScreenProps) {
|
|
84
171
|
const signInState = useSignIn();
|
|
85
172
|
const signUpState = useSignUp();
|
|
@@ -89,6 +176,18 @@ export function SignInScreen({
|
|
|
89
176
|
const [password, setPassword] = useState("");
|
|
90
177
|
const [code, setCode] = useState("");
|
|
91
178
|
const [pendingVerification, setPendingVerification] = useState(false);
|
|
179
|
+
const [pendingSecondFactor, setPendingSecondFactor] = useState(false);
|
|
180
|
+
const [pendingFirstFactor, setPendingFirstFactor] = useState(false);
|
|
181
|
+
const [firstFactors, setFirstFactors] = useState<FirstFactor[]>([]);
|
|
182
|
+
const [firstStrategy, setFirstStrategy] = useState<FirstFactorStrategy>("email_code");
|
|
183
|
+
const [firstLoading, setFirstLoading] = useState(false);
|
|
184
|
+
const [firstError, setFirstError] = useState("");
|
|
185
|
+
const [factors, setFactors] = useState<SecondFactor[]>([]);
|
|
186
|
+
const [clientTrust, setClientTrust] = useState(false);
|
|
187
|
+
const [pendingNewPassword, setPendingNewPassword] = useState(false);
|
|
188
|
+
const [newPassword, setNewPassword] = useState("");
|
|
189
|
+
const [confirmPassword, setConfirmPassword] = useState("");
|
|
190
|
+
const secondFactor = useSecondFactor();
|
|
92
191
|
const [error, setError] = useState("");
|
|
93
192
|
const [submitting, setSubmitting] = useState(false);
|
|
94
193
|
const signUp = mode === "sign-up";
|
|
@@ -104,27 +203,174 @@ export function SignInScreen({
|
|
|
104
203
|
else router.replace(afterAuthHref, replaceOptions);
|
|
105
204
|
};
|
|
106
205
|
|
|
107
|
-
const
|
|
206
|
+
const warnIncomplete = (attempt: IncompleteSignIn) => {
|
|
207
|
+
console.warn(
|
|
208
|
+
`[native-ui] Sign-in not complete: status=${attempt.status}, ` +
|
|
209
|
+
`secondFactors=${JSON.stringify((attempt.supportedSecondFactors ?? []).map((f) => f.strategy))}, ` +
|
|
210
|
+
`firstFactors=${JSON.stringify((attempt.supportedFirstFactors ?? []).map((f) => f.strategy))}. ` +
|
|
211
|
+
"Pass onIncomplete to handle it in the app.",
|
|
212
|
+
);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// A status the built-in UI cannot finish: the app's `onIncomplete`, or the web fallback.
|
|
216
|
+
const unhandled = (attempt: IncompleteSignIn) => {
|
|
217
|
+
if (onIncomplete) return onIncomplete(attempt);
|
|
218
|
+
warnIncomplete(attempt);
|
|
219
|
+
fail(AUTH_MESSAGES.signInFailed, AUTH_MESSAGES.needsAnotherStep);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const chooseStrategy = async (strategy: SecondFactorStrategy, list: SecondFactor[]) => {
|
|
223
|
+
secondFactor.clearError();
|
|
224
|
+
secondFactor.setStrategy(strategy);
|
|
225
|
+
setPendingSecondFactor(true);
|
|
226
|
+
await secondFactor.prepare(
|
|
227
|
+
strategy,
|
|
228
|
+
list.find((factor) => factor.strategy === strategy),
|
|
229
|
+
);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const startSecondFactor = async (attempt: IncompleteSignIn, trust: boolean) => {
|
|
233
|
+
const list = attempt.supportedSecondFactors ?? [];
|
|
234
|
+
const strategy = pickSecondFactorStrategy(list);
|
|
235
|
+
if (!strategy) return unhandled(attempt);
|
|
236
|
+
setFactors(list);
|
|
237
|
+
setClientTrust(trust);
|
|
238
|
+
await chooseStrategy(strategy, list);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const chooseFirstStrategy = async (strategy: FirstFactorStrategy, list: FirstFactor[]) => {
|
|
242
|
+
const { signIn } = signInState;
|
|
243
|
+
if (!signIn) return;
|
|
244
|
+
setFirstError("");
|
|
245
|
+
setFirstStrategy(strategy);
|
|
246
|
+
setPendingFirstFactor(true);
|
|
247
|
+
setFirstLoading(true);
|
|
248
|
+
const sent = await prepareFirstFactorIfNeeded({
|
|
249
|
+
signIn,
|
|
250
|
+
strategy,
|
|
251
|
+
factor: list.find((factor) => factor.strategy === strategy),
|
|
252
|
+
});
|
|
253
|
+
if (!sent.ok) setFirstError(sent.error);
|
|
254
|
+
setFirstLoading(false);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const startFirstFactor = async (attempt: IncompleteSignIn) => {
|
|
258
|
+
const list = attempt.supportedFirstFactors ?? [];
|
|
259
|
+
const strategy = pickFirstFactorStrategy(list);
|
|
260
|
+
if (!strategy) return unhandled(attempt);
|
|
261
|
+
setFirstFactors(list);
|
|
262
|
+
await chooseFirstStrategy(strategy, list);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// Routes a sign-in attempt. `needs_first_factor` is built in only after `signIn.create`.
|
|
266
|
+
const routeAttempt = async (attempt: IncompleteSignIn, afterCreate: boolean) => {
|
|
267
|
+
const { setActive } = signInState;
|
|
268
|
+
// Clerk's `SignInStatus` omits `needs_client_trust`: widen before comparing.
|
|
269
|
+
const status: string | null = attempt.status;
|
|
270
|
+
if (status === "complete" && attempt.createdSessionId && setActive) {
|
|
271
|
+
await setActive({ session: attempt.createdSessionId });
|
|
272
|
+
done();
|
|
273
|
+
} else if (status === "needs_new_password") {
|
|
274
|
+
setPendingFirstFactor(false);
|
|
275
|
+
setPendingNewPassword(true);
|
|
276
|
+
} else if (status === "needs_second_factor" || status === "needs_client_trust") {
|
|
277
|
+
setPendingFirstFactor(false);
|
|
278
|
+
await startSecondFactor(attempt, status === "needs_client_trust");
|
|
279
|
+
} else if (status === "needs_first_factor" && afterCreate) {
|
|
280
|
+
await startFirstFactor(attempt);
|
|
281
|
+
} else {
|
|
282
|
+
unhandled(attempt);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const onFirstFactor = async (firstCode: string) => {
|
|
287
|
+
const { isLoaded, signIn } = signInState;
|
|
288
|
+
if (!isLoaded || !signIn || firstLoading) return;
|
|
289
|
+
setFirstLoading(true);
|
|
290
|
+
setFirstError("");
|
|
291
|
+
const result = await submitFirstFactor({ signIn, code: firstCode, strategy: firstStrategy });
|
|
292
|
+
if (!result.ok) {
|
|
293
|
+
setFirstError(result.error);
|
|
294
|
+
setFirstLoading(false);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
await routeAttempt(result.attempt, false);
|
|
299
|
+
} catch (caught) {
|
|
300
|
+
setFirstError(authErrorMessage(caught, AUTH_MESSAGES.verifyFailed));
|
|
301
|
+
} finally {
|
|
302
|
+
setFirstLoading(false);
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const onNewPassword = async () => {
|
|
108
307
|
const { isLoaded, signIn, setActive } = signInState;
|
|
109
308
|
if (!isLoaded || !signIn || !setActive || submitting) return;
|
|
110
|
-
if (!
|
|
309
|
+
if (!newPassword) return fail(AUTH_MESSAGES.resetPasswordFailed, AUTH_MESSAGES.missingFields);
|
|
310
|
+
if (minPasswordLength && newPassword.length < minPasswordLength) {
|
|
311
|
+
return fail(AUTH_MESSAGES.resetPasswordFailed, `Use at least ${minPasswordLength} characters for the password`);
|
|
312
|
+
}
|
|
313
|
+
if (newPassword !== confirmPassword) {
|
|
314
|
+
return fail(AUTH_MESSAGES.resetPasswordFailed, AUTH_MESSAGES.passwordMismatch);
|
|
315
|
+
}
|
|
111
316
|
setSubmitting(true);
|
|
112
317
|
setError("");
|
|
113
318
|
try {
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
116
|
-
await setActive({ session:
|
|
319
|
+
const result = await signIn.resetPassword({ password: newPassword });
|
|
320
|
+
if (result.status === "complete" && result.createdSessionId) {
|
|
321
|
+
await setActive({ session: result.createdSessionId });
|
|
117
322
|
done();
|
|
323
|
+
} else if (onIncomplete) {
|
|
324
|
+
onIncomplete(result);
|
|
118
325
|
} else {
|
|
119
|
-
|
|
326
|
+
warnIncomplete(result);
|
|
327
|
+
fail(AUTH_MESSAGES.resetPasswordFailed, AUTH_MESSAGES.needsAnotherStep);
|
|
120
328
|
}
|
|
121
329
|
} catch (caught) {
|
|
122
|
-
fail(AUTH_MESSAGES.
|
|
330
|
+
fail(AUTH_MESSAGES.resetPasswordFailed, authErrorMessage(caught, AUTH_MESSAGES.resetPasswordFailed));
|
|
123
331
|
} finally {
|
|
124
332
|
setSubmitting(false);
|
|
125
333
|
}
|
|
126
334
|
};
|
|
127
335
|
|
|
336
|
+
const onSignIn = async () => {
|
|
337
|
+
const { isLoaded, signIn, setActive } = signInState;
|
|
338
|
+
if (!isLoaded || !signIn || !setActive || submitting) return;
|
|
339
|
+
if (!email || !password) return fail(AUTH_MESSAGES.signInFailed, AUTH_MESSAGES.missingFields);
|
|
340
|
+
setSubmitting(true);
|
|
341
|
+
setError("");
|
|
342
|
+
try {
|
|
343
|
+
const attempt = await signIn.create({ identifier: email, password });
|
|
344
|
+
await routeAttempt(attempt, true);
|
|
345
|
+
} catch (caught) {
|
|
346
|
+
logAuthFailure("create", caught);
|
|
347
|
+
fail(
|
|
348
|
+
AUTH_MESSAGES.signInFailed,
|
|
349
|
+
authErrorMessage(caught, AUTH_MESSAGES.invalidCredentials, { credentials: true }),
|
|
350
|
+
);
|
|
351
|
+
} finally {
|
|
352
|
+
setSubmitting(false);
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const onSecondFactor = async (secondFactorCode: string) => {
|
|
357
|
+
const result = await secondFactor.submit(secondFactorCode);
|
|
358
|
+
if (result.ok) done();
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const onSecondFactorBack = () => {
|
|
362
|
+
// The pending attempt stays; the next `signIn.create` replaces it.
|
|
363
|
+
setPendingSecondFactor(false);
|
|
364
|
+
setPendingFirstFactor(false);
|
|
365
|
+
setFirstError("");
|
|
366
|
+
setPendingNewPassword(false);
|
|
367
|
+
setNewPassword("");
|
|
368
|
+
setConfirmPassword("");
|
|
369
|
+
setPassword("");
|
|
370
|
+
setError("");
|
|
371
|
+
secondFactor.clearError();
|
|
372
|
+
};
|
|
373
|
+
|
|
128
374
|
const onSignUp = async () => {
|
|
129
375
|
const { isLoaded, signUp: attempt } = signUpState;
|
|
130
376
|
if (!isLoaded || !attempt || submitting) return;
|
|
@@ -190,6 +436,129 @@ export function SignInScreen({
|
|
|
190
436
|
);
|
|
191
437
|
const hasFooter = Boolean((linkHref && !pendingVerification) || footer);
|
|
192
438
|
|
|
439
|
+
if (pendingNewPassword) {
|
|
440
|
+
return (
|
|
441
|
+
<AuthScreen
|
|
442
|
+
testID={id("new-password-screen")}
|
|
443
|
+
title={AUTH_MESSAGES.newPasswordTitle}
|
|
444
|
+
subtitle={AUTH_MESSAGES.newPasswordDescription}
|
|
445
|
+
header={header}
|
|
446
|
+
>
|
|
447
|
+
<PasswordInput
|
|
448
|
+
testID={id("new-password")}
|
|
449
|
+
accessibilityLabel={AUTH_MESSAGES.newPasswordLabel}
|
|
450
|
+
autoCapitalize="none"
|
|
451
|
+
autoComplete="new-password"
|
|
452
|
+
textContentType="newPassword"
|
|
453
|
+
autoFocus
|
|
454
|
+
returnKeyType="next"
|
|
455
|
+
value={newPassword}
|
|
456
|
+
placeholder={AUTH_MESSAGES.newPasswordLabel}
|
|
457
|
+
onChangeText={setNewPassword}
|
|
458
|
+
/>
|
|
459
|
+
<PasswordInput
|
|
460
|
+
testID={id("confirm-password")}
|
|
461
|
+
accessibilityLabel={AUTH_MESSAGES.confirmPasswordLabel}
|
|
462
|
+
autoCapitalize="none"
|
|
463
|
+
autoComplete="new-password"
|
|
464
|
+
textContentType="newPassword"
|
|
465
|
+
returnKeyType="go"
|
|
466
|
+
value={confirmPassword}
|
|
467
|
+
placeholder={AUTH_MESSAGES.confirmPasswordLabel}
|
|
468
|
+
onChangeText={setConfirmPassword}
|
|
469
|
+
onSubmitEditing={() => void onNewPassword()}
|
|
470
|
+
/>
|
|
471
|
+
<FieldError testID={id("error")} message={error} />
|
|
472
|
+
<Button testID={id("new-password-submit")} loading={submitting} onPress={() => void onNewPassword()}>
|
|
473
|
+
{AUTH_MESSAGES.setPassword}
|
|
474
|
+
</Button>
|
|
475
|
+
<Button testID={id("new-password-back")} variant="ghost" onPress={onSecondFactorBack}>
|
|
476
|
+
Use a different account
|
|
477
|
+
</Button>
|
|
478
|
+
</AuthScreen>
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (pendingFirstFactor && !pendingSecondFactor && !pendingNewPassword) {
|
|
483
|
+
const others = supportedFirstFactorStrategies(firstFactors).filter((strategy) => strategy !== firstStrategy);
|
|
484
|
+
const switchTo = others[0] ?? null;
|
|
485
|
+
const copy = secondFactorCopy(firstStrategy, false);
|
|
486
|
+
return (
|
|
487
|
+
<SecondFactorScreen
|
|
488
|
+
key={`first-${firstStrategy}`}
|
|
489
|
+
title={copy.title}
|
|
490
|
+
description={copy.description}
|
|
491
|
+
footer={
|
|
492
|
+
switchTo ? (
|
|
493
|
+
<Button
|
|
494
|
+
testID={id("first-factor-switch")}
|
|
495
|
+
variant="link"
|
|
496
|
+
size="sm"
|
|
497
|
+
fullWidth={false}
|
|
498
|
+
disabled={firstLoading}
|
|
499
|
+
onPress={() => void chooseFirstStrategy(switchTo, firstFactors)}
|
|
500
|
+
>
|
|
501
|
+
{METHOD_LABEL[switchTo]}
|
|
502
|
+
</Button>
|
|
503
|
+
) : undefined
|
|
504
|
+
}
|
|
505
|
+
onSubmit={onFirstFactor}
|
|
506
|
+
error={firstError}
|
|
507
|
+
loading={firstLoading}
|
|
508
|
+
onBack={onSecondFactorBack}
|
|
509
|
+
backLabel="Use a different account"
|
|
510
|
+
header={header}
|
|
511
|
+
testIDPrefix={id("first-factor")}
|
|
512
|
+
/>
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (pendingSecondFactor) {
|
|
517
|
+
const active = secondFactor.strategy;
|
|
518
|
+
const available = supportedSecondFactorStrategies(factors);
|
|
519
|
+
const others = available.filter((strategy) => strategy !== active);
|
|
520
|
+
// From a backup code, go back to the default non-backup method. Otherwise offer a backup code.
|
|
521
|
+
const switchTo =
|
|
522
|
+
active === "backup_code"
|
|
523
|
+
? pickSecondFactorStrategy(factors.filter((factor) => factor.strategy !== "backup_code"))
|
|
524
|
+
: others.includes("backup_code")
|
|
525
|
+
? "backup_code"
|
|
526
|
+
: null;
|
|
527
|
+
const copy = secondFactorCopy(active, clientTrust);
|
|
528
|
+
const backup = active === "backup_code";
|
|
529
|
+
return (
|
|
530
|
+
<SecondFactorScreen
|
|
531
|
+
key={active}
|
|
532
|
+
title={copy.title}
|
|
533
|
+
description={copy.description}
|
|
534
|
+
codeLength={backup ? 8 : 6}
|
|
535
|
+
digitsOnly={!backup}
|
|
536
|
+
placeholder="Backup code"
|
|
537
|
+
footer={
|
|
538
|
+
switchTo ? (
|
|
539
|
+
<Button
|
|
540
|
+
testID={id("second-factor-switch")}
|
|
541
|
+
variant="link"
|
|
542
|
+
size="sm"
|
|
543
|
+
fullWidth={false}
|
|
544
|
+
disabled={secondFactor.loading}
|
|
545
|
+
onPress={() => void chooseStrategy(switchTo, factors)}
|
|
546
|
+
>
|
|
547
|
+
{METHOD_LABEL[switchTo]}
|
|
548
|
+
</Button>
|
|
549
|
+
) : undefined
|
|
550
|
+
}
|
|
551
|
+
onSubmit={onSecondFactor}
|
|
552
|
+
error={secondFactor.error}
|
|
553
|
+
loading={secondFactor.loading}
|
|
554
|
+
onBack={onSecondFactorBack}
|
|
555
|
+
backLabel="Use a different account"
|
|
556
|
+
header={header}
|
|
557
|
+
testIDPrefix={id("second-factor")}
|
|
558
|
+
/>
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
193
562
|
if (pendingVerification) {
|
|
194
563
|
return (
|
|
195
564
|
<AuthScreen
|
|
@@ -267,13 +636,12 @@ export function SignInScreen({
|
|
|
267
636
|
placeholder="Email"
|
|
268
637
|
onChangeText={setEmail}
|
|
269
638
|
/>
|
|
270
|
-
<
|
|
639
|
+
<PasswordInput
|
|
271
640
|
testID={id("password")}
|
|
272
641
|
accessibilityLabel="Password"
|
|
273
642
|
autoCapitalize="none"
|
|
274
643
|
autoComplete={signUp ? "new-password" : "password"}
|
|
275
644
|
textContentType={signUp ? "newPassword" : "password"}
|
|
276
|
-
secureTextEntry
|
|
277
645
|
returnKeyType="go"
|
|
278
646
|
value={password}
|
|
279
647
|
placeholder="Password"
|
package/src/auth/authErrors.ts
CHANGED
|
@@ -1,19 +1,97 @@
|
|
|
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/
|
|
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
|
+
/**
|
|
53
|
+
* Logs a failed auth call as one structured `console.error`. It holds the
|
|
54
|
+
* Clerk code, message, longMessage and meta.paramName, plus stage, errorName,
|
|
55
|
+
* status and clerkTraceId when present. Only the error object is read, so a
|
|
56
|
+
* password or one-time code is never logged.
|
|
57
|
+
*/
|
|
58
|
+
export function logAuthFailure(stage: string, error: unknown): void {
|
|
59
|
+
const parsed = parseAuthError(error);
|
|
60
|
+
const { code, message, longMessage, paramName } = firstError(parsed);
|
|
61
|
+
const errorName = parsed?.errorName ?? (error instanceof Error ? error.name : undefined);
|
|
62
|
+
console.error("[sign-in] failure", {
|
|
63
|
+
stage,
|
|
64
|
+
...(errorName ? { errorName } : {}),
|
|
65
|
+
...(parsed?.status !== undefined ? { status: parsed.status } : {}),
|
|
66
|
+
...(parsed?.clerkTraceId ? { clerkTraceId: parsed.clerkTraceId } : {}),
|
|
67
|
+
code,
|
|
68
|
+
message,
|
|
69
|
+
longMessage,
|
|
70
|
+
meta: { paramName },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type AuthErrorOptions = {
|
|
75
|
+
/**
|
|
76
|
+
* Sign-in with email and password. `form_password_incorrect` and
|
|
77
|
+
* `form_identifier_not_found` (only these) return `fallback`.
|
|
78
|
+
*/
|
|
79
|
+
credentials?: boolean;
|
|
80
|
+
};
|
|
5
81
|
|
|
6
82
|
/**
|
|
7
|
-
* A message to show for an auth error.
|
|
8
|
-
* `longMessage` or `message`
|
|
9
|
-
*
|
|
10
|
-
*
|
|
83
|
+
* A message to show for an auth error. For a Clerk error it gives the first
|
|
84
|
+
* `longMessage` or `message`, with `clerk` and `convex` written as "the
|
|
85
|
+
* sign-in service", then `(code: <code>)`. With no text it gives `fallback`,
|
|
86
|
+
* still followed by the code when there is one. With `credentials: true`, the
|
|
87
|
+
* two credential codes give `fallback` alone. Anything that is not a Clerk
|
|
88
|
+
* error gives `fallback`. Never shows a raw exception message.
|
|
11
89
|
*/
|
|
12
|
-
export function authErrorMessage(error: unknown, fallback: string): string {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return
|
|
90
|
+
export function authErrorMessage(error: unknown, fallback: string, options: AuthErrorOptions = {}): string {
|
|
91
|
+
const parsed = parseAuthError(error);
|
|
92
|
+
if (!parsed) return fallback;
|
|
93
|
+
const { code, message, longMessage } = firstError(parsed);
|
|
94
|
+
if (options.credentials && code && (CREDENTIAL_ERROR_CODES as readonly string[]).includes(code)) return fallback;
|
|
95
|
+
const text = (longMessage ?? message)?.replace(PRODUCT_NAMES, "the sign-in service") ?? fallback;
|
|
96
|
+
return code ? `${text} (code: ${code})` : text;
|
|
19
97
|
}
|
package/src/auth/index.ts
CHANGED
|
@@ -6,7 +6,8 @@ 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 { authErrorMessage, logAuthFailure, CREDENTIAL_ERROR_CODES } from "./authErrors";
|
|
10
|
+
export type { AuthErrorOptions } from "./authErrors";
|
|
10
11
|
export {
|
|
11
12
|
GoogleSignInButton,
|
|
12
13
|
useGoogleSignIn,
|
|
@@ -19,4 +20,35 @@ export type {
|
|
|
19
20
|
GoogleSignInButtonProps,
|
|
20
21
|
} from "./GoogleSignInButton";
|
|
21
22
|
export { SignInScreen, SignUpScreen, AUTH_MESSAGES } from "./SignInScreen";
|
|
22
|
-
export type { SignInMode, SignInScreenProps, SignUpScreenProps } from "./SignInScreen";
|
|
23
|
+
export type { IncompleteSignIn, SignInMode, SignInScreenProps, SignUpScreenProps } from "./SignInScreen";
|
|
24
|
+
export { SecondFactorScreen } from "./SecondFactorScreen";
|
|
25
|
+
export type { SecondFactorScreenProps } from "./SecondFactorScreen";
|
|
26
|
+
export {
|
|
27
|
+
submitTotpSecondFactor,
|
|
28
|
+
submitSecondFactor,
|
|
29
|
+
prepareSecondFactorIfNeeded,
|
|
30
|
+
prepareFirstFactorIfNeeded,
|
|
31
|
+
submitFirstFactor,
|
|
32
|
+
pickFirstFactorStrategy,
|
|
33
|
+
supportedFirstFactorStrategies,
|
|
34
|
+
FIRST_FACTOR_PREFERENCE,
|
|
35
|
+
pickSecondFactorStrategy,
|
|
36
|
+
supportedSecondFactorStrategies,
|
|
37
|
+
SECOND_FACTOR_PREFERENCE,
|
|
38
|
+
useSecondFactor,
|
|
39
|
+
TOTP_MESSAGES,
|
|
40
|
+
} from "./totp";
|
|
41
|
+
export type {
|
|
42
|
+
FirstFactor,
|
|
43
|
+
FirstFactorResult,
|
|
44
|
+
FirstFactorSignIn,
|
|
45
|
+
FirstFactorStrategy,
|
|
46
|
+
SecondFactor,
|
|
47
|
+
SecondFactorSignIn,
|
|
48
|
+
SecondFactorStrategy,
|
|
49
|
+
TotpResult,
|
|
50
|
+
TotpSignIn,
|
|
51
|
+
TotpSetActive,
|
|
52
|
+
UseSecondFactorOptions,
|
|
53
|
+
UseSecondFactorResult,
|
|
54
|
+
} from "./totp";
|
package/src/auth/totp.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
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
|
+
/** The first-factor code strategies the built-in step handles. */
|
|
129
|
+
export type FirstFactorStrategy = "email_code" | "phone_code";
|
|
130
|
+
|
|
131
|
+
/** Default order: `email_code`, then `phone_code`. */
|
|
132
|
+
export const FIRST_FACTOR_PREFERENCE: readonly FirstFactorStrategy[] = ["email_code", "phone_code"];
|
|
133
|
+
|
|
134
|
+
/** One entry of `signIn.supportedFirstFactors`. */
|
|
135
|
+
export type FirstFactor = {
|
|
136
|
+
strategy: string;
|
|
137
|
+
emailAddressId?: string;
|
|
138
|
+
phoneNumberId?: string;
|
|
139
|
+
safeIdentifier?: string;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** The part of the sign-in resource that a first-factor code needs. Tests pass a mock. */
|
|
143
|
+
export type FirstFactorSignIn = {
|
|
144
|
+
supportedFirstFactors?: FirstFactor[] | null;
|
|
145
|
+
prepareFirstFactor?(params: {
|
|
146
|
+
strategy: FirstFactorStrategy;
|
|
147
|
+
phoneNumberId?: string;
|
|
148
|
+
emailAddressId?: string;
|
|
149
|
+
}): Promise<unknown>;
|
|
150
|
+
attemptFirstFactor(params: { strategy: FirstFactorStrategy; code: string }): Promise<AttemptResult>;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** A first-factor result: the attempt, for the caller to route, or an error. */
|
|
154
|
+
export type FirstFactorResult = { ok: true; attempt: AttemptResult } | { ok: false; error: string };
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The first-factor strategies in `factors` that the built-in step handles, in
|
|
158
|
+
* preference order. A factor counts only when it has the id that
|
|
159
|
+
* `prepareFirstFactor` needs (`emailAddressId` or `phoneNumberId`).
|
|
160
|
+
*/
|
|
161
|
+
export function supportedFirstFactorStrategies(
|
|
162
|
+
factors: readonly FirstFactor[] | null | undefined,
|
|
163
|
+
): FirstFactorStrategy[] {
|
|
164
|
+
const list = factors ?? [];
|
|
165
|
+
return FIRST_FACTOR_PREFERENCE.filter((strategy) =>
|
|
166
|
+
list.some(
|
|
167
|
+
(factor) =>
|
|
168
|
+
factor.strategy === strategy &&
|
|
169
|
+
Boolean(strategy === "email_code" ? factor.emailAddressId : factor.phoneNumberId),
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The default first-factor strategy: `email_code`, then `phone_code`. `null` when none is supported. */
|
|
175
|
+
export function pickFirstFactorStrategy(
|
|
176
|
+
factors: readonly FirstFactor[] | null | undefined,
|
|
177
|
+
): FirstFactorStrategy | null {
|
|
178
|
+
return supportedFirstFactorStrategies(factors)[0] ?? null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Sends the first-factor code with the `emailAddressId` or `phoneNumberId` of
|
|
183
|
+
* the matching factor. Without that id it returns an error and makes no call.
|
|
184
|
+
* Never throws.
|
|
185
|
+
*/
|
|
186
|
+
export async function prepareFirstFactorIfNeeded(args: {
|
|
187
|
+
signIn: Pick<FirstFactorSignIn, "supportedFirstFactors" | "prepareFirstFactor">;
|
|
188
|
+
strategy: FirstFactorStrategy;
|
|
189
|
+
factor?: FirstFactor;
|
|
190
|
+
}): Promise<TotpResult> {
|
|
191
|
+
const { signIn, strategy } = args;
|
|
192
|
+
if (!signIn.prepareFirstFactor) return { ok: false, error: TOTP_MESSAGES.sendFailed };
|
|
193
|
+
const factor = args.factor ?? signIn.supportedFirstFactors?.find((item) => item.strategy === strategy);
|
|
194
|
+
try {
|
|
195
|
+
if (strategy === "phone_code") {
|
|
196
|
+
if (!factor?.phoneNumberId) return { ok: false, error: TOTP_MESSAGES.sendFailed };
|
|
197
|
+
await signIn.prepareFirstFactor({ strategy, phoneNumberId: factor.phoneNumberId });
|
|
198
|
+
} else {
|
|
199
|
+
if (!factor?.emailAddressId) return { ok: false, error: TOTP_MESSAGES.sendFailed };
|
|
200
|
+
await signIn.prepareFirstFactor({ strategy, emailAddressId: factor.emailAddressId });
|
|
201
|
+
}
|
|
202
|
+
return { ok: true };
|
|
203
|
+
} catch (caught) {
|
|
204
|
+
return { ok: false, error: authErrorMessage(caught, TOTP_MESSAGES.sendFailed) };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Checks a first-factor code. It does not start the session: the attempt may
|
|
210
|
+
* need a second factor or a new password, so the caller routes it. Never
|
|
211
|
+
* throws: a failure is `{ ok: false, error }`.
|
|
212
|
+
*/
|
|
213
|
+
export async function submitFirstFactor(args: {
|
|
214
|
+
signIn: Pick<FirstFactorSignIn, "attemptFirstFactor">;
|
|
215
|
+
code: string;
|
|
216
|
+
strategy: FirstFactorStrategy;
|
|
217
|
+
}): Promise<FirstFactorResult> {
|
|
218
|
+
try {
|
|
219
|
+
const attempt = await args.signIn.attemptFirstFactor({ strategy: args.strategy, code: args.code });
|
|
220
|
+
return { ok: true, attempt };
|
|
221
|
+
} catch (caught) {
|
|
222
|
+
return { ok: false, error: authErrorMessage(caught, TOTP_MESSAGES.invalidCodeGeneric) };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Checks a TOTP code and starts the session. TOTP needs no `prepareSecondFactor`. Same as `submitSecondFactor` with `strategy: "totp"`. */
|
|
227
|
+
export function submitTotpSecondFactor(args: {
|
|
228
|
+
signIn: TotpSignIn;
|
|
229
|
+
setActive: TotpSetActive;
|
|
230
|
+
code: string;
|
|
231
|
+
}): Promise<TotpResult> {
|
|
232
|
+
return submitSecondFactor({ signIn: args.signIn, setActive: args.setActive, code: args.code, strategy: "totp" });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export type UseSecondFactorOptions = {
|
|
236
|
+
/** The strategy `submit` uses until `setStrategy` changes it. Default `"totp"`. */
|
|
237
|
+
strategy?: SecondFactorStrategy;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
export type UseSecondFactorResult = {
|
|
241
|
+
/** Checks the code and starts the session. Never throws. Sets `error` on failure. */
|
|
242
|
+
submit: (code: string) => Promise<TotpResult>;
|
|
243
|
+
/** Sends the code for `phone_code` and `email_code` (no call for the others). Never throws. Sets `error` on failure. */
|
|
244
|
+
prepare: (strategy?: SecondFactorStrategy, factor?: SecondFactor) => Promise<TotpResult>;
|
|
245
|
+
/** The strategy `submit` uses. */
|
|
246
|
+
strategy: SecondFactorStrategy;
|
|
247
|
+
setStrategy: (strategy: SecondFactorStrategy) => void;
|
|
248
|
+
/** True while `submit` or `prepare` runs. */
|
|
249
|
+
loading: boolean;
|
|
250
|
+
/** The last failure message, or `""`. */
|
|
251
|
+
error: string;
|
|
252
|
+
clearError: () => void;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Completes a sign-in that returned `needs_second_factor`. Default strategy
|
|
257
|
+
* `"totp"` (an authenticator-app code); pass `{ strategy }` or call
|
|
258
|
+
* `setStrategy` for the others. Pair with `SecondFactorScreen`:
|
|
259
|
+
* `onSubmit={submit}`, `error={error}`, `loading={loading}`. After
|
|
260
|
+
* `{ ok: true }` the session is active: navigate in the caller.
|
|
261
|
+
*/
|
|
262
|
+
export function useSecondFactor(options: UseSecondFactorOptions = {}): UseSecondFactorResult {
|
|
263
|
+
const { isLoaded, signIn, setActive } = useSignIn();
|
|
264
|
+
const [chosen, setChosen] = useState<SecondFactorStrategy | null>(null);
|
|
265
|
+
const strategy = chosen ?? options.strategy ?? "totp";
|
|
266
|
+
const [loading, setLoading] = useState(false);
|
|
267
|
+
const [error, setError] = useState("");
|
|
268
|
+
|
|
269
|
+
const submit = async (code: string): Promise<TotpResult> => {
|
|
270
|
+
if (loading) return { ok: false, error };
|
|
271
|
+
if (!isLoaded || !signIn || !setActive) {
|
|
272
|
+
setError(TOTP_MESSAGES.notReady);
|
|
273
|
+
return { ok: false, error: TOTP_MESSAGES.notReady };
|
|
274
|
+
}
|
|
275
|
+
setLoading(true);
|
|
276
|
+
setError("");
|
|
277
|
+
const result = await submitSecondFactor({ signIn, setActive, code, strategy });
|
|
278
|
+
if (!result.ok) setError(result.error);
|
|
279
|
+
setLoading(false);
|
|
280
|
+
return result;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const prepare = async (
|
|
284
|
+
prepareStrategy: SecondFactorStrategy = strategy,
|
|
285
|
+
factor?: SecondFactor,
|
|
286
|
+
): Promise<TotpResult> => {
|
|
287
|
+
if (!isLoaded || !signIn) {
|
|
288
|
+
setError(TOTP_MESSAGES.notReady);
|
|
289
|
+
return { ok: false, error: TOTP_MESSAGES.notReady };
|
|
290
|
+
}
|
|
291
|
+
setLoading(true);
|
|
292
|
+
setError("");
|
|
293
|
+
const result = await prepareSecondFactorIfNeeded({ signIn, strategy: prepareStrategy, factor });
|
|
294
|
+
if (!result.ok) setError(result.error);
|
|
295
|
+
setLoading(false);
|
|
296
|
+
return result;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
return { submit, prepare, strategy, setStrategy: setChosen, loading, error, clearError: () => setError("") };
|
|
300
|
+
}
|
|
@@ -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";
|