Initial frontend repository commit.
Include app source and update .gitignore to exclude local release artifacts and signing files. Made-with: Cursor
This commit is contained in:
447
src/screens/profile/AccountSecurityScreen.tsx
Normal file
447
src/screens/profile/AccountSecurityScreen.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { Text, ResponsiveContainer } from '../../components/common';
|
||||
import { useResponsive } from '../../hooks';
|
||||
import { authService, resolveAuthApiError } from '../../services/authService';
|
||||
import { showPrompt } from '../../services/promptService';
|
||||
import { useAuthStore } from '../../stores';
|
||||
|
||||
export const AccountSecurityScreen: React.FC = () => {
|
||||
const { isWideScreen } = useResponsive();
|
||||
const currentUser = useAuthStore((state) => state.currentUser);
|
||||
const fetchCurrentUser = useAuthStore((state) => state.fetchCurrentUser);
|
||||
|
||||
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 < 6) {
|
||||
showPrompt({ title: '提示', message: '新密码至少 6 位', 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 content = (
|
||||
<>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialCommunityIcons name="email-check-outline" size={18} color={colors.primary.main} />
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||||
邮箱验证
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.statusRow}>
|
||||
<Text variant="body" color={colors.text.primary}>当前状态</Text>
|
||||
<View style={[styles.statusBadge, isEmailVerified ? styles.statusVerified : styles.statusUnverified]}>
|
||||
<Text variant="caption" color={isEmailVerified ? '#1B5E20' : '#B26A00'}>
|
||||
{emailStatusText}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputWrapper}>
|
||||
<MaterialCommunityIcons name="email-outline" size={20} color={colors.primary.main} 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.primary.main} 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>
|
||||
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialCommunityIcons name="lock-reset" size={18} color={colors.primary.main} />
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||||
修改密码
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.inputWrapper}>
|
||||
<MaterialCommunityIcons name="lock-outline" size={20} color={colors.primary.main} 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.primary.main} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="新密码(至少6位)"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={newPassword}
|
||||
onChangeText={setNewPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.codeRow}>
|
||||
<View style={[styles.inputWrapper, styles.codeInput]}>
|
||||
<MaterialCommunityIcons name="shield-key-outline" size={20} color={colors.primary.main} 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>
|
||||
<View style={styles.inputWrapper}>
|
||||
<MaterialCommunityIcons name="lock-check-outline" size={20} color={colors.primary.main} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="确认新密码"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={800}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>{content}</ScrollView>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>{content}</ScrollView>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
section: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
marginBottom: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
marginHorizontal: spacing.lg,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
borderRadius: borderRadius.sm,
|
||||
},
|
||||
statusVerified: {
|
||||
backgroundColor: '#E8F5E9',
|
||||
},
|
||||
statusUnverified: {
|
||||
backgroundColor: '#FFF3E0',
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
paddingHorizontal: spacing.md,
|
||||
height: 48,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
color: colors.text.primary,
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
codeRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
codeInput: {
|
||||
flex: 1,
|
||||
marginBottom: 0,
|
||||
},
|
||||
sendCodeButton: {
|
||||
height: 48,
|
||||
minWidth: 110,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.primary.main,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
sendCodeButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: fontSizes.sm,
|
||||
fontWeight: '600',
|
||||
},
|
||||
primaryButton: {
|
||||
height: 48,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.primary.main,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '700',
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
});
|
||||
|
||||
export default AccountSecurityScreen;
|
||||
Reference in New Issue
Block a user