внедрена защищенная смена пароля (бэк + фронт)

This commit is contained in:
max
2026-04-17 19:52:57 +03:00
parent d2e6eb578a
commit b346372555
6 changed files with 240 additions and 0 deletions
@@ -62,6 +62,13 @@ export class AuthApi {
return httpClient.request<any>('/config');
}
static async changePassword(data: { oldPassword: string; newPassword: string; confirmPassword: string }) {
return httpClient.request<void>('/auth/change-password', {
method: 'POST',
body: JSON.stringify(data),
});
}
static setToken(token: string | null) {
httpClient.setToken(token);
}
@@ -0,0 +1,140 @@
import { useMemo, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { Loader2, X } from 'lucide-react';
import { AuthApi } from '../../../auth/infrastructure/authApi';
import { useLang } from '../../../../core/infrastructure/i18n';
interface ChangePasswordModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
export default function ChangePasswordModal({ open, onClose, onSuccess }: ChangePasswordModalProps) {
const { lang } = useLang();
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const minLengthOk = newPassword.length >= 8;
const matchOk = newPassword.length > 0 && confirmPassword.length > 0 && newPassword === confirmPassword;
const canSubmit = useMemo(
() => oldPassword.length > 0 && minLengthOk && matchOk && !loading,
[oldPassword.length, minLengthOk, matchOk, loading]
);
const resetState = () => {
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
setError(null);
setLoading(false);
};
const close = () => {
resetState();
onClose();
};
const submit = async () => {
if (!canSubmit) return;
setLoading(true);
setError(null);
try {
await AuthApi.changePassword({ oldPassword, newPassword, confirmPassword });
resetState();
onSuccess();
} catch (e: any) {
const message = e?.message || (lang === 'ru' ? 'Не удалось сменить пароль' : 'Failed to change password');
setError(message);
} finally {
setLoading(false);
}
};
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[12000] flex items-center justify-center p-4"
>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={close} />
<motion.div
initial={{ opacity: 0, y: 12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.98 }}
onClick={(e) => e.stopPropagation()}
className="relative w-full max-w-md rounded-[2rem] border border-white/10 bg-[#111111] p-6 shadow-2xl"
>
<button
onClick={close}
className="absolute right-4 top-4 p-2 rounded-xl text-white/50 hover:text-white hover:bg-white/10"
>
<X size={16} />
</button>
<h3 className="text-lg font-black text-white mb-5">
{lang === 'ru' ? 'Смена пароля' : 'Change password'}
</h3>
<div className="space-y-3">
<input
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder={lang === 'ru' ? 'Текущий пароль' : 'Current password'}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-4 py-3 text-sm text-white outline-none focus:border-primary/60"
/>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={lang === 'ru' ? 'Новый пароль' : 'New password'}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-4 py-3 text-sm text-white outline-none focus:border-primary/60"
/>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={lang === 'ru' ? 'Подтвердите пароль' : 'Confirm password'}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-4 py-3 text-sm text-white outline-none focus:border-primary/60"
/>
</div>
<div className="mt-3 space-y-1">
<p className={`text-xs ${minLengthOk ? 'text-emerald-400' : 'text-white/45'}`}>
{lang === 'ru' ? 'Минимум 8 символов' : 'At least 8 characters'}
</p>
<p className={`text-xs ${matchOk ? 'text-emerald-400' : 'text-white/45'}`}>
{lang === 'ru' ? 'Пароли совпадают' : 'Passwords match'}
</p>
</div>
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
<div className="mt-6 flex items-center justify-end gap-2">
<button
onClick={close}
className="px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider text-white/60 hover:text-white"
>
{lang === 'ru' ? 'Отмена' : 'Cancel'}
</button>
<button
onClick={submit}
disabled={!canSubmit}
className="px-5 py-2 rounded-xl bg-primary text-on-primary text-xs font-black uppercase tracking-wider disabled:opacity-40 disabled:cursor-not-allowed"
>
{loading ? <Loader2 size={14} className="animate-spin" /> : (lang === 'ru' ? 'Сменить' : 'Change')}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -15,6 +15,7 @@ import AvatarCropModal from './AvatarCropModal';
import { Area } from 'react-easy-crop';
import { getCroppedImg } from '../../../../core/utils/cropImage';
import EmojiOnlyPicker from '../../../stories/presentation/components/EmojiOnlyPicker';
import ChangePasswordModal from './ChangePasswordModal';
const BIO_MAX_LENGTH = 200;
const STATUS_MAX_LENGTH = 50;
@@ -40,6 +41,7 @@ export default function SettingsPage() {
});
const [saving, setSaving] = useState(false);
const [showStatusPicker, setShowStatusPicker] = useState(false);
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
// Avatar cropping state
const [cropModal, setCropModal] = useState<{
@@ -410,6 +412,20 @@ export default function SettingsPage() {
{/* Account Section */}
<div className="p-6 rounded-3xl bg-white/[0.02] border border-white/5 flex flex-col justify-between">
<button
onClick={() => setShowChangePasswordModal(true)}
className="w-full mb-3 flex items-center justify-between p-4 rounded-2xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all group"
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-white/10 flex items-center justify-center text-primary group-hover:scale-105 transition-transform">
<Shield size={16} />
</div>
<span className="text-[11px] font-black uppercase tracking-widest text-white/70 group-hover:text-white transition-colors">
{lang === 'ru' ? 'Сменить пароль' : 'Change password'}
</span>
</div>
<ChevronRight size={16} className="text-white/30" />
</button>
<button
onClick={() => { logout(); }}
className="w-full flex items-center justify-between p-4 rounded-2xl bg-red-500/5 hover:bg-red-500/15 border border-red-500/10 transition-all group"
@@ -447,6 +463,15 @@ export default function SettingsPage() {
/>
)}
</AnimatePresence>
<ChangePasswordModal
open={showChangePasswordModal}
onClose={() => setShowChangePasswordModal(false)}
onSuccess={() => {
setShowChangePasswordModal(false);
// Access token remains valid; ask user to re-login to ensure security on all devices.
logout();
}}
/>
</div>
);
}