import { withClickTracking } from "../../services/telemetry"; import React, { useEffect, useMemo, useState } from "react"; import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { useNavigation } from "../../context/NavigationContext"; 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 { Checkbox } from "../../components/checkbox"; import { Separator, SeparatorWithText } from "../../components/separator"; 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 { validateEmail } from "../../utils/validation"; import { checkPasswordPwned, debounce, getPwnedMessage } from "../../services/hibp"; import { getCachedPolicy } from "../../services/hibp-cache"; import { useAppConfig } from "../../context/AppConfigContext"; const SCROLL_CONTENT_STYLE = { flexGrow: 1 } as const; const DEFAULT_POLICY = { enabled: false, enforce: false, block_threshold: 0 }; export interface RegisterScreenProps { /** Optional widget rendered top-right (vault wires its admin cog here). */ topAccessory?: React.ReactNode; /** Optional social-auth block rendered under the create-account button. */ socialAuth?: React.ReactNode; /** Master-password field label. */ passwordLabel?: string; /** Master-password field placeholder. */ passwordPlaceholder?: string; } export function RegisterScreen({ topAccessory, socialAuth, passwordLabel = "Password", passwordPlaceholder = "Create a strong password", }: RegisterScreenProps = {}) { const { register: authRegister } = useAuth(); const router = useNavigation(); const { appName, Logo, routes } = useAppConfig(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [isLoading, setIsLoading] = useState(false); const [agreeToTerms, setAgreeToTerms] = useState(false); const [displayName, setDisplayName] = useState(""); const [errors, setErrors] = useState<{ email?: string; displayName?: string; password?: string; confirmPassword?: string; terms?: string; }>({}); const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; const [pwnedPasswordInfo, setPwnedPasswordInfo] = useState<{ title: string; message: string; severity: "error" | "warning" | "info"; count: number; } | null>(null); const emailShake = useShakeAnimation(); const displayNameShake = useShakeAnimation(); const passwordShake = useShakeAnimation(); const confirmShake = useShakeAnimation(); const termsShake = useShakeAnimation(); const firstError = errors.email ?? errors.displayName ?? errors.password ?? errors.confirmPassword ?? errors.terms; const checkPasswordDebounced = useMemo( () => debounce(async (pass: string) => { const policy = getCachedPolicy() ?? DEFAULT_POLICY; if (!policy.enabled || pass.length < 8) { setPwnedPasswordInfo(null); return; } const result = await checkPasswordPwned(pass); if (result.isPwned) { const message = getPwnedMessage(result); setPwnedPasswordInfo({ ...message, count: result.count }); } else { setPwnedPasswordInfo(null); } }, 1000), [] ); useEffect(() => () => checkPasswordDebounced.cancel(), [checkPasswordDebounced]); const validateForm = () => { const newErrors: typeof errors = {}; if (!email) { newErrors.email = "Email is required"; } else if (!validateEmail(email)) { newErrors.email = "Please enter a valid email"; } if (!displayName) { newErrors.displayName = "Display name is required"; } else if (displayName.length > 100) { newErrors.displayName = "Display name must be 100 characters or less"; } if (!password) { newErrors.password = "Password is required"; } else if (password.length < 8) { newErrors.password = "Password must be at least 8 characters"; } if (!confirmPassword) { newErrors.confirmPassword = "Please confirm your password"; } else if (password !== confirmPassword) { newErrors.confirmPassword = "Passwords do not match"; } if (!agreeToTerms) { newErrors.terms = "You must agree to the terms and privacy policy"; } return newErrors; }; const handleRegister = async () => { // Re-entry guard. The HIBP probe and register POST both run // network round-trips; a double-submit before `setIsLoading(true)` // lands would otherwise duplicate-fire the register endpoint // (and depending on idempotency could create a second account // attempt with the same email). if (isLoading) { return; } const newErrors = validateForm(); if (Object.keys(newErrors).length > 0) { setErrors(newErrors); if (newErrors.email) { emailShake.shake(); } if (newErrors.displayName) { displayNameShake.shake(); } if (newErrors.password) { passwordShake.shake(); } if (newErrors.confirmPassword) { confirmShake.shake(); } if (newErrors.terms) { termsShake.shake(); } await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); return; } setErrors({}); setIsLoading(true); const hibpPolicy = getCachedPolicy() ?? DEFAULT_POLICY; if (hibpPolicy.enabled) { const pwnedResult = await checkPasswordPwned(password); if (pwnedResult.isPwned) { const message = getPwnedMessage(pwnedResult); setPwnedPasswordInfo({ ...message, count: pwnedResult.count }); if (hibpPolicy.enforce && pwnedResult.count > hibpPolicy.block_threshold) { await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); passwordShake.shake(); confirmShake.shake(); setIsLoading(false); return; } } else { setPwnedPasswordInfo(null); } } try { await authRegister(email.trim().toLowerCase(), password, displayName.trim()); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } catch (error) { await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); const errorMessage = error instanceof Error ? error.message : "Registration failed"; if (errorMessage.includes("email") && errorMessage.includes("exists")) { setErrors({ email: "This email is already registered" }); emailShake.shake(); } else if (errorMessage.includes("password")) { setErrors({ password: errorMessage }); passwordShake.shake(); } else { setErrors({ email: errorMessage }); emailShake.shake(); } } finally { setIsLoading(false); } }; return ( router.push(routes.welcome)} accessibilityLabel="Go back" accessibilityRole="button" > {topAccessory} {appName} Create Account { setEmail(text); if (errors.email) { setErrors({ ...errors, email: undefined }); } }} autoFocus placeholder="you@example.com" autoComplete="email" invalid={!!errors.email} accessibilityLabel="Email address" testID="signup-email-input" /> { setDisplayName(text); if (errors.displayName) { setErrors({ ...errors, displayName: undefined }); } }} placeholder="Your display name" leftIcon={ } autoCapitalize="words" autoCorrect={false} maxLength={100} invalid={!!errors.displayName} accessibilityLabel="Display name" testID="signup-displayname-input" /> { setPassword(text); if (errors.password) { setErrors({ ...errors, password: undefined }); } checkPasswordDebounced(text); }} placeholder={passwordPlaceholder} autoComplete="password-new" invalid={!!errors.password} accessibilityLabel={passwordLabel} testID="signup-password-input" /> { setConfirmPassword(text); if (errors.confirmPassword) { setErrors({ ...errors, confirmPassword: undefined }); } }} placeholder="Re-enter your password" autoComplete="password-new" returnKeyType="done" onSubmitEditing={handleRegister} invalid={!!errors.confirmPassword} accessibilityLabel="Confirm password" testID="signup-confirm-password-input" /> { setAgreeToTerms(!!checked); if (errors.terms) { setErrors({ ...errors, terms: undefined }); } }} accessibilityLabel="Agree to terms of service and privacy policy" accessibilityRole="checkbox" /> I agree to the{" "} router.push(routes.termsOfService) )} accessibilityRole="link" accessibilityLabel="Terms of Service" > Terms of Service {" "} and{" "} router.push(routes.privacyPolicy) )} accessibilityRole="link" accessibilityLabel="Privacy Policy" > Privacy Policy Already have an account?{" "} router.push(routes.login) )} accessibilityRole="link" accessibilityLabel="Sign in" > Sign In {socialAuth ? ( <> {socialAuth} ) : null} ); } export default RegisterScreen;