- 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()
195 lines
6.5 KiB
TypeScript
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' }}>✉</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>
|
|
);
|
|
}
|