474 lines
16 KiB
TypeScript
474 lines
16 KiB
TypeScript
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;
|
||
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 < 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={colors.text.inverse} />
|
||
) : (
|
||
<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={colors.text.inverse} />
|
||
) : (
|
||
<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={colors.text.inverse} />
|
||
) : (
|
||
<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={colors.text.inverse} />
|
||
) : (
|
||
<Text style={styles.primaryButtonText}>更新密码</Text>
|
||
)}
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
</>
|
||
);
|
||
|
||
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 }]}>
|
||
{content}
|
||
</ScrollView>
|
||
</SafeAreaView>
|
||
);
|
||
};
|
||
|
||
function createAccountSecurityStyles(colors: AppColors) {
|
||
return 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,
|
||
maxWidth: CONTENT_MAX_WIDTH,
|
||
alignSelf: 'center',
|
||
width: '100%',
|
||
},
|
||
statusRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
marginBottom: spacing.md,
|
||
},
|
||
statusBadge: {
|
||
paddingHorizontal: spacing.sm,
|
||
paddingVertical: 4,
|
||
borderRadius: borderRadius.sm,
|
||
},
|
||
statusVerified: {
|
||
backgroundColor: colors.success.light + '40',
|
||
},
|
||
statusUnverified: {
|
||
backgroundColor: colors.warning.light + '40',
|
||
},
|
||
statusVerifiedText: {
|
||
color: colors.success.dark,
|
||
},
|
||
statusUnverifiedText: {
|
||
color: colors.warning.dark,
|
||
},
|
||
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: colors.text.inverse,
|
||
fontSize: fontSizes.sm,
|
||
fontWeight: '600',
|
||
},
|
||
primaryButton: {
|
||
height: 48,
|
||
borderRadius: borderRadius.lg,
|
||
backgroundColor: colors.primary.main,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginTop: spacing.xs,
|
||
},
|
||
primaryButtonText: {
|
||
color: colors.text.inverse,
|
||
fontSize: fontSizes.md,
|
||
fontWeight: '700',
|
||
},
|
||
buttonDisabled: {
|
||
opacity: 0.6,
|
||
},
|
||
});
|
||
}
|
||
|
||
export default AccountSecurityScreen;
|