[CLAUDE] Phase5.1: Security headers + account lockout + Users management
Security hardening:
- Api/Middleware/SecurityHeadersMiddleware MOI: remove server fingerprint (Server, X-Powered-By, ...), add X-Content-Type-Options:nosniff, X-Frame-Options:DENY, Referrer-Policy:strict-origin-when-cross-origin, Permissions-Policy (disable geolocation/mic/cam/payment), X-Permitted-Cross-Domain-Policies:none, CSP (default-src 'self' + img data: + style inline for Tailwind + frame-ancestors 'none'). Skip CSP tren /swagger (dung inline script).
- Program.cs wire UseMiddleware SecurityHeadersMiddleware first in pipeline
- Infrastructure/DependencyInjection Identity options:
- Password.RequiredLength config-driven (Identity:Password:RequiredLength, default 8 dev, override 12+ prod)
- Lockout: DefaultLockoutTimeSpan (15min), MaxFailedAccessAttempts (5), AllowedForNewUsers=true — all config-driven
- LoginCommandHandler: IsLockedOutAsync check truoc → throw voi deadline message, AccessFailedAsync khi sai password, ResetAccessFailedCountAsync khi login thanh cong
Users management:
- Application/Users/UserFeatures.cs: 8 CQRS (ListUsersQuery paging+search, GetUserQuery, CreateUserCommand + Validator, UpdateUserCommand voi self-disable protection, AssignRolesCommand voi self-demote protection (khong tu go Admin), ResetPasswordCommand (invalidate refresh token + unlock), UnlockUserCommand)
- UserDto: Id, Email, FullName, IsActive, IsLocked (computed tu LockoutEnd), CreatedAt, Roles
- Api/Controllers/UsersController: 7 endpoint (Users.Read/Create/Update policies):
- GET / (list paged), GET /{id}, POST /, PUT /{id}, PUT /{id}/roles, POST /{id}/reset-password, POST /{id}/unlock
- using alias ValidationException = Application.Common.Exceptions.ValidationException (fix ambiguity voi FluentValidation)
Frontend fe-admin:
- types/users.ts MOI: User type + AVAILABLE_ROLES 12 role (match BE AppRoles.cs) + RoleLabel Vietnamese
- pages/system/UsersPage.tsx MOI:
- DataTable columns: Email (mono), FullName, Roles (badge chips voi Vietnamese label), IsActive (CheckCircle/XCircle), IsLocked (KeyRound red), CreatedAt
- Actions per row (PermissionGuard Users.Update wrap): Gan role (Shield icon → Dialog grid 12 checkbox), Reset password (KeyRound → Dialog voi warning user se bi logout), Unlock (Unlock icon, chi hien khi isLocked), Toggle active (XCircle/CheckCircle)
- Create user dialog: email + fullName + password (min 8) + grid 12 role checkbox
- Route /system/users vao App.tsx
E2E verified:
- Security headers present tren moi response (check qua curl -I)
- POST /api/users voi roles: [Drafter] → 201 + id
- GET /api/users → paged voi 2 user (admin + new test.drafter)
- TS check fe-admin → pass
- dotnet build → 0 errors
Docs:
- docs/STATUS.md: Phase 5.1 xong, cumulative BE 3700 LOC, 42 endpoints, 17 FE pages
- docs/HANDOFF.md: phase table update row Phase 5.1, last updated timestamp
- docs/changelog/migration-todos.md: tick 6 items Phase 5.1 + 4 items remaining (IDOR, deps scan, admin warning, Roles CRUD)
- docs/changelog/sessions/2026-04-21-1630-phase5-1-security-users.md: session log
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
This commit is contained in:
@ -13,6 +13,7 @@ import { FormsPage } from '@/pages/forms/FormsPage'
|
||||
import { ContractsListPage } from '@/pages/contracts/ContractsListPage'
|
||||
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
|
||||
import { ReportsPage } from '@/pages/ReportsPage'
|
||||
import { UsersPage } from '@/pages/system/UsersPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@ -31,6 +32,7 @@ function App() {
|
||||
<Route path="/master/suppliers" element={<SuppliersPage />} />
|
||||
<Route path="/master/projects" element={<ProjectsPage />} />
|
||||
<Route path="/master/departments" element={<DepartmentsPage />} />
|
||||
<Route path="/system/users" element={<UsersPage />} />
|
||||
<Route path="/system/permissions" element={<PermissionsPage />} />
|
||||
<Route path="/forms" element={<FormsPage />} />
|
||||
<Route path="/contracts" element={<ContractsListPage />} />
|
||||
|
||||
327
fe-admin/src/pages/system/UsersPage.tsx
Normal file
327
fe-admin/src/pages/system/UsersPage.tsx
Normal file
@ -0,0 +1,327 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { KeyRound, Plus, Shield, Unlock, Users, CheckCircle2, XCircle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { DataTable, Pagination, type Column } from '@/components/DataTable'
|
||||
import { PermissionGuard } from '@/components/PermissionGuard'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { MenuKeys } from '@/lib/menuKeys'
|
||||
import type { Paged } from '@/types/master'
|
||||
import { AVAILABLE_ROLES, RoleLabel, type User } from '@/types/users'
|
||||
|
||||
const fmtDate = (s: string) => new Date(s).toLocaleDateString('vi-VN')
|
||||
|
||||
export function UsersPage() {
|
||||
const qc = useQueryClient()
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({ email: '', fullName: '', password: '', roles: [] as string[] })
|
||||
|
||||
const [rolesModal, setRolesModal] = useState<User | null>(null)
|
||||
const [roleSelection, setRoleSelection] = useState<string[]>([])
|
||||
|
||||
const [resetModal, setResetModal] = useState<User | null>(null)
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ['users', { page, search }],
|
||||
queryFn: async () =>
|
||||
(await api.get<Paged<User>>('/users', { params: { page, pageSize: 20, search: search || undefined } })).data,
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
await api.post('/users', createForm)
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['users'] })
|
||||
toast.success('Đã tạo user')
|
||||
setCreateOpen(false)
|
||||
setCreateForm({ email: '', fullName: '', password: '', roles: [] })
|
||||
},
|
||||
onError: err => toast.error(getErrorMessage(err)),
|
||||
})
|
||||
|
||||
const rolesMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!rolesModal) return
|
||||
await api.put(`/users/${rolesModal.id}/roles`, { roles: roleSelection })
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['users'] })
|
||||
toast.success('Đã cập nhật role')
|
||||
setRolesModal(null)
|
||||
},
|
||||
onError: err => toast.error(getErrorMessage(err)),
|
||||
})
|
||||
|
||||
const resetMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!resetModal) return
|
||||
await api.post(`/users/${resetModal.id}/reset-password`, { newPassword })
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(`Đã reset password. User phải login lại.`)
|
||||
setResetModal(null)
|
||||
setNewPassword('')
|
||||
},
|
||||
onError: err => toast.error(getErrorMessage(err)),
|
||||
})
|
||||
|
||||
const unlockMut = useMutation({
|
||||
mutationFn: (id: string) => api.post(`/users/${id}/unlock`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['users'] })
|
||||
toast.success('Đã mở khóa')
|
||||
},
|
||||
onError: err => toast.error(getErrorMessage(err)),
|
||||
})
|
||||
|
||||
const toggleActiveMut = useMutation({
|
||||
mutationFn: (u: User) => api.put(`/users/${u.id}`, { id: u.id, fullName: u.fullName, isActive: !u.isActive }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['users'] })
|
||||
toast.success('Đã cập nhật trạng thái')
|
||||
},
|
||||
onError: err => toast.error(getErrorMessage(err)),
|
||||
})
|
||||
|
||||
function openRoles(u: User) {
|
||||
setRolesModal(u)
|
||||
setRoleSelection([...u.roles])
|
||||
}
|
||||
|
||||
function toggleRole(r: string) {
|
||||
setRoleSelection(sel => (sel.includes(r) ? sel.filter(x => x !== r) : [...sel, r]))
|
||||
}
|
||||
|
||||
function toggleCreateRole(r: string) {
|
||||
setCreateForm(f => ({
|
||||
...f,
|
||||
roles: f.roles.includes(r) ? f.roles.filter(x => x !== r) : [...f.roles, r],
|
||||
}))
|
||||
}
|
||||
|
||||
const columns: Column<User>[] = [
|
||||
{ key: 'email', header: 'Email', render: u => <span className="font-mono text-xs">{u.email}</span> },
|
||||
{ key: 'fullName', header: 'Họ tên', render: u => u.fullName },
|
||||
{
|
||||
key: 'roles',
|
||||
header: 'Vai trò',
|
||||
render: u => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{u.roles.length === 0 && <span className="text-xs text-slate-400">—</span>}
|
||||
{u.roles.map(r => (
|
||||
<span key={r} className="rounded bg-brand-50 px-1.5 py-0.5 text-xs text-brand-700">
|
||||
{RoleLabel[r] ?? r}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
header: 'Active',
|
||||
width: 'w-24',
|
||||
align: 'center',
|
||||
render: u =>
|
||||
u.isActive ? (
|
||||
<CheckCircle2 className="mx-auto h-4 w-4 text-emerald-600" />
|
||||
) : (
|
||||
<XCircle className="mx-auto h-4 w-4 text-slate-400" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'isLocked',
|
||||
header: 'Locked',
|
||||
width: 'w-24',
|
||||
align: 'center',
|
||||
render: u =>
|
||||
u.isLocked ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-red-600">
|
||||
<KeyRound className="h-3.5 w-3.5" />
|
||||
Locked
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">—</span>
|
||||
),
|
||||
},
|
||||
{ key: 'createdAt', header: 'Ngày tạo', width: 'w-28', render: u => fmtDate(u.createdAt) },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'right',
|
||||
width: 'w-52',
|
||||
render: u => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<PermissionGuard menuKey={MenuKeys.Users} action="Update">
|
||||
<Button size="sm" variant="ghost" onClick={() => openRoles(u)} title="Gán role">
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setResetModal(u); setNewPassword('') }} title="Reset password">
|
||||
<KeyRound className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{u.isLocked && (
|
||||
<Button size="sm" variant="ghost" onClick={() => unlockMut.mutate(u.id)} title="Mở khóa">
|
||||
<Unlock className="h-3.5 w-3.5 text-amber-600" />
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={() => toggleActiveMut.mutate(u)} title={u.isActive ? 'Vô hiệu hóa' : 'Kích hoạt'}>
|
||||
{u.isActive ? <XCircle className="h-3.5 w-3.5 text-red-500" /> : <CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />}
|
||||
</Button>
|
||||
</PermissionGuard>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Người dùng
|
||||
</span>
|
||||
}
|
||||
description="Tạo user + gán role để test quyền với non-admin."
|
||||
actions={
|
||||
<PermissionGuard menuKey={MenuKeys.Users} action="Create">
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Thêm user
|
||||
</Button>
|
||||
</PermissionGuard>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-3 flex gap-2">
|
||||
<Input
|
||||
placeholder="Tìm email hoặc tên…"
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(1) }}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable columns={columns} rows={list.data?.items ?? []} getRowKey={u => u.id} isLoading={list.isLoading} />
|
||||
<Pagination page={page} pageSize={20} total={list.data?.total ?? 0} onChange={setPage} />
|
||||
|
||||
{/* Create user */}
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title="Thêm user mới"
|
||||
size="md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Hủy</Button>
|
||||
<Button onClick={(e: FormEvent) => { e.preventDefault(); createMut.mutate() }} disabled={createMut.isPending}>
|
||||
{createMut.isPending ? 'Đang tạo…' : 'Tạo'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form className="space-y-4" onSubmit={e => { e.preventDefault(); createMut.mutate() }}>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Email *</Label>
|
||||
<Input type="email" value={createForm.email} onChange={e => setCreateForm(f => ({ ...f, email: e.target.value }))} required />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Họ tên *</Label>
|
||||
<Input value={createForm.fullName} onChange={e => setCreateForm(f => ({ ...f, fullName: e.target.value }))} required />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Password *</Label>
|
||||
<Input type="password" value={createForm.password} onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))} required minLength={8} />
|
||||
<div className="text-xs text-slate-500">Tối thiểu 8 ký tự + chữ hoa + thường + số + ký tự đặc biệt</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Roles</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{AVAILABLE_ROLES.map(r => (
|
||||
<label key={r} className="flex cursor-pointer items-center gap-2 rounded border border-slate-200 px-2 py-1 text-xs hover:bg-slate-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-brand-600"
|
||||
checked={createForm.roles.includes(r)}
|
||||
onChange={() => toggleCreateRole(r)}
|
||||
/>
|
||||
{RoleLabel[r]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
{/* Assign roles */}
|
||||
<Dialog
|
||||
open={!!rolesModal}
|
||||
onClose={() => setRolesModal(null)}
|
||||
title={`Gán role cho ${rolesModal?.fullName}`}
|
||||
size="md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setRolesModal(null)}>Hủy</Button>
|
||||
<Button onClick={() => rolesMut.mutate()} disabled={rolesMut.isPending}>
|
||||
{rolesMut.isPending ? 'Đang lưu…' : 'Lưu'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{AVAILABLE_ROLES.map(r => (
|
||||
<label key={r} className="flex cursor-pointer items-center gap-2 rounded border border-slate-200 px-2 py-1 text-xs hover:bg-slate-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-brand-600"
|
||||
checked={roleSelection.includes(r)}
|
||||
onChange={() => toggleRole(r)}
|
||||
/>
|
||||
{RoleLabel[r]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Reset password */}
|
||||
<Dialog
|
||||
open={!!resetModal}
|
||||
onClose={() => setResetModal(null)}
|
||||
title={`Reset password: ${resetModal?.email}`}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setResetModal(null)}>Hủy</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => resetMut.mutate()}
|
||||
disabled={resetMut.isPending || newPassword.length < 8}
|
||||
>
|
||||
{resetMut.isPending ? 'Đang reset…' : 'Reset'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||
User sẽ bị logout khỏi mọi session + phải login lại với password mới.
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Password mới</Label>
|
||||
<Input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} minLength={8} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
fe-admin/src/types/users.ts
Normal file
47
fe-admin/src/types/users.ts
Normal file
@ -0,0 +1,47 @@
|
||||
export type User = {
|
||||
id: string
|
||||
email: string
|
||||
fullName: string
|
||||
isActive: boolean
|
||||
isLocked: boolean
|
||||
createdAt: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
export type CreateUserInput = {
|
||||
email: string
|
||||
fullName: string
|
||||
password: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
// 12 role seed trong BE (AppRoles.cs)
|
||||
export const AVAILABLE_ROLES = [
|
||||
'Admin',
|
||||
'Drafter',
|
||||
'DeptManager',
|
||||
'ProjectManager',
|
||||
'Procurement',
|
||||
'CostControl',
|
||||
'Finance',
|
||||
'Accounting',
|
||||
'Equipment',
|
||||
'Director',
|
||||
'AuthorizedSigner',
|
||||
'HrAdmin',
|
||||
] as const
|
||||
|
||||
export const RoleLabel: Record<string, string> = {
|
||||
Admin: 'Quản trị',
|
||||
Drafter: 'Người soạn',
|
||||
DeptManager: 'Trưởng phòng ban',
|
||||
ProjectManager: 'Giám đốc dự án',
|
||||
Procurement: 'Cung ứng',
|
||||
CostControl: 'Kiểm soát chi phí',
|
||||
Finance: 'Tài chính',
|
||||
Accounting: 'Kế toán',
|
||||
Equipment: 'Thiết bị',
|
||||
Director: 'Ban Giám đốc',
|
||||
AuthorizedSigner: 'Người ủy quyền ký',
|
||||
HrAdmin: 'Nhân sự / Đóng dấu',
|
||||
}
|
||||
Reference in New Issue
Block a user