@vvedantb/native-ui 0.8.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 +145 -7
- package/package.json +35 -8
- package/src/BulkActionBar.tsx +303 -0
- package/src/DividedList.tsx +37 -0
- package/src/DropdownMenu.tsx +439 -0
- package/src/FloatingFAB.tsx +4 -1
- package/src/ai/activity-step-detail.tsx +95 -0
- package/src/ai/choice-question.tsx +297 -0
- package/src/ai/code-block.tsx +50 -7
- package/src/ai/index.ts +10 -0
- package/src/ai/message.tsx +59 -12
- package/src/ai/prompt-input.tsx +79 -28
- package/src/ai/trace.tsx +32 -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/src/configureTheme.ts +60 -0
- package/src/entries/markdown.ts +5 -0
- package/src/entries/primitives.ts +22 -0
- package/src/entries/theme.ts +2 -0
- package/src/examples/ParityGallery.tsx +56 -0
- package/src/floating.ts +2 -4
- package/src/highlight.ts +117 -0
- package/src/index.ts +24 -0
- package/src/markdown/Markdown.tsx +191 -0
- package/src/push.ts +186 -0
package/src/ai/trace.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import { palette } from "../palette";
|
|
|
7
7
|
import { useLargerText } from "../theme";
|
|
8
8
|
import { tokens } from "../tokens";
|
|
9
9
|
import { useScheme } from "../useScheme";
|
|
10
|
-
import { chatTextSize } from "./chat-text";
|
|
10
|
+
import { chatTextSize, formatElapsed } from "./chat-text";
|
|
11
11
|
import {
|
|
12
12
|
Collapsible,
|
|
13
13
|
CollapsibleChevron,
|
|
@@ -232,3 +232,34 @@ export function AgentTraceCollapsibleContent({
|
|
|
232
232
|
</CollapsibleContent>
|
|
233
233
|
);
|
|
234
234
|
}
|
|
235
|
+
|
|
236
|
+
export type AgentTraceFoldProps = Omit<CollapsibleProps, "children"> & {
|
|
237
|
+
/** How long the turn ran, in seconds. The title reads "Worked for 1m 5s". */
|
|
238
|
+
seconds: number;
|
|
239
|
+
/** The settled turn's steps, shown in an `AgentTrace` when open. */
|
|
240
|
+
children: ReactNode;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A settled turn folded behind one line, as the web `ActivityTasks` in its
|
|
245
|
+
* folded (Codex-style) mode: "Worked for …" over a hairline
|
|
246
|
+
* (`border-b border-border pb-1.5`), closed by default, and the steps in an
|
|
247
|
+
* `AgentTrace` 8 below when open.
|
|
248
|
+
*/
|
|
249
|
+
export function AgentTraceFold({ seconds, children, defaultOpen = false, className, ...props }: AgentTraceFoldProps) {
|
|
250
|
+
const scheme = useScheme();
|
|
251
|
+
return (
|
|
252
|
+
<Collapsible className={cn("w-full min-w-0", className)} defaultOpen={defaultOpen} {...props}>
|
|
253
|
+
<View style={{ borderBottomWidth: 1, borderBottomColor: tokens.color.border[scheme], paddingBottom: 6 }}>
|
|
254
|
+
<AgentTraceCollapsibleTrigger>
|
|
255
|
+
<AgentTraceLine>
|
|
256
|
+
<AgentTraceTitle>{`Worked for ${formatElapsed(seconds)}`}</AgentTraceTitle>
|
|
257
|
+
</AgentTraceLine>
|
|
258
|
+
</AgentTraceCollapsibleTrigger>
|
|
259
|
+
</View>
|
|
260
|
+
<CollapsibleContent>
|
|
261
|
+
<AgentTrace className="mt-2">{children}</AgentTrace>
|
|
262
|
+
</CollapsibleContent>
|
|
263
|
+
</Collapsible>
|
|
264
|
+
);
|
|
265
|
+
}
|
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";
|