Files
frontend/src/screens/profile/AccountSecurityScreen.tsx
lafay accf7c04e8
Some checks failed
Frontend CI / build-android-apk (push) Failing after 8m40s
Frontend CI / ota-android (push) Successful in 12m13s
Frontend CI / build-and-push-web (push) Successful in 2m47s
feat(auth): enhance password policy with stronger requirements
Update password validation across authentication screens to require minimum 8 characters with uppercase letters, lowercase letters, and numbers. Changes applied to ForgotPasswordScreen, RegisterStep3Profile, and AccountSecurityScreen. LoginScreen password validation was removed as it occurs post-authentication.
2026-04-07 00:12:03 +08:00

518 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useState } from 'react';
import {
View,
StyleSheet,
TextInput,
TouchableOpacity,
ActivityIndicator,
ScrollView,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import { Text } from '../../components/common';
import { useResponsive, useResponsiveSpacing } from '../../hooks';
// 内容最大宽度
const CONTENT_MAX_WIDTH = 720;
// 胡萝卜橙主题色
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
import { authService, resolveAuthApiError } from '../../services/authService';
import { showPrompt } from '../../services/promptService';
import { useAuthStore } from '../../stores';
export const AccountSecurityScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createAccountSecurityStyles(colors), [colors]);
const { isWideScreen, isMobile } = useResponsive();
const insets = useSafeAreaInsets();
const currentUser = useAuthStore((state) => state.currentUser);
const fetchCurrentUser = useAuthStore((state) => state.fetchCurrentUser);
// 底部间距,避免被 TabBar 遮挡
const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md;
const [email, setEmail] = useState(currentUser?.email || '');
const [verificationCode, setVerificationCode] = useState('');
const [sendingCode, setSendingCode] = useState(false);
const [verifyingEmail, setVerifyingEmail] = useState(false);
const [countdown, setCountdown] = useState(0);
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [changePasswordCode, setChangePasswordCode] = useState('');
const [sendingChangePwdCode, setSendingChangePwdCode] = useState(false);
const [changePwdCountdown, setChangePwdCountdown] = useState(0);
const [updatingPassword, setUpdatingPassword] = useState(false);
useEffect(() => {
if (currentUser?.email) {
setEmail(currentUser.email);
}
}, [currentUser?.email]);
useEffect(() => {
if (countdown <= 0) {
return;
}
const timer = setInterval(() => {
setCountdown((prev) => (prev <= 1 ? 0 : prev - 1));
}, 1000);
return () => clearInterval(timer);
}, [countdown]);
useEffect(() => {
if (changePwdCountdown <= 0) {
return;
}
const timer = setInterval(() => {
setChangePwdCountdown((prev) => (prev <= 1 ? 0 : prev - 1));
}, 1000);
return () => clearInterval(timer);
}, [changePwdCountdown]);
const isEmailVerified = !!currentUser?.email_verified;
const emailStatusText = useMemo(() => {
if (!currentUser?.email) {
return '未绑定邮箱';
}
return isEmailVerified ? '已验证' : '未验证';
}, [currentUser?.email, isEmailVerified]);
const validateEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
const handleSendCode = async () => {
const targetEmail = email.trim();
if (!validateEmail(targetEmail)) {
showPrompt({ title: '提示', message: '请输入正确的邮箱地址', type: 'warning' });
return;
}
if (countdown > 0) {
return;
}
setSendingCode(true);
try {
const ok = await authService.sendCurrentUserEmailVerifyCode(targetEmail);
if (ok) {
setCountdown(60);
showPrompt({ title: '发送成功', message: '验证码已发送,请查收邮箱', type: 'success' });
}
} catch (error: any) {
showPrompt({ title: '发送失败', message: resolveAuthApiError(error, '验证码发送失败'), type: 'error' });
} finally {
setSendingCode(false);
}
};
const handleVerifyEmail = async () => {
const targetEmail = email.trim();
if (!validateEmail(targetEmail)) {
showPrompt({ title: '提示', message: '请输入正确的邮箱地址', type: 'warning' });
return;
}
if (!verificationCode.trim()) {
showPrompt({ title: '提示', message: '请输入验证码', type: 'warning' });
return;
}
setVerifyingEmail(true);
try {
const ok = await authService.verifyCurrentUserEmail({
email: targetEmail,
verification_code: verificationCode.trim(),
});
if (ok) {
setVerificationCode('');
await fetchCurrentUser();
showPrompt({ title: '验证成功', message: '邮箱已完成验证', type: 'success' });
}
} catch (error: any) {
showPrompt({ title: '验证失败', message: resolveAuthApiError(error, '邮箱验证失败'), type: 'error' });
} finally {
setVerifyingEmail(false);
}
};
const handleChangePassword = async () => {
if (!oldPassword) {
showPrompt({ title: '提示', message: '请输入当前密码', type: 'warning' });
return;
}
if (!changePasswordCode.trim()) {
showPrompt({ title: '提示', message: '请输入邮箱验证码', type: 'warning' });
return;
}
if (newPassword.length < 8) {
showPrompt({ title: '提示', message: '新密码至少 8 位', type: 'warning' });
return;
}
if (!/[A-Z]/.test(newPassword)) {
showPrompt({ title: '提示', message: '新密码必须包含大写字母', type: 'warning' });
return;
}
if (!/[a-z]/.test(newPassword)) {
showPrompt({ title: '提示', message: '新密码必须包含小写字母', type: 'warning' });
return;
}
if (!/[0-9]/.test(newPassword)) {
showPrompt({ title: '提示', message: '新密码必须包含数字', type: 'warning' });
return;
}
if (newPassword !== confirmPassword) {
showPrompt({ title: '提示', message: '两次输入的新密码不一致', type: 'warning' });
return;
}
setUpdatingPassword(true);
try {
const ok = await authService.changePassword(oldPassword, newPassword, changePasswordCode.trim());
if (ok) {
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
setChangePasswordCode('');
showPrompt({ title: '修改成功', message: '密码已更新', type: 'success' });
} else {
showPrompt({ title: '修改失败', message: '请检查当前密码后重试', type: 'error' });
}
} catch (error: any) {
showPrompt({ title: '修改失败', message: resolveAuthApiError(error, '请稍后重试'), type: 'error' });
} finally {
setUpdatingPassword(false);
}
};
const handleSendChangePasswordCode = async () => {
if (changePwdCountdown > 0) {
return;
}
setSendingChangePwdCode(true);
try {
const ok = await authService.sendChangePasswordCode();
if (ok) {
setChangePwdCountdown(60);
showPrompt({ title: '发送成功', message: '改密验证码已发送到你的邮箱', type: 'success' });
}
} catch (error: any) {
showPrompt({ title: '发送失败', message: resolveAuthApiError(error, '验证码发送失败'), type: 'error' });
} finally {
setSendingChangePwdCode(false);
}
};
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
<View style={styles.content}>
{/* 邮箱验证分组 */}
<View style={styles.groupHeader}>
<Text variant="caption" style={styles.groupTitle}>
</Text>
</View>
<View style={styles.card}>
{/* 状态显示 */}
<View style={styles.statusRow}>
<Text style={styles.statusLabel}></Text>
<View style={[styles.statusBadge, isEmailVerified ? styles.statusVerified : styles.statusUnverified]}>
<Text style={[styles.statusText, isEmailVerified ? styles.statusVerifiedText : styles.statusUnverifiedText]}>
{emailStatusText}
</Text>
</View>
</View>
{/* 邮箱输入框 */}
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="email-outline" size={20} color={colors.text.secondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="请输入邮箱"
placeholderTextColor={colors.text.hint}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
</View>
{/* 验证码行 */}
<View style={styles.codeRow}>
<View style={[styles.inputWrapper, styles.codeInput]}>
<MaterialCommunityIcons name="shield-key-outline" size={20} color={colors.text.secondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="验证码"
placeholderTextColor={colors.text.hint}
value={verificationCode}
onChangeText={setVerificationCode}
keyboardType="number-pad"
maxLength={6}
/>
</View>
<TouchableOpacity
style={[styles.sendCodeButton, (sendingCode || countdown > 0) && styles.buttonDisabled]}
onPress={handleSendCode}
disabled={sendingCode || countdown > 0}
>
{sendingCode ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.sendCodeButtonText}>{countdown > 0 ? `${countdown}s` : '发送验证码'}</Text>
)}
</TouchableOpacity>
</View>
{/* 验证按钮 */}
<TouchableOpacity
style={[styles.primaryButton, verifyingEmail && styles.buttonDisabled]}
onPress={handleVerifyEmail}
disabled={verifyingEmail}
>
{verifyingEmail ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.primaryButtonText}></Text>
)}
</TouchableOpacity>
</View>
{/* 修改密码分组 */}
<View style={styles.groupHeader}>
<Text variant="caption" style={styles.groupTitle}>
</Text>
</View>
<View style={styles.card}>
{/* 当前密码 */}
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="lock-outline" size={20} color={colors.text.secondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="当前密码"
placeholderTextColor={colors.text.hint}
value={oldPassword}
onChangeText={setOldPassword}
secureTextEntry
/>
</View>
{/* 新密码 */}
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="lock-plus-outline" size={20} color={colors.text.secondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="新密码至少8位含大小写字母和数字"
placeholderTextColor={colors.text.hint}
value={newPassword}
onChangeText={setNewPassword}
secureTextEntry
/>
</View>
{/* 确认新密码 */}
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="lock-check-outline" size={20} color={colors.text.secondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="确认新密码"
placeholderTextColor={colors.text.hint}
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
/>
</View>
{/* 验证码行 */}
<View style={styles.codeRow}>
<View style={[styles.inputWrapper, styles.codeInput]}>
<MaterialCommunityIcons name="shield-key-outline" size={20} color={colors.text.secondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="邮箱验证码"
placeholderTextColor={colors.text.hint}
value={changePasswordCode}
onChangeText={setChangePasswordCode}
keyboardType="number-pad"
maxLength={6}
/>
</View>
<TouchableOpacity
style={[styles.sendCodeButton, (sendingChangePwdCode || changePwdCountdown > 0) && styles.buttonDisabled]}
onPress={handleSendChangePasswordCode}
disabled={sendingChangePwdCode || changePwdCountdown > 0}
>
{sendingChangePwdCode ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.sendCodeButtonText}>
{changePwdCountdown > 0 ? `${changePwdCountdown}s` : '发送验证码'}
</Text>
)}
</TouchableOpacity>
</View>
{/* 更新密码按钮 */}
<TouchableOpacity
style={[styles.primaryButton, updatingPassword && styles.buttonDisabled]}
onPress={handleChangePassword}
disabled={updatingPassword}
>
{updatingPassword ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.primaryButtonText}></Text>
)}
</TouchableOpacity>
</View>
</View>
</ScrollView>
</SafeAreaView>
);
};
function createAccountSecurityStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
scrollContent: {
paddingVertical: spacing.lg,
},
content: {
maxWidth: CONTENT_MAX_WIDTH,
alignSelf: 'center',
width: '100%',
},
// 分组标题
groupHeader: {
paddingHorizontal: spacing['2xl'],
marginBottom: spacing.sm,
marginTop: spacing.sm,
},
groupTitle: {
fontWeight: '600',
fontSize: fontSizes.sm,
color: colors.text.secondary,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
// 卡片样式 - 统一
card: {
backgroundColor: '#F5F5F7',
borderRadius: 16,
padding: 6,
marginBottom: spacing['2xl'],
marginHorizontal: spacing['2xl'],
},
// 状态显示
statusRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
marginBottom: spacing.md,
backgroundColor: '#fff',
borderRadius: 12,
},
statusLabel: {
fontSize: 15,
color: colors.text.secondary,
},
statusBadge: {
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: borderRadius.sm,
},
statusVerified: {
backgroundColor: '#E8F5E9',
},
statusUnverified: {
backgroundColor: '#FFF3E0',
},
statusText: {
fontSize: 13,
fontWeight: '600',
},
statusVerifiedText: {
color: '#2E7D32',
},
statusUnverifiedText: {
color: '#E65100',
},
// 输入框样式 - 统一
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fff',
borderRadius: 14,
paddingHorizontal: spacing.md,
height: 56,
marginBottom: spacing.sm,
},
inputIcon: {
marginRight: spacing.sm,
},
input: {
flex: 1,
color: colors.text.primary,
fontSize: fontSizes.md,
height: 56,
},
// 验证码行
codeRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.sm,
},
codeInput: {
flex: 1,
marginBottom: 0,
},
// 发送验证码按钮
sendCodeButton: {
height: 56,
minWidth: 110,
borderRadius: 14,
backgroundColor: THEME_COLORS.primary,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.sm,
},
sendCodeButtonText: {
color: '#fff',
fontSize: fontSizes.sm,
fontWeight: '600',
},
// 主按钮样式 - 统一
primaryButton: {
height: 56,
borderRadius: 14,
backgroundColor: THEME_COLORS.primary,
alignItems: 'center',
justifyContent: 'center',
marginTop: spacing.xs,
},
primaryButtonText: {
color: '#fff',
fontSize: fontSizes.md,
fontWeight: '600',
},
buttonDisabled: {
opacity: 0.6,
},
});
}
export default AccountSecurityScreen;