user-managementadmin/src/views/user-management/api/internal/user/ và api/internal/activity_log/users, activity_logs tables)/api/users/RouterLink (route-based tab):| Tab | Route name | View component |
|---|---|---|
| Quản lý tài khoản | user_management.user | users/UserView.vue |
| Quản lý hoạt động | user_management.active | activities/ActivityView.vue |
RouterLink với active-class="active", không dùng v-show/v-if — mỗi tab là một route con riêng biệt. Transition hiệu ứng fade-transform được áp dụng qua <Transition>.IndexView.vue ← Tab navigation (2 RouterLinks)
├── users/
│ ├── UserView.vue ← Trang Quản lý tài khoản
│ ├── components/
│ │ ├── DataTable.vue ← Bảng danh sách users
│ │ ├── CreateModal.vue ← Modal tạo user mới
│ │ ├── UpdateModal.vue ← Modal sửa thông tin user
│ │ └── ChangePasswordModal.vue ← Modal đổi mật khẩu
│ ├── composables/
│ │ ├── useDataTable.ts ← Cấu hình columns, actions, delete handler
│ │ └── useFormData.ts ← Form data (create + edit + change password)
│ └── stores/
│ └── useUserStore.ts ← Pinia store: fetchData, pagination, modal states
│
└── activities/
├── ActivityView.vue ← Trang Quản lý hoạt động
├── components/
│ └── DataTable.vue ← Bảng audit log (grouped by user)
├── composables/
│ └── useDataTable.ts ← Columns + groupedData computed
└── stores/
└── useActivityStore.ts ← Pinia store: fetchData, paginationonBeforeMount → userStore.resetUrlParams() (sync URL params) → userStore.fetchData()onUnmounted → userStore.$reset() (reset toàn bộ state về default)NInput bind với userStore.urlParams.keywords, debounce 500ms trước khi gọi onChangePage(1).Lưu ý: Filter theo is_active(trạng thái) đã được comment out — fieldis_activekhông được sử dụng hiện tại dù vẫn còn trongUrlSearchParams.
fetchData():isFetching = true.apiUsers.getList(urlParams).page > 1 và data.length === 0 → tự động lùi về page - 1 (xử lý trường hợp xóa hết item trong trang cuối).listData.isFetching = false.resetUrlParams(): Dùng zod để parse và validate page/limit từ URL:editId (number | null) — Lưu ID của user đang được sửa/đổi mật khẩu. Được set trước khi mở modal tương ứng.| Column | Key | Mô tả |
|---|---|---|
| STT | — | renderIndex(page, limit, index) — số thứ tự theo trang |
| Tên người dùng | name | Sort client-side bằng localeCompare |
email | Hiển thị obfuscated — chỉ lộ 2 ký tự đầu + 2 ký tự cuối | |
| Ngày tạo | created_at | Format bằng $d(date, 'dateTime') (i18n) |
| Tuỳ chọn | — | 3 action buttons |
| Icon | Tooltip | Action |
|---|---|---|
icon-passworddd (màu #23B7E5) | "Đổi mật khẩu" | Set editId, mở isShowChangePasswordModal |
icon-edit | — | Set editId, mở isShowUpdateModal |
icon-trash | — | Dialog xác nhận → apiUsers.delete(row.id) |
authStore.profile?.id !== row.id — user không thể tự xóa tài khoản của mình.name — Tên người dùng (required)email — Email (required, unique)password — Mật khẩu (required)formRef.value?.validate().apiUsers.create(formData).success → đóng modal.showMessageError(error) — hiển thị error từ response.isShow).name — field email đã bị comment out trong template:onBeforeMount → gọi fetchDataDetail() nếu có id.isShow → nếu mở (true) và có id → gọi fetchDataDetail().props.id → nếu id thay đổi khi modal đang mở → reload.fetchDataDetail():apiUsers.getDetail(props.id).formData.name = data.name, formData.email = data.email.isFetching = true/false → form bị disabled trong lúc load.apiUsers.update(props.id, formData) — PUT request với {name}.password (new password, required).fetchDataDetail) — nhưng kết quả chỉ đọc vào dataDetail (không dùng hiển thị gì thêm trong form), có thể dùng để xác nhận user còn tồn tại trước khi đổi mật khẩu.apiUsers.changePassword(props.id, formData) → PUT /api/users/change-password/:id.users tableusersPassword được lưu dưới dạng bcrypt hash — không bao giờ lưu plain text.SecretKey là secret key base32 dùng cho TOTP (Google Authenticator compatible).DeviceToken và IsLoggedIn dùng để quản lý session thiết bị.LastActiveAt tự động cập nhật qua GORM autoUpdateTime.| Value | Name | Quyền |
|---|---|---|
0 | admin | Toàn quyền |
1 | user | Quyền hạn chế |
role_name (string) thay vì số nguyên: User.RoleType(role).String().Password, SecretKey, DeviceToken, IsLoggedIn, LastActiveAt không bao giờ xuất hiện trong response API./api/users/ (Fiber Router, có middleware xác thực JWT)| Method | Path | Handler | Mô tả |
|---|---|---|---|
GET | / | GetListWithPagination | Danh sách users phân trang |
GET | /get-all | GetList | Toàn bộ users (không phân trang) |
GET | /profile | Profile | Thông tin user đang đăng nhập |
GET | /generate-totp | GenerateTOTP | Tạo TOTP secret + URI |
GET | /:id | FindById | Chi tiết một user |
POST | / | Create | Tạo user mới |
POST | /verify-totp | VerifyTOTP | Xác thực mã OTP |
DELETE | /:id | Delete | Xóa user |
PUT | /:id | Update | Cập nhật thông tin user |
PUT | change-password/:id | ChangePassword | Đổi mật khẩu |
page (int, default: 1)
limit (int, default: 20)
keywords (string, optional) — tìm kiếm theo t ên
email (string, optional)
username (string, optional)
is_active (string, optional: "Y"/"N"){
"message": "Lấy dữ liệu thành công",
"data": {
"data": [...UserResponse],
"paginate": {
"page": 1,
"limit": 20,
"total": 29,
"total_pages": 2
}
}
}page và limit từ query → common.Paging struct.pagingData.Fulfill() — set default nếu thiếu (page=1, limit=20).GetListWithPagination(ctx, params, &pagingData).[]*user.User sang []*user.UserResponse (ẩn sensitive fields).{
"name": "Tên người dùng",
"email": "user@example.com",
"password": "password123",
"first_name": "",
"last_name": ""
}dto.CreateUser được validate bởi validate.Validate(body).IsDuplicateEmailError(err) → 400 Bad Request: "Email đã tồn tại".activityLogService.LogActivity(c, "Tạo người dùng", "user", user.ID, nil, nil, body).:id (int){ "password": "newSecurePassword123" }dto.ChangePasswordRequest với validate tag.ChangePassword(ctx, parseUserID, ¶ms):users.password trong MySQL.activityLogService.LogActivity(ctx, "Thay đổi mật khẩu", "user", 0, nil, nil, params) — modelID = 0 (không truyền ID cụ thể).GET /api/users/generate-totpuserID từ JWT token.userService.FindById(userID).userService.GenerateTOTPWithSecret(user):secret_key vào users table.otpauth://totp/{issuer}:{email}?secret={secret}&issuer={issuer}{
"message": "Tạo mã OTP thành công",
"data": "otpauth://totp/TrafficTools:user@example.com?secret=BASE32SECRET&issuer=TrafficTools"
}"Tạo mã OTP", model "user".POST /api/users/verify-totp{ "totp": "123456" }userID từ JWT.userService.VerifyTOTP(userID, body.Totp):secret_key từ DB.pquerna/otp).two_factor_authentication = true.400 "Mã OTP không hợp lệ".onBeforeMount gọi song song:UrlSearchParams nhưng chưa hoạt động:description — Lọc theo tên công việcuser_id — Lọc theo người thao tácfrom / to — Khoảng thời gian (yyyy-MM-dd)fetchData() gọi apiActivities.getList(urlParams) → GET /api/activity-logs/.fetchOptionUsers() và fetchOptiopJobs() đều không thực hiện gì (body bị comment out):activity_logsactivity_logs| Column | Ví dụ giá trị |
|---|---|
action | "Tạo người dùng", "Thay đổi mật khẩu", "Tạo mã OTP" |
model_name | "user", "project", "campaign" |
parameters | {"name":"abc","email":"x@x.com","password":"..."} (JSON) |
old_value | {"name":"old name"} |
new_value | {"name":"new name"} |
ip_address | "192.168.1.100" |
user_agent | "Mozilla/5.0 (Windows NT 10.0; Win64; x64)..." |
activityLogService.LogActivity(ctx, action, modelName, modelID, oldValue, newValue, params)userID → extract từ JWT token bằng jwt.GetUserFromToken(ctx). Nếu lỗi → userID = 0, lưu user_id = NULL.ipAddress → ctx.IP()userAgent → ctx.Get("User-Agent")oldValue, newValue, params đều được serialize thành JSON string qua formatJsonValue():activityLog.LogActivity gặp lỗi khi ghi DB → chỉ log error, không làm fail request chính. Audit logging là best-effort, không blocking.| Handler | Action | ModelName | ModelID | Params |
|---|---|---|---|---|
Create | "Tạo người dùng" | "user" | user.ID | dto.CreateUser (có password!) |
ChangePassword | "Thay đổi mật khẩu" | "user" | 0 | dto.ChangePasswordRequest |
GenerateTOTP | "Tạo mã OTP" | "user" | user.ID | nil |
Security concern: Createhandler log cảparamsbao gồm password text (trước khi hash). Đây là security risk vì password sẽ được lưu (dù là chuỗi đã nhập) trongactivity_logs.parameters.
limit), không giới hạn số activity.useDataTable.ts trong activities không dùng raw data từ store mà group lại theo user bằng computed property:| Column | Mô tả |
|---|---|
| STT | Số thứ tự |
| Người thao tác | Tên user (bold), fallback "Không xác định" |
| Tổng số hoạt động | NTag màu info, hiển thị totalCount |
| Chi tiết hoạt động | NCollapse — click để expand danh sách activities |
DataTable.vue có một NModal kèm NCode để hiển thị JSON của một activity:handleOpenPropertyModal đã bị comment out trong columns, nên modal này không còn được trigger từ UI. Vẫn giữ lại trong code như là tính năng dự phòng./api/activity-logs/| Method | Path | Handler | Mô tả |
|---|---|---|---|
GET | / | GetListWithPagination | Danh sách activity logs phân trang theo user |
page (int, default: 1)
limit (int, default: 20){
"message": "...",
"data": {
"data": [
{
"id": 1,
"user_id": 3,
"action": "Tạo người dùng",
"parameter": {"name": "abc", "email": "x@x.com"},
"model_name": "user",
"model_id": 15,
"old_value": null,
"new_value": null,
"ip_address": "192.168.1.1",
"user_agent": "Mozilla/5.0...",
"created_at": "2026-03-23T07:00:00Z",
"user": {
"id": 3,
"name": "admin",
"email": "ad****in"
}
}
],
"paginate": {
"page": 1,
"limit": 20,
"total": 8, // Số user unique, không phải số activity
"total_pages": 1
}
}
}CPagination)CPagination (custom):CPagination là component dùng chung (@/components), wraps NDataTable's native pagination với thêm các xử lý custom.api/internal/user/
├── user.go ← User struct, UserResponse, RoleType
├── dto/
│ ├── create_user.go ← CreateUser DTO
│ ├── update_user.go ← UpdateUser DTO
│ ├── change_password_request.go ← ChangePasswordRequest DTO
│ ├── verify_totp_request.go ← VerifyTOTPRequest DTO
│ └── get_list_with_pagination_request.go
├── handler/
│ ├── handler.go ← DI, Router setup
│ ├── get_list.go ← GET / (no pagination)
│ ├── get_list_with_pagination.go ← GET / (with pagination)
│ ├── find_by_id.go ← GET /:id
│ ├── create.go ← POST /
│ ├── update.go ← PUT /:id
│ ├── delete.go ← DELETE /:id
│ ├── change_password.go ← PUT /change-password/:id
│ ├── login.go ← POST /login (auth endpoint)
│ ├── profile.go ← GET /profile
│ └── two_factor_authentication.go ← GET /generate-totp, POST /verify-totp
├── repository/
│ └── ...
└── service/
└── ...
api/internal/activity_log/
├── activity_log.go ← ActivityLog struct, ActivityLogResponse
├── dto/
│ └── get_list_pagination_request.go
├── handler/
│ ├── handler.go
│ └── get_list_with_pagination.go
├── repository/
│ ├── repository.go
│ └── get_list_with_pagination.go ← Pagination by unique user logic
└── service/
├── service.go
├── create_log.go ← Ghi một record vào DB
├── log_activity.go ← LogActivity() — public API
└── get_list_with_pagination.gorender() function của DataTable column. API /api/users/ trả về email đầy đủ.is_active chưa hoạt động:urlParams.is_active tồn tại nhưng chưa được truyền vào query và backend cũng chưa xử lý filter này.delete button tự bảo vệ (self-delete prevention):authStore.profile?.id === row.id. Đây là UI-level guard, không phải backend-level guard. Nếu có backend-level check, cần kiểm tra service Delete.Create lưu password:Create handler truyền nguyên body (dto.CreateUser) vào LogActivity → password được lưu vào activity_logs.parameters. Dù password sau này được hash, giá trị originally-typed password vẫn tồn tại trong audit log.total_pages trong response là số trang user, không phải trang activity. Một page có thể chứa hàng trăm activity records nếu một user có nhiều hành động.NCollapse:defaultExpandedNames: [] → tất cả collapse mặc định đóng. Chỉ khi user click "Xem N hoạt động" mới render nội dung bên trong. Tốt về performance khi nhiều user.fetchOptionUsers và fetchOptiopJobs là no-op:onBeforeMount nhưng không làm gì. Nếu filter cần được implement, cần thêm API call vào đây.POST /api/auth/loginapi/internal/user/handler/login.go1. Parse body: { email, password }
2. FindByEmail(body.Email) lấy user từ MySQL
3. Login(user, body, c):
a. ValidatePassword(user.Password, body.Password)
bcrypt.CompareHashAndPassword(hash, plaintext)
b. BEGIN TRANSACTION
c. generateUniqueDeviceToken(tx, c):
SHA-256(UUID-v4 + User-Agent + IP) | Max 5 attempts
d. middleware.HandleLogin(tx, redis, userID, deviceToken, IP)
e. COMMIT TRANSACTION
f. jwt.GenerateJWT(user, ENV) JWT
4. 2FA check:
- ENV.Disable2FA=true AND AppMode=development is_2fa=false
- HasServerPerformanceAccess(name, email) is_2fa=false
- Else: is_2fa = user.TwoFactorAuthentication
5. LogActivity("Đăng nhập", "user", user.ID, nil, nil, body)
6. Return: { token, device_token, is_2fa }users.device_token) và Redis session. HasServerPerformanceAccess là whitelist bypass 2FA cho service accounts/monitoring users.github.com/xlzd/gotptwo_factor | secret_key | Ý nghĩa |
|---|---|---|
false | "" | Chưa setup 2FA |
false | "ABC..." | Đã gen secret, chưa verify |
true | "ABC..." | 2FA hoàn toàn active |
| Method | File | Mô tả |
|---|---|---|
Create(ctx, user) | create.go | bcrypt hash password INSERT |
FindByEmail(email) | find_by_email.go | SELECT WHERE email=? |
FindById(id) | find_by_id.go | SELECT WHERE id=? |
GetList(ctx) | get_list.go | SELECT tất cả |
GetListWithPagination(ctx, params, paging) | get_list_with_pagination.go | Filter + phân trang |
Update(ctx, id, user) | update.go | UPDATE name WHERE id=? |
Delete(ctx, id) | delete.go | DELETE WHERE id=? |
ChangePassword(ctx, id, params) | change_password.go | bcrypt hash mới UPDATE |
Login(user, body, c) | login.go | bcrypt verify + device token + JWT |
GenerateTOTPWithSecret(user) | two_factor_authentication.go | Gen secret + URI |
VerifyTOTP(userId, totp) | two_factor_authentication.go | Verify + activate 2FA |
users/composables/useFormData.ts| Method | Endpoint | Nơi dùng |
|---|---|---|
getList(params) | GET /api/users/ | useUserStore.fetchData() |
create(data) | POST /api/users/ | CreateModal.onSubmit() |
getDetail(id) | GET /api/users/:id | UpdateModal, ChangePasswordModal |
update(id, data) | PUT /api/users/:id | UpdateModal.onSubmit() |
delete(id) | DELETE /api/users/:id | useDataTable.onDeleteRow() |
changePassword(id, data) | PUT /api/users/change-password/:id | ChangePasswordModal.onSubmit() |
apiActivities.getList(params) | GET /api/activity-logs/ | useActivityStore.fetchData() |
Admin click "Thêm tài khoản"
isShowCreateModal = true CreateModal hiển thị
User nhập: name, email, password
formRef.validate() POST /api/users/
Backend:
1. dto.CreateUser validate
2. bcrypt.GenerateFromPassword(password, cost=12)
3. INSERT INTO users (name, email, password, role=1)
4. IsDuplicateEmailError 400 "Email đã tồn tại"
5. LogActivity("Tạo người dùng", body) INSERT activity_logs
Response OK:
message.success
reset form emit success fetchData() reload
isShow = falseClick icon trash (chỉ render nếu row.id !== authStore.profile.id)
window.$dialog.warning("Xác nhận xóa...")
User click "Xóa"
dialog.loading = true
DELETE /api/users/:id
Backend: DELETE FROM users WHERE id = ?
OK: message.success fetchData()
Err: showMessageError dialog.loading = falseuser_id records còn tồn tại với NULL reference sau khi user bị xóa vì GORM không có CASCADE DELETE mặc định).#09484a0d (5% opacity teal) với rounded-[48px] pill shape.<RouterView> dùng <Transition name="fade-transform" mode="out-in"> chuyển tab có hiệu ứng fade./user-management/users UserView.vue
/user-management/activities ActivityView.vueurlParams.is_active tồn tại nhưng không được expose trong UI và backend chưa xử lý.Create handler truyền nguyên dto.CreateUser body (bao gồm plain-text password) vào LogActivity. Password này được serialize và lưu vào activity_logs.parameters.