feat: 多用户管理与公开注册功能
- 后端:User 实体新增 role/enabled 字段、UserController CRUD、UserService - 安全:SecurityConfig /api/users/** 要求 ROLE_ADMIN、JWT 过滤器检查账号状态 - 注册:POST /api/auth/register 公开注册,固定 ROLE_USER - 保护:删除/禁用/降级最后 admin 均拒绝,DataInitializer 含 backfill - 前端:用户管理页面、登录/注册切换、admin 专属导航入口 - 测试:UserServiceTest 19 个 + UserControllerTest 6 个 + AuthControllerTest 适配
This commit is contained in:
@@ -26,8 +26,31 @@ public class DataInitializer implements CommandLineRunner {
|
||||
admin.setUsername("admin");
|
||||
admin.setPasswordHash(passwordEncoder.encode("admin123"));
|
||||
admin.setDisplayName("Administrator");
|
||||
admin.setPasswordChangedAt(Instant.now());
|
||||
admin.setRole("ROLE_ADMIN");
|
||||
admin.setEnabled(true);
|
||||
admin.setPasswordChangedAt(Instant.EPOCH);
|
||||
userRepository.save(admin);
|
||||
} else {
|
||||
// Backfill: ensure all existing admin users have ROLE_ADMIN and enabled
|
||||
for (User user : userRepository.findAll()) {
|
||||
boolean changed = false;
|
||||
if (user.getRole() == null) {
|
||||
if ("admin".equals(user.getUsername())) {
|
||||
user.setRole("ROLE_ADMIN");
|
||||
} else {
|
||||
user.setRole("ROLE_USER");
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
if (user.getEnabled() == null) {
|
||||
user.setEnabled(true);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
user.setUpdatedAt(Instant.now());
|
||||
userRepository.save(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ public class SecurityConfig {
|
||||
.authorizeRequests()
|
||||
.antMatchers("/api/auth/**").permitAll()
|
||||
.antMatchers("/ws/**").authenticated()
|
||||
.antMatchers("/api/users/**").hasRole("ADMIN")
|
||||
.antMatchers("/api/**").authenticated()
|
||||
.anyRequest().permitAll()
|
||||
.and()
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package com.sshmanager.controller;
|
||||
|
||||
package com.sshmanager.controller;
|
||||
|
||||
import com.sshmanager.dto.LoginRequest;
|
||||
import com.sshmanager.dto.LoginResponse;
|
||||
import com.sshmanager.dto.ChangePasswordRequest;
|
||||
import com.sshmanager.dto.RegisterRequest;
|
||||
import com.sshmanager.dto.UserDto;
|
||||
import com.sshmanager.exception.InvalidOperationException;
|
||||
import com.sshmanager.service.UserService;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.security.JwtTokenProvider;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
@@ -22,45 +26,74 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final AuthenticationManager authenticationManager;
|
||||
private final JwtTokenProvider tokenProvider;
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final UserService userService;
|
||||
|
||||
public AuthController(AuthenticationManager authenticationManager,
|
||||
JwtTokenProvider tokenProvider,
|
||||
UserRepository userRepository,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
PasswordEncoder passwordEncoder,
|
||||
UserService userService) {
|
||||
this.authenticationManager = authenticationManager;
|
||||
this.tokenProvider = tokenProvider;
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
|
||||
try {
|
||||
Authentication authentication = authenticationManager.authenticate(
|
||||
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));
|
||||
|
||||
try {
|
||||
Authentication authentication = authenticationManager.authenticate(
|
||||
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));
|
||||
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
String token = tokenProvider.generateToken(authentication);
|
||||
|
||||
User user = userRepository.findByUsername(request.getUsername()).orElseThrow(() -> new IllegalStateException("User not found"));
|
||||
LoginResponse response = new LoginResponse(token, user.getUsername(),
|
||||
user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(),
|
||||
user.getRole() != null ? user.getRole() : "ROLE_USER",
|
||||
isPasswordChangeRequired(user));
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (BadCredentialsException e) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("message", "Invalid username or password");
|
||||
return ResponseEntity.status(401).body(error);
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("message", "Invalid username or password");
|
||||
return ResponseEntity.status(401).body(error);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<?> register(@RequestBody RegisterRequest request) {
|
||||
try {
|
||||
UserDto created = userService.createRegularUser(
|
||||
request.getUsername(), request.getPassword(), request.getDisplayName());
|
||||
|
||||
// Auto-login: construct authentication directly (user already created & verified)
|
||||
UsernamePasswordAuthenticationToken auth =
|
||||
new UsernamePasswordAuthenticationToken(created.getUsername(), null);
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
String token = tokenProvider.generateToken(auth);
|
||||
|
||||
LoginResponse response = new LoginResponse(token, created.getUsername(),
|
||||
created.getDisplayName() != null ? created.getDisplayName() : created.getUsername(),
|
||||
created.getRole() != null ? created.getRole() : "ROLE_USER",
|
||||
false);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (InvalidOperationException e) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("message", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,15 +108,16 @@ public class AuthController {
|
||||
|
||||
@GetMapping("/me")
|
||||
public ResponseEntity<?> me(Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", "Unauthorized");
|
||||
return ResponseEntity.status(401).body(error);
|
||||
}
|
||||
User user = userRepository.findByUsername(authentication.getName()).orElseThrow(() -> new IllegalStateException("User not found"));
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", "Unauthorized");
|
||||
return ResponseEntity.status(401).body(error);
|
||||
}
|
||||
User user = userRepository.findByUsername(authentication.getName()).orElseThrow(() -> new IllegalStateException("User not found"));
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("username", user.getUsername());
|
||||
data.put("displayName", user.getDisplayName());
|
||||
data.put("role", user.getRole() != null ? user.getRole() : "ROLE_USER");
|
||||
data.put("passwordChangeRequired", isPasswordChangeRequired(user));
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
@@ -135,6 +169,7 @@ public class AuthController {
|
||||
}
|
||||
|
||||
private boolean isPasswordChangeRequired(User user) {
|
||||
return "admin".equals(user.getUsername()) && passwordEncoder.matches("admin123", user.getPasswordHash());
|
||||
return user.getPasswordChangedAt() == null
|
||||
|| !user.getPasswordChangedAt().isAfter(Instant.EPOCH);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.sshmanager.controller;
|
||||
|
||||
import com.sshmanager.dto.CreateUserRequest;
|
||||
import com.sshmanager.dto.UpdateUserRequest;
|
||||
import com.sshmanager.dto.UserDto;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.UserService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
public class UserController {
|
||||
|
||||
private final UserService userService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public UserController(UserService userService, UserRepository userRepository) {
|
||||
this.userService = userService;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<UserDto>> listUsers() {
|
||||
return ResponseEntity.ok(userService.listUsers());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(userService.getUser(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<UserDto> createUser(@RequestBody CreateUserRequest request) {
|
||||
return ResponseEntity.ok(userService.createUser(request));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<UserDto> updateUser(@PathVariable Long id,
|
||||
@RequestBody UpdateUserRequest request,
|
||||
Authentication authentication) {
|
||||
Long currentUserId = getCurrentUserId(authentication);
|
||||
return ResponseEntity.ok(userService.updateUser(id, request, currentUserId));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Map<String, String>> deleteUser(@PathVariable Long id,
|
||||
Authentication authentication) {
|
||||
Long currentUserId = getCurrentUserId(authentication);
|
||||
userService.deleteUser(id, currentUserId);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("message", "Deleted");
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/reset-password")
|
||||
public ResponseEntity<Map<String, Object>> resetPassword(@PathVariable Long id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String newPassword = body.get("newPassword");
|
||||
userService.resetPassword(id, newPassword);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("message", "Password reset successfully");
|
||||
result.put("passwordChangeRequired", true);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
private Long getCurrentUserId(Authentication auth) {
|
||||
User user = userRepository.findByUsername(auth.getName())
|
||||
.orElseThrow(() -> new IllegalStateException("User not found"));
|
||||
return user.getId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.sshmanager.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class CreateUserRequest {
|
||||
private String username;
|
||||
private String password;
|
||||
private String displayName;
|
||||
private String role;
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
package com.sshmanager.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
package com.sshmanager.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class LoginResponse {
|
||||
private String token;
|
||||
private String username;
|
||||
private String displayName;
|
||||
private String role;
|
||||
private boolean passwordChangeRequired;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.sshmanager.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class RegisterRequest {
|
||||
private String username;
|
||||
private String password;
|
||||
private String displayName;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.sshmanager.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class UpdateUserRequest {
|
||||
private String displayName;
|
||||
private String role;
|
||||
private Boolean enabled;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.sshmanager.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UserDto {
|
||||
private Long id;
|
||||
private String username;
|
||||
private String displayName;
|
||||
private String role;
|
||||
private Boolean enabled;
|
||||
private Instant createdAt;
|
||||
private Instant updatedAt;
|
||||
private Instant passwordChangedAt;
|
||||
}
|
||||
@@ -35,4 +35,9 @@ public class User {
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant passwordChangedAt;
|
||||
|
||||
@Column(length = 20)
|
||||
private String role = "ROLE_USER";
|
||||
|
||||
private Boolean enabled = true;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@ public class CustomUserDetailsService implements UserDetailsService {
|
||||
return new org.springframework.security.core.userdetails.User(
|
||||
user.getUsername(),
|
||||
user.getPasswordHash(),
|
||||
Collections.emptyList()
|
||||
user.getEnabled() != null ? user.getEnabled() : true,
|
||||
true, true, true,
|
||||
Collections.singletonList(() -> user.getRole() != null ? user.getRole() : "ROLE_USER")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
String username = tokenProvider.getUsernameFromToken(jwt);
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||
|
||||
// Reject disabled accounts
|
||||
if (!userDetails.isEnabled()) {
|
||||
SecurityContextHolder.clearContext();
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
|
||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
import com.sshmanager.dto.CreateUserRequest;
|
||||
import com.sshmanager.dto.UpdateUserRequest;
|
||||
import com.sshmanager.dto.UserDto;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.exception.InvalidOperationException;
|
||||
import com.sshmanager.exception.NotFoundException;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class UserService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
public List<UserDto> listUsers() {
|
||||
return userRepository.findAll().stream()
|
||||
.map(this::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public UserDto getUser(Long id) {
|
||||
User user = userRepository.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("User not found: " + id));
|
||||
return toDto(user);
|
||||
}
|
||||
|
||||
public UserDto createUser(CreateUserRequest request) {
|
||||
if (request.getUsername() == null || request.getUsername().trim().isEmpty()) {
|
||||
throw new InvalidOperationException("Username is required");
|
||||
}
|
||||
if (request.getPassword() == null || request.getPassword().length() < 8) {
|
||||
throw new InvalidOperationException("Password must be at least 8 characters");
|
||||
}
|
||||
if (userRepository.existsByUsername(request.getUsername().trim())) {
|
||||
throw new InvalidOperationException("Username already exists: " + request.getUsername());
|
||||
}
|
||||
|
||||
User user = new User();
|
||||
user.setUsername(request.getUsername().trim());
|
||||
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
|
||||
user.setDisplayName(request.getDisplayName() != null ? request.getDisplayName().trim() : request.getUsername().trim());
|
||||
user.setRole(request.getRole() != null ? request.getRole() : "ROLE_USER");
|
||||
if (!"ROLE_ADMIN".equals(user.getRole()) && !"ROLE_USER".equals(user.getRole())) {
|
||||
user.setRole("ROLE_USER");
|
||||
}
|
||||
user.setEnabled(true);
|
||||
user.setPasswordChangedAt(Instant.now());
|
||||
user.setCreatedAt(Instant.now());
|
||||
user.setUpdatedAt(Instant.now());
|
||||
|
||||
User saved = userRepository.save(user);
|
||||
return toDto(saved);
|
||||
}
|
||||
|
||||
public UserDto updateUser(Long id, UpdateUserRequest request, Long currentUserId) {
|
||||
User user = userRepository.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("User not found: " + id));
|
||||
|
||||
if (request.getDisplayName() != null) {
|
||||
user.setDisplayName(request.getDisplayName().trim());
|
||||
}
|
||||
if (request.getRole() != null) {
|
||||
if ("ROLE_ADMIN".equals(request.getRole()) || "ROLE_USER".equals(request.getRole())) {
|
||||
// Cannot change the last admin to a regular user
|
||||
if ("ROLE_ADMIN".equals(user.getRole()) && "ROLE_USER".equals(request.getRole()) && isLastAdmin(id)) {
|
||||
throw new InvalidOperationException("Cannot change the last admin user to a regular user");
|
||||
}
|
||||
user.setRole(request.getRole());
|
||||
}
|
||||
}
|
||||
if (request.getEnabled() != null) {
|
||||
// Cannot disable yourself
|
||||
if (user.getId().equals(currentUserId) && !request.getEnabled()) {
|
||||
throw new InvalidOperationException("You cannot disable your own account");
|
||||
}
|
||||
// Cannot disable the last admin
|
||||
if (!request.getEnabled() && "ROLE_ADMIN".equals(user.getRole()) && isLastAdmin(id)) {
|
||||
throw new InvalidOperationException("Cannot disable the last admin user");
|
||||
}
|
||||
user.setEnabled(request.getEnabled());
|
||||
}
|
||||
|
||||
user.setUpdatedAt(Instant.now());
|
||||
User saved = userRepository.save(user);
|
||||
return toDto(saved);
|
||||
}
|
||||
|
||||
public void deleteUser(Long id, Long currentUserId) {
|
||||
if (id.equals(currentUserId)) {
|
||||
throw new InvalidOperationException("You cannot delete your own account");
|
||||
}
|
||||
|
||||
User user = userRepository.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("User not found: " + id));
|
||||
|
||||
// Cannot delete the last admin
|
||||
if ("ROLE_ADMIN".equals(user.getRole()) && isLastAdmin(id)) {
|
||||
throw new InvalidOperationException("Cannot delete the last admin user");
|
||||
}
|
||||
|
||||
userRepository.delete(user);
|
||||
}
|
||||
|
||||
public void resetPassword(Long id, String newPassword) {
|
||||
if (newPassword == null || newPassword.length() < 8) {
|
||||
throw new InvalidOperationException("New password must be at least 8 characters");
|
||||
}
|
||||
|
||||
User user = userRepository.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("User not found: " + id));
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
user.setPasswordChangedAt(Instant.EPOCH);
|
||||
user.setUpdatedAt(Instant.now());
|
||||
userRepository.save(user);
|
||||
}
|
||||
|
||||
public UserDto createRegularUser(String username, String password, String displayName) {
|
||||
if (username == null || username.trim().isEmpty()) {
|
||||
throw new InvalidOperationException("Username is required");
|
||||
}
|
||||
if (password == null || password.length() < 8) {
|
||||
throw new InvalidOperationException("Password must be at least 8 characters");
|
||||
}
|
||||
if (userRepository.existsByUsername(username.trim())) {
|
||||
throw new InvalidOperationException("Username already exists: " + username);
|
||||
}
|
||||
|
||||
User user = new User();
|
||||
user.setUsername(username.trim());
|
||||
user.setPasswordHash(passwordEncoder.encode(password));
|
||||
user.setDisplayName(displayName != null && !displayName.trim().isEmpty()
|
||||
? displayName.trim() : username.trim());
|
||||
user.setRole("ROLE_USER");
|
||||
user.setEnabled(true);
|
||||
user.setPasswordChangedAt(Instant.now());
|
||||
user.setCreatedAt(Instant.now());
|
||||
user.setUpdatedAt(Instant.now());
|
||||
|
||||
User saved = userRepository.save(user);
|
||||
return toDto(saved);
|
||||
}
|
||||
|
||||
private boolean isLastAdmin(Long id) {
|
||||
User user = userRepository.findById(id).orElse(null);
|
||||
if (user == null || !"ROLE_ADMIN".equals(user.getRole())) {
|
||||
return false;
|
||||
}
|
||||
long adminCount = userRepository.findAll().stream()
|
||||
.filter(u -> "ROLE_ADMIN".equals(u.getRole()))
|
||||
.count();
|
||||
return adminCount <= 1;
|
||||
}
|
||||
|
||||
private UserDto toDto(User user) {
|
||||
return new UserDto(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getDisplayName(),
|
||||
user.getRole(),
|
||||
user.getEnabled(),
|
||||
user.getCreatedAt(),
|
||||
user.getUpdatedAt(),
|
||||
user.getPasswordChangedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,13 @@ package com.sshmanager.controller;
|
||||
import com.sshmanager.dto.ChangePasswordRequest;
|
||||
import com.sshmanager.dto.LoginRequest;
|
||||
import com.sshmanager.dto.LoginResponse;
|
||||
import com.sshmanager.dto.RegisterRequest;
|
||||
import com.sshmanager.dto.UserDto;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.exception.InvalidOperationException;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.security.JwtTokenProvider;
|
||||
import com.sshmanager.service.UserService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -43,13 +47,16 @@ class AuthControllerTest {
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Mock
|
||||
private UserService userService;
|
||||
|
||||
private PasswordEncoder passwordEncoder;
|
||||
private AuthController authController;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
passwordEncoder = new BCryptPasswordEncoder();
|
||||
authController = new AuthController(authenticationManager, tokenProvider, userRepository, passwordEncoder);
|
||||
authController = new AuthController(authenticationManager, tokenProvider, userRepository, passwordEncoder, userService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -137,6 +144,47 @@ class AuthControllerTest {
|
||||
assertEquals("Current password is incorrect", body.get("message"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerCreatesUserAndReturnsToken() {
|
||||
RegisterRequest request = new RegisterRequest();
|
||||
request.setUsername("newuser");
|
||||
request.setPassword("password123");
|
||||
request.setDisplayName("New User");
|
||||
|
||||
UserDto created = new UserDto(10L, "newuser", "New User", "ROLE_USER", true,
|
||||
Instant.now(), Instant.now(), Instant.now());
|
||||
when(userService.createRegularUser("newuser", "password123", "New User")).thenReturn(created);
|
||||
|
||||
when(tokenProvider.generateToken(any())).thenReturn("reg-token");
|
||||
|
||||
ResponseEntity<?> response = authController.register(request);
|
||||
assertEquals(200, response.getStatusCodeValue());
|
||||
LoginResponse body = (LoginResponse) response.getBody();
|
||||
assertNotNull(body);
|
||||
assertEquals("newuser", body.getUsername());
|
||||
assertEquals("New User", body.getDisplayName());
|
||||
assertEquals("ROLE_USER", body.getRole());
|
||||
assertEquals("reg-token", body.getToken());
|
||||
assertFalse(body.isPasswordChangeRequired());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerReturns400ForDuplicateUsername() {
|
||||
RegisterRequest request = new RegisterRequest();
|
||||
request.setUsername("existing");
|
||||
request.setPassword("password123");
|
||||
|
||||
when(userService.createRegularUser("existing", "password123", null))
|
||||
.thenThrow(new InvalidOperationException("Username already exists: existing"));
|
||||
|
||||
ResponseEntity<?> response = authController.register(request);
|
||||
assertEquals(400, response.getStatusCodeValue());
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> body = (Map<String, String>) response.getBody();
|
||||
assertNotNull(body);
|
||||
assertTrue(body.get("message").contains("already exists"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void meReturnsPasswordChangeRequiredFlag() {
|
||||
Authentication authentication = mock(Authentication.class);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.sshmanager.controller;
|
||||
|
||||
import com.sshmanager.dto.CreateUserRequest;
|
||||
import com.sshmanager.dto.UpdateUserRequest;
|
||||
import com.sshmanager.dto.UserDto;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.UserService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserControllerTest {
|
||||
|
||||
@Mock
|
||||
private UserService userService;
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Mock
|
||||
private Authentication authentication;
|
||||
|
||||
private UserController userController;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userController = new UserController(userService, userRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listUsersReturnsAllUsers() {
|
||||
UserDto user1 = new UserDto(1L, "admin", "Admin", "ROLE_ADMIN", true, Instant.now(), Instant.now(), Instant.now());
|
||||
UserDto user2 = new UserDto(2L, "user1", "User One", "ROLE_USER", true, Instant.now(), Instant.now(), Instant.now());
|
||||
when(userService.listUsers()).thenReturn(Arrays.asList(user1, user2));
|
||||
|
||||
ResponseEntity<List<UserDto>> response = userController.listUsers();
|
||||
|
||||
assertEquals(200, response.getStatusCodeValue());
|
||||
assertEquals(2, response.getBody().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getUserReturnsUser() {
|
||||
UserDto user = new UserDto(1L, "admin", "Admin", "ROLE_ADMIN", true, Instant.now(), Instant.now(), Instant.now());
|
||||
when(userService.getUser(1L)).thenReturn(user);
|
||||
|
||||
ResponseEntity<UserDto> response = userController.getUser(1L);
|
||||
|
||||
assertEquals(200, response.getStatusCodeValue());
|
||||
assertEquals("admin", response.getBody().getUsername());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserDelegatesToService() {
|
||||
CreateUserRequest request = new CreateUserRequest();
|
||||
request.setUsername("newuser");
|
||||
request.setPassword("password123");
|
||||
|
||||
UserDto created = new UserDto(3L, "newuser", "newuser", "ROLE_USER", true, Instant.now(), Instant.now(), Instant.now());
|
||||
when(userService.createUser(request)).thenReturn(created);
|
||||
|
||||
ResponseEntity<UserDto> response = userController.createUser(request);
|
||||
|
||||
assertEquals(200, response.getStatusCodeValue());
|
||||
assertEquals("newuser", response.getBody().getUsername());
|
||||
verify(userService).createUser(request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserReturnsUpdatedUser() {
|
||||
UpdateUserRequest request = new UpdateUserRequest();
|
||||
request.setDisplayName("Updated");
|
||||
|
||||
User admin = new User();
|
||||
admin.setId(1L);
|
||||
admin.setUsername("admin");
|
||||
admin.setRole("ROLE_ADMIN");
|
||||
when(authentication.getName()).thenReturn("admin");
|
||||
when(userRepository.findByUsername("admin")).thenReturn(Optional.of(admin));
|
||||
|
||||
UserDto updated = new UserDto(2L, "user1", "Updated", "ROLE_USER", true, Instant.now(), Instant.now(), Instant.now());
|
||||
when(userService.updateUser(2L, request, 1L)).thenReturn(updated);
|
||||
|
||||
ResponseEntity<UserDto> response = userController.updateUser(2L, request, authentication);
|
||||
|
||||
assertEquals(200, response.getStatusCodeValue());
|
||||
assertEquals("Updated", response.getBody().getDisplayName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteUserReturnsOk() {
|
||||
User admin = new User();
|
||||
admin.setId(1L);
|
||||
admin.setUsername("admin");
|
||||
admin.setRole("ROLE_ADMIN");
|
||||
when(authentication.getName()).thenReturn("admin");
|
||||
when(userRepository.findByUsername("admin")).thenReturn(Optional.of(admin));
|
||||
|
||||
doNothing().when(userService).deleteUser(2L, 1L);
|
||||
|
||||
ResponseEntity<Map<String, String>> response = userController.deleteUser(2L, authentication);
|
||||
|
||||
assertEquals(200, response.getStatusCodeValue());
|
||||
assertEquals("Deleted", response.getBody().get("message"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetPasswordReturnsSuccess() {
|
||||
Map<String, String> body = new HashMap<>();
|
||||
body.put("newPassword", "newPass123");
|
||||
doNothing().when(userService).resetPassword(1L, "newPass123");
|
||||
|
||||
ResponseEntity<Map<String, Object>> response = userController.resetPassword(1L, body);
|
||||
|
||||
assertEquals(200, response.getStatusCodeValue());
|
||||
assertEquals("Password reset successfully", response.getBody().get("message"));
|
||||
assertEquals(true, response.getBody().get("passwordChangeRequired"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
import com.sshmanager.dto.CreateUserRequest;
|
||||
import com.sshmanager.dto.UpdateUserRequest;
|
||||
import com.sshmanager.dto.UserDto;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.exception.InvalidOperationException;
|
||||
import com.sshmanager.exception.NotFoundException;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserServiceTest {
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
private PasswordEncoder passwordEncoder;
|
||||
private UserService userService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
passwordEncoder = new BCryptPasswordEncoder();
|
||||
userService = new UserService(userRepository, passwordEncoder);
|
||||
}
|
||||
|
||||
// --- listUsers ---
|
||||
|
||||
@Test
|
||||
void listUsersReturnsAllUsers() {
|
||||
User admin = createUser(1L, "admin", "ROLE_ADMIN", "Administrator", true);
|
||||
User regular = createUser(2L, "user1", "ROLE_USER", "User One", true);
|
||||
when(userRepository.findAll()).thenReturn(Arrays.asList(admin, regular));
|
||||
|
||||
List<UserDto> result = userService.listUsers();
|
||||
|
||||
assertEquals(2, result.size());
|
||||
assertEquals("admin", result.get(0).getUsername());
|
||||
assertEquals("ROLE_ADMIN", result.get(0).getRole());
|
||||
assertTrue(result.get(0).getEnabled());
|
||||
}
|
||||
|
||||
// --- getUser ---
|
||||
|
||||
@Test
|
||||
void getUserReturnsUserWhenFound() {
|
||||
User user = createUser(1L, "admin", "ROLE_ADMIN", "Admin", true);
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
|
||||
|
||||
UserDto result = userService.getUser(1L);
|
||||
|
||||
assertEquals("admin", result.getUsername());
|
||||
assertEquals("ROLE_ADMIN", result.getRole());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getUserThrowsWhenNotFound() {
|
||||
when(userRepository.findById(99L)).thenReturn(Optional.empty());
|
||||
assertThrows(NotFoundException.class, () -> userService.getUser(99L));
|
||||
}
|
||||
|
||||
// --- createUser ---
|
||||
|
||||
@Test
|
||||
void createUserSucceedsWithValidRequest() {
|
||||
CreateUserRequest request = new CreateUserRequest();
|
||||
request.setUsername("newuser");
|
||||
request.setPassword("password123");
|
||||
request.setDisplayName("New User");
|
||||
|
||||
when(userRepository.existsByUsername("newuser")).thenReturn(false);
|
||||
when(userRepository.save(any())).thenAnswer(invocation -> {
|
||||
User saved = invocation.getArgument(0);
|
||||
saved.setId(10L);
|
||||
return saved;
|
||||
});
|
||||
|
||||
UserDto result = userService.createUser(request);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("newuser", result.getUsername());
|
||||
assertEquals("ROLE_USER", result.getRole());
|
||||
assertTrue(result.getEnabled());
|
||||
|
||||
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
|
||||
verify(userRepository).save(captor.capture());
|
||||
User saved = captor.getValue();
|
||||
assertTrue(passwordEncoder.matches("password123", saved.getPasswordHash()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserDefaultsDisplayNameToUsername() {
|
||||
CreateUserRequest request = new CreateUserRequest();
|
||||
request.setUsername("minimal");
|
||||
request.setPassword("password123");
|
||||
|
||||
when(userRepository.existsByUsername("minimal")).thenReturn(false);
|
||||
when(userRepository.save(any())).thenAnswer(invocation -> {
|
||||
User saved = invocation.getArgument(0);
|
||||
saved.setId(11L);
|
||||
return saved;
|
||||
});
|
||||
|
||||
UserDto result = userService.createUser(request);
|
||||
assertEquals("minimal", result.getDisplayName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserRejectsDuplicateUsername() {
|
||||
CreateUserRequest request = new CreateUserRequest();
|
||||
request.setUsername("existing");
|
||||
request.setPassword("password123");
|
||||
|
||||
when(userRepository.existsByUsername("existing")).thenReturn(true);
|
||||
|
||||
assertThrows(InvalidOperationException.class, () -> userService.createUser(request));
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserRejectsShortPassword() {
|
||||
CreateUserRequest request = new CreateUserRequest();
|
||||
request.setUsername("user");
|
||||
request.setPassword("short");
|
||||
|
||||
assertThrows(InvalidOperationException.class, () -> userService.createUser(request));
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserRejectsEmptyUsername() {
|
||||
CreateUserRequest request = new CreateUserRequest();
|
||||
request.setUsername(" ");
|
||||
request.setPassword("password123");
|
||||
|
||||
assertThrows(InvalidOperationException.class, () -> userService.createUser(request));
|
||||
}
|
||||
|
||||
// --- updateUser ---
|
||||
|
||||
@Test
|
||||
void updateUserChangesDisplayNameAndRole() {
|
||||
User user = createUser(1L, "admin", "ROLE_ADMIN", "Old Name", true);
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
|
||||
when(userRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
UpdateUserRequest request = new UpdateUserRequest();
|
||||
request.setDisplayName("New Name");
|
||||
request.setRole("ROLE_USER");
|
||||
// We need another admin to avoid the "last admin" guard
|
||||
User otherAdmin = createUser(2L, "admin2", "ROLE_ADMIN", "Admin 2", true);
|
||||
when(userRepository.findAll()).thenReturn(Arrays.asList(user, otherAdmin));
|
||||
|
||||
UserDto result = userService.updateUser(1L, request, 2L);
|
||||
|
||||
assertEquals("New Name", result.getDisplayName());
|
||||
assertEquals("ROLE_USER", result.getRole());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserCannotDisableLastAdmin() {
|
||||
User admin = createUser(1L, "admin", "ROLE_ADMIN", "Admin", true);
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(admin));
|
||||
when(userRepository.findAll()).thenReturn(Arrays.asList(admin));
|
||||
|
||||
UpdateUserRequest request = new UpdateUserRequest();
|
||||
request.setEnabled(false);
|
||||
|
||||
assertThrows(InvalidOperationException.class, () -> userService.updateUser(1L, request, 2L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserCannotChangeLastAdminRole() {
|
||||
User admin = createUser(1L, "admin", "ROLE_ADMIN", "Admin", true);
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(admin));
|
||||
when(userRepository.findAll()).thenReturn(Arrays.asList(admin));
|
||||
|
||||
UpdateUserRequest request = new UpdateUserRequest();
|
||||
request.setRole("ROLE_USER");
|
||||
|
||||
assertThrows(InvalidOperationException.class, () -> userService.updateUser(1L, request, 2L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserCannotDisableSelf() {
|
||||
User admin = createUser(1L, "admin", "ROLE_ADMIN", "Admin", true);
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(admin));
|
||||
|
||||
UpdateUserRequest request = new UpdateUserRequest();
|
||||
request.setEnabled(false);
|
||||
|
||||
assertThrows(InvalidOperationException.class, () -> userService.updateUser(1L, request, 1L));
|
||||
}
|
||||
|
||||
// --- deleteUser ---
|
||||
|
||||
@Test
|
||||
void deleteUserSucceedsWhenNotLastAdmin() {
|
||||
User admin = createUser(1L, "admin", "ROLE_ADMIN", "Admin", true);
|
||||
User other = createUser(2L, "admin2", "ROLE_ADMIN", "Admin 2", true);
|
||||
when(userRepository.findById(2L)).thenReturn(Optional.of(other));
|
||||
when(userRepository.findAll()).thenReturn(Arrays.asList(admin, other));
|
||||
|
||||
userService.deleteUser(2L, 1L);
|
||||
|
||||
verify(userRepository).delete(other);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteUserRefusesSelfDeletion() {
|
||||
assertThrows(InvalidOperationException.class, () -> userService.deleteUser(1L, 1L));
|
||||
verify(userRepository, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteUserRefusesLastAdmin() {
|
||||
User admin = createUser(1L, "admin", "ROLE_ADMIN", "Admin", true);
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(admin));
|
||||
when(userRepository.findAll()).thenReturn(Arrays.asList(admin));
|
||||
|
||||
assertThrows(InvalidOperationException.class, () -> userService.deleteUser(1L, 2L));
|
||||
verify(userRepository, never()).delete(any());
|
||||
}
|
||||
|
||||
// --- resetPassword ---
|
||||
|
||||
@Test
|
||||
void resetPasswordUpdatesHashAndSetsEpoch() {
|
||||
User user = createUser(1L, "user1", "ROLE_USER", "User", true);
|
||||
user.setPasswordChangedAt(Instant.now());
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
|
||||
when(userRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
userService.resetPassword(1L, "newPassword123");
|
||||
|
||||
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
|
||||
verify(userRepository).save(captor.capture());
|
||||
User saved = captor.getValue();
|
||||
assertTrue(passwordEncoder.matches("newPassword123", saved.getPasswordHash()));
|
||||
assertEquals(Instant.EPOCH, saved.getPasswordChangedAt());
|
||||
}
|
||||
|
||||
// --- createRegularUser ---
|
||||
|
||||
@Test
|
||||
void createRegularUserSetsRoleUser() {
|
||||
when(userRepository.existsByUsername("newguy")).thenReturn(false);
|
||||
when(userRepository.save(any())).thenAnswer(invocation -> {
|
||||
User saved = invocation.getArgument(0);
|
||||
saved.setId(20L);
|
||||
return saved;
|
||||
});
|
||||
|
||||
UserDto result = userService.createRegularUser("newguy", "password123", "New Guy");
|
||||
|
||||
assertEquals("ROLE_USER", result.getRole());
|
||||
assertTrue(result.getEnabled());
|
||||
assertFalse(result.getPasswordChangedAt().equals(Instant.EPOCH));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRegularUserRejectsShortPassword() {
|
||||
assertThrows(InvalidOperationException.class,
|
||||
() -> userService.createRegularUser("user", "short", null));
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRegularUserRejectsDuplicateUsername() {
|
||||
when(userRepository.existsByUsername("taken")).thenReturn(true);
|
||||
|
||||
assertThrows(InvalidOperationException.class,
|
||||
() -> userService.createRegularUser("taken", "password123", null));
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private User createUser(Long id, String username, String role, String displayName, boolean enabled) {
|
||||
User user = new User();
|
||||
user.setId(id);
|
||||
user.setUsername(username);
|
||||
user.setPasswordHash(passwordEncoder.encode("password123"));
|
||||
user.setDisplayName(displayName);
|
||||
user.setRole(role);
|
||||
user.setEnabled(enabled);
|
||||
user.setCreatedAt(Instant.now());
|
||||
user.setUpdatedAt(Instant.now());
|
||||
user.setPasswordChangedAt(Instant.now());
|
||||
return user;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user