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