chahinebrini 5434254f74 feat(auth,mail): pw-reset OTP-flow + custom mail templates + account-switch cleanup
- Phase 3 PW-Reset: 3 screens (forgot-password → reset-otp → new-password),
  verifyOtp({type:'recovery'}), new updatePassword() action
- Custom Brevo-Mail templates (backend/public/templates/) — 5 HTMLs with
  go-template i18n (de/en/fr/ar incl. RTL for AR), OTP-only (no link),
  ReBreak branding
- signUp metadata.data.locale aus i18n.language → templates resolven Sprache
- Account-Switch-Bug fix: signOut() resettet alle 10 user-spezifischen stores
  + invalidateMe()
2026-05-19 10:49:23 +02:00

195 lines
6.5 KiB
TypeScript

import { useState, useEffect, useRef } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
ActivityIndicator,
} from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../stores/auth';
import { KeyboardAwareScreen } from '../../components/KeyboardAwareScreen';
const OTP_LENGTH = 6;
const OTP_INPUT_STYLE = {
fontSize: 20,
fontFamily: 'Nunito_700Bold',
color: '#0a0a0a',
textAlign: 'center' as const,
width: 48,
height: 56,
borderRadius: 12,
};
export default function ResetOtpScreen() {
const router = useRouter();
const { t } = useTranslation();
const params = useLocalSearchParams<{ email: string }>();
const email = decodeURIComponent(params.email ?? '');
const { verifyOtp, resetPasswordForEmail } = useAuthStore();
const [digits, setDigits] = useState<string[]>(Array(OTP_LENGTH).fill(''));
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [resendCooldown, setResendCooldown] = useState(0);
const inputs = useRef<Array<TextInput | null>>([]);
useEffect(() => {
if (!email) {
router.replace('/(auth)/forgot-password');
}
}, [email]);
useEffect(() => {
if (resendCooldown <= 0) return;
const timer = setInterval(() => {
setResendCooldown((c) => {
if (c <= 1) { clearInterval(timer); return 0; }
return c - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [resendCooldown]);
const otp = digits.join('');
const handleDigit = (value: string, index: number) => {
if (value.length === OTP_LENGTH && /^\d+$/.test(value)) {
const next = value.split('');
setDigits(next);
inputs.current[OTP_LENGTH - 1]?.focus();
return;
}
const digit = value.replace(/\D/g, '').slice(-1);
const next = [...digits];
next[index] = digit;
setDigits(next);
if (digit && index < OTP_LENGTH - 1) {
inputs.current[index + 1]?.focus();
}
};
const handleKeyPress = (key: string, index: number) => {
if (key === 'Backspace' && !digits[index] && index > 0) {
inputs.current[index - 1]?.focus();
}
};
const verify = async () => {
if (otp.length < OTP_LENGTH || loading) return;
setError(null);
setLoading(true);
const res = await verifyOtp(email, otp, 'recovery');
setLoading(false);
if (res.error) {
setError(res.error);
setDigits(Array(OTP_LENGTH).fill(''));
inputs.current[0]?.focus();
return;
}
router.replace(`/(auth)/new-password?email=${encodeURIComponent(email)}`);
};
const resend = async () => {
if (resendCooldown > 0) return;
setError(null);
const res = await resetPasswordForEmail(email);
if (res.error) {
setError(res.error);
return;
}
setResendCooldown(60);
};
return (
<SafeAreaView className="flex-1 bg-white">
<KeyboardAwareScreen contentContainerStyle={{ paddingHorizontal: 24, justifyContent: 'center' }}>
<View className="items-center mb-8">
<View className="w-16 h-16 bg-rebreak-500/10 rounded-full items-center justify-center mb-4">
<Text className="text-3xl" style={{ fontFamily: 'Nunito_400Regular' }}>&#x2709;</Text>
</View>
<Text className="text-2xl text-neutral-900 text-center mb-2" style={{ fontFamily: 'Nunito_700Bold' }}>
{t('auth.resetOtpTitle')}
</Text>
<Text className="text-sm text-neutral-500 text-center leading-5" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('auth.resetOtpLine1')}{'\n'}
<Text className="text-neutral-900" style={{ fontFamily: 'Nunito_600SemiBold' }}>{email}</Text>
{'\n'}{t('auth.resetOtpLine2')}
</Text>
</View>
<View className="flex-row justify-center gap-3 mb-6">
{digits.map((digit, index) => (
<TextInput
key={index}
ref={(ref) => { inputs.current[index] = ref; }}
style={[
OTP_INPUT_STYLE,
{
backgroundColor: '#f5f5f5',
borderWidth: 2,
borderColor: digit ? '#f59e0b' : '#e5e5e5',
},
]}
value={digit}
onChangeText={(val) => handleDigit(val, index)}
onKeyPress={({ nativeEvent }) => handleKeyPress(nativeEvent.key, index)}
keyboardType="number-pad"
maxLength={OTP_LENGTH}
editable={!loading}
selectTextOnFocus
/>
))}
</View>
{error && (
<View className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 mb-4">
<Text className="text-red-600 text-sm text-center" style={{ fontFamily: 'Nunito_400Regular' }}>{error}</Text>
</View>
)}
<TouchableOpacity
onPress={verify}
disabled={otp.length < OTP_LENGTH || loading}
activeOpacity={0.8}
className="bg-rebreak-500 rounded-xl items-center mb-4 disabled:opacity-40"
style={{ paddingVertical: 16 }}
>
{loading ? (
<ActivityIndicator color="white" size="small" />
) : (
<Text className="text-white text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.resetOtpConfirmBtn')}</Text>
)}
</TouchableOpacity>
<TouchableOpacity
onPress={resend}
disabled={resendCooldown > 0 || loading}
activeOpacity={0.7}
className="py-3 items-center"
>
<Text className="text-neutral-500 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('auth.noCode')}{' '}
<Text className={resendCooldown > 0 ? 'text-neutral-400' : 'text-rebreak-500'} style={{ fontFamily: 'Nunito_600SemiBold' }}>
{resendCooldown > 0 ? t('auth.resendCooldown', { seconds: resendCooldown }) : t('auth.resetOtpResend')}
</Text>
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => router.push('/(auth)/forgot-password')}
activeOpacity={0.7}
className="py-3 items-center mt-2"
>
<Text className="text-neutral-400 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>{t('auth.resetOtpBackToForgot')}</Text>
</TouchableOpacity>
</KeyboardAwareScreen>
</SafeAreaView>
);
}