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

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
operation, actor, failure category, and correlation details needed to locate
the server-side exception.
---
## Scenario: Admin User Management Audit And System Log Viewer
### 1. Scope / Trigger
- Trigger: administrator user/account management writes durable audit rows, and
`/admin/system-logs` reads those rows through a Servlet -> Service -> DAO ->
MySQL flow.
- The viewer is read-only; mutation belongs to the business service that owns
the operation being audited.
### 2. Signatures
- DB signature: `system_logs(id, operator_id, operator_role, operation_type,
target_table, target_id, result_status, message, request_ip, created_at)`.
- DAO signatures: `SystemLogDao.search(criteria)`,
`SystemLogDao.findOperationTypes()`, and
`SystemLogDao.create(connection, log)`.
- Service signatures:
`SystemLogService.searchLogs(AuthenticatedUser actor, SystemLogSearchCriteria criteria)`
and user-management methods that accept `requestIp` for audit rows.
- Routes: `GET /admin/system-logs`; user-management audit sources include
`POST /admin/users`, `POST /admin/users/update`, and
`POST /admin/users/deactivate`.
### 3. Contracts
- `/admin/system-logs` requires `VIEW_SYSTEM_LOGS`; user-management write
routes require `MANAGE_USERS`.
- Search criteria fields are `operationType`, `keyword`, `createdFrom`, and
`createdTo`; dates use `YYYY-MM-DD`.
- System-log result rows are newest-first and may include missing joined user
names. Entity display helpers must render an operator label as display name,
username, `User #<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;
import com.mzh.library.dao.UserAccountDao;
import com.mzh.library.dao.UserDao;
import com.mzh.library.entity.Role;
import com.mzh.library.entity.User;
import com.mzh.library.entity.UserSearchCriteria;
import com.mzh.library.exception.DaoException;
import com.mzh.library.util.JdbcUtil;
@@ -10,14 +12,40 @@ import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class JdbcUserDao implements UserDao {
public class JdbcUserDao implements UserDao, UserAccountDao {
private static final String USER_COLUMNS = ""
+ "id, username, password_hash, display_name, role_code, active, created_at, updated_at ";
private static final String FIND_ACTIVE_BY_USERNAME = ""
+ "SELECT id, username, password_hash, display_name, role_code, active "
+ "SELECT " + USER_COLUMNS
+ "FROM users "
+ "WHERE username = ? AND active = 1";
private static final String FIND_BY_ID = ""
+ "SELECT " + USER_COLUMNS
+ "FROM users "
+ "WHERE id = ?";
private static final String FIND_BY_USERNAME = ""
+ "SELECT " + USER_COLUMNS
+ "FROM users "
+ "WHERE username = ?";
private static final String CREATE = ""
+ "INSERT INTO users (username, password_hash, display_name, role_code, active) "
+ "VALUES (?, ?, ?, ?, ?)";
private static final String UPDATE_BASE = ""
+ "UPDATE users "
+ "SET display_name = ?, role_code = ?, active = ? ";
@Override
public Optional<User> findActiveByUsername(String username) {
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 {
User user = new User();
user.setId(resultSet.getLong("id"));
@@ -44,6 +197,12 @@ public class JdbcUserDao implements UserDao {
user.setDisplayName(resultSet.getString("display_name"));
user.setRole(Role.fromCode(resultSet.getString("role_code")));
user.setActive(resultSet.getBoolean("active"));
user.setCreatedAt(toLocalDateTime(resultSet.getTimestamp("created_at")));
user.setUpdatedAt(toLocalDateTime(resultSet.getTimestamp("updated_at")));
return user;
}
private LocalDateTime toLocalDateTime(Timestamp timestamp) {
return timestamp == null ? null : timestamp.toLocalDateTime();
}
}
@@ -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;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class User {
private static final DateTimeFormatter DISPLAY_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private long id;
private String username;
private String passwordHash;
private String displayName;
private Role role;
private boolean active;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public long getId() {
return id;
@@ -55,4 +62,40 @@ public class User {
public void setActive(boolean active) {
this.active = active;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public String getActiveStatusCode() {
return active ? "active" : "inactive";
}
public String getActiveStatusName() {
return active ? "Active" : "Inactive";
}
public String getCreatedAtText() {
return format(createdAt);
}
public String getUpdatedAtText() {
return format(updatedAt);
}
private String format(LocalDateTime value) {
return value == null ? "" : DISPLAY_FORMAT.format(value);
}
}
@@ -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 String UNAUTHORIZED_JSP = "/WEB-INF/jsp/auth/unauthorized.jsp";
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("/borrowing", Permission.MANAGE_BORROWING),
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>
<c:if test="${sessionScope.userRole == 'administrator'}">
<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 test="${sessionScope.userRole == 'administrator' or sessionScope.userRole == 'librarian'}">
<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>
<a class="button button-secondary" href="${pageContext.request.contextPath}/admin/home">Open</a>
</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 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>
<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">
<h2>Book Management</h2>
<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>
</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-name>BookCatalogServlet</servlet-name>
<servlet-class>com.mzh.library.controller.BookCatalogServlet</servlet-class>
+34
View File
@@ -198,6 +198,11 @@ h2 {
background: #8f3028;
}
.button:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.message {
margin-bottom: 16px;
padding: 10px 12px;
@@ -326,6 +331,10 @@ h2 {
grid-template-columns: repeat(3, minmax(120px, 1fr)) auto auto;
}
.system-log-search-form {
grid-template-columns: repeat(4, minmax(120px, 1fr)) auto auto;
}
.search-field {
display: grid;
gap: 6px;
@@ -343,6 +352,8 @@ h2 {
.book-form select,
.reader-form input,
.reader-form select,
.user-form input,
.user-form select,
.borrow-form input {
width: 100%;
min-height: 42px;
@@ -359,6 +370,8 @@ h2 {
.book-form select:focus,
.reader-form input:focus,
.reader-form select:focus,
.user-form input:focus,
.user-form select:focus,
.borrow-form input:focus {
outline: 3px solid rgba(37, 111, 108, 0.18);
border-color: var(--color-primary);
@@ -380,6 +393,11 @@ h2 {
min-width: 980px;
}
.user-table,
.system-log-table {
min-width: 980px;
}
.data-table th,
.data-table td {
padding: 12px 10px;
@@ -445,6 +463,21 @@ h2 {
background: #eef1f5;
}
.status-success {
color: var(--color-success);
background: #edf8ef;
}
.status-failure {
color: #7a211a;
background: #fff0ee;
}
.status-unknown {
color: var(--color-muted);
background: #eef1f5;
}
.status-overdue {
color: #7a211a;
background: #fff0ee;
@@ -472,6 +505,7 @@ h2 {
.book-form,
.reader-form,
.user-form,
.borrow-form {
display: grid;
gap: 20px;
@@ -12,14 +12,17 @@ public final class PermissionPolicyCheck {
require(policy.allows(Role.ADMINISTRATOR, Permission.MANAGE_USERS), "administrator should manage users");
require(policy.allows(Role.ADMINISTRATOR, Permission.VIEW_REPORTS), "administrator should view reports");
require(policy.allows(Role.ADMINISTRATOR, Permission.VIEW_SYSTEM_LOGS), "administrator should view system logs");
require(policy.allows(Role.LIBRARIAN, Permission.MANAGE_BORROWING), "librarian should manage borrowing");
require(policy.allows(Role.LIBRARIAN, Permission.MANAGE_READERS), "librarian should manage readers");
require(policy.allows(Role.LIBRARIAN, Permission.VIEW_REPORTS), "librarian should view reports");
require(!policy.allows(Role.LIBRARIAN, Permission.VIEW_SYSTEM_LOGS), "librarian should not view system logs");
require(!policy.allows(Role.LIBRARIAN, Permission.BORROW_BOOKS), "librarian should not borrow as a reader");
require(!policy.allows(Role.LIBRARIAN, Permission.MANAGE_USERS), "librarian should not manage users");
require(policy.allows(Role.READER, Permission.VIEW_CATALOG), "reader should view catalog");
require(policy.allows(Role.READER, Permission.BORROW_BOOKS), "reader should view borrowing capabilities");
require(!policy.allows(Role.READER, Permission.VIEW_REPORTS), "reader should not view reports");
require(!policy.allows(Role.READER, Permission.VIEW_SYSTEM_LOGS), "reader should not view system logs");
require(!policy.allows(Role.READER, Permission.MANAGE_BORROWING), "reader should not manage borrowing");
require(!policy.allows(Role.READER, Permission.MANAGE_BOOKS), "reader should not manage books");
require(!policy.allows(Role.READER, Permission.MANAGE_READERS), "reader should not manage readers");
@@ -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);
}
}
}