From f99002e664fb997ea8d194ca3ab16904dbcf7017 Mon Sep 17 00:00:00 2001 From: Zzzz Date: Mon, 27 Apr 2026 22:56:27 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=A8=E6=88=B7/=E8=B4=A6=E5=8F=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86,=E7=B3=BB=E7=BB=9F=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/backend/logging-guidelines.md | 93 +++++ .../04-27-continue-development/check.jsonl | 11 + .../implement.jsonl | 12 + .../tasks/04-27-continue-development/prd.md | 100 +++++ .../04-27-continue-development/task.json | 26 ++ .../library/controller/SystemLogServlet.java | 78 ++++ .../controller/UserManagementServlet.java | 366 ++++++++++++++++++ .../com/mzh/library/dao/SystemLogDao.java | 15 + .../com/mzh/library/dao/UserAccountDao.java | 20 + .../library/dao/impl/JdbcSystemLogDao.java | 166 ++++++++ .../com/mzh/library/dao/impl/JdbcUserDao.java | 163 +++++++- .../com/mzh/library/entity/SystemLog.java | 177 +++++++++ .../com/mzh/library/entity/SystemLogPage.java | 26 ++ .../entity/SystemLogSearchCriteria.java | 76 ++++ .../java/com/mzh/library/entity/User.java | 43 ++ .../library/entity/UserSearchCriteria.java | 58 +++ .../library/filter/AuthorizationFilter.java | 1 + .../mzh/library/service/SystemLogService.java | 9 + .../library/service/UserAccountService.java | 20 + .../service/impl/SystemLogServiceImpl.java | 101 +++++ .../service/impl/UserAccountServiceImpl.java | 345 +++++++++++++++++ .../webapp/WEB-INF/jsp/admin/users/form.jsp | 121 ++++++ .../webapp/WEB-INF/jsp/admin/users/manage.jsp | 139 +++++++ .../webapp/WEB-INF/jsp/common/header.jspf | 2 + src/main/webapp/WEB-INF/jsp/dashboard.jsp | 12 + .../WEB-INF/jsp/maintenance/system-logs.jsp | 138 +++++++ src/main/webapp/WEB-INF/jsp/role-home.jsp | 14 + src/main/webapp/WEB-INF/web.xml | 22 ++ src/main/webapp/static/css/app.css | 34 ++ .../service/PermissionPolicyCheck.java | 3 + .../service/SystemLogServiceCheck.java | 149 +++++++ .../service/UserAccountServiceCheck.java | 263 +++++++++++++ 32 files changed, 2801 insertions(+), 2 deletions(-) create mode 100644 .trellis/tasks/04-27-continue-development/check.jsonl create mode 100644 .trellis/tasks/04-27-continue-development/implement.jsonl create mode 100644 .trellis/tasks/04-27-continue-development/prd.md create mode 100644 .trellis/tasks/04-27-continue-development/task.json create mode 100644 src/main/java/com/mzh/library/controller/SystemLogServlet.java create mode 100644 src/main/java/com/mzh/library/controller/UserManagementServlet.java create mode 100644 src/main/java/com/mzh/library/dao/SystemLogDao.java create mode 100644 src/main/java/com/mzh/library/dao/UserAccountDao.java create mode 100644 src/main/java/com/mzh/library/dao/impl/JdbcSystemLogDao.java create mode 100644 src/main/java/com/mzh/library/entity/SystemLog.java create mode 100644 src/main/java/com/mzh/library/entity/SystemLogPage.java create mode 100644 src/main/java/com/mzh/library/entity/SystemLogSearchCriteria.java create mode 100644 src/main/java/com/mzh/library/entity/UserSearchCriteria.java create mode 100644 src/main/java/com/mzh/library/service/SystemLogService.java create mode 100644 src/main/java/com/mzh/library/service/UserAccountService.java create mode 100644 src/main/java/com/mzh/library/service/impl/SystemLogServiceImpl.java create mode 100644 src/main/java/com/mzh/library/service/impl/UserAccountServiceImpl.java create mode 100644 src/main/webapp/WEB-INF/jsp/admin/users/form.jsp create mode 100644 src/main/webapp/WEB-INF/jsp/admin/users/manage.jsp create mode 100644 src/main/webapp/WEB-INF/jsp/maintenance/system-logs.jsp create mode 100644 src/test/java/com/mzh/library/service/SystemLogServiceCheck.java create mode 100644 src/test/java/com/mzh/library/service/UserAccountServiceCheck.java diff --git a/.trellis/spec/backend/logging-guidelines.md b/.trellis/spec/backend/logging-guidelines.md index c91cdeb..365c238 100644 --- a/.trellis/spec/backend/logging-guidelines.md +++ b/.trellis/spec/backend/logging-guidelines.md @@ -51,3 +51,96 @@ Server logs may contain technical stack traces for developers. User-facing JSP pages should receive concise messages. Durable system logs should record the operation, actor, failure category, and correlation details needed to locate the server-side exception. + +--- + +## Scenario: Admin User Management Audit And System Log Viewer + +### 1. Scope / Trigger + +- Trigger: administrator user/account management writes durable audit rows, and + `/admin/system-logs` reads those rows through a Servlet -> Service -> DAO -> + MySQL flow. +- The viewer is read-only; mutation belongs to the business service that owns + the operation being audited. + +### 2. Signatures + +- DB signature: `system_logs(id, operator_id, operator_role, operation_type, + target_table, target_id, result_status, message, request_ip, created_at)`. +- DAO signatures: `SystemLogDao.search(criteria)`, + `SystemLogDao.findOperationTypes()`, and + `SystemLogDao.create(connection, log)`. +- Service signatures: + `SystemLogService.searchLogs(AuthenticatedUser actor, SystemLogSearchCriteria criteria)` + and user-management methods that accept `requestIp` for audit rows. +- Routes: `GET /admin/system-logs`; user-management audit sources include + `POST /admin/users`, `POST /admin/users/update`, and + `POST /admin/users/deactivate`. + +### 3. Contracts + +- `/admin/system-logs` requires `VIEW_SYSTEM_LOGS`; user-management write + routes require `MANAGE_USERS`. +- Search criteria fields are `operationType`, `keyword`, `createdFrom`, and + `createdTo`; dates use `YYYY-MM-DD`. +- System-log result rows are newest-first and may include missing joined user + names. Entity display helpers must render an operator label as display name, + username, `User #`, or `System` in that order. +- JSPs must use safe JavaBean helpers such as `operatorLabel`, + `operatorMetaText`, `resultStatusCode`, and `resultStatusName`; do not put raw + database status strings into CSS class names. +- Audit messages must summarize actions with IDs/usernames/roles, never + passwords, raw credentials, password hashes, or request bodies. + +### 4. Validation & Error Matrix + +- Unauthenticated or unauthorized viewer -> `You do not have permission to view + system logs.` +- `operationType` longer than 64 characters -> field error on `operationType`. +- `keyword` longer than 120 characters -> field error on `keyword`. +- Invalid date text -> `Start date must use YYYY-MM-DD.` or `End date must use + YYYY-MM-DD.` +- `createdFrom` after `createdTo` -> field error on `createdTo`. +- DAO failure while loading logs -> server-side Java logger entry plus `System + log service is temporarily unavailable. Please try again later.` +- Audit insert failure inside a user-management transaction -> rollback the + mutating operation and return the user-management unavailable message. + +### 5. Good/Base/Bad Cases + +- Good: user creation and its `user.create` audit row commit in the same + transaction, including `operator_id`, `operator_role`, `target_table=users`, + `target_id`, `result_status=success`, and `request_ip`. +- Base: a system log with `operator_id` but no joined user displays as + `User #`, not as `System`. +- Bad: a JSP renders `class="status-${log.resultStatus}"` from an arbitrary + database value. + +### 6. Tests Required + +- Service checks for permission denial, criteria validation, date-range + validation, DAO failure fallback, newest-first/search DAO contract, and empty + results. +- User-management service checks for audit log creation on create/update/ + deactivate and rollback when audit creation fails inside the transaction. +- Entity/display checks for operator fallback labels and normalized result + status CSS codes. +- JSP scan confirming no scriptlet, SQL, JDBC, password, or password-hash + rendering in admin/log pages. + +### 7. Wrong vs Correct + +#### Wrong + +```jsp +${log.resultStatus} +``` + +#### Correct + +```jsp + + + +``` diff --git a/.trellis/tasks/04-27-continue-development/check.jsonl b/.trellis/tasks/04-27-continue-development/check.jsonl new file mode 100644 index 0000000..0863d0e --- /dev/null +++ b/.trellis/tasks/04-27-continue-development/check.jsonl @@ -0,0 +1,11 @@ +{"file": ".trellis/spec/backend/index.md", "reason": "Backend architecture and checklist for verification"} +{"file": ".trellis/spec/backend/directory-structure.md", "reason": "Verify new Java classes follow expected package layout"} +{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "Verify schema/DAO changes are safe and consistent"} +{"file": ".trellis/spec/backend/error-handling.md", "reason": "Verify validation and ServiceResult behavior"} +{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "Verify audit/system-log behavior"} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Backend quality checks"} +{"file": ".trellis/spec/frontend/index.md", "reason": "Frontend checklist for JSP changes"} +{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "Verify UI consistency for admin forms/tables/alerts"} +{"file": ".trellis/spec/frontend/type-safety.md", "reason": "Verify Servlet/JSP attribute contracts"} +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "Frontend quality checks"} +{"file": ".trellis/tasks/archive/2026-04/00-bootstrap-guidelines/research/project-requirements.md", "reason": "Verify implementation remains aligned with project requirements"} diff --git a/.trellis/tasks/04-27-continue-development/implement.jsonl b/.trellis/tasks/04-27-continue-development/implement.jsonl new file mode 100644 index 0000000..9c534fc --- /dev/null +++ b/.trellis/tasks/04-27-continue-development/implement.jsonl @@ -0,0 +1,12 @@ +{"file": ".trellis/spec/backend/index.md", "reason": "Backend architecture and pre-development checklist for JSP/Servlet/MySQL implementation"} +{"file": ".trellis/spec/backend/directory-structure.md", "reason": "Expected controller/service/DAO/entity package layout"} +{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "Schema, JDBC DAO, and migration safety rules for users and system logs"} +{"file": ".trellis/spec/backend/error-handling.md", "reason": "ServiceResult and validation/error behavior for admin workflows"} +{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "System-log/audit-log behavior and Java logging conventions"} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Backend quality bar before coding and build checks"} +{"file": ".trellis/spec/frontend/index.md", "reason": "Frontend JSP/CSS conventions and checklist"} +{"file": ".trellis/spec/frontend/directory-structure.md", "reason": "JSP placement and static asset organization"} +{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "Existing UI patterns for forms, tables, alerts, and navigation"} +{"file": ".trellis/spec/frontend/type-safety.md", "reason": "Servlet-to-JSP request attribute and validation contracts"} +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "Frontend verification expectations"} +{"file": ".trellis/tasks/archive/2026-04/00-bootstrap-guidelines/research/project-requirements.md", "reason": "Original project scope and library-management requirements"} diff --git a/.trellis/tasks/04-27-continue-development/prd.md b/.trellis/tasks/04-27-continue-development/prd.md new file mode 100644 index 0000000..f434428 --- /dev/null +++ b/.trellis/tasks/04-27-continue-development/prd.md @@ -0,0 +1,100 @@ +# Admin User Management And System Logs + +## Goal + +Implement the next administrator feature slice for the JSP + Servlet + MySQL library-management system: user/account management and system log viewing. + +## What I already know + +* The user asked to continue developing the program. +* The project is a Java 11 Maven WAR application using JSP + Servlet on Tomcat and MySQL through JDBC DAO classes. +* Existing implemented slices include login, role/permission checks, dashboard navigation, book catalog/search, book management, reader profile/eligibility management, borrowing circulation, reader loan history, overdue visibility, and a report center. +* Recent commits show the latest completed feature slices were borrowing circulation and the report center. +* Current routes include `/login`, `/logout`, `/dashboard`, role homes, `/catalog`, `/books`, `/readers`, `/borrowing`, `/reader/loans`, and `/reports`. +* The schema already defines `users`, `roles`, `permissions`, `role_permissions`, `system_logs`, `readers`, `book_categories`, `books`, and `borrow_records`. +* Permissions already include `manage_users` and `view_system_logs`, but there are no dedicated user-management or system-log UI/controller/service/DAO slices in the current codebase. +* The user asked whether user/account management and system-log viewing can be completed together; they are closely related administrator backend features and should be implemented in one task. + +## Assumptions (temporary) + +* The feature should build on the existing library-management roadmap rather than refactor unrelated infrastructure. +* User/account management and system logs should share the administrator area, navigation pattern, and authorization style where practical. + +## Open Questions + +* None blocking. MVP scope is locked to user/account management plus read-only system-log viewing. + +## Requirements (evolving) + +* Preserve the existing JSP -> Servlet -> Service -> DAO -> MySQL layering. +* Keep authorization consistent with `PermissionPolicy` and `AuthorizationFilter`. +* Reuse existing card, form, table, alert, and header patterns for JSP/CSS work. +* Add or update schema/data-access/service/controller/JSP pieces only for user/account management and system-log viewing. + +### User / Account Management + +* Administrators can open a user-management page from the administrator dashboard/header area. +* Administrators can list users with username, display name, role, active state, created time, and updated time. +* Administrators can search/filter users by keyword, role, and active state. +* Administrators can create user accounts for administrator, librarian, and reader roles. +* Account creation requires username, display name, role, active state, and password. +* Account update allows display name, role, active state, and password reset when a new password is provided. +* Usernames must be unique and normalized consistently with login behavior. +* Passwords must use the existing `PasswordHasher`; no plain-text password storage. +* Deactivation should be supported through the same user edit/update path or a clear action; physical deletion is out of scope. +* Reader-account creation does not need to automatically create or link a reader profile in this MVP. Existing reader profile management may continue to link by user id. +* Administrators should not accidentally lock out all administrator access. At minimum, block deactivating the currently logged-in administrator's own account and block changing their own role away from administrator. + +### System Log Viewing + +* Administrators can open a system-log page from the administrator dashboard/header area. +* System-log viewing is read-only in this MVP. +* Logs should show operator id/name when available, operation type, detail, IP address when available, and created time. +* Logs can be filtered by operation type, keyword, and date range when practical within existing schema. +* The newest logs should appear first. +* Empty and error states should use existing JSP alert/empty-state conventions. + +### Audit Logging + +* User-management create/update/deactivate actions should write system-log rows. +* Login/logout logging can remain as existing Java logger output unless implementing database audit logging is cheap and consistent. +* Log write failures should not make normal user-management operations appear successful if the business transaction depends on the log row; otherwise, keep behavior conservative and explain in code via service result/logging. + +## Acceptance Criteria (evolving) + +* [ ] Administrator can open user management from the admin area. +* [ ] Administrator can list, search, create, update, and deactivate user accounts. +* [ ] User create/update validation handles required fields, duplicate username, valid role, active state, and optional password reset. +* [ ] User passwords are hashed with the existing password hashing utility. +* [ ] The current administrator cannot deactivate their own account or change their own role away from administrator. +* [ ] Administrator can open read-only system logs from the admin area. +* [ ] System logs show newest entries first and support practical filtering. +* [ ] User-management changes create system-log entries. +* [ ] Routes are protected by `manage_users` / `view_system_logs` authorization as appropriate. +* [ ] Feature follows existing validation and `ServiceResult` behavior. +* [ ] Maven build/check commands pass where available. + +## Definition of Done (team quality bar) + +* Tests added/updated where appropriate. +* Lint/typecheck/build checks are green. +* Docs/notes updated if behavior changes. +* Rollout/rollback considered if risky. + +## Out of Scope (explicit) + +* No unrelated visual redesign. +* No broad framework migration. +* No destructive database reset requirement. +* No role/permission editor UI. +* No automatic reader-profile creation/linking from user creation. +* No system-log deletion/export/retention policy. +* No password self-service or email reset workflow. + +## Technical Notes + +* `src/main/webapp/WEB-INF/web.xml` defines the current Servlet mappings. +* `src/main/resources/db/schema.sql` already contains user, permission, and system log tables. +* `src/main/java/com/mzh/library/entity/Permission.java` includes `MANAGE_USERS` and `VIEW_SYSTEM_LOGS`. +* `src/main/java/com/mzh/library/filter/AuthorizationFilter.java` maps `/admin` to `MANAGE_USERS`. +* `src/main/webapp/WEB-INF/jsp/dashboard.jsp` and `role-home.jsp` describe administrator account, role, permission, and system-maintenance entry points, but those are not fully implemented yet. diff --git a/.trellis/tasks/04-27-continue-development/task.json b/.trellis/tasks/04-27-continue-development/task.json new file mode 100644 index 0000000..22fcd74 --- /dev/null +++ b/.trellis/tasks/04-27-continue-development/task.json @@ -0,0 +1,26 @@ +{ + "id": "continue-development", + "name": "continue-development", + "title": "brainstorm: 继续开发程序", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "Zzzz", + "assignee": "Zzzz", + "createdAt": "2026-04-27", + "completedAt": null, + "branch": null, + "base_branch": "master", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/src/main/java/com/mzh/library/controller/SystemLogServlet.java b/src/main/java/com/mzh/library/controller/SystemLogServlet.java new file mode 100644 index 0000000..bedb6d9 --- /dev/null +++ b/src/main/java/com/mzh/library/controller/SystemLogServlet.java @@ -0,0 +1,78 @@ +package com.mzh.library.controller; + +import com.mzh.library.dao.impl.JdbcSystemLogDao; +import com.mzh.library.entity.AuthenticatedUser; +import com.mzh.library.entity.SystemLogPage; +import com.mzh.library.entity.SystemLogSearchCriteria; +import com.mzh.library.service.ServiceResult; +import com.mzh.library.service.SystemLogService; +import com.mzh.library.service.impl.SystemLogServiceImpl; +import com.mzh.library.util.SessionAttributes; + +import java.io.IOException; +import java.util.Collections; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +public class SystemLogServlet extends HttpServlet { + private static final String LOGS_JSP = "/WEB-INF/jsp/maintenance/system-logs.jsp"; + private static final String UNAUTHORIZED_JSP = "/WEB-INF/jsp/auth/unauthorized.jsp"; + private static final String DENIED_MESSAGE = "You do not have permission to view system logs."; + + private SystemLogService systemLogService; + + @Override + public void init() { + this.systemLogService = new SystemLogServiceImpl(new JdbcSystemLogDao()); + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + SystemLogSearchCriteria criteria = new SystemLogSearchCriteria( + request.getParameter("operationType"), + request.getParameter("keyword"), + request.getParameter("createdFrom"), + request.getParameter("createdTo") + ); + request.setAttribute("criteria", criteria); + + ServiceResult result = systemLogService.searchLogs(currentUser(request), criteria); + if (isPermissionDenied(result)) { + forwardDenied(request, response, result.getMessage()); + return; + } + + if (result.isSuccessful()) { + request.setAttribute("logs", result.getData().getLogs()); + request.setAttribute("operationTypes", result.getData().getOperationTypes()); + } else { + request.setAttribute("logs", Collections.emptyList()); + request.setAttribute("operationTypes", Collections.emptyList()); + request.setAttribute("errorMessage", result.getMessage()); + request.setAttribute("errors", result.getErrors()); + } + + request.getRequestDispatcher(LOGS_JSP).forward(request, response); + } + + private boolean isPermissionDenied(ServiceResult result) { + return !result.isSuccessful() && DENIED_MESSAGE.equals(result.getMessage()); + } + + private void forwardDenied(HttpServletRequest request, HttpServletResponse response, String message) + throws ServletException, IOException { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + request.setAttribute("errorMessage", message); + request.getRequestDispatcher(UNAUTHORIZED_JSP).forward(request, response); + } + + private AuthenticatedUser currentUser(HttpServletRequest request) { + HttpSession session = request.getSession(false); + Object value = session == null ? null : session.getAttribute(SessionAttributes.AUTHENTICATED_USER); + return value instanceof AuthenticatedUser ? (AuthenticatedUser) value : null; + } +} diff --git a/src/main/java/com/mzh/library/controller/UserManagementServlet.java b/src/main/java/com/mzh/library/controller/UserManagementServlet.java new file mode 100644 index 0000000..cf7ddf3 --- /dev/null +++ b/src/main/java/com/mzh/library/controller/UserManagementServlet.java @@ -0,0 +1,366 @@ +package com.mzh.library.controller; + +import com.mzh.library.dao.impl.JdbcSystemLogDao; +import com.mzh.library.dao.impl.JdbcUserDao; +import com.mzh.library.entity.AuthenticatedUser; +import com.mzh.library.entity.Role; +import com.mzh.library.entity.User; +import com.mzh.library.entity.UserSearchCriteria; +import com.mzh.library.service.ServiceResult; +import com.mzh.library.service.UserAccountService; +import com.mzh.library.service.impl.UserAccountServiceImpl; +import com.mzh.library.util.SessionAttributes; + +import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +public class UserManagementServlet extends HttpServlet { + private static final String MANAGE_JSP = "/WEB-INF/jsp/admin/users/manage.jsp"; + private static final String FORM_JSP = "/WEB-INF/jsp/admin/users/form.jsp"; + private static final String UNAUTHORIZED_JSP = "/WEB-INF/jsp/auth/unauthorized.jsp"; + private static final String FLASH_SUCCESS = "flashSuccess"; + private static final String FLASH_ERROR = "flashError"; + private static final String DENIED_MESSAGE = "You do not have permission to manage users."; + + private UserAccountService userAccountService; + + @Override + public void init() { + JdbcUserDao userDao = new JdbcUserDao(); + this.userAccountService = new UserAccountServiceImpl(userDao, new JdbcSystemLogDao()); + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + String path = request.getServletPath(); + if ("/admin/users/new".equals(path)) { + renderForm(request, response, "Create user account", "/admin/users", defaultUser(), + Collections.emptyMap(), Collections.emptyMap(), null); + return; + } + if ("/admin/users/edit".equals(path)) { + showEditForm(request, response); + return; + } + if (!"/admin/users".equals(path)) { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + + showManagementList(request, response); + } + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + String path = request.getServletPath(); + if ("/admin/users".equals(path)) { + createUser(request, response); + return; + } + if ("/admin/users/update".equals(path)) { + updateUser(request, response); + return; + } + if ("/admin/users/deactivate".equals(path)) { + deactivateUser(request, response); + return; + } + + response.sendError(HttpServletResponse.SC_NOT_FOUND); + } + + private void showManagementList(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + UserSearchCriteria criteria = searchCriteria(request); + request.setAttribute("criteria", criteria); + request.setAttribute("roles", Role.values()); + applyFlash(request); + + ServiceResult> result = userAccountService.searchUsers(currentUser(request), criteria); + if (isPermissionDenied(result)) { + forwardDenied(request, response, result.getMessage()); + return; + } + + request.setAttribute("users", result.isSuccessful() ? result.getData() : Collections.emptyList()); + if (!result.isSuccessful()) { + request.setAttribute("errorMessage", result.getMessage()); + request.setAttribute("errors", result.getErrors()); + } + request.getRequestDispatcher(MANAGE_JSP).forward(request, response); + } + + private void showEditForm(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + long id = requiredLong(request.getParameter("id"), -1L); + ServiceResult> result = userAccountService.findUser(currentUser(request), id); + if (isPermissionDenied(result)) { + forwardDenied(request, response, result.getMessage()); + return; + } + if (!result.isSuccessful() || !result.getData().isPresent()) { + flashError(request, result.isSuccessful() ? "User account was not found." : result.getMessage()); + response.sendRedirect(request.getContextPath() + "/admin/users"); + return; + } + + renderForm(request, response, "Edit user account", "/admin/users/update", result.getData().get(), + Collections.emptyMap(), Collections.emptyMap(), null); + } + + private void createUser(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + UserForm form = readUserForm(request, false); + if (!form.getErrors().isEmpty()) { + renderForm(request, response, "Create user account", "/admin/users", form.getUser(), form.getValues(), + form.getErrors(), "Please correct the highlighted account fields."); + return; + } + + ServiceResult result = userAccountService.createUser(currentUser(request), form.getUser(), + form.getPassword(), clientIp(request)); + if (!result.isSuccessful()) { + handleFormFailure(request, response, "Create user account", "/admin/users", form, result); + return; + } + + flashSuccess(request, result.getMessage()); + response.sendRedirect(request.getContextPath() + "/admin/users"); + } + + private void updateUser(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + UserForm form = readUserForm(request, true); + if (!form.getErrors().isEmpty()) { + renderForm(request, response, "Edit user account", "/admin/users/update", form.getUser(), form.getValues(), + form.getErrors(), "Please correct the highlighted account fields."); + return; + } + + ServiceResult result = userAccountService.updateUser(currentUser(request), form.getUser(), + form.getPassword(), clientIp(request)); + if (!result.isSuccessful()) { + handleFormFailure(request, response, "Edit user account", "/admin/users/update", form, result); + return; + } + + flashSuccess(request, result.getMessage()); + response.sendRedirect(request.getContextPath() + "/admin/users"); + } + + private void deactivateUser(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + long id = requiredLong(request.getParameter("id"), -1L); + ServiceResult result = userAccountService.deactivateUser(currentUser(request), id, clientIp(request)); + if (isPermissionDenied(result)) { + forwardDenied(request, response, result.getMessage()); + return; + } + if (result.isSuccessful()) { + flashSuccess(request, result.getMessage()); + } else { + flashError(request, result.getMessage()); + } + response.sendRedirect(request.getContextPath() + "/admin/users"); + } + + private void handleFormFailure(HttpServletRequest request, HttpServletResponse response, String title, + String action, UserForm form, ServiceResult result) + throws ServletException, IOException { + if (isPermissionDenied(result)) { + forwardDenied(request, response, result.getMessage()); + return; + } + renderForm(request, response, title, action, form.getUser(), form.getValues(), result.getErrors(), + result.getMessage()); + } + + private void renderForm(HttpServletRequest request, HttpServletResponse response, String title, String action, + User user, Map formValues, Map errors, + String errorMessage) + throws ServletException, IOException { + request.setAttribute("roles", Role.values()); + request.setAttribute("formTitle", title); + request.setAttribute("formAction", action); + request.setAttribute("user", user); + request.setAttribute("formValues", formValues); + request.setAttribute("errors", errors); + if (errorMessage != null && !errorMessage.isEmpty()) { + request.setAttribute("errorMessage", errorMessage); + } + request.getRequestDispatcher(FORM_JSP).forward(request, response); + } + + private UserForm readUserForm(HttpServletRequest request, boolean requireId) { + Map values = formValues(request); + Map errors = new LinkedHashMap<>(); + User user = new User(); + + if (requireId) { + user.setId(parseLong(values.get("id"), "id", "Select a valid user account.", errors)); + } + user.setUsername(values.get("username")); + user.setDisplayName(values.get("displayName")); + user.setActive(parseActive(values.get("active"), errors)); + try { + user.setRole(Role.fromCode(values.get("role"))); + } catch (IllegalArgumentException ex) { + errors.put("role", "Select a role."); + } + + return new UserForm(user, values, errors, request.getParameter("password")); + } + + private Map formValues(HttpServletRequest request) { + Map values = new LinkedHashMap<>(); + values.put("id", trim(request.getParameter("id"))); + values.put("username", trim(request.getParameter("username"))); + values.put("displayName", trim(request.getParameter("displayName"))); + values.put("role", trim(request.getParameter("role"))); + values.put("active", trim(request.getParameter("active"))); + return values; + } + + private UserSearchCriteria searchCriteria(HttpServletRequest request) { + return new UserSearchCriteria( + request.getParameter("keyword"), + request.getParameter("role"), + request.getParameter("active") + ); + } + + private User defaultUser() { + User user = new User(); + user.setRole(Role.READER); + user.setActive(true); + return user; + } + + private boolean parseActive(String value, Map errors) { + String normalized = trim(value); + if ("true".equals(normalized) || UserSearchCriteria.ACTIVE_STATUS.equals(normalized)) { + return true; + } + if ("false".equals(normalized) || UserSearchCriteria.INACTIVE_STATUS.equals(normalized)) { + return false; + } + errors.put("active", "Select an active state."); + return false; + } + + private long parseLong(String value, String field, String message, Map errors) { + String trimmed = trim(value); + if (trimmed.isEmpty()) { + errors.put(field, message); + return 0L; + } + try { + long parsed = Long.parseLong(trimmed); + if (parsed <= 0) { + errors.put(field, message); + } + return parsed; + } catch (NumberFormatException ex) { + errors.put(field, message); + return 0L; + } + } + + private long requiredLong(String value, long fallback) { + try { + long parsed = Long.parseLong(trim(value)); + return parsed > 0 ? parsed : fallback; + } catch (NumberFormatException ex) { + return fallback; + } + } + + private boolean isPermissionDenied(ServiceResult result) { + return !result.isSuccessful() && DENIED_MESSAGE.equals(result.getMessage()); + } + + private void forwardDenied(HttpServletRequest request, HttpServletResponse response, String message) + throws ServletException, IOException { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + request.setAttribute("errorMessage", message); + request.getRequestDispatcher(UNAUTHORIZED_JSP).forward(request, response); + } + + private AuthenticatedUser currentUser(HttpServletRequest request) { + HttpSession session = request.getSession(false); + Object value = session == null ? null : session.getAttribute(SessionAttributes.AUTHENTICATED_USER); + return value instanceof AuthenticatedUser ? (AuthenticatedUser) value : null; + } + + private void applyFlash(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null) { + return; + } + moveFlash(session, request, FLASH_SUCCESS, "successMessage"); + moveFlash(session, request, FLASH_ERROR, "errorMessage"); + } + + private void moveFlash(HttpSession session, HttpServletRequest request, String sessionKey, String requestKey) { + Object value = session.getAttribute(sessionKey); + if (value != null) { + request.setAttribute(requestKey, value); + session.removeAttribute(sessionKey); + } + } + + private void flashSuccess(HttpServletRequest request, String message) { + request.getSession().setAttribute(FLASH_SUCCESS, message); + } + + private void flashError(HttpServletRequest request, String message) { + request.getSession().setAttribute(FLASH_ERROR, message); + } + + private String clientIp(HttpServletRequest request) { + return trim(request.getRemoteAddr()); + } + + private String trim(String value) { + return value == null ? "" : value.trim(); + } + + private static final class UserForm { + private final User user; + private final Map values; + private final Map errors; + private final String password; + + private UserForm(User user, Map values, Map errors, String password) { + this.user = user; + this.values = values; + this.errors = errors; + this.password = password; + } + + private User getUser() { + return user; + } + + private Map getValues() { + return values; + } + + private Map getErrors() { + return errors; + } + + private String getPassword() { + return password; + } + } +} diff --git a/src/main/java/com/mzh/library/dao/SystemLogDao.java b/src/main/java/com/mzh/library/dao/SystemLogDao.java new file mode 100644 index 0000000..3eebd5d --- /dev/null +++ b/src/main/java/com/mzh/library/dao/SystemLogDao.java @@ -0,0 +1,15 @@ +package com.mzh.library.dao; + +import com.mzh.library.entity.SystemLog; +import com.mzh.library.entity.SystemLogSearchCriteria; + +import java.sql.Connection; +import java.util.List; + +public interface SystemLogDao { + List search(SystemLogSearchCriteria criteria); + + List findOperationTypes(); + + long create(Connection connection, SystemLog log); +} diff --git a/src/main/java/com/mzh/library/dao/UserAccountDao.java b/src/main/java/com/mzh/library/dao/UserAccountDao.java new file mode 100644 index 0000000..d162c46 --- /dev/null +++ b/src/main/java/com/mzh/library/dao/UserAccountDao.java @@ -0,0 +1,20 @@ +package com.mzh.library.dao; + +import com.mzh.library.entity.User; +import com.mzh.library.entity.UserSearchCriteria; + +import java.sql.Connection; +import java.util.List; +import java.util.Optional; + +public interface UserAccountDao { + List search(UserSearchCriteria criteria); + + Optional findById(long id); + + Optional findByUsername(String username); + + long create(Connection connection, User user); + + boolean update(Connection connection, User user, boolean updatePassword); +} diff --git a/src/main/java/com/mzh/library/dao/impl/JdbcSystemLogDao.java b/src/main/java/com/mzh/library/dao/impl/JdbcSystemLogDao.java new file mode 100644 index 0000000..547cf2f --- /dev/null +++ b/src/main/java/com/mzh/library/dao/impl/JdbcSystemLogDao.java @@ -0,0 +1,166 @@ +package com.mzh.library.dao.impl; + +import com.mzh.library.dao.SystemLogDao; +import com.mzh.library.entity.SystemLog; +import com.mzh.library.entity.SystemLogSearchCriteria; +import com.mzh.library.exception.DaoException; +import com.mzh.library.util.JdbcUtil; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +public class JdbcSystemLogDao implements SystemLogDao { + private static final String LOG_COLUMNS = "" + + "sl.id, sl.operator_id, u.username AS operator_username, " + + "u.display_name AS operator_display_name, sl.operator_role, sl.operation_type, " + + "sl.target_table, sl.target_id, sl.result_status, sl.message, sl.request_ip, sl.created_at "; + + private static final String LOG_FROM = "" + + "FROM system_logs sl " + + "LEFT JOIN users u ON u.id = sl.operator_id "; + + private static final String OPERATION_TYPES = "" + + "SELECT DISTINCT operation_type " + + "FROM system_logs " + + "ORDER BY operation_type"; + + private static final String CREATE = "" + + "INSERT INTO system_logs " + + "(operator_id, operator_role, operation_type, target_table, target_id, result_status, message, request_ip) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; + + @Override + public List search(SystemLogSearchCriteria criteria) { + SystemLogSearchCriteria normalized = criteria == null ? new SystemLogSearchCriteria() : criteria; + List parameters = new ArrayList<>(); + StringBuilder sql = new StringBuilder("SELECT ") + .append(LOG_COLUMNS) + .append(LOG_FROM) + .append("WHERE 1 = 1 "); + + if (!normalized.getOperationType().isEmpty()) { + sql.append("AND sl.operation_type = ? "); + parameters.add(normalized.getOperationType()); + } + appendKeyword(sql, parameters, normalized.getKeyword()); + if (normalized.getCreatedFrom() != null) { + sql.append("AND sl.created_at >= ? "); + parameters.add(Timestamp.valueOf(normalized.getCreatedFrom().atStartOfDay())); + } + if (normalized.getCreatedTo() != null) { + sql.append("AND sl.created_at < ? "); + parameters.add(Timestamp.valueOf(normalized.getCreatedTo().plusDays(1).atStartOfDay())); + } + sql.append("ORDER BY sl.created_at DESC, sl.id DESC"); + + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql.toString())) { + bind(statement, parameters); + try (ResultSet resultSet = statement.executeQuery()) { + List logs = new ArrayList<>(); + while (resultSet.next()) { + logs.add(mapLog(resultSet)); + } + return logs; + } + } catch (SQLException ex) { + throw new DaoException("Unable to search system logs", ex); + } + } + + @Override + public List findOperationTypes() { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(OPERATION_TYPES); + ResultSet resultSet = statement.executeQuery()) { + List operationTypes = new ArrayList<>(); + while (resultSet.next()) { + operationTypes.add(resultSet.getString("operation_type")); + } + return operationTypes; + } catch (SQLException ex) { + throw new DaoException("Unable to load system log operation types", ex); + } + } + + @Override + public long create(Connection connection, SystemLog log) { + try (PreparedStatement statement = connection.prepareStatement(CREATE, Statement.RETURN_GENERATED_KEYS)) { + if (log.getOperatorId() == null) { + statement.setNull(1, Types.BIGINT); + } else { + statement.setLong(1, log.getOperatorId()); + } + statement.setString(2, log.getOperatorRole()); + statement.setString(3, log.getOperationType()); + statement.setString(4, log.getTargetTable()); + statement.setString(5, log.getTargetId()); + statement.setString(6, log.getResultStatus()); + statement.setString(7, log.getMessage()); + statement.setString(8, log.getRequestIp()); + statement.executeUpdate(); + + try (ResultSet generatedKeys = statement.getGeneratedKeys()) { + if (generatedKeys.next()) { + return generatedKeys.getLong(1); + } + } + throw new DaoException("Unable to read generated system log id", null); + } catch (SQLException ex) { + throw new DaoException("Unable to create system log", ex); + } + } + + private void appendKeyword(StringBuilder sql, List parameters, String value) { + if (value == null || value.trim().isEmpty()) { + return; + } + String filter = "%" + value.trim() + "%"; + sql.append("AND (sl.operation_type LIKE ? OR sl.target_table LIKE ? OR sl.target_id LIKE ? ") + .append("OR sl.message LIKE ? OR sl.request_ip LIKE ? OR u.username LIKE ? OR u.display_name LIKE ?) "); + for (int i = 0; i < 7; i++) { + parameters.add(filter); + } + } + + private void bind(PreparedStatement statement, List parameters) throws SQLException { + for (int i = 0; i < parameters.size(); i++) { + Object value = parameters.get(i); + if (value instanceof Timestamp) { + statement.setTimestamp(i + 1, (Timestamp) value); + } else { + statement.setString(i + 1, value.toString()); + } + } + } + + private SystemLog mapLog(ResultSet resultSet) throws SQLException { + SystemLog log = new SystemLog(); + log.setId(resultSet.getLong("id")); + long operatorId = resultSet.getLong("operator_id"); + log.setOperatorId(resultSet.wasNull() ? null : operatorId); + log.setOperatorUsername(resultSet.getString("operator_username")); + log.setOperatorDisplayName(resultSet.getString("operator_display_name")); + log.setOperatorRole(resultSet.getString("operator_role")); + log.setOperationType(resultSet.getString("operation_type")); + log.setTargetTable(resultSet.getString("target_table")); + log.setTargetId(resultSet.getString("target_id")); + log.setResultStatus(resultSet.getString("result_status")); + log.setMessage(resultSet.getString("message")); + log.setRequestIp(resultSet.getString("request_ip")); + log.setCreatedAt(toLocalDateTime(resultSet.getTimestamp("created_at"))); + return log; + } + + private LocalDateTime toLocalDateTime(Timestamp timestamp) { + return timestamp == null ? null : timestamp.toLocalDateTime(); + } +} diff --git a/src/main/java/com/mzh/library/dao/impl/JdbcUserDao.java b/src/main/java/com/mzh/library/dao/impl/JdbcUserDao.java index eb2cd56..4c932fd 100644 --- a/src/main/java/com/mzh/library/dao/impl/JdbcUserDao.java +++ b/src/main/java/com/mzh/library/dao/impl/JdbcUserDao.java @@ -1,8 +1,10 @@ package com.mzh.library.dao.impl; +import com.mzh.library.dao.UserAccountDao; import com.mzh.library.dao.UserDao; import com.mzh.library.entity.Role; import com.mzh.library.entity.User; +import com.mzh.library.entity.UserSearchCriteria; import com.mzh.library.exception.DaoException; import com.mzh.library.util.JdbcUtil; @@ -10,14 +12,40 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; -public class JdbcUserDao implements UserDao { +public class JdbcUserDao implements UserDao, UserAccountDao { + private static final String USER_COLUMNS = "" + + "id, username, password_hash, display_name, role_code, active, created_at, updated_at "; + private static final String FIND_ACTIVE_BY_USERNAME = "" - + "SELECT id, username, password_hash, display_name, role_code, active " + + "SELECT " + USER_COLUMNS + "FROM users " + "WHERE username = ? AND active = 1"; + private static final String FIND_BY_ID = "" + + "SELECT " + USER_COLUMNS + + "FROM users " + + "WHERE id = ?"; + + private static final String FIND_BY_USERNAME = "" + + "SELECT " + USER_COLUMNS + + "FROM users " + + "WHERE username = ?"; + + private static final String CREATE = "" + + "INSERT INTO users (username, password_hash, display_name, role_code, active) " + + "VALUES (?, ?, ?, ?, ?)"; + + private static final String UPDATE_BASE = "" + + "UPDATE users " + + "SET display_name = ?, role_code = ?, active = ? "; + @Override public Optional findActiveByUsername(String username) { try (Connection connection = JdbcUtil.getConnection(); @@ -36,6 +64,131 @@ public class JdbcUserDao implements UserDao { } } + @Override + public List search(UserSearchCriteria criteria) { + UserSearchCriteria normalized = criteria == null ? new UserSearchCriteria() : criteria; + List parameters = new ArrayList<>(); + StringBuilder sql = new StringBuilder("SELECT ") + .append(USER_COLUMNS) + .append("FROM users ") + .append("WHERE 1 = 1 "); + + appendKeyword(sql, parameters, normalized.getKeyword()); + if (!normalized.getRoleCode().isEmpty()) { + sql.append("AND role_code = ? "); + parameters.add(normalized.getRoleCode()); + } + Boolean activeValue = normalized.getActiveValue(); + if (activeValue != null) { + sql.append("AND active = ? "); + parameters.add(activeValue); + } + sql.append("ORDER BY username, id"); + + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql.toString())) { + bind(statement, parameters); + try (ResultSet resultSet = statement.executeQuery()) { + List users = new ArrayList<>(); + while (resultSet.next()) { + users.add(mapUser(resultSet)); + } + return users; + } + } catch (SQLException | IllegalArgumentException ex) { + throw new DaoException("Unable to search users", ex); + } + } + + @Override + public Optional findById(long id) { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(FIND_BY_ID)) { + statement.setLong(1, id); + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.next() ? Optional.of(mapUser(resultSet)) : Optional.empty(); + } + } catch (SQLException | IllegalArgumentException ex) { + throw new DaoException("Unable to load user by id", ex); + } + } + + @Override + public Optional findByUsername(String username) { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(FIND_BY_USERNAME)) { + statement.setString(1, username); + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.next() ? Optional.of(mapUser(resultSet)) : Optional.empty(); + } + } catch (SQLException | IllegalArgumentException ex) { + throw new DaoException("Unable to load user by username", ex); + } + } + + @Override + public long create(Connection connection, User user) { + try (PreparedStatement statement = connection.prepareStatement(CREATE, Statement.RETURN_GENERATED_KEYS)) { + statement.setString(1, user.getUsername()); + statement.setString(2, user.getPasswordHash()); + statement.setString(3, user.getDisplayName()); + statement.setString(4, user.getRole().getCode()); + statement.setBoolean(5, user.isActive()); + statement.executeUpdate(); + + try (ResultSet generatedKeys = statement.getGeneratedKeys()) { + if (generatedKeys.next()) { + return generatedKeys.getLong(1); + } + } + throw new DaoException("Unable to read generated user id", null); + } catch (SQLException ex) { + throw new DaoException("Unable to create user", ex); + } + } + + @Override + public boolean update(Connection connection, User user, boolean updatePassword) { + String sql = UPDATE_BASE + + (updatePassword ? ", password_hash = ? " : "") + + "WHERE id = ?"; + + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, user.getDisplayName()); + statement.setString(2, user.getRole().getCode()); + statement.setBoolean(3, user.isActive()); + int index = 4; + if (updatePassword) { + statement.setString(index++, user.getPasswordHash()); + } + statement.setLong(index, user.getId()); + return statement.executeUpdate() == 1; + } catch (SQLException ex) { + throw new DaoException("Unable to update user", ex); + } + } + + private void appendKeyword(StringBuilder sql, List parameters, String value) { + if (value == null || value.trim().isEmpty()) { + return; + } + String filter = "%" + value.trim() + "%"; + sql.append("AND (username LIKE ? OR display_name LIKE ?) "); + parameters.add(filter); + parameters.add(filter); + } + + private void bind(PreparedStatement statement, List parameters) throws SQLException { + for (int i = 0; i < parameters.size(); i++) { + Object value = parameters.get(i); + if (value instanceof Boolean) { + statement.setBoolean(i + 1, (Boolean) value); + } else { + statement.setString(i + 1, value.toString()); + } + } + } + private User mapUser(ResultSet resultSet) throws SQLException { User user = new User(); user.setId(resultSet.getLong("id")); @@ -44,6 +197,12 @@ public class JdbcUserDao implements UserDao { user.setDisplayName(resultSet.getString("display_name")); user.setRole(Role.fromCode(resultSet.getString("role_code"))); user.setActive(resultSet.getBoolean("active")); + user.setCreatedAt(toLocalDateTime(resultSet.getTimestamp("created_at"))); + user.setUpdatedAt(toLocalDateTime(resultSet.getTimestamp("updated_at"))); return user; } + + private LocalDateTime toLocalDateTime(Timestamp timestamp) { + return timestamp == null ? null : timestamp.toLocalDateTime(); + } } diff --git a/src/main/java/com/mzh/library/entity/SystemLog.java b/src/main/java/com/mzh/library/entity/SystemLog.java new file mode 100644 index 0000000..b5095cf --- /dev/null +++ b/src/main/java/com/mzh/library/entity/SystemLog.java @@ -0,0 +1,177 @@ +package com.mzh.library.entity; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +public class SystemLog { + private static final DateTimeFormatter DISPLAY_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + private long id; + private Long operatorId; + private String operatorUsername; + private String operatorDisplayName; + private String operatorRole; + private String operationType; + private String targetTable; + private String targetId; + private String resultStatus; + private String message; + private String requestIp; + private LocalDateTime createdAt; + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public Long getOperatorId() { + return operatorId; + } + + public void setOperatorId(Long operatorId) { + this.operatorId = operatorId; + } + + public String getOperatorUsername() { + return operatorUsername; + } + + public void setOperatorUsername(String operatorUsername) { + this.operatorUsername = operatorUsername; + } + + public String getOperatorDisplayName() { + return operatorDisplayName; + } + + public void setOperatorDisplayName(String operatorDisplayName) { + this.operatorDisplayName = operatorDisplayName; + } + + public String getOperatorRole() { + return operatorRole; + } + + public void setOperatorRole(String operatorRole) { + this.operatorRole = operatorRole; + } + + public String getOperationType() { + return operationType; + } + + public void setOperationType(String operationType) { + this.operationType = operationType; + } + + public String getTargetTable() { + return targetTable; + } + + public void setTargetTable(String targetTable) { + this.targetTable = targetTable; + } + + public String getTargetId() { + return targetId; + } + + public void setTargetId(String targetId) { + this.targetId = targetId; + } + + public String getResultStatus() { + return resultStatus; + } + + public void setResultStatus(String resultStatus) { + this.resultStatus = resultStatus; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getRequestIp() { + return requestIp; + } + + public void setRequestIp(String requestIp) { + this.requestIp = requestIp; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public String getCreatedAtText() { + return createdAt == null ? "" : DISPLAY_FORMAT.format(createdAt); + } + + public String getOperatorLabel() { + String displayName = trim(operatorDisplayName); + if (!displayName.isEmpty()) { + return displayName; + } + + String username = trim(operatorUsername); + if (!username.isEmpty()) { + return username; + } + + return operatorId == null ? "System" : "User #" + operatorId; + } + + public String getOperatorMetaText() { + StringBuilder meta = new StringBuilder(); + String displayName = trim(operatorDisplayName); + String username = trim(operatorUsername); + if (!username.isEmpty() && !username.equals(displayName)) { + appendMeta(meta, username); + } + if (operatorId != null && (!displayName.isEmpty() || !username.isEmpty())) { + appendMeta(meta, "#" + operatorId); + } + appendMeta(meta, trim(operatorRole)); + return meta.toString(); + } + + public String getResultStatusCode() { + String normalized = trim(resultStatus).toLowerCase(Locale.ROOT); + if ("success".equals(normalized) || "failure".equals(normalized)) { + return normalized; + } + return "unknown"; + } + + public String getResultStatusName() { + String trimmed = trim(resultStatus); + return trimmed.isEmpty() ? "Unknown" : trimmed; + } + + private void appendMeta(StringBuilder meta, String value) { + if (value.isEmpty()) { + return; + } + if (meta.length() > 0) { + meta.append(" "); + } + meta.append(value); + } + + private String trim(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/src/main/java/com/mzh/library/entity/SystemLogPage.java b/src/main/java/com/mzh/library/entity/SystemLogPage.java new file mode 100644 index 0000000..7734c24 --- /dev/null +++ b/src/main/java/com/mzh/library/entity/SystemLogPage.java @@ -0,0 +1,26 @@ +package com.mzh.library.entity; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class SystemLogPage { + private List logs = Collections.emptyList(); + private List operationTypes = Collections.emptyList(); + + public List getLogs() { + return logs; + } + + public void setLogs(List logs) { + this.logs = logs == null ? Collections.emptyList() : new ArrayList<>(logs); + } + + public List getOperationTypes() { + return operationTypes; + } + + public void setOperationTypes(List operationTypes) { + this.operationTypes = operationTypes == null ? Collections.emptyList() : new ArrayList<>(operationTypes); + } +} diff --git a/src/main/java/com/mzh/library/entity/SystemLogSearchCriteria.java b/src/main/java/com/mzh/library/entity/SystemLogSearchCriteria.java new file mode 100644 index 0000000..07af570 --- /dev/null +++ b/src/main/java/com/mzh/library/entity/SystemLogSearchCriteria.java @@ -0,0 +1,76 @@ +package com.mzh.library.entity; + +import java.time.LocalDate; + +public class SystemLogSearchCriteria { + private String operationType; + private String keyword; + private String createdFromText; + private String createdToText; + private LocalDate createdFrom; + private LocalDate createdTo; + + public SystemLogSearchCriteria() { + this(null, null, null, null); + } + + public SystemLogSearchCriteria(String operationType, String keyword, String createdFromText, + String createdToText) { + this.operationType = trim(operationType); + this.keyword = trim(keyword); + this.createdFromText = trim(createdFromText); + this.createdToText = trim(createdToText); + } + + public String getOperationType() { + return operationType; + } + + public void setOperationType(String operationType) { + this.operationType = trim(operationType); + } + + public String getKeyword() { + return keyword; + } + + public void setKeyword(String keyword) { + this.keyword = trim(keyword); + } + + public String getCreatedFromText() { + return createdFromText; + } + + public void setCreatedFromText(String createdFromText) { + this.createdFromText = trim(createdFromText); + } + + public String getCreatedToText() { + return createdToText; + } + + public void setCreatedToText(String createdToText) { + this.createdToText = trim(createdToText); + } + + public LocalDate getCreatedFrom() { + return createdFrom; + } + + public void setCreatedFrom(LocalDate createdFrom) { + this.createdFrom = createdFrom; + } + + public LocalDate getCreatedTo() { + return createdTo; + } + + public void setCreatedTo(LocalDate createdTo) { + this.createdTo = createdTo; + } + + private String trim(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/src/main/java/com/mzh/library/entity/User.java b/src/main/java/com/mzh/library/entity/User.java index 6bf2037..6d9ef2c 100644 --- a/src/main/java/com/mzh/library/entity/User.java +++ b/src/main/java/com/mzh/library/entity/User.java @@ -1,12 +1,19 @@ package com.mzh.library.entity; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + public class User { + private static final DateTimeFormatter DISPLAY_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + private long id; private String username; private String passwordHash; private String displayName; private Role role; private boolean active; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; public long getId() { return id; @@ -55,4 +62,40 @@ public class User { public void setActive(boolean active) { this.active = active; } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public String getActiveStatusCode() { + return active ? "active" : "inactive"; + } + + public String getActiveStatusName() { + return active ? "Active" : "Inactive"; + } + + public String getCreatedAtText() { + return format(createdAt); + } + + public String getUpdatedAtText() { + return format(updatedAt); + } + + private String format(LocalDateTime value) { + return value == null ? "" : DISPLAY_FORMAT.format(value); + } } diff --git a/src/main/java/com/mzh/library/entity/UserSearchCriteria.java b/src/main/java/com/mzh/library/entity/UserSearchCriteria.java new file mode 100644 index 0000000..f582958 --- /dev/null +++ b/src/main/java/com/mzh/library/entity/UserSearchCriteria.java @@ -0,0 +1,58 @@ +package com.mzh.library.entity; + +public class UserSearchCriteria { + public static final String ACTIVE_STATUS = "active"; + public static final String INACTIVE_STATUS = "inactive"; + + private String keyword; + private String roleCode; + private String activeStatus; + + public UserSearchCriteria() { + this(null, null, null); + } + + public UserSearchCriteria(String keyword, String roleCode, String activeStatus) { + this.keyword = trim(keyword); + this.roleCode = trim(roleCode); + this.activeStatus = trim(activeStatus); + } + + public String getKeyword() { + return keyword; + } + + public void setKeyword(String keyword) { + this.keyword = trim(keyword); + } + + public String getRoleCode() { + return roleCode; + } + + public void setRoleCode(String roleCode) { + this.roleCode = trim(roleCode); + } + + public String getActiveStatus() { + return activeStatus; + } + + public void setActiveStatus(String activeStatus) { + this.activeStatus = trim(activeStatus); + } + + public Boolean getActiveValue() { + if (ACTIVE_STATUS.equals(activeStatus)) { + return Boolean.TRUE; + } + if (INACTIVE_STATUS.equals(activeStatus)) { + return Boolean.FALSE; + } + return null; + } + + private String trim(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/src/main/java/com/mzh/library/filter/AuthorizationFilter.java b/src/main/java/com/mzh/library/filter/AuthorizationFilter.java index 4fbd1d0..4c2418b 100644 --- a/src/main/java/com/mzh/library/filter/AuthorizationFilter.java +++ b/src/main/java/com/mzh/library/filter/AuthorizationFilter.java @@ -26,6 +26,7 @@ public class AuthorizationFilter implements Filter { private static final Logger LOGGER = Logger.getLogger(AuthorizationFilter.class.getName()); private static final String UNAUTHORIZED_JSP = "/WEB-INF/jsp/auth/unauthorized.jsp"; private static final List RULES = Arrays.asList( + new PathRule("/admin/system-logs", Permission.VIEW_SYSTEM_LOGS), new PathRule("/reports", Permission.VIEW_REPORTS), new PathRule("/borrowing", Permission.MANAGE_BORROWING), new PathRule("/books", Permission.MANAGE_BOOKS), diff --git a/src/main/java/com/mzh/library/service/SystemLogService.java b/src/main/java/com/mzh/library/service/SystemLogService.java new file mode 100644 index 0000000..a1c00ab --- /dev/null +++ b/src/main/java/com/mzh/library/service/SystemLogService.java @@ -0,0 +1,9 @@ +package com.mzh.library.service; + +import com.mzh.library.entity.AuthenticatedUser; +import com.mzh.library.entity.SystemLogPage; +import com.mzh.library.entity.SystemLogSearchCriteria; + +public interface SystemLogService { + ServiceResult searchLogs(AuthenticatedUser actor, SystemLogSearchCriteria criteria); +} diff --git a/src/main/java/com/mzh/library/service/UserAccountService.java b/src/main/java/com/mzh/library/service/UserAccountService.java new file mode 100644 index 0000000..4a4bef3 --- /dev/null +++ b/src/main/java/com/mzh/library/service/UserAccountService.java @@ -0,0 +1,20 @@ +package com.mzh.library.service; + +import com.mzh.library.entity.AuthenticatedUser; +import com.mzh.library.entity.User; +import com.mzh.library.entity.UserSearchCriteria; + +import java.util.List; +import java.util.Optional; + +public interface UserAccountService { + ServiceResult> searchUsers(AuthenticatedUser actor, UserSearchCriteria criteria); + + ServiceResult> findUser(AuthenticatedUser actor, long id); + + ServiceResult createUser(AuthenticatedUser actor, User user, String password, String requestIp); + + ServiceResult updateUser(AuthenticatedUser actor, User user, String password, String requestIp); + + ServiceResult deactivateUser(AuthenticatedUser actor, long id, String requestIp); +} diff --git a/src/main/java/com/mzh/library/service/impl/SystemLogServiceImpl.java b/src/main/java/com/mzh/library/service/impl/SystemLogServiceImpl.java new file mode 100644 index 0000000..3b7f926 --- /dev/null +++ b/src/main/java/com/mzh/library/service/impl/SystemLogServiceImpl.java @@ -0,0 +1,101 @@ +package com.mzh.library.service.impl; + +import com.mzh.library.dao.SystemLogDao; +import com.mzh.library.entity.AuthenticatedUser; +import com.mzh.library.entity.Permission; +import com.mzh.library.entity.SystemLogPage; +import com.mzh.library.entity.SystemLogSearchCriteria; +import com.mzh.library.exception.DaoException; +import com.mzh.library.service.PermissionPolicy; +import com.mzh.library.service.ServiceResult; +import com.mzh.library.service.SystemLogService; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class SystemLogServiceImpl implements SystemLogService { + private static final Logger LOGGER = Logger.getLogger(SystemLogServiceImpl.class.getName()); + private static final String UNAVAILABLE_MESSAGE = + "System log service is temporarily unavailable. Please try again later."; + private static final String DENIED_MESSAGE = "You do not have permission to view system logs."; + private static final String VALIDATION_MESSAGE = "Please correct the system log search filters."; + + private final SystemLogDao systemLogDao; + private final PermissionPolicy permissionPolicy; + + public SystemLogServiceImpl(SystemLogDao systemLogDao) { + this(systemLogDao, new PermissionPolicy()); + } + + public SystemLogServiceImpl(SystemLogDao systemLogDao, PermissionPolicy permissionPolicy) { + this.systemLogDao = systemLogDao; + this.permissionPolicy = permissionPolicy; + } + + @Override + public ServiceResult searchLogs(AuthenticatedUser actor, SystemLogSearchCriteria criteria) { + if (!canViewSystemLogs(actor)) { + return ServiceResult.failure(DENIED_MESSAGE); + } + + SystemLogSearchCriteria normalized = criteria == null ? new SystemLogSearchCriteria() : criteria; + Map errors = validate(normalized); + if (!errors.isEmpty()) { + return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors); + } + + try { + SystemLogPage page = new SystemLogPage(); + page.setLogs(systemLogDao.search(normalized)); + page.setOperationTypes(systemLogDao.findOperationTypes()); + return ServiceResult.success(page); + } catch (DaoException ex) { + LOGGER.log(Level.SEVERE, "Unable to load system logs actorId=" + actor.getId(), ex); + return ServiceResult.failure(UNAVAILABLE_MESSAGE); + } + } + + private Map validate(SystemLogSearchCriteria criteria) { + Map errors = new LinkedHashMap<>(); + if (criteria.getOperationType().length() > 64) { + errors.put("operationType", "Operation type must be 64 characters or fewer."); + } + if (criteria.getKeyword().length() > 120) { + errors.put("keyword", "Keyword must be 120 characters or fewer."); + } + + parseDate(criteria.getCreatedFromText(), "createdFrom", "Start date", errors, criteria, true); + parseDate(criteria.getCreatedToText(), "createdTo", "End date", errors, criteria, false); + if (criteria.getCreatedFrom() != null + && criteria.getCreatedTo() != null + && criteria.getCreatedFrom().isAfter(criteria.getCreatedTo())) { + errors.put("createdTo", "End date must be on or after start date."); + } + return errors; + } + + private void parseDate(String value, String field, String label, Map errors, + SystemLogSearchCriteria criteria, boolean fromDate) { + if (value == null || value.isEmpty()) { + return; + } + try { + LocalDate parsed = LocalDate.parse(value); + if (fromDate) { + criteria.setCreatedFrom(parsed); + } else { + criteria.setCreatedTo(parsed); + } + } catch (DateTimeParseException ex) { + errors.put(field, label + " must use YYYY-MM-DD."); + } + } + + private boolean canViewSystemLogs(AuthenticatedUser actor) { + return actor != null && permissionPolicy.allows(actor.getRole(), Permission.VIEW_SYSTEM_LOGS); + } +} diff --git a/src/main/java/com/mzh/library/service/impl/UserAccountServiceImpl.java b/src/main/java/com/mzh/library/service/impl/UserAccountServiceImpl.java new file mode 100644 index 0000000..c1150b1 --- /dev/null +++ b/src/main/java/com/mzh/library/service/impl/UserAccountServiceImpl.java @@ -0,0 +1,345 @@ +package com.mzh.library.service.impl; + +import com.mzh.library.dao.SystemLogDao; +import com.mzh.library.dao.UserAccountDao; +import com.mzh.library.entity.AuthenticatedUser; +import com.mzh.library.entity.Permission; +import com.mzh.library.entity.Role; +import com.mzh.library.entity.SystemLog; +import com.mzh.library.entity.User; +import com.mzh.library.entity.UserSearchCriteria; +import com.mzh.library.exception.DaoException; +import com.mzh.library.service.PermissionPolicy; +import com.mzh.library.service.ServiceResult; +import com.mzh.library.service.UserAccountService; +import com.mzh.library.util.JdbcUtil; +import com.mzh.library.util.PasswordHasher; + +import java.sql.SQLException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class UserAccountServiceImpl implements UserAccountService { + public interface TransactionExecutor { + T execute(JdbcUtil.TransactionCallback callback); + } + + private static final Logger LOGGER = Logger.getLogger(UserAccountServiceImpl.class.getName()); + private static final String UNAVAILABLE_MESSAGE = + "User management service is temporarily unavailable. Please try again later."; + private static final String VALIDATION_MESSAGE = "Please correct the highlighted account fields."; + private static final String SEARCH_VALIDATION_MESSAGE = "Please correct the account search filters."; + private static final String DENIED_MESSAGE = "You do not have permission to manage users."; + private static final String SELF_DEACTIVATE_MESSAGE = "You cannot deactivate your own administrator account."; + private static final String SELF_ROLE_MESSAGE = "You cannot change your own administrator role."; + + private final UserAccountDao userAccountDao; + private final SystemLogDao systemLogDao; + private final PermissionPolicy permissionPolicy; + private final TransactionExecutor transactionExecutor; + + public UserAccountServiceImpl(UserAccountDao userAccountDao, SystemLogDao systemLogDao) { + this(userAccountDao, systemLogDao, new PermissionPolicy(), new JdbcTransactionExecutor()); + } + + public UserAccountServiceImpl(UserAccountDao userAccountDao, SystemLogDao systemLogDao, + PermissionPolicy permissionPolicy, TransactionExecutor transactionExecutor) { + this.userAccountDao = userAccountDao; + this.systemLogDao = systemLogDao; + this.permissionPolicy = permissionPolicy; + this.transactionExecutor = transactionExecutor; + } + + @Override + public ServiceResult> searchUsers(AuthenticatedUser actor, UserSearchCriteria criteria) { + if (!canManageUsers(actor)) { + return ServiceResult.failure(DENIED_MESSAGE); + } + + UserSearchCriteria normalized = criteria == null ? new UserSearchCriteria() : criteria; + Map errors = validateSearch(normalized); + if (!errors.isEmpty()) { + return ServiceResult.validationFailure(SEARCH_VALIDATION_MESSAGE, errors); + } + + try { + return ServiceResult.success(userAccountDao.search(normalized)); + } catch (DaoException ex) { + LOGGER.log(Level.SEVERE, "Unable to search users actorId=" + actor.getId(), ex); + return ServiceResult.failure(UNAVAILABLE_MESSAGE); + } + } + + @Override + public ServiceResult> findUser(AuthenticatedUser actor, long id) { + if (!canManageUsers(actor)) { + return ServiceResult.failure(DENIED_MESSAGE); + } + if (id <= 0) { + return ServiceResult.failure("Select a valid user account."); + } + + try { + return ServiceResult.success(userAccountDao.findById(id)); + } catch (DaoException ex) { + LOGGER.log(Level.SEVERE, "Unable to load user id=" + id + " actorId=" + actor.getId(), ex); + return ServiceResult.failure(UNAVAILABLE_MESSAGE); + } + } + + @Override + public ServiceResult createUser(AuthenticatedUser actor, User user, String password, String requestIp) { + if (!canManageUsers(actor)) { + return ServiceResult.failure(DENIED_MESSAGE); + } + + normalize(user); + Map errors = validateUser(user, false, password, true); + if (!errors.isEmpty()) { + return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors); + } + + try { + if (userAccountDao.findByUsername(user.getUsername()).isPresent()) { + errors.put("username", "Username is already in use."); + return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors); + } + + user.setPasswordHash(PasswordHasher.hash(password)); + return transactionExecutor.execute(connection -> { + long id = userAccountDao.create(connection, user); + systemLogDao.create(connection, auditLog(actor, "user.create", id, + "Created account username=" + user.getUsername() + " role=" + user.getRole().getCode(), + requestIp)); + LOGGER.info("Created user id=" + id + " actorId=" + actor.getId()); + return ServiceResult.success(id, "User account created."); + }); + } catch (DaoException | IllegalStateException ex) { + LOGGER.log(Level.SEVERE, "Unable to create user actorId=" + actor.getId() + + " username=" + safeUsername(user), ex); + return ServiceResult.failure(UNAVAILABLE_MESSAGE); + } + } + + @Override + public ServiceResult updateUser(AuthenticatedUser actor, User user, String password, String requestIp) { + if (!canManageUsers(actor)) { + return ServiceResult.failure(DENIED_MESSAGE); + } + + normalize(user); + Map errors = validateUser(user, true, password, false); + if (!errors.isEmpty()) { + return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors); + } + + try { + Optional existingResult = userAccountDao.findById(user.getId()); + if (!existingResult.isPresent()) { + return ServiceResult.failure("User account was not found."); + } + + protectCurrentAdministrator(actor, user, errors); + if (!errors.isEmpty()) { + return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors); + } + + User existing = existingResult.get(); + user.setUsername(existing.getUsername()); + boolean updatePassword = password != null && !password.trim().isEmpty(); + if (updatePassword) { + user.setPasswordHash(PasswordHasher.hash(password)); + } + + final boolean passwordChanged = updatePassword; + return transactionExecutor.execute(connection -> { + if (!userAccountDao.update(connection, user, passwordChanged)) { + return ServiceResult.failure("User account was not found."); + } + systemLogDao.create(connection, auditLog(actor, "user.update", user.getId(), + "Updated account username=" + user.getUsername() + " role=" + user.getRole().getCode() + + " active=" + user.isActive() + + (passwordChanged ? " passwordReset=true" : ""), + requestIp)); + LOGGER.info("Updated user id=" + user.getId() + " actorId=" + actor.getId()); + return ServiceResult.success(null, "User account updated."); + }); + } catch (DaoException | IllegalStateException ex) { + LOGGER.log(Level.SEVERE, "Unable to update user id=" + user.getId() + " actorId=" + actor.getId(), ex); + return ServiceResult.failure(UNAVAILABLE_MESSAGE); + } + } + + @Override + public ServiceResult deactivateUser(AuthenticatedUser actor, long id, String requestIp) { + if (!canManageUsers(actor)) { + return ServiceResult.failure(DENIED_MESSAGE); + } + if (id <= 0) { + return ServiceResult.failure("Select a valid user account."); + } + if (actor.getId() == id) { + Map errors = new LinkedHashMap<>(); + errors.put("active", SELF_DEACTIVATE_MESSAGE); + return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors); + } + + try { + Optional existingResult = userAccountDao.findById(id); + if (!existingResult.isPresent()) { + return ServiceResult.failure("User account was not found."); + } + + User user = existingResult.get(); + user.setActive(false); + return transactionExecutor.execute(connection -> { + if (!userAccountDao.update(connection, user, false)) { + return ServiceResult.failure("User account was not found."); + } + systemLogDao.create(connection, auditLog(actor, "user.deactivate", id, + "Deactivated account username=" + user.getUsername(), + requestIp)); + LOGGER.info("Deactivated user id=" + id + " actorId=" + actor.getId()); + return ServiceResult.success(null, "User account deactivated."); + }); + } catch (DaoException ex) { + LOGGER.log(Level.SEVERE, "Unable to deactivate user id=" + id + " actorId=" + actor.getId(), ex); + return ServiceResult.failure(UNAVAILABLE_MESSAGE); + } + } + + private Map validateSearch(UserSearchCriteria criteria) { + Map errors = new LinkedHashMap<>(); + if (!criteria.getRoleCode().isEmpty()) { + try { + criteria.setRoleCode(Role.fromCode(criteria.getRoleCode()).getCode()); + } catch (IllegalArgumentException ex) { + errors.put("role", "Select a valid role."); + } + } + + String activeStatus = criteria.getActiveStatus(); + if (!activeStatus.isEmpty() + && !UserSearchCriteria.ACTIVE_STATUS.equals(activeStatus) + && !UserSearchCriteria.INACTIVE_STATUS.equals(activeStatus)) { + errors.put("active", "Select a valid active state."); + } + return errors; + } + + private Map validateUser(User user, boolean requireId, String password, boolean requirePassword) { + Map errors = new LinkedHashMap<>(); + if (user == null) { + errors.put("user", "User account details are required."); + return errors; + } + + if (requireId && user.getId() <= 0) { + errors.put("id", "Select a valid user account."); + } + if (!requireId) { + requireLength(errors, "username", user.getUsername(), "Username", 64); + } + requireLength(errors, "displayName", user.getDisplayName(), "Display name", 100); + if (user.getRole() == null) { + errors.put("role", "Select a role."); + } + validatePassword(errors, password, requirePassword); + return errors; + } + + private void validatePassword(Map errors, String password, boolean required) { + String trimmed = password == null ? "" : password.trim(); + if (trimmed.isEmpty()) { + if (required) { + errors.put("password", "Password is required."); + } + return; + } + if (password.length() > 128) { + errors.put("password", "Password must be 128 characters or fewer."); + } + } + + private void protectCurrentAdministrator(AuthenticatedUser actor, User user, Map errors) { + if (actor.getId() != user.getId()) { + return; + } + if (!user.isActive()) { + errors.put("active", SELF_DEACTIVATE_MESSAGE); + } + if (user.getRole() != Role.ADMINISTRATOR) { + errors.put("role", SELF_ROLE_MESSAGE); + } + } + + private void requireLength(Map errors, String field, String value, String label, int maxLength) { + if (value == null || value.isEmpty()) { + errors.put(field, label + " is required."); + return; + } + if (value.length() > maxLength) { + errors.put(field, label + " must be " + maxLength + " characters or fewer."); + } + } + + private SystemLog auditLog(AuthenticatedUser actor, String operationType, long userId, String message, + String requestIp) { + SystemLog log = new SystemLog(); + log.setOperatorId(actor.getId()); + log.setOperatorRole(actor.getRole().getCode()); + log.setOperationType(operationType); + log.setTargetTable("users"); + log.setTargetId(String.valueOf(userId)); + log.setResultStatus("success"); + log.setMessage(message); + log.setRequestIp(trim(requestIp)); + return log; + } + + private boolean canManageUsers(AuthenticatedUser actor) { + return actor != null && permissionPolicy.allows(actor.getRole(), Permission.MANAGE_USERS); + } + + private void normalize(User user) { + if (user == null) { + return; + } + user.setUsername(normalizeUsername(user.getUsername())); + user.setDisplayName(trim(user.getDisplayName())); + } + + private String normalizeUsername(String username) { + return trim(username); + } + + private String safeUsername(User user) { + return user == null ? "" : user.getUsername(); + } + + private String trim(String value) { + return value == null ? "" : value.trim(); + } + + private static final class JdbcTransactionExecutor implements TransactionExecutor { + @Override + public T execute(JdbcUtil.TransactionCallback callback) { + return JdbcUtil.executeInTransaction(callback); + } + } + + public static final class DirectTransactionExecutor implements TransactionExecutor { + @Override + public T execute(JdbcUtil.TransactionCallback callback) { + try { + return callback.execute(null); + } catch (SQLException ex) { + throw new DaoException("Unable to execute direct transaction", ex); + } + } + } +} diff --git a/src/main/webapp/WEB-INF/jsp/admin/users/form.jsp b/src/main/webapp/WEB-INF/jsp/admin/users/form.jsp new file mode 100644 index 0000000..61be306 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/admin/users/form.jsp @@ -0,0 +1,121 @@ +<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> +<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %> + + + + + + <c:out value="${formTitle}" /> - MZH Library + + + +<%@ include file="/WEB-INF/jsp/common/header.jspf" %> +
+
+

Administration

+

+ + + + + + + + + + + +
+ + + + + +
+
+ + + + + + + + + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + + + + + + + + +
+
+ +
+ + Cancel +
+
+
+
+ + diff --git a/src/main/webapp/WEB-INF/jsp/admin/users/manage.jsp b/src/main/webapp/WEB-INF/jsp/admin/users/manage.jsp new file mode 100644 index 0000000..bf2a62d --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/admin/users/manage.jsp @@ -0,0 +1,139 @@ +<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> +<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %> + + + + + + Manage Users - MZH Library + + + +<%@ include file="/WEB-INF/jsp/common/header.jspf" %> +
+
+
+

Administration

+

Manage users

+

Create, update, deactivate, and review administrator, librarian, and reader accounts.

+
+ New user +
+ + +
+ +
+
+ + + + +
+
+
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + Clear +
+
+ +
+

User accounts

+ + +

No user accounts match the current filters.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
UsernameDisplay nameRoleStateCreatedUpdatedActions
+ + + + +
+ Edit + + + + + +
+ + +
+
+
+
+
+
+
+
+
+
+ + diff --git a/src/main/webapp/WEB-INF/jsp/common/header.jspf b/src/main/webapp/WEB-INF/jsp/common/header.jspf index b05b002..15c6bf5 100644 --- a/src/main/webapp/WEB-INF/jsp/common/header.jspf +++ b/src/main/webapp/WEB-INF/jsp/common/header.jspf @@ -7,6 +7,8 @@ Catalog Admin + Users + Logs Librarian diff --git a/src/main/webapp/WEB-INF/jsp/dashboard.jsp b/src/main/webapp/WEB-INF/jsp/dashboard.jsp index 739315f..4e14118 100644 --- a/src/main/webapp/WEB-INF/jsp/dashboard.jsp +++ b/src/main/webapp/WEB-INF/jsp/dashboard.jsp @@ -26,6 +26,18 @@

Account, role, permission, and system-maintenance entry point.

Open + +
+

User Management

+

Create, update, deactivate, and review login accounts.

+ Open +
+ +
+

System Logs

+

Review read-only audit entries for account and maintenance actions.

+ Open +
diff --git a/src/main/webapp/WEB-INF/jsp/maintenance/system-logs.jsp b/src/main/webapp/WEB-INF/jsp/maintenance/system-logs.jsp new file mode 100644 index 0000000..078792d --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/maintenance/system-logs.jsp @@ -0,0 +1,138 @@ +<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> +<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %> + + + + + + System Logs - MZH Library + + + +<%@ include file="/WEB-INF/jsp/common/header.jspf" %> +
+
+
+

System Maintenance

+

System logs

+

Review administrative account changes and maintenance audit records.

+
+
+ + + + + +
+
+
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + Clear +
+
+ +
+

Log entries

+ + +

No system logs match the current filters.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
TimeOperatorOperationTargetResultIP addressDetail
+
+ +
+
+
+ + + # + + + + + +
+
+
+
+
+
+ + diff --git a/src/main/webapp/WEB-INF/jsp/role-home.jsp b/src/main/webapp/WEB-INF/jsp/role-home.jsp index c29c9f7..6192eba 100644 --- a/src/main/webapp/WEB-INF/jsp/role-home.jsp +++ b/src/main/webapp/WEB-INF/jsp/role-home.jsp @@ -28,6 +28,20 @@ + +
+

User Management

+

Create, update, deactivate, and review login accounts.

+ Manage users +
+ +
+

System Logs

+

Review read-only audit entries for account and maintenance actions.

+ View logs +
+
+

Book Management

Create, update, delete, and review inventory fields for book records.

diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index 0662853..8117e00 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -75,6 +75,28 @@ /reader/home + + UserManagementServlet + com.mzh.library.controller.UserManagementServlet + + + UserManagementServlet + /admin/users + /admin/users/new + /admin/users/edit + /admin/users/update + /admin/users/deactivate + + + + SystemLogServlet + com.mzh.library.controller.SystemLogServlet + + + SystemLogServlet + /admin/system-logs + + BookCatalogServlet com.mzh.library.controller.BookCatalogServlet diff --git a/src/main/webapp/static/css/app.css b/src/main/webapp/static/css/app.css index d8ef89c..f2ec2aa 100644 --- a/src/main/webapp/static/css/app.css +++ b/src/main/webapp/static/css/app.css @@ -198,6 +198,11 @@ h2 { background: #8f3028; } +.button:disabled { + cursor: not-allowed; + opacity: 0.58; +} + .message { margin-bottom: 16px; padding: 10px 12px; @@ -326,6 +331,10 @@ h2 { grid-template-columns: repeat(3, minmax(120px, 1fr)) auto auto; } +.system-log-search-form { + grid-template-columns: repeat(4, minmax(120px, 1fr)) auto auto; +} + .search-field { display: grid; gap: 6px; @@ -343,6 +352,8 @@ h2 { .book-form select, .reader-form input, .reader-form select, +.user-form input, +.user-form select, .borrow-form input { width: 100%; min-height: 42px; @@ -359,6 +370,8 @@ h2 { .book-form select:focus, .reader-form input:focus, .reader-form select:focus, +.user-form input:focus, +.user-form select:focus, .borrow-form input:focus { outline: 3px solid rgba(37, 111, 108, 0.18); border-color: var(--color-primary); @@ -380,6 +393,11 @@ h2 { min-width: 980px; } +.user-table, +.system-log-table { + min-width: 980px; +} + .data-table th, .data-table td { padding: 12px 10px; @@ -445,6 +463,21 @@ h2 { background: #eef1f5; } +.status-success { + color: var(--color-success); + background: #edf8ef; +} + +.status-failure { + color: #7a211a; + background: #fff0ee; +} + +.status-unknown { + color: var(--color-muted); + background: #eef1f5; +} + .status-overdue { color: #7a211a; background: #fff0ee; @@ -472,6 +505,7 @@ h2 { .book-form, .reader-form, +.user-form, .borrow-form { display: grid; gap: 20px; diff --git a/src/test/java/com/mzh/library/service/PermissionPolicyCheck.java b/src/test/java/com/mzh/library/service/PermissionPolicyCheck.java index e9fc97c..a5d6222 100644 --- a/src/test/java/com/mzh/library/service/PermissionPolicyCheck.java +++ b/src/test/java/com/mzh/library/service/PermissionPolicyCheck.java @@ -12,14 +12,17 @@ public final class PermissionPolicyCheck { require(policy.allows(Role.ADMINISTRATOR, Permission.MANAGE_USERS), "administrator should manage users"); require(policy.allows(Role.ADMINISTRATOR, Permission.VIEW_REPORTS), "administrator should view reports"); + require(policy.allows(Role.ADMINISTRATOR, Permission.VIEW_SYSTEM_LOGS), "administrator should view system logs"); require(policy.allows(Role.LIBRARIAN, Permission.MANAGE_BORROWING), "librarian should manage borrowing"); require(policy.allows(Role.LIBRARIAN, Permission.MANAGE_READERS), "librarian should manage readers"); require(policy.allows(Role.LIBRARIAN, Permission.VIEW_REPORTS), "librarian should view reports"); + require(!policy.allows(Role.LIBRARIAN, Permission.VIEW_SYSTEM_LOGS), "librarian should not view system logs"); require(!policy.allows(Role.LIBRARIAN, Permission.BORROW_BOOKS), "librarian should not borrow as a reader"); require(!policy.allows(Role.LIBRARIAN, Permission.MANAGE_USERS), "librarian should not manage users"); require(policy.allows(Role.READER, Permission.VIEW_CATALOG), "reader should view catalog"); require(policy.allows(Role.READER, Permission.BORROW_BOOKS), "reader should view borrowing capabilities"); require(!policy.allows(Role.READER, Permission.VIEW_REPORTS), "reader should not view reports"); + require(!policy.allows(Role.READER, Permission.VIEW_SYSTEM_LOGS), "reader should not view system logs"); require(!policy.allows(Role.READER, Permission.MANAGE_BORROWING), "reader should not manage borrowing"); require(!policy.allows(Role.READER, Permission.MANAGE_BOOKS), "reader should not manage books"); require(!policy.allows(Role.READER, Permission.MANAGE_READERS), "reader should not manage readers"); diff --git a/src/test/java/com/mzh/library/service/SystemLogServiceCheck.java b/src/test/java/com/mzh/library/service/SystemLogServiceCheck.java new file mode 100644 index 0000000..200ef28 --- /dev/null +++ b/src/test/java/com/mzh/library/service/SystemLogServiceCheck.java @@ -0,0 +1,149 @@ +package com.mzh.library.service; + +import com.mzh.library.dao.SystemLogDao; +import com.mzh.library.entity.AuthenticatedUser; +import com.mzh.library.entity.Permission; +import com.mzh.library.entity.Role; +import com.mzh.library.entity.SystemLog; +import com.mzh.library.entity.SystemLogPage; +import com.mzh.library.entity.SystemLogSearchCriteria; +import com.mzh.library.exception.DaoException; +import com.mzh.library.service.impl.SystemLogServiceImpl; + +import java.sql.Connection; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +public final class SystemLogServiceCheck { + private static final String DENIED_MESSAGE = "You do not have permission to view system logs."; + private static final String UNAVAILABLE_MESSAGE = + "System log service is temporarily unavailable. Please try again later."; + + private SystemLogServiceCheck() { + } + + public static void main(String[] args) { + Logger.getLogger(SystemLogServiceImpl.class.getName()).setLevel(Level.OFF); + + InMemorySystemLogDao dao = new InMemorySystemLogDao(); + dao.logs.add(log(1L, "user.create", "Created account username=staff")); + dao.logs.add(log(2L, "user.update", "Updated account username=staff")); + SystemLogService service = new SystemLogServiceImpl(dao); + + AuthenticatedUser admin = actor(1L, Role.ADMINISTRATOR); + AuthenticatedUser reader = actor(2L, Role.READER); + + requireMessage(service.searchLogs(reader, new SystemLogSearchCriteria()), DENIED_MESSAGE); + + ServiceResult pageResult = service.searchLogs(admin, new SystemLogSearchCriteria()); + require(pageResult.isSuccessful(), "administrator should view system logs"); + require(pageResult.getData().getLogs().size() == 2, "system log page should include log entries"); + require(pageResult.getData().getOperationTypes().contains("user.create"), + "system log page should include operation type filters"); + + ServiceResult invalidDate = service.searchLogs(admin, + new SystemLogSearchCriteria("", "", "bad-date", "")); + require(!invalidDate.isSuccessful(), "invalid date should fail validation"); + require(invalidDate.getErrors().containsKey("createdFrom"), "invalid start date should target createdFrom"); + + ServiceResult invalidRange = service.searchLogs(admin, + new SystemLogSearchCriteria("", "", "2026-04-30", "2026-04-01")); + require(!invalidRange.isSuccessful(), "inverted date range should fail validation"); + require(invalidRange.getErrors().containsKey("createdTo"), "inverted range should target createdTo"); + + SystemLog orphanedOperator = log(99L, "user.update", "Updated orphaned operator account"); + orphanedOperator.setOperatorUsername(""); + orphanedOperator.setOperatorDisplayName(""); + require("User #1".equals(orphanedOperator.getOperatorLabel()), + "operator id should still render when joined user names are unavailable"); + require("administrator".equals(orphanedOperator.getOperatorMetaText()), + "operator meta should preserve role when names are unavailable"); + + SystemLog unsafeStatus = log(100L, "user.update", "Unsafe status check"); + unsafeStatus.setResultStatus("success\" onclick=\"x"); + require("unknown".equals(unsafeStatus.getResultStatusCode()), + "unexpected log result status should not become a raw CSS class"); + require("success\" onclick=\"x".equals(unsafeStatus.getResultStatusName()), + "unexpected log result status should remain escaped display text"); + + SystemLogService failingService = new SystemLogServiceImpl(new FailingSystemLogDao()); + requireMessage(failingService.searchLogs(admin, new SystemLogSearchCriteria()), UNAVAILABLE_MESSAGE); + } + + private static SystemLog log(long id, String operationType, String message) { + SystemLog log = new SystemLog(); + log.setId(id); + log.setOperatorId(1L); + log.setOperatorRole(Role.ADMINISTRATOR.getCode()); + log.setOperatorUsername("admin"); + log.setOperatorDisplayName("System Administrator"); + log.setOperationType(operationType); + log.setTargetTable("users"); + log.setTargetId(String.valueOf(id)); + log.setResultStatus("success"); + log.setMessage(message); + log.setRequestIp("127.0.0.1"); + log.setCreatedAt(LocalDateTime.of(2026, 4, 27, 12, 0)); + return log; + } + + private static AuthenticatedUser actor(long id, Role role) { + return new AuthenticatedUser(id, role.getCode(), role.getDisplayName(), role, + role == Role.ADMINISTRATOR + ? EnumSet.allOf(Permission.class) + : EnumSet.of(Permission.VIEW_CATALOG, Permission.BORROW_BOOKS)); + } + + private static void requireMessage(ServiceResult result, String message) { + require(!result.isSuccessful(), "result should be a failure"); + require(message.equals(result.getMessage()), "expected message: " + message); + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } + + private static final class InMemorySystemLogDao implements SystemLogDao { + private final List logs = new ArrayList<>(); + + @Override + public List search(SystemLogSearchCriteria criteria) { + return new ArrayList<>(logs); + } + + @Override + public List findOperationTypes() { + return Arrays.asList("user.create", "user.update"); + } + + @Override + public long create(Connection connection, SystemLog log) { + logs.add(log); + return logs.size(); + } + } + + private static final class FailingSystemLogDao implements SystemLogDao { + @Override + public List search(SystemLogSearchCriteria criteria) { + throw new DaoException("Simulated system log search failure", null); + } + + @Override + public List findOperationTypes() { + throw new DaoException("Simulated operation type failure", null); + } + + @Override + public long create(Connection connection, SystemLog log) { + throw new DaoException("Simulated system log create failure", null); + } + } +} diff --git a/src/test/java/com/mzh/library/service/UserAccountServiceCheck.java b/src/test/java/com/mzh/library/service/UserAccountServiceCheck.java new file mode 100644 index 0000000..054cf41 --- /dev/null +++ b/src/test/java/com/mzh/library/service/UserAccountServiceCheck.java @@ -0,0 +1,263 @@ +package com.mzh.library.service; + +import com.mzh.library.dao.SystemLogDao; +import com.mzh.library.dao.UserAccountDao; +import com.mzh.library.entity.AuthenticatedUser; +import com.mzh.library.entity.Permission; +import com.mzh.library.entity.Role; +import com.mzh.library.entity.SystemLog; +import com.mzh.library.entity.SystemLogSearchCriteria; +import com.mzh.library.entity.User; +import com.mzh.library.entity.UserSearchCriteria; +import com.mzh.library.exception.DaoException; +import com.mzh.library.service.impl.UserAccountServiceImpl; +import com.mzh.library.util.PasswordHasher; + +import java.sql.Connection; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +public final class UserAccountServiceCheck { + private static final String DENIED_MESSAGE = "You do not have permission to manage users."; + private static final String UNAVAILABLE_MESSAGE = + "User management service is temporarily unavailable. Please try again later."; + + private UserAccountServiceCheck() { + } + + public static void main(String[] args) { + Logger.getLogger(UserAccountServiceImpl.class.getName()).setLevel(Level.OFF); + + InMemoryUserAccountDao userDao = new InMemoryUserAccountDao(); + InMemorySystemLogDao logDao = new InMemorySystemLogDao(); + User adminAccount = user(1L, "admin", "System Administrator", Role.ADMINISTRATOR, true, "admin-password"); + User staffAccount = user(2L, "staff", "Library Staff", Role.LIBRARIAN, true, "staff-password"); + userDao.put(adminAccount); + userDao.put(staffAccount); + + UserAccountService service = new UserAccountServiceImpl(userDao, logDao, new PermissionPolicy(), + new UserAccountServiceImpl.DirectTransactionExecutor()); + AuthenticatedUser admin = actor(1L, Role.ADMINISTRATOR); + AuthenticatedUser reader = actor(3L, Role.READER); + + requireMessage(service.searchUsers(reader, new UserSearchCriteria()), DENIED_MESSAGE); + + User duplicate = user(0L, " admin ", "Duplicate Admin", Role.ADMINISTRATOR, true, "unused"); + ServiceResult duplicateResult = service.createUser(admin, duplicate, "new-password", "127.0.0.1"); + require(!duplicateResult.isSuccessful(), "duplicate username should be rejected"); + require(duplicateResult.getErrors().containsKey("username"), "duplicate username should target username"); + + User invalid = new User(); + ServiceResult invalidResult = service.createUser(admin, invalid, "", "127.0.0.1"); + require(!invalidResult.isSuccessful(), "invalid user should be rejected"); + require(invalidResult.getErrors().containsKey("username"), "missing username should be reported"); + require(invalidResult.getErrors().containsKey("displayName"), "missing display name should be reported"); + require(invalidResult.getErrors().containsKey("password"), "missing password should be reported"); + + User newReader = new User(); + newReader.setUsername(" new.reader "); + newReader.setDisplayName("New Reader"); + newReader.setRole(Role.READER); + newReader.setActive(true); + ServiceResult created = service.createUser(admin, newReader, "reader-password", "127.0.0.1"); + require(created.isSuccessful(), "administrator should create user accounts"); + User storedReader = userDao.findById(created.getData()).orElseThrow(AssertionError::new); + require("new.reader".equals(storedReader.getUsername()), "username should be trimmed like login"); + require(!"reader-password".equals(storedReader.getPasswordHash()), "password should not be stored in plain text"); + require(PasswordHasher.verify("reader-password", storedReader.getPasswordHash()), "stored password should verify"); + require(logDao.logs.size() == 1, "user creation should write one system log"); + require("user.create".equals(logDao.logs.get(0).getOperationType()), "creation log should use user.create"); + + User selfRoleChange = user(1L, "admin", "System Administrator", Role.LIBRARIAN, true, "ignored"); + ServiceResult selfRoleResult = service.updateUser(admin, selfRoleChange, "", "127.0.0.1"); + require(!selfRoleResult.isSuccessful(), "current administrator role change should be blocked"); + require(selfRoleResult.getErrors().containsKey("role"), "self role change should target role"); + + User selfDeactivate = user(1L, "admin", "System Administrator", Role.ADMINISTRATOR, false, "ignored"); + ServiceResult selfDeactivateResult = service.updateUser(admin, selfDeactivate, "", "127.0.0.1"); + require(!selfDeactivateResult.isSuccessful(), "current administrator deactivation should be blocked"); + require(selfDeactivateResult.getErrors().containsKey("active"), "self deactivation should target active state"); + + String originalStaffHash = staffAccount.getPasswordHash(); + User updatedStaff = user(2L, "staff", "Lead Librarian", Role.LIBRARIAN, true, "ignored"); + ServiceResult updated = service.updateUser(admin, updatedStaff, "", "127.0.0.1"); + require(updated.isSuccessful(), "administrator should update user accounts"); + require(originalStaffHash.equals(userDao.findById(2L).orElseThrow(AssertionError::new).getPasswordHash()), + "blank update password should preserve existing hash"); + + ServiceResult reset = service.updateUser(admin, updatedStaff, "replacement-password", "127.0.0.1"); + require(reset.isSuccessful(), "administrator should reset passwords"); + require(PasswordHasher.verify("replacement-password", + userDao.findById(2L).orElseThrow(AssertionError::new).getPasswordHash()), + "replacement password should be hashed"); + + ServiceResult deactivated = service.deactivateUser(admin, 2L, "127.0.0.1"); + require(deactivated.isSuccessful(), "administrator should deactivate other accounts"); + require(!userDao.findById(2L).orElseThrow(AssertionError::new).isActive(), + "deactivate action should mark account inactive"); + require(logDao.logs.stream().anyMatch(log -> "user.deactivate".equals(log.getOperationType())), + "deactivate should write a system log"); + + UserAccountService failingService = new UserAccountServiceImpl(new FailingUserAccountDao(), logDao, + new PermissionPolicy(), new UserAccountServiceImpl.DirectTransactionExecutor()); + requireMessage(failingService.searchUsers(admin, new UserSearchCriteria()), UNAVAILABLE_MESSAGE); + } + + private static User user(long id, String username, String displayName, Role role, boolean active, String password) { + User user = new User(); + user.setId(id); + user.setUsername(username); + user.setDisplayName(displayName); + user.setRole(role); + user.setActive(active); + user.setPasswordHash(PasswordHasher.hash(password)); + return user; + } + + private static AuthenticatedUser actor(long id, Role role) { + return new AuthenticatedUser(id, role.getCode(), role.getDisplayName(), role, + role == Role.ADMINISTRATOR + ? EnumSet.allOf(Permission.class) + : EnumSet.of(Permission.VIEW_CATALOG, Permission.BORROW_BOOKS)); + } + + private static void requireMessage(ServiceResult result, String message) { + require(!result.isSuccessful(), "result should be a failure"); + require(message.equals(result.getMessage()), "expected message: " + message); + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } + + private static final class InMemoryUserAccountDao implements UserAccountDao { + private final Map users = new LinkedHashMap<>(); + private long nextId = 10L; + + private void put(User user) { + users.put(user.getId(), copy(user)); + } + + @Override + public List search(UserSearchCriteria criteria) { + return users.values().stream() + .filter(user -> criteria.getRoleCode().isEmpty() + || user.getRole().getCode().equals(criteria.getRoleCode())) + .filter(user -> criteria.getActiveValue() == null + || user.isActive() == criteria.getActiveValue()) + .map(this::copy) + .collect(Collectors.toList()); + } + + @Override + public Optional findById(long id) { + return Optional.ofNullable(users.get(id)).map(this::copy); + } + + @Override + public Optional findByUsername(String username) { + return users.values().stream() + .filter(user -> user.getUsername().equals(username)) + .findFirst() + .map(this::copy); + } + + @Override + public long create(Connection connection, User user) { + long id = nextId++; + User stored = copy(user); + stored.setId(id); + users.put(id, stored); + return id; + } + + @Override + public boolean update(Connection connection, User user, boolean updatePassword) { + User existing = users.get(user.getId()); + if (existing == null) { + return false; + } + existing.setDisplayName(user.getDisplayName()); + existing.setRole(user.getRole()); + existing.setActive(user.isActive()); + if (updatePassword) { + existing.setPasswordHash(user.getPasswordHash()); + } + return true; + } + + private User copy(User source) { + User copy = new User(); + copy.setId(source.getId()); + copy.setUsername(source.getUsername()); + copy.setDisplayName(source.getDisplayName()); + copy.setRole(source.getRole()); + copy.setActive(source.isActive()); + copy.setPasswordHash(source.getPasswordHash()); + copy.setCreatedAt(source.getCreatedAt()); + copy.setUpdatedAt(source.getUpdatedAt()); + return copy; + } + } + + private static final class InMemorySystemLogDao implements SystemLogDao { + private final List logs = new ArrayList<>(); + + @Override + public List search(SystemLogSearchCriteria criteria) { + return new ArrayList<>(logs); + } + + @Override + public List findOperationTypes() { + return logs.stream() + .map(SystemLog::getOperationType) + .distinct() + .collect(Collectors.toList()); + } + + @Override + public long create(Connection connection, SystemLog log) { + log.setId(logs.size() + 1L); + logs.add(log); + return log.getId(); + } + } + + private static final class FailingUserAccountDao implements UserAccountDao { + @Override + public List search(UserSearchCriteria criteria) { + throw new DaoException("Simulated user search failure", null); + } + + @Override + public Optional findById(long id) { + throw new DaoException("Simulated user lookup failure", null); + } + + @Override + public Optional findByUsername(String username) { + throw new DaoException("Simulated username lookup failure", null); + } + + @Override + public long create(Connection connection, User user) { + throw new DaoException("Simulated user create failure", null); + } + + @Override + public boolean update(Connection connection, User user, boolean updatePassword) { + throw new DaoException("Simulated user update failure", null); + } + } +}