From 8b8df72c9690a6f1accca2593d53ef0c10f233d3 Mon Sep 17 00:00:00 2001 From: DangQuangTien <136679780+tienml@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:33:38 +0700 Subject: [PATCH 1/4] Delete doc/api directory --- doc/api/SecurityPassword_API_Documentation.md | 903 ------------------ 1 file changed, 903 deletions(-) delete mode 100644 doc/api/SecurityPassword_API_Documentation.md diff --git a/doc/api/SecurityPassword_API_Documentation.md b/doc/api/SecurityPassword_API_Documentation.md deleted file mode 100644 index bbf77d8..0000000 --- a/doc/api/SecurityPassword_API_Documentation.md +++ /dev/null @@ -1,903 +0,0 @@ -# SecurityPassword API Documentation - -**Project:** Modern Password Authentication System -**Backend:** Spring Boot REST API -**Database:** SQL Server -**Authentication:** JWT Bearer Token -**Password hashing:** BCrypt -**Main response format:** `ApiResponse` - ---- - -## 1. Base URL - -### Local backend - -```text -http://localhost:8080 -``` - -### Khi dùng ngrok - -```text -https://your-backend-ngrok-url.ngrok-free.app -``` - -Frontend chỉ cần thay `BASE_URL` theo môi trường. - -```js -const BASE_URL = "http://localhost:8080"; -// hoặc -const BASE_URL = "https://your-backend-ngrok-url.ngrok-free.app"; -``` - ---- - -## 2. Response format chung - -Tất cả API nên trả về theo format: - -```json -{ - "success": true, - "message": "Thông báo kết quả", - "data": {} -} -``` - -Khi lỗi: - -```json -{ - "success": false, - "message": "Nội dung lỗi", - "data": null -} -``` - ---- - -## 3. Authentication header - -Các API cần đăng nhập phải gửi JWT trong header: - -```http -Authorization: Bearer -``` - -Ví dụ: - -```js -headers: { - "Authorization": `Bearer ${token}`, - "Content-Type": "application/json" -} -``` - ---- - -# 4. Public Auth APIs - -Các API này **không cần token**. - ---- - -## 4.1. Register - -Đăng ký tài khoản người dùng thường. - -```http -POST /api/auth/register -``` - -### Request body - -```json -{ - "userName": "tien", - "email": "tien@example.com", - "password": "Tien@123456" -} -``` - -### Validation - -| Field | Rule | -|---|---| -| `userName` | Không rỗng, 4–50 ký tự | -| `email` | Không rỗng, đúng định dạng email | -| `password` | Không rỗng, tối thiểu 8 ký tự | - -### Lưu ý cho frontend - -- Không gửi field `role`. -- Backend luôn tạo tài khoản mới với `role = USER`. -- Mật khẩu được hash bằng BCrypt trước khi lưu database. - -### Success response - -```json -{ - "success": true, - "message": "Đăng ký tài khoản thành công", - "data": { - "id": 1, - "userName": "tien", - "email": "tien@example.com", - "role": "USER", - "isActive": true, - "failedAttempts": 0, - "lockedUntil": null, - "lastLoginAt": null, - "passwordChangedAt": null, - "createdAt": "2026-07-01T10:00:00" - } -} -``` - -### Possible errors - -```json -{ - "success": false, - "message": "Username đã tồn tại", - "data": null -} -``` - -```json -{ - "success": false, - "message": "Email đã tồn tại", - "data": null -} -``` - ---- - -## 4.2. Login - -Đăng nhập bằng username hoặc email. - -```http -POST /api/auth/login -``` - -### Request body - -```json -{ - "loginIdentifier": "tien", - "password": "Tien@123456" -} -``` - -Hoặc: - -```json -{ - "loginIdentifier": "tien@example.com", - "password": "Tien@123456" -} -``` - -### Success response - -```json -{ - "success": true, - "message": "Đăng nhập thành công", - "data": { - "token": "eyJhbGciOiJIUzI1NiJ9...", - "tokenType": "Bearer", - "expiresIn": 86400000, - "userId": 1, - "userName": "tien", - "email": "tien@example.com", - "role": "USER" - } -} -``` - -### Frontend cần làm sau khi login thành công - -Lưu token và thông tin user. - -Ví dụ: - -```js -localStorage.setItem("token", response.data.token); -localStorage.setItem("role", response.data.role); -localStorage.setItem("userName", response.data.userName); -``` - -Điều hướng: - -```text -role = USER → chuyển vào trang user/home -role = ADMIN → chuyển vào trang admin/dashboard -``` - -### Possible errors - -```json -{ - "success": false, - "message": "Tài khoản hoặc mật khẩu không đúng", - "data": null -} -``` - -```json -{ - "success": false, - "message": "Tài khoản đang bị khóa tạm thời", - "data": null -} -``` - -```json -{ - "success": false, - "message": "Tài khoản đã bị vô hiệu hóa", - "data": null -} -``` - ---- - -## 4.3. Forgot Password - -Gửi OTP về email để reset mật khẩu. - -```http -POST /api/auth/forgot-password -``` - -### Request body - -```json -{ - "email": "tien@example.com" -} -``` - -### Success response - -```json -{ - "success": true, - "message": "OTP đã được gửi về email", - "data": null -} -``` - -### Frontend flow - -1. Người dùng nhập email. -2. Gọi API `/api/auth/forgot-password`. -3. Nếu thành công, chuyển sang trang nhập OTP và mật khẩu mới. -4. Truyền email sang trang reset password. -5. Ở trang reset password, hiển thị email dạng chỉ đọc, không cho sửa. - -Ví dụ UI: - -```text -Email: tien@example.com [readonly] -OTP: [______] -Mật khẩu mới: [______] -Xác nhận mật khẩu mới: [______] -``` - -### Possible errors - -```json -{ - "success": false, - "message": "Email không tồn tại trong hệ thống", - "data": null -} -``` - ---- - -## 4.4. Reset Password - -Xác thực OTP và đặt lại mật khẩu mới. - -```http -POST /api/auth/reset-password -``` - -### Request body - -```json -{ - "email": "tien@example.com", - "otp": "123456", - "newPassword": "NewPassword@123" -} -``` - -### Lưu ý - -- `email` vẫn phải gửi lên backend dù frontend không cho người dùng sửa email. -- Backend dùng email để xác định OTP thuộc tài khoản nào. -- OTP được hash trong database, backend dùng BCrypt để verify. -- Sau khi reset password thành công, toàn bộ session/token cũ của user bị vô hiệu hóa. - -### Success response - -```json -{ - "success": true, - "message": "Đặt lại mật khẩu thành công", - "data": null -} -``` - -### Frontend cần làm sau khi reset thành công - -```text -1. Xóa token cũ nếu có. -2. Chuyển về trang login. -3. Hiển thị thông báo: Đặt lại mật khẩu thành công, vui lòng đăng nhập lại. -``` - -### Possible errors - -```json -{ - "success": false, - "message": "OTP không tồn tại hoặc đã hết hạn", - "data": null -} -``` - -```json -{ - "success": false, - "message": "OTP không đúng", - "data": null -} -``` - -```json -{ - "success": false, - "message": "OTP đã nhập sai quá nhiều lần", - "data": null -} -``` - ---- - -# 5. Authenticated User APIs - -Các API này **cần token**. - ---- - -## 5.1. Get Current User - -Lấy thông tin tài khoản đang đăng nhập. - -```http -GET /api/auth/me -``` - -### Headers - -```http -Authorization: Bearer -``` - -### Success response - -```json -{ - "success": true, - "message": "Lấy thông tin tài khoản thành công", - "data": { - "id": 1, - "userName": "tien", - "email": "tien@example.com", - "role": "USER", - "isActive": true, - "failedAttempts": 0, - "lockedUntil": null, - "lastLoginAt": "2026-07-01T10:20:00", - "passwordChangedAt": null, - "createdAt": "2026-07-01T10:00:00" - } -} -``` - ---- - -## 5.2. Change Password - -Đổi mật khẩu khi người dùng/admin đang đăng nhập. - -```http -POST /api/auth/change-password -``` - -### Headers - -```http -Authorization: Bearer -``` - -### Request body - -```json -{ - "currentPassword": "OldPassword@123", - "newPassword": "NewPassword@123" -} -``` - -### Success response - -```json -{ - "success": true, - "message": "Đổi mật khẩu thành công. Vui lòng đăng nhập lại.", - "data": null -} -``` - -### Frontend cần làm sau khi đổi mật khẩu thành công - -Do backend vô hiệu hóa toàn bộ session/token cũ sau khi đổi mật khẩu, frontend cần: - -```text -1. Xóa token khỏi localStorage/sessionStorage. -2. Xóa role/userName nếu đã lưu. -3. Chuyển về trang login. -4. Hiển thị thông báo: Đổi mật khẩu thành công, vui lòng đăng nhập lại. -``` - -Ví dụ: - -```js -localStorage.removeItem("token"); -localStorage.removeItem("role"); -localStorage.removeItem("userName"); -navigate("/login"); -``` - -### Possible errors - -```json -{ - "success": false, - "message": "Mật khẩu hiện tại không đúng", - "data": null -} -``` - -```json -{ - "success": false, - "message": "Mật khẩu mới không được trùng mật khẩu hiện tại", - "data": null -} -``` - ---- - -# 6. Admin APIs - -Các API này cần token của tài khoản có: - -```text -role = ADMIN -``` - -Frontend cần gửi: - -```http -Authorization: Bearer -``` - -Nếu user thường gọi API admin, backend sẽ trả lỗi 403 Forbidden. - ---- - -## 6.1. Get All Users - -Lấy danh sách tài khoản. - -```http -GET /api/admin/users -``` - -### Success response - -```json -{ - "success": true, - "message": "Lấy danh sách user thành công", - "data": [ - { - "id": 1, - "userName": "admin", - "email": "admin@example.com", - "role": "ADMIN", - "isActive": true, - "failedAttempts": 0, - "lockedUntil": null, - "lastLoginAt": "2026-07-01T10:20:00", - "passwordChangedAt": null, - "createdAt": "2026-07-01T10:00:00" - }, - { - "id": 2, - "userName": "tien", - "email": "tien@example.com", - "role": "USER", - "isActive": true, - "failedAttempts": 0, - "lockedUntil": null, - "lastLoginAt": null, - "passwordChangedAt": null, - "createdAt": "2026-07-01T10:05:00" - } - ] -} -``` - ---- - -## 6.2. Get Login Logs - -Admin xem log đăng nhập thành công/thất bại. - -```http -GET /api/admin/login-logs -``` - -### Success response - -```json -{ - "success": true, - "message": "Lấy login logs thành công", - "data": [ - { - "id": 1, - "userId": 1, - "userName": "admin", - "success": true, - "reason": "LOGIN_SUCCESS", - "ipAddress": "127.0.0.1", - "userAgent": "Mozilla/5.0...", - "createdAt": "2026-07-01T10:20:00" - }, - { - "id": 2, - "userId": null, - "userName": "hacker123", - "success": false, - "reason": "USER_NOT_FOUND", - "ipAddress": "127.0.0.1", - "userAgent": "Mozilla/5.0...", - "createdAt": "2026-07-01T10:21:00" - } - ] -} -``` - -### Các reason có thể gặp - -```text -LOGIN_SUCCESS -USER_NOT_FOUND -WRONG_PASSWORD -ACCOUNT_LOCKED -ACCOUNT_INACTIVE -``` - ---- - -## 6.3. Get Sessions - -Admin xem danh sách session/JWT. - -```http -GET /api/admin/sessions -``` - -### Success response - -```json -{ - "success": true, - "message": "Lấy danh sách session thành công", - "data": [ - { - "id": 1, - "userId": 1, - "userName": "admin", - "jwtId": "8e6b1d21-9f5e-4d5a-b4a1-123456789abc", - "isActive": true, - "createdAt": "2026-07-01T10:20:00", - "expiresAt": "2026-07-02T10:20:00", - "revokedAt": null, - "revokedReason": null - }, - { - "id": 2, - "userId": 2, - "userName": "tien", - "jwtId": "7c6f1e11-1f2a-4a1a-b1c2-abcdef123456", - "isActive": false, - "createdAt": "2026-07-01T09:00:00", - "expiresAt": "2026-07-02T09:00:00", - "revokedAt": "2026-07-01T10:00:00", - "revokedReason": "PASSWORD_CHANGED" - } - ] -} -``` - -### Revoked reason có thể gặp - -```text -PASSWORD_CHANGED -PASSWORD_RESET -``` - ---- - -## 6.4. Get Password Change Logs - -Admin xem log đổi/reset mật khẩu. - -```http -GET /api/admin/password-change-logs -``` - -### Success response - -```json -{ - "success": true, - "message": "Lấy password change logs thành công", - "data": [ - { - "id": 1, - "userId": 2, - "userName": "tien", - "changeType": "CHANGE_PASSWORD", - "note": "User changed password", - "ipAddress": "127.0.0.1", - "userAgent": "Mozilla/5.0...", - "createdAt": "2026-07-01T10:30:00" - }, - { - "id": 2, - "userId": 2, - "userName": "tien", - "changeType": "RESET_PASSWORD", - "note": "Password reset by OTP", - "ipAddress": "127.0.0.1", - "userAgent": "Mozilla/5.0...", - "createdAt": "2026-07-01T11:00:00" - } - ] -} -``` - ---- - -# 7. Frontend flows - ---- - -## 7.1. Register flow - -```text -User nhập username, email, password -→ Frontend gọi POST /api/auth/register -→ Nếu thành công: chuyển sang login -→ Nếu lỗi: hiển thị message từ response -``` - ---- - -## 7.2. Login flow - -```text -User nhập username/email + password -→ Frontend gọi POST /api/auth/login -→ Nếu thành công: - - Lưu token - - Lưu role - - Nếu role = ADMIN → vào admin dashboard - - Nếu role = USER → vào user home -→ Nếu lỗi: - - Hiển thị message từ response -``` - ---- - -## 7.3. Protected API flow - -```text -Frontend lấy token đã lưu -→ Gửi header Authorization: Bearer -→ Backend kiểm tra: - - JWT hợp lệ - - JWT chưa hết hạn - - Session trong bảng sessions còn active - - User còn active -→ Nếu hợp lệ: cho truy cập -→ Nếu không hợp lệ: trả 401/403 -``` - ---- - -## 7.4. Change password flow - -```text -User/Admin đang đăng nhập -→ Nhập currentPassword + newPassword -→ Frontend gọi POST /api/auth/change-password kèm token -→ Backend: - - Verify currentPassword bằng BCrypt - - Hash newPassword bằng BCrypt - - Update password_hash - - Revoke toàn bộ session cũ - - Ghi password_change_logs -→ Frontend: - - Xóa token - - Chuyển về login -``` - ---- - -## 7.5. Forgot/reset password flow - -```text -Bước 1: Forgot password -User nhập email -→ Frontend gọi POST /api/auth/forgot-password -→ Backend tạo OTP 6 số, hash OTP, lưu DB, gửi email -→ Frontend chuyển sang trang reset password - -Bước 2: Reset password -Frontend tự truyền email sang trang reset -→ Email hiển thị readonly -→ User nhập OTP + mật khẩu mới -→ Frontend gọi POST /api/auth/reset-password -→ Backend verify OTP bằng BCrypt -→ Backend đổi password_hash -→ Backend revoke toàn bộ session cũ -→ Frontend chuyển về login -``` - ---- - -## 7.6. Admin flow - -```text -Admin login bằng POST /api/auth/login -→ Backend trả token với role = ADMIN -→ Frontend lưu token và role -→ Chuyển vào admin dashboard -→ Admin gọi: - GET /api/admin/users - GET /api/admin/login-logs - GET /api/admin/sessions - GET /api/admin/password-change-logs -``` - ---- - -# 8. Cách tạo tài khoản admin - -Hệ thống không cho người dùng tự đăng ký admin từ frontend. - -Cách tạo admin trong demo: - -### Bước 1: Đăng ký tài khoản admin bằng API register - -```json -{ - "userName": "admin", - "email": "admin@example.com", - "password": "Admin@123456" -} -``` - -Tài khoản này ban đầu có role: - -```text -USER -``` - -### Bước 2: Set role admin trong SQL Server - -```sql -UPDATE users -SET role = 'ADMIN' -WHERE username = 'admin'; -``` - -### Bước 3: Đăng nhập lại tài khoản admin - -```json -{ - "loginIdentifier": "admin", - "password": "Admin@123456" -} -``` - -Nếu login thành công, response sẽ có: - -```json -"role": "ADMIN" -``` - ---- - -# 9. Frontend notes - -## 9.1. Không hiển thị password hash - -Frontend không cần hiển thị `password_hash`. -Phần chứng minh mật khẩu đã được bảo vệ sẽ chụp trong SQL Server. - -## 9.2. Role-based routing - -Frontend nên có logic: - -```text -Nếu chưa có token → vào login -Nếu role = USER → vào trang user -Nếu role = ADMIN → vào trang admin -``` - -## 9.3. Token invalid sau đổi/reset mật khẩu - -Sau khi đổi mật khẩu hoặc reset mật khẩu, token cũ không dùng được nữa vì backend đã revoke session. - -Frontend nên xử lý: - -```text -Nếu API trả 401/403: -- Xóa token local -- Chuyển về login -``` - -## 9.4. Ngrok - -Nếu backend chạy qua ngrok, frontend gọi API bằng URL ngrok backend. - -Ví dụ: - -```js -const BASE_URL = "https://abc-xyz.ngrok-free.app"; -``` - -Backend cần cấu hình CORS cho domain frontend. - ---- - -# 10. Summary endpoints - -| Method | Endpoint | Auth | Role | Chức năng | -|---|---|---|---|---| -| POST | `/api/auth/register` | No | Any | Đăng ký user | -| POST | `/api/auth/login` | No | Any | Đăng nhập | -| POST | `/api/auth/forgot-password` | No | Any | Gửi OTP | -| POST | `/api/auth/reset-password` | No | Any | Reset mật khẩu bằng OTP | -| GET | `/api/auth/me` | Yes | USER/ADMIN | Lấy thông tin tài khoản hiện tại | -| POST | `/api/auth/change-password` | Yes | USER/ADMIN | Đổi mật khẩu | -| GET | `/api/admin/users` | Yes | ADMIN | Xem danh sách user | -| GET | `/api/admin/login-logs` | Yes | ADMIN | Xem log đăng nhập | -| GET | `/api/admin/sessions` | Yes | ADMIN | Xem session | -| GET | `/api/admin/password-change-logs` | Yes | ADMIN | Xem log đổi/reset mật khẩu | From 0eab885f1644a5d28ff4fb8b32f6e2da5d4afdf6 Mon Sep 17 00:00:00 2001 From: DangQuangTien Date: Wed, 1 Jul 2026 18:48:31 +0700 Subject: [PATCH 2/4] docs: add API documentation for frontend --- doc/api/SecurityPassword_API_Documentation.md | 903 ++++++++++++++++++ 1 file changed, 903 insertions(+) create mode 100644 doc/api/SecurityPassword_API_Documentation.md diff --git a/doc/api/SecurityPassword_API_Documentation.md b/doc/api/SecurityPassword_API_Documentation.md new file mode 100644 index 0000000..bbf77d8 --- /dev/null +++ b/doc/api/SecurityPassword_API_Documentation.md @@ -0,0 +1,903 @@ +# SecurityPassword API Documentation + +**Project:** Modern Password Authentication System +**Backend:** Spring Boot REST API +**Database:** SQL Server +**Authentication:** JWT Bearer Token +**Password hashing:** BCrypt +**Main response format:** `ApiResponse` + +--- + +## 1. Base URL + +### Local backend + +```text +http://localhost:8080 +``` + +### Khi dùng ngrok + +```text +https://your-backend-ngrok-url.ngrok-free.app +``` + +Frontend chỉ cần thay `BASE_URL` theo môi trường. + +```js +const BASE_URL = "http://localhost:8080"; +// hoặc +const BASE_URL = "https://your-backend-ngrok-url.ngrok-free.app"; +``` + +--- + +## 2. Response format chung + +Tất cả API nên trả về theo format: + +```json +{ + "success": true, + "message": "Thông báo kết quả", + "data": {} +} +``` + +Khi lỗi: + +```json +{ + "success": false, + "message": "Nội dung lỗi", + "data": null +} +``` + +--- + +## 3. Authentication header + +Các API cần đăng nhập phải gửi JWT trong header: + +```http +Authorization: Bearer +``` + +Ví dụ: + +```js +headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json" +} +``` + +--- + +# 4. Public Auth APIs + +Các API này **không cần token**. + +--- + +## 4.1. Register + +Đăng ký tài khoản người dùng thường. + +```http +POST /api/auth/register +``` + +### Request body + +```json +{ + "userName": "tien", + "email": "tien@example.com", + "password": "Tien@123456" +} +``` + +### Validation + +| Field | Rule | +|---|---| +| `userName` | Không rỗng, 4–50 ký tự | +| `email` | Không rỗng, đúng định dạng email | +| `password` | Không rỗng, tối thiểu 8 ký tự | + +### Lưu ý cho frontend + +- Không gửi field `role`. +- Backend luôn tạo tài khoản mới với `role = USER`. +- Mật khẩu được hash bằng BCrypt trước khi lưu database. + +### Success response + +```json +{ + "success": true, + "message": "Đăng ký tài khoản thành công", + "data": { + "id": 1, + "userName": "tien", + "email": "tien@example.com", + "role": "USER", + "isActive": true, + "failedAttempts": 0, + "lockedUntil": null, + "lastLoginAt": null, + "passwordChangedAt": null, + "createdAt": "2026-07-01T10:00:00" + } +} +``` + +### Possible errors + +```json +{ + "success": false, + "message": "Username đã tồn tại", + "data": null +} +``` + +```json +{ + "success": false, + "message": "Email đã tồn tại", + "data": null +} +``` + +--- + +## 4.2. Login + +Đăng nhập bằng username hoặc email. + +```http +POST /api/auth/login +``` + +### Request body + +```json +{ + "loginIdentifier": "tien", + "password": "Tien@123456" +} +``` + +Hoặc: + +```json +{ + "loginIdentifier": "tien@example.com", + "password": "Tien@123456" +} +``` + +### Success response + +```json +{ + "success": true, + "message": "Đăng nhập thành công", + "data": { + "token": "eyJhbGciOiJIUzI1NiJ9...", + "tokenType": "Bearer", + "expiresIn": 86400000, + "userId": 1, + "userName": "tien", + "email": "tien@example.com", + "role": "USER" + } +} +``` + +### Frontend cần làm sau khi login thành công + +Lưu token và thông tin user. + +Ví dụ: + +```js +localStorage.setItem("token", response.data.token); +localStorage.setItem("role", response.data.role); +localStorage.setItem("userName", response.data.userName); +``` + +Điều hướng: + +```text +role = USER → chuyển vào trang user/home +role = ADMIN → chuyển vào trang admin/dashboard +``` + +### Possible errors + +```json +{ + "success": false, + "message": "Tài khoản hoặc mật khẩu không đúng", + "data": null +} +``` + +```json +{ + "success": false, + "message": "Tài khoản đang bị khóa tạm thời", + "data": null +} +``` + +```json +{ + "success": false, + "message": "Tài khoản đã bị vô hiệu hóa", + "data": null +} +``` + +--- + +## 4.3. Forgot Password + +Gửi OTP về email để reset mật khẩu. + +```http +POST /api/auth/forgot-password +``` + +### Request body + +```json +{ + "email": "tien@example.com" +} +``` + +### Success response + +```json +{ + "success": true, + "message": "OTP đã được gửi về email", + "data": null +} +``` + +### Frontend flow + +1. Người dùng nhập email. +2. Gọi API `/api/auth/forgot-password`. +3. Nếu thành công, chuyển sang trang nhập OTP và mật khẩu mới. +4. Truyền email sang trang reset password. +5. Ở trang reset password, hiển thị email dạng chỉ đọc, không cho sửa. + +Ví dụ UI: + +```text +Email: tien@example.com [readonly] +OTP: [______] +Mật khẩu mới: [______] +Xác nhận mật khẩu mới: [______] +``` + +### Possible errors + +```json +{ + "success": false, + "message": "Email không tồn tại trong hệ thống", + "data": null +} +``` + +--- + +## 4.4. Reset Password + +Xác thực OTP và đặt lại mật khẩu mới. + +```http +POST /api/auth/reset-password +``` + +### Request body + +```json +{ + "email": "tien@example.com", + "otp": "123456", + "newPassword": "NewPassword@123" +} +``` + +### Lưu ý + +- `email` vẫn phải gửi lên backend dù frontend không cho người dùng sửa email. +- Backend dùng email để xác định OTP thuộc tài khoản nào. +- OTP được hash trong database, backend dùng BCrypt để verify. +- Sau khi reset password thành công, toàn bộ session/token cũ của user bị vô hiệu hóa. + +### Success response + +```json +{ + "success": true, + "message": "Đặt lại mật khẩu thành công", + "data": null +} +``` + +### Frontend cần làm sau khi reset thành công + +```text +1. Xóa token cũ nếu có. +2. Chuyển về trang login. +3. Hiển thị thông báo: Đặt lại mật khẩu thành công, vui lòng đăng nhập lại. +``` + +### Possible errors + +```json +{ + "success": false, + "message": "OTP không tồn tại hoặc đã hết hạn", + "data": null +} +``` + +```json +{ + "success": false, + "message": "OTP không đúng", + "data": null +} +``` + +```json +{ + "success": false, + "message": "OTP đã nhập sai quá nhiều lần", + "data": null +} +``` + +--- + +# 5. Authenticated User APIs + +Các API này **cần token**. + +--- + +## 5.1. Get Current User + +Lấy thông tin tài khoản đang đăng nhập. + +```http +GET /api/auth/me +``` + +### Headers + +```http +Authorization: Bearer +``` + +### Success response + +```json +{ + "success": true, + "message": "Lấy thông tin tài khoản thành công", + "data": { + "id": 1, + "userName": "tien", + "email": "tien@example.com", + "role": "USER", + "isActive": true, + "failedAttempts": 0, + "lockedUntil": null, + "lastLoginAt": "2026-07-01T10:20:00", + "passwordChangedAt": null, + "createdAt": "2026-07-01T10:00:00" + } +} +``` + +--- + +## 5.2. Change Password + +Đổi mật khẩu khi người dùng/admin đang đăng nhập. + +```http +POST /api/auth/change-password +``` + +### Headers + +```http +Authorization: Bearer +``` + +### Request body + +```json +{ + "currentPassword": "OldPassword@123", + "newPassword": "NewPassword@123" +} +``` + +### Success response + +```json +{ + "success": true, + "message": "Đổi mật khẩu thành công. Vui lòng đăng nhập lại.", + "data": null +} +``` + +### Frontend cần làm sau khi đổi mật khẩu thành công + +Do backend vô hiệu hóa toàn bộ session/token cũ sau khi đổi mật khẩu, frontend cần: + +```text +1. Xóa token khỏi localStorage/sessionStorage. +2. Xóa role/userName nếu đã lưu. +3. Chuyển về trang login. +4. Hiển thị thông báo: Đổi mật khẩu thành công, vui lòng đăng nhập lại. +``` + +Ví dụ: + +```js +localStorage.removeItem("token"); +localStorage.removeItem("role"); +localStorage.removeItem("userName"); +navigate("/login"); +``` + +### Possible errors + +```json +{ + "success": false, + "message": "Mật khẩu hiện tại không đúng", + "data": null +} +``` + +```json +{ + "success": false, + "message": "Mật khẩu mới không được trùng mật khẩu hiện tại", + "data": null +} +``` + +--- + +# 6. Admin APIs + +Các API này cần token của tài khoản có: + +```text +role = ADMIN +``` + +Frontend cần gửi: + +```http +Authorization: Bearer +``` + +Nếu user thường gọi API admin, backend sẽ trả lỗi 403 Forbidden. + +--- + +## 6.1. Get All Users + +Lấy danh sách tài khoản. + +```http +GET /api/admin/users +``` + +### Success response + +```json +{ + "success": true, + "message": "Lấy danh sách user thành công", + "data": [ + { + "id": 1, + "userName": "admin", + "email": "admin@example.com", + "role": "ADMIN", + "isActive": true, + "failedAttempts": 0, + "lockedUntil": null, + "lastLoginAt": "2026-07-01T10:20:00", + "passwordChangedAt": null, + "createdAt": "2026-07-01T10:00:00" + }, + { + "id": 2, + "userName": "tien", + "email": "tien@example.com", + "role": "USER", + "isActive": true, + "failedAttempts": 0, + "lockedUntil": null, + "lastLoginAt": null, + "passwordChangedAt": null, + "createdAt": "2026-07-01T10:05:00" + } + ] +} +``` + +--- + +## 6.2. Get Login Logs + +Admin xem log đăng nhập thành công/thất bại. + +```http +GET /api/admin/login-logs +``` + +### Success response + +```json +{ + "success": true, + "message": "Lấy login logs thành công", + "data": [ + { + "id": 1, + "userId": 1, + "userName": "admin", + "success": true, + "reason": "LOGIN_SUCCESS", + "ipAddress": "127.0.0.1", + "userAgent": "Mozilla/5.0...", + "createdAt": "2026-07-01T10:20:00" + }, + { + "id": 2, + "userId": null, + "userName": "hacker123", + "success": false, + "reason": "USER_NOT_FOUND", + "ipAddress": "127.0.0.1", + "userAgent": "Mozilla/5.0...", + "createdAt": "2026-07-01T10:21:00" + } + ] +} +``` + +### Các reason có thể gặp + +```text +LOGIN_SUCCESS +USER_NOT_FOUND +WRONG_PASSWORD +ACCOUNT_LOCKED +ACCOUNT_INACTIVE +``` + +--- + +## 6.3. Get Sessions + +Admin xem danh sách session/JWT. + +```http +GET /api/admin/sessions +``` + +### Success response + +```json +{ + "success": true, + "message": "Lấy danh sách session thành công", + "data": [ + { + "id": 1, + "userId": 1, + "userName": "admin", + "jwtId": "8e6b1d21-9f5e-4d5a-b4a1-123456789abc", + "isActive": true, + "createdAt": "2026-07-01T10:20:00", + "expiresAt": "2026-07-02T10:20:00", + "revokedAt": null, + "revokedReason": null + }, + { + "id": 2, + "userId": 2, + "userName": "tien", + "jwtId": "7c6f1e11-1f2a-4a1a-b1c2-abcdef123456", + "isActive": false, + "createdAt": "2026-07-01T09:00:00", + "expiresAt": "2026-07-02T09:00:00", + "revokedAt": "2026-07-01T10:00:00", + "revokedReason": "PASSWORD_CHANGED" + } + ] +} +``` + +### Revoked reason có thể gặp + +```text +PASSWORD_CHANGED +PASSWORD_RESET +``` + +--- + +## 6.4. Get Password Change Logs + +Admin xem log đổi/reset mật khẩu. + +```http +GET /api/admin/password-change-logs +``` + +### Success response + +```json +{ + "success": true, + "message": "Lấy password change logs thành công", + "data": [ + { + "id": 1, + "userId": 2, + "userName": "tien", + "changeType": "CHANGE_PASSWORD", + "note": "User changed password", + "ipAddress": "127.0.0.1", + "userAgent": "Mozilla/5.0...", + "createdAt": "2026-07-01T10:30:00" + }, + { + "id": 2, + "userId": 2, + "userName": "tien", + "changeType": "RESET_PASSWORD", + "note": "Password reset by OTP", + "ipAddress": "127.0.0.1", + "userAgent": "Mozilla/5.0...", + "createdAt": "2026-07-01T11:00:00" + } + ] +} +``` + +--- + +# 7. Frontend flows + +--- + +## 7.1. Register flow + +```text +User nhập username, email, password +→ Frontend gọi POST /api/auth/register +→ Nếu thành công: chuyển sang login +→ Nếu lỗi: hiển thị message từ response +``` + +--- + +## 7.2. Login flow + +```text +User nhập username/email + password +→ Frontend gọi POST /api/auth/login +→ Nếu thành công: + - Lưu token + - Lưu role + - Nếu role = ADMIN → vào admin dashboard + - Nếu role = USER → vào user home +→ Nếu lỗi: + - Hiển thị message từ response +``` + +--- + +## 7.3. Protected API flow + +```text +Frontend lấy token đã lưu +→ Gửi header Authorization: Bearer +→ Backend kiểm tra: + - JWT hợp lệ + - JWT chưa hết hạn + - Session trong bảng sessions còn active + - User còn active +→ Nếu hợp lệ: cho truy cập +→ Nếu không hợp lệ: trả 401/403 +``` + +--- + +## 7.4. Change password flow + +```text +User/Admin đang đăng nhập +→ Nhập currentPassword + newPassword +→ Frontend gọi POST /api/auth/change-password kèm token +→ Backend: + - Verify currentPassword bằng BCrypt + - Hash newPassword bằng BCrypt + - Update password_hash + - Revoke toàn bộ session cũ + - Ghi password_change_logs +→ Frontend: + - Xóa token + - Chuyển về login +``` + +--- + +## 7.5. Forgot/reset password flow + +```text +Bước 1: Forgot password +User nhập email +→ Frontend gọi POST /api/auth/forgot-password +→ Backend tạo OTP 6 số, hash OTP, lưu DB, gửi email +→ Frontend chuyển sang trang reset password + +Bước 2: Reset password +Frontend tự truyền email sang trang reset +→ Email hiển thị readonly +→ User nhập OTP + mật khẩu mới +→ Frontend gọi POST /api/auth/reset-password +→ Backend verify OTP bằng BCrypt +→ Backend đổi password_hash +→ Backend revoke toàn bộ session cũ +→ Frontend chuyển về login +``` + +--- + +## 7.6. Admin flow + +```text +Admin login bằng POST /api/auth/login +→ Backend trả token với role = ADMIN +→ Frontend lưu token và role +→ Chuyển vào admin dashboard +→ Admin gọi: + GET /api/admin/users + GET /api/admin/login-logs + GET /api/admin/sessions + GET /api/admin/password-change-logs +``` + +--- + +# 8. Cách tạo tài khoản admin + +Hệ thống không cho người dùng tự đăng ký admin từ frontend. + +Cách tạo admin trong demo: + +### Bước 1: Đăng ký tài khoản admin bằng API register + +```json +{ + "userName": "admin", + "email": "admin@example.com", + "password": "Admin@123456" +} +``` + +Tài khoản này ban đầu có role: + +```text +USER +``` + +### Bước 2: Set role admin trong SQL Server + +```sql +UPDATE users +SET role = 'ADMIN' +WHERE username = 'admin'; +``` + +### Bước 3: Đăng nhập lại tài khoản admin + +```json +{ + "loginIdentifier": "admin", + "password": "Admin@123456" +} +``` + +Nếu login thành công, response sẽ có: + +```json +"role": "ADMIN" +``` + +--- + +# 9. Frontend notes + +## 9.1. Không hiển thị password hash + +Frontend không cần hiển thị `password_hash`. +Phần chứng minh mật khẩu đã được bảo vệ sẽ chụp trong SQL Server. + +## 9.2. Role-based routing + +Frontend nên có logic: + +```text +Nếu chưa có token → vào login +Nếu role = USER → vào trang user +Nếu role = ADMIN → vào trang admin +``` + +## 9.3. Token invalid sau đổi/reset mật khẩu + +Sau khi đổi mật khẩu hoặc reset mật khẩu, token cũ không dùng được nữa vì backend đã revoke session. + +Frontend nên xử lý: + +```text +Nếu API trả 401/403: +- Xóa token local +- Chuyển về login +``` + +## 9.4. Ngrok + +Nếu backend chạy qua ngrok, frontend gọi API bằng URL ngrok backend. + +Ví dụ: + +```js +const BASE_URL = "https://abc-xyz.ngrok-free.app"; +``` + +Backend cần cấu hình CORS cho domain frontend. + +--- + +# 10. Summary endpoints + +| Method | Endpoint | Auth | Role | Chức năng | +|---|---|---|---|---| +| POST | `/api/auth/register` | No | Any | Đăng ký user | +| POST | `/api/auth/login` | No | Any | Đăng nhập | +| POST | `/api/auth/forgot-password` | No | Any | Gửi OTP | +| POST | `/api/auth/reset-password` | No | Any | Reset mật khẩu bằng OTP | +| GET | `/api/auth/me` | Yes | USER/ADMIN | Lấy thông tin tài khoản hiện tại | +| POST | `/api/auth/change-password` | Yes | USER/ADMIN | Đổi mật khẩu | +| GET | `/api/admin/users` | Yes | ADMIN | Xem danh sách user | +| GET | `/api/admin/login-logs` | Yes | ADMIN | Xem log đăng nhập | +| GET | `/api/admin/sessions` | Yes | ADMIN | Xem session | +| GET | `/api/admin/password-change-logs` | Yes | ADMIN | Xem log đổi/reset mật khẩu | From bf710edce266c270f597f201fdd4b6f9021c3bd3 Mon Sep 17 00:00:00 2001 From: HungVu646 Date: Thu, 2 Jul 2026 22:03:18 +0700 Subject: [PATCH 3/4] update --- Fontend/.vscode/settings.json | 5 + Fontend/app.js | 381 ++++++++++++++++++++++++++++++++++ Fontend/index.html | 158 ++++++++++++++ Fontend/package.json | 8 + Fontend/styles.css | 128 ++++++++++++ Fontend/test.html | 0 6 files changed, 680 insertions(+) create mode 100644 Fontend/.vscode/settings.json create mode 100644 Fontend/app.js create mode 100644 Fontend/index.html create mode 100644 Fontend/package.json create mode 100644 Fontend/styles.css delete mode 100644 Fontend/test.html diff --git a/Fontend/.vscode/settings.json b/Fontend/.vscode/settings.json new file mode 100644 index 0000000..ae8fece --- /dev/null +++ b/Fontend/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "liveServer.settings.port": 3000, + "liveServer.settings.host": "localhost", + "liveServer.settings.CustomBrowser": "chrome" +} diff --git a/Fontend/app.js b/Fontend/app.js new file mode 100644 index 0000000..cb8c948 --- /dev/null +++ b/Fontend/app.js @@ -0,0 +1,381 @@ +const $ = (selector) => document.querySelector(selector); +const $$ = (selector) => document.querySelectorAll(selector); + +const DEFAULT_BASE_URL = 'https://grandpa-discharge-isolated.ngrok-free.dev'; +const STORAGE_KEYS = { baseUrl: 'sp_base_url', token: 'sp_token', user: 'sp_user' }; + +const apiList = [ + { key: 'register', method: 'POST', path: '/api/auth/register', auth: false, body: ['userName', 'email', 'password'] }, + { key: 'login', method: 'POST', path: '/api/auth/login', auth: false, body: ['loginIdentifier', 'password'] }, + { key: 'forgot', method: 'POST', path: '/api/auth/forgot-password', auth: false, body: ['email'] }, + { key: 'reset', method: 'POST', path: '/api/auth/reset-password', auth: false, body: ['email', 'otp', 'newPassword'] }, + { key: 'me', method: 'GET', path: '/api/auth/me', auth: true, body: [] }, + { key: 'changePassword', method: 'POST', path: '/api/auth/change-password', auth: true, body: ['currentPassword', 'newPassword'] }, + { key: 'users', method: 'GET', path: '/api/admin/users', auth: true, admin: true, body: [] }, + { key: 'loginLogs', method: 'GET', path: '/api/admin/login-logs', auth: true, admin: true, body: [] }, + { key: 'sessions', method: 'GET', path: '/api/admin/sessions', auth: true, admin: true, body: [] }, + { key: 'passwordLogs', method: 'GET', path: '/api/admin/password-change-logs', auth: true, admin: true, body: [] } +]; + +const endpoints = Object.fromEntries(apiList.map((api) => [api.key, api.path])); + +const state = { + baseUrl: (localStorage.getItem(STORAGE_KEYS.baseUrl) || localStorage.getItem('baseUrl') || DEFAULT_BASE_URL).replace(/\/$/, ''), + token: localStorage.getItem(STORAGE_KEYS.token) || localStorage.getItem('token') || '', + user: safeJson(localStorage.getItem(STORAGE_KEYS.user) || localStorage.getItem('user')), + adminTab: 'users', + isLoading: false +}; + +function safeJson(value) { + try { return value ? JSON.parse(value) : null; } catch { return null; } +} + +function setResponse(data) { + const el = $('#apiResponse'); + if (!el) return; + el.textContent = typeof data === 'string' ? data : JSON.stringify(data, null, 2); +} + +function setLastRequest(method, path, body) { + const el = $('#lastRequest'); + if (!el) return; + const bodyText = body ? ` | Body: ${JSON.stringify(body)}` : ''; + el.textContent = `${method} ${state.baseUrl}${path}${bodyText}`; +} + +function showToast(message, isError = false) { + const toast = $('#toast'); + if (!toast) { + alert(message); + return; + } + toast.textContent = message; + toast.className = `toast ${isError ? 'error' : ''}`; + clearTimeout(showToast.timer); + showToast.timer = setTimeout(() => toast.classList.add('hidden'), 3500); +} + +function setLoading(loading) { + state.isLoading = loading; + $$('button, input').forEach((el) => { el.disabled = loading; }); +} + +async function apiRequest(keyOrPath, { method = 'GET', body = null, auth = true } = {}) { + const path = endpoints[keyOrPath] || keyOrPath; + const payload = body && typeof body === 'object' ? cleanBody(body) : null; + setLastRequest(method, path, payload); + + const headers = { Accept: 'application/json', 'ngrok-skip-browser-warning': 'true' }; + if (payload) headers['Content-Type'] = 'application/json'; + if (auth && state.token) headers.Authorization = `Bearer ${state.token}`; + + const fetchOptions = { method, headers, mode: 'cors' }; + if (payload) fetchOptions.body = JSON.stringify(payload); + + setLoading(true); + let responseData = null; + try { + const res = await fetch(state.baseUrl + path, fetchOptions); + const text = await res.text(); + responseData = parseResponse(text, res.ok, res.status); + setResponse(responseData); + if (!res.ok || responseData.success === false) throw new Error(responseData.message || `HTTP ${res.status}`); + return responseData; + } catch (error) { + const message = normalizeFetchError(error); + setResponse(responseData || { success: false, message, data: null }); + throw new Error(message); + } finally { + setLoading(false); + } +} + +function cleanBody(data) { + const result = {}; + Object.entries(data).forEach(([key, value]) => { + const text = typeof value === 'string' ? value.trim() : value; + if (text !== '') result[key] = text; + }); + return result; +} + +function parseResponse(text, ok, status) { + try { return text ? JSON.parse(text) : { success: ok, message: `HTTP ${status}`, data: null }; } + catch { return { success: ok, message: text || `HTTP ${status}`, data: null }; } +} + +function normalizeFetchError(error) { + if (String(error.message).includes('Failed to fetch')) { + return 'Không gọi được API. Với Live Server hãy chạy port 3000 hoặc 5173, kiểm tra ngrok còn sống và backend đã bật CORS.'; + } + return error.message || 'Có lỗi khi gọi API.'; +} + +function getFormData(form) { + return cleanBody(Object.fromEntries(new FormData(form).entries())); +} + +function getValue(obj, ...names) { + if (!obj || typeof obj !== 'object') return undefined; + for (const name of names) { + if (Object.prototype.hasOwnProperty.call(obj, name)) return obj[name]; + } + const lowerMap = Object.fromEntries(Object.keys(obj).map((key) => [key.toLowerCase(), key])); + for (const name of names) { + const realKey = lowerMap[String(name).toLowerCase()]; + if (realKey) return obj[realKey]; + } + return undefined; +} + +function saveSession(loginData) { + const data = loginData.data || {}; + state.token = data.token || data.accessToken || ''; + state.user = { + id: getValue(data, 'userId', 'id'), + userName: getValue(data, 'userName', 'username', 'name'), + email: getValue(data, 'email'), + role: getValue(data, 'role') || 'USER' + }; + localStorage.setItem(STORAGE_KEYS.token, state.token); + localStorage.setItem(STORAGE_KEYS.user, JSON.stringify(state.user)); + localStorage.setItem('token', state.token); + localStorage.setItem('user', JSON.stringify(state.user)); + updateAuthUi(); +} + +function logout() { + state.token = ''; + state.user = null; + localStorage.removeItem(STORAGE_KEYS.token); + localStorage.removeItem(STORAGE_KEYS.user); + localStorage.removeItem('token'); + localStorage.removeItem('user'); + updateAuthUi(); + openPage('loginPage'); + setResponse('Đã xóa token ở frontend. Backend không có API logout trong danh sách bạn gửi.'); +} + +function updateAuthUi() { + const isLoggedIn = Boolean(state.token); + const role = (state.user?.role || 'GUEST').toUpperCase(); + const isAdmin = role === 'ADMIN'; + + $('#authStatus').textContent = isLoggedIn ? `${state.user?.userName || 'User'} đã đăng nhập` : 'Chưa đăng nhập'; + $('#roleBadge').textContent = role; + $('#roleBadge').classList.toggle('muted', !isLoggedIn); + $('#logoutBtn').classList.toggle('hidden', !isLoggedIn); + + // Khi đã đăng nhập thì ẩn Đăng nhập / Đăng ký. + $$('.guest-only').forEach((btn) => btn.classList.toggle('hidden', isLoggedIn)); + + // Trang thường chỉ hiện sau khi đăng nhập. + $$('.protected').forEach((btn) => btn.classList.toggle('hidden', !isLoggedIn)); + + // ADMIN không hiện chức năng đổi mật khẩu ở thanh trái. + $$('.user-only').forEach((btn) => btn.classList.toggle('hidden', !isLoggedIn || isAdmin)); + + // Chỉ ADMIN mới hiện quản trị. + $$('.admin-only').forEach((btn) => btn.classList.toggle('hidden', !isAdmin)); + + // Khi đã đăng nhập mà đang ở trang đăng nhập/đăng ký/quên mật khẩu thì chuyển sang trang phù hợp. + const activePageId = $('.page.active')?.id; + if (isLoggedIn && ['loginPage', 'registerPage', 'forgotPage', 'resetPage', 'changePasswordPage'].includes(activePageId) && isAdmin) { + openPage('adminPage'); + } else if (isLoggedIn && ['loginPage', 'registerPage', 'forgotPage', 'resetPage'].includes(activePageId)) { + openPage('profilePage'); + } +} + +function openPage(pageId) { + const role = (state.user?.role || '').toUpperCase(); + const isLoggedIn = Boolean(state.token); + + if (isLoggedIn && ['loginPage', 'registerPage', 'forgotPage', 'resetPage'].includes(pageId)) { + pageId = role === 'ADMIN' ? 'adminPage' : 'profilePage'; + } + + if (pageId === 'changePasswordPage' && role === 'ADMIN') { + showToast('Tài khoản ADMIN không có chức năng đổi mật khẩu ở giao diện này.', true); + pageId = 'adminPage'; + } + + if ((pageId === 'forgotPage' || pageId === 'resetPage') && role === 'ADMIN') { + showToast('Tài khoản ADMIN không sử dụng chức năng quên/đặt lại mật khẩu.', true); + return; + } + $$('.page').forEach((page) => page.classList.remove('active')); + const page = $('#' + pageId); + if (page) page.classList.add('active'); + $$('.nav-item').forEach((btn) => btn.classList.toggle('active', btn.dataset.page === pageId)); + if (pageId === 'profilePage' && state.token) loadProfile(); + if (pageId === 'adminPage' && state.token) loadAdminData(); +} + +function formatDate(value) { + if (!value) return 'Chưa có'; + const text = String(value); + // Chỉ format chuỗi ngày giờ ISO như 2026-07-02T21:13:32. + // Không xử lý mọi chuỗi có chữ T để tránh mất chữ đầu trong tên như "Tuan". + const isIsoDateTime = /^\d{4}-\d{2}-\d{2}T/.test(text); + return isIsoDateTime ? text.replace('T', ' ').slice(0, 19) : text; +} + +function renderProfile(user = {}) { + const isActive = getValue(user, 'isActive', 'active'); + const rows = [ + ['ID', getValue(user, 'id', 'userId')], + ['UserName', getValue(user, 'userName', 'username', 'name')], + ['Email', getValue(user, 'email')], + ['Role', getValue(user, 'role')], + ['Active', isActive === true ? 'Đang hoạt động' : isActive === false ? 'Bị khóa' : 'Không rõ'], + ['Failed Attempts', getValue(user, 'failedAttempts', 'failed_attempts')], + ['Locked Until', getValue(user, 'lockedUntil', 'locked_until') || 'Không'], + ['Last Login', formatDate(getValue(user, 'lastLoginAt', 'lastLogin', 'last_login_at'))], + ['Password Changed', formatDate(getValue(user, 'passwordChangedAt', 'passwordChanged', 'password_changed_at'))], + ['Created At', formatDate(getValue(user, 'createdAt', 'created_at'))] + ]; + const profileView = $('#profileView'); + if (!profileView) return; + profileView.innerHTML = rows.map(([label, value]) => `
${label}${value ?? ''}
`).join(''); +} + +function renderTable(rows) { + if (!Array.isArray(rows) || rows.length === 0) { + const adminTable = $('#adminTable'); + if (adminTable) adminTable.innerHTML = '
Không có dữ liệu.
'; + return; + } + const keys = Object.keys(rows[0]); + const adminTable = $('#adminTable'); + if (!adminTable) return; + adminTable.innerHTML = `${keys.map((k) => ``).join('')}${rows.map((row) => `${keys.map((k) => ``).join('')}`).join('')}
${k}
${formatCell(row[k])}
`; +} + +function formatCell(value) { + if (value === null || value === undefined) return ''; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'object') return JSON.stringify(value); + return formatDate(value); +} + +async function loadProfile() { + try { + const res = await apiRequest('me'); + const userData = res.data || {}; + state.user = { + ...state.user, + id: getValue(userData, 'id', 'userId') ?? state.user?.id, + userName: getValue(userData, 'userName', 'username', 'name') ?? state.user?.userName, + email: getValue(userData, 'email') ?? state.user?.email, + role: getValue(userData, 'role') ?? state.user?.role + }; + localStorage.setItem(STORAGE_KEYS.user, JSON.stringify(state.user)); + localStorage.setItem('user', JSON.stringify(state.user)); + updateAuthUi(); + renderProfile(userData); + } catch (error) { showToast(error.message, true); } +} + +async function loadAdminData() { + if ((state.user?.role || '').toUpperCase() !== 'ADMIN') { + showToast('Bạn cần đăng nhập tài khoản ADMIN để xem mục này.', true); + return; + } + try { + const res = await apiRequest(state.adminTab); + renderTable(res.data || []); + } catch (error) { showToast(error.message, true); } +} + +function bindEvents() { + localStorage.setItem(STORAGE_KEYS.baseUrl, state.baseUrl); + + $$('.nav-item').forEach((btn) => btn.addEventListener('click', () => openPage(btn.dataset.page))); + $$('.forgot-link').forEach((btn) => btn.addEventListener('click', () => openPage('forgotPage'))); + $$('.reset-link').forEach((btn) => btn.addEventListener('click', () => openPage('resetPage'))); + $$('.tab').forEach((btn) => btn.addEventListener('click', () => { + $$('.tab').forEach((tab) => tab.classList.remove('active')); + btn.classList.add('active'); + state.adminTab = btn.dataset.admin; + loadAdminData(); + })); + + + $('#loginForm')?.addEventListener('submit', handleLogin); + $('#registerForm')?.addEventListener('submit', handleRegister); + $('#forgotForm')?.addEventListener('submit', handleForgot); + $('#resetForm')?.addEventListener('submit', handleReset); + $('#changePasswordForm')?.addEventListener('submit', handleChangePassword); + $('#loadProfile')?.addEventListener('click', loadProfile); + $('#refreshAdmin')?.addEventListener('click', loadAdminData); + $('#logoutBtn')?.addEventListener('click', logout); + $('#clearResponse')?.addEventListener('click', () => setResponse('Chưa có dữ liệu.')); +} + +async function handleLogin(e) { + e.preventDefault(); + try { + const res = await apiRequest('login', { method: 'POST', body: getFormData(e.target), auth: false }); + saveSession(res); + showToast(res.message || 'Đăng nhập thành công'); + openPage('profilePage'); + } catch (error) { showToast(error.message, true); } +} + +async function handleRegister(e) { + e.preventDefault(); + try { + const res = await apiRequest('register', { method: 'POST', body: getFormData(e.target), auth: false }); + showToast(res.message || 'Đăng ký thành công'); + openPage('loginPage'); + } catch (error) { showToast(error.message, true); } +} + +async function handleForgot(e) { + e.preventDefault(); + if ((state.user?.role || '').toUpperCase() === 'ADMIN') { + showToast('Tài khoản ADMIN không được dùng quên mật khẩu.', true); + return; + } + try { + const body = getFormData(e.target); + const res = await apiRequest('forgot', { method: 'POST', body, auth: false }); + if ($('#resetForm')?.email) $('#resetForm').email.value = body.email; + showToast(res.message || 'Đã gửi OTP'); + openPage('resetPage'); + } catch (error) { showToast(error.message, true); } +} + +async function handleReset(e) { + e.preventDefault(); + if ((state.user?.role || '').toUpperCase() === 'ADMIN') { + showToast('Tài khoản ADMIN không được đặt lại mật khẩu ở đây.', true); + return; + } + try { + const body = getFormData(e.target); + const res = await apiRequest('reset', { method: 'POST', body, auth: false }); + showToast(res.message || 'Đặt lại mật khẩu thành công'); + openPage('loginPage'); + } catch (error) { showToast(error.message, true); } +} + +async function handleChangePassword(e) { + e.preventDefault(); + if (!state.token) { + showToast('Bạn cần đăng nhập trước khi đổi mật khẩu.', true); + openPage('loginPage'); + return; + } + try { + const res = await apiRequest('changePassword', { method: 'POST', body: getFormData(e.target) }); + showToast(res.message || 'Đổi mật khẩu thành công'); + e.target.reset(); + logout(); + } catch (error) { showToast(error.message, true); } +} + +bindEvents(); +updateAuthUi(); diff --git a/Fontend/index.html b/Fontend/index.html new file mode 100644 index 0000000..17fadc2 --- /dev/null +++ b/Fontend/index.html @@ -0,0 +1,158 @@ + + + + + + Security Password Frontend + + + +
+ + +
+
+
+

An toàn bảo mật thông tin

+

Hệ thống Authentication

+
+
+ + + +
+
+
+
+

Đăng nhập

+
+
+
+ + + + + + +
+
+
+ +
+
+

Đăng ký tài khoản

+
+
+
+
+ + +
+
+
+ +
+
+

Quên mật khẩu

+

Nhập email tài khoản USER để nhận OTP

+
+ + + +
+
+
+ +
+
+

Đặt lại mật khẩu

+

Nhập OTP đã nhận và mật khẩu mới. Chức năng này chỉ dành cho tài khoản USER.

+
+ + + + + + + +
+
+
+ +
+
+
+

Thông tin tài khoản

+ +
+
+
+
+ +
+
+

Đổi mật khẩu khi đã đăng nhập

+
+
+
+ +
+
+
+ +
+
+
+
+

Quản trị hệ thống

+
+ +
+
+ + + + +
+
+
+
+ +
+
+

Kết quả API

Chưa gọi API.

+ +
+
Chưa có dữ liệu.
+
+
+
+ + + diff --git a/Fontend/package.json b/Fontend/package.json new file mode 100644 index 0000000..1d4669f --- /dev/null +++ b/Fontend/package.json @@ -0,0 +1,8 @@ +{ + "scripts": { + "dev": "vite --host 0.0.0.0 --port 5173" + }, + "devDependencies": { + "vite": "latest" + } +} diff --git a/Fontend/styles.css b/Fontend/styles.css new file mode 100644 index 0000000..bc2df52 --- /dev/null +++ b/Fontend/styles.css @@ -0,0 +1,128 @@ +*{box-sizing:border-box}body{margin:0;font-family:Inter,Arial,Helvetica,sans-serif;background:#0b1020;color:#e5e7eb}.app-shell{display:flex;min-height:100vh}.sidebar{width:300px;background:#0f172a;border-right:1px solid #253047;padding:24px;position:fixed;inset:0 auto 0 0}.brand{display:flex;gap:12px;align-items:center;margin-bottom:26px}.brand-icon{width:50px;height:50px;border-radius:18px;background:linear-gradient(135deg,#2563eb,#7c3aed);display:grid;place-items:center;font-size:25px}.brand h1{font-size:20px;margin:0}.brand p,.desc{color:#94a3b8;margin:6px 0 0;line-height:1.5}.nav{display:grid;gap:10px}.nav-item,.tab,button{border:0;border-radius:13px;padding:12px 14px;background:#1e293b;color:#e5e7eb;cursor:pointer;font-weight:800}.nav-item{text-align:left}.nav-item.active,.tab.active,button[type=submit]{background:linear-gradient(135deg,#2563eb,#7c3aed)}button:hover{filter:brightness(1.1)}.auth-card{position:absolute;left:24px;right:24px;bottom:24px;padding:18px;border:1px solid #334155;border-radius:18px;background:#111827;display:grid;gap:10px}.small-label,.eyebrow{color:#38bdf8;text-transform:uppercase;font-size:12px;letter-spacing:.08em}.badge{display:inline-block;width:max-content;padding:6px 11px;border-radius:999px;background:#16a34a;color:white;font-size:12px}.badge.muted{background:#475569}.danger{background:#dc2626!important}.ghost{background:transparent;border:1px solid #475569}.secondary{background:#334155}.hidden{display:none!important}.content{margin-left:300px;width:calc(100% - 300px);padding:28px}.hero{display:flex;justify-content:space-between;gap:22px;align-items:center;padding:28px;border-radius:26px;background:radial-gradient(circle at top left,#1d4ed8,#111827 48%,#0b1020);border:1px solid #334155;margin-bottom:22px}.hero h2{font-size:32px;margin:8px 0}.api-box{min-width:340px;background:rgba(15,23,42,.76);border:1px solid #475569;border-radius:18px;padding:16px;display:grid;gap:10px}.api-box input,input{width:100%;border:1px solid #334155;background:#020617;color:#e5e7eb;border-radius:12px;padding:13px 14px;outline:none}input:focus{border-color:#38bdf8}.panel{background:#111827;border:1px solid #263244;border-radius:22px;padding:24px;margin-bottom:20px;box-shadow:0 18px 45px rgba(0,0,0,.18)}.two-col{display:grid;grid-template-columns:1fr 1fr;gap:24px}.auth-panel{max-width:720px;margin-inline:auto}.auth-form{max-width:560px}.form-link{margin-top:2px;text-align:left}.grid-link{grid-column:1/-1;margin-top:0}.link-button{background:transparent!important;color:#93c5fd;padding:0;text-decoration:underline}.link-button:hover{color:#bfdbfe;filter:none}.grid-form{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;align-items:end}.page{display:none}.page.active{display:block}.form{display:grid;gap:12px;margin-top:16px}.form label,.api-box label{font-weight:800;color:#cbd5e1;font-size:14px}.section-head{display:flex;justify-content:space-between;gap:16px;align-items:flex-start}.section-head.compact{margin-bottom:4px}.section-head h3,.panel h3{margin:0 0 6px;font-size:22px}.security-note{padding:20px;border-radius:18px;background:#0b1220;border:1px solid #334155}.security-note h4{margin-top:0}.quick{display:block;width:100%;margin:10px 0;background:#334155}.endpoint-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(310px,1fr));gap:12px;margin-top:18px}.endpoint-grid>div{display:grid;grid-template-columns:auto 1fr;gap:7px 11px;align-items:center;padding:13px;border:1px solid rgba(255,255,255,.1);border-radius:15px;background:rgba(255,255,255,.04)}.endpoint-grid code{color:#f8fafc;overflow-wrap:anywhere}.endpoint-grid small{grid-column:2;color:#94a3b8}.method{display:inline-block;min-width:54px;text-align:center;font-size:12px;font-weight:900;padding:6px 8px;border-radius:8px;letter-spacing:.4px}.method.post{background:rgba(59,130,246,.18);color:#93c5fd;border:1px solid rgba(59,130,246,.35)}.method.get{background:rgba(34,197,94,.16);color:#86efac;border:1px solid rgba(34,197,94,.35)}.profile-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:12px}.info-card,.empty{padding:15px;border-radius:15px;background:#0b1220;border:1px solid #334155}.info-card span{display:block;color:#94a3b8;font-size:13px;margin-bottom:7px}.tabs{display:flex;gap:10px;flex-wrap:wrap;margin:18px 0}.table-wrap{overflow:auto;border-radius:16px;border:1px solid #334155}table{width:100%;border-collapse:collapse;background:#0f172a}th,td{padding:12px 14px;border-bottom:1px solid #263244;text-align:left;white-space:nowrap}th{background:#1f2937;color:#38bdf8}pre{white-space:pre-wrap;word-break:break-word;background:#020617;border:1px solid #334155;border-radius:16px;padding:18px;max-height:390px;overflow:auto}.toast{position:fixed;right:28px;top:28px;z-index:10;padding:14px 18px;border-radius:14px;background:#16a34a;color:white;font-weight:800;box-shadow:0 10px 30px rgba(0,0,0,.35)}.toast.error{background:#dc2626}@media(max-width:920px){.sidebar{position:static;width:100%;height:auto}.app-shell{display:block}.content{margin-left:0;width:100%;padding:16px}.hero,.two-col{display:grid;grid-template-columns:1fr}.grid-form{grid-template-columns:1fr}.api-box{min-width:0}.auth-card{position:static;margin-top:18px}.section-head{display:grid}.endpoint-grid{grid-template-columns:1fr}} + +/* FINAL FIX: chống tràn chữ/input và tách form quên/đặt lại mật khẩu */ +input, +.form input, +.auth-form input, +.profile-grid, +.info-card, +.info-card strong, +.info-card span, +table, +td, +th { + min-width: 0; + max-width: 100%; + box-sizing: border-box; +} + +.info-card strong, +td, +th { + overflow-wrap: anywhere; + word-break: break-word; + white-space: normal; +} + +.profile-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.auth-panel { + width: 100%; + max-width: 760px; +} + +.auth-form { + width: 100%; + max-width: 100%; +} + +.form-link { + width: 100%; + text-align: left; +} + +.link-button { + display: inline-block; + margin-left: 0; +} + +@media (max-width: 900px) { + .sidebar { position: static; width: 100%; } + .app-shell { display: block; } + .content { margin-left: 0; width: 100%; padding: 16px; } + .two-col, .grid-form { grid-template-columns: 1fr; } +} + + +/* FIX V3: bỏ quên mật khẩu ở sidebar, giữ dữ liệu chữ thường/chữ hoa nguyên bản và làm bảng admin dễ đọc hơn */ +#adminTable table { + min-width: 1100px; + table-layout: auto; +} + +#adminTable th, +#adminTable td { + white-space: normal; + word-break: normal; + overflow-wrap: anywhere; + vertical-align: middle; +} + +#adminTable th:nth-child(2), +#adminTable td:nth-child(2) { + min-width: 110px; +} + +#adminTable th:nth-child(3), +#adminTable td:nth-child(3) { + min-width: 220px; +} + +#adminTable th:nth-child(4), +#adminTable td:nth-child(4) { + min-width: 90px; +} + +/* FIX V4: nút quên/đặt lại mật khẩu và chống vỡ chữ trong bảng */ +.form-link{ + display:flex; + align-items:center; + justify-content:flex-start; + gap:18px; + flex-wrap:wrap; +} +.link-button{ + width:auto !important; + padding:0 !important; + background:transparent !important; + border:0 !important; + color:#9ed0ff !important; + text-decoration:underline; + text-align:left; + font-weight:700; +} +.table-wrap table th, +.table-wrap table td{ + word-break:normal !important; + overflow-wrap:anywhere; + white-space:normal; + min-width:92px; +} +.table-wrap table th:first-child, +.table-wrap table td:first-child{ + min-width:48px; +} +.table-wrap table th:nth-child(2), +.table-wrap table td:nth-child(2), +.table-wrap table th:nth-child(4), +.table-wrap table td:nth-child(4){ + min-width:110px; + overflow-wrap:normal; + word-break:keep-all !important; +} +.table-wrap table th:nth-child(3), +.table-wrap table td:nth-child(3){ + min-width:220px; +} diff --git a/Fontend/test.html b/Fontend/test.html deleted file mode 100644 index e69de29..0000000 From d16177349bef33c61d6e26dfb69915931914b7d3 Mon Sep 17 00:00:00 2001 From: HungVu646 Date: Sat, 4 Jul 2026 00:08:25 +0700 Subject: [PATCH 4/4] fix(frontend) --- Fontend/app.js | 353 +++++++++++++++++++++++++++++++++++++++++++-- Fontend/index.html | 1 + Fontend/styles.css | 38 +++++ 3 files changed, 378 insertions(+), 14 deletions(-) diff --git a/Fontend/app.js b/Fontend/app.js index cb8c948..c9cd3e0 100644 --- a/Fontend/app.js +++ b/Fontend/app.js @@ -24,9 +24,18 @@ const state = { token: localStorage.getItem(STORAGE_KEYS.token) || localStorage.getItem('token') || '', user: safeJson(localStorage.getItem(STORAGE_KEYS.user) || localStorage.getItem('user')), adminTab: 'users', - isLoading: false + adminPage: { users: 1, loginLogs: 1, sessions: 1, passwordLogs: 1 }, + adminData: { users: [], loginLogs: [], sessions: [], passwordLogs: [] }, + isLoading: false, + loginLockTimer: null, + loginCountdown: 0 }; +const LOGIN_LOCK_KEY = 'sp_login_lock_state'; +const LOGIN_FAIL_COUNT_KEY = 'sp_login_fail_count_state'; +const TEMP_LOCK_SECONDS = 15; +const MAX_FAILED_ATTEMPTS = 5; + function safeJson(value) { try { return value ? JSON.parse(value) : null; } catch { return null; } } @@ -80,12 +89,20 @@ async function apiRequest(keyOrPath, { method = 'GET', body = null, auth = true const text = await res.text(); responseData = parseResponse(text, res.ok, res.status); setResponse(responseData); - if (!res.ok || responseData.success === false) throw new Error(responseData.message || `HTTP ${res.status}`); + if (!res.ok || responseData.success === false) { + const err = new Error(responseData.message || `HTTP ${res.status}`); + err.response = responseData; + err.status = res.status; + throw err; + } return responseData; } catch (error) { const message = normalizeFetchError(error); - setResponse(responseData || { success: false, message, data: null }); - throw new Error(message); + const err = new Error(message); + err.response = error.response || responseData || { success: false, message, data: null }; + err.status = error.status; + setResponse(err.response); + throw err; } finally { setLoading(false); } @@ -241,16 +258,94 @@ function renderProfile(user = {}) { profileView.innerHTML = rows.map(([label, value]) => `
${label}${value ?? ''}
`).join(''); } -function renderTable(rows) { - if (!Array.isArray(rows) || rows.length === 0) { - const adminTable = $('#adminTable'); - if (adminTable) adminTable.innerHTML = '
Không có dữ liệu.
'; - return; +const ADMIN_PAGE_SIZE = 15; + +function isAdminRow(row) { + return String(getValue(row, 'role') || '').toUpperCase() === 'ADMIN'; +} + +function getAllLoginLockStates() { + return safeJson(localStorage.getItem(LOGIN_LOCK_KEY)) || {}; +} + +function normalizeLockKey(value) { + return String(value ?? '').trim().toLowerCase(); +} + +function getUserIdentifiers(row = {}) { + return [ + getValue(row, 'id', 'userId'), + getValue(row, 'userName', 'username', 'name'), + getValue(row, 'email'), + getValue(row, 'loginIdentifier') + ].map(normalizeLockKey).filter(Boolean); +} + +function getLocalLockForUser(row = {}) { + const allLocks = getAllLoginLockStates(); + const keys = getUserIdentifiers(row); + for (const key of keys) { + if (allLocks[key]) return allLocks[key]; } - const keys = Object.keys(rows[0]); + return null; +} + +// Backend hiện chỉ khóa tạm 15 giây sau khi sai mật khẩu. +// Frontend không còn chức năng khóa vĩnh viễn hoặc yêu cầu ADMIN mở khóa. +function isTemporaryLockedUser(row) { + const lockedUntil = getValue(row, 'lockedUntil', 'locked_until'); + const lockedUntilTime = lockedUntil ? new Date(lockedUntil).getTime() : 0; + return Number.isFinite(lockedUntilTime) && lockedUntilTime > Date.now(); +} + +function enrichAdminUser(row) { + return row; +} + +function clearLocalLockForUser(row = {}) { + const allLocks = getAllLoginLockStates(); + getUserIdentifiers(row).forEach((key) => delete allLocks[key]); + localStorage.setItem(LOGIN_LOCK_KEY, JSON.stringify(allLocks)); +} + +function getVisibleAdminRows(rows) { + if (!Array.isArray(rows)) return []; + if (state.adminTab !== 'users') return rows; + return rows.filter((row) => !isAdminRow(row)); +} + +function renderTable(rows) { const adminTable = $('#adminTable'); if (!adminTable) return; - adminTable.innerHTML = `${keys.map((k) => ``).join('')}${rows.map((row) => `${keys.map((k) => ``).join('')}`).join('')}
${k}
${formatCell(row[k])}
`; + + const visibleRows = getVisibleAdminRows(rows); + + if (!Array.isArray(visibleRows) || visibleRows.length === 0) { + adminTable.innerHTML = '
Không có dữ liệu.
'; + return; + } + + const hiddenKeys = ['passwordHash', 'password', 'hash']; + const keys = Object.keys(visibleRows[0]).filter((key) => !key.startsWith('_') && !hiddenKeys.some((hidden) => key.toLowerCase().includes(hidden.toLowerCase()))); + const currentPage = state.adminPage[state.adminTab] || 1; + const totalPages = Math.max(1, Math.ceil(visibleRows.length / ADMIN_PAGE_SIZE)); + const safePage = Math.min(Math.max(currentPage, 1), totalPages); + state.adminPage[state.adminTab] = safePage; + + const start = (safePage - 1) * ADMIN_PAGE_SIZE; + const pageRows = visibleRows.slice(start, start + ADMIN_PAGE_SIZE); + + const tableHtml = `${keys.map((k) => ``).join('')}${pageRows.map((row) => `${keys.map((k) => ``).join('')}`).join('')}
${k}
${formatCell(row[k])}
`; + + const paginationHtml = visibleRows.length > ADMIN_PAGE_SIZE + ? `` + : ``; + + adminTable.innerHTML = tableHtml + paginationHtml; } function formatCell(value) { @@ -285,7 +380,8 @@ async function loadAdminData() { } try { const res = await apiRequest(state.adminTab); - renderTable(res.data || []); + state.adminData[state.adminTab] = Array.isArray(res.data) ? res.data : []; + renderTable(state.adminData[state.adminTab]); } catch (error) { showToast(error.message, true); } } @@ -299,11 +395,23 @@ function bindEvents() { $$('.tab').forEach((tab) => tab.classList.remove('active')); btn.classList.add('active'); state.adminTab = btn.dataset.admin; + state.adminPage[state.adminTab] = 1; loadAdminData(); })); $('#loginForm')?.addEventListener('submit', handleLogin); + $('#loginForm input[name="loginIdentifier"]')?.addEventListener('input', (e) => { + if (state.loginLockTimer) clearInterval(state.loginLockTimer); + state.loginLockTimer = null; + const submitBtn = getLoginSubmitButton(); + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.textContent = 'Đăng nhập'; + } + setLoginMessage(''); + restoreLoginCountdownIfNeeded(e.target.value); + }); $('#registerForm')?.addEventListener('submit', handleRegister); $('#forgotForm')?.addEventListener('submit', handleForgot); $('#resetForm')?.addEventListener('submit', handleReset); @@ -312,16 +420,233 @@ function bindEvents() { $('#refreshAdmin')?.addEventListener('click', loadAdminData); $('#logoutBtn')?.addEventListener('click', logout); $('#clearResponse')?.addEventListener('click', () => setResponse('Chưa có dữ liệu.')); + $('#adminTable')?.addEventListener('click', (e) => { + const pageBtn = e.target.closest('.page-btn'); + if (pageBtn) { + const total = state.adminData[state.adminTab]?.length || 0; + const totalPages = Math.max(1, Math.ceil(total / ADMIN_PAGE_SIZE)); + const action = pageBtn.dataset.pageAction; + if (action === 'prev') state.adminPage[state.adminTab] = Math.max(1, (state.adminPage[state.adminTab] || 1) - 1); + if (action === 'next') state.adminPage[state.adminTab] = Math.min(totalPages, (state.adminPage[state.adminTab] || 1) + 1); + renderTable(state.adminData[state.adminTab] || []); + } + }); +} + + + + +function getAllLoginLocks() { + return safeJson(localStorage.getItem(LOGIN_LOCK_KEY)) || {}; +} + +function getLoginLockState(identifier = '') { + const all = getAllLoginLocks(); + return all[normalizeLockKey(identifier)] || null; +} + +function saveLoginLockState(identifier, value) { + const key = normalizeLockKey(identifier); + if (!key) return; + const all = getAllLoginLocks(); + all[key] = value; + localStorage.setItem(LOGIN_LOCK_KEY, JSON.stringify(all)); +} + +function clearLoginLockState(identifier) { + const key = normalizeLockKey(identifier); + if (!key) return; + const all = getAllLoginLocks(); + delete all[key]; + localStorage.setItem(LOGIN_LOCK_KEY, JSON.stringify(all)); +} + +function getAllLoginFailCounts() { + return safeJson(localStorage.getItem(LOGIN_FAIL_COUNT_KEY)) || {}; +} + +function getLoginFailCount(identifier = '') { + const key = normalizeLockKey(identifier); + if (!key) return 0; + const all = getAllLoginFailCounts(); + return Number(all[key] || 0); +} + +function saveLoginFailCount(identifier, count) { + const key = normalizeLockKey(identifier); + if (!key) return; + const all = getAllLoginFailCounts(); + all[key] = Math.max(0, Number(count) || 0); + localStorage.setItem(LOGIN_FAIL_COUNT_KEY, JSON.stringify(all)); +} + +function clearLoginFailCount(identifier) { + const key = normalizeLockKey(identifier); + if (!key) return; + const all = getAllLoginFailCounts(); + delete all[key]; + localStorage.setItem(LOGIN_FAIL_COUNT_KEY, JSON.stringify(all)); +} + +function setLoginMessage(message = '', isError = false) { + const box = $('#loginMessage'); + if (!box) return; + box.textContent = message; + box.className = `inline-message ${isError ? 'error' : ''} ${message ? '' : 'hidden'}`; +} + +function getLoginSubmitButton() { + return $('#loginForm button[type="submit"]'); +} + +function extractSecondsFromMessage(message = '') { + const text = String(message || '').toLowerCase(); + const match = text.match(/(\d+)\s*(giây|giay|s|sec|second)/i); + if (!match) return null; + const seconds = Number(match[1]); + return Number.isFinite(seconds) && seconds > 0 ? seconds : null; +} + +function extractLockUntilMs(response = {}) { + const data = response.data && typeof response.data === 'object' ? response.data : {}; + const rawLockedUntil = getValue(response, 'lockedUntil', 'locked_until') || getValue(data, 'lockedUntil', 'locked_until'); + const rawSeconds = getValue(response, 'retryAfterSeconds', 'remainingSeconds', 'lockSeconds', 'lockedSeconds', 'retryAfter') + ?? getValue(data, 'retryAfterSeconds', 'remainingSeconds', 'lockSeconds', 'lockedSeconds', 'retryAfter'); + + if (rawLockedUntil) { + const time = new Date(rawLockedUntil).getTime(); + if (Number.isFinite(time) && time > Date.now()) return time; + } + + const seconds = Number(rawSeconds) || extractSecondsFromMessage(response.message); + if (Number.isFinite(seconds) && seconds > 0) return Date.now() + seconds * 1000; + + return null; +} + +function startLoginCountdownUntil(lockedUntilMs, identifier = '', messagePrefix = 'Bạn đã nhập sai 5 lần. Tài khoản bị khóa tạm 15 giây') { + clearInterval(state.loginLockTimer); + const submitBtn = getLoginSubmitButton(); + + const tick = () => { + const remaining = Math.ceil((Number(lockedUntilMs) - Date.now()) / 1000); + if (remaining <= 0) { + clearInterval(state.loginLockTimer); + state.loginLockTimer = null; + state.loginCountdown = 0; + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.textContent = 'Đăng nhập'; + } + clearLoginLockState(identifier); + clearLoginFailCount(identifier); + setLoginMessage('Đã hết 15 giây khóa tạm. Bạn có thể bấm Đăng nhập lại. Nếu sai tiếp 5 lần, tài khoản sẽ tiếp tục bị khóa 15 giây.', false); + return; + } + + state.loginCountdown = remaining; + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.textContent = `Đợi ${remaining}s`; + } + setLoginMessage(`${messagePrefix}. Còn ${remaining}s.`, true); + }; + + tick(); + state.loginLockTimer = setInterval(tick, 1000); } +function restoreLoginCountdownIfNeeded(identifier = '') { + const lock = getLoginLockState(identifier); + if (!lock || !lock.lockedUntil) return false; + + const lockedUntilMs = Number(lock.lockedUntil); + if (Number.isFinite(lockedUntilMs) && lockedUntilMs > Date.now()) { + startLoginCountdownUntil(lockedUntilMs, identifier, lock.message || 'Tài khoản đang bị khóa tạm'); + return true; + } + + clearLoginLockState(identifier); + return false; +} + +function handleLoginFailure(error, identifier) { + const response = error.response || {}; + const data = response.data && typeof response.data === 'object' ? response.data : {}; + const failedAttemptsFromApi = getValue(response, 'failedAttempts', 'failed_attempts') ?? getValue(data, 'failedAttempts', 'failed_attempts'); + const remainingAttemptsFromApi = getValue(response, 'remainingAttempts', 'remaining_attempts') ?? getValue(data, 'remainingAttempts', 'remaining_attempts'); + const lockedUntilMs = extractLockUntilMs(response); + + if (lockedUntilMs) { + const lockMessage = response.message || error.message || 'Bạn đã nhập sai 5 lần. Tài khoản bị khóa tạm 15 giây'; + saveLoginLockState(identifier, { lockedUntil: lockedUntilMs, message: lockMessage }); + saveLoginFailCount(identifier, MAX_FAILED_ATTEMPTS); + startLoginCountdownUntil(lockedUntilMs, identifier, lockMessage); + return; + } + + // Trường hợp backend chỉ trả status 423/429 hoặc chỉ trả message khóa 15 giây nhưng chưa trả lockedUntil. + const errorText = `${response.message || ''} ${error.message || ''}`.toLowerCase(); + const looksLikeTemporaryLock = error.status === 423 || error.status === 429 || + ((errorText.includes('khóa') || errorText.includes('khoa') || errorText.includes('lock')) && + (errorText.includes('15') || errorText.includes('giây') || errorText.includes('giay') || errorText.includes('thử lại') || errorText.includes('thu lai'))); + + if (looksLikeTemporaryLock) { + const fallbackSeconds = extractSecondsFromMessage(errorText) || TEMP_LOCK_SECONDS; + const fallbackUntil = Date.now() + fallbackSeconds * 1000; + const lockMessage = response.message || error.message || `Bạn đã nhập sai 5 lần. Tài khoản bị khóa tạm ${fallbackSeconds} giây`; + saveLoginLockState(identifier, { lockedUntil: fallbackUntil, message: lockMessage }); + saveLoginFailCount(identifier, MAX_FAILED_ATTEMPTS); + startLoginCountdownUntil(fallbackUntil, identifier, lockMessage); + return; + } + + const apiFailed = Number(failedAttemptsFromApi); + const localFailed = Number.isFinite(apiFailed) && apiFailed > 0 + ? apiFailed + : getLoginFailCount(identifier) + 1; + + // Backend khóa sau mỗi 5 lần sai. Nếu backend chưa trả lockedUntil ở lần thứ 5, frontend vẫn khóa nút 15s để đúng giao diện yêu cầu. + if (localFailed >= MAX_FAILED_ATTEMPTS) { + const fallbackUntil = Date.now() + TEMP_LOCK_SECONDS * 1000; + const lockMessage = `Bạn đã nhập sai ${MAX_FAILED_ATTEMPTS} lần. Tài khoản bị khóa tạm ${TEMP_LOCK_SECONDS} giây`; + saveLoginFailCount(identifier, MAX_FAILED_ATTEMPTS); + saveLoginLockState(identifier, { lockedUntil: fallbackUntil, message: lockMessage }); + startLoginCountdownUntil(fallbackUntil, identifier, lockMessage); + return; + } + + saveLoginFailCount(identifier, localFailed); + const remaining = Number.isFinite(Number(remainingAttemptsFromApi)) + ? Number(remainingAttemptsFromApi) + : Math.max(0, MAX_FAILED_ATTEMPTS - localFailed); + + setLoginMessage(`Sai tài khoản hoặc mật khẩu lần ${localFailed}/${MAX_FAILED_ATTEMPTS}. Còn ${remaining} lần thử, sai đủ ${MAX_FAILED_ATTEMPTS} lần sẽ khóa tạm ${TEMP_LOCK_SECONDS} giây.`, true); +} + + async function handleLogin(e) { e.preventDefault(); + const body = getFormData(e.target); + const identifier = body.loginIdentifier || body.email || body.userName || ''; + + // Nếu đang trong 15 giây khóa tạm, frontend chỉ đếm ngược và KHÔNG gọi API. + if (state.loginLockTimer || restoreLoginCountdownIfNeeded(identifier)) return; + try { - const res = await apiRequest('login', { method: 'POST', body: getFormData(e.target), auth: false }); + setLoginMessage(''); + const submitBtn = getLoginSubmitButton(); + if (submitBtn) submitBtn.textContent = 'Đăng nhập'; + const res = await apiRequest('login', { method: 'POST', body, auth: false }); + clearLoginLockState(identifier); + clearLoginFailCount(identifier); saveSession(res); showToast(res.message || 'Đăng nhập thành công'); openPage('profilePage'); - } catch (error) { showToast(error.message, true); } + } catch (error) { + showToast(error.message, true); + handleLoginFailure(error, identifier); + } } async function handleRegister(e) { diff --git a/Fontend/index.html b/Fontend/index.html index 17fadc2..ef21c61 100644 --- a/Fontend/index.html +++ b/Fontend/index.html @@ -57,6 +57,7 @@

Đăng nhập

+ diff --git a/Fontend/styles.css b/Fontend/styles.css index bc2df52..6bab9b9 100644 --- a/Fontend/styles.css +++ b/Fontend/styles.css @@ -126,3 +126,41 @@ th { .table-wrap table td:nth-child(3){ min-width:220px; } + +/* Thông báo lỗi đăng nhập và bộ đếm khóa tạm 15 giây */ +.inline-message{ + width:100%; + box-sizing:border-box; + padding:12px 14px; + border-radius:12px; + background:#12351f; + border:1px solid #16a34a; + color:#d1fae5; + font-weight:700; + overflow-wrap:anywhere; +} +.inline-message.error{ + background:#3a1111; + border-color:#dc2626; + color:#fecaca; +} +.pagination{ + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; + padding:14px 0 4px; + flex-wrap:wrap; + color:#475569; + font-weight:700; +} +.pagination.single{ + justify-content:flex-end; +} +.page-btn:disabled{ + opacity:.45; + cursor:not-allowed; +} +.table-wrap td:last-child{ + white-space:nowrap; +}