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

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
@@ -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;