Files
smart-cropx-ui/src/app/(app)/central-config/personal-center/account-security/page.tsx

648 lines
23 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.

/**
* filekorolheader: 账户安全页面 - 用户账户安全设置管理
* 功能:密码修改、安全验证、账户信息展示、登录记录查看
* 路径:/central-config/personal-center/account-security
* 规范遵循crop-x-new/docs/开发项目规范.md使用useReducer状态管理集成真实用户数据
*/
'use client';
import { useReducer, useEffect } from 'react';
import { useAuthStore } from '@/stores/modules/auth';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Shield, Lock, Key, CheckCircle, XCircle, Eye, EyeOff, AlertTriangle } from 'lucide-react';
import { toast } from 'sonner';
// Types
interface PasswordForm {
oldPassword: string;
newPassword: string;
confirmPassword: string;
}
interface SecurityState {
user: {
username: string;
phone: string;
email: string;
lastLoginTime: string;
lastLoginDevice: string;
lastLoginIp: string;
};
showPasswordDialog: boolean;
isLoading: boolean;
showOldPassword: boolean;
showNewPassword: boolean;
showConfirmPassword: boolean;
passwordForm: PasswordForm;
passwordStrength: {
checks: {
length: boolean;
hasUpper: boolean;
hasLower: boolean;
hasNumber: boolean;
hasSpecial: boolean;
};
strength: 'weak' | 'medium' | 'strong';
passedCount: number;
};
}
type SecurityAction =
| { type: 'TOGGLE_PASSWORD_DIALOG'; payload: boolean }
| { type: 'TOGGLE_OLD_PASSWORD_VISIBILITY' }
| { type: 'TOGGLE_NEW_PASSWORD_VISIBILITY' }
| { type: 'TOGGLE_CONFIRM_PASSWORD_VISIBILITY' }
| { type: 'UPDATE_PASSWORD_FORM'; payload: Partial<PasswordForm> }
| { type: 'UPDATE_PASSWORD_STRENGTH'; payload: SecurityState['passwordStrength'] }
| { type: 'UPDATE_USER_DATA'; payload: SecurityState['user'] }
| { type: 'SET_LOADING'; payload: boolean };
// Initial state
const initialState: SecurityState = {
user: {
username: '',
phone: '',
email: '',
lastLoginTime: '',
lastLoginDevice: '',
lastLoginIp: ''
},
showPasswordDialog: false,
isLoading: true,
showOldPassword: false,
showNewPassword: false,
showConfirmPassword: false,
passwordForm: {
oldPassword: '',
newPassword: '',
confirmPassword: ''
},
passwordStrength: {
checks: {
length: false,
hasUpper: false,
hasLower: false,
hasNumber: false,
hasSpecial: false
},
strength: 'weak',
passedCount: 0
}
};
// Reducer
function securityReducer(state: SecurityState, action: SecurityAction): SecurityState {
switch (action.type) {
case 'TOGGLE_PASSWORD_DIALOG':
return {
...state,
showPasswordDialog: action.payload,
...(action.payload === false ? {
passwordForm: {
oldPassword: '',
newPassword: '',
confirmPassword: ''
},
passwordStrength: {
checks: {
length: false,
hasUpper: false,
hasLower: false,
hasNumber: false,
hasSpecial: false
},
strength: 'weak' as const,
passedCount: 0
}
} : {})
};
case 'TOGGLE_OLD_PASSWORD_VISIBILITY':
return { ...state, showOldPassword: !state.showOldPassword };
case 'TOGGLE_NEW_PASSWORD_VISIBILITY':
return { ...state, showNewPassword: !state.showNewPassword };
case 'TOGGLE_CONFIRM_PASSWORD_VISIBILITY':
return { ...state, showConfirmPassword: !state.showConfirmPassword };
case 'UPDATE_PASSWORD_FORM':
return {
...state,
passwordForm: { ...state.passwordForm, ...action.payload }
};
case 'UPDATE_PASSWORD_STRENGTH':
return {
...state,
passwordStrength: action.payload
};
case 'UPDATE_USER_DATA':
return {
...state,
user: action.payload
};
case 'SET_LOADING':
return {
...state,
isLoading: action.payload
};
default:
return state;
}
}
// Password strength checker
const checkPasswordStrength = (password: string): SecurityState['passwordStrength'] => {
const checks = {
length: password.length >= 8,
hasUpper: /[A-Z]/.test(password),
hasLower: /[a-z]/.test(password),
hasNumber: /[0-9]/.test(password),
hasSpecial: /[!@#$%^&*(),.?":{}|<>]/.test(password),
};
const passedCount = Object.values(checks).filter(Boolean).length;
let strength: 'weak' | 'medium' | 'strong' = 'weak';
if (passedCount >= 4) strength = 'strong';
else if (passedCount >= 3) strength = 'medium';
return { checks, strength, passedCount };
};
// Utility functions
const getStrengthColor = (strength: 'weak' | 'medium' | 'strong') => {
switch (strength) {
case 'strong':
return 'text-green-600';
case 'medium':
return 'text-yellow-600';
case 'weak':
return 'text-red-600';
}
};
const getStrengthBg = (strength: 'weak' | 'medium' | 'strong') => {
switch (strength) {
case 'strong':
return 'bg-green-100';
case 'medium':
return 'bg-yellow-100';
case 'weak':
return 'bg-red-100';
}
};
const getStrengthText = (strength: 'weak' | 'medium' | 'strong') => {
switch (strength) {
case 'strong':
return '强';
case 'medium':
return '中';
case 'weak':
return '弱';
}
};
export default function AccountSecurity() {
const [state, dispatch] = useReducer(securityReducer, initialState);
const { getAuthUser } = useAuthStore();
// 加载用户数据
useEffect(() => {
const loadUserData = () => {
const authUser = getAuthUser();
if (authUser) {
// 格式化最后登录时间
const formatLastLoginTime = (loginTime: string) => {
try {
const date = new Date(loginTime);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
} catch {
return loginTime;
}
};
// 更新用户数据到状态
dispatch({
type: 'UPDATE_USER_DATA',
payload: {
username: authUser.username || '',
phone: authUser.phone || '',
email: authUser.email || '',
lastLoginTime: authUser.last_login_at ? formatLastLoginTime(authUser.last_login_at) : '',
lastLoginDevice: 'Web Browser', // 可以从user-agent或其他地方获取
lastLoginIp: '192.168.1.100' // 可以从登录记录中获取
}
});
}
// 数据加载完成设置加载状态为false
dispatch({ type: 'SET_LOADING', payload: false });
};
loadUserData();
}, [getAuthUser]);
const handleChangePassword = () => {
dispatch({ type: 'UPDATE_PASSWORD_FORM', payload: { oldPassword: '', newPassword: '', confirmPassword: '' } });
dispatch({ type: 'TOGGLE_PASSWORD_DIALOG', payload: true });
};
const handleConfirmChangePassword = () => {
const { passwordForm } = state;
// Validation
if (!passwordForm.oldPassword) {
toast.error('请输入原密码');
return;
}
if (!passwordForm.newPassword) {
toast.error('请输入新密码');
return;
}
if (passwordForm.newPassword.length < 8) {
toast.error('密码长度至少为8位');
return;
}
if (state.passwordStrength.strength === 'weak') {
toast.error('密码强度过弱,请使用更复杂的密码');
return;
}
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
toast.error('两次输入的密码不一致');
return;
}
if (passwordForm.oldPassword === passwordForm.newPassword) {
toast.error('新密码不能与原密码相同');
return;
}
// Mock password validation (assuming old password is "123456")
if (passwordForm.oldPassword !== '123456') {
toast.error('原密码错误');
return;
}
// Success
toast.success('密码修改成功,请使用新密码重新登录');
dispatch({ type: 'TOGGLE_PASSWORD_DIALOG', payload: false });
};
const updatePassword = (field: keyof PasswordForm, value: string) => {
const updatePayload = {
[field]: value || ''
};
dispatch({ type: 'UPDATE_PASSWORD_FORM', payload: updatePayload });
if (field === 'newPassword') {
const strength = checkPasswordStrength(value || '');
dispatch({ type: 'UPDATE_PASSWORD_STRENGTH', payload: strength });
}
};
return (
<div className="space-y-6">
{/* Loading State */}
{state.isLoading && (
<Card className="p-6">
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-3 text-muted-foreground">...</span>
</div>
</Card>
)}
{/* Page Header */}
{!state.isLoading && (
<Card className="p-6 bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-200">
<div className="flex items-start gap-3">
<Shield className="w-6 h-6 text-blue-600 flex-shrink-0 mt-1" />
<div className="flex-1">
<h2 className="text-green-800 mb-2"></h2>
<p className="text-sm text-muted-foreground mb-3">
</p>
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="bg-white">
<Lock className="w-3 h-3 mr-1" />
</Badge>
<Badge variant="outline" className="bg-white">
<Key className="w-3 h-3 mr-1" />
</Badge>
<Badge variant="outline" className="bg-white">
<Shield className="w-3 h-3 mr-1" />
</Badge>
</div>
</div>
</div>
</Card>
)}
{/* Account Overview */}
{!state.isLoading && (
<Card className="p-6">
<h3 className="mb-4"></h3>
<div className="grid grid-cols-2 gap-6">
<div className="p-4 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-2"></div>
<div className="font-medium">
{state.user.username || (state.isLoading ? '加载中...' : '未设置')}
</div>
</div>
<div className="p-4 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-2"></div>
<div className="font-medium">
{state.user.phone || (state.isLoading ? '加载中...' : '未绑定')}
</div>
</div>
<div className="p-4 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-2"></div>
<div className="font-medium">
{state.user.email || (state.isLoading ? '加载中...' : '未设置')}
</div>
</div>
<div className="p-4 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-2"></div>
<div className="font-medium text-sm">
{state.user.lastLoginTime || (state.isLoading ? '加载中...' : '暂无登录记录')}
</div>
</div>
</div>
</Card>
)}
{/* Password Management */}
{!state.isLoading && (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="mb-1"></h3>
<p className="text-sm text-muted-foreground"></p>
</div>
<Button onClick={handleChangePassword} className="gap-2">
<Key className="w-4 h-4" />
</Button>
</div>
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-3">
<Shield className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-medium text-blue-900 mb-2"></h4>
<ul className="text-sm text-blue-800 space-y-1">
<li> 8</li>
<li> </li>
<li> 使</li>
<li> 3</li>
<li> 使</li>
</ul>
</div>
</div>
</div>
</Card>
)}
{/* Security Settings */}
{!state.isLoading && (
<Card className="p-6">
<h3 className="mb-4"></h3>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
<div>
<div className="font-medium"></div>
<div className="text-sm text-muted-foreground">
{state.user.phone || '未绑定'}
</div>
</div>
</div>
<Badge className="bg-green-100 text-green-700"></Badge>
</div>
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<Lock className="w-5 h-5 text-blue-600" />
</div>
<div>
<div className="font-medium"></div>
<div className="text-sm text-muted-foreground"></div>
</div>
</div>
<Badge className="bg-blue-100 text-blue-700"></Badge>
</div>
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-yellow-600" />
</div>
<div>
<div className="font-medium"></div>
<div className="text-sm text-muted-foreground">
{state.user.email || '未设置'}
</div>
</div>
</div>
<Badge className="bg-green-100 text-green-700"></Badge>
</div>
</div>
</Card>
)}
{/* Recent Login Records */}
{!state.isLoading && (
<Card className="p-6">
<h3 className="mb-4"></h3>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<div className="flex items-center gap-3">
<CheckCircle className="w-5 h-5 text-green-600" />
<div>
<div className="text-sm font-medium"></div>
<div className="text-xs text-muted-foreground">
{state.user.lastLoginDevice || 'Web Browser'} · {state.user.lastLoginIp || '局域网'}
</div>
</div>
</div>
<div className="text-sm text-muted-foreground">
{state.user.lastLoginTime || '首次登录'}
</div>
</div>
</div>
</Card>
)} {/* End of loading state check */}
{/* Change Password Dialog - always visible regardless of loading state */}
<Dialog open={state.showPasswordDialog} onOpenChange={(open) => dispatch({ type: 'TOGGLE_PASSWORD_DIALOG', payload: open })}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
<div className="flex items-center gap-2">
<Lock className="w-5 h-5 text-blue-600" />
</div>
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Old Password */}
<div>
<Label> *</Label>
<div className="relative mt-2">
<Input
type={state.showOldPassword ? 'text' : 'password'}
value={state.passwordForm.oldPassword}
onChange={(e) => updatePassword('oldPassword', e.target.value)}
placeholder="请输入原密码"
className="pr-10"
/>
<button
type="button"
onClick={() => dispatch({ type: 'TOGGLE_OLD_PASSWORD_VISIBILITY' })}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{state.showOldPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{/* New Password */}
<div>
<Label> *</Label>
<div className="relative mt-2">
<Input
type={state.showNewPassword ? 'text' : 'password'}
value={state.passwordForm.newPassword}
onChange={(e) => updatePassword('newPassword', e.target.value)}
placeholder="请输入新密码至少8位"
className="pr-10"
/>
<button
type="button"
onClick={() => dispatch({ type: 'TOGGLE_NEW_PASSWORD_VISIBILITY' })}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{state.showNewPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{/* Password Strength Indicator */}
{state.passwordForm.newPassword && (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"></span>
<Badge className={`${getStrengthBg(state.passwordStrength.strength)} ${getStrengthColor(state.passwordStrength.strength)}`}>
{getStrengthText(state.passwordStrength.strength)}
</Badge>
</div>
<div className="space-y-1.5">
<div className={`flex items-center gap-2 text-sm ${state.passwordStrength.checks.length ? 'text-green-600' : 'text-muted-foreground'}`}>
{state.passwordStrength.checks.length ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
<span>8</span>
</div>
<div className={`flex items-center gap-2 text-sm ${state.passwordStrength.checks.hasUpper ? 'text-green-600' : 'text-muted-foreground'}`}>
{state.passwordStrength.checks.hasUpper ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
<span></span>
</div>
<div className={`flex items-center gap-2 text-sm ${state.passwordStrength.checks.hasLower ? 'text-green-600' : 'text-muted-foreground'}`}>
{state.passwordStrength.checks.hasLower ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
<span></span>
</div>
<div className={`flex items-center gap-2 text-sm ${state.passwordStrength.checks.hasNumber ? 'text-green-600' : 'text-muted-foreground'}`}>
{state.passwordStrength.checks.hasNumber ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
<span></span>
</div>
<div className={`flex items-center gap-2 text-sm ${state.passwordStrength.checks.hasSpecial ? 'text-green-600' : 'text-muted-foreground'}`}>
{state.passwordStrength.checks.hasSpecial ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
<span></span>
</div>
</div>
</div>
)}
</div>
{/* Confirm Password */}
<div>
<Label> *</Label>
<div className="relative mt-2">
<Input
type={state.showConfirmPassword ? 'text' : 'password'}
value={state.passwordForm.confirmPassword}
onChange={(e) => updatePassword('confirmPassword', e.target.value)}
placeholder="请再次输入新密码"
className="pr-10"
/>
<button
type="button"
onClick={() => dispatch({ type: 'TOGGLE_CONFIRM_PASSWORD_VISIBILITY' })}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{state.showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{state.passwordForm.confirmPassword && state.passwordForm.newPassword !== state.passwordForm.confirmPassword && (
<p className="text-sm text-red-600 mt-2 flex items-center gap-1">
<XCircle className="w-4 h-4" />
</p>
)}
</div>
{/* Security Notice */}
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-yellow-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-yellow-800">
</p>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => dispatch({ type: 'TOGGLE_PASSWORD_DIALOG', payload: false })}>
</Button>
<Button onClick={handleConfirmChangePassword}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}