From b3463725555752899f3017b758aa468227eaf330 Mon Sep 17 00:00:00 2001 From: max Date: Fri, 17 Apr 2026 19:52:57 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B2=D0=BD=D0=B5=D0=B4=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B7=D0=B0=D1=89=D0=B8=D1=89=D0=B5=D0=BD=D0=BD=D0=B0?= =?UTF-8?q?=D1=8F=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=B0=D1=80?= =?UTF-8?q?=D0=BE=D0=BB=D1=8F=20(=D0=B1=D1=8D=D0=BA=20+=20=D1=84=D1=80?= =?UTF-8?q?=D0=BE=D0=BD=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Auth/Application/Auth/DTOs/AuthErrors.cs | 3 + .../Users/ChangePassword/ChangePassword.cs | 48 ++++++ .../Presentation/Endpoints/AuthEndpoints.cs | 17 +++ .../modules/auth/infrastructure/authApi.ts | 7 + .../components/ChangePasswordModal.tsx | 140 ++++++++++++++++++ .../presentation/components/SettingsPage.tsx | 25 ++++ 6 files changed, 240 insertions(+) create mode 100644 backend/src/Modules/Auth/Application/Users/ChangePassword/ChangePassword.cs create mode 100644 client-web/src/modules/users/presentation/components/ChangePasswordModal.tsx diff --git a/backend/src/Contracts/Auth/Application/Auth/DTOs/AuthErrors.cs b/backend/src/Contracts/Auth/Application/Auth/DTOs/AuthErrors.cs index 8beb50e..4c87ba4 100644 --- a/backend/src/Contracts/Auth/Application/Auth/DTOs/AuthErrors.cs +++ b/backend/src/Contracts/Auth/Application/Auth/DTOs/AuthErrors.cs @@ -8,4 +8,7 @@ public static class AuthErrors public static Error IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled"); public static Error IdentityUsernameNotUnique => new("Auth.UsernameNotUnique", "Username is already taken"); public static Error UserNotFound => new("Auth.UserNotFound", "User not found"); + public static Error PasswordConfirmationMismatch => new("Auth.PasswordConfirmationMismatch", "Passwords do not match"); + public static Error PasswordTooShort => new("Auth.PasswordTooShort", "Password must be at least 8 characters"); + public static Error OldPasswordInvalid => new("Auth.OldPasswordInvalid", "Current password is incorrect"); } diff --git a/backend/src/Modules/Auth/Application/Users/ChangePassword/ChangePassword.cs b/backend/src/Modules/Auth/Application/Users/ChangePassword/ChangePassword.cs new file mode 100644 index 0000000..184080b --- /dev/null +++ b/backend/src/Modules/Auth/Application/Users/ChangePassword/ChangePassword.cs @@ -0,0 +1,48 @@ +using BCrypt.Net; +using Knot.Contracts.Auth.Application.Abstractions; +using Knot.Contracts.Auth.Application.Auth.DTOs; +using Knot.Modules.Auth.Domain; +using Knot.Shared.Kernel; +using Microsoft.EntityFrameworkCore; + +namespace Knot.Modules.Auth.Application.Users.ChangePassword; + +public sealed record ChangePasswordCommand( + Guid UserId, + string OldPassword, + string NewPassword, + string ConfirmPassword) : ICommand; + +internal sealed class ChangePasswordCommandHandler : ICommandHandler +{ + private readonly IAuthDbContext _dbContext; + + public ChangePasswordCommandHandler(IAuthDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task Handle(ChangePasswordCommand request, CancellationToken cancellationToken) + { + if (request.NewPassword != request.ConfirmPassword) + return Result.Failure(AuthErrors.PasswordConfirmationMismatch); + + if (request.NewPassword.Length < 8) + return Result.Failure(AuthErrors.PasswordTooShort); + + var user = await _dbContext.Set() + .FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); + + if (user is null) + return Result.Failure(AuthErrors.UserNotFound); + + if (!BCrypt.Net.BCrypt.Verify(request.OldPassword, user.PasswordHash)) + return Result.Failure(AuthErrors.OldPasswordInvalid); + + user.ChangePassword(BCrypt.Net.BCrypt.HashPassword(request.NewPassword)); + user.SetRefreshToken(null); + + await _dbContext.SaveChangesAsync(cancellationToken); + return Result.Success(); + } +} diff --git a/backend/src/Modules/Auth/Presentation/Endpoints/AuthEndpoints.cs b/backend/src/Modules/Auth/Presentation/Endpoints/AuthEndpoints.cs index 18d349c..a94304b 100644 --- a/backend/src/Modules/Auth/Presentation/Endpoints/AuthEndpoints.cs +++ b/backend/src/Modules/Auth/Presentation/Endpoints/AuthEndpoints.cs @@ -2,6 +2,7 @@ using Knot.Shared.Kernel; using Knot.Modules.Auth.Application.Users.Login; using Knot.Modules.Auth.Application.Users.Register; using Knot.Modules.Auth.Application.Users.GetMe; +using Knot.Modules.Auth.Application.Users.ChangePassword; using MediatR; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -12,6 +13,8 @@ namespace Knot.Modules.Auth.Presentation.Endpoints; public static class AuthEndpoints { + public sealed record ChangePasswordRequest(string OldPassword, string NewPassword, string ConfirmPassword); + public static void MapAuthEndpoints(this WebApplication app) { var group = app.MapGroup("api/auth"); @@ -33,5 +36,19 @@ public static class AuthEndpoints var result = await sender.Send(new GetMeQuery(userContext.UserId), ct); return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(); }).RequireAuthorization(); + + group.MapPost("change-password", async ([FromBody] ChangePasswordRequest request, ISender sender, IUserContext userContext, CancellationToken ct) => + { + var result = await sender.Send( + new ChangePasswordCommand(userContext.UserId, request.OldPassword, request.NewPassword, request.ConfirmPassword), + ct); + + if (result.IsSuccess) return Results.Ok(); + + if (result.Error.Code == "Auth.OldPasswordInvalid") + return Results.StatusCode(StatusCodes.Status403Forbidden); + + return Results.BadRequest(new { error = result.Error.Code ?? result.Error.Description }); + }).RequireAuthorization(); } } diff --git a/client-web/src/modules/auth/infrastructure/authApi.ts b/client-web/src/modules/auth/infrastructure/authApi.ts index 17a4888..41b7d08 100644 --- a/client-web/src/modules/auth/infrastructure/authApi.ts +++ b/client-web/src/modules/auth/infrastructure/authApi.ts @@ -62,6 +62,13 @@ export class AuthApi { return httpClient.request('/config'); } + static async changePassword(data: { oldPassword: string; newPassword: string; confirmPassword: string }) { + return httpClient.request('/auth/change-password', { + method: 'POST', + body: JSON.stringify(data), + }); + } + static setToken(token: string | null) { httpClient.setToken(token); } diff --git a/client-web/src/modules/users/presentation/components/ChangePasswordModal.tsx b/client-web/src/modules/users/presentation/components/ChangePasswordModal.tsx new file mode 100644 index 0000000..df80785 --- /dev/null +++ b/client-web/src/modules/users/presentation/components/ChangePasswordModal.tsx @@ -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(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 ( + + {open && ( + +
+ e.stopPropagation()} + className="relative w-full max-w-md rounded-[2rem] border border-white/10 bg-[#111111] p-6 shadow-2xl" + > + + +

+ {lang === 'ru' ? 'Смена пароля' : 'Change password'} +

+ +
+ 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" + /> + 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" + /> + 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" + /> +
+ +
+

+ {lang === 'ru' ? 'Минимум 8 символов' : 'At least 8 characters'} +

+

+ {lang === 'ru' ? 'Пароли совпадают' : 'Passwords match'} +

+
+ + {error &&

{error}

} + +
+ + +
+
+ + )} + + ); +} diff --git a/client-web/src/modules/users/presentation/components/SettingsPage.tsx b/client-web/src/modules/users/presentation/components/SettingsPage.tsx index ee95b42..a7ba8b2 100644 --- a/client-web/src/modules/users/presentation/components/SettingsPage.tsx +++ b/client-web/src/modules/users/presentation/components/SettingsPage.tsx @@ -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 */}
+
); }