@entropy-softworks/ui 2026.8.34 → 2026.8.36
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/CHANGELOG.md +30 -0
- package/lib/components/auth/PasswordStrengthMeter.d.ts +9 -0
- package/lib/components/auth/PasswordStrengthMeter.d.ts.map +1 -1
- package/lib/components/auth/PasswordStrengthMeter.jsx +10 -4
- package/lib/components/auth/PasswordStrengthMeter.jsx.map +1 -1
- package/lib/components/profile/SecurityTab.d.ts +5 -1
- package/lib/components/profile/SecurityTab.d.ts.map +1 -1
- package/lib/components/profile/SecurityTab.jsx.map +1 -1
- package/lib/components/segmented-toggle.d.ts +9 -0
- package/lib/components/segmented-toggle.d.ts.map +1 -1
- package/lib/components/segmented-toggle.jsx +4 -0
- package/lib/components/segmented-toggle.jsx.map +1 -1
- package/lib/components/social-connections.d.ts +22 -5
- package/lib/components/social-connections.d.ts.map +1 -1
- package/lib/components/social-connections.jsx +37 -11
- package/lib/components/social-connections.jsx.map +1 -1
- package/lib/context/AppConfigContext.d.ts +27 -0
- package/lib/context/AppConfigContext.d.ts.map +1 -1
- package/lib/context/AppConfigContext.jsx.map +1 -1
- package/lib/demos/extension-popup.d.ts +212 -0
- package/lib/demos/extension-popup.d.ts.map +1 -0
- package/lib/demos/extension-popup.jsx +1172 -0
- package/lib/demos/extension-popup.jsx.map +1 -0
- package/lib/demos/index.d.ts +11 -0
- package/lib/demos/index.d.ts.map +1 -0
- package/lib/demos/index.js +14 -0
- package/lib/demos/index.js.map +1 -0
- package/lib/screens/auth/AuthScreen.d.ts +75 -1
- package/lib/screens/auth/AuthScreen.d.ts.map +1 -1
- package/lib/screens/auth/AuthScreen.jsx +356 -122
- package/lib/screens/auth/AuthScreen.jsx.map +1 -1
- package/lib/services/auth.service.d.ts +41 -1
- package/lib/services/auth.service.d.ts.map +1 -1
- package/lib/services/auth.service.js +53 -7
- package/lib/services/auth.service.js.map +1 -1
- package/package.json +7 -1
- package/src/components/auth/PasswordStrengthMeter.tsx +25 -3
- package/src/components/profile/SecurityTab.tsx +11 -1
- package/src/components/segmented-toggle.tsx +13 -0
- package/src/components/social-connections.tsx +66 -20
- package/src/context/AppConfigContext.tsx +27 -0
- package/src/demos/extension-popup.tsx +2051 -0
- package/src/demos/index.ts +38 -0
- package/src/screens/auth/AuthScreen.tsx +701 -312
- package/src/services/auth.service.ts +62 -8
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
KeyboardAvoidingView,
|
|
4
|
+
type LayoutChangeEvent,
|
|
5
|
+
Platform,
|
|
6
|
+
ScrollView,
|
|
7
|
+
useWindowDimensions,
|
|
8
|
+
View,
|
|
9
|
+
} from "react-native";
|
|
3
10
|
import { SafeAreaView } from "react-native-safe-area-context";
|
|
4
11
|
import Animated, {
|
|
5
12
|
Easing,
|
|
6
|
-
FadeInDown,
|
|
7
|
-
FadeOutUp,
|
|
8
13
|
LinearTransition,
|
|
9
14
|
runOnJS,
|
|
10
15
|
useAnimatedStyle,
|
|
@@ -40,6 +45,19 @@ import { useAppConfig } from "../../context/AppConfigContext";
|
|
|
40
45
|
import { useNavigation } from "../../context/NavigationContext";
|
|
41
46
|
|
|
42
47
|
const SCROLL_CONTENT_STYLE = { flexGrow: 1 } as const;
|
|
48
|
+
|
|
49
|
+
/** Width of the form column when the aside is showing. */
|
|
50
|
+
/**
|
|
51
|
+
* Width of the form column when the aside is showing.
|
|
52
|
+
*
|
|
53
|
+
* With symmetric padding the card centres in this column, so the gap either
|
|
54
|
+
* side of it is simply `(FORM_PANE_WIDTH - 360) / 2`. 716 puts 178px between
|
|
55
|
+
* the card and the seam, and the same 178px between the card and the window
|
|
56
|
+
* edge — the two gaps match by construction rather than by tuning two numbers
|
|
57
|
+
* against each other.
|
|
58
|
+
*/
|
|
59
|
+
const FORM_PANE_WIDTH = 716;
|
|
60
|
+
|
|
43
61
|
const DEFAULT_POLICY = { enabled: false, enforce: false, block_threshold: 0 };
|
|
44
62
|
|
|
45
63
|
type Mode = "signin" | "signup";
|
|
@@ -63,10 +81,28 @@ export interface AuthFormTransition {
|
|
|
63
81
|
exiting?: AnimatedViewProps["exiting"];
|
|
64
82
|
}
|
|
65
83
|
|
|
66
|
-
|
|
84
|
+
/** `Required` would forbid the `undefined` the defaults deliberately use. */
|
|
85
|
+
type ResolvedTransition = AuthFormTransition;
|
|
86
|
+
|
|
87
|
+
const DEFAULT_TRANSITION: ResolvedTransition = {
|
|
88
|
+
// Height is locked to the tallest mode (see the form-box note below), so the
|
|
89
|
+
// layout transition has nothing left to ease. Kept for the cases where the
|
|
90
|
+
// box legitimately grows past the lock — a pwned-password warning, a wrapped
|
|
91
|
+
// error, a larger system font.
|
|
67
92
|
layout: LinearTransition.duration(280),
|
|
68
|
-
|
|
69
|
-
|
|
93
|
+
// No enter/exit animation. Two earlier attempts were both wrong: the
|
|
94
|
+
// directional FadeInDown/FadeOutUp slid fields in from above, and a plain
|
|
95
|
+
// cross-fade still singled out the only fields that actually mount on a mode
|
|
96
|
+
// switch — Display Name, Confirm Password — so they arrived visibly later
|
|
97
|
+
// than the fields around them, which are already on screen and simply stay
|
|
98
|
+
// put. One field fading in while its neighbours do not reads as a glitch, not
|
|
99
|
+
// as a transition. The card's size is locked, so an instant swap has nothing
|
|
100
|
+
// to hide anyway.
|
|
101
|
+
//
|
|
102
|
+
// Overridable: `transition` on the props still accepts both, for a consumer
|
|
103
|
+
// that wants them.
|
|
104
|
+
entering: undefined,
|
|
105
|
+
exiting: undefined,
|
|
70
106
|
};
|
|
71
107
|
|
|
72
108
|
// Stage morph timing — fade/scale out, swap the stage at zero opacity (so the
|
|
@@ -92,8 +128,23 @@ export interface AuthScreenProps {
|
|
|
92
128
|
/**
|
|
93
129
|
* Connection / server-settings dialog, folded into this single page (no
|
|
94
130
|
* route). When provided, a gear button is shown top-right that toggles it.
|
|
131
|
+
*
|
|
132
|
+
* Mutually exclusive with `onSettingsPress`, which is the better fit where
|
|
133
|
+
* the settings surface deserves a whole screen rather than a dialog.
|
|
95
134
|
*/
|
|
96
135
|
settings?: (props: { visible: boolean; onClose: () => void }) => React.ReactNode;
|
|
136
|
+
/**
|
|
137
|
+
* Show the gear, but navigate instead of opening a dialog.
|
|
138
|
+
*
|
|
139
|
+
* Takes precedence over `settings`. A connection screen that has to explain
|
|
140
|
+
* offline against cloud, official against self-hosted, and show a live health
|
|
141
|
+
* check is too much for a dialog on a phone — it wants a real screen with
|
|
142
|
+
* room and a back button. Web, by contrast, passes NEITHER: a browser reached
|
|
143
|
+
* the app by loading a particular server's URL, so offering to point it
|
|
144
|
+
* somewhere else is answering a question the page's own origin already
|
|
145
|
+
* settled.
|
|
146
|
+
*/
|
|
147
|
+
onSettingsPress?: () => void;
|
|
97
148
|
/** Override the animations. Merged over the defaults. */
|
|
98
149
|
transition?: AuthFormTransition;
|
|
99
150
|
/**
|
|
@@ -102,8 +153,67 @@ export interface AuthScreenProps {
|
|
|
102
153
|
* simple-finance's AssetCarousel, …). Omit for none.
|
|
103
154
|
*/
|
|
104
155
|
decoration?: React.ReactNode;
|
|
156
|
+
/**
|
|
157
|
+
* Full-bleed layer behind everything on the screen — a texture, an ambient
|
|
158
|
+
* gradient. Rendered absolutely-filled and `pointerEvents="none"`, below the
|
|
159
|
+
* morph, so a welcome→form stage change does not fade it.
|
|
160
|
+
*
|
|
161
|
+
* Pairs with `cardBackdrop`: this is the ground, that is the accent hugging
|
|
162
|
+
* the form. A backdrop drawn here sits UNDER the card backdrop, so a card
|
|
163
|
+
* glow still reads over an opaque texture.
|
|
164
|
+
*/
|
|
165
|
+
backdrop?: React.ReactNode;
|
|
166
|
+
/**
|
|
167
|
+
* Persistent footer under the form — copyright, build, which server the
|
|
168
|
+
* client is pointed at. Always visible, unlike the legal line, which is
|
|
169
|
+
* welcome-stage only.
|
|
170
|
+
*/
|
|
171
|
+
footer?: React.ReactNode;
|
|
172
|
+
/**
|
|
173
|
+
* Companion pane shown BESIDE the form on a wide viewport, and not rendered
|
|
174
|
+
* at all on a narrow one.
|
|
175
|
+
*
|
|
176
|
+
* This is the difference between a mobile layout stretched across a monitor
|
|
177
|
+
* and something that looks built for a desktop. A single centred card on a
|
|
178
|
+
* 1440px screen leaves most of the window empty; giving the brand, the
|
|
179
|
+
* product story and the ambient motion their own half turns that emptiness
|
|
180
|
+
* into the composition.
|
|
181
|
+
*
|
|
182
|
+
* Rendered inside the same `KeyboardAvoidingView` as the form but outside its
|
|
183
|
+
* scroll view, so it holds still while the form scrolls.
|
|
184
|
+
*/
|
|
185
|
+
aside?: React.ReactNode;
|
|
186
|
+
/**
|
|
187
|
+
* Viewport width, in px, at which `aside` appears. Defaults to 1024 — below
|
|
188
|
+
* that a two-pane split squeezes the form narrower than its own 360px card.
|
|
189
|
+
*/
|
|
190
|
+
asideBreakpoint?: number;
|
|
191
|
+
/**
|
|
192
|
+
* Ambient layer immediately behind the auth card — a soft glow, a tint.
|
|
193
|
+
* Rendered absolutely, `pointerEvents="none"`, and bled a little past the
|
|
194
|
+
* card's edges so the effect hugs the form rather than washing the screen.
|
|
195
|
+
*
|
|
196
|
+
* Deliberately scoped to the card. A full-bleed version of this reads as a
|
|
197
|
+
* coloured background, which is a much louder decision than an accent behind
|
|
198
|
+
* the one thing the user is looking at.
|
|
199
|
+
*
|
|
200
|
+
* Distinct from `decoration`, which is content INSIDE the welcome view's
|
|
201
|
+
* flow and moves with it.
|
|
202
|
+
*/
|
|
203
|
+
cardBackdrop?: React.ReactNode;
|
|
204
|
+
/** How far the card backdrop bleeds past the card, in px. Defaults to 48. */
|
|
205
|
+
cardBackdropBleed?: number;
|
|
105
206
|
/** Welcome-view tagline/subtitle. Defaults to AppConfig `tagline`. */
|
|
106
207
|
welcomeCtaLabel?: string;
|
|
208
|
+
/**
|
|
209
|
+
* Called the first time the user leaves the welcome stage.
|
|
210
|
+
*
|
|
211
|
+
* The consumer persists this — an introduction repeated on every launch is an
|
|
212
|
+
* obstacle, not an introduction. Kept as a callback rather than handled here
|
|
213
|
+
* because this screen is shared across apps and has no business knowing where
|
|
214
|
+
* any of them keep their preferences.
|
|
215
|
+
*/
|
|
216
|
+
onWelcomeDismissed?: () => void;
|
|
107
217
|
/**
|
|
108
218
|
* Show the Sign Up half of the toggle. Defaults to `true`.
|
|
109
219
|
*
|
|
@@ -165,9 +275,17 @@ export function AuthScreen({
|
|
|
165
275
|
passkeyUnlockTitle = "Unlock your vault",
|
|
166
276
|
passkeyUnlockDescription = "Signed in successfully. This passkey can't unlock the vault on its own — enter your master password to decrypt your data.",
|
|
167
277
|
settings,
|
|
278
|
+
onSettingsPress,
|
|
168
279
|
transition,
|
|
169
280
|
decoration,
|
|
281
|
+
footer,
|
|
282
|
+
aside,
|
|
283
|
+
asideBreakpoint = 1024,
|
|
284
|
+
backdrop,
|
|
285
|
+
cardBackdrop,
|
|
286
|
+
cardBackdropBleed = 48,
|
|
170
287
|
welcomeCtaLabel = "Get Started",
|
|
288
|
+
onWelcomeDismissed,
|
|
171
289
|
signUpEnabled = true,
|
|
172
290
|
requireInviteCode = false,
|
|
173
291
|
onSignUp,
|
|
@@ -181,12 +299,42 @@ export function AuthScreen({
|
|
|
181
299
|
unlockWithPassword,
|
|
182
300
|
} = useAuth();
|
|
183
301
|
const router = useNavigation();
|
|
184
|
-
const {
|
|
185
|
-
|
|
302
|
+
const {
|
|
303
|
+
appName,
|
|
304
|
+
Logo,
|
|
305
|
+
tagline,
|
|
306
|
+
taglineLayout,
|
|
307
|
+
routes,
|
|
308
|
+
termsText,
|
|
309
|
+
privacyText,
|
|
310
|
+
logoIsWordmark,
|
|
311
|
+
logoWidth,
|
|
312
|
+
} = useAppConfig();
|
|
186
313
|
|
|
187
314
|
const { colorScheme } = useColorScheme();
|
|
188
315
|
const isDark = colorScheme === "dark";
|
|
189
|
-
|
|
316
|
+
|
|
317
|
+
// Two-pane only when there is a pane to show AND room to show it.
|
|
318
|
+
const { width: viewportWidth } = useWindowDimensions();
|
|
319
|
+
const showAside = !!aside && viewportWidth >= asideBreakpoint;
|
|
320
|
+
|
|
321
|
+
// A wordmark has no app-name text to sit beside, so the two layouts that
|
|
322
|
+
// need one degrade to the ruled treatment rather than rendering a pipe, or a
|
|
323
|
+
// lockup, against nothing.
|
|
324
|
+
const inlineTagline = taglineLayout === "inline" && !logoIsWordmark;
|
|
325
|
+
const lockupBrand = taglineLayout === "lockup" && !logoIsWordmark;
|
|
326
|
+
const ruledTagline = taglineLayout !== undefined && !inlineTagline && !lockupBrand;
|
|
327
|
+
|
|
328
|
+
// The lockup sizes the mark to the measured height of the name + tagline
|
|
329
|
+
// block, so the three line up however the app scales its type. Measured
|
|
330
|
+
// rather than hard-coded because a fixed px value drifts the moment the type
|
|
331
|
+
// scale or the system font size moves.
|
|
332
|
+
const [brandTextHeight, setBrandTextHeight] = useState(0);
|
|
333
|
+
const onBrandTextLayout = useCallback((e: LayoutChangeEvent) => {
|
|
334
|
+
const h = Math.round(e.nativeEvent.layout.height);
|
|
335
|
+
setBrandTextHeight((prev) => (Math.abs(prev - h) > 1 ? h : prev));
|
|
336
|
+
}, []);
|
|
337
|
+
const baseTransition = useMemo(() => ({ ...DEFAULT_TRANSITION, ...transition }), [transition]);
|
|
190
338
|
|
|
191
339
|
const [mode, setMode] = useState<Mode>("signin");
|
|
192
340
|
const [inviteCode, setInviteCode] = useState("");
|
|
@@ -291,6 +439,49 @@ export function AuthScreen({
|
|
|
291
439
|
setPwnedInfo(null);
|
|
292
440
|
}, []);
|
|
293
441
|
|
|
442
|
+
// ── Stable form box ───────────────────────────────────────────────────
|
|
443
|
+
// Sign In and Sign Up mount different field sets (passkey button +
|
|
444
|
+
// separator + "Forgot password?" against Display Name + strength meter +
|
|
445
|
+
// Confirm Password), so the card grew ~70 px on every toggle. On a short
|
|
446
|
+
// viewport the taller Sign Up layout also pushed the page into a scroll,
|
|
447
|
+
// which moved the logo, the footer and the card's own vertical centre —
|
|
448
|
+
// the whole screen appeared to resize, not only the fields.
|
|
449
|
+
//
|
|
450
|
+
// So lock the body to the TALLEST layout and let the shorter mode leave
|
|
451
|
+
// its slack at the bottom of the card. `probeSignup` renders the Sign Up
|
|
452
|
+
// field set for the single frame the form first mounts, purely to measure
|
|
453
|
+
// it: coming from the welcome stage that frame is already at zero opacity
|
|
454
|
+
// (the morph is mid-fade), and it is explicitly hidden anyway for apps
|
|
455
|
+
// that skip the welcome stage. Without that pre-measure the FIRST toggle
|
|
456
|
+
// of every page load would still be the one that grows the card.
|
|
457
|
+
//
|
|
458
|
+
// `minHeight` (not `height`) on purpose: anything that legitimately needs
|
|
459
|
+
// more room later — a pwned-password warning, a wrapped error, a larger
|
|
460
|
+
// system font — still expands the card instead of being clipped.
|
|
461
|
+
const [formMinHeight, setFormMinHeight] = useState(0);
|
|
462
|
+
const [probed, setProbed] = useState(!signUpEnabled);
|
|
463
|
+
const probeSignup = !probed;
|
|
464
|
+
const showSignup = probeSignup || isSignup;
|
|
465
|
+
|
|
466
|
+
const onFormLayout = useCallback((e: LayoutChangeEvent) => {
|
|
467
|
+
const measured = Math.ceil(e.nativeEvent.layout.height);
|
|
468
|
+
setFormMinHeight((prev) => (measured > prev ? measured : prev));
|
|
469
|
+
setProbed(true);
|
|
470
|
+
}, []);
|
|
471
|
+
|
|
472
|
+
// No enter/exit animation until the measuring pass is done.
|
|
473
|
+
//
|
|
474
|
+
// When `probed` flips, the probe's Sign Up fields unmount — and an exiting
|
|
475
|
+
// animation on that teardown plays VISIBLY, because the wrapper's opacity is
|
|
476
|
+
// back to 1 by the time it runs. That was a Display Name field blinking into
|
|
477
|
+
// view and fading out on first render, for a frame the user was never meant
|
|
478
|
+
// to see at all.
|
|
479
|
+
const t = useMemo(
|
|
480
|
+
() =>
|
|
481
|
+
probed ? baseTransition : { ...baseTransition, entering: undefined, exiting: undefined },
|
|
482
|
+
[baseTransition, probed]
|
|
483
|
+
);
|
|
484
|
+
|
|
294
485
|
const clearField = (field: keyof typeof errors) => {
|
|
295
486
|
if (errors[field]) {
|
|
296
487
|
setErrors((e) => ({ ...e, [field]: undefined }));
|
|
@@ -545,27 +736,67 @@ export function AuthScreen({
|
|
|
545
736
|
<>
|
|
546
737
|
<SafeAreaView className="flex-1 bg-background dark:bg-black">
|
|
547
738
|
<StatusBar style={isDark ? "light" : "dark"} />
|
|
739
|
+
{backdrop ? (
|
|
740
|
+
<View
|
|
741
|
+
pointerEvents="none"
|
|
742
|
+
style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}
|
|
743
|
+
>
|
|
744
|
+
{backdrop}
|
|
745
|
+
</View>
|
|
746
|
+
) : null}
|
|
548
747
|
<KeyboardAvoidingView
|
|
549
748
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
|
550
749
|
className="flex-1"
|
|
750
|
+
// Full-bleed two-pane split. The aside is a real panel with its own
|
|
751
|
+
// ground colour reaching the window edge, so it cannot be centred
|
|
752
|
+
// inside a capped container — a floating panel with black either
|
|
753
|
+
// side would read as a mistake. The form column is fixed and the
|
|
754
|
+
// aside takes everything else, which on a typical desktop leaves it
|
|
755
|
+
// roughly two thirds.
|
|
756
|
+
style={showAside ? { flexDirection: "row" } : undefined}
|
|
551
757
|
>
|
|
758
|
+
{showAside ? (
|
|
759
|
+
// Sits outside the ScrollView on purpose: the pane is scenery and
|
|
760
|
+
// should hold still while the form beside it scrolls.
|
|
761
|
+
<View style={{ flex: 1 }} pointerEvents="box-none">
|
|
762
|
+
{aside}
|
|
763
|
+
</View>
|
|
764
|
+
) : null}
|
|
552
765
|
<ScrollView
|
|
553
766
|
className="flex-1"
|
|
767
|
+
// Wide: a fixed column on the right rather than `flex: 1`, so the
|
|
768
|
+
// form keeps a comfortable measure instead of growing with the
|
|
769
|
+
// window and stranding its 360px card in the middle of a wide lane.
|
|
770
|
+
style={showAside ? { flexGrow: 0, flexBasis: FORM_PANE_WIDTH } : undefined}
|
|
554
771
|
contentContainerStyle={SCROLL_CONTENT_STYLE}
|
|
555
772
|
keyboardShouldPersistTaps="handled"
|
|
556
773
|
showsVerticalScrollIndicator={false}
|
|
557
774
|
>
|
|
558
|
-
<View
|
|
775
|
+
<View
|
|
776
|
+
className="flex-1 justify-between px-6"
|
|
777
|
+
// Asymmetric on the wide layout: more room against the window
|
|
778
|
+
// edge than against the panel seam, so the card sits inside its
|
|
779
|
+
// column rather than pressed to the right of the screen.
|
|
780
|
+
// Symmetric: see FORM_PANE_WIDTH. Asymmetric padding would shift the card
|
|
781
|
+
// off centre and break the matching gaps.
|
|
782
|
+
style={showAside ? { paddingLeft: 64, paddingRight: 64 } : undefined}
|
|
783
|
+
>
|
|
559
784
|
{/* top bar: settings gear only — back lives inside the cards
|
|
560
785
|
(thumb-reachable) so users don't stretch to the corner. */}
|
|
561
786
|
<View className="mt-4 h-10 flex-row items-center justify-between">
|
|
562
787
|
<View />
|
|
563
|
-
{settings ? (
|
|
788
|
+
{settings || onSettingsPress ? (
|
|
564
789
|
<IconButton
|
|
565
790
|
variant="ghost"
|
|
566
791
|
size="sm"
|
|
567
792
|
telemetryId="auth.auth.settings_open"
|
|
568
|
-
onPress={() =>
|
|
793
|
+
onPress={() => {
|
|
794
|
+
if (onSettingsPress) {
|
|
795
|
+
onSettingsPress();
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
setSettingsOpen(true);
|
|
799
|
+
}}
|
|
569
800
|
accessibilityLabel="Connection settings"
|
|
570
801
|
accessibilityRole="button"
|
|
571
802
|
>
|
|
@@ -591,20 +822,71 @@ export function AuthScreen({
|
|
|
591
822
|
morphStyle,
|
|
592
823
|
]}
|
|
593
824
|
>
|
|
594
|
-
|
|
825
|
+
{/* On the wide layout the lockup is constrained to the same
|
|
826
|
+
360px column as the title and the card and left-aligned with
|
|
827
|
+
them, so all three share one left edge. Centred, it sat at
|
|
828
|
+
its own width and read as indented against the form below
|
|
829
|
+
it. Narrow keeps it centred, where there is no column to
|
|
830
|
+
align to. */}
|
|
831
|
+
<View
|
|
832
|
+
className={
|
|
833
|
+
showAside ? "mb-6 w-full max-w-[360px] items-start" : "mb-6 items-center"
|
|
834
|
+
}
|
|
835
|
+
>
|
|
595
836
|
{logoIsWordmark ? (
|
|
596
837
|
// The mark carries the name; height is left to the
|
|
597
838
|
// component so a wide wordmark keeps its aspect ratio, and
|
|
598
839
|
// the ink colour comes from the already-resolved theme.
|
|
599
840
|
<Logo width={logoWidth ?? 160} color={isDark ? "#FFFFFF" : "#000000"} />
|
|
841
|
+
) : lockupBrand ? (
|
|
842
|
+
// Lockup: mark, vertical rule, then the name over the
|
|
843
|
+
// tagline. The mark and the rule are both sized to the
|
|
844
|
+
// MEASURED height of that text block, so all three read as
|
|
845
|
+
// one unit at whatever type scale the app (or the system
|
|
846
|
+
// font size) ends up at. `brandTextHeight || 64` only
|
|
847
|
+
// covers the single frame before the first measurement.
|
|
848
|
+
<View className="flex-row items-center" style={{ gap: 14 }}>
|
|
849
|
+
<Logo width={brandTextHeight || 64} height={brandTextHeight || 64} bleed />
|
|
850
|
+
<View
|
|
851
|
+
style={{ width: 1, height: brandTextHeight || 64 }}
|
|
852
|
+
className="bg-border dark:bg-neutral-700"
|
|
853
|
+
/>
|
|
854
|
+
<View onLayout={onBrandTextLayout}>
|
|
855
|
+
<Heading className="text-4xl">{appName}</Heading>
|
|
856
|
+
{tagline ? (
|
|
857
|
+
<MutedText className="font-mono text-base">{tagline}</MutedText>
|
|
858
|
+
) : null}
|
|
859
|
+
</View>
|
|
860
|
+
</View>
|
|
600
861
|
) : (
|
|
601
862
|
<View className="flex-row items-center">
|
|
602
863
|
<Logo width={72} height={72} style={{ marginRight: -2 }} />
|
|
603
864
|
<Heading className="text-4xl">{appName}</Heading>
|
|
865
|
+
{/* Inline tagline sits on the name's baseline, at body
|
|
866
|
+
size — matching the 4xl heading would make the
|
|
867
|
+
category label compete with the brand. */}
|
|
868
|
+
{tagline && inlineTagline ? (
|
|
869
|
+
<MutedText className="ml-2 font-mono text-base">{`| ${tagline}`}</MutedText>
|
|
870
|
+
) : null}
|
|
604
871
|
</View>
|
|
605
872
|
)}
|
|
606
|
-
{tagline ? (
|
|
607
|
-
<
|
|
873
|
+
{tagline && !inlineTagline && !lockupBrand ? (
|
|
874
|
+
<View className="items-center">
|
|
875
|
+
{/* The rule is only drawn for the "ruled" layout. Width
|
|
876
|
+
tracks the row rather than being a fixed px value, so
|
|
877
|
+
it lines up under the logo + name pairing at whatever
|
|
878
|
+
size the app renders them. */}
|
|
879
|
+
{ruledTagline ? (
|
|
880
|
+
<View className="mt-3 h-px w-full self-stretch bg-border dark:bg-neutral-800" />
|
|
881
|
+
) : null}
|
|
882
|
+
<MutedText
|
|
883
|
+
className={
|
|
884
|
+
ruledTagline ? "mt-3 font-mono text-base" : "mt-1 font-mono text-base"
|
|
885
|
+
}
|
|
886
|
+
>
|
|
887
|
+
{tagline}
|
|
888
|
+
</MutedText>
|
|
889
|
+
</View>
|
|
608
890
|
) : null}
|
|
609
891
|
</View>
|
|
610
892
|
|
|
@@ -617,7 +899,10 @@ export function AuthScreen({
|
|
|
617
899
|
<Button
|
|
618
900
|
className="w-full"
|
|
619
901
|
telemetryId="auth.auth.stage"
|
|
620
|
-
onPress={() =>
|
|
902
|
+
onPress={() => {
|
|
903
|
+
setStage("auth");
|
|
904
|
+
onWelcomeDismissed?.();
|
|
905
|
+
}}
|
|
621
906
|
accessibilityLabel={welcomeCtaLabel}
|
|
622
907
|
accessibilityRole="button"
|
|
623
908
|
testID="welcome-get-started"
|
|
@@ -628,24 +913,191 @@ export function AuthScreen({
|
|
|
628
913
|
</View>
|
|
629
914
|
) : (
|
|
630
915
|
<View style={{ width: "100%", alignItems: "center" }}>
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
916
|
+
{/* Positioned wrapper so the ambient layer can sit behind
|
|
917
|
+
the card and bleed past its edges. Width tracks the
|
|
918
|
+
card's own max so the glow is centred on the form, not
|
|
919
|
+
the viewport. */}
|
|
920
|
+
{/* No mode heading above the card. The Sign In / Sign Up
|
|
921
|
+
toggle at the top of the card already says which mode
|
|
922
|
+
this is, and a title restating it directly beneath the
|
|
923
|
+
brand lockup made three stacked pieces of naming before
|
|
924
|
+
the first field. The forgot-password card keeps its own
|
|
925
|
+
header title, which is doing real work. */}
|
|
926
|
+
<View style={{ width: "100%", maxWidth: 360, position: "relative" }}>
|
|
927
|
+
{cardBackdrop && !showAside ? (
|
|
928
|
+
<View
|
|
929
|
+
pointerEvents="none"
|
|
930
|
+
style={{
|
|
931
|
+
position: "absolute",
|
|
932
|
+
top: -cardBackdropBleed,
|
|
933
|
+
left: -cardBackdropBleed,
|
|
934
|
+
right: -cardBackdropBleed,
|
|
935
|
+
bottom: -cardBackdropBleed,
|
|
936
|
+
}}
|
|
937
|
+
>
|
|
938
|
+
{cardBackdrop}
|
|
939
|
+
</View>
|
|
940
|
+
) : null}
|
|
941
|
+
<Card className="w-full max-w-[360px]">
|
|
942
|
+
{displayStage === "forgot" ? (
|
|
943
|
+
<>
|
|
944
|
+
<CardHeader>
|
|
945
|
+
<CardTitle>Reset password</CardTitle>
|
|
946
|
+
</CardHeader>
|
|
947
|
+
<CardContent className="gap-4">
|
|
948
|
+
{forgotSent ? (
|
|
645
949
|
<MutedText className="text-sm leading-5">
|
|
646
|
-
|
|
647
|
-
password.
|
|
950
|
+
If an account exists for that email, we've sent a link to
|
|
951
|
+
reset your password. Check your inbox.
|
|
648
952
|
</MutedText>
|
|
953
|
+
) : (
|
|
954
|
+
<>
|
|
955
|
+
<MutedText className="text-sm leading-5">
|
|
956
|
+
Enter your email and we'll send you a link to reset your
|
|
957
|
+
password.
|
|
958
|
+
</MutedText>
|
|
959
|
+
<ShakeField shake={emailShake} label="Email">
|
|
960
|
+
<EmailInput
|
|
961
|
+
value={email}
|
|
962
|
+
onChangeText={(text) => {
|
|
963
|
+
setEmail(text);
|
|
964
|
+
clearField("email");
|
|
965
|
+
}}
|
|
966
|
+
autoFocus
|
|
967
|
+
placeholder="you@example.com"
|
|
968
|
+
autoComplete="email"
|
|
969
|
+
invalid={!!errors.email}
|
|
970
|
+
returnKeyType="done"
|
|
971
|
+
onSubmitEditing={handleForgot}
|
|
972
|
+
accessibilityLabel="Email address"
|
|
973
|
+
/>
|
|
974
|
+
</ShakeField>
|
|
975
|
+
<FormError message={errors.email} />
|
|
976
|
+
</>
|
|
977
|
+
)}
|
|
978
|
+
</CardContent>
|
|
979
|
+
<CardFooter className="flex-col items-stretch gap-3 border-t-0 pt-0">
|
|
980
|
+
{forgotSent ? (
|
|
981
|
+
<Button
|
|
982
|
+
className="w-full"
|
|
983
|
+
telemetryId="auth.auth.go_auth"
|
|
984
|
+
onPress={goAuth}
|
|
985
|
+
accessibilityLabel="Back to sign in"
|
|
986
|
+
>
|
|
987
|
+
Back to Sign In
|
|
988
|
+
</Button>
|
|
989
|
+
) : (
|
|
990
|
+
<>
|
|
991
|
+
<Button
|
|
992
|
+
className="w-full"
|
|
993
|
+
telemetryId="auth.auth.forgot"
|
|
994
|
+
onPress={handleForgot}
|
|
995
|
+
isLoading={isLoading}
|
|
996
|
+
accessibilityLabel="Send reset link"
|
|
997
|
+
>
|
|
998
|
+
Send reset link
|
|
999
|
+
</Button>
|
|
1000
|
+
<MutedText
|
|
1001
|
+
className="mt-1 text-center font-mono text-sm"
|
|
1002
|
+
onPress={withClickTracking("auth.auth.go_auth_2", goAuth)}
|
|
1003
|
+
accessibilityRole="button"
|
|
1004
|
+
accessibilityLabel="Back to sign in"
|
|
1005
|
+
>
|
|
1006
|
+
← Back
|
|
1007
|
+
</MutedText>
|
|
1008
|
+
</>
|
|
1009
|
+
)}
|
|
1010
|
+
</CardFooter>
|
|
1011
|
+
</>
|
|
1012
|
+
) : (
|
|
1013
|
+
<>
|
|
1014
|
+
{/* `gap-5` under the toggle, not `gap-3`. The
|
|
1015
|
+
toggle is the mode switch and the title restates
|
|
1016
|
+
the mode, so they were reading as one crowded
|
|
1017
|
+
block; a wider gap separates the control from the
|
|
1018
|
+
heading it drives and takes some of the slack
|
|
1019
|
+
that otherwise all collects above the footer.
|
|
1020
|
+
Applies to both modes — the header is shared. */}
|
|
1021
|
+
<CardHeader className="gap-5 pb-3 pt-5">
|
|
1022
|
+
{signUpEnabled ? (
|
|
1023
|
+
<SegmentedToggle<Mode>
|
|
1024
|
+
value={mode}
|
|
1025
|
+
onChange={switchMode}
|
|
1026
|
+
options={[
|
|
1027
|
+
{ key: "signin", label: "Sign In" },
|
|
1028
|
+
{ key: "signup", label: "Sign Up" },
|
|
1029
|
+
]}
|
|
1030
|
+
/>
|
|
1031
|
+
) : null}
|
|
1032
|
+
{signedUp ? (
|
|
1033
|
+
<MutedText className="text-sm leading-5">{signedUp}</MutedText>
|
|
1034
|
+
) : null}
|
|
1035
|
+
</CardHeader>
|
|
1036
|
+
|
|
1037
|
+
{/* Height is locked to the tallest of the two
|
|
1038
|
+
modes (see the form-box note above), so the
|
|
1039
|
+
layout transition only ever eases fields in and
|
|
1040
|
+
out — the card itself no longer moves. Width is
|
|
1041
|
+
pinned explicitly so the layout animation has
|
|
1042
|
+
nothing to interpolate horizontally either. */}
|
|
1043
|
+
<Animated.View
|
|
1044
|
+
layout={t.layout}
|
|
1045
|
+
onLayout={onFormLayout}
|
|
1046
|
+
pointerEvents={probeSignup ? "none" : "auto"}
|
|
1047
|
+
style={{
|
|
1048
|
+
width: "100%",
|
|
1049
|
+
minHeight: formMinHeight || undefined,
|
|
1050
|
+
opacity: probeSignup ? 0 : 1,
|
|
1051
|
+
}}
|
|
1052
|
+
>
|
|
1053
|
+
{/* `flex: 1` with the default top alignment, so the
|
|
1054
|
+
fields keep their natural rhythm and ALL the
|
|
1055
|
+
locked height's slack collects in one place —
|
|
1056
|
+
between the last field and the footer.
|
|
1057
|
+
That pins the submit button, the OR divider and
|
|
1058
|
+
the social row to the same y in both modes, so
|
|
1059
|
+
they hold still while toggling instead of
|
|
1060
|
+
jumping up and down by the height of two
|
|
1061
|
+
fields.
|
|
1062
|
+
Note this is not the earlier attempt: spreading
|
|
1063
|
+
the slack BETWEEN the rows (`space-between`)
|
|
1064
|
+
looked worse than the gap it removed. Same
|
|
1065
|
+
total space, collected instead of distributed. */}
|
|
1066
|
+
<CardContent className="gap-2.5 pb-2" style={{ flex: 1 }}>
|
|
1067
|
+
{!showSignup && passkeyEnabled ? (
|
|
1068
|
+
<Animated.View
|
|
1069
|
+
entering={t.entering}
|
|
1070
|
+
exiting={t.exiting}
|
|
1071
|
+
layout={t.layout}
|
|
1072
|
+
>
|
|
1073
|
+
<Button
|
|
1074
|
+
variant="outline"
|
|
1075
|
+
className="w-full"
|
|
1076
|
+
telemetryId="auth.auth.passkey_login"
|
|
1077
|
+
onPress={handlePasskeyLogin}
|
|
1078
|
+
isLoading={passkeyStep === "passkey-loading"}
|
|
1079
|
+
accessibilityLabel="Sign in with passkey"
|
|
1080
|
+
accessibilityRole="button"
|
|
1081
|
+
testID="login-passkey-button"
|
|
1082
|
+
>
|
|
1083
|
+
<View className="flex-row items-center justify-center">
|
|
1084
|
+
<Ionicons
|
|
1085
|
+
name="key-outline"
|
|
1086
|
+
size={20}
|
|
1087
|
+
color={isDark ? "#FFFFFF" : "#000000"}
|
|
1088
|
+
className="mr-2"
|
|
1089
|
+
/>
|
|
1090
|
+
<Text className="text-base font-medium">
|
|
1091
|
+
Sign in with passkey
|
|
1092
|
+
</Text>
|
|
1093
|
+
</View>
|
|
1094
|
+
</Button>
|
|
1095
|
+
<View className="mt-3">
|
|
1096
|
+
<SeparatorWithText />
|
|
1097
|
+
</View>
|
|
1098
|
+
</Animated.View>
|
|
1099
|
+
) : null}
|
|
1100
|
+
|
|
649
1101
|
<ShakeField shake={emailShake} label="Email">
|
|
650
1102
|
<EmailInput
|
|
651
1103
|
value={email}
|
|
@@ -657,307 +1109,227 @@ export function AuthScreen({
|
|
|
657
1109
|
placeholder="you@example.com"
|
|
658
1110
|
autoComplete="email"
|
|
659
1111
|
invalid={!!errors.email}
|
|
660
|
-
returnKeyType="done"
|
|
661
|
-
onSubmitEditing={handleForgot}
|
|
662
1112
|
accessibilityLabel="Email address"
|
|
1113
|
+
testID="auth-email-input"
|
|
663
1114
|
/>
|
|
664
1115
|
</ShakeField>
|
|
665
|
-
<FormError message={errors.email} />
|
|
666
|
-
</>
|
|
667
|
-
)}
|
|
668
|
-
</CardContent>
|
|
669
|
-
<CardFooter className="flex-col items-stretch gap-3 border-t-0 pt-0">
|
|
670
|
-
{forgotSent ? (
|
|
671
|
-
<Button
|
|
672
|
-
className="w-full"
|
|
673
|
-
telemetryId="auth.auth.go_auth"
|
|
674
|
-
onPress={goAuth}
|
|
675
|
-
accessibilityLabel="Back to sign in"
|
|
676
|
-
>
|
|
677
|
-
Back to Sign In
|
|
678
|
-
</Button>
|
|
679
|
-
) : (
|
|
680
|
-
<>
|
|
681
|
-
<Button
|
|
682
|
-
className="w-full"
|
|
683
|
-
telemetryId="auth.auth.forgot"
|
|
684
|
-
onPress={handleForgot}
|
|
685
|
-
isLoading={isLoading}
|
|
686
|
-
accessibilityLabel="Send reset link"
|
|
687
|
-
>
|
|
688
|
-
Send reset link
|
|
689
|
-
</Button>
|
|
690
|
-
<MutedText
|
|
691
|
-
className="mt-1 text-center font-mono text-sm"
|
|
692
|
-
onPress={withClickTracking("auth.auth.go_auth_2", goAuth)}
|
|
693
|
-
accessibilityRole="button"
|
|
694
|
-
accessibilityLabel="Back to sign in"
|
|
695
|
-
>
|
|
696
|
-
← Back
|
|
697
|
-
</MutedText>
|
|
698
|
-
</>
|
|
699
|
-
)}
|
|
700
|
-
</CardFooter>
|
|
701
|
-
</>
|
|
702
|
-
) : (
|
|
703
|
-
<>
|
|
704
|
-
<CardHeader className="gap-4">
|
|
705
|
-
{signUpEnabled ? (
|
|
706
|
-
<SegmentedToggle<Mode>
|
|
707
|
-
value={mode}
|
|
708
|
-
onChange={switchMode}
|
|
709
|
-
options={[
|
|
710
|
-
{ key: "signin", label: "Sign In" },
|
|
711
|
-
{ key: "signup", label: "Sign Up" },
|
|
712
|
-
]}
|
|
713
|
-
/>
|
|
714
|
-
) : null}
|
|
715
|
-
{signedUp ? (
|
|
716
|
-
<MutedText className="text-sm leading-5">{signedUp}</MutedText>
|
|
717
|
-
) : null}
|
|
718
|
-
<CardTitle>
|
|
719
|
-
{isSignup ? "Create your account" : `Sign in to ${appName}`}
|
|
720
|
-
</CardTitle>
|
|
721
|
-
</CardHeader>
|
|
722
1116
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
exiting={t.exiting}
|
|
730
|
-
layout={t.layout}
|
|
731
|
-
>
|
|
732
|
-
<Button
|
|
733
|
-
variant="outline"
|
|
734
|
-
className="w-full"
|
|
735
|
-
telemetryId="auth.auth.passkey_login"
|
|
736
|
-
onPress={handlePasskeyLogin}
|
|
737
|
-
isLoading={passkeyStep === "passkey-loading"}
|
|
738
|
-
accessibilityLabel="Sign in with passkey"
|
|
739
|
-
accessibilityRole="button"
|
|
740
|
-
testID="login-passkey-button"
|
|
1117
|
+
{showSignup ? (
|
|
1118
|
+
<Animated.View
|
|
1119
|
+
key="displayName"
|
|
1120
|
+
entering={t.entering}
|
|
1121
|
+
exiting={t.exiting}
|
|
1122
|
+
layout={t.layout}
|
|
741
1123
|
>
|
|
742
|
-
<
|
|
743
|
-
<
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
1124
|
+
<ShakeField shake={displayNameShake} label="Display Name">
|
|
1125
|
+
<Input
|
|
1126
|
+
value={displayName}
|
|
1127
|
+
onChangeText={(text) => {
|
|
1128
|
+
setDisplayName(text);
|
|
1129
|
+
clearField("displayName");
|
|
1130
|
+
}}
|
|
1131
|
+
placeholder="Your display name"
|
|
1132
|
+
leftIcon={
|
|
1133
|
+
<Ionicons
|
|
1134
|
+
name="person-outline"
|
|
1135
|
+
size={20}
|
|
1136
|
+
color={isDark ? "#A3A3A3" : "#737373"}
|
|
1137
|
+
/>
|
|
1138
|
+
}
|
|
1139
|
+
autoCapitalize="words"
|
|
1140
|
+
autoCorrect={false}
|
|
1141
|
+
maxLength={100}
|
|
1142
|
+
invalid={!!errors.displayName}
|
|
1143
|
+
accessibilityLabel="Display name"
|
|
1144
|
+
testID="auth-displayname-input"
|
|
748
1145
|
/>
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
</View>
|
|
753
|
-
</Button>
|
|
754
|
-
<View className="mt-3">
|
|
755
|
-
<SeparatorWithText />
|
|
756
|
-
</View>
|
|
757
|
-
</Animated.View>
|
|
758
|
-
) : null}
|
|
759
|
-
|
|
760
|
-
<ShakeField shake={emailShake} label="Email">
|
|
761
|
-
<EmailInput
|
|
762
|
-
value={email}
|
|
763
|
-
onChangeText={(text) => {
|
|
764
|
-
setEmail(text);
|
|
765
|
-
clearField("email");
|
|
766
|
-
}}
|
|
767
|
-
autoFocus
|
|
768
|
-
placeholder="you@example.com"
|
|
769
|
-
autoComplete="email"
|
|
770
|
-
invalid={!!errors.email}
|
|
771
|
-
accessibilityLabel="Email address"
|
|
772
|
-
testID="auth-email-input"
|
|
773
|
-
/>
|
|
774
|
-
</ShakeField>
|
|
1146
|
+
</ShakeField>
|
|
1147
|
+
</Animated.View>
|
|
1148
|
+
) : null}
|
|
775
1149
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
<Input
|
|
785
|
-
value={displayName}
|
|
786
|
-
onChangeText={(text) => {
|
|
787
|
-
setDisplayName(text);
|
|
788
|
-
clearField("displayName");
|
|
789
|
-
}}
|
|
790
|
-
placeholder="Your display name"
|
|
791
|
-
leftIcon={
|
|
792
|
-
<Ionicons
|
|
793
|
-
name="person-outline"
|
|
794
|
-
size={20}
|
|
795
|
-
color={isDark ? "#A3A3A3" : "#737373"}
|
|
796
|
-
/>
|
|
1150
|
+
<ShakeField shake={passwordShake} label={passwordLabel}>
|
|
1151
|
+
<PasswordInput
|
|
1152
|
+
value={password}
|
|
1153
|
+
onChangeText={(text) => {
|
|
1154
|
+
setPassword(text);
|
|
1155
|
+
clearField("password");
|
|
1156
|
+
if (isSignup) {
|
|
1157
|
+
checkPasswordDebounced(text);
|
|
797
1158
|
}
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
<ShakeField shake={passwordShake} label={passwordLabel}>
|
|
810
|
-
<PasswordInput
|
|
811
|
-
value={password}
|
|
812
|
-
onChangeText={(text) => {
|
|
813
|
-
setPassword(text);
|
|
814
|
-
clearField("password");
|
|
815
|
-
if (isSignup) {
|
|
816
|
-
checkPasswordDebounced(text);
|
|
817
|
-
}
|
|
818
|
-
}}
|
|
819
|
-
placeholder={passwordPlaceholder}
|
|
820
|
-
autoComplete={isSignup ? "password-new" : "password"}
|
|
821
|
-
invalid={!!errors.password}
|
|
822
|
-
returnKeyType={isSignup ? "next" : "done"}
|
|
823
|
-
onSubmitEditing={isSignup ? undefined : handleSubmit}
|
|
824
|
-
accessibilityLabel={passwordLabel}
|
|
825
|
-
testID="auth-password-input"
|
|
826
|
-
/>
|
|
827
|
-
</ShakeField>
|
|
828
|
-
|
|
829
|
-
{isSignup ? (
|
|
830
|
-
<Animated.View
|
|
831
|
-
key="strength"
|
|
832
|
-
entering={t.entering}
|
|
833
|
-
exiting={t.exiting}
|
|
834
|
-
layout={t.layout}
|
|
835
|
-
>
|
|
836
|
-
<PasswordStrengthMeter password={password} />
|
|
837
|
-
<PwnedPasswordWarning pwnedInfo={pwnedInfo} />
|
|
838
|
-
</Animated.View>
|
|
839
|
-
) : null}
|
|
1159
|
+
}}
|
|
1160
|
+
placeholder={passwordPlaceholder}
|
|
1161
|
+
autoComplete={isSignup ? "password-new" : "password"}
|
|
1162
|
+
invalid={!!errors.password}
|
|
1163
|
+
returnKeyType={isSignup ? "next" : "done"}
|
|
1164
|
+
onSubmitEditing={isSignup ? undefined : handleSubmit}
|
|
1165
|
+
accessibilityLabel={passwordLabel}
|
|
1166
|
+
testID="auth-password-input"
|
|
1167
|
+
/>
|
|
1168
|
+
</ShakeField>
|
|
840
1169
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1170
|
+
{/* Rendered only when it has something to show.
|
|
1171
|
+
An empty wrapper still consumes one of
|
|
1172
|
+
CardContent's `gap-3` slots, which put 12px
|
|
1173
|
+
more space above Confirm Password than
|
|
1174
|
+
between any other pair of fields — visible as
|
|
1175
|
+
a stray gap. `probeSignup` keeps it in the
|
|
1176
|
+
measuring pass so the locked height still
|
|
1177
|
+
accounts for the meter. */}
|
|
1178
|
+
{showSignup && (probeSignup || password.length > 0 || pwnedInfo) ? (
|
|
1179
|
+
<Animated.View
|
|
1180
|
+
key="strength"
|
|
1181
|
+
entering={t.entering}
|
|
1182
|
+
exiting={t.exiting}
|
|
1183
|
+
layout={t.layout}
|
|
1184
|
+
>
|
|
1185
|
+
{/* `reserveSpace` only during the probe.
|
|
1186
|
+
Keeping it on permanently held an empty
|
|
1187
|
+
28px block between Password and Confirm
|
|
1188
|
+
Password, which reads as a stray gap.
|
|
1189
|
+
Reserving it just for the measurement
|
|
1190
|
+
means the locked height still accounts
|
|
1191
|
+
for the meter, so it can appear later
|
|
1192
|
+
without the card growing. */}
|
|
1193
|
+
<PasswordStrengthMeter
|
|
1194
|
+
password={password}
|
|
1195
|
+
reserveSpace={probeSignup}
|
|
862
1196
|
/>
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
placeholder="Paste your invite code"
|
|
874
|
-
autoCapitalize="none"
|
|
875
|
-
autoCorrect={false}
|
|
876
|
-
returnKeyType="done"
|
|
877
|
-
onSubmitEditing={handleSubmit}
|
|
878
|
-
invalid={!!errors.inviteCode}
|
|
879
|
-
accessibilityLabel="Invite code"
|
|
880
|
-
testID="auth-invite-code-input"
|
|
881
|
-
/>
|
|
882
|
-
</ShakeField>
|
|
883
|
-
<FormError message={errors.inviteCode} />
|
|
884
|
-
</View>
|
|
885
|
-
) : null}
|
|
886
|
-
</Animated.View>
|
|
887
|
-
) : (
|
|
888
|
-
<Animated.View
|
|
889
|
-
key="forgot"
|
|
890
|
-
entering={t.entering}
|
|
891
|
-
exiting={t.exiting}
|
|
892
|
-
layout={t.layout}
|
|
893
|
-
className="items-end"
|
|
894
|
-
>
|
|
895
|
-
<Text
|
|
896
|
-
className="font-mono text-sm underline"
|
|
897
|
-
onPress={withClickTracking("auth.auth.stage_2", () => {
|
|
898
|
-
setStage("forgot");
|
|
899
|
-
setForgotSent(false);
|
|
900
|
-
setErrors({});
|
|
901
|
-
})}
|
|
902
|
-
accessibilityRole="link"
|
|
903
|
-
accessibilityLabel="Forgot password"
|
|
1197
|
+
<PwnedPasswordWarning pwnedInfo={pwnedInfo} />
|
|
1198
|
+
</Animated.View>
|
|
1199
|
+
) : null}
|
|
1200
|
+
|
|
1201
|
+
{showSignup ? (
|
|
1202
|
+
<Animated.View
|
|
1203
|
+
key="confirm"
|
|
1204
|
+
entering={t.entering}
|
|
1205
|
+
exiting={t.exiting}
|
|
1206
|
+
layout={t.layout}
|
|
904
1207
|
>
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1208
|
+
<ShakeField shake={confirmShake} label="Confirm Password">
|
|
1209
|
+
<PasswordInput
|
|
1210
|
+
value={confirmPassword}
|
|
1211
|
+
onChangeText={(text) => {
|
|
1212
|
+
setConfirmPassword(text);
|
|
1213
|
+
clearField("confirmPassword");
|
|
1214
|
+
}}
|
|
1215
|
+
placeholder="Re-enter your password"
|
|
1216
|
+
autoComplete="password-new"
|
|
1217
|
+
returnKeyType="done"
|
|
1218
|
+
onSubmitEditing={handleSubmit}
|
|
1219
|
+
invalid={!!errors.confirmPassword}
|
|
1220
|
+
accessibilityLabel="Confirm password"
|
|
1221
|
+
testID="auth-confirm-password-input"
|
|
1222
|
+
/>
|
|
1223
|
+
</ShakeField>
|
|
1224
|
+
{requireInviteCode ? (
|
|
1225
|
+
<View className="mt-3">
|
|
1226
|
+
<ShakeField shake={confirmShake} label="Invite code">
|
|
1227
|
+
<Input
|
|
1228
|
+
value={inviteCode}
|
|
1229
|
+
onChangeText={(text) => {
|
|
1230
|
+
setInviteCode(text);
|
|
1231
|
+
clearField("inviteCode");
|
|
1232
|
+
}}
|
|
1233
|
+
placeholder="Paste your invite code"
|
|
1234
|
+
autoCapitalize="none"
|
|
1235
|
+
autoCorrect={false}
|
|
1236
|
+
returnKeyType="done"
|
|
1237
|
+
onSubmitEditing={handleSubmit}
|
|
1238
|
+
invalid={!!errors.inviteCode}
|
|
1239
|
+
accessibilityLabel="Invite code"
|
|
1240
|
+
testID="auth-invite-code-input"
|
|
1241
|
+
/>
|
|
1242
|
+
</ShakeField>
|
|
1243
|
+
<FormError message={errors.inviteCode} />
|
|
1244
|
+
</View>
|
|
1245
|
+
) : null}
|
|
1246
|
+
</Animated.View>
|
|
1247
|
+
) : (
|
|
1248
|
+
<Animated.View
|
|
1249
|
+
key="forgot"
|
|
1250
|
+
entering={t.entering}
|
|
1251
|
+
exiting={t.exiting}
|
|
1252
|
+
layout={t.layout}
|
|
1253
|
+
className="items-end"
|
|
1254
|
+
>
|
|
1255
|
+
<Text
|
|
1256
|
+
className="font-mono text-sm underline"
|
|
1257
|
+
onPress={withClickTracking("auth.auth.stage_2", () => {
|
|
1258
|
+
setStage("forgot");
|
|
1259
|
+
setForgotSent(false);
|
|
1260
|
+
setErrors({});
|
|
1261
|
+
})}
|
|
1262
|
+
accessibilityRole="link"
|
|
1263
|
+
accessibilityLabel="Forgot password"
|
|
1264
|
+
>
|
|
1265
|
+
Forgot password?
|
|
1266
|
+
</Text>
|
|
1267
|
+
</Animated.View>
|
|
1268
|
+
)}
|
|
1269
|
+
</CardContent>
|
|
910
1270
|
|
|
911
|
-
|
|
912
|
-
|
|
1271
|
+
<CardFooter className="flex-col items-stretch gap-2.5 border-t-0 pb-5 pt-0">
|
|
1272
|
+
<FormError message={firstError} />
|
|
913
1273
|
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1274
|
+
<Button
|
|
1275
|
+
className="w-full"
|
|
1276
|
+
telemetryId="auth.auth.submit"
|
|
1277
|
+
onPress={handleSubmit}
|
|
1278
|
+
isLoading={isLoading}
|
|
1279
|
+
accessibilityLabel={isSignup ? "Create account" : "Sign in"}
|
|
1280
|
+
accessibilityRole="button"
|
|
1281
|
+
testID="auth-submit"
|
|
1282
|
+
>
|
|
1283
|
+
{isSignup ? "Create Account" : "Sign In"}
|
|
1284
|
+
</Button>
|
|
925
1285
|
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1286
|
+
{socialAuth ? (
|
|
1287
|
+
<>
|
|
1288
|
+
<SeparatorWithText />
|
|
1289
|
+
{socialAuth}
|
|
1290
|
+
</>
|
|
1291
|
+
) : null}
|
|
932
1292
|
|
|
933
|
-
|
|
1293
|
+
{/* In-card back — thumb-reachable, so users don't
|
|
934
1294
|
have to stretch to the top-left corner arrow.
|
|
935
1295
|
Hidden when there is no welcome stage behind it. */}
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1296
|
+
<Text
|
|
1297
|
+
className={
|
|
1298
|
+
showWelcome
|
|
1299
|
+
? "mt-1 text-center font-mono text-sm text-muted-foreground"
|
|
1300
|
+
: "hidden"
|
|
1301
|
+
}
|
|
1302
|
+
onPress={withClickTracking("auth.auth.stage_3", () =>
|
|
1303
|
+
setStage("welcome")
|
|
1304
|
+
)}
|
|
1305
|
+
accessibilityRole="button"
|
|
1306
|
+
accessibilityLabel="Back"
|
|
1307
|
+
>
|
|
1308
|
+
← Back
|
|
1309
|
+
</Text>
|
|
1310
|
+
</CardFooter>
|
|
1311
|
+
</Animated.View>
|
|
1312
|
+
</>
|
|
1313
|
+
)}
|
|
1314
|
+
</Card>
|
|
1315
|
+
</View>
|
|
955
1316
|
</View>
|
|
956
1317
|
)}
|
|
957
1318
|
</Animated.View>
|
|
958
1319
|
|
|
959
|
-
{/*
|
|
960
|
-
|
|
1320
|
+
{/* Legal footer — Terms/Privacy as popups, no routes.
|
|
1321
|
+
Welcome stage ONLY. Pressing "Get Started" and proceeding IS
|
|
1322
|
+
the acknowledgement, so the line belongs beside that button
|
|
1323
|
+
and nowhere else; repeating it under the sign-in and sign-up
|
|
1324
|
+
forms restates a consent the user already gave and crowds two
|
|
1325
|
+
screens that are busier than the welcome. When there is no
|
|
1326
|
+
welcome stage at all (`showWelcome={false}`) it stays on the
|
|
1327
|
+
form. Note there is deliberately NO "but show it on the form
|
|
1328
|
+
if there is no welcome stage" fallback: `showWelcome` is false
|
|
1329
|
+
on every launch after the first, so such a fallback puts the
|
|
1330
|
+
line back under the form permanently — which is exactly the
|
|
1331
|
+
bug it was written to avoid. */}
|
|
1332
|
+
<View className={onWelcome ? "mb-6 mt-4" : "hidden"}>
|
|
961
1333
|
<MutedText className="text-center text-xs leading-5">
|
|
962
1334
|
By continuing, you agree to our{" "}
|
|
963
1335
|
<Text
|
|
@@ -979,9 +1351,26 @@ export function AuthScreen({
|
|
|
979
1351
|
</Text>
|
|
980
1352
|
</MutedText>
|
|
981
1353
|
</View>
|
|
1354
|
+
|
|
1355
|
+
{footer && !showAside ? <View className="mb-6">{footer}</View> : null}
|
|
982
1356
|
</View>
|
|
983
1357
|
</ScrollView>
|
|
984
1358
|
</KeyboardAvoidingView>
|
|
1359
|
+
{/* Wide only. Screen-level and absolutely placed, so it centres on the
|
|
1360
|
+
PAGE rather than under the form column, and drawn after the panes so
|
|
1361
|
+
it sits above them. `pointerEvents="box-none"` keeps it from
|
|
1362
|
+
intercepting a tap meant for anything behind it.
|
|
1363
|
+
On a narrow layout it is rendered IN FLOW instead (below): the card
|
|
1364
|
+
reaches the bottom of a phone screen, and an absolute footer simply
|
|
1365
|
+
lands on top of the card's own back link. */}
|
|
1366
|
+
{footer && showAside ? (
|
|
1367
|
+
<View
|
|
1368
|
+
pointerEvents="box-none"
|
|
1369
|
+
style={{ position: "absolute", left: 0, right: 0, bottom: 16, alignItems: "center" }}
|
|
1370
|
+
>
|
|
1371
|
+
{footer}
|
|
1372
|
+
</View>
|
|
1373
|
+
) : null}
|
|
985
1374
|
</SafeAreaView>
|
|
986
1375
|
|
|
987
1376
|
{/* Passkey unlock dialog — non-PRF authenticators. */}
|