import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Keyboard, KeyboardAvoidingView, type LayoutChangeEvent, Platform, ScrollView, useWindowDimensions, View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import Animated, { Easing, LinearTransition, runOnJS, useAnimatedStyle, useSharedValue, withTiming, } from "react-native-reanimated"; import { withClickTracking } from "../../services/telemetry"; import { type Href } from "expo-router"; import { StatusBar } from "expo-status-bar"; import { Ionicons } from "@expo/vector-icons"; import { useColorScheme } from "nativewind"; import * as Haptics from "expo-haptics"; import { Heading, MutedText, Text } from "../../components/text"; import { Button, IconButton } from "../../components/button"; import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "../../components/card"; import { EmailInput, Input, PasswordInput } from "../../components/input"; import { Dialog, DialogButton, DialogFooter } from "../../components/dialog"; import { SeparatorWithText } from "../../components/separator"; import { LegalDialog } from "../../components/legal-dialog"; import { SegmentedToggle } from "../../components/segmented-toggle"; import PasswordStrengthMeter from "../../components/auth/PasswordStrengthMeter"; import PwnedPasswordWarning from "../../components/auth/PwnedPasswordWarning"; import ShakeField from "../../forms/shake-field"; import FormError from "../../forms/form-error"; import { useAuth } from "../../hooks/useAuth"; import { useShakeAnimation } from "../../hooks/useShakeAnimation"; import { ApiError } from "../../utils/http-error"; import { validateEmail } from "../../utils/validation"; import AuthService from "../../services/auth.service"; import { checkPasswordPwned, debounce, getPwnedMessage } from "../../services/hibp"; import { getCachedPolicy } from "../../services/hibp-cache"; import { useAppConfig } from "../../context/AppConfigContext"; import { useNavigation } from "../../context/NavigationContext"; const SCROLL_CONTENT_STYLE = { flexGrow: 1 } as const; /** * Overhang added below the form while the software keyboard is up. * * Enough to lift the last field and its button clear of the keyboard without * leaving a visible gap once it closes — the style is only applied while the * keyboard is showing. */ const KEYBOARD_SCROLL_PADDING = { paddingBottom: 160 } as const; /** Width of the form column when the aside is showing. */ /** * Width of the form column when the aside is showing. * * With symmetric padding the card centres in this column, so the gap either * side of it is simply `(FORM_PANE_WIDTH - 360) / 2`. 716 puts 178px between * the card and the seam, and the same 178px between the card and the window * edge — the two gaps match by construction rather than by tuning two numbers * against each other. */ const FORM_PANE_WIDTH = 716; const DEFAULT_POLICY = { enabled: false, enforce: false, block_threshold: 0 }; type Mode = "signin" | "signup"; // welcome — logo + decoration + Get Started (the entry view) // auth — the Sign In / Sign Up form // forgot — inline password-reset request type Stage = "welcome" | "auth" | "forgot"; type AnimatedViewProps = React.ComponentProps; /** * Reanimated config for the FIELD-level Sign In ↔ Sign Up toggle. (The * welcome ↔ form stage swap is a fixed, shared-value fade-through — see the * morph effect in the component.) * • `layout` — eases the card height as fields appear/disappear. * • `entering`/`exiting` — fields that mount/unmount (Sign In ↔ Sign Up). */ export interface AuthFormTransition { layout?: AnimatedViewProps["layout"]; entering?: AnimatedViewProps["entering"]; exiting?: AnimatedViewProps["exiting"]; } /** `Required` would forbid the `undefined` the defaults deliberately use. */ type ResolvedTransition = AuthFormTransition; const DEFAULT_TRANSITION: ResolvedTransition = { // Height is locked to the tallest mode (see the form-box note below), so the // layout transition has nothing left to ease. Kept for the cases where the // box legitimately grows past the lock — a pwned-password warning, a wrapped // error, a larger system font. layout: LinearTransition.duration(280), // No enter/exit animation. Two earlier attempts were both wrong: the // directional FadeInDown/FadeOutUp slid fields in from above, and a plain // cross-fade still singled out the only fields that actually mount on a mode // switch — Display Name, Confirm Password — so they arrived visibly later // than the fields around them, which are already on screen and simply stay // put. One field fading in while its neighbours do not reads as a glitch, not // as a transition. The card's size is locked, so an instant swap has nothing // to hide anyway. // // Overridable: `transition` on the props still accepts both, for a consumer // that wants them. entering: undefined, exiting: undefined, }; // Stage morph timing — fade/scale out, swap the stage at zero opacity (so the // centered→centered swap is invisible, no overlap/teleport), then fade/scale in. const STAGE_OUT_MS = 200; const STAGE_IN_MS = 360; export interface AuthScreenProps { /** Master-password field label. */ passwordLabel?: string; /** Master-password field placeholder. */ passwordPlaceholder?: string; /** Optional social-auth block, rendered under the primary CTA. */ socialAuth?: React.ReactNode; /** * Render the "Sign in with passkey" button + ceremony flow. Pass the * result of `isPasskeySupported()` (only meaningful in sign-in mode). */ passkeyEnabled?: boolean; passkeyUnlockPlaceholder?: string; passkeyUnlockTitle?: string; passkeyUnlockDescription?: string; /** * Connection / server-settings dialog, folded into this single page (no * route). When provided, a gear button is shown top-right that toggles it. * * Mutually exclusive with `onSettingsPress`, which is the better fit where * the settings surface deserves a whole screen rather than a dialog. */ settings?: (props: { visible: boolean; onClose: () => void }) => React.ReactNode; /** * Show the gear, but navigate instead of opening a dialog. * * Takes precedence over `settings`. A connection screen that has to explain * offline against cloud, official against self-hosted, and show a live health * check is too much for a dialog on a phone — it wants a real screen with * room and a back button. Web, by contrast, passes NEITHER: a browser reached * the app by loading a particular server's URL, so offering to point it * somewhere else is answering a question the page's own origin already * settled. */ onSettingsPress?: () => void; /** Override the animations. Merged over the defaults. */ transition?: AuthFormTransition; /** * App-specific hero rendered on the welcome view, under the logo (the home * for each app's old welcome-screen animation — vault's PasswordDemoAnimation, * simple-finance's AssetCarousel, …). Omit for none. */ decoration?: React.ReactNode; /** * Full-bleed layer behind everything on the screen — a texture, an ambient * gradient. Rendered absolutely-filled and `pointerEvents="none"`, below the * morph, so a welcome→form stage change does not fade it. * * Pairs with `cardBackdrop`: this is the ground, that is the accent hugging * the form. A backdrop drawn here sits UNDER the card backdrop, so a card * glow still reads over an opaque texture. */ backdrop?: React.ReactNode; /** * Persistent footer under the form — copyright, build, which server the * client is pointed at. Always visible, unlike the legal line, which is * welcome-stage only. */ footer?: React.ReactNode; /** * Companion pane shown BESIDE the form on a wide viewport, and not rendered * at all on a narrow one. * * This is the difference between a mobile layout stretched across a monitor * and something that looks built for a desktop. A single centred card on a * 1440px screen leaves most of the window empty; giving the brand, the * product story and the ambient motion their own half turns that emptiness * into the composition. * * Rendered inside the same `KeyboardAvoidingView` as the form but outside its * scroll view, so it holds still while the form scrolls. */ aside?: React.ReactNode; /** * Viewport width, in px, at which `aside` appears. Defaults to 1024 — below * that a two-pane split squeezes the form narrower than its own 360px card. */ asideBreakpoint?: number; /** * Reserve the height of the TALLER of Sign In / Sign Up so the card holds * still when the two are toggled. Defaults to `true`. * * The cost of the lock is slack: whichever mode is shorter leaves the * difference as empty space above its submit button. Where Sign Up carries * several more fields than Sign In that gap is the more visible flaw of the * two — most people only ever sign in, and they all see it — so a consumer * can set this `false` and let the card resize instead. The body is already * wrapped in a layout animation, so the change eases rather than jumps. */ lockFormHeight?: boolean; /** * Ambient layer immediately behind the auth card — a soft glow, a tint. * Rendered absolutely, `pointerEvents="none"`, and bled a little past the * card's edges so the effect hugs the form rather than washing the screen. * * Deliberately scoped to the card. A full-bleed version of this reads as a * coloured background, which is a much louder decision than an accent behind * the one thing the user is looking at. * * Distinct from `decoration`, which is content INSIDE the welcome view's * flow and moves with it. */ cardBackdrop?: React.ReactNode; /** How far the card backdrop bleeds past the card, in px. Defaults to 48. */ cardBackdropBleed?: number; /** Welcome-view tagline/subtitle. Defaults to AppConfig `tagline`. */ welcomeCtaLabel?: string; /** * Called the first time the user leaves the welcome stage. * * The consumer persists this — an introduction repeated on every launch is an * obstacle, not an introduction. Kept as a callback rather than handled here * because this screen is shared across apps and has no business knowing where * any of them keep their preferences. */ onWelcomeDismissed?: () => void; /** * Show the Sign Up half of the toggle. Defaults to `true`. * * Set `false` where the server has no self-service registration — an * identity provider whose tenant is `closed`, for instance. The toggle is * hidden rather than disabled, because a control that cannot ever succeed is * worse than no control. */ signUpEnabled?: boolean; /** * Offer the "Forgot password?" link. Defaults to `true`. * * Hidden rather than disabled, for the same reason `signUpEnabled` hides its * toggle: a control that cannot ever succeed is worse than no control. A * server that has not finished first-boot setup has one account and no mail * transport to send a reset through, and the link is also a way out of a * flow that has to be completed before anything else works. */ forgotPasswordEnabled?: boolean; /** * Collect an invite code on the Sign Up form and require it. For servers * whose registration is invite-only. */ requireInviteCode?: boolean; /** * Override registration entirely. When provided this runs instead of * `useAuth().register`, and the screen shows `signUpSuccessMessage` rather * than assuming the user is now signed in. * * This exists because registration is not uniform across the estate: the * single-tenant apps sign you straight in, while the multi-tenant IdP creates * an unverified account, emails a link, and leaves you signed out. Reshaping * the shared `AuthService.register` to cover both would have changed the * contract vault and simple-finance already depend on. */ onSignUp?: (input: { email: string; password: string; displayName: string; inviteCode?: string; }) => Promise; /** Shown after a successful `onSignUp`. */ signUpSuccessMessage?: string; /** * Render the welcome stage (logo, decoration, "Get Started") before the form. * Defaults to `true`. * * Set `false` to land straight on the form. An app whose only purpose at this * URL is to authenticate has nothing to introduce, and a button that only * reveals the form it precedes is pure friction. The in-card "← Back" is * hidden too, since there is no stage behind it. */ showWelcome?: boolean; /** * Page heading, rendered ABOVE the card, e.g. * "Log into your Entropy account to continue." * * Undefined renders no heading, which is the right default for an app whose * own login the user reached deliberately -- the surrounding brand already * says where they are. Set it on an identity provider, where the user is * frequently redirected in from a relying application and this is the only * thing on screen that can say whose credentials to type. * * It used to render inside the card header, where it sat over the * Sign In / Sign Up toggle and read as a label for the selected mode. Above * the card it is the page's heading and the card is purely the form. */ title?: string; /** Optional line under `title`. Ignored unless `title` is set. */ subtitle?: string; } /** * Single consolidated unauthenticated screen with three animated stages: * a welcome view (logo + app decoration + Get Started), the Sign In / Sign Up * form (with an animated mode toggle), and inline forgot-password. Terms/Privacy * are themed popups and a connection-settings dialog can be folded in. Preserves * the passkey ceremony + non-PRF unlock and the HIBP/strength checks. */ 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, lockFormHeight = true, backdrop, cardBackdrop, cardBackdropBleed = 48, welcomeCtaLabel = "Get Started", onWelcomeDismissed, signUpEnabled = true, forgotPasswordEnabled = true, requireInviteCode = false, onSignUp, signUpSuccessMessage = "Account created. Check your email to verify the address, then sign in.", showWelcome = true, title, subtitle, }: AuthScreenProps = {}) { const { login: authLogin, register: authRegister, loginWithPasskey, unlockWithPassword, } = useAuth(); const router = useNavigation(); const { appName, Logo, tagline, taglineLayout, routes, termsText, privacyText, logoIsWordmark, logoWidth, } = useAppConfig(); const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; // Two-pane only when there is a pane to show AND room to show it. const { width: viewportWidth } = useWindowDimensions(); const showAside = !!aside && viewportWidth >= asideBreakpoint; // A wordmark has no app-name text to sit beside, so the two layouts that // need one degrade to the ruled treatment rather than rendering a pipe, or a // lockup, against nothing. const inlineTagline = taglineLayout === "inline" && !logoIsWordmark; const lockupBrand = taglineLayout === "lockup" && !logoIsWordmark; const ruledTagline = taglineLayout !== undefined && !inlineTagline && !lockupBrand; // The lockup sizes the mark to the measured height of the name + tagline // block, so the three line up however the app scales its type. Measured // rather than hard-coded because a fixed px value drifts the moment the type // scale or the system font size moves. /** * Scrolls the form so the field being typed into is actually visible. * * On a phone the sign-up form is taller than the space left once the * keyboard is up, and the fields near the bottom — password, confirm — sat * underneath it. `adjustResize` shrinks the window, but the content inside * this ScrollView is pinned to exactly the viewport height (`flexGrow: 1` * around a `flex-1 justify-between` view), so there was nothing to scroll: * the layout just compressed and the bottom fields stayed hidden. * * Two changes fix it together — the extra bottom room added to the content * style below, and this scroll once the keyboard has actually appeared. * `keyboardDidShow`, not `WillShow`, because Android only fires the former * and the height is not final until it does. */ const scrollRef = useRef(null); const [keyboardVisible, setKeyboardVisible] = useState(false); useEffect(() => { // Web has no software keyboard to avoid, and the desktop split-pane layout // must not start scrolling itself when a field is focused. if (Platform.OS === "web") { return; } const show = Keyboard.addListener("keyboardDidShow", () => { setKeyboardVisible(true); // A frame later: the ScrollView has to re-measure against the shrunken // viewport before scrollToEnd knows where the end is. requestAnimationFrame(() => { scrollRef.current?.scrollToEnd({ animated: true }); }); }); const hide = Keyboard.addListener("keyboardDidHide", () => { setKeyboardVisible(false); }); return () => { show.remove(); hide.remove(); }; }, []); const [brandTextHeight, setBrandTextHeight] = useState(0); const onBrandTextLayout = useCallback((e: LayoutChangeEvent) => { const h = Math.round(e.nativeEvent.layout.height); setBrandTextHeight((prev) => (Math.abs(prev - h) > 1 ? h : prev)); }, []); const baseTransition = useMemo(() => ({ ...DEFAULT_TRANSITION, ...transition }), [transition]); const [mode, setMode] = useState("signin"); const [inviteCode, setInviteCode] = useState(""); const [signedUp, setSignedUp] = useState(null); const [stage, setStage] = useState(showWelcome ? "welcome" : "auth"); const isSignup = mode === "signup"; // Stage morph: `stage` is the target; `displayStage` is what's rendered. // On a stage change we fade/scale the content out, swap displayStage at zero // opacity (so the centered-welcome → top-anchored-form layout change is // invisible — no teleport, no overlap of the two states), then fade in. const [displayStage, setDisplayStage] = useState(showWelcome ? "welcome" : "auth"); // Fade-through: `stage` is the target, `displayStage` is what's rendered. On // a change the content fades/scales out, swaps at zero opacity (so the // position change between stages is never seen), then fades back in. const morph = useSharedValue(1); useEffect(() => { if (stage === displayStage) { return; } morph.value = withTiming( 0, { duration: STAGE_OUT_MS, easing: Easing.in(Easing.cubic) }, (done) => { if (done) { runOnJS(setDisplayStage)(stage); morph.value = withTiming(1, { duration: STAGE_IN_MS, easing: Easing.out(Easing.cubic) }); } } ); }, [stage, displayStage, morph]); const morphStyle = useAnimatedStyle(() => ({ opacity: morph.value, transform: [{ scale: 0.94 + 0.06 * morph.value }, { translateY: (1 - morph.value) * 8 }], })); const onWelcome = displayStage === "welcome"; const [email, setEmail] = useState(""); const [displayName, setDisplayName] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [isLoading, setIsLoading] = useState(false); const [errors, setErrors] = useState<{ email?: string; displayName?: string; password?: string; confirmPassword?: string; inviteCode?: string; }>({}); const [legal, setLegal] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); const [pwnedInfo, setPwnedInfo] = useState<{ title: string; message: string; severity: "error" | "warning" | "info"; count: number; } | null>(null); const [passkeyStep, setPasskeyStep] = useState<"idle" | "passkey-loading" | "passkey-unlock">( "idle" ); const [unlockPassword, setUnlockPassword] = useState(""); const [unlockError, setUnlockError] = useState(""); const [pendingOnboardingStep, setPendingOnboardingStep] = useState(null); const passkeyAbortRef = useRef(null); const [forgotSent, setForgotSent] = useState(false); const emailShake = useShakeAnimation(); const displayNameShake = useShakeAnimation(); const passwordShake = useShakeAnimation(); const confirmShake = useShakeAnimation(); const firstError = errors.email ?? errors.displayName ?? errors.password ?? errors.confirmPassword; useEffect(() => () => passkeyAbortRef.current?.abort(), []); const checkPasswordDebounced = useMemo( () => debounce(async (pass: string) => { const policy = getCachedPolicy() ?? DEFAULT_POLICY; if (!policy.enabled || pass.length < 8) { setPwnedInfo(null); return; } const result = await checkPasswordPwned(pass); if (result.isPwned) { setPwnedInfo({ ...getPwnedMessage(result), count: result.count }); } else { setPwnedInfo(null); } }, 1000), [] ); useEffect(() => () => checkPasswordDebounced.cancel(), [checkPasswordDebounced]); const switchMode = useCallback((next: Mode) => { setMode(next); setErrors({}); setPwnedInfo(null); }, []); // ── Stable form box ─────────────────────────────────────────────────── // Sign In and Sign Up mount different field sets (passkey button + // separator + "Forgot password?" against Display Name + strength meter + // Confirm Password), so the card grew ~70 px on every toggle. On a short // viewport the taller Sign Up layout also pushed the page into a scroll, // which moved the logo, the footer and the card's own vertical centre — // the whole screen appeared to resize, not only the fields. // // So lock the body to the TALLEST layout and let the shorter mode leave // its slack at the bottom of the card. `probeSignup` renders the Sign Up // field set for the single frame the form first mounts, purely to measure // it: coming from the welcome stage that frame is already at zero opacity // (the morph is mid-fade), and it is explicitly hidden anyway for apps // that skip the welcome stage. Without that pre-measure the FIRST toggle // of every page load would still be the one that grows the card. // // `minHeight` (not `height`) on purpose: anything that legitimately needs // more room later — a pwned-password warning, a wrapped error, a larger // system font — still expands the card instead of being clipped. // // A consumer can turn the lock OFF with `lockFormHeight={false}`, and then // the card sizes to whichever mode is showing — the layout animation already // wrapping the body eases the height change rather than jumping it. That is // the right trade where the reserved slack is the more visible cost: a Sign // Up set much taller than Sign In leaves a conspicuous gap above the submit // button for every user who only ever signs in, which is most of them. const [formMinHeight, setFormMinHeight] = useState(0); const [probed, setProbed] = useState(!signUpEnabled || !lockFormHeight); const probeSignup = !probed; const showSignup = probeSignup || isSignup; /** * Whether the social block is on screen. * * A boolean rather than the node itself, because the node's identity changes * on every render of the consumer and would re-arm the probe forever. */ const hasSocialAuth = socialAuth !== undefined && socialAuth !== null; // Re-arm the probe when anything that changes the form's height ARRIVES LATE. // // `probed` starts at `!signUpEnabled`, which is right for a consumer that // knows its registration policy at mount. One that has to ASK — Access's // portal fetches the tenant's policy and starts at the conservative // "closed" — mounts with sign-up off, skips the measuring pass, and then // turns sign-up on a moment later with nothing measured. The first real // toggle is then the one that grows the card: it resizes once, then holds // still forever, which is precisely the symptom the lock exists to remove. // // `socialAuth` is the same story and was missed. Vault asks the server // whether federation is configured before it offers the buttons, so the block // lands a moment after mount — after the measuring pass had already recorded // a form without it. Same symptom, one resize in, then still. // // Cheap and idempotent: the effect only fires on an edge, and for a consumer // whose values were already settled at mount this sets what it already had. useEffect(() => { if (signUpEnabled && lockFormHeight) { setProbed(false); } }, [signUpEnabled, lockFormHeight, hasSocialAuth]); const onFormLayout = useCallback((e: LayoutChangeEvent) => { const measured = Math.ceil(e.nativeEvent.layout.height); setFormMinHeight((prev) => (measured > prev ? measured : prev)); setProbed(true); }, []); // No enter/exit animation until the measuring pass is done. // // When `probed` flips, the probe's Sign Up fields unmount — and an exiting // animation on that teardown plays VISIBLY, because the wrapper's opacity is // back to 1 by the time it runs. That was a Display Name field blinking into // view and fading out on first render, for a frame the user was never meant // to see at all. const t = useMemo( () => probed ? baseTransition : { ...baseTransition, entering: undefined, exiting: undefined }, [baseTransition, probed] ); const clearField = (field: keyof typeof errors) => { if (errors[field]) { setErrors((e) => ({ ...e, [field]: undefined })); } }; const validate = () => { const next: typeof errors = {}; if (!email) { next.email = "Email is required"; } else if (!validateEmail(email)) { next.email = "Please enter a valid email"; } if (!password) { next.password = "Password is required"; } else if (password.length < 8) { next.password = "Password must be at least 8 characters"; } if (isSignup) { if (!displayName) { next.displayName = "Display name is required"; } else if (displayName.length > 100) { next.displayName = "Display name must be 100 characters or less"; } if (!confirmPassword) { next.confirmPassword = "Please confirm your password"; } else if (password !== confirmPassword) { next.confirmPassword = "Passwords do not match"; } if (requireInviteCode && !inviteCode.trim()) { next.inviteCode = "An invite code is required to create an account here"; } } return next; }; const shakeFor = (next: typeof errors) => { if (next.email) { emailShake.shake(); } if (next.displayName) { displayNameShake.shake(); } if (next.password) { passwordShake.shake(); } if (next.confirmPassword) { confirmShake.shake(); } }; const performLogin = async () => { setIsLoading(true); try { await authLogin(email.trim().toLowerCase(), password); setPassword(""); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } catch (error) { await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); const message = error instanceof Error ? error.message : "Login failed"; const code = error instanceof ApiError ? error.code : undefined; if (code === "INVALID_CREDENTIALS") { setErrors({ password: "Invalid email or password" }); emailShake.shake(); passwordShake.shake(); } else if (code === "ACCOUNT_LOCKED") { setErrors({ email: "Account is locked. Please contact support." }); emailShake.shake(); } else { setErrors({ password: message }); passwordShake.shake(); } } finally { setIsLoading(false); } }; const performRegister = async () => { setIsLoading(true); const policy = getCachedPolicy() ?? DEFAULT_POLICY; if (policy.enabled) { const pwned = await checkPasswordPwned(password); if (pwned.isPwned) { setPwnedInfo({ ...getPwnedMessage(pwned), count: pwned.count }); if (policy.enforce && pwned.count > policy.block_threshold) { await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); passwordShake.shake(); confirmShake.shake(); setIsLoading(false); return; } } else { setPwnedInfo(null); } } try { if (onSignUp) { // The host owns the contract. It may leave the user signed out (the // IdP emails a verification link), so show the notice rather than // assuming a session now exists. await onSignUp({ email: email.trim().toLowerCase(), password, displayName: displayName.trim(), inviteCode: requireInviteCode ? inviteCode.trim() : undefined, }); setSignedUp(signUpSuccessMessage); setPassword(""); setConfirmPassword(""); setInviteCode(""); setMode("signin"); } else { await authRegister(email.trim().toLowerCase(), password, displayName.trim()); } await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } catch (error) { await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); const message = error instanceof Error ? error.message : "Registration failed"; if (message.includes("email") && message.includes("exists")) { setErrors({ email: "This email is already registered" }); emailShake.shake(); } else if (message.includes("password")) { setErrors({ password: message }); passwordShake.shake(); } else { setErrors({ email: message }); emailShake.shake(); } } finally { setIsLoading(false); } }; const handleSubmit = async () => { if (isLoading) { return; } const next = validate(); if (Object.keys(next).length > 0) { setErrors(next); shakeFor(next); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); return; } setErrors({}); if (isSignup) { await performRegister(); } else { await performLogin(); } }; const routeAfterUnlock = (onboardingStep?: string | null) => { if (onboardingStep === "email_verification") { router.replace({ pathname: routes.verifyEmail, params: { email } } as Href); } else if (onboardingStep === "mfa_enrollment") { router.replace(routes.mfaEnroll); } else if (onboardingStep === "import") { router.replace(routes.importData); } else { router.replace(routes.home); } }; const handlePasskeyLogin = async () => { setPasskeyStep("passkey-loading"); setErrors({}); passkeyAbortRef.current?.abort(); const controller = new AbortController(); passkeyAbortRef.current = controller; try { const trimmed = email.trim().toLowerCase(); const result = await loginWithPasskey( trimmed.length > 0 ? trimmed : undefined, controller.signal ); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); if (result.unlocked) { setPasskeyStep("idle"); routeAfterUnlock(result.user.onboarding_step); return; } setPendingOnboardingStep(result.user.onboarding_step ?? null); setPasskeyStep("passkey-unlock"); } catch (error) { const name = error instanceof Error ? error.name : undefined; const benign = name === "NotAllowedError" || name === "AbortError"; if (!benign) { await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); setErrors({ password: error instanceof Error ? error.message : "Passkey sign-in failed" }); } setPasskeyStep("idle"); } }; const handleUnlockAfterPasskey = async () => { if (!unlockPassword) { setUnlockError("Master password is required"); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); return; } setIsLoading(true); try { await unlockWithPassword(unlockPassword); setUnlockPassword(""); setUnlockError(""); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); routeAfterUnlock(pendingOnboardingStep ?? "complete"); setPasskeyStep("idle"); } catch (error) { setUnlockError( error instanceof Error ? error.message : "Failed to unlock the vault. Check your password." ); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); } finally { setIsLoading(false); } }; const handleForgot = async () => { if (isLoading) { return; } if (!email || !validateEmail(email)) { setErrors({ email: "Please enter a valid email" }); emailShake.shake(); return; } setErrors({}); setIsLoading(true); try { await AuthService.forgotPassword(email.trim().toLowerCase()); setForgotSent(true); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } catch { // Don't leak account existence — always show the same confirmation. setForgotSent(true); } finally { setIsLoading(false); } }; const goAuth = () => { setStage("auth"); setForgotSent(false); setErrors({}); }; return ( <> {backdrop ? ( {backdrop} ) : null} {showAside ? ( // Sits outside the ScrollView on purpose: the pane is scenery and // should hold still while the form beside it scrolls. {aside} ) : null} {/* top bar: settings gear only — back lives inside the cards (thumb-reachable) so users don't stretch to the corner. */} {settings || onSettingsPress ? ( { if (onSettingsPress) { onSettingsPress(); return; } setSettingsOpen(true); }} accessibilityLabel="Connection settings" accessibilityRole="button" > ) : ( )} {/* One morph view driven by `morph`: dematerialize → swap layout+content at zero opacity → materialize. Welcome is centered; the form is top-anchored. Because the swap happens while invisible, the position change is never seen (no teleport) and the two stages never overlap. */} {/* On the wide layout the lockup is constrained to the same 360px column as the title and the card and left-aligned with them, so all three share one left edge. Centred, it sat at its own width and read as indented against the form below it. Narrow keeps it centred, where there is no column to align to. */} {logoIsWordmark ? ( // The mark carries the name; height is left to the // component so a wide wordmark keeps its aspect ratio, and // the ink colour comes from the already-resolved theme. ) : lockupBrand ? ( // Lockup: mark, vertical rule, then the name over the // tagline. The mark and the rule are both sized to the // MEASURED height of that text block, so all three read as // one unit at whatever type scale the app (or the system // font size) ends up at. `brandTextHeight || 64` only // covers the single frame before the first measurement. {appName} {tagline ? ( {tagline} ) : null} ) : ( {appName} {/* Inline tagline sits on the name's baseline, at body size — matching the 4xl heading would make the category label compete with the brand. */} {tagline && inlineTagline ? ( {`| ${tagline}`} ) : null} )} {tagline && !inlineTagline && !lockupBrand ? ( {/* The rule is only drawn for the "ruled" layout. Width tracks the row rather than being a fixed px value, so it lines up under the logo + name pairing at whatever size the app renders them. */} {ruledTagline ? ( ) : null} {tagline} ) : null} {onWelcome ? ( {decoration ? ( {decoration} ) : null} ) : ( {/* Positioned wrapper so the ambient layer can sit behind the card and bleed past its edges. Width tracks the card's own max so the glow is centred on the form, not the viewport. */} {/* No mode heading above the card. The Sign In / Sign Up toggle at the top of the card already says which mode this is, and a title restating it directly beneath the brand lockup made three stacked pieces of naming before the first field. The forgot-password card keeps its own header title, which is doing real work. */} {cardBackdrop && !showAside ? ( {cardBackdrop} ) : null} {/* ── WHOSE login this is, ABOVE the card ────────────── Without it the card is a pair of unlabelled fields under a Sign In / Sign Up toggle: fine when the user arrived from the app they are signing into, actively confusing when an OIDC redirect dropped them here from somewhere else -- the case an identity provider is FOR. Above the card rather than in its header, which is where this started. In the header it sat directly over the mode toggle and read as a label for the selected mode ("Log in to Entropy" above a Sign In / Sign Up switch restates the switch). Out here it is unambiguously the page's own heading, the card below it is purely the form, and the brand backdrop has something to sit behind. Width is pinned to the card's 360px so the two are one column, and the text is centred to match. */} {title ? ( {title} {subtitle ? ( {subtitle} ) : null} ) : null} {displayStage === "forgot" ? ( <> Reset password {forgotSent ? ( If an account exists for that email, we've sent a link to reset your password. Check your inbox. ) : ( <> Enter your email and we'll send you a link to reset your password. { setEmail(text); clearField("email"); }} autoFocus placeholder="you@example.com" autoComplete="email" invalid={!!errors.email} returnKeyType="done" onSubmitEditing={handleForgot} accessibilityLabel="Email address" /> )} {forgotSent ? ( ) : ( <> ← Back )} ) : ( <> {/* `gap-5` under the toggle, not `gap-3`. The toggle is the mode switch and the title restates the mode, so they were reading as one crowded block; a wider gap separates the control from the heading it drives and takes some of the slack that otherwise all collects above the footer. Applies to both modes — the header is shared. */} {/* The heading moved OUT of the card -- see the block above . Inside here it read as a label for whichever toggle mode was selected; above the card it reads as the page saying whose login this is, which is what it is for. */} {signUpEnabled ? ( value={mode} onChange={switchMode} options={[ { key: "signin", label: "Sign In" }, { key: "signup", label: "Sign Up" }, ]} /> ) : null} {signedUp ? ( {signedUp} ) : null} {/* Height is locked to the tallest of the two modes (see the form-box note above), so the layout transition only ever eases fields in and out — the card itself no longer moves. Width is pinned explicitly so the layout animation has nothing to interpolate horizontally either. */} {/* `flex: 1` with the default top alignment, so the fields keep their natural rhythm and ALL the locked height's slack collects in one place — between the last field and the footer. That pins the submit button, the OR divider and the social row to the same y in both modes, so they hold still while toggling instead of jumping up and down by the height of two fields. Note this is not the earlier attempt: spreading the slack BETWEEN the rows (`space-between`) looked worse than the gap it removed. Same total space, collected instead of distributed. */} {!showSignup && passkeyEnabled ? ( ) : null} {/* No autoFocus on this one. Arriving here popped the keyboard and put the cursor in Email, which presumes the visitor came to sign in — and the Sign up half of the toggle directly above it is the other half of that question. Let them answer it. The forgot-password panel keeps its autoFocus: that is a single field somebody opened on purpose. */} { setEmail(text); clearField("email"); }} placeholder="you@example.com" autoComplete="email" invalid={!!errors.email} accessibilityLabel="Email address" testID="auth-email-input" /> {showSignup ? ( { setDisplayName(text); clearField("displayName"); }} placeholder="Your display name" leftIcon={ } autoCapitalize="words" autoCorrect={false} maxLength={100} invalid={!!errors.displayName} accessibilityLabel="Display name" testID="auth-displayname-input" /> ) : null} { setPassword(text); clearField("password"); if (isSignup) { checkPasswordDebounced(text); } }} placeholder={passwordPlaceholder} autoComplete={isSignup ? "password-new" : "password"} invalid={!!errors.password} returnKeyType={isSignup ? "next" : "done"} onSubmitEditing={isSignup ? undefined : handleSubmit} accessibilityLabel={passwordLabel} testID="auth-password-input" /> {/* Rendered only when it has something to show. An empty wrapper still consumes one of CardContent's `gap-3` slots, which put 12px more space above Confirm Password than between any other pair of fields — visible as a stray gap. `probeSignup` keeps it in the measuring pass so the locked height still accounts for the meter. */} {showSignup && (probeSignup || password.length > 0 || pwnedInfo) ? ( {/* `reserveSpace` only during the probe. Keeping it on permanently held an empty 28px block between Password and Confirm Password, which reads as a stray gap. Reserving it just for the measurement means the locked height still accounts for the meter, so it can appear later without the card growing. */} ) : null} {showSignup ? ( { setConfirmPassword(text); clearField("confirmPassword"); }} placeholder="Re-enter your password" autoComplete="password-new" returnKeyType="done" onSubmitEditing={handleSubmit} invalid={!!errors.confirmPassword} accessibilityLabel="Confirm password" testID="auth-confirm-password-input" /> {requireInviteCode ? ( { setInviteCode(text); clearField("inviteCode"); }} placeholder="Paste your invite code" autoCapitalize="none" autoCorrect={false} returnKeyType="done" onSubmitEditing={handleSubmit} invalid={!!errors.inviteCode} accessibilityLabel="Invite code" testID="auth-invite-code-input" /> ) : null} ) : !forgotPasswordEnabled ? null : ( { setStage("forgot"); setForgotSent(false); setErrors({}); })} accessibilityRole="link" accessibilityLabel="Forgot password" > Forgot password? )} {socialAuth ? ( <> {socialAuth} ) : null} {/* In-card back — thumb-reachable, so users don't have to stretch to the top-left corner arrow. Hidden when there is no welcome stage behind it. */} setStage("welcome") )} accessibilityRole="button" accessibilityLabel="Back" > ← Back )} )} {/* Legal footer — Terms/Privacy as popups, no routes. Welcome stage ONLY. Pressing "Get Started" and proceeding IS the acknowledgement, so the line belongs beside that button and nowhere else; repeating it under the sign-in and sign-up forms restates a consent the user already gave and crowds two screens that are busier than the welcome. When there is no welcome stage at all (`showWelcome={false}`) it stays on the form. Note there is deliberately NO "but show it on the form if there is no welcome stage" fallback: `showWelcome` is false on every launch after the first, so such a fallback puts the line back under the form permanently — which is exactly the bug it was written to avoid. */} By continuing, you agree to our{" "} setLegal("terms"))} accessibilityRole="link" accessibilityLabel="Terms of Service" > Terms of Service {" "} and{" "} setLegal("privacy"))} accessibilityRole="link" accessibilityLabel="Privacy Policy" > Privacy Policy {footer && !showAside ? {footer} : null} {/* Wide only. Screen-level and absolutely placed, so it centres on the PAGE rather than under the form column, and drawn after the panes so it sits above them. `pointerEvents="box-none"` keeps it from intercepting a tap meant for anything behind it. On a narrow layout it is rendered IN FLOW instead (below): the card reaches the bottom of a phone screen, and an absolute footer simply lands on top of the card's own back link. */} {footer && showAside ? ( {footer} ) : null} {/* Passkey unlock dialog — non-PRF authenticators. */} { setPasskeyStep("idle"); setUnlockPassword(""); setUnlockError(""); }} draggable dismissOnBackdrop={false} > {passkeyUnlockTitle} {passkeyUnlockDescription} { setUnlockPassword(text); if (unlockError) { setUnlockError(""); } }} placeholder={passkeyUnlockPlaceholder} error={unlockError} autoComplete="password" returnKeyType="done" onSubmitEditing={handleUnlockAfterPasskey} accessibilityLabel="Master password" testID="login-passkey-unlock-input" /> {} : handleUnlockAfterPasskey )} accessibilityLabel="Unlock" accessibilityRole="button" > {isLoading ? "Unlocking…" : "Unlock"} {/* Folded-in connection/server settings dialog. */} {settings?.({ visible: settingsOpen, onClose: () => setSettingsOpen(false) })} {/* Terms / Privacy popups. */} setLegal(null)} title="Terms of Service" content={termsText} /> setLegal(null)} title="Privacy Policy" content={privacyText} /> ); } export default AuthScreen;