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.setUsername("admin");
|
||||||
admin.setPasswordHash(passwordEncoder.encode("admin123"));
|
admin.setPasswordHash(passwordEncoder.encode("admin123"));
|
||||||
admin.setDisplayName("Administrator");
|
admin.setDisplayName("Administrator");
|
||||||
admin.setPasswordChangedAt(Instant.now());
|
admin.setRole("ROLE_ADMIN");
|
||||||
|
admin.setEnabled(true);
|
||||||
|
admin.setPasswordChangedAt(Instant.EPOCH);
|
||||||
userRepository.save(admin);
|
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()
|
.authorizeRequests()
|
||||||
.antMatchers("/api/auth/**").permitAll()
|
.antMatchers("/api/auth/**").permitAll()
|
||||||
.antMatchers("/ws/**").authenticated()
|
.antMatchers("/ws/**").authenticated()
|
||||||
|
.antMatchers("/api/users/**").hasRole("ADMIN")
|
||||||
.antMatchers("/api/**").authenticated()
|
.antMatchers("/api/**").authenticated()
|
||||||
.anyRequest().permitAll()
|
.anyRequest().permitAll()
|
||||||
.and()
|
.and()
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ package com.sshmanager.controller;
|
|||||||
import com.sshmanager.dto.LoginRequest;
|
import com.sshmanager.dto.LoginRequest;
|
||||||
import com.sshmanager.dto.LoginResponse;
|
import com.sshmanager.dto.LoginResponse;
|
||||||
import com.sshmanager.dto.ChangePasswordRequest;
|
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.entity.User;
|
||||||
import com.sshmanager.repository.UserRepository;
|
import com.sshmanager.repository.UserRepository;
|
||||||
import com.sshmanager.security.JwtTokenProvider;
|
import com.sshmanager.security.JwtTokenProvider;
|
||||||
@@ -31,15 +35,18 @@ public class AuthController {
|
|||||||
private final JwtTokenProvider tokenProvider;
|
private final JwtTokenProvider tokenProvider;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
public AuthController(AuthenticationManager authenticationManager,
|
public AuthController(AuthenticationManager authenticationManager,
|
||||||
JwtTokenProvider tokenProvider,
|
JwtTokenProvider tokenProvider,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
PasswordEncoder passwordEncoder) {
|
PasswordEncoder passwordEncoder,
|
||||||
|
UserService userService) {
|
||||||
this.authenticationManager = authenticationManager;
|
this.authenticationManager = authenticationManager;
|
||||||
this.tokenProvider = tokenProvider;
|
this.tokenProvider = tokenProvider;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.userService = userService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
@@ -54,6 +61,7 @@ public class AuthController {
|
|||||||
User user = userRepository.findByUsername(request.getUsername()).orElseThrow(() -> new IllegalStateException("User not found"));
|
User user = userRepository.findByUsername(request.getUsername()).orElseThrow(() -> new IllegalStateException("User not found"));
|
||||||
LoginResponse response = new LoginResponse(token, user.getUsername(),
|
LoginResponse response = new LoginResponse(token, user.getUsername(),
|
||||||
user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(),
|
user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(),
|
||||||
|
user.getRole() != null ? user.getRole() : "ROLE_USER",
|
||||||
isPasswordChangeRequired(user));
|
isPasswordChangeRequired(user));
|
||||||
|
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
@@ -64,6 +72,31 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/health")
|
@GetMapping("/health")
|
||||||
public ResponseEntity<?> health() {
|
public ResponseEntity<?> health() {
|
||||||
Map<String, Object> data = new HashMap<>();
|
Map<String, Object> data = new HashMap<>();
|
||||||
@@ -84,6 +117,7 @@ public class AuthController {
|
|||||||
Map<String, Object> data = new HashMap<>();
|
Map<String, Object> data = new HashMap<>();
|
||||||
data.put("username", user.getUsername());
|
data.put("username", user.getUsername());
|
||||||
data.put("displayName", user.getDisplayName());
|
data.put("displayName", user.getDisplayName());
|
||||||
|
data.put("role", user.getRole() != null ? user.getRole() : "ROLE_USER");
|
||||||
data.put("passwordChangeRequired", isPasswordChangeRequired(user));
|
data.put("passwordChangeRequired", isPasswordChangeRequired(user));
|
||||||
return ResponseEntity.ok(data);
|
return ResponseEntity.ok(data);
|
||||||
}
|
}
|
||||||
@@ -135,6 +169,7 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean isPasswordChangeRequired(User user) {
|
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;
|
||||||
|
}
|
||||||
@@ -11,5 +11,6 @@ public class LoginResponse {
|
|||||||
private String token;
|
private String token;
|
||||||
private String username;
|
private String username;
|
||||||
private String displayName;
|
private String displayName;
|
||||||
|
private String role;
|
||||||
private boolean passwordChangeRequired;
|
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)
|
@Column(nullable = false)
|
||||||
private Instant passwordChangedAt;
|
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(
|
return new org.springframework.security.core.userdetails.User(
|
||||||
user.getUsername(),
|
user.getUsername(),
|
||||||
user.getPasswordHash(),
|
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);
|
String username = tokenProvider.getUsernameFromToken(jwt);
|
||||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||||
|
|
||||||
|
// Reject disabled accounts
|
||||||
|
if (!userDetails.isEnabled()) {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
UsernamePasswordAuthenticationToken authentication =
|
UsernamePasswordAuthenticationToken authentication =
|
||||||
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
|
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
|
||||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
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.ChangePasswordRequest;
|
||||||
import com.sshmanager.dto.LoginRequest;
|
import com.sshmanager.dto.LoginRequest;
|
||||||
import com.sshmanager.dto.LoginResponse;
|
import com.sshmanager.dto.LoginResponse;
|
||||||
|
import com.sshmanager.dto.RegisterRequest;
|
||||||
|
import com.sshmanager.dto.UserDto;
|
||||||
import com.sshmanager.entity.User;
|
import com.sshmanager.entity.User;
|
||||||
|
import com.sshmanager.exception.InvalidOperationException;
|
||||||
import com.sshmanager.repository.UserRepository;
|
import com.sshmanager.repository.UserRepository;
|
||||||
import com.sshmanager.security.JwtTokenProvider;
|
import com.sshmanager.security.JwtTokenProvider;
|
||||||
|
import com.sshmanager.service.UserService;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -43,13 +47,16 @@ class AuthControllerTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private UserRepository userRepository;
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
private PasswordEncoder passwordEncoder;
|
private PasswordEncoder passwordEncoder;
|
||||||
private AuthController authController;
|
private AuthController authController;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
passwordEncoder = new BCryptPasswordEncoder();
|
passwordEncoder = new BCryptPasswordEncoder();
|
||||||
authController = new AuthController(authenticationManager, tokenProvider, userRepository, passwordEncoder);
|
authController = new AuthController(authenticationManager, tokenProvider, userRepository, passwordEncoder, userService);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -137,6 +144,47 @@ class AuthControllerTest {
|
|||||||
assertEquals("Current password is incorrect", body.get("message"));
|
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
|
@Test
|
||||||
void meReturnsPasswordChangeRequiredFlag() {
|
void meReturnsPasswordChangeRequiredFlag() {
|
||||||
Authentication authentication = mock(Authentication.class);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-1
@@ -2,11 +2,13 @@ import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-
|
|||||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import WorkspacePage from './pages/WorkspacePage'
|
import WorkspacePage from './pages/WorkspacePage'
|
||||||
|
import UserManagementPage from './pages/UserManagementPage'
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const { isAuthenticated, loading } = useAuth()
|
const { isAuthenticated, user, loading } = useAuth()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const isAdmin = user?.role === 'ROLE_ADMIN'
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -37,6 +39,18 @@ function AppRoutes() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/users"
|
||||||
|
element={
|
||||||
|
isAuthenticated && isAdmin ? (
|
||||||
|
<UserManagementPage onBack={() => navigate('/workspace')} />
|
||||||
|
) : isAuthenticated ? (
|
||||||
|
<Navigate to="/workspace" replace />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/login" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="/" element={<Navigate to={isAuthenticated ? '/workspace' : '/login'} replace />} />
|
<Route path="/" element={<Navigate to={isAuthenticated ? '/workspace' : '/login'} replace />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
import { changePassword as changePasswordApi, getMe, login as loginApi } from '../services/auth'
|
import { changePassword as changePasswordApi, getMe, login as loginApi, register as registerApi } from '../services/auth'
|
||||||
import type { CurrentUser, LoginResponse } from '../types'
|
import type { CurrentUser, LoginResponse, RegisterRequest } from '../types'
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
token: string | null
|
token: string | null
|
||||||
@@ -8,6 +8,7 @@ interface AuthContextValue {
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
login: (username: string, password: string) => Promise<LoginResponse>
|
login: (username: string, password: string) => Promise<LoginResponse>
|
||||||
|
register: (payload: RegisterRequest) => Promise<LoginResponse>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
refreshMe: () => Promise<void>
|
refreshMe: () => Promise<void>
|
||||||
changePassword: (currentPassword: string, newPassword: string) => Promise<void>
|
changePassword: (currentPassword: string, newPassword: string) => Promise<void>
|
||||||
@@ -52,6 +53,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setUser({
|
setUser({
|
||||||
username: response.data.username,
|
username: response.data.username,
|
||||||
displayName: response.data.displayName,
|
displayName: response.data.displayName,
|
||||||
|
role: response.data.role,
|
||||||
|
passwordChangeRequired: response.data.passwordChangeRequired,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const register = useCallback(async (payload: RegisterRequest) => {
|
||||||
|
const response = await registerApi(payload)
|
||||||
|
localStorage.setItem('token', response.data.token)
|
||||||
|
setToken(response.data.token)
|
||||||
|
setUser({
|
||||||
|
username: response.data.username,
|
||||||
|
displayName: response.data.displayName,
|
||||||
|
role: response.data.role,
|
||||||
passwordChangeRequired: response.data.passwordChangeRequired,
|
passwordChangeRequired: response.data.passwordChangeRequired,
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
@@ -69,11 +84,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
loading,
|
loading,
|
||||||
isAuthenticated: !!token,
|
isAuthenticated: !!token,
|
||||||
login,
|
login,
|
||||||
|
register,
|
||||||
logout,
|
logout,
|
||||||
refreshMe,
|
refreshMe,
|
||||||
changePassword,
|
changePassword,
|
||||||
}),
|
}),
|
||||||
[token, user, loading, login, logout, refreshMe, changePassword],
|
[token, user, loading, login, register, logout, refreshMe, changePassword],
|
||||||
)
|
)
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Lock, Terminal, User } from 'lucide-react'
|
import { Lock, Terminal, User, UserPlus } from 'lucide-react'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
export default function LoginPage({ onSuccess }: { onSuccess: () => void }) {
|
export default function LoginPage({ onSuccess }: { onSuccess: () => void }) {
|
||||||
const { login } = useAuth()
|
const { login, register } = useAuth()
|
||||||
|
const [mode, setMode] = useState<'login' | 'register'>('login')
|
||||||
|
|
||||||
|
// Login fields
|
||||||
const [username, setUsername] = useState('admin')
|
const [username, setUsername] = useState('admin')
|
||||||
const [password, setPassword] = useState('admin123')
|
const [password, setPassword] = useState('admin123')
|
||||||
|
|
||||||
|
// Register fields
|
||||||
|
const [regUsername, setRegUsername] = useState('')
|
||||||
|
const [regDisplayName, setRegDisplayName] = useState('')
|
||||||
|
const [regPassword, setRegPassword] = useState('')
|
||||||
|
const [regConfirmPassword, setRegConfirmPassword] = useState('')
|
||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
async function handleLogin(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -26,6 +36,42 @@ export default function LoginPage({ onSuccess }: { onSuccess: () => void }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRegister(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
if (regPassword.length < 8) {
|
||||||
|
setError('密码至少需要 8 个字符')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (regPassword !== regConfirmPassword) {
|
||||||
|
setError('两次输入的密码不一致')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await register({
|
||||||
|
username: regUsername.trim(),
|
||||||
|
password: regPassword,
|
||||||
|
displayName: regDisplayName.trim() || undefined,
|
||||||
|
})
|
||||||
|
onSuccess()
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
(err as { response?: { data?: { message?: string; error?: string } } }).response?.data?.message ||
|
||||||
|
'注册失败'
|
||||||
|
setError(message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchMode() {
|
||||||
|
setMode(mode === 'login' ? 'register' : 'login')
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-slate-950 p-4">
|
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-slate-950 p-4">
|
||||||
<div className="absolute left-[12%] top-[18%] h-80 w-80 rounded-full bg-cyan-500/10 blur-3xl" />
|
<div className="absolute left-[12%] top-[18%] h-80 w-80 rounded-full bg-cyan-500/10 blur-3xl" />
|
||||||
@@ -43,46 +89,136 @@ export default function LoginPage({ onSuccess }: { onSuccess: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="space-y-5" onSubmit={handleSubmit}>
|
{mode === 'login' ? (
|
||||||
<label className="block space-y-2">
|
<form className="space-y-5" onSubmit={handleLogin}>
|
||||||
<span className="text-sm text-slate-300">账号</span>
|
<label className="block space-y-2">
|
||||||
<div className="relative">
|
<span className="text-sm text-slate-300">账号</span>
|
||||||
<User className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
<div className="relative">
|
||||||
<input
|
<User className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
||||||
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-10 py-3 text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
<input
|
||||||
value={username}
|
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-10 py-3 text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
value={username}
|
||||||
placeholder="请输入管理员账号"
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
/>
|
placeholder="请输入账号"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block space-y-2">
|
||||||
|
<span className="text-sm text-slate-300">密码</span>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-10 py-3 text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error ? <div className="rounded-xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</div> : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 px-4 py-3 font-medium text-white shadow-lg shadow-cyan-900/30 transition hover:from-cyan-400 hover:to-blue-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{submitting ? '登录中...' : '登 录'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-sm text-slate-500 transition hover:text-cyan-400"
|
||||||
|
onClick={switchMode}
|
||||||
|
>
|
||||||
|
没有账号?<span className="font-medium">注册账号</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</form>
|
||||||
|
) : (
|
||||||
|
<form className="space-y-5" onSubmit={handleRegister}>
|
||||||
|
<label className="block space-y-2">
|
||||||
|
<span className="text-sm text-slate-300">用户名 *</span>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
||||||
|
<input
|
||||||
|
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-10 py-3 text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||||
|
value={regUsername}
|
||||||
|
onChange={(event) => setRegUsername(event.target.value)}
|
||||||
|
placeholder="登录使用的用户名"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label className="block space-y-2">
|
<label className="block space-y-2">
|
||||||
<span className="text-sm text-slate-300">密码</span>
|
<span className="text-sm text-slate-300">显示名(选填)</span>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
<UserPlus className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
||||||
<input
|
<input
|
||||||
type="password"
|
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-10 py-3 text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||||
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-10 py-3 text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
value={regDisplayName}
|
||||||
value={password}
|
onChange={(event) => setRegDisplayName(event.target.value)}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
placeholder="你希望别人看到的名称"
|
||||||
placeholder="请输入密码"
|
/>
|
||||||
/>
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block space-y-2">
|
||||||
|
<span className="text-sm text-slate-300">密码 *</span>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-10 py-3 text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||||
|
value={regPassword}
|
||||||
|
onChange={(event) => setRegPassword(event.target.value)}
|
||||||
|
placeholder="至少 8 个字符"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block space-y-2">
|
||||||
|
<span className="text-sm text-slate-300">确认密码 *</span>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-10 py-3 text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||||
|
value={regConfirmPassword}
|
||||||
|
onChange={(event) => setRegConfirmPassword(event.target.value)}
|
||||||
|
placeholder="再次输入密码"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error ? <div className="rounded-xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</div> : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || !regUsername.trim()}
|
||||||
|
className="w-full rounded-xl bg-gradient-to-r from-emerald-500 to-teal-600 px-4 py-3 font-medium text-white shadow-lg shadow-emerald-900/30 transition hover:from-emerald-400 hover:to-teal-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{submitting ? '注册中...' : '注 册'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-sm text-slate-500 transition hover:text-cyan-400"
|
||||||
|
onClick={switchMode}
|
||||||
|
>
|
||||||
|
已有账号?<span className="font-medium">去登录</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
{error ? <div className="rounded-xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</div> : null}
|
{mode === 'login' && (
|
||||||
|
<div className="mt-6 text-center text-sm text-slate-500">默认测试账号: admin / admin123</div>
|
||||||
<button
|
)}
|
||||||
type="submit"
|
|
||||||
disabled={submitting}
|
|
||||||
className="w-full rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 px-4 py-3 font-medium text-white shadow-lg shadow-cyan-900/30 transition hover:from-cyan-400 hover:to-blue-500 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{submitting ? '登录中...' : '登 录'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mt-6 text-center text-sm text-slate-500">默认测试账号: admin / admin123</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,462 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Shield, ShieldOff, Trash2, UserPlus, KeyRound, Pencil, ChevronLeft, AlertCircle } from 'lucide-react'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { createUser, deleteUser, listUsers, resetPassword, updateUser } from '../services/users'
|
||||||
|
import type { CreateUserRequest, UpdateUserRequest, UserDto } from '../types'
|
||||||
|
import Modal from '../components/Modal'
|
||||||
|
|
||||||
|
export default function UserManagementPage({ onBack }: { onBack: () => void }) {
|
||||||
|
const { user: currentUser } = useAuth()
|
||||||
|
const [users, setUsers] = useState<UserDto[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Modal states
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [editingUser, setEditingUser] = useState<UserDto | null>(null)
|
||||||
|
const [resettingUser, setResettingUser] = useState<UserDto | null>(null)
|
||||||
|
const [deletingUser, setDeletingUser] = useState<UserDto | null>(null)
|
||||||
|
|
||||||
|
// Form states
|
||||||
|
const [formUsername, setFormUsername] = useState('')
|
||||||
|
const [formPassword, setFormPassword] = useState('')
|
||||||
|
const [formDisplayName, setFormDisplayName] = useState('')
|
||||||
|
const [formRole, setFormRole] = useState('ROLE_USER')
|
||||||
|
const [formEnabled, setFormEnabled] = useState(true)
|
||||||
|
const [formNewPassword, setFormNewPassword] = useState('')
|
||||||
|
const [formSubmitting, setFormSubmitting] = useState(false)
|
||||||
|
const [formError, setFormError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchUsers = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const response = await listUsers()
|
||||||
|
setUsers(response.data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
(err as { response?: { data?: { message?: string } } }).response?.data?.message || '加载用户列表失败',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchUsers()
|
||||||
|
}, [fetchUsers])
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
setFormUsername('')
|
||||||
|
setFormPassword('')
|
||||||
|
setFormDisplayName('')
|
||||||
|
setFormRole('ROLE_USER')
|
||||||
|
setFormError(null)
|
||||||
|
setShowCreate(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(user: UserDto) {
|
||||||
|
setEditingUser(user)
|
||||||
|
setFormDisplayName(user.displayName || '')
|
||||||
|
setFormRole(user.role)
|
||||||
|
setFormEnabled(user.enabled)
|
||||||
|
setFormError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openResetModal(user: UserDto) {
|
||||||
|
setResettingUser(user)
|
||||||
|
setFormNewPassword('')
|
||||||
|
setFormError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
setFormSubmitting(true)
|
||||||
|
setFormError(null)
|
||||||
|
try {
|
||||||
|
const payload: CreateUserRequest = {
|
||||||
|
username: formUsername.trim(),
|
||||||
|
password: formPassword,
|
||||||
|
displayName: formDisplayName.trim() || undefined,
|
||||||
|
role: formRole,
|
||||||
|
}
|
||||||
|
await createUser(payload)
|
||||||
|
setShowCreate(false)
|
||||||
|
await fetchUsers()
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(
|
||||||
|
(err as { response?: { data?: { message?: string } } }).response?.data?.message || '创建用户失败',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setFormSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdate() {
|
||||||
|
if (!editingUser) return
|
||||||
|
setFormSubmitting(true)
|
||||||
|
setFormError(null)
|
||||||
|
try {
|
||||||
|
const payload: UpdateUserRequest = {
|
||||||
|
displayName: formDisplayName.trim() || undefined,
|
||||||
|
role: formRole,
|
||||||
|
enabled: formEnabled,
|
||||||
|
}
|
||||||
|
await updateUser(editingUser.id, payload)
|
||||||
|
setEditingUser(null)
|
||||||
|
await fetchUsers()
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(
|
||||||
|
(err as { response?: { data?: { message?: string } } }).response?.data?.message || '更新用户失败',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setFormSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deletingUser) return
|
||||||
|
setFormSubmitting(true)
|
||||||
|
setFormError(null)
|
||||||
|
try {
|
||||||
|
await deleteUser(deletingUser.id)
|
||||||
|
setDeletingUser(null)
|
||||||
|
await fetchUsers()
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(
|
||||||
|
(err as { response?: { data?: { message?: string } } }).response?.data?.message || '删除用户失败',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setFormSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResetPassword() {
|
||||||
|
if (!resettingUser) return
|
||||||
|
if (formNewPassword.length < 8) {
|
||||||
|
setFormError('密码至少需要 8 个字符')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFormSubmitting(true)
|
||||||
|
setFormError(null)
|
||||||
|
try {
|
||||||
|
await resetPassword(resettingUser.id, formNewPassword)
|
||||||
|
setResettingUser(null)
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(
|
||||||
|
(err as { response?: { data?: { message?: string } } }).response?.data?.message || '重置密码失败',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setFormSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(isoString: string) {
|
||||||
|
try {
|
||||||
|
return new Date(isoString).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col bg-slate-950 text-slate-100">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex h-14 items-center justify-between border-b border-slate-800 bg-slate-900 px-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 rounded-md px-2 py-1 text-sm text-slate-400 transition hover:text-white"
|
||||||
|
onClick={onBack}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={18} />
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
<div className="h-6 w-px bg-slate-800" />
|
||||||
|
<h1 className="text-lg font-semibold tracking-wide text-blue-400">
|
||||||
|
用户管理
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 px-4 py-2 text-sm font-medium text-white shadow-lg shadow-cyan-900/30 transition hover:from-cyan-400 hover:to-blue-500"
|
||||||
|
onClick={openCreateModal}
|
||||||
|
>
|
||||||
|
<UserPlus size={16} />
|
||||||
|
新建用户
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20 text-slate-500">加载中...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-20 text-red-400">
|
||||||
|
<AlertCircle size={18} />
|
||||||
|
{error}
|
||||||
|
<button className="ml-2 underline hover:text-red-300" onClick={() => void fetchUsers()}>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-slate-800 bg-slate-900/60">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-800 bg-slate-900/80">
|
||||||
|
<th className="px-5 py-3.5 font-medium text-slate-400">ID</th>
|
||||||
|
<th className="px-5 py-3.5 font-medium text-slate-400">用户名</th>
|
||||||
|
<th className="px-5 py-3.5 font-medium text-slate-400">显示名</th>
|
||||||
|
<th className="px-5 py-3.5 font-medium text-slate-400">角色</th>
|
||||||
|
<th className="px-5 py-3.5 font-medium text-slate-400">状态</th>
|
||||||
|
<th className="px-5 py-3.5 font-medium text-slate-400">创建时间</th>
|
||||||
|
<th className="px-5 py-3.5 font-medium text-slate-400">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-5 py-16 text-center text-slate-500">
|
||||||
|
暂无用户
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
users.map((u) => {
|
||||||
|
const isSelf = currentUser?.username === u.username
|
||||||
|
return (
|
||||||
|
<tr key={u.id} className="border-b border-slate-800/50 transition hover:bg-slate-800/40">
|
||||||
|
<td className="px-5 py-3.5 text-slate-500">{u.id}</td>
|
||||||
|
<td className="px-5 py-3.5 font-medium text-slate-200">
|
||||||
|
{u.username}
|
||||||
|
{isSelf && <span className="ml-2 rounded bg-blue-500/15 px-1.5 py-0.5 text-xs text-blue-400">当前</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5 text-slate-300">{u.displayName || '-'}</td>
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<span
|
||||||
|
className={`inline-block rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||||
|
u.role === 'ROLE_ADMIN'
|
||||||
|
? 'bg-amber-500/15 text-amber-400'
|
||||||
|
: 'bg-slate-700/50 text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{u.role === 'ROLE_ADMIN' ? '管理员' : '普通用户'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
{u.enabled ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-emerald-400">
|
||||||
|
<Shield size={14} />
|
||||||
|
启用
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1.5 text-red-400">
|
||||||
|
<ShieldOff size={14} />
|
||||||
|
禁用
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5 text-slate-500">{formatDateTime(u.createdAt)}</td>
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
className="rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-700 hover:text-white"
|
||||||
|
title="编辑"
|
||||||
|
onClick={() => openEditModal(u)}
|
||||||
|
>
|
||||||
|
<Pencil size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-700 hover:text-amber-400"
|
||||||
|
title="重置密码"
|
||||||
|
onClick={() => openResetModal(u)}
|
||||||
|
>
|
||||||
|
<KeyRound size={15} />
|
||||||
|
</button>
|
||||||
|
{!isSelf && (
|
||||||
|
<button
|
||||||
|
className="rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-700 hover:text-red-400"
|
||||||
|
title="删除"
|
||||||
|
onClick={() => {
|
||||||
|
setFormError(null)
|
||||||
|
setDeletingUser(u)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Modal */}
|
||||||
|
<Modal title="新建用户" open={showCreate} onClose={() => setShowCreate(false)} maxWidth="max-w-md">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-sm text-slate-300">用户名 *</span>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-4 py-2.5 text-sm text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||||
|
value={formUsername}
|
||||||
|
onChange={(e) => setFormUsername(e.target.value)}
|
||||||
|
placeholder="登录用户名"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-sm text-slate-300">密码 *</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-4 py-2.5 text-sm text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||||
|
value={formPassword}
|
||||||
|
onChange={(e) => setFormPassword(e.target.value)}
|
||||||
|
placeholder="至少 8 个字符"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-sm text-slate-300">显示名</span>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-4 py-2.5 text-sm text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||||
|
value={formDisplayName}
|
||||||
|
onChange={(e) => setFormDisplayName(e.target.value)}
|
||||||
|
placeholder="用户显示名称(选填)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-sm text-slate-300">角色</span>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-4 py-2.5 text-sm text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||||
|
value={formRole}
|
||||||
|
onChange={(e) => setFormRole(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="ROLE_USER">普通用户</option>
|
||||||
|
<option value="ROLE_ADMIN">管理员</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{formError && (
|
||||||
|
<div className="rounded-xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-300">{formError}</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="w-full rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 px-4 py-2.5 text-sm font-medium text-white shadow-lg shadow-cyan-900/30 transition hover:from-cyan-400 hover:to-blue-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
disabled={formSubmitting || !formUsername.trim() || formPassword.length < 8}
|
||||||
|
onClick={() => void handleCreate()}
|
||||||
|
>
|
||||||
|
{formSubmitting ? '创建中...' : '创建'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
<Modal title="编辑用户" open={!!editingUser} onClose={() => setEditingUser(null)} maxWidth="max-w-md">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-950/50 px-4 py-2.5 text-sm text-slate-400">
|
||||||
|
用户名: <span className="text-slate-200">{editingUser?.username}</span>
|
||||||
|
</div>
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-sm text-slate-300">显示名</span>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-4 py-2.5 text-sm text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||||
|
value={formDisplayName}
|
||||||
|
onChange={(e) => setFormDisplayName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-sm text-slate-300">角色</span>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-4 py-2.5 text-sm text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||||
|
value={formRole}
|
||||||
|
onChange={(e) => setFormRole(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="ROLE_USER">普通用户</option>
|
||||||
|
<option value="ROLE_ADMIN">管理员</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-slate-700 bg-slate-800 text-cyan-500 focus:ring-cyan-500/30"
|
||||||
|
checked={formEnabled}
|
||||||
|
onChange={(e) => setFormEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-300">账号已启用</span>
|
||||||
|
</label>
|
||||||
|
{formError && (
|
||||||
|
<div className="rounded-xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-300">{formError}</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="w-full rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 px-4 py-2.5 text-sm font-medium text-white shadow-lg shadow-cyan-900/30 transition hover:from-cyan-400 hover:to-blue-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
disabled={formSubmitting}
|
||||||
|
onClick={() => void handleUpdate()}
|
||||||
|
>
|
||||||
|
{formSubmitting ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Reset Password Modal */}
|
||||||
|
<Modal title="重置密码" open={!!resettingUser} onClose={() => setResettingUser(null)} maxWidth="max-w-md">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-950/50 px-4 py-2.5 text-sm text-slate-400">
|
||||||
|
用户: <span className="text-slate-200">{resettingUser?.username}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-amber-400">重置后该用户下次登录将被要求修改密码。</p>
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-sm text-slate-300">新密码 *</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="w-full rounded-xl border border-slate-800 bg-slate-950 px-4 py-2.5 text-sm text-white outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20"
|
||||||
|
value={formNewPassword}
|
||||||
|
onChange={(e) => setFormNewPassword(e.target.value)}
|
||||||
|
placeholder="至少 8 个字符"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{formError && (
|
||||||
|
<div className="rounded-xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-300">{formError}</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="w-full rounded-xl bg-gradient-to-r from-amber-500 to-orange-600 px-4 py-2.5 text-sm font-medium text-white shadow-lg shadow-amber-900/30 transition hover:from-amber-400 hover:to-orange-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
disabled={formSubmitting || formNewPassword.length < 8}
|
||||||
|
onClick={() => void handleResetPassword()}
|
||||||
|
>
|
||||||
|
{formSubmitting ? '重置中...' : '确认重置'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Delete Confirm */}
|
||||||
|
<Modal title="确认删除" open={!!deletingUser} onClose={() => setDeletingUser(null)} maxWidth="max-w-sm">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-slate-300">
|
||||||
|
确定要删除用户 <span className="font-medium text-white">{deletingUser?.username}</span> 吗?此操作不可撤销。
|
||||||
|
</p>
|
||||||
|
{formError && (
|
||||||
|
<div className="rounded-xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-300">{formError}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
className="rounded-xl border border-slate-700 px-4 py-2 text-sm text-slate-300 transition hover:bg-slate-800"
|
||||||
|
onClick={() => setDeletingUser(null)}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-xl bg-red-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-red-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
disabled={formSubmitting}
|
||||||
|
onClick={() => void handleDelete()}
|
||||||
|
>
|
||||||
|
{formSubmitting ? '删除中...' : '删除'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,8 +13,10 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
SplitSquareHorizontal,
|
SplitSquareHorizontal,
|
||||||
Terminal,
|
Terminal,
|
||||||
|
Users,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useLocalStorage } from '../hooks/useLocalStorage'
|
import { useLocalStorage } from '../hooks/useLocalStorage'
|
||||||
import {
|
import {
|
||||||
buildSessionTree,
|
buildSessionTree,
|
||||||
@@ -110,6 +112,7 @@ export default function WorkspacePage({
|
|||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
}) {
|
}) {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
const [connections, setConnections] = useState<Connection[]>([])
|
const [connections, setConnections] = useState<Connection[]>([])
|
||||||
const [layout, setLayout] = useState<WorkspaceLayout>('split')
|
const [layout, setLayout] = useState<WorkspaceLayout>('split')
|
||||||
const [treeLayout, setTreeLayout] = useState<SessionTreeLayoutPayload | null>(null)
|
const [treeLayout, setTreeLayout] = useState<SessionTreeLayoutPayload | null>(null)
|
||||||
@@ -582,6 +585,15 @@ export default function WorkspacePage({
|
|||||||
<FileUp size={16} className="text-blue-400" />
|
<FileUp size={16} className="text-blue-400" />
|
||||||
传输中心
|
传输中心
|
||||||
</button>
|
</button>
|
||||||
|
{user?.role === 'ROLE_ADMIN' && (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white"
|
||||||
|
onClick={() => navigate('/users')}
|
||||||
|
>
|
||||||
|
<Users size={16} className="text-emerald-400" />
|
||||||
|
用户管理
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import http from './http'
|
import http from './http'
|
||||||
import type { CurrentUser, LoginResponse } from '../types'
|
import type { CurrentUser, LoginResponse, RegisterRequest } from '../types'
|
||||||
|
|
||||||
export function login(username: string, password: string) {
|
export function login(username: string, password: string) {
|
||||||
return http.post<LoginResponse>('/auth/login', { username, password })
|
return http.post<LoginResponse>('/auth/login', { username, password })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function register(payload: RegisterRequest) {
|
||||||
|
return http.post<LoginResponse>('/auth/register', payload)
|
||||||
|
}
|
||||||
|
|
||||||
export function getMe() {
|
export function getMe() {
|
||||||
return http.get<CurrentUser>('/auth/me')
|
return http.get<CurrentUser>('/auth/me')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import http from './http'
|
||||||
|
import type { CreateUserRequest, UpdateUserRequest, UserDto } from '../types'
|
||||||
|
|
||||||
|
export function listUsers() {
|
||||||
|
return http.get<UserDto[]>('/users')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUser(id: number) {
|
||||||
|
return http.get<UserDto>(`/users/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUser(payload: CreateUserRequest) {
|
||||||
|
return http.post<UserDto>('/users', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUser(id: number, payload: UpdateUserRequest) {
|
||||||
|
return http.put<UserDto>(`/users/${id}`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteUser(id: number) {
|
||||||
|
return http.delete<{ message: string }>(`/users/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetPassword(id: number, newPassword: string) {
|
||||||
|
return http.post<{ message: string; passwordChangeRequired: boolean }>(`/users/${id}/reset-password`, { newPassword })
|
||||||
|
}
|
||||||
@@ -10,15 +10,47 @@ export interface LoginResponse {
|
|||||||
token: string
|
token: string
|
||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
|
role: string
|
||||||
passwordChangeRequired: boolean
|
passwordChangeRequired: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CurrentUser {
|
export interface CurrentUser {
|
||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
|
role: string
|
||||||
passwordChangeRequired?: boolean
|
passwordChangeRequired?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserDto {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
role: string
|
||||||
|
enabled: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
passwordChangedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
displayName?: string
|
||||||
|
role?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserRequest {
|
||||||
|
displayName?: string
|
||||||
|
role?: string
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
displayName?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Connection {
|
export interface Connection {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# 多用户管理功能 — 实现计划
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
当前项目只支持一个硬编码的 `admin` 用户。本计划将其升级为完整的**多用户管理体系**,支持管理员创建/编辑/删除普通用户、密码重置、用户列表展示、用户启用/禁用,以及权限分离(管理员 vs 普通用户)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实施步骤
|
||||||
|
|
||||||
|
### 步骤 1:后端 — User 实体增加字段
|
||||||
|
|
||||||
|
**文件**: `backend/src/main/java/com/sshmanager/entity/User.java`
|
||||||
|
|
||||||
|
| 新增字段 | 类型 | 说明 |
|
||||||
|
|---------|------|------|
|
||||||
|
| `role` | `String` | `"ROLE_ADMIN"` 或 `"ROLE_USER"`,默认 `"ROLE_USER"` |
|
||||||
|
| `enabled` | `boolean` | 是否启用,默认 `true` |
|
||||||
|
|
||||||
|
同时为 `role` 和 `enabled` 添加数据库列映射。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 步骤 2:后端 — 创建 User 相关 DTO
|
||||||
|
|
||||||
|
**新建文件**:
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/src/main/java/com/sshmanager/dto/UserDto.java` | 返回给前端的用户信息(不含密码) |
|
||||||
|
| `backend/src/main/java/com/sshmanager/dto/CreateUserRequest.java` | 创建用户请求体 |
|
||||||
|
| `backend/src/main/java/com/sshmanager/dto/UpdateUserRequest.java` | 更新用户请求体 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 步骤 3:后端 — 创建 UserService
|
||||||
|
|
||||||
|
**新建文件**: `backend/src/main/java/com/sshmanager/service/UserService.java`
|
||||||
|
|
||||||
|
方法:
|
||||||
|
|
||||||
|
- `listUsers()` — 列出所有用户(仅 admin)
|
||||||
|
- `getUser(id)` — 获取单个用户
|
||||||
|
- `createUser(request)` — 创建用户,校验 username 唯一性
|
||||||
|
- `updateUser(id, request)` — 更新用户 displayName / role / enabled
|
||||||
|
- `deleteUser(id)` — 删除用户(不能删除自己,不能删除最后一个 admin)
|
||||||
|
- `resetPassword(id, newPassword)` — 管理员重置指定用户密码
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 步骤 4:后端 — 创建 UserController
|
||||||
|
|
||||||
|
**新建文件**: `backend/src/main/java/com/sshmanager/controller/UserController.java`
|
||||||
|
|
||||||
|
REST API (`/api/users`):
|
||||||
|
|
||||||
|
| 方法 | 路径 | 权限 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| GET | `/api/users` | ADMIN | 用户列表 |
|
||||||
|
| GET | `/api/users/{id}` | ADMIN | 用户详情 |
|
||||||
|
| POST | `/api/users` | ADMIN | 创建用户 |
|
||||||
|
| PUT | `/api/users/{id}` | ADMIN | 更新用户 |
|
||||||
|
| DELETE | `/api/users/{id}` | ADMIN | 删除用户 |
|
||||||
|
| POST | `/api/users/{id}/reset-password` | ADMIN | 重置密码 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 步骤 5:后端 — Security / 权限改造
|
||||||
|
|
||||||
|
**文件**:
|
||||||
|
- `CustomUserDetailsService.java` — 在 `UserDetails` 中赋予 `role` 对应的 `SimpleGrantedAuthority`
|
||||||
|
- `SecurityConfig.java` — 添加 `antMatchers("/api/users/**").hasRole("ADMIN")`
|
||||||
|
- `AuthController.java` — 修改 `/auth/me` 返回 `role` 字段
|
||||||
|
- `LoginResponse.java` — 添加 `role` 字段
|
||||||
|
- `DataInitializer.java` — admin 初始角色设为 `ROLE_ADMIN`
|
||||||
|
|
||||||
|
**新建文件**:
|
||||||
|
- `backend/src/main/java/com/sshmanager/security/AdminOnlyAspect.java` (可选,或直接在 Controller 里通过 Authentication 检查)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 步骤 6:前端 — 类型和服务
|
||||||
|
|
||||||
|
**文件**:
|
||||||
|
- `frontend/src/types.ts` — `LoginResponse` 和 `CurrentUser` 增加 `role: string`
|
||||||
|
- `frontend/src/services/users.ts` — 用户 CRUD HTTP 服务
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 步骤 7:前端 — 用户管理页面
|
||||||
|
|
||||||
|
**新建文件**: `frontend/src/pages/UserManagementPage.tsx`
|
||||||
|
|
||||||
|
功能:
|
||||||
|
- 用户列表表格(用户名、显示名、角色、状态、创建时间)
|
||||||
|
- 创建用户弹窗(用户名、密码、显示名、角色选择)
|
||||||
|
- 编辑用户弹窗(修改显示名、角色、启用/禁用)
|
||||||
|
- 删除用户(确认 + 不能删除自己)
|
||||||
|
- 重置密码弹窗
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 步骤 8:前端 — 路由 & 导航接入
|
||||||
|
|
||||||
|
**文件**:
|
||||||
|
- `App.tsx` — 添加 `/users` 路由指向 `UserManagementPage`
|
||||||
|
- `WorkspacePage.tsx` — 顶部导航栏增加"用户管理"按钮,仅当当前用户 `role === "ROLE_ADMIN"` 时显示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
[UserManagementPage] --GET /api/users--> [UserController] --UserService--> [UserRepository] --> [DB]
|
||||||
|
--POST /api/users-->
|
||||||
|
--PUT /api/users/{id}-->
|
||||||
|
--DELETE /api/users/{id}-->
|
||||||
|
--POST /api/users/{id}/reset-password-->
|
||||||
|
```
|
||||||
|
|
||||||
|
每个连接(Connection)已经通过 `userId` 与用户关联,同一用户仅能看到自己的连接。多用户后此逻辑保持不变。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风险 & 注意事项
|
||||||
|
|
||||||
|
1. **不能删除最后一个 admin** — UserService 中 deleteUser 需检查
|
||||||
|
2. **不能禁用自己** — updateUser 如果自己禁用自己的 enabled=false 需拒绝
|
||||||
|
3. **密码重置后应强制改密** — 重置时设 `passwordChangedAt = Instant.EPOCH`,使 `isPasswordChangeRequired` 返回 true
|
||||||
|
4. **H2 数据库** — 重启后数据消失但 DataInitializer 会重建 admin;正式部署应考虑迁移到持久化数据库
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
|
||||||
|
1. 启动后端,`curl /api/users` 应返回 admin 用户
|
||||||
|
2. 用新用户登录验证数据隔离(自己只能看到自己的连接)
|
||||||
|
3. admin 删除最后一个 admin 用户应拒绝
|
||||||
Reference in New Issue
Block a user