用户/账号管理,系统日志

This commit is contained in:
Zzzz
2026-04-27 22:56:27 +08:00
parent f80f2b807f
commit f99002e664
32 changed files with 2801 additions and 2 deletions
@@ -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>
+12
View File
@@ -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>
+14
View File
@@ -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>
+22
View File
@@ -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>
+34
View File
@@ -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);
}
}
}