@entropy-softworks/ui 2026.8.35 → 2026.8.37
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/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/index.d.ts +1 -0
- package/lib/components/index.d.ts.map +1 -1
- package/lib/components/index.js +1 -0
- package/lib/components/index.js.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/components/update-overlay.d.ts +41 -0
- package/lib/components/update-overlay.d.ts.map +1 -0
- package/lib/components/update-overlay.jsx +132 -0
- package/lib/components/update-overlay.jsx.map +1 -0
- 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 +5 -1
- package/lib/services/auth.service.d.ts.map +1 -1
- package/lib/services/auth.service.js +5 -1
- 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/index.ts +1 -0
- 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/components/update-overlay.tsx +206 -0
- 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 +13 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native";
|
|
2
|
+
import { KeyboardAvoidingView, Platform, ScrollView, useWindowDimensions, View, } from "react-native";
|
|
3
3
|
import { SafeAreaView } from "react-native-safe-area-context";
|
|
4
|
-
import Animated, { Easing,
|
|
4
|
+
import Animated, { Easing, LinearTransition, runOnJS, useAnimatedStyle, useSharedValue, withTiming, } from "react-native-reanimated";
|
|
5
5
|
import { withClickTracking } from "../../services/telemetry";
|
|
6
6
|
import { StatusBar } from "expo-status-bar";
|
|
7
7
|
import { Ionicons } from "@expo/vector-icons";
|
|
@@ -29,11 +29,37 @@ import { getCachedPolicy } from "../../services/hibp-cache";
|
|
|
29
29
|
import { useAppConfig } from "../../context/AppConfigContext";
|
|
30
30
|
import { useNavigation } from "../../context/NavigationContext";
|
|
31
31
|
const SCROLL_CONTENT_STYLE = { flexGrow: 1 };
|
|
32
|
+
/** Width of the form column when the aside is showing. */
|
|
33
|
+
/**
|
|
34
|
+
* Width of the form column when the aside is showing.
|
|
35
|
+
*
|
|
36
|
+
* With symmetric padding the card centres in this column, so the gap either
|
|
37
|
+
* side of it is simply `(FORM_PANE_WIDTH - 360) / 2`. 716 puts 178px between
|
|
38
|
+
* the card and the seam, and the same 178px between the card and the window
|
|
39
|
+
* edge — the two gaps match by construction rather than by tuning two numbers
|
|
40
|
+
* against each other.
|
|
41
|
+
*/
|
|
42
|
+
const FORM_PANE_WIDTH = 716;
|
|
32
43
|
const DEFAULT_POLICY = { enabled: false, enforce: false, block_threshold: 0 };
|
|
33
44
|
const DEFAULT_TRANSITION = {
|
|
45
|
+
// Height is locked to the tallest mode (see the form-box note below), so the
|
|
46
|
+
// layout transition has nothing left to ease. Kept for the cases where the
|
|
47
|
+
// box legitimately grows past the lock — a pwned-password warning, a wrapped
|
|
48
|
+
// error, a larger system font.
|
|
34
49
|
layout: LinearTransition.duration(280),
|
|
35
|
-
|
|
36
|
-
|
|
50
|
+
// No enter/exit animation. Two earlier attempts were both wrong: the
|
|
51
|
+
// directional FadeInDown/FadeOutUp slid fields in from above, and a plain
|
|
52
|
+
// cross-fade still singled out the only fields that actually mount on a mode
|
|
53
|
+
// switch — Display Name, Confirm Password — so they arrived visibly later
|
|
54
|
+
// than the fields around them, which are already on screen and simply stay
|
|
55
|
+
// put. One field fading in while its neighbours do not reads as a glitch, not
|
|
56
|
+
// as a transition. The card's size is locked, so an instant swap has nothing
|
|
57
|
+
// to hide anyway.
|
|
58
|
+
//
|
|
59
|
+
// Overridable: `transition` on the props still accepts both, for a consumer
|
|
60
|
+
// that wants them.
|
|
61
|
+
entering: undefined,
|
|
62
|
+
exiting: undefined,
|
|
37
63
|
};
|
|
38
64
|
// Stage morph timing — fade/scale out, swap the stage at zero opacity (so the
|
|
39
65
|
// centered→centered swap is invisible, no overlap/teleport), then fade/scale in.
|
|
@@ -46,13 +72,31 @@ const STAGE_IN_MS = 360;
|
|
|
46
72
|
* are themed popups and a connection-settings dialog can be folded in. Preserves
|
|
47
73
|
* the passkey ceremony + non-PRF unlock and the HIBP/strength checks.
|
|
48
74
|
*/
|
|
49
|
-
export function AuthScreen({ passwordLabel = "Password", passwordPlaceholder = "Enter your password", socialAuth, passkeyEnabled = false, passkeyUnlockPlaceholder = "Enter your master password", passkeyUnlockTitle = "Unlock your vault", passkeyUnlockDescription = "Signed in successfully. This passkey can't unlock the vault on its own — enter your master password to decrypt your data.", settings, transition, decoration, welcomeCtaLabel = "Get Started", signUpEnabled = true, requireInviteCode = false, onSignUp, signUpSuccessMessage = "Account created. Check your email to verify the address, then sign in.", showWelcome = true, } = {}) {
|
|
75
|
+
export function AuthScreen({ passwordLabel = "Password", passwordPlaceholder = "Enter your password", socialAuth, passkeyEnabled = false, passkeyUnlockPlaceholder = "Enter your master password", passkeyUnlockTitle = "Unlock your vault", passkeyUnlockDescription = "Signed in successfully. This passkey can't unlock the vault on its own — enter your master password to decrypt your data.", settings, onSettingsPress, transition, decoration, footer, aside, asideBreakpoint = 1024, backdrop, cardBackdrop, cardBackdropBleed = 48, welcomeCtaLabel = "Get Started", onWelcomeDismissed, signUpEnabled = true, requireInviteCode = false, onSignUp, signUpSuccessMessage = "Account created. Check your email to verify the address, then sign in.", showWelcome = true, } = {}) {
|
|
50
76
|
const { login: authLogin, register: authRegister, loginWithPasskey, unlockWithPassword, } = useAuth();
|
|
51
77
|
const router = useNavigation();
|
|
52
|
-
const { appName, Logo, tagline, routes, termsText, privacyText, logoIsWordmark, logoWidth } = useAppConfig();
|
|
78
|
+
const { appName, Logo, tagline, taglineLayout, routes, termsText, privacyText, logoIsWordmark, logoWidth, } = useAppConfig();
|
|
53
79
|
const { colorScheme } = useColorScheme();
|
|
54
80
|
const isDark = colorScheme === "dark";
|
|
55
|
-
|
|
81
|
+
// Two-pane only when there is a pane to show AND room to show it.
|
|
82
|
+
const { width: viewportWidth } = useWindowDimensions();
|
|
83
|
+
const showAside = !!aside && viewportWidth >= asideBreakpoint;
|
|
84
|
+
// A wordmark has no app-name text to sit beside, so the two layouts that
|
|
85
|
+
// need one degrade to the ruled treatment rather than rendering a pipe, or a
|
|
86
|
+
// lockup, against nothing.
|
|
87
|
+
const inlineTagline = taglineLayout === "inline" && !logoIsWordmark;
|
|
88
|
+
const lockupBrand = taglineLayout === "lockup" && !logoIsWordmark;
|
|
89
|
+
const ruledTagline = taglineLayout !== undefined && !inlineTagline && !lockupBrand;
|
|
90
|
+
// The lockup sizes the mark to the measured height of the name + tagline
|
|
91
|
+
// block, so the three line up however the app scales its type. Measured
|
|
92
|
+
// rather than hard-coded because a fixed px value drifts the moment the type
|
|
93
|
+
// scale or the system font size moves.
|
|
94
|
+
const [brandTextHeight, setBrandTextHeight] = useState(0);
|
|
95
|
+
const onBrandTextLayout = useCallback((e) => {
|
|
96
|
+
const h = Math.round(e.nativeEvent.layout.height);
|
|
97
|
+
setBrandTextHeight((prev) => (Math.abs(prev - h) > 1 ? h : prev));
|
|
98
|
+
}, []);
|
|
99
|
+
const baseTransition = useMemo(() => ({ ...DEFAULT_TRANSITION, ...transition }), [transition]);
|
|
56
100
|
const [mode, setMode] = useState("signin");
|
|
57
101
|
const [inviteCode, setInviteCode] = useState("");
|
|
58
102
|
const [signedUp, setSignedUp] = useState(null);
|
|
@@ -124,6 +168,42 @@ export function AuthScreen({ passwordLabel = "Password", passwordPlaceholder = "
|
|
|
124
168
|
setErrors({});
|
|
125
169
|
setPwnedInfo(null);
|
|
126
170
|
}, []);
|
|
171
|
+
// ── Stable form box ───────────────────────────────────────────────────
|
|
172
|
+
// Sign In and Sign Up mount different field sets (passkey button +
|
|
173
|
+
// separator + "Forgot password?" against Display Name + strength meter +
|
|
174
|
+
// Confirm Password), so the card grew ~70 px on every toggle. On a short
|
|
175
|
+
// viewport the taller Sign Up layout also pushed the page into a scroll,
|
|
176
|
+
// which moved the logo, the footer and the card's own vertical centre —
|
|
177
|
+
// the whole screen appeared to resize, not only the fields.
|
|
178
|
+
//
|
|
179
|
+
// So lock the body to the TALLEST layout and let the shorter mode leave
|
|
180
|
+
// its slack at the bottom of the card. `probeSignup` renders the Sign Up
|
|
181
|
+
// field set for the single frame the form first mounts, purely to measure
|
|
182
|
+
// it: coming from the welcome stage that frame is already at zero opacity
|
|
183
|
+
// (the morph is mid-fade), and it is explicitly hidden anyway for apps
|
|
184
|
+
// that skip the welcome stage. Without that pre-measure the FIRST toggle
|
|
185
|
+
// of every page load would still be the one that grows the card.
|
|
186
|
+
//
|
|
187
|
+
// `minHeight` (not `height`) on purpose: anything that legitimately needs
|
|
188
|
+
// more room later — a pwned-password warning, a wrapped error, a larger
|
|
189
|
+
// system font — still expands the card instead of being clipped.
|
|
190
|
+
const [formMinHeight, setFormMinHeight] = useState(0);
|
|
191
|
+
const [probed, setProbed] = useState(!signUpEnabled);
|
|
192
|
+
const probeSignup = !probed;
|
|
193
|
+
const showSignup = probeSignup || isSignup;
|
|
194
|
+
const onFormLayout = useCallback((e) => {
|
|
195
|
+
const measured = Math.ceil(e.nativeEvent.layout.height);
|
|
196
|
+
setFormMinHeight((prev) => (measured > prev ? measured : prev));
|
|
197
|
+
setProbed(true);
|
|
198
|
+
}, []);
|
|
199
|
+
// No enter/exit animation until the measuring pass is done.
|
|
200
|
+
//
|
|
201
|
+
// When `probed` flips, the probe's Sign Up fields unmount — and an exiting
|
|
202
|
+
// animation on that teardown plays VISIBLY, because the wrapper's opacity is
|
|
203
|
+
// back to 1 by the time it runs. That was a Display Name field blinking into
|
|
204
|
+
// view and fading out on first render, for a frame the user was never meant
|
|
205
|
+
// to see at all.
|
|
206
|
+
const t = useMemo(() => probed ? baseTransition : { ...baseTransition, entering: undefined, exiting: undefined }, [baseTransition, probed]);
|
|
127
207
|
const clearField = (field) => {
|
|
128
208
|
if (errors[field]) {
|
|
129
209
|
setErrors((e) => ({ ...e, [field]: undefined }));
|
|
@@ -382,14 +462,46 @@ export function AuthScreen({ passwordLabel = "Password", passwordPlaceholder = "
|
|
|
382
462
|
return (<>
|
|
383
463
|
<SafeAreaView className="flex-1 bg-background dark:bg-black">
|
|
384
464
|
<StatusBar style={isDark ? "light" : "dark"}/>
|
|
385
|
-
<
|
|
386
|
-
|
|
387
|
-
|
|
465
|
+
{backdrop ? (<View pointerEvents="none" style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}>
|
|
466
|
+
{backdrop}
|
|
467
|
+
</View>) : null}
|
|
468
|
+
<KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : "height"} className="flex-1"
|
|
469
|
+
// Full-bleed two-pane split. The aside is a real panel with its own
|
|
470
|
+
// ground colour reaching the window edge, so it cannot be centred
|
|
471
|
+
// inside a capped container — a floating panel with black either
|
|
472
|
+
// side would read as a mistake. The form column is fixed and the
|
|
473
|
+
// aside takes everything else, which on a typical desktop leaves it
|
|
474
|
+
// roughly two thirds.
|
|
475
|
+
style={showAside ? { flexDirection: "row" } : undefined}>
|
|
476
|
+
{showAside ? (
|
|
477
|
+
// Sits outside the ScrollView on purpose: the pane is scenery and
|
|
478
|
+
// should hold still while the form beside it scrolls.
|
|
479
|
+
<View style={{ flex: 1 }} pointerEvents="box-none">
|
|
480
|
+
{aside}
|
|
481
|
+
</View>) : null}
|
|
482
|
+
<ScrollView className="flex-1"
|
|
483
|
+
// Wide: a fixed column on the right rather than `flex: 1`, so the
|
|
484
|
+
// form keeps a comfortable measure instead of growing with the
|
|
485
|
+
// window and stranding its 360px card in the middle of a wide lane.
|
|
486
|
+
style={showAside ? { flexGrow: 0, flexBasis: FORM_PANE_WIDTH } : undefined} contentContainerStyle={SCROLL_CONTENT_STYLE} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}>
|
|
487
|
+
<View className="flex-1 justify-between px-6"
|
|
488
|
+
// Asymmetric on the wide layout: more room against the window
|
|
489
|
+
// edge than against the panel seam, so the card sits inside its
|
|
490
|
+
// column rather than pressed to the right of the screen.
|
|
491
|
+
// Symmetric: see FORM_PANE_WIDTH. Asymmetric padding would shift the card
|
|
492
|
+
// off centre and break the matching gaps.
|
|
493
|
+
style={showAside ? { paddingLeft: 64, paddingRight: 64 } : undefined}>
|
|
388
494
|
{/* top bar: settings gear only — back lives inside the cards
|
|
389
495
|
(thumb-reachable) so users don't stretch to the corner. */}
|
|
390
496
|
<View className="mt-4 h-10 flex-row items-center justify-between">
|
|
391
497
|
<View />
|
|
392
|
-
{settings ? (<IconButton variant="ghost" size="sm" telemetryId="auth.auth.settings_open" onPress={() =>
|
|
498
|
+
{settings || onSettingsPress ? (<IconButton variant="ghost" size="sm" telemetryId="auth.auth.settings_open" onPress={() => {
|
|
499
|
+
if (onSettingsPress) {
|
|
500
|
+
onSettingsPress();
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
setSettingsOpen(true);
|
|
504
|
+
}} accessibilityLabel="Connection settings" accessibilityRole="button">
|
|
393
505
|
<Ionicons name="settings-outline" size={22} color={isDark ? "#A3A3A3" : "#737373"}/>
|
|
394
506
|
</IconButton>) : (<View />)}
|
|
395
507
|
</View>
|
|
@@ -403,177 +515,287 @@ export function AuthScreen({ passwordLabel = "Password", passwordPlaceholder = "
|
|
|
403
515
|
{ flex: 1, width: "100%", alignItems: "center", justifyContent: "center" },
|
|
404
516
|
morphStyle,
|
|
405
517
|
]}>
|
|
406
|
-
|
|
518
|
+
{/* On the wide layout the lockup is constrained to the same
|
|
519
|
+
360px column as the title and the card and left-aligned with
|
|
520
|
+
them, so all three share one left edge. Centred, it sat at
|
|
521
|
+
its own width and read as indented against the form below
|
|
522
|
+
it. Narrow keeps it centred, where there is no column to
|
|
523
|
+
align to. */}
|
|
524
|
+
<View className={showAside ? "mb-6 w-full max-w-[360px] items-start" : "mb-6 items-center"}>
|
|
407
525
|
{logoIsWordmark ? (
|
|
408
526
|
// The mark carries the name; height is left to the
|
|
409
527
|
// component so a wide wordmark keeps its aspect ratio, and
|
|
410
528
|
// the ink colour comes from the already-resolved theme.
|
|
411
|
-
<Logo width={logoWidth ?? 160} color={isDark ? "#FFFFFF" : "#000000"}/>) :
|
|
529
|
+
<Logo width={logoWidth ?? 160} color={isDark ? "#FFFFFF" : "#000000"}/>) : lockupBrand ? (
|
|
530
|
+
// Lockup: mark, vertical rule, then the name over the
|
|
531
|
+
// tagline. The mark and the rule are both sized to the
|
|
532
|
+
// MEASURED height of that text block, so all three read as
|
|
533
|
+
// one unit at whatever type scale the app (or the system
|
|
534
|
+
// font size) ends up at. `brandTextHeight || 64` only
|
|
535
|
+
// covers the single frame before the first measurement.
|
|
536
|
+
<View className="flex-row items-center" style={{ gap: 14 }}>
|
|
537
|
+
<Logo width={brandTextHeight || 64} height={brandTextHeight || 64} bleed/>
|
|
538
|
+
<View style={{ width: 1, height: brandTextHeight || 64 }} className="bg-border dark:bg-neutral-700"/>
|
|
539
|
+
<View onLayout={onBrandTextLayout}>
|
|
540
|
+
<Heading className="text-4xl">{appName}</Heading>
|
|
541
|
+
{tagline ? (<MutedText className="font-mono text-base">{tagline}</MutedText>) : null}
|
|
542
|
+
</View>
|
|
543
|
+
</View>) : (<View className="flex-row items-center">
|
|
412
544
|
<Logo width={72} height={72} style={{ marginRight: -2 }}/>
|
|
413
545
|
<Heading className="text-4xl">{appName}</Heading>
|
|
546
|
+
{/* Inline tagline sits on the name's baseline, at body
|
|
547
|
+
size — matching the 4xl heading would make the
|
|
548
|
+
category label compete with the brand. */}
|
|
549
|
+
{tagline && inlineTagline ? (<MutedText className="ml-2 font-mono text-base">{`| ${tagline}`}</MutedText>) : null}
|
|
414
550
|
</View>)}
|
|
415
|
-
{tagline ? (<
|
|
551
|
+
{tagline && !inlineTagline && !lockupBrand ? (<View className="items-center">
|
|
552
|
+
{/* The rule is only drawn for the "ruled" layout. Width
|
|
553
|
+
tracks the row rather than being a fixed px value, so
|
|
554
|
+
it lines up under the logo + name pairing at whatever
|
|
555
|
+
size the app renders them. */}
|
|
556
|
+
{ruledTagline ? (<View className="mt-3 h-px w-full self-stretch bg-border dark:bg-neutral-800"/>) : null}
|
|
557
|
+
<MutedText className={ruledTagline ? "mt-3 font-mono text-base" : "mt-1 font-mono text-base"}>
|
|
558
|
+
{tagline}
|
|
559
|
+
</MutedText>
|
|
560
|
+
</View>) : null}
|
|
416
561
|
</View>
|
|
417
562
|
|
|
418
563
|
{onWelcome ? (<View style={{ width: "100%", alignItems: "center" }}>
|
|
419
564
|
{decoration ? (<View className="mb-6 w-full items-center">{decoration}</View>) : null}
|
|
420
565
|
<View style={{ width: "100%", maxWidth: 320 }}>
|
|
421
|
-
<Button className="w-full" telemetryId="auth.auth.stage" onPress={() =>
|
|
566
|
+
<Button className="w-full" telemetryId="auth.auth.stage" onPress={() => {
|
|
567
|
+
setStage("auth");
|
|
568
|
+
onWelcomeDismissed?.();
|
|
569
|
+
}} accessibilityLabel={welcomeCtaLabel} accessibilityRole="button" testID="welcome-get-started">
|
|
422
570
|
{welcomeCtaLabel}
|
|
423
571
|
</Button>
|
|
424
572
|
</View>
|
|
425
573
|
</View>) : (<View style={{ width: "100%", alignItems: "center" }}>
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
574
|
+
{/* Positioned wrapper so the ambient layer can sit behind
|
|
575
|
+
the card and bleed past its edges. Width tracks the
|
|
576
|
+
card's own max so the glow is centred on the form, not
|
|
577
|
+
the viewport. */}
|
|
578
|
+
{/* No mode heading above the card. The Sign In / Sign Up
|
|
579
|
+
toggle at the top of the card already says which mode
|
|
580
|
+
this is, and a title restating it directly beneath the
|
|
581
|
+
brand lockup made three stacked pieces of naming before
|
|
582
|
+
the first field. The forgot-password card keeps its own
|
|
583
|
+
header title, which is doing real work. */}
|
|
584
|
+
<View style={{ width: "100%", maxWidth: 360, position: "relative" }}>
|
|
585
|
+
{cardBackdrop && !showAside ? (<View pointerEvents="none" style={{
|
|
586
|
+
position: "absolute",
|
|
587
|
+
top: -cardBackdropBleed,
|
|
588
|
+
left: -cardBackdropBleed,
|
|
589
|
+
right: -cardBackdropBleed,
|
|
590
|
+
bottom: -cardBackdropBleed,
|
|
591
|
+
}}>
|
|
592
|
+
{cardBackdrop}
|
|
593
|
+
</View>) : null}
|
|
594
|
+
<Card className="w-full max-w-[360px]">
|
|
595
|
+
{displayStage === "forgot" ? (<>
|
|
596
|
+
<CardHeader>
|
|
597
|
+
<CardTitle>Reset password</CardTitle>
|
|
598
|
+
</CardHeader>
|
|
599
|
+
<CardContent className="gap-4">
|
|
600
|
+
{forgotSent ? (<MutedText className="text-sm leading-5">
|
|
601
|
+
If an account exists for that email, we've sent a link to
|
|
602
|
+
reset your password. Check your inbox.
|
|
603
|
+
</MutedText>) : (<>
|
|
604
|
+
<MutedText className="text-sm leading-5">
|
|
605
|
+
Enter your email and we'll send you a link to reset your
|
|
606
|
+
password.
|
|
607
|
+
</MutedText>
|
|
608
|
+
<ShakeField shake={emailShake} label="Email">
|
|
609
|
+
<EmailInput value={email} onChangeText={(text) => {
|
|
442
610
|
setEmail(text);
|
|
443
611
|
clearField("email");
|
|
444
612
|
}} autoFocus placeholder="you@example.com" autoComplete="email" invalid={!!errors.email} returnKeyType="done" onSubmitEditing={handleForgot} accessibilityLabel="Email address"/>
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
613
|
+
</ShakeField>
|
|
614
|
+
<FormError message={errors.email}/>
|
|
615
|
+
</>)}
|
|
616
|
+
</CardContent>
|
|
617
|
+
<CardFooter className="flex-col items-stretch gap-3 border-t-0 pt-0">
|
|
618
|
+
{forgotSent ? (<Button className="w-full" telemetryId="auth.auth.go_auth" onPress={goAuth} accessibilityLabel="Back to sign in">
|
|
619
|
+
Back to Sign In
|
|
620
|
+
</Button>) : (<>
|
|
621
|
+
<Button className="w-full" telemetryId="auth.auth.forgot" onPress={handleForgot} isLoading={isLoading} accessibilityLabel="Send reset link">
|
|
622
|
+
Send reset link
|
|
623
|
+
</Button>
|
|
624
|
+
<MutedText className="mt-1 text-center font-mono text-sm" onPress={withClickTracking("auth.auth.go_auth_2", goAuth)} accessibilityRole="button" accessibilityLabel="Back to sign in">
|
|
625
|
+
← Back
|
|
626
|
+
</MutedText>
|
|
627
|
+
</>)}
|
|
628
|
+
</CardFooter>
|
|
629
|
+
</>) : (<>
|
|
630
|
+
{/* `gap-5` under the toggle, not `gap-3`. The
|
|
631
|
+
toggle is the mode switch and the title restates
|
|
632
|
+
the mode, so they were reading as one crowded
|
|
633
|
+
block; a wider gap separates the control from the
|
|
634
|
+
heading it drives and takes some of the slack
|
|
635
|
+
that otherwise all collects above the footer.
|
|
636
|
+
Applies to both modes — the header is shared. */}
|
|
637
|
+
<CardHeader className="gap-5 pb-3 pt-5">
|
|
638
|
+
{signUpEnabled ? (<SegmentedToggle value={mode} onChange={switchMode} options={[
|
|
464
639
|
{ key: "signin", label: "Sign In" },
|
|
465
640
|
{ key: "signup", label: "Sign Up" },
|
|
466
641
|
]}/>) : null}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
{isSignup ? "Create your account" : `Sign in to ${appName}`}
|
|
470
|
-
</CardTitle>
|
|
471
|
-
</CardHeader>
|
|
642
|
+
{signedUp ? (<MutedText className="text-sm leading-5">{signedUp}</MutedText>) : null}
|
|
643
|
+
</CardHeader>
|
|
472
644
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
645
|
+
{/* Height is locked to the tallest of the two
|
|
646
|
+
modes (see the form-box note above), so the
|
|
647
|
+
layout transition only ever eases fields in and
|
|
648
|
+
out — the card itself no longer moves. Width is
|
|
649
|
+
pinned explicitly so the layout animation has
|
|
650
|
+
nothing to interpolate horizontally either. */}
|
|
651
|
+
<Animated.View layout={t.layout} onLayout={onFormLayout} pointerEvents={probeSignup ? "none" : "auto"} style={{
|
|
652
|
+
width: "100%",
|
|
653
|
+
minHeight: formMinHeight || undefined,
|
|
654
|
+
opacity: probeSignup ? 0 : 1,
|
|
655
|
+
}}>
|
|
656
|
+
{/* `flex: 1` with the default top alignment, so the
|
|
657
|
+
fields keep their natural rhythm and ALL the
|
|
658
|
+
locked height's slack collects in one place —
|
|
659
|
+
between the last field and the footer.
|
|
660
|
+
That pins the submit button, the OR divider and
|
|
661
|
+
the social row to the same y in both modes, so
|
|
662
|
+
they hold still while toggling instead of
|
|
663
|
+
jumping up and down by the height of two
|
|
664
|
+
fields.
|
|
665
|
+
Note this is not the earlier attempt: spreading
|
|
666
|
+
the slack BETWEEN the rows (`space-between`)
|
|
667
|
+
looked worse than the gap it removed. Same
|
|
668
|
+
total space, collected instead of distributed. */}
|
|
669
|
+
<CardContent className="gap-2.5 pb-2" style={{ flex: 1 }}>
|
|
670
|
+
{!showSignup && passkeyEnabled ? (<Animated.View entering={t.entering} exiting={t.exiting} layout={t.layout}>
|
|
671
|
+
<Button variant="outline" className="w-full" telemetryId="auth.auth.passkey_login" onPress={handlePasskeyLogin} isLoading={passkeyStep === "passkey-loading"} accessibilityLabel="Sign in with passkey" accessibilityRole="button" testID="login-passkey-button">
|
|
672
|
+
<View className="flex-row items-center justify-center">
|
|
673
|
+
<Ionicons name="key-outline" size={20} color={isDark ? "#FFFFFF" : "#000000"} className="mr-2"/>
|
|
674
|
+
<Text className="text-base font-medium">
|
|
675
|
+
Sign in with passkey
|
|
676
|
+
</Text>
|
|
677
|
+
</View>
|
|
678
|
+
</Button>
|
|
679
|
+
<View className="mt-3">
|
|
680
|
+
<SeparatorWithText />
|
|
483
681
|
</View>
|
|
484
|
-
</
|
|
485
|
-
<View className="mt-3">
|
|
486
|
-
<SeparatorWithText />
|
|
487
|
-
</View>
|
|
488
|
-
</Animated.View>) : null}
|
|
682
|
+
</Animated.View>) : null}
|
|
489
683
|
|
|
490
|
-
|
|
491
|
-
|
|
684
|
+
<ShakeField shake={emailShake} label="Email">
|
|
685
|
+
<EmailInput value={email} onChangeText={(text) => {
|
|
492
686
|
setEmail(text);
|
|
493
687
|
clearField("email");
|
|
494
688
|
}} autoFocus placeholder="you@example.com" autoComplete="email" invalid={!!errors.email} accessibilityLabel="Email address" testID="auth-email-input"/>
|
|
495
|
-
|
|
689
|
+
</ShakeField>
|
|
496
690
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
691
|
+
{showSignup ? (<Animated.View key="displayName" entering={t.entering} exiting={t.exiting} layout={t.layout}>
|
|
692
|
+
<ShakeField shake={displayNameShake} label="Display Name">
|
|
693
|
+
<Input value={displayName} onChangeText={(text) => {
|
|
500
694
|
setDisplayName(text);
|
|
501
695
|
clearField("displayName");
|
|
502
696
|
}} placeholder="Your display name" leftIcon={<Ionicons name="person-outline" size={20} color={isDark ? "#A3A3A3" : "#737373"}/>} autoCapitalize="words" autoCorrect={false} maxLength={100} invalid={!!errors.displayName} accessibilityLabel="Display name" testID="auth-displayname-input"/>
|
|
503
|
-
|
|
504
|
-
|
|
697
|
+
</ShakeField>
|
|
698
|
+
</Animated.View>) : null}
|
|
505
699
|
|
|
506
|
-
|
|
507
|
-
|
|
700
|
+
<ShakeField shake={passwordShake} label={passwordLabel}>
|
|
701
|
+
<PasswordInput value={password} onChangeText={(text) => {
|
|
508
702
|
setPassword(text);
|
|
509
703
|
clearField("password");
|
|
510
704
|
if (isSignup) {
|
|
511
705
|
checkPasswordDebounced(text);
|
|
512
706
|
}
|
|
513
707
|
}} placeholder={passwordPlaceholder} autoComplete={isSignup ? "password-new" : "password"} invalid={!!errors.password} returnKeyType={isSignup ? "next" : "done"} onSubmitEditing={isSignup ? undefined : handleSubmit} accessibilityLabel={passwordLabel} testID="auth-password-input"/>
|
|
514
|
-
|
|
708
|
+
</ShakeField>
|
|
515
709
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
710
|
+
{/* Rendered only when it has something to show.
|
|
711
|
+
An empty wrapper still consumes one of
|
|
712
|
+
CardContent's `gap-3` slots, which put 12px
|
|
713
|
+
more space above Confirm Password than
|
|
714
|
+
between any other pair of fields — visible as
|
|
715
|
+
a stray gap. `probeSignup` keeps it in the
|
|
716
|
+
measuring pass so the locked height still
|
|
717
|
+
accounts for the meter. */}
|
|
718
|
+
{showSignup && (probeSignup || password.length > 0 || pwnedInfo) ? (<Animated.View key="strength" entering={t.entering} exiting={t.exiting} layout={t.layout}>
|
|
719
|
+
{/* `reserveSpace` only during the probe.
|
|
720
|
+
Keeping it on permanently held an empty
|
|
721
|
+
28px block between Password and Confirm
|
|
722
|
+
Password, which reads as a stray gap.
|
|
723
|
+
Reserving it just for the measurement
|
|
724
|
+
means the locked height still accounts
|
|
725
|
+
for the meter, so it can appear later
|
|
726
|
+
without the card growing. */}
|
|
727
|
+
<PasswordStrengthMeter password={password} reserveSpace={probeSignup}/>
|
|
728
|
+
<PwnedPasswordWarning pwnedInfo={pwnedInfo}/>
|
|
729
|
+
</Animated.View>) : null}
|
|
520
730
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
731
|
+
{showSignup ? (<Animated.View key="confirm" entering={t.entering} exiting={t.exiting} layout={t.layout}>
|
|
732
|
+
<ShakeField shake={confirmShake} label="Confirm Password">
|
|
733
|
+
<PasswordInput value={confirmPassword} onChangeText={(text) => {
|
|
524
734
|
setConfirmPassword(text);
|
|
525
735
|
clearField("confirmPassword");
|
|
526
736
|
}} placeholder="Re-enter your password" autoComplete="password-new" returnKeyType="done" onSubmitEditing={handleSubmit} invalid={!!errors.confirmPassword} accessibilityLabel="Confirm password" testID="auth-confirm-password-input"/>
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
737
|
+
</ShakeField>
|
|
738
|
+
{requireInviteCode ? (<View className="mt-3">
|
|
739
|
+
<ShakeField shake={confirmShake} label="Invite code">
|
|
740
|
+
<Input value={inviteCode} onChangeText={(text) => {
|
|
531
741
|
setInviteCode(text);
|
|
532
742
|
clearField("inviteCode");
|
|
533
743
|
}} placeholder="Paste your invite code" autoCapitalize="none" autoCorrect={false} returnKeyType="done" onSubmitEditing={handleSubmit} invalid={!!errors.inviteCode} accessibilityLabel="Invite code" testID="auth-invite-code-input"/>
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
744
|
+
</ShakeField>
|
|
745
|
+
<FormError message={errors.inviteCode}/>
|
|
746
|
+
</View>) : null}
|
|
747
|
+
</Animated.View>) : (<Animated.View key="forgot" entering={t.entering} exiting={t.exiting} layout={t.layout} className="items-end">
|
|
748
|
+
<Text className="font-mono text-sm underline" onPress={withClickTracking("auth.auth.stage_2", () => {
|
|
539
749
|
setStage("forgot");
|
|
540
750
|
setForgotSent(false);
|
|
541
751
|
setErrors({});
|
|
542
752
|
})} accessibilityRole="link" accessibilityLabel="Forgot password">
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
753
|
+
Forgot password?
|
|
754
|
+
</Text>
|
|
755
|
+
</Animated.View>)}
|
|
756
|
+
</CardContent>
|
|
547
757
|
|
|
548
|
-
|
|
549
|
-
|
|
758
|
+
<CardFooter className="flex-col items-stretch gap-2.5 border-t-0 pb-5 pt-0">
|
|
759
|
+
<FormError message={firstError}/>
|
|
550
760
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
761
|
+
<Button className="w-full" telemetryId="auth.auth.submit" onPress={handleSubmit} isLoading={isLoading} accessibilityLabel={isSignup ? "Create account" : "Sign in"} accessibilityRole="button" testID="auth-submit">
|
|
762
|
+
{isSignup ? "Create Account" : "Sign In"}
|
|
763
|
+
</Button>
|
|
554
764
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
765
|
+
{socialAuth ? (<>
|
|
766
|
+
<SeparatorWithText />
|
|
767
|
+
{socialAuth}
|
|
768
|
+
</>) : null}
|
|
559
769
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
770
|
+
{/* In-card back — thumb-reachable, so users don't
|
|
771
|
+
have to stretch to the top-left corner arrow.
|
|
772
|
+
Hidden when there is no welcome stage behind it. */}
|
|
773
|
+
<Text className={showWelcome
|
|
564
774
|
? "mt-1 text-center font-mono text-sm text-muted-foreground"
|
|
565
775
|
: "hidden"} onPress={withClickTracking("auth.auth.stage_3", () => setStage("welcome"))} accessibilityRole="button" accessibilityLabel="Back">
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
776
|
+
← Back
|
|
777
|
+
</Text>
|
|
778
|
+
</CardFooter>
|
|
779
|
+
</Animated.View>
|
|
780
|
+
</>)}
|
|
781
|
+
</Card>
|
|
782
|
+
</View>
|
|
572
783
|
</View>)}
|
|
573
784
|
</Animated.View>
|
|
574
785
|
|
|
575
|
-
{/*
|
|
576
|
-
|
|
786
|
+
{/* Legal footer — Terms/Privacy as popups, no routes.
|
|
787
|
+
Welcome stage ONLY. Pressing "Get Started" and proceeding IS
|
|
788
|
+
the acknowledgement, so the line belongs beside that button
|
|
789
|
+
and nowhere else; repeating it under the sign-in and sign-up
|
|
790
|
+
forms restates a consent the user already gave and crowds two
|
|
791
|
+
screens that are busier than the welcome. When there is no
|
|
792
|
+
welcome stage at all (`showWelcome={false}`) it stays on the
|
|
793
|
+
form. Note there is deliberately NO "but show it on the form
|
|
794
|
+
if there is no welcome stage" fallback: `showWelcome` is false
|
|
795
|
+
on every launch after the first, so such a fallback puts the
|
|
796
|
+
line back under the form permanently — which is exactly the
|
|
797
|
+
bug it was written to avoid. */}
|
|
798
|
+
<View className={onWelcome ? "mb-6 mt-4" : "hidden"}>
|
|
577
799
|
<MutedText className="text-center text-xs leading-5">
|
|
578
800
|
By continuing, you agree to our{" "}
|
|
579
801
|
<Text className="text-xs text-foreground underline" onPress={withClickTracking("auth.auth.legal", () => setLegal("terms"))} accessibilityRole="link" accessibilityLabel="Terms of Service">
|
|
@@ -585,9 +807,21 @@ export function AuthScreen({ passwordLabel = "Password", passwordPlaceholder = "
|
|
|
585
807
|
</Text>
|
|
586
808
|
</MutedText>
|
|
587
809
|
</View>
|
|
810
|
+
|
|
811
|
+
{footer && !showAside ? <View className="mb-6">{footer}</View> : null}
|
|
588
812
|
</View>
|
|
589
813
|
</ScrollView>
|
|
590
814
|
</KeyboardAvoidingView>
|
|
815
|
+
{/* Wide only. Screen-level and absolutely placed, so it centres on the
|
|
816
|
+
PAGE rather than under the form column, and drawn after the panes so
|
|
817
|
+
it sits above them. `pointerEvents="box-none"` keeps it from
|
|
818
|
+
intercepting a tap meant for anything behind it.
|
|
819
|
+
On a narrow layout it is rendered IN FLOW instead (below): the card
|
|
820
|
+
reaches the bottom of a phone screen, and an absolute footer simply
|
|
821
|
+
lands on top of the card's own back link. */}
|
|
822
|
+
{footer && showAside ? (<View pointerEvents="box-none" style={{ position: "absolute", left: 0, right: 0, bottom: 16, alignItems: "center" }}>
|
|
823
|
+
{footer}
|
|
824
|
+
</View>) : null}
|
|
591
825
|
</SafeAreaView>
|
|
592
826
|
|
|
593
827
|
{/* Passkey unlock dialog — non-PRF authenticators. */}
|