All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m10s
Go-live request anh Kiệt FDC (Zalo "user chưa tự đổi pass được hả em"): trước đây chỉ admin
Reset hộ (POST users/{id}/reset-password), user KHÔNG tự đổi được. Thêm self-service:
- BE: ChangePasswordCommand + POST /api/auth/change-password ([Authorize]). Lấy user qua
ICurrentUser.UserId; UserManager.ChangePasswordAsync verify mật khẩu HIỆN TẠI; sai →
ValidationException field CurrentPassword "Mật khẩu hiện tại không đúng."; rule mật khẩu mới
≥ 12 + khác mật khẩu cũ (validator); sau đổi vô hiệu refresh token (mirror ResetPassword).
Qualify SolutionErp...ValidationException (tránh CS0104 clash với FluentValidation).
- FE 2 app (SHA-mirror): ChangePasswordDialog (3 ô current/new/confirm + validate client:
≥12, khớp, khác cũ) wire vào menu tài khoản TopBar — "Đổi mật khẩu" trên "Đăng xuất".
- Test: +ChangePasswordCommandTests (đúng/sai pass cũ keyed-field + msg, <12 boundary, ==cũ,
chưa-auth/UserId-null/user-not-found, RefreshToken cleared). Full suite 431 PASS (45D + 386I).
Build: BE 0 warn/0 err · fe-user + fe-admin build OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
96 lines
3.7 KiB
TypeScript
96 lines
3.7 KiB
TypeScript
import { useEffect, useRef, useState } from '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<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const close = (e: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
|
}
|
|
document.addEventListener('mousedown', close)
|
|
return () => document.removeEventListener('mousedown', close)
|
|
}, [open])
|
|
|
|
const initials = (user?.fullName ?? user?.email ?? '?')
|
|
.split(' ')
|
|
.filter(Boolean)
|
|
.map(s => s[0])
|
|
.slice(-2)
|
|
.join('')
|
|
.toUpperCase()
|
|
|
|
return (
|
|
<div className="relative" ref={ref}>
|
|
<button
|
|
onClick={() => setOpen(o => !o)}
|
|
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition hover:bg-slate-100"
|
|
>
|
|
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-brand-100 text-[11px] font-semibold text-brand-700">
|
|
{initials}
|
|
</span>
|
|
<span className="hidden max-w-32 truncate text-slate-700 md:inline">{user?.fullName ?? user?.email}</span>
|
|
<ChevronDown className={cn('h-3.5 w-3.5 text-slate-400 transition', open && 'rotate-180')} />
|
|
</button>
|
|
{open && (
|
|
<div className="absolute right-0 z-50 mt-2 w-60 overflow-hidden rounded-lg border border-slate-200 bg-white shadow-lg">
|
|
<div className="border-b border-slate-100 px-4 py-3">
|
|
<div className="truncate text-sm font-medium text-slate-800">{user?.fullName}</div>
|
|
<div className="truncate text-xs text-slate-500">{user?.email}</div>
|
|
{user && user.roles.length > 0 && (
|
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
|
{user.roles.map(r => (
|
|
<span key={r} className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
|
{r}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
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"
|
|
>
|
|
<KeyRound className="h-4 w-4" />
|
|
Đổi mật khẩu
|
|
</button>
|
|
<button
|
|
onClick={logout}
|
|
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-slate-600 transition hover:bg-slate-50"
|
|
>
|
|
<LogOut className="h-4 w-4" />
|
|
Đăng xuất
|
|
</button>
|
|
</div>
|
|
)}
|
|
<ChangePasswordDialog open={pwOpen} onClose={() => setPwOpen(false)} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function TopBar({ title }: { title?: string }) {
|
|
return (
|
|
// 2026-06-16: thin brand gradient hairline under the bar (border-b-2 với
|
|
// border-image) + bolder title — đậm nhận diện nhẹ. Signature unchanged.
|
|
<header className="relative flex h-14 items-center justify-between border-b border-slate-200 bg-white px-6">
|
|
<div className="text-sm font-bold text-slate-800">{title}</div>
|
|
<div className="flex items-center gap-2">
|
|
<NotificationBell />
|
|
<UserMenu />
|
|
</div>
|
|
<span aria-hidden className="absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-brand-500/60 via-brand-300/30 to-transparent" />
|
|
</header>
|
|
)
|
|
}
|