diff --git a/fe-admin/src/components/ChangePasswordDialog.tsx b/fe-admin/src/components/ChangePasswordDialog.tsx new file mode 100644 index 0000000..35ea66b --- /dev/null +++ b/fe-admin/src/components/ChangePasswordDialog.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import { toast } from 'sonner' +import { KeyRound } from 'lucide-react' +import { Dialog } from '@/components/ui/Dialog' +import { Button } from '@/components/ui/Button' +import { Input } from '@/components/ui/Input' +import { Label } from '@/components/ui/Label' +import { api } from '@/lib/api' +import { getErrorMessage } from '@/lib/apiError' + +type Props = { open: boolean; onClose: () => void } + +// Self-service đổi mật khẩu — user tự đổi (nhập mật khẩu hiện tại để xác thực). +// BE: POST /auth/change-password (Identity verify pass cũ). Khác admin Reset. +export function ChangePasswordDialog({ open, onClose }: Props) { + const [current, setCurrent] = useState('') + const [next, setNext] = useState('') + const [confirm, setConfirm] = useState('') + + const close = () => { + setCurrent('') + setNext('') + setConfirm('') + onClose() + } + + const mutation = useMutation({ + mutationFn: () => + api.post('/auth/change-password', { currentPassword: current, newPassword: next }), + onSuccess: () => { + toast.success('Đổi mật khẩu thành công. Lần đăng nhập sau dùng mật khẩu mới.') + close() + }, + onError: err => toast.error(getErrorMessage(err)), + }) + + const newTooShort = next.length > 0 && next.length < 12 + const sameAsOld = next.length > 0 && next === current + const mismatch = confirm.length > 0 && next !== confirm + const canSubmit = + current.length > 0 && next.length >= 12 && next === confirm && next !== current && !mutation.isPending + + const submit = () => { + if (canSubmit) mutation.mutate() + } + + return ( + + Đổi mật khẩu + + } + footer={ + <> + + Hủy + + + {mutation.isPending ? 'Đang lưu…' : 'Đổi mật khẩu'} + + > + } + > + { + e.preventDefault() + submit() + }} + > + + Mật khẩu hiện tại + setCurrent(e.target.value)} + /> + + + Mật khẩu mới + setNext(e.target.value)} + /> + {newTooShort && Mật khẩu mới phải có ít nhất 12 ký tự.} + {sameAsOld && Mật khẩu mới phải khác mật khẩu hiện tại.} + + + Nhập lại mật khẩu mới + setConfirm(e.target.value)} + /> + {mismatch && Mật khẩu nhập lại không khớp.} + + Mật khẩu cần tối thiểu 12 ký tự. + {/* cho phép submit bằng Enter */} + + + + ) +} diff --git a/fe-admin/src/components/TopBar.tsx b/fe-admin/src/components/TopBar.tsx index 43a0777..c99b9aa 100644 --- a/fe-admin/src/components/TopBar.tsx +++ b/fe-admin/src/components/TopBar.tsx @@ -1,12 +1,14 @@ import { useEffect, useRef, useState } from 'react' -import { ChevronDown, LogOut } from 'lucide-react' +import { ChevronDown, LogOut, KeyRound } from 'lucide-react' import { useAuth } from '@/contexts/AuthContext' +import { ChangePasswordDialog } from '@/components/ChangePasswordDialog' import { NotificationBell } from '@/components/NotificationBell' import { cn } from '@/lib/cn' function UserMenu() { const { user, logout } = useAuth() const [open, setOpen] = useState(false) + const [pwOpen, setPwOpen] = useState(false) const ref = useRef(null) useEffect(() => { @@ -53,6 +55,16 @@ function UserMenu() { )} + { + setPwOpen(true) + setOpen(false) + }} + className="flex w-full items-center gap-2 px-4 py-2 text-sm text-slate-600 transition hover:bg-slate-50" + > + + Đổi mật khẩu + )} + setPwOpen(false)} /> ) } diff --git a/fe-user/src/components/ChangePasswordDialog.tsx b/fe-user/src/components/ChangePasswordDialog.tsx new file mode 100644 index 0000000..35ea66b --- /dev/null +++ b/fe-user/src/components/ChangePasswordDialog.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import { toast } from 'sonner' +import { KeyRound } from 'lucide-react' +import { Dialog } from '@/components/ui/Dialog' +import { Button } from '@/components/ui/Button' +import { Input } from '@/components/ui/Input' +import { Label } from '@/components/ui/Label' +import { api } from '@/lib/api' +import { getErrorMessage } from '@/lib/apiError' + +type Props = { open: boolean; onClose: () => void } + +// Self-service đổi mật khẩu — user tự đổi (nhập mật khẩu hiện tại để xác thực). +// BE: POST /auth/change-password (Identity verify pass cũ). Khác admin Reset. +export function ChangePasswordDialog({ open, onClose }: Props) { + const [current, setCurrent] = useState('') + const [next, setNext] = useState('') + const [confirm, setConfirm] = useState('') + + const close = () => { + setCurrent('') + setNext('') + setConfirm('') + onClose() + } + + const mutation = useMutation({ + mutationFn: () => + api.post('/auth/change-password', { currentPassword: current, newPassword: next }), + onSuccess: () => { + toast.success('Đổi mật khẩu thành công. Lần đăng nhập sau dùng mật khẩu mới.') + close() + }, + onError: err => toast.error(getErrorMessage(err)), + }) + + const newTooShort = next.length > 0 && next.length < 12 + const sameAsOld = next.length > 0 && next === current + const mismatch = confirm.length > 0 && next !== confirm + const canSubmit = + current.length > 0 && next.length >= 12 && next === confirm && next !== current && !mutation.isPending + + const submit = () => { + if (canSubmit) mutation.mutate() + } + + return ( + + Đổi mật khẩu + + } + footer={ + <> + + Hủy + + + {mutation.isPending ? 'Đang lưu…' : 'Đổi mật khẩu'} + + > + } + > + { + e.preventDefault() + submit() + }} + > + + Mật khẩu hiện tại + setCurrent(e.target.value)} + /> + + + Mật khẩu mới + setNext(e.target.value)} + /> + {newTooShort && Mật khẩu mới phải có ít nhất 12 ký tự.} + {sameAsOld && Mật khẩu mới phải khác mật khẩu hiện tại.} + + + Nhập lại mật khẩu mới + setConfirm(e.target.value)} + /> + {mismatch && Mật khẩu nhập lại không khớp.} + + Mật khẩu cần tối thiểu 12 ký tự. + {/* cho phép submit bằng Enter */} + + + + ) +} diff --git a/fe-user/src/components/TopBar.tsx b/fe-user/src/components/TopBar.tsx index a16769d..f7e8375 100644 --- a/fe-user/src/components/TopBar.tsx +++ b/fe-user/src/components/TopBar.tsx @@ -1,12 +1,14 @@ import { useEffect, useRef, useState } from 'react' -import { ChevronDown, LogOut } from 'lucide-react' +import { ChevronDown, LogOut, KeyRound } from 'lucide-react' import { useAuth } from '@/contexts/AuthContext' +import { ChangePasswordDialog } from '@/components/ChangePasswordDialog' import { NotificationBell } from '@/components/NotificationBell' import { cn } from '@/lib/cn' function UserMenu() { const { user, logout } = useAuth() const [open, setOpen] = useState(false) + const [pwOpen, setPwOpen] = useState(false) const ref = useRef(null) useEffect(() => { @@ -53,6 +55,16 @@ function UserMenu() { )} + { + setPwOpen(true) + setOpen(false) + }} + className="flex w-full items-center gap-2 px-4 py-2 text-sm text-slate-600 transition hover:bg-slate-50" + > + + Đổi mật khẩu + )} + setPwOpen(false)} /> ) } diff --git a/src/Backend/SolutionErp.Api/Controllers/AuthController.cs b/src/Backend/SolutionErp.Api/Controllers/AuthController.cs index aae12a5..4a20f71 100644 --- a/src/Backend/SolutionErp.Api/Controllers/AuthController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/AuthController.cs @@ -1,6 +1,7 @@ using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using SolutionErp.Application.Auth.Commands.ChangePassword; using SolutionErp.Application.Auth.Commands.Login; using SolutionErp.Application.Auth.Commands.Refresh; using SolutionErp.Application.Auth.Dtos; @@ -38,4 +39,13 @@ public class AuthController : ControllerBase { return NoContent(); } + + // Self-service: user đang đăng nhập tự đổi mật khẩu (xác thực mật khẩu hiện tại). + [HttpPost("change-password")] + [Authorize] + public async Task ChangePassword([FromBody] ChangePasswordCommand command, CancellationToken ct) + { + await _mediator.Send(command, ct); + return NoContent(); + } } diff --git a/src/Backend/SolutionErp.Application/Auth/Commands/ChangePassword/ChangePasswordCommand.cs b/src/Backend/SolutionErp.Application/Auth/Commands/ChangePassword/ChangePasswordCommand.cs new file mode 100644 index 0000000..58e673f --- /dev/null +++ b/src/Backend/SolutionErp.Application/Auth/Commands/ChangePassword/ChangePasswordCommand.cs @@ -0,0 +1,58 @@ +using FluentValidation; +using MediatR; +using Microsoft.AspNetCore.Identity; +using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Domain.Identity; + +namespace SolutionErp.Application.Auth.Commands.ChangePassword; + +// Self-service đổi mật khẩu — user đang đăng nhập TỰ đổi, BẮT BUỘC nhập mật khẩu hiện tại +// để xác thực. Khác ResetPasswordCommand (admin đặt pass hộ theo userId, KHÔNG cần pass cũ). +public record ChangePasswordCommand(string CurrentPassword, string NewPassword) : IRequest; + +public class ChangePasswordCommandValidator : AbstractValidator +{ + public ChangePasswordCommandValidator() + { + RuleFor(x => x.CurrentPassword) + .NotEmpty().WithMessage("Vui lòng nhập mật khẩu hiện tại."); + RuleFor(x => x.NewPassword) + .NotEmpty().WithMessage("Vui lòng nhập mật khẩu mới.") + .MinimumLength(12).WithMessage("Mật khẩu mới phải có ít nhất 12 ký tự."); + RuleFor(x => x.NewPassword) + .NotEqual(x => x.CurrentPassword).WithMessage("Mật khẩu mới phải khác mật khẩu hiện tại.") + .When(x => !string.IsNullOrEmpty(x.NewPassword) && !string.IsNullOrEmpty(x.CurrentPassword)); + } +} + +public class ChangePasswordCommandHandler( + UserManager userManager, + ICurrentUser currentUser) : IRequestHandler +{ + public async Task Handle(ChangePasswordCommand request, CancellationToken ct) + { + if (!currentUser.IsAuthenticated || currentUser.UserId is null) + throw new UnauthorizedException(); + + var user = await userManager.FindByIdAsync(currentUser.UserId.Value.ToString()) + ?? throw new UnauthorizedException(); + + // Identity tự verify CurrentPassword — sai mật khẩu cũ => result.Errors có code "PasswordMismatch". + // Luật mật khẩu mới (độ dài, ...) cũng do Identity + validator ở trên kiểm. + var result = await userManager.ChangePasswordAsync(user, request.CurrentPassword, request.NewPassword); + if (!result.Succeeded) + { + var failures = result.Errors.Select(e => new FluentValidation.Results.ValidationFailure( + e.Code == "PasswordMismatch" ? nameof(request.CurrentPassword) : nameof(request.NewPassword), + e.Code == "PasswordMismatch" ? "Mật khẩu hiện tại không đúng." : e.Description)); + throw new SolutionErp.Application.Common.Exceptions.ValidationException(failures); + } + + // Vô hiệu refresh token (mirror ResetPasswordCommand) → các phiên hiện có sẽ phải + // đăng nhập lại bằng mật khẩu mới khi access token hết hạn. + user.RefreshToken = null; + user.RefreshTokenExpiresAt = null; + await userManager.UpdateAsync(user); + } +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/ChangePasswordCommandTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/ChangePasswordCommandTests.cs new file mode 100644 index 0000000..b73d73b --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/ChangePasswordCommandTests.cs @@ -0,0 +1,243 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using SolutionErp.Application.Auth.Commands.ChangePassword; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Domain.Identity; +using SolutionErp.Infrastructure.Tests.Common; +using AppValidationException = SolutionErp.Application.Common.Exceptions.ValidationException; +using AppUnauthorizedException = SolutionErp.Application.Common.Exceptions.UnauthorizedException; + +namespace SolutionErp.Infrastructure.Tests.Application; + +// ===== NEW (go-live 2026-06-26) — Self-service đổi mật khẩu ===== +// ChangePasswordCommand.cs (Application/Auth/Commands/ChangePassword). +// Security-sensitive (xử lý mật khẩu) → test-before-merge per docs/rules.md §7. +// Test theo CODE đã land (S34 rule — KHÔNG touch production). +// +// Spec handler: +// - !IsAuthenticated || UserId is null → UnauthorizedException. +// - FindByIdAsync(UserId) null → UnauthorizedException. +// - userManager.ChangePasswordAsync fail → ValidationException (CUSTOM app +// exception, Errors là Dictionary key=PropertyName): +// code "PasswordMismatch" → key "CurrentPassword" ("Mật khẩu hiện tại không đúng.") +// lỗi khác → key "NewPassword". +// - Success → user.RefreshToken=null + RefreshTokenExpiresAt=null + UpdateAsync. +// +// Validator (test trực tiếp .Validate() — mirror PeSuggestedPriceSetterAuthzTests): +// CurrentPassword NotEmpty; NewPassword NotEmpty + MinimumLength(12); +// NewPassword NotEqual CurrentPassword. +// +// ⚠️ GOTCHA harness: IdentityFixture cấu hình Password.RequiredLength=4 + tắt +// mọi complexity rule (NV codegen tests dùng "Test@123"). Nên ràng-buộc-độ-dài-12 +// là DO VALIDATOR enforce, KHÔNG phải Identity ở tầng test → case "<12" test ở +// validator (Identity tầng này sẽ pass length=4). "PasswordMismatch" thì Identity +// VẪN verify mật khẩu cũ bất kể RequiredLength → test #2 đi qua handler thật. +// +// ⚠️ Tạo user bằng UserManager.CreateAsync(user, "") trực tiếp (KHÔNG +// dùng fix.CreateUserAsync — helper đó hardcode "Test@123", không kiểm soát được +// pass cũ cần cho đổi-mật-khẩu). +public class ChangePasswordCommandTests +{ + private const string OldPassword = "OldPass@12345"; // ≥12 ký tự + private const string NewPassword = "NewPass@12345"; // ≥12 ký tự, khác pass cũ + + // ICurrentUser stub — UserId settable + IsAuthenticated cấu hình riêng để + // mô phỏng cả nhánh "chưa đăng nhập" (IsAuthenticated=false) lẫn "UserId null". + private sealed class FakeCurrentUser : ICurrentUser + { + public Guid? UserId { get; init; } + public string? Email { get; init; } = "actor@test.local"; + public string? FullName { get; init; } = "Actor Test"; + public IReadOnlyList Roles { get; init; } = Array.Empty(); + public bool IsAuthenticated { get; init; } = true; + } + + // Tạo user với mật khẩu cụ thể (faithful cho password-change test). + private static async Task CreateUserWithPasswordAsync( + IdentityFixture fix, string password, string email = "changepass@test.local") + { + var um = fix.Services.GetRequiredService>(); + var user = new User + { + Id = Guid.NewGuid(), + UserName = email, + Email = email, + EmailConfirmed = true, + FullName = "Đổi Mật Khẩu", + IsActive = true, + // Pre-seed refresh token để chứng minh handler XÓA nó khi đổi pass thành công. + RefreshToken = "stale-refresh-token", + RefreshTokenExpiresAt = DateTime.UtcNow.AddDays(7), + }; + var created = await um.CreateAsync(user, password); + created.Succeeded.Should().BeTrue( + "seed user phải tạo được: " + string.Join(",", created.Errors.Select(e => e.Description))); + return user; + } + + // ==================================================================== + // ===== Handler ===== + // ==================================================================== + + // 1. Đổi mật khẩu thành công → KHÔNG throw; pass mới hiệu lực + refresh token xóa. + [Fact] + public async Task Handle_CurrentCorrect_NewValid_ChangesPasswordAndClearsRefreshToken() + { + using var fix = new IdentityFixture(); + var um = fix.Services.GetRequiredService>(); + var user = await CreateUserWithPasswordAsync(fix, OldPassword); + + var handler = new ChangePasswordCommandHandler( + um, new FakeCurrentUser { UserId = user.Id }); + + var act = async () => await handler.Handle( + new ChangePasswordCommand(OldPassword, NewPassword), CancellationToken.None); + + await act.Should().NotThrowAsync(); + + // Re-load sạch để verify state đã persist. + var reloaded = await um.FindByIdAsync(user.Id.ToString()); + reloaded.Should().NotBeNull(); + (await um.CheckPasswordAsync(reloaded!, NewPassword)).Should().BeTrue("pass mới phải hiệu lực"); + (await um.CheckPasswordAsync(reloaded!, OldPassword)).Should().BeFalse("pass cũ phải hết hiệu lực"); + reloaded!.RefreshToken.Should().BeNull("đổi pass phải vô hiệu refresh token"); + reloaded.RefreshTokenExpiresAt.Should().BeNull(); + } + + // 2. Mật khẩu hiện tại SAI → ValidationException (custom), failure key chứa "CurrentPassword". + [Fact] + public async Task Handle_WrongCurrentPassword_ThrowsValidationOnCurrentPassword() + { + using var fix = new IdentityFixture(); + var um = fix.Services.GetRequiredService>(); + var user = await CreateUserWithPasswordAsync(fix, OldPassword); + + var handler = new ChangePasswordCommandHandler( + um, new FakeCurrentUser { UserId = user.Id }); + + var act = async () => await handler.Handle( + new ChangePasswordCommand("WrongCurrent@999", NewPassword), CancellationToken.None); + + var ex = (await act.Should().ThrowAsync()).Which; + ex.Errors.Keys.Should().Contain(nameof(ChangePasswordCommand.CurrentPassword)); + ex.Errors[nameof(ChangePasswordCommand.CurrentPassword)] + .Should().Contain("Mật khẩu hiện tại không đúng."); + + // Pass cũ KHÔNG bị đổi (fail-safe) + refresh token còn nguyên (handler thoát trước khi clear). + var reloaded = await um.FindByIdAsync(user.Id.ToString()); + (await um.CheckPasswordAsync(reloaded!, OldPassword)).Should().BeTrue(); + reloaded!.RefreshToken.Should().Be("stale-refresh-token"); + } + + // 5. Chưa đăng nhập (IsAuthenticated=false) → UnauthorizedException trước mọi side-effect. + [Fact] + public async Task Handle_NotAuthenticated_ThrowsUnauthorized() + { + using var fix = new IdentityFixture(); + var um = fix.Services.GetRequiredService>(); + var user = await CreateUserWithPasswordAsync(fix, OldPassword); + + // IsAuthenticated=false dù UserId có giá trị → guard đầu tiên phải bắt. + var handler = new ChangePasswordCommandHandler( + um, new FakeCurrentUser { UserId = user.Id, IsAuthenticated = false }); + + var act = async () => await handler.Handle( + new ChangePasswordCommand(OldPassword, NewPassword), CancellationToken.None); + + await act.Should().ThrowAsync(); + + // Không có side-effect: pass cũ giữ nguyên. + var reloaded = await um.FindByIdAsync(user.Id.ToString()); + (await um.CheckPasswordAsync(reloaded!, OldPassword)).Should().BeTrue(); + } + + // 5-bis. UserId null (kể cả IsAuthenticated=true) → UnauthorizedException. + [Fact] + public async Task Handle_NullUserId_ThrowsUnauthorized() + { + using var fix = new IdentityFixture(); + var um = fix.Services.GetRequiredService>(); + + var handler = new ChangePasswordCommandHandler( + um, new FakeCurrentUser { UserId = null, IsAuthenticated = true }); + + var act = async () => await handler.Handle( + new ChangePasswordCommand(OldPassword, NewPassword), CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + // 5-ter. Authenticated nhưng user không tồn tại (UserId lạ) → UnauthorizedException (FindByIdAsync null). + [Fact] + public async Task Handle_UserNotFound_ThrowsUnauthorized() + { + using var fix = new IdentityFixture(); + var um = fix.Services.GetRequiredService>(); + + var handler = new ChangePasswordCommandHandler( + um, new FakeCurrentUser { UserId = Guid.NewGuid(), IsAuthenticated = true }); + + var act = async () => await handler.Handle( + new ChangePasswordCommand(OldPassword, NewPassword), CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + // ==================================================================== + // ===== Validator ===== + // ==================================================================== + + // 3. Mật khẩu mới <12 ký tự → validator reject (Identity tầng test pass length=4, + // nên độ-dài-12 là do VALIDATOR enforce). + [Fact] + public void Validator_NewPasswordTooShort_Fails() + { + var validator = new ChangePasswordCommandValidator(); + + var result = validator.Validate( + new ChangePasswordCommand(OldPassword, NewPassword: "Short@12345")); // 11 ký tự + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == nameof(ChangePasswordCommand.NewPassword)); + } + + // 3-bis. Đúng 12 ký tự (biên) → hợp lệ về độ dài. + [Fact] + public void Validator_NewPasswordExactly12_PassesLengthRule() + { + var validator = new ChangePasswordCommandValidator(); + + // "Abcdef@12345" = 12 ký tự, khác CurrentPassword → toàn bộ rule pass. + validator.Validate(new ChangePasswordCommand("Current@9999", "Abcdef@12345")) + .IsValid.Should().BeTrue(); + } + + // 4. Mật khẩu mới == mật khẩu cũ → validator reject (NotEqual), trên field NewPassword. + [Fact] + public void Validator_NewEqualsCurrent_Fails() + { + var validator = new ChangePasswordCommandValidator(); + + var result = validator.Validate( + new ChangePasswordCommand(OldPassword, NewPassword: OldPassword)); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => + e.PropertyName == nameof(ChangePasswordCommand.NewPassword) + && e.ErrorMessage == "Mật khẩu mới phải khác mật khẩu hiện tại."); + } + + // 4-bis. CurrentPassword rỗng → validator reject (NotEmpty). + [Fact] + public void Validator_EmptyCurrentPassword_Fails() + { + var validator = new ChangePasswordCommandValidator(); + + var result = validator.Validate( + new ChangePasswordCommand(CurrentPassword: "", NewPassword: NewPassword)); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == nameof(ChangePasswordCommand.CurrentPassword)); + } +}
Mật khẩu mới phải có ít nhất 12 ký tự.
Mật khẩu mới phải khác mật khẩu hiện tại.
Mật khẩu nhập lại không khớp.
Mật khẩu cần tối thiểu 12 ký tự.