用户/账号管理,系统日志
This commit is contained in:
@@ -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
|
pages should receive concise messages. Durable system logs should record the
|
||||||
operation, actor, failure category, and correlation details needed to locate
|
operation, actor, failure category, and correlation details needed to locate
|
||||||
the server-side exception.
|
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 #<id>`, 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 #<id>`, 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
|
||||||
|
<span class="status-${log.resultStatus}">${log.resultStatus}</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Correct
|
||||||
|
|
||||||
|
```jsp
|
||||||
|
<span class="status-${log.resultStatusCode}">
|
||||||
|
<c:out value="${log.resultStatusName}" />
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -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"}
|
||||||
@@ -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.
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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<SystemLogPage> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<User>> 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<Optional<User>> 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<Long> 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<Void> 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<Void> 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<String, String> formValues, Map<String, String> 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<String, String> values = formValues(request);
|
||||||
|
Map<String, String> 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<String, String> formValues(HttpServletRequest request) {
|
||||||
|
Map<String, String> 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<String, String> 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<String, String> 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<String, String> values;
|
||||||
|
private final Map<String, String> errors;
|
||||||
|
private final String password;
|
||||||
|
|
||||||
|
private UserForm(User user, Map<String, String> values, Map<String, String> errors, String password) {
|
||||||
|
this.user = user;
|
||||||
|
this.values = values;
|
||||||
|
this.errors = errors;
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
private User getUser() {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> getValues() {
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> getErrors() {
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getPassword() {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SystemLog> search(SystemLogSearchCriteria criteria);
|
||||||
|
|
||||||
|
List<String> findOperationTypes();
|
||||||
|
|
||||||
|
long create(Connection connection, SystemLog log);
|
||||||
|
}
|
||||||
@@ -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<User> search(UserSearchCriteria criteria);
|
||||||
|
|
||||||
|
Optional<User> findById(long id);
|
||||||
|
|
||||||
|
Optional<User> findByUsername(String username);
|
||||||
|
|
||||||
|
long create(Connection connection, User user);
|
||||||
|
|
||||||
|
boolean update(Connection connection, User user, boolean updatePassword);
|
||||||
|
}
|
||||||
@@ -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<SystemLog> search(SystemLogSearchCriteria criteria) {
|
||||||
|
SystemLogSearchCriteria normalized = criteria == null ? new SystemLogSearchCriteria() : criteria;
|
||||||
|
List<Object> 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<SystemLog> 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<String> findOperationTypes() {
|
||||||
|
try (Connection connection = JdbcUtil.getConnection();
|
||||||
|
PreparedStatement statement = connection.prepareStatement(OPERATION_TYPES);
|
||||||
|
ResultSet resultSet = statement.executeQuery()) {
|
||||||
|
List<String> 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<Object> 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<Object> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package com.mzh.library.dao.impl;
|
package com.mzh.library.dao.impl;
|
||||||
|
|
||||||
|
import com.mzh.library.dao.UserAccountDao;
|
||||||
import com.mzh.library.dao.UserDao;
|
import com.mzh.library.dao.UserDao;
|
||||||
import com.mzh.library.entity.Role;
|
import com.mzh.library.entity.Role;
|
||||||
import com.mzh.library.entity.User;
|
import com.mzh.library.entity.User;
|
||||||
|
import com.mzh.library.entity.UserSearchCriteria;
|
||||||
import com.mzh.library.exception.DaoException;
|
import com.mzh.library.exception.DaoException;
|
||||||
import com.mzh.library.util.JdbcUtil;
|
import com.mzh.library.util.JdbcUtil;
|
||||||
|
|
||||||
@@ -10,14 +12,40 @@ import java.sql.Connection;
|
|||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
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;
|
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 = ""
|
private static final String FIND_ACTIVE_BY_USERNAME = ""
|
||||||
+ "SELECT id, username, password_hash, display_name, role_code, active "
|
+ "SELECT " + USER_COLUMNS
|
||||||
+ "FROM users "
|
+ "FROM users "
|
||||||
+ "WHERE username = ? AND active = 1";
|
+ "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
|
@Override
|
||||||
public Optional<User> findActiveByUsername(String username) {
|
public Optional<User> findActiveByUsername(String username) {
|
||||||
try (Connection connection = JdbcUtil.getConnection();
|
try (Connection connection = JdbcUtil.getConnection();
|
||||||
@@ -36,6 +64,131 @@ public class JdbcUserDao implements UserDao {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<User> search(UserSearchCriteria criteria) {
|
||||||
|
UserSearchCriteria normalized = criteria == null ? new UserSearchCriteria() : criteria;
|
||||||
|
List<Object> 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<User> 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<User> 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<User> 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<Object> 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<Object> 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 {
|
private User mapUser(ResultSet resultSet) throws SQLException {
|
||||||
User user = new User();
|
User user = new User();
|
||||||
user.setId(resultSet.getLong("id"));
|
user.setId(resultSet.getLong("id"));
|
||||||
@@ -44,6 +197,12 @@ public class JdbcUserDao implements UserDao {
|
|||||||
user.setDisplayName(resultSet.getString("display_name"));
|
user.setDisplayName(resultSet.getString("display_name"));
|
||||||
user.setRole(Role.fromCode(resultSet.getString("role_code")));
|
user.setRole(Role.fromCode(resultSet.getString("role_code")));
|
||||||
user.setActive(resultSet.getBoolean("active"));
|
user.setActive(resultSet.getBoolean("active"));
|
||||||
|
user.setCreatedAt(toLocalDateTime(resultSet.getTimestamp("created_at")));
|
||||||
|
user.setUpdatedAt(toLocalDateTime(resultSet.getTimestamp("updated_at")));
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private LocalDateTime toLocalDateTime(Timestamp timestamp) {
|
||||||
|
return timestamp == null ? null : timestamp.toLocalDateTime();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SystemLog> logs = Collections.emptyList();
|
||||||
|
private List<String> operationTypes = Collections.emptyList();
|
||||||
|
|
||||||
|
public List<SystemLog> getLogs() {
|
||||||
|
return logs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLogs(List<SystemLog> logs) {
|
||||||
|
this.logs = logs == null ? Collections.emptyList() : new ArrayList<>(logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getOperationTypes() {
|
||||||
|
return operationTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOperationTypes(List<String> operationTypes) {
|
||||||
|
this.operationTypes = operationTypes == null ? Collections.emptyList() : new ArrayList<>(operationTypes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
package com.mzh.library.entity;
|
package com.mzh.library.entity;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
public class User {
|
public class User {
|
||||||
|
private static final DateTimeFormatter DISPLAY_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||||||
|
|
||||||
private long id;
|
private long id;
|
||||||
private String username;
|
private String username;
|
||||||
private String passwordHash;
|
private String passwordHash;
|
||||||
private String displayName;
|
private String displayName;
|
||||||
private Role role;
|
private Role role;
|
||||||
private boolean active;
|
private boolean active;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
public long getId() {
|
public long getId() {
|
||||||
return id;
|
return id;
|
||||||
@@ -55,4 +62,40 @@ public class User {
|
|||||||
public void setActive(boolean active) {
|
public void setActive(boolean active) {
|
||||||
this.active = 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ public class AuthorizationFilter implements Filter {
|
|||||||
private static final Logger LOGGER = Logger.getLogger(AuthorizationFilter.class.getName());
|
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 String UNAUTHORIZED_JSP = "/WEB-INF/jsp/auth/unauthorized.jsp";
|
||||||
private static final List<PathRule> RULES = Arrays.asList(
|
private static final List<PathRule> RULES = Arrays.asList(
|
||||||
|
new PathRule("/admin/system-logs", Permission.VIEW_SYSTEM_LOGS),
|
||||||
new PathRule("/reports", Permission.VIEW_REPORTS),
|
new PathRule("/reports", Permission.VIEW_REPORTS),
|
||||||
new PathRule("/borrowing", Permission.MANAGE_BORROWING),
|
new PathRule("/borrowing", Permission.MANAGE_BORROWING),
|
||||||
new PathRule("/books", Permission.MANAGE_BOOKS),
|
new PathRule("/books", Permission.MANAGE_BOOKS),
|
||||||
|
|||||||
@@ -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<SystemLogPage> searchLogs(AuthenticatedUser actor, SystemLogSearchCriteria criteria);
|
||||||
|
}
|
||||||
@@ -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<List<User>> searchUsers(AuthenticatedUser actor, UserSearchCriteria criteria);
|
||||||
|
|
||||||
|
ServiceResult<Optional<User>> findUser(AuthenticatedUser actor, long id);
|
||||||
|
|
||||||
|
ServiceResult<Long> createUser(AuthenticatedUser actor, User user, String password, String requestIp);
|
||||||
|
|
||||||
|
ServiceResult<Void> updateUser(AuthenticatedUser actor, User user, String password, String requestIp);
|
||||||
|
|
||||||
|
ServiceResult<Void> deactivateUser(AuthenticatedUser actor, long id, String requestIp);
|
||||||
|
}
|
||||||
@@ -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<SystemLogPage> searchLogs(AuthenticatedUser actor, SystemLogSearchCriteria criteria) {
|
||||||
|
if (!canViewSystemLogs(actor)) {
|
||||||
|
return ServiceResult.failure(DENIED_MESSAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemLogSearchCriteria normalized = criteria == null ? new SystemLogSearchCriteria() : criteria;
|
||||||
|
Map<String, String> 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<String, String> validate(SystemLogSearchCriteria criteria) {
|
||||||
|
Map<String, String> 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<String, String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> T execute(JdbcUtil.TransactionCallback<T> 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<List<User>> searchUsers(AuthenticatedUser actor, UserSearchCriteria criteria) {
|
||||||
|
if (!canManageUsers(actor)) {
|
||||||
|
return ServiceResult.failure(DENIED_MESSAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
UserSearchCriteria normalized = criteria == null ? new UserSearchCriteria() : criteria;
|
||||||
|
Map<String, String> 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<Optional<User>> 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<Long> createUser(AuthenticatedUser actor, User user, String password, String requestIp) {
|
||||||
|
if (!canManageUsers(actor)) {
|
||||||
|
return ServiceResult.failure(DENIED_MESSAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize(user);
|
||||||
|
Map<String, String> 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<Void> updateUser(AuthenticatedUser actor, User user, String password, String requestIp) {
|
||||||
|
if (!canManageUsers(actor)) {
|
||||||
|
return ServiceResult.failure(DENIED_MESSAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize(user);
|
||||||
|
Map<String, String> errors = validateUser(user, true, password, false);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Optional<User> 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<Void> 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<String, String> errors = new LinkedHashMap<>();
|
||||||
|
errors.put("active", SELF_DEACTIVATE_MESSAGE);
|
||||||
|
return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Optional<User> 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<String, String> validateSearch(UserSearchCriteria criteria) {
|
||||||
|
Map<String, String> 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<String, String> validateUser(User user, boolean requireId, String password, boolean requirePassword) {
|
||||||
|
Map<String, String> 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<String, String> 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<String, String> 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<String, String> 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> T execute(JdbcUtil.TransactionCallback<T> callback) {
|
||||||
|
return JdbcUtil.executeInTransaction(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class DirectTransactionExecutor implements TransactionExecutor {
|
||||||
|
@Override
|
||||||
|
public <T> T execute(JdbcUtil.TransactionCallback<T> callback) {
|
||||||
|
try {
|
||||||
|
return callback.execute(null);
|
||||||
|
} catch (SQLException ex) {
|
||||||
|
throw new DaoException("Unable to execute direct transaction", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" %>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><c:out value="${formTitle}" /> - MZH Library</title>
|
||||||
|
<link rel="stylesheet" href="${pageContext.request.contextPath}/static/css/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<%@ include file="/WEB-INF/jsp/common/header.jspf" %>
|
||||||
|
<main class="page-shell">
|
||||||
|
<section class="form-panel" aria-labelledby="user-form-title">
|
||||||
|
<p class="eyebrow">Administration</p>
|
||||||
|
<h1 id="user-form-title"><c:out value="${formTitle}" /></h1>
|
||||||
|
|
||||||
|
<c:if test="${not empty errorMessage}">
|
||||||
|
<div class="message message-error" role="alert">
|
||||||
|
<c:out value="${errorMessage}" />
|
||||||
|
</div>
|
||||||
|
</c:if>
|
||||||
|
|
||||||
|
<c:set var="hasFormValues" value="${not empty formValues}" />
|
||||||
|
<c:set var="usernameValue" value="${hasFormValues ? formValues.username : user.username}" />
|
||||||
|
<c:set var="displayNameValue" value="${hasFormValues ? formValues.displayName : user.displayName}" />
|
||||||
|
<c:set var="roleValue" value="${hasFormValues ? formValues.role : user.role.code}" />
|
||||||
|
<c:set var="activeValue" value="${hasFormValues ? formValues.active : user.active}" />
|
||||||
|
|
||||||
|
<form class="user-form" action="${pageContext.request.contextPath}${formAction}" method="post" novalidate>
|
||||||
|
<c:if test="${user.id > 0}">
|
||||||
|
<input type="hidden" name="id" value="${user.id}">
|
||||||
|
<input type="hidden" name="username" value="${fn:escapeXml(usernameValue)}">
|
||||||
|
</c:if>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${user.id > 0}">
|
||||||
|
<input id="username" type="text" value="${fn:escapeXml(usernameValue)}" disabled>
|
||||||
|
</c:when>
|
||||||
|
<c:otherwise>
|
||||||
|
<input id="username" name="username" type="text" value="${fn:escapeXml(usernameValue)}" required>
|
||||||
|
</c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
<c:if test="${not empty errors.username}">
|
||||||
|
<span class="field-error"><c:out value="${errors.username}" /></span>
|
||||||
|
</c:if>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="displayName">Display name</label>
|
||||||
|
<input id="displayName" name="displayName" type="text"
|
||||||
|
value="${fn:escapeXml(displayNameValue)}" required>
|
||||||
|
<c:if test="${not empty errors.displayName}">
|
||||||
|
<span class="field-error"><c:out value="${errors.displayName}" /></span>
|
||||||
|
</c:if>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="role">Role</label>
|
||||||
|
<select id="role" name="role" required>
|
||||||
|
<option value="">Select role</option>
|
||||||
|
<c:forEach var="role" items="${roles}">
|
||||||
|
<option value="${role.code}" <c:if test="${roleValue == role.code}">selected</c:if>>
|
||||||
|
<c:out value="${role.displayName}" />
|
||||||
|
</option>
|
||||||
|
</c:forEach>
|
||||||
|
</select>
|
||||||
|
<c:if test="${not empty errors.role}">
|
||||||
|
<span class="field-error"><c:out value="${errors.role}" /></span>
|
||||||
|
</c:if>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="active">Active state</label>
|
||||||
|
<select id="active" name="active" required>
|
||||||
|
<option value="true" <c:if test="${activeValue == true or activeValue == 'true'}">selected</c:if>>
|
||||||
|
Active
|
||||||
|
</option>
|
||||||
|
<option value="false" <c:if test="${activeValue == false or activeValue == 'false'}">selected</c:if>>
|
||||||
|
Inactive
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<c:if test="${not empty errors.active}">
|
||||||
|
<span class="field-error"><c:out value="${errors.active}" /></span>
|
||||||
|
</c:if>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="password">
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${user.id > 0}">New password</c:when>
|
||||||
|
<c:otherwise>Password</c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</label>
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${user.id > 0}">
|
||||||
|
<input id="password" name="password" type="password" autocomplete="new-password">
|
||||||
|
</c:when>
|
||||||
|
<c:otherwise>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="new-password" required>
|
||||||
|
</c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
<c:if test="${not empty errors.password}">
|
||||||
|
<span class="field-error"><c:out value="${errors.password}" /></span>
|
||||||
|
</c:if>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button button-primary" type="submit">Save</button>
|
||||||
|
<a class="button button-secondary" href="${pageContext.request.contextPath}/admin/users">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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" %>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Manage Users - MZH Library</title>
|
||||||
|
<link rel="stylesheet" href="${pageContext.request.contextPath}/static/css/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<%@ include file="/WEB-INF/jsp/common/header.jspf" %>
|
||||||
|
<main class="page-shell">
|
||||||
|
<section class="dashboard-hero catalog-hero" aria-labelledby="manage-users-title">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Administration</p>
|
||||||
|
<h1 id="manage-users-title">Manage users</h1>
|
||||||
|
<p>Create, update, deactivate, and review administrator, librarian, and reader accounts.</p>
|
||||||
|
</div>
|
||||||
|
<a class="button button-primary" href="${pageContext.request.contextPath}/admin/users/new">New user</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<c:if test="${not empty successMessage}">
|
||||||
|
<div class="message message-success" role="status">
|
||||||
|
<c:out value="${successMessage}" />
|
||||||
|
</div>
|
||||||
|
</c:if>
|
||||||
|
<c:if test="${not empty errorMessage}">
|
||||||
|
<div class="message message-error" role="alert">
|
||||||
|
<c:out value="${errorMessage}" />
|
||||||
|
</div>
|
||||||
|
</c:if>
|
||||||
|
|
||||||
|
<section class="toolbar-panel" aria-label="User management search">
|
||||||
|
<form class="search-form" action="${pageContext.request.contextPath}/admin/users" method="get">
|
||||||
|
<div class="search-field">
|
||||||
|
<label for="keyword">Keyword</label>
|
||||||
|
<input id="keyword" name="keyword" type="text" value="${fn:escapeXml(criteria.keyword)}">
|
||||||
|
<c:if test="${not empty errors.keyword}">
|
||||||
|
<span class="field-error"><c:out value="${errors.keyword}" /></span>
|
||||||
|
</c:if>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-field">
|
||||||
|
<label for="role">Role</label>
|
||||||
|
<select id="role" name="role">
|
||||||
|
<option value="">All roles</option>
|
||||||
|
<c:forEach var="role" items="${roles}">
|
||||||
|
<option value="${role.code}" <c:if test="${criteria.roleCode == role.code}">selected</c:if>>
|
||||||
|
<c:out value="${role.displayName}" />
|
||||||
|
</option>
|
||||||
|
</c:forEach>
|
||||||
|
</select>
|
||||||
|
<c:if test="${not empty errors.role}">
|
||||||
|
<span class="field-error"><c:out value="${errors.role}" /></span>
|
||||||
|
</c:if>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-field">
|
||||||
|
<label for="active">Active state</label>
|
||||||
|
<select id="active" name="active">
|
||||||
|
<option value="">All states</option>
|
||||||
|
<option value="active" <c:if test="${criteria.activeStatus == 'active'}">selected</c:if>>Active</option>
|
||||||
|
<option value="inactive" <c:if test="${criteria.activeStatus == 'inactive'}">selected</c:if>>Inactive</option>
|
||||||
|
</select>
|
||||||
|
<c:if test="${not empty errors.active}">
|
||||||
|
<span class="field-error"><c:out value="${errors.active}" /></span>
|
||||||
|
</c:if>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="button button-primary" type="submit">Search</button>
|
||||||
|
<a class="button button-secondary" href="${pageContext.request.contextPath}/admin/users">Clear</a>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="table-panel" aria-labelledby="user-results-title">
|
||||||
|
<h2 id="user-results-title">User accounts</h2>
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${empty users}">
|
||||||
|
<p class="empty-state">No user accounts match the current filters.</p>
|
||||||
|
</c:when>
|
||||||
|
<c:otherwise>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="data-table user-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Username</th>
|
||||||
|
<th scope="col">Display name</th>
|
||||||
|
<th scope="col">Role</th>
|
||||||
|
<th scope="col">State</th>
|
||||||
|
<th scope="col">Created</th>
|
||||||
|
<th scope="col">Updated</th>
|
||||||
|
<th scope="col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<c:forEach var="account" items="${users}">
|
||||||
|
<tr>
|
||||||
|
<td><c:out value="${account.username}" /></td>
|
||||||
|
<td><c:out value="${account.displayName}" /></td>
|
||||||
|
<td><c:out value="${account.role.displayName}" /></td>
|
||||||
|
<td>
|
||||||
|
<span class="status-pill status-${account.activeStatusCode}">
|
||||||
|
<c:out value="${account.activeStatusName}" />
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><c:out value="${account.createdAtText}" /></td>
|
||||||
|
<td><c:out value="${account.updatedAtText}" /></td>
|
||||||
|
<td>
|
||||||
|
<div class="table-actions">
|
||||||
|
<a class="button button-secondary"
|
||||||
|
href="${pageContext.request.contextPath}/admin/users/edit?id=${account.id}">Edit</a>
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${account.id == sessionScope.authenticatedUser.id or not account.active}">
|
||||||
|
<button class="button button-secondary" type="button" disabled>Deactivate</button>
|
||||||
|
</c:when>
|
||||||
|
<c:otherwise>
|
||||||
|
<form action="${pageContext.request.contextPath}/admin/users/deactivate"
|
||||||
|
method="post"
|
||||||
|
onsubmit="return confirm('Deactivate this user account?');">
|
||||||
|
<input type="hidden" name="id" value="${account.id}">
|
||||||
|
<button class="button button-danger" type="submit">Deactivate</button>
|
||||||
|
</form>
|
||||||
|
</c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</c:forEach>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
<a href="${pageContext.request.contextPath}/catalog">Catalog</a>
|
<a href="${pageContext.request.contextPath}/catalog">Catalog</a>
|
||||||
<c:if test="${sessionScope.userRole == 'administrator'}">
|
<c:if test="${sessionScope.userRole == 'administrator'}">
|
||||||
<a href="${pageContext.request.contextPath}/admin/home">Admin</a>
|
<a href="${pageContext.request.contextPath}/admin/home">Admin</a>
|
||||||
|
<a href="${pageContext.request.contextPath}/admin/users">Users</a>
|
||||||
|
<a href="${pageContext.request.contextPath}/admin/system-logs">Logs</a>
|
||||||
</c:if>
|
</c:if>
|
||||||
<c:if test="${sessionScope.userRole == 'administrator' or sessionScope.userRole == 'librarian'}">
|
<c:if test="${sessionScope.userRole == 'administrator' or sessionScope.userRole == 'librarian'}">
|
||||||
<a href="${pageContext.request.contextPath}/librarian/home">Librarian</a>
|
<a href="${pageContext.request.contextPath}/librarian/home">Librarian</a>
|
||||||
|
|||||||
@@ -26,6 +26,18 @@
|
|||||||
<p>Account, role, permission, and system-maintenance entry point.</p>
|
<p>Account, role, permission, and system-maintenance entry point.</p>
|
||||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/admin/home">Open</a>
|
<a class="button button-secondary" href="${pageContext.request.contextPath}/admin/home">Open</a>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<article class="workspace-card">
|
||||||
|
<h2>User Management</h2>
|
||||||
|
<p>Create, update, deactivate, and review login accounts.</p>
|
||||||
|
<a class="button button-secondary" href="${pageContext.request.contextPath}/admin/users">Open</a>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="workspace-card">
|
||||||
|
<h2>System Logs</h2>
|
||||||
|
<p>Review read-only audit entries for account and maintenance actions.</p>
|
||||||
|
<a class="button button-secondary" href="${pageContext.request.contextPath}/admin/system-logs">Open</a>
|
||||||
|
</article>
|
||||||
</c:if>
|
</c:if>
|
||||||
|
|
||||||
<c:if test="${sessionScope.userRole == 'administrator' or sessionScope.userRole == 'librarian'}">
|
<c:if test="${sessionScope.userRole == 'administrator' or sessionScope.userRole == 'librarian'}">
|
||||||
|
|||||||
@@ -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" %>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>System Logs - MZH Library</title>
|
||||||
|
<link rel="stylesheet" href="${pageContext.request.contextPath}/static/css/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<%@ include file="/WEB-INF/jsp/common/header.jspf" %>
|
||||||
|
<main class="page-shell">
|
||||||
|
<section class="dashboard-hero catalog-hero" aria-labelledby="system-logs-title">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">System Maintenance</p>
|
||||||
|
<h1 id="system-logs-title">System logs</h1>
|
||||||
|
<p>Review administrative account changes and maintenance audit records.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<c:if test="${not empty errorMessage}">
|
||||||
|
<div class="message message-error" role="alert">
|
||||||
|
<c:out value="${errorMessage}" />
|
||||||
|
</div>
|
||||||
|
</c:if>
|
||||||
|
|
||||||
|
<section class="toolbar-panel" aria-label="System log search">
|
||||||
|
<form class="search-form system-log-search-form"
|
||||||
|
action="${pageContext.request.contextPath}/admin/system-logs" method="get">
|
||||||
|
<div class="search-field">
|
||||||
|
<label for="operationType">Operation</label>
|
||||||
|
<select id="operationType" name="operationType">
|
||||||
|
<option value="">All operations</option>
|
||||||
|
<c:forEach var="operationType" items="${operationTypes}">
|
||||||
|
<option value="${fn:escapeXml(operationType)}"
|
||||||
|
<c:if test="${criteria.operationType == operationType}">selected</c:if>>
|
||||||
|
<c:out value="${operationType}" />
|
||||||
|
</option>
|
||||||
|
</c:forEach>
|
||||||
|
<c:if test="${not empty criteria.operationType and empty operationTypes}">
|
||||||
|
<option value="${fn:escapeXml(criteria.operationType)}" selected>
|
||||||
|
<c:out value="${criteria.operationType}" />
|
||||||
|
</option>
|
||||||
|
</c:if>
|
||||||
|
</select>
|
||||||
|
<c:if test="${not empty errors.operationType}">
|
||||||
|
<span class="field-error"><c:out value="${errors.operationType}" /></span>
|
||||||
|
</c:if>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-field">
|
||||||
|
<label for="keyword">Keyword</label>
|
||||||
|
<input id="keyword" name="keyword" type="text" value="${fn:escapeXml(criteria.keyword)}">
|
||||||
|
<c:if test="${not empty errors.keyword}">
|
||||||
|
<span class="field-error"><c:out value="${errors.keyword}" /></span>
|
||||||
|
</c:if>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-field">
|
||||||
|
<label for="createdFrom">From</label>
|
||||||
|
<input id="createdFrom" name="createdFrom" type="date"
|
||||||
|
value="${fn:escapeXml(criteria.createdFromText)}">
|
||||||
|
<c:if test="${not empty errors.createdFrom}">
|
||||||
|
<span class="field-error"><c:out value="${errors.createdFrom}" /></span>
|
||||||
|
</c:if>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-field">
|
||||||
|
<label for="createdTo">To</label>
|
||||||
|
<input id="createdTo" name="createdTo" type="date"
|
||||||
|
value="${fn:escapeXml(criteria.createdToText)}">
|
||||||
|
<c:if test="${not empty errors.createdTo}">
|
||||||
|
<span class="field-error"><c:out value="${errors.createdTo}" /></span>
|
||||||
|
</c:if>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="button button-primary" type="submit">Search</button>
|
||||||
|
<a class="button button-secondary" href="${pageContext.request.contextPath}/admin/system-logs">Clear</a>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="table-panel" aria-labelledby="system-log-results-title">
|
||||||
|
<h2 id="system-log-results-title">Log entries</h2>
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${empty logs}">
|
||||||
|
<p class="empty-state">No system logs match the current filters.</p>
|
||||||
|
</c:when>
|
||||||
|
<c:otherwise>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="data-table system-log-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Time</th>
|
||||||
|
<th scope="col">Operator</th>
|
||||||
|
<th scope="col">Operation</th>
|
||||||
|
<th scope="col">Target</th>
|
||||||
|
<th scope="col">Result</th>
|
||||||
|
<th scope="col">IP address</th>
|
||||||
|
<th scope="col">Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<c:forEach var="log" items="${logs}">
|
||||||
|
<tr>
|
||||||
|
<td><c:out value="${log.createdAtText}" /></td>
|
||||||
|
<td>
|
||||||
|
<div><c:out value="${log.operatorLabel}" /></div>
|
||||||
|
<c:if test="${not empty log.operatorMetaText}">
|
||||||
|
<div class="muted-text"><c:out value="${log.operatorMetaText}" /></div>
|
||||||
|
</c:if>
|
||||||
|
</td>
|
||||||
|
<td><c:out value="${log.operationType}" /></td>
|
||||||
|
<td>
|
||||||
|
<c:out value="${log.targetTable}" />
|
||||||
|
<c:if test="${not empty log.targetId}">
|
||||||
|
#<c:out value="${log.targetId}" />
|
||||||
|
</c:if>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-pill status-${log.resultStatusCode}">
|
||||||
|
<c:out value="${log.resultStatusName}" />
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><c:out value="${log.requestIp}" /></td>
|
||||||
|
<td><c:out value="${log.message}" /></td>
|
||||||
|
</tr>
|
||||||
|
</c:forEach>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -28,6 +28,20 @@
|
|||||||
</article>
|
</article>
|
||||||
|
|
||||||
<c:if test="${sessionScope.userRole == 'administrator' or sessionScope.userRole == 'librarian'}">
|
<c:if test="${sessionScope.userRole == 'administrator' or sessionScope.userRole == 'librarian'}">
|
||||||
|
<c:if test="${sessionScope.userRole == 'administrator'}">
|
||||||
|
<article class="workspace-card">
|
||||||
|
<h2>User Management</h2>
|
||||||
|
<p>Create, update, deactivate, and review login accounts.</p>
|
||||||
|
<a class="button button-secondary" href="${pageContext.request.contextPath}/admin/users">Manage users</a>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="workspace-card">
|
||||||
|
<h2>System Logs</h2>
|
||||||
|
<p>Review read-only audit entries for account and maintenance actions.</p>
|
||||||
|
<a class="button button-secondary" href="${pageContext.request.contextPath}/admin/system-logs">View logs</a>
|
||||||
|
</article>
|
||||||
|
</c:if>
|
||||||
|
|
||||||
<article class="workspace-card">
|
<article class="workspace-card">
|
||||||
<h2>Book Management</h2>
|
<h2>Book Management</h2>
|
||||||
<p>Create, update, delete, and review inventory fields for book records.</p>
|
<p>Create, update, delete, and review inventory fields for book records.</p>
|
||||||
|
|||||||
@@ -75,6 +75,28 @@
|
|||||||
<url-pattern>/reader/home</url-pattern>
|
<url-pattern>/reader/home</url-pattern>
|
||||||
</servlet-mapping>
|
</servlet-mapping>
|
||||||
|
|
||||||
|
<servlet>
|
||||||
|
<servlet-name>UserManagementServlet</servlet-name>
|
||||||
|
<servlet-class>com.mzh.library.controller.UserManagementServlet</servlet-class>
|
||||||
|
</servlet>
|
||||||
|
<servlet-mapping>
|
||||||
|
<servlet-name>UserManagementServlet</servlet-name>
|
||||||
|
<url-pattern>/admin/users</url-pattern>
|
||||||
|
<url-pattern>/admin/users/new</url-pattern>
|
||||||
|
<url-pattern>/admin/users/edit</url-pattern>
|
||||||
|
<url-pattern>/admin/users/update</url-pattern>
|
||||||
|
<url-pattern>/admin/users/deactivate</url-pattern>
|
||||||
|
</servlet-mapping>
|
||||||
|
|
||||||
|
<servlet>
|
||||||
|
<servlet-name>SystemLogServlet</servlet-name>
|
||||||
|
<servlet-class>com.mzh.library.controller.SystemLogServlet</servlet-class>
|
||||||
|
</servlet>
|
||||||
|
<servlet-mapping>
|
||||||
|
<servlet-name>SystemLogServlet</servlet-name>
|
||||||
|
<url-pattern>/admin/system-logs</url-pattern>
|
||||||
|
</servlet-mapping>
|
||||||
|
|
||||||
<servlet>
|
<servlet>
|
||||||
<servlet-name>BookCatalogServlet</servlet-name>
|
<servlet-name>BookCatalogServlet</servlet-name>
|
||||||
<servlet-class>com.mzh.library.controller.BookCatalogServlet</servlet-class>
|
<servlet-class>com.mzh.library.controller.BookCatalogServlet</servlet-class>
|
||||||
|
|||||||
@@ -198,6 +198,11 @@ h2 {
|
|||||||
background: #8f3028;
|
background: #8f3028;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@@ -326,6 +331,10 @@ h2 {
|
|||||||
grid-template-columns: repeat(3, minmax(120px, 1fr)) auto auto;
|
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 {
|
.search-field {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -343,6 +352,8 @@ h2 {
|
|||||||
.book-form select,
|
.book-form select,
|
||||||
.reader-form input,
|
.reader-form input,
|
||||||
.reader-form select,
|
.reader-form select,
|
||||||
|
.user-form input,
|
||||||
|
.user-form select,
|
||||||
.borrow-form input {
|
.borrow-form input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
@@ -359,6 +370,8 @@ h2 {
|
|||||||
.book-form select:focus,
|
.book-form select:focus,
|
||||||
.reader-form input:focus,
|
.reader-form input:focus,
|
||||||
.reader-form select:focus,
|
.reader-form select:focus,
|
||||||
|
.user-form input:focus,
|
||||||
|
.user-form select:focus,
|
||||||
.borrow-form input:focus {
|
.borrow-form input:focus {
|
||||||
outline: 3px solid rgba(37, 111, 108, 0.18);
|
outline: 3px solid rgba(37, 111, 108, 0.18);
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
@@ -380,6 +393,11 @@ h2 {
|
|||||||
min-width: 980px;
|
min-width: 980px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-table,
|
||||||
|
.system-log-table {
|
||||||
|
min-width: 980px;
|
||||||
|
}
|
||||||
|
|
||||||
.data-table th,
|
.data-table th,
|
||||||
.data-table td {
|
.data-table td {
|
||||||
padding: 12px 10px;
|
padding: 12px 10px;
|
||||||
@@ -445,6 +463,21 @@ h2 {
|
|||||||
background: #eef1f5;
|
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 {
|
.status-overdue {
|
||||||
color: #7a211a;
|
color: #7a211a;
|
||||||
background: #fff0ee;
|
background: #fff0ee;
|
||||||
@@ -472,6 +505,7 @@ h2 {
|
|||||||
|
|
||||||
.book-form,
|
.book-form,
|
||||||
.reader-form,
|
.reader-form,
|
||||||
|
.user-form,
|
||||||
.borrow-form {
|
.borrow-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
|||||||
@@ -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.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_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_BORROWING), "librarian should manage borrowing");
|
||||||
require(policy.allows(Role.LIBRARIAN, Permission.MANAGE_READERS), "librarian should manage readers");
|
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_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.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.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.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.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_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_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_BOOKS), "reader should not manage books");
|
||||||
require(!policy.allows(Role.READER, Permission.MANAGE_READERS), "reader should not manage readers");
|
require(!policy.allows(Role.READER, Permission.MANAGE_READERS), "reader should not manage readers");
|
||||||
|
|||||||
@@ -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<SystemLogPage> 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<SystemLogPage> 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<SystemLogPage> 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<SystemLog> logs = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<SystemLog> search(SystemLogSearchCriteria criteria) {
|
||||||
|
return new ArrayList<>(logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> 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<SystemLog> search(SystemLogSearchCriteria criteria) {
|
||||||
|
throw new DaoException("Simulated system log search failure", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Long> 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<Long> 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<Long> 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<Void> 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<Void> 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<Void> 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<Void> 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<Void> 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<Long, User> users = new LinkedHashMap<>();
|
||||||
|
private long nextId = 10L;
|
||||||
|
|
||||||
|
private void put(User user) {
|
||||||
|
users.put(user.getId(), copy(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<User> 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<User> findById(long id) {
|
||||||
|
return Optional.ofNullable(users.get(id)).map(this::copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<User> 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<SystemLog> logs = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<SystemLog> search(SystemLogSearchCriteria criteria) {
|
||||||
|
return new ArrayList<>(logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> 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<User> search(UserSearchCriteria criteria) {
|
||||||
|
throw new DaoException("Simulated user search failure", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<User> findById(long id) {
|
||||||
|
throw new DaoException("Simulated user lookup failure", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<User> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user